volatile 关键字的作用

在 Java 中,volatile关键字主要有以下作用:

一、保证可见性

当一个变量被声明为volatile时,它确保了不同线程对这个变量的修改是可见的。也就是说,当一个线程修改了一个volatile变量的值,其他线程能够立即看到这个变化。

例如,在没有使用volatile关键字的情况下,线程 A 对一个普通变量进行了修改,但是线程 B 可能不会立即看到这个修改,因为每个线程可能会将变量的值缓存到自己的本地内存中。而如果这个变量被声明为volatile,那么线程 A 的修改会立即被刷新到主内存中,并且线程 B 在读取这个变量时会从主内存中获取最新的值,而不是使用自己本地内存中的缓存值。

二、禁止指令重排序

在 Java 中,为了提高性能,编译器和处理器可能会对指令进行重排序。但是,当一个变量被声明为volatile时,编译器和处理器会禁止对与这个变量相关的指令进行重排序。

例如,在一个多线程环境中,如果没有使用volatile关键字,可能会出现指令重排序导致的一些奇怪的问题。假设一个对象的初始化过程包括分配内存、初始化成员变量和将对象的引用赋值给一个变量这三个步骤。如果没有使用volatile关键字,编译器和处理器可能会对这三个步骤进行重排序,导致其他线程在对象还没有完全初始化的时候就看到了这个对象的引用,从而访问到一个未完全初始化的对象。而如果这个对象的引用被声明为volatile,那么就可以避免这种情况的发生。

三、适用场景

  1. 多线程环境下的变量同步:当多个线程需要共享一个变量,并且这个变量的值可能会被一个线程修改而被其他线程读取时,可以使用volatile关键字来保证变量的可见性。
  2. 作为状态标志:例如,在一个线程中设置一个标志来通知其他线程某个事件已经发生,这个标志可以被声明为volatile,以确保其他线程能够及时看到这个标志的变化。
  3. 单例模式的双重检查锁定:在单例模式的实现中,为了避免在多线程环境下创建多个实例,可以使用双重检查锁定的方式。在这种方式中,需要将单例对象的引用声明为volatile,以确保其他线程能够看到这个对象的正确引用。

总之,volatile关键字在多线程编程中是一个非常有用的工具,它可以帮助我们解决一些由于变量可见性和指令重排序导致的问题。但是,需要注意的是,**volatile关键字并不能保证原子性**,即多个线程同时对一个volatile变量进行操作时,仍然需要使用同步机制来保证操作的原子性。

volatile如何保证变量的可见性?

在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取

volatile 关键字其实并非是 Java 语言特有的,在 C 语言里也有,它最原始的意义就是禁用 CPU 缓存。如果我们将一个变量使用 volatile 修饰,这就指示 编译器,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

volatile 关键字能保证数据的可见性,但不能保证数据的原子性synchronized 关键字两者都能保证。

更深入了解如何保证变量可见性:

  • 从JVM层面:保证内存数据的可见性,原理还是内存屏障,但用的是读+写屏障当多个线程读共享变量时,会触发读屏障,读屏障中会记录哪些线程读了这个变量,然后当一条线程写回数据时,就会触发写屏障,此时写屏障里面,就会根据前面“读屏障”记录下来的线程,去通知所有还未刷回的线程,重新再来读取一次最新值,以此实现了内存中共享数据的可见性。

  • 从硬件层面volatile修改的高速缓存数据,写回到机器内存时,这个写回内存操作会将把其他处理器(寄存器)中,缓存了该地址的数据置为无效。多核处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据,来检查自己寄存器中的值是不是过期了,当处理器发现自己寄存器中对应内存地址的数据被修改时,就会将当前处理器的缓存行设置成无效状态,当处理器对这个无效状态的数据进行修改时,就会重新从机器内存中读取数据到CPU寄存器。

PS:上述两段话中,前者是JVM字节码指令保障的软件层面数据一致性,后者是OS原语指令保障的硬件层面数据一致性,两者相结合,从而实现了volatile关键字的可见性。

