Volatile
volatile 的使用是为了线程安全,但 volatile 不保证线程安全。
线程安全有三个要素:可见性、有序性和原子性。
线程安全是指在多线程情况下,对共享内存的使用,不会因为不同线程的访问和修改发生与预期不符的情况。
volatile 的作用
volatile 有以下三个作用:
volatile 用于解决多核 CPU cache(高速缓存)导致的变量不同步
这本质上是个硬件问题,其根源在于:CPU 的高速缓存的读取速度远远快于主存(物理内存)。
所以,CPU 在读取一个变量的时候,会把数据先读取到缓存,这样下次再访问同一个数据的时候就可以直接从缓存读取了,显然提高了读取的性能。
而多核 CPU 有多个这样的缓存。这就带来了问题,当某个 CPU(例如 CPU1)修改了这个变量(比如把 a 的值从 1 修改为 2 ),但是其他的 CPU(例如 CPU2)在修改前已经把 a=1 读取到自己的缓存了,当 CPU2 再次读取数据的时候,它仍然会去自己的缓存区中读取,此时读取到的值仍然是 1,但是实际上这个值已经变成 2 了。
这里,就涉及了线程安全的要素:可见性。
可见性是指当多个线程在访问同一个变量时,如果其中一个线程修改了变量的值,那么其他线程应该能立即看到修改后的值。
volatile 的实现原理是内存屏障(Memory Barrier),其原理为:当 CPU 写数据时,如果发现一个变量在其他 CPU 中存有副本,那么会发出信号通知其他 CPU 将该副本对应的缓存行置为无效状态,当其他 CPU 读取到 变量副本的时候,会发现该缓存行是无效的,然后,它会从主存重新读取变量。
volatile 可以解决指令重排序的问题
一般情况下,程序是按照顺序执行的,例如下面的代码:
1 int i = 0;
2 i++;
3 boolean f = false;
4 f = true;
如果 i++
发生在 int i = 0
之前,那么会不可避免地出错,CPU 在执行代码对应指令的时候,会认为 1、2 两行是具备依赖性的,因此,CPU 一定会安排行 1 早于行 2 执行。
那么,int i = 0
一定会早于 boolean f = false
吗?
并不一定,CPU 在运行期间会对指令进行优化,没有依赖关系的指令,它们的顺序可能会被重排。
在单线程执行的情况下,发生重排是没有问题的,CPU 保证了顺序不一定一致,但结果一定一致(重排前和重排后指令执行得到的结果一致)。
但在多线程环境下,重排序则会引起很大的问题,这又涉及了线程安全的要素:有序性
有序性是指程序执行的顺序应当按照代码的先后顺序执行。
为了更好地理解有序性,下面通过一个例子来分析:
//成员变量 i
int i = 0;
//线程一的执行代码
Thread.sleep(10);
i++;
f = true;
//线程二的执行代码
while(!f){
System.out.println(i);
}
理想的结果应该是:线程二不停地打印 0,最后打印一个1,终止。
在线程一里,f 和 i 没有依赖性,如果发生了指令重排,那么 f = true 发生在 i++ 之前,就有可能导致线程二在终止循环前输出的全部是 0。
需要注意的是,这种情况并不常见,再次运行并不一定能重现,正因为如此,很可能会导致出现一些莫名的问题。
如果修改上方代码中 i 的定义为使用 volatile 关键字来修饰,那么就可以保证最后的输出结果符合预期。
这是因为,被 volatile 修饰的变量,CPU 不会对它做重排序优化,所以也就保证了有序性。
volatile 不保证操作的原子性
原子性:一个或多个操作,要么全部连续执行且不会被任何因素中断,要么就都不执行。
一眼看上去,这个概念和数据库概念里的事务(Transaction)很类似,没错,事务就是一种原子性操作。
原子性、可见性和有序性,是线程安全的三要素。
需要特别注意的是,volatile 保证可见性和有序性,但是不保证操作的原子性,下面的代码将会证明这一点:
import java.util.HashMap;
import java.util.Map;
public class TestVolatile3 {
static volatile int intVal = 0;
public static void main(String[] args) {
Map<Long, Long> map = new HashMap<>();
//创建10个线程,执行简单的自加操作
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++) {
intVal++;
if (j == 999) {
System.out.printf("id:%d 已经循环到999\n", Thread.currentThread().getId());
}
}
}).start();
}
// 保证之前启动的全部线程执行完毕
while(Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(intVal);
}
}
注意:上面代码在 Windows 命令行中运行没有问题:
E:\record\2023\3\2>javac TestVolatile3.java -encoding utf-8
E:\record\2023\3\2>java TestVolatile3
id:13 已经循环到 999
id:17 已经循环到 999
id:16 已经循环到 999
id:19 已经循环到 999
id:21 已经循环到 999
id:20 已经循环到 999
id:22 已经循环到 999
id:14 已经循环到 999
id:18 已经循环到 999
id:15 已经循环到 999
7759
可以进行测试
但在 IDEA 中运行的话会出问题:IDEA 启动程序后出了有“main”线程,还会多出一个“Monitor Ctrl-Break”的线程,所以会因 while(Thread.activeCount() > 1)
陷入死循环
在之前的内容有提及,volatile 能保证修改后的数据对所有线程可见,那么,这一段对 intVal 自增的代码,最终执行完毕的时候,intVal 应该为 10000。
但事实上,结果是不确定的,大部分情况下会小于 10000。这是因为,无论是 volatile 还是自增操作,都不具备原子性。
假设 intVal 初始值为 100,自增操作的指令执行顺序如下所示:
- 获取 intVal,此时主存内 intVal 值为 100;
- intVal 执行 +1, 得到 101,此时主存内 intVal 值仍然为 100;
- 将 101 写回给 intVal,此时主存内 intVal 值从 100 变化为 101。
具体执行流程如图:
这个过程很容易理解,如果这段指令发生在多线程环境下呢?
以下面这段会发生错误的指令顺序为例:
- 线程一获得了 intVal 值为 100;
- 线程一执行+1,得到 101,此时值没有写回给主存;
- 线程二在主存内获得了 intVal 值为 100;
- 线程二执行+1,得到 101;
- 线程一写回 101;
- 线程二写回 101;
于是,最终主存内的 intVal 值,还是 101。具体执行流程如图:
为什么 volatile 的可见性保证在这里没有生效?
根据 volatile 保证可见性的原理(内存屏障),当一个线程执行写的时候,才会改变“数据修改”的标量,在上述过程中,线程一在执行加法操作发生后,写回操作发生前,CPU 开始处理线程二的时间片,执行了另外一次读取 intVal,此时 intVal 值为 100,且由于写回操作尚未发生,这一次读取是成功的。
因此,出现了最后计算结果不符合预期的情况。
synchronized 关键字确实可以解决多线程的原子操作问题,可以修改上面的代码为:
Object lock = new Object();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
synchronized (lock) {
for (int j = 0; j < 1000; j++) {
intVal++;
}
}
}).start();
}
但是,这种方式明显效率不高,10个线程都在争抢同一个代码块的使用权。(其实可以通过 CAS 来保证原子性可以获得更高的效率)
由此可见,volatile 只能提供线程安全的两个必要条件:可见性和有序性。