volatile 关键字
volatile 是 java 提供的一种轻量级的同步机制,主要的作用就是对指令添加内存屏障(Memory barrier)确保变量的内存可见性和防止指令重排两点
- 确保变量的内存可见性
同一个成员变量在不同线程中处于各自独立的虚拟机栈帧中,A 线程修改了该变量,B 线程中读的值可能仍然是其内存缓存的值。通过 volatile 修饰,可以在读取该变量时强制刷出主内存中的值,以确保线程可以获取到最新的正确值
- 防止指令重排
不管是 cpu 还是虚拟机,都会对操作指令进行重新排序以到达最佳的执行效果,volatile 针对 JVM 指令禁止重排序,达到避免因指令重排在某些边界条件下出现的不符合预期的问题
volatile 使用场景
由上面对 volatile 的了解来看,多线程下访问相同变量的场景才会出问题,先来看个例子
csharp
public class MySingletonTest {
private static MySingletonTest mySingletonTest;
public static MySingletonTest getInstance() {
if (mySingletonTest == null) {
synchronized (MySingletonTest.class) {
if (mySingletonTest == null) {
mySingletonTest = new MySingletonTest();
}
}
}
return mySingletonTest;
}
}
这是一个典型的双重校验的懒汉式模的单例模式,但这里隐藏着一个重大风险,就是在多线程访问下,在创建之初,某个线程会获取到一个没有执行 MySingletonTest
构造方法的对象,进而可能引发一系列问题。原因是 mySingletonTest
没有加上 volatile
关键字修饰,在调用 getInstance()
时可能发生了指令重排。
先来看下 MySingletonTest 类编译后生成的字节码,通过在终端执行命令
bash
$javac MySingletonTest.java
$javap -c MySingletonTest
查看字节码的指令
java
public class com.freeman.mygradletest.MySingletonTest {
public com.freeman.mygradletest.MySingletonTest();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static com.freeman.mygradletest.MySingletonTest getInstance();
Code:
0: getstatic #2 // Field mySingletonTest:Lcom/freeman/mygradletest/MySingletonTest;
3: ifnonnull 37
6: ldc #3 // class com/freeman/mygradletest/MySingletonTest
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field mySingletonTest:Lcom/freeman/mygradletest/MySingletonTest;
14: ifnonnull 27
17: new #3 // class com/freeman/mygradletest/MySingletonTest
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field mySingletonTest:Lcom/freeman/mygradletest/MySingletonTest;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field mySingletonTest:Lcom/freeman/mygradletest/MySingletonTest;
40: areturn
Exception table:
from to target type
11 29 32 any
32 35 32 any
}
重点看 mySingletonTest = new MySingletonTest();
的指令
java
17: new #3 // class com/freeman/mygradletest/MySingletonTest
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field mySingletonTest:Lcom/freeman/mygradletest/MySingletonTest;
可以发现,创建一个对象并赋值给引用经历了四个指令(参考对象创建指令),我们逐一分析
csharp
1. new 分配内存,创建 MySingletonTest 实例但还未执行构造方法,并把地址引用 ref 压入操作数栈顶
2. dup 复制操作数栈顶元素,此时栈顶有两个一样的地址引用, ref ref
3. invokespecial 执行 MySingletonTest 的构造方法,用掉栈顶一个引用 ref,栈顶元素此时为 ref
4. putstatic 将栈顶地址引用 ref 赋值给静态变量 mySingletonTest
由于指令3和指令4之间没有严格依赖关系,虚拟机指令重排可能先执行指令4再执行指令3,此时 mySingletonTest
被赋值了引用,指向了堆内存中 MySingletonTest
对象,但是 MySingletonTest
却还没有执行构造方法
这样就在多线程场景中存在线程A先执行到 mySingletonTest = new MySingletonTest();
,接着线程B调用 getInstance()
判断 mySingletonTest == null
为 false
后获取到了 mySingletonTest
,但实际上 mySingletonTest
并没有被调用构造方法进行初始化,在后续的业务中调用 MySingletonTest
的其他成员方法就有可能出现报错等预期外的结果
解决方法就是对 mySingletonTest 变量加入 volatile 关键字进行修饰,禁止对其创建时进行指令重排,这样其他线程在等待锁释放时就可以获取到一个完全合法的对象地址
arduino
private static volatile MySingletonTest mySingletonTest;
上面介绍了 volatile
在禁止指令重排上的使用场景,接下来再看看确保变量的内存可见性的场景,在MySingletonTest
上添加一个两个线程访问一个变量的场景
vbnet
private boolean stop = false;
public void test1() {
new Thread(() -> {
LogUtil.info("MySingletonTest", "Thread1 start");
while (!stop) { // 等待 Thread2 刷新 stop 为 true 后停止线程
}
LogUtil.info("MySingletonTest", "Thread1 finish");
}, "Thread1").start();
new Thread(() -> {
LogUtil.info("MySingletonTest", "Thread2 start");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
stop = true; // Thread2 sleep 3秒后,刷新 stop 值为 true,期望 Thread1 可以停止
LogUtil.info("MySingletonTest", "Thread2 finish and stop Thread 1");
}, "Thread2").start();
}
正常情况下,期望结果打印如下
yaml
2025-03-10 00:13:56.999 MySingletonTest: Thread1 start
2025-03-10 00:13:56.999 MySingletonTest: Thread2 start
2025-03-10 00:13:59.999 MySingletonTest: Thread2 finish and stop Thread 1
2025-03-10 00:14:00.000 MySingletonTest: Thread1 finish
但在某些情况下,可能会出现 Thread1
过好久才停止甚至永远不停止的情况,原因就是线程运行时使用的寄存器上的线程缓存,stop
在 Thread1
一直为 false
,没有刷新到主内存中 stop
已经变为 true
解决方法就是对 stop 变量加入 volatile 关键字进行修饰。原因是 volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值
arduino
private volatile boolean stop = false;
volatile 不适合的场景
volatile
只能保证内存可见性,确保每个线程读到的值都是最新,但却无法保证原子性。以一个 int
类型执行 ++
为例
csharp
private volatile int count = 0;
public void test2() {
new Thread(() -> {
LogUtil.info("MySingletonTest", "Thread3 start");
for (int i = 0; i < 100000; i++) {
count++;
}
LogUtil.info("MySingletonTest", "Thread3 finish");
}, "Thread3").start();
new Thread(() -> {
LogUtil.info("MySingletonTest", "Thread4 start");
for (int i = 0; i < 100000; i++) {
count++;
}
LogUtil.info("MySingletonTest", "Thread4 finish");
}, "Thread4").start();
new Thread(() -> {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
LogUtil.info("MySingletonTest", "count = " + count);
}, "Thread5").start();
}
两个线程分别对count执行100000的自增,期望结果是200000,事实上打印的结果是
ini
2025-03-10 00:44:45.040 MySingletonTest: count = 197136
这是因为自增操作不是原子性的,从字节码指令上看
yaml
2: getfield #3 // Field count:I
5: iconst_1
6: iadd
7: putfield #3 // Field count:I
一次自增有三个步骤,1.取出 count 值; 2.自增; 3.赋值给count。这就导致了在多线程访问时,拿到的值是自增前的值,然后又被覆盖回去。
上面例子确保原子性可以用 AtomicInteger 或者加锁
和 synchronized 的区别
synchronized 同一个锁可以保证内存可见性和原子性;volatile 只能保证内存可见性