之前,我对volatile的理解是很浅显的,实际操作时就出错了。今天来深入了解一下volatile这个关键字。
一、volatile的两个语义
- volatile关键字有两个语义:
- 保证可见性
- 禁止指令重排序优化
- 可见性:指的就是在多线程环境中,如果一个线程修改了某个变量的值,新值对于其他线程来说是可以立即得知的。我们知道,Java内存模型是通过在变量修改后将新值同步回主内存,在遍历读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,而volatile修饰的变量和普通变量不同的地方就是,volatile可以使新值能立即同步回主内存,以及每次使用前立即从主内存刷新,因此volatile保证了多线程操作时变量的可见性。
- 禁止指令重排序:volatile为什么能禁止指令重排序呢?我们需要知道虚拟机是怎样做的了,这时候从字节码层面上分析就没意义了,所以需要看JIT编译后的汇编代码。
没有在windows下获取过JIT编译后的汇编代码的同学可以看我记录的步骤,会看汇编代码的同学可以略过下面这一部分。
Windows没有HSDIS插件的同学需要先下载这个插件:下载链接
解压后放到%JAVA_HOME%\jre\bin\server里
接着运行以下命令就可以看到汇编代码了
java -server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*Test.getInstance Test
比如在DCL单例代码中:
public class Test {
private static Test instance;
public static Test getInstance() {
if (instance == null) {
synchronized (Test.class) {
if (instance == null) {
instance = new Test();
}
}
}
return instance;
}
public static void main(String[] args) {
getInstance();
}
}
其中,对instance赋值部分的编译后的代码如下:
0x0000000002cd455c: mov rax,0dafdd6a8h ; {oop(a 'java/lang/Class' = 'Test')}
0x0000000002cd4566: mov rsi,qword ptr [rsp+20h]
0x0000000002cd456b: mov r10,rsi
0x0000000002cd456e: mov dword ptr [rax+68h],r10d
0x0000000002cd4572: shr rax,9h
0x0000000002cd4576: mov rsi,11b0a000h
0x0000000002cd4580: mov byte ptr [rax+rsi],0h
0x0000000002cd4584: lock add dword ptr [rsp],0h ;*putstatic instance
; - Test::getInstance@24 (line 12)
有volatile修饰时,赋值后多了一个”lock addl $0x0,(%esp)”操作,这个操作可以形成一个内存屏障,有了内存屏障,在指令重排时就不能把屏障后的指令重排到前面。
lock前缀的指令的作用就是引起当前CPU的Cache写入内存,并且该动作会使其他CPU的Cache无效,这样就可以让volatile变量的修改对其他CPU立即可见。
听起来这像是在保证可见性是吧?其实是这样的,在执行”lock addl $0x0,(%esp)”这个操作时,修改已经都同步到了主内存,这就意味着在之前的操作都已经被执行完成了,所以便形成“指令重排序无法越过内存屏障”的效果。
二、volatile不能保证原子性
面试时被问到,能用volatile实现一个计数器吗?答案是不能的。解释如下:
首先我们看这个计数器的具体例子:
public class Test {
private volatile static int count = 0;
private static void inc() {
count++;
}
public static void main(String[] args) {
// 开10个线程
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
// 每个线程执行1000次自增
for (int j = 0; j < 1000; j++)
Test.inc();
}
}).start();
}
// 让前面线程执行完
while(Thread.activeCount()>1)
Thread.yield();
System.out.println(count);
}
}
我们创建了1000个线程去执行这个inc()方法,运行后我们发现,结果不是1000,这是为什么呢?我们明明用volatile来修饰count变量了,volatile可以保证可见性,在inc()方法中一个线程进行count++ 得到的值其他线程应该能立即看到才对呀,然后1000个线程分别都对这个count进行了自增,最后应该得到1000。这就是使用volatile时常见的一个误区,这段程序错在了没有保证原子性,为什么没有保证原子性呢?注意了,这是因为自增操作(count++)不是一个原子操作,也就是说count++是由多条字节码指令构成的,大概是三个子操作:读、修改、写,而这三个子操作有可能被分割开执行,也就是说,可能一个线程在读数据,这时volatile可以保证它读的正确性,但是volatile不能保证原子性,所以有可能同时有另一个线程在修改或者写操作,那么之前那个线程在读的数据就失效了,所以最后的putstatic指令可能会把错误的值同步回主内存,
那么,这段代码怎么改才能是线程安全的呢?
有以下几种方法:
- 使用synchronized
synchronized会将inc()方法变成一个同步块,当有线程在里面时,其他线程会被阻塞
private synchronized static void inc() {
count++;
}
- 使用java.util.concurrent.locks里的lock锁
通过获取锁,释放锁来确保线程安全
private static Lock lock = new ReentrantLock();
private static void inc() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
- 使用AtomicInteger
这个类可以保证用原子方式更新的 int 值
private static AtomicInteger count = new AtomicInteger();
private static void inc() {
count.getAndIncrement();
}