volatile是 Java 中的一个关键字,是一种同步机制,当某个变量是共享变量,且这个变量是被 volatile 修饰的,那么在修改了这个变量的值之后,再去读取该变量的值时,可以保证获取到的值是修改后的最新的值,而不是过期的值。
相比于 synchronized 和lock, volatile 是更轻量的,因为 volatile 不会发生上下文切换等开销很大的情况,不会让线程阻塞。但正是由于它的开销相对比较小,所以它的效果也就是能力相对也小一些。虽然说 volatile 是用来保证线程安全的,但是它做不到像 synchronized 那样的同步保护, volatile 仅在很有限的场景中才能发挥作用,所以下面就让我们来看一下它的适用场景。
volatile主要有两个作用:保证可见性、禁止指令重排序。
可见性
在多线程环境中,每个线程都可能有自己的工作内存,用于存储使用的变量副本。非Volatile修饰的变量可能存在于每个线程的工作内存中,而且线程间的变量值可能不同步。这样,一个线程对这个变量的修改,其他线程可能无法立即看到。volatile关键字的主要作用就是确保变量的修改对所有线程立即可见。
可见性示例
public class MyThread extends Thread {
private volatile boolean isRunning = true;
public void run() {
while (isRunning) {
// 线程A执行任务
}
}
//线程B停止任务
public void stopRunning () {
isRunning = false;
}
}
在上述示例中,线程A在执行一个任务,并通过判isRunning字段为false来停止。
如果示例中isRunning字段没有使用volatile修饰,那么A线程可能无法立即看到该字段的最新值,从而继续执行循环,导致无法正常停止。
isRunning字段volatile修饰后,当线程B修改完volatile修饰的共享变量后会立即写回主内存,这时JVM就会向CPU发送LOCK#前缀指令,此时当前处理器的缓存行就会被锁定,然后更新对应的主存地址的数据,使其他线程中的缓存数据失效,当其他线程需要用到该数据时再去主内存中重新读取,保证读取到的数据是修改后的值,保证了数据的可见性和一致性。
禁止重排序
编译器和CPU为了优化执行效率,可能会对代码执行顺序进行调整(只要不影响单线程下的语义)。但volatile修饰的变量,在其前后的操作都不能随意重排,以确保多线程环境下的有序性。
示例:在单例模式中的“双重检查锁定”(Double-Check Locking)设计模式中,volatile发挥了关键作用
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
我们通过使用synchronized对Singleton.class进行加锁,可以保证同一时间只有一个线程可以执行到同步代码块中的内容,也就是说singleton=new Singleton()这个操作只会执行一次,这就是实现了一个单例。
但是,当我们在代码中使用上述单例对象的时候有可能发生空指针异常。这是一个比较诡异的情况。
我们假设Thread1 和 Thread2两个线程同时请求Singleton.getSingleton方法的时候.
Step1:Thread1执行到第9行,开始进行对象的初始化。
Step2:Thread2执行到第6行,判断singleton ==null,
Step3:Thread2经过判断发现singleton!=nul,所以执行第13行,返回singleton。
Step4:Thread2拿到singleton对象之后,开始执行后续的操作,比如调用singleton.call0)。
以上过程,看上去并没有什么问题,但是,其实,在Step4,Thread2在调用singleton.call(的时候,是有可能抛出空指针异常的。
之所有会有NPE抛出,是因为在Step3,Thread2拿到的singleton对象并不是一个完整的对象。
们这里来分析一下,singleton =new Singleton();这行代码到底做了什么事情,大致过程如下
a、JVM为对象分配一块内存M
b、在内存M上为对象进行初始化
c、将内存M的地址赋值给singleton变量
但是,以上过程并不是一个原子操作,并且编译器可能会进行重排序,如果以上步骤被重排成:
a、JVM为对象分配一块内存M
c、将内存的地址复制给singleton变量
b、在内存M上为对象进行初始化
这样的话,Thread1会先执行内存分配,在执行变量赋值,最后执行对象的初始化,那么,也就是说,在Thread1还没有为对象进行初始化的时候,Thread2进来判断singleton==null就可能提前得到一个false,则会返回一个不元整的siqleton对象,因为他还未完成初始化操作。
这种情况一旦发生,我们拿到了一个不完整的singleton对象,当尝试使用这个对象的时候就极有可能发生NPE异常。
那么,怎么解决这个问题呢?因为指令重排导致了这个问题,那就避免指令重排就行了。
所以,volatile就派上用场了,因为volatile可以避免指令重排。只要将代码改成以下代码,就可以解决这个问题:
private volatile static singleton singleton;
注意
可见性而非原子性: volatile主要保证了多线程间的内存可见性和禁止指令重排序,但它并不能提供原子操作。这意味着对于复合操作(如递增、递减等非原子操作)来说,即使变量声明为volatile,也不能确保其在并发环境下的线程安全性。例如,对于volatile int count;,直接执行count++操作不是线程安全的。
适合简单的状态标记: volatile更适合于作为简单布尔标志来表示状态变化,比如终止标志或者初始化完成标志。当某个线程修改了这个标志,其他线程可以立即看到这一变化。
避免过度使用: 由于volatile读写具有较高的成本,应谨慎使用,只在确实需要共享且不需要同步块提供的互斥控制时才使用它。对于大多数涉及复杂数据结构和逻辑的操作,应优先考虑synchronized、Lock或原子类(如AtomicInteger、AtomicLong等)。