volatile 关键字


volatile 是什么?可以保证有序性吗?

一旦一个共享变量(的成员变量、类的静态成员变量)被 volatile 关键字修饰之后,那么就具备了两层语义:

1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值并且该变量被 volatile,这被修改后的新值对 其他线程来说是立即可见的,volatile 关键字会强制将修改的值立即写入主存。

2)禁止进行指令重排序。

volatile 不是原子性操作。volatile具有可见性、有序性。


什么叫保证部分有序性?

当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且前面操作得到的结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

举个栗子:

x = 2;	//语句 1
y = 0;	//语句 2
flag = true;	//语句 3
x = 4;	//语句 4
y = -1;	//语句 5

由于flag 变量为 volatile 变量,那么在进行指令重排序的过程的时候,

不会将语句 3 放到语句 1、语句 2 前面,也不会将语句 3 放到语句 4、语句 5 后面。

但是要注意 语句 1 和 语句 2 的顺序、语句 4 和 语句 5 的顺序是不做任何保证的。

使用 volatile 一般用于 状态标记量 和 单例模式的双检锁。


volatile 是一种轻量级的同步机制,它能保证共享变量的可见性,同时禁止重排序保证了操作的有序性,但是它无法保证原子性。所以使用 volatile 必须要满足这两个条件:

  • 写入变量不依赖当前值:变量的新值不能依赖于之前的旧值。
    • 如果变量的当前值与新值之间存在依赖关系,那么仅使用 volatile 是不够的,因为它不能保证一系列操作的原子性。比如 i++ 就不满足该使用条件。
  • 变量不参与和其他变量的不变性条件:如果一个变量是与其他变量共同参与不变性条件的一部分,那么简单地声明变量为 volatile 是不够的。
    • “变量不参与与其他变量的不变性条件”,这里的“不变性条件”指的是一个或多个变量在程序执行过程中需要保持的条件或关系,以确保程序的正确性。假设我们有两个变量,它们需要满足某种关系(例如,a + b = 99)。我们需要在多线程环境下保证这种关闭在任何时候都是成立的。如果这个时候我们只是将其中一个变量声明为 volatile,虽然确保了这个变量的更新对其他线程立即可见,但却不能保证这两个变量作为一个整体满足特定的不变性条件。在更新这两个变量的过程中,其他线程可能会看到这些变量处于不一致的状态。在这种情况下我们就需要使用锁或者其他同步机制来保证这种关系的整体一致性。

volatile 比较适合多个线程读一个线程写的场合,典型的场景有如下几个:

  • 状态标志。
  • 重检查锁定的单例模式。
  • 开销较低的“读-写锁”策略。

volatile 使用场景

volatile 修饰修变量作为判断条件时,通常搭配 while(){}使用,而不是 if。

状态标志

当我们需要用一个变量来作为状态标志,控制线程的执行流程时,使用 volatile 可以确保当一个线程修改了这个标志时,其他线程能够立即看到最新的值。

public class TaskRunner implements Runnable {
    // 状态标记,控制任务
    private volatile boolean runningStatus = true;

    @Override
    public void run() {
        while (runningStatus) { // 检查状态标记
            // 执行任务,业务逻辑代码
            doSomething();
        }
    }

    public void stop() {
        // 修改状态标记,使得线程能够停止执行
        runningStatus = false;
    }

    /**
     * 线程中实际要执行的任务,业务代码逻辑
     */
    private void doSomething() {
        // 实际业务逻辑代码
        System.out.println(“多线程执行代码”);
    }
}

@SpringBootTest
public class MainApplicationTests {

    @Test
    void test() {
        TaskRunner taskRunner = new TaskRunner();
        Thread thread = new Thread(taskRunner);
        thread.start();
    }

}

DCL 的单例模式

在实现单例模式时,为了保证线程安全,通常使用双重检查锁定(Double-Checked Locking)模式。

在这种模式中,volatile 用于避免单例实例的初始化过程中的指令重排序,确保其他线程看到一个完全初始化的单例对象,具体来说,就是使用 volatile防止了Java 对象在实例化过程中的指令重排,确保在对象的构造函数执行完毕之前,不会将 instance 的内存分配操作指令重排到构造函数之外。

public class Singleton {
    // 使用 volatile 保证实例的可见性和有序性
    private static volatile Singleton instance;
    
    private Singleton() {
        
    }
    
    public static Singleton getInstance() {
        if (instance = null) { //第一次进入就检查,避免不必要的同步
            synchronized (Singleton.class) { // 上锁,锁定
                if (instance = null) { // 第二次检查,确保只创建一次实例
                    instance = new Singleton();
                }
                
            }
        }
        return instance;
    }
    
}

开销较低的“读-写锁”策略

这种策略一般都是允许多个线程同时读取一个资源,但只允许一个线程写入同步机制

这种“读-写锁”非常适合读多写少的场景,我们可以利用 volatile + 锁的机制减少公共代码路径的开销。如下:

public class VolatileTest {
	private volatile int values;
	
	// 读取操作  不加锁,提高效率
	public int getValue() {
		return values;
	}
	
	// 写操作  使用锁,保证线程安全
	public synchronized int increment() {
		return values++;
	}
	
}

在 J.U.C 中,有一个采用“读-写锁”方式的类:ReentrantReadWriteLock,它包含两个锁:一个是读锁,另一个是写锁。下面是伪代码:

import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class DataStructure {
    private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private final Object data = ...; // 被保护的数据

    public void read() {
        readWriteLock.readLock().lock(); // 获取读锁
        try {
            // 执行读操作
            // 例如,读取data的内容
        } finally {
            readWriteLock.readLock().unlock(); // 释放读锁
        }
    }

    public void write(Object newData) {
        readWriteLock.writeLock().lock(); // 获取写锁
        try {
            // 执行写操作
            // 例如,修改data的内容
        } finally {
            readWriteLock.writeLock().unlock(); // 释放写锁
        }
    }
}

  • 读操作 :多个线程可以同时持有读锁,因此多个线程可以同时执行 read() 方法。
  • 写操作: 只有一个线程可以持有写锁,并且在持有写锁时,其他线程不能读取或写入。

这种“读-写锁”策略提高了在多线程环境下对共享资源的读取效率,尤其是在读操作远远多于写操作的情况下。

但是,它也会让我们的程序变更更加复杂:比如潜在的读写锁冲突锁升级(从读锁升级到写锁)等问题。

因此,在实际应用中,推荐直接使用 ReentrantReadWriteLock 即可,无需头铁自己造轮子。


JAVA-技能点
知识点