读写屏障:读写屏障是一种硬件或者软件层面的机制,用于控制内存操作的顺序。写屏障用于确保在屏障之前的所有写操作都在屏障之后的写操作之前对其他处理器可见;读屏障用于确保在屏障之后的读操作都能读到屏障之前的写操作的结果。
对于volatile变量,在写操作后会插入一个写屏障,在读操作前会插入一个读屏障。在字节码指令层面,并没有直接的 “读写屏障” 这样的指令名称,但是通过JVM在处理volatile变量时会在合适的位置插入一些指令来达到类似的效果。例如,在写volatile变量时,会插入putfieldputstatic指令,并且在其前后可能会有一些额外的指令来保证数据被刷新到主存;在读volatile变量时,会插入getfieldgetstatic指令,并且会有指令保证是从主存中读取而不是从线程本地缓存读取。

volatile如何禁止指令重排序

什么是指令重排序? 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。

常见的指令重排序有下面 2 种情况:

  • 编译器优化重排:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
  • 指令并行重排:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

另外,内存系统也会有“重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。

Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。

指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

编译器和处理器的指令重排序的处理方式不一样。对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。指令并行重排和内存系统重排都属于是处理器级别的指令重排序

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性

同时还有个误区要纠正一下:**volatile并没有直接使用OS的内存屏障指令,而是使用JVM内存屏障字节码指令,JVM的内存屏障字节码指令会间接使用OS的内存屏障指令**。这句话有点绕,简单来说就是:JVMOS原生的内存屏障指令有层封装,volatile使用的是JVM封装后的内存屏障。

许多资料讲述volatile可见性时,会直接跳过JVM这层封装,直接去聊操作系统级别的**MESI等一致性协议,其实这是有点不太妥当的,因为OS的内存屏障指令,保证了cpu寄存器、高速缓冲区、机器内存的数据一致性,这是硬件层面的数据一致性。**

JVM的内存屏障指令(字节码指令),保证了JVM线程工作内存(线程栈),和JVM程序主内存中的数据一致性,这是软件层面的数据一致性。

JVM的指令最终会依赖OS的指令,但有些资料会跳过了JVM内存屏障这层封装,直接跟你去聊了OS内存屏障,这就导致了许多人,压根不清楚JVM还有一层封装,以为volatile直接用了OS的原语指令。

双重校验锁实现对象单例模式

在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是防止 JVM 的指令重排序。 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序

面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”

双重校验锁实现对象单例(线程安全)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Singleton {

private volatile static Singleton uniqueInstance;

private Singleton() {
}

public static Singleton getUniqueInstance() {
//先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
//类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}

uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  1. uniqueInstance 分配内存空间
  2. 初始化 uniqueInstance
  3. uniqueInstance 指向分配的内存地址
1
2
3
4
5
6
7
8
// 1.分配对象内存空间
memory = allocate();

// 3.设置singleton指向刚分配的内存地址,此时singleton != null
singleton = memory;

// 2.初始化对象
singleton(memory);

由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后,程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。

所以执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化

为什么要加static?

  • 在单例模式中,目标是整个应用程序只有一个实例。如果没有static关键字,每次通过类创建对象时都会创建一个新的实例,这就违背了单例模式的初衷。使用static关键字可以确保这个实例是类级别的,所有线程共享这一个实例,而不是每个对象都有自己的一份。

image.png

volatile 可以保证原子性吗?

volatile 关键字能保证变量的可见性,但不能保证对变量的操作是原子性的。

例子:对于i++的操作,很多人会误认为自增操作 inc++ 是原子性的,实际上,inc++ 其实是一个复合操作,包括三步:

  1. 读取 i 的值。
  2. 对 i 加 1。
  3. 将 i 的值写回内存。

volatile 是无法保证这三个操作是具有原子性的,有可能导致下面这种情况出现:

  1. 线程 1 对 i 进行读取操作之后,还未对其进行修改。线程 2 又读取了 i的值并对其进行修改(+1),再将i 的值写回内存。
  2. 线程 2 操作完毕后,线程 1 对 i的值进行修改(+1),再将i 的值写回内存。

这也就导致两个线程分别对 i 进行了一次自增操作后,i 实际上只增加了 1。

其实,如果想要保证上面的代码运行正确也非常简单,利用 synchronizedLock或者AtomicInteger都可以。