文章目录
- volatile能否保证线程安全?
- 原子性
- 可见性
- 有序性
volatile能否保证线程安全?
下文使用到了javap命令进行class文件的反汇编来查看字节码,如果想要了解的可以学习一下javap命令。
什么是javap命令
javap命令的参数
要解决这个问题首先要明白什么样是线程安全的。
线程安全要考虑三个方面:可见性、有序性、原子性
- 可见性指,一个线程对共享变量修改,另一个线程能看到最新的结果
- 有序性指,一个线程内代码按编写顺序执行
- 原子性指,一个线程内多行代码以一个整体运行,期间不能有其它线程的代码插队
volatile 能够保证共享变量的可见性与有序性,但并不能保证原子性
原子性
这里先来解释一下什么是原子性吧。
我们知道Java的代码结果编译之后会变为class文件(字节码文件),而class文件经过JVM的解释器之后就能变为最后操作底层操作系统的机器码了。
而我们可以使用javap去查看class文件对应的字节码指令。
javap是jdk自带的反解析工具。它的作用就是根据class字节码文件,反解析出当前类对应的code区(字节码指令)、局部变量表、异常表和代码行偏移量映射表、常量池等信息。
通过局部变量表,我们可以查看局部变量的作用域范围、所在槽位等信息,甚至可以
看到槽位复用等信息。
而经过解析之后可以发现,我们在Java中写的一行代码,其实对应了字节码中的好多行操作。如下
可以发现编写在Java中的一行代码对应了字节码中的好多行代码,在多线程情况下,CPU执行这些命令的时候,有可能线程分配的时间片结束了,那么这个任务就得被迫结束,然后CPU去执行其他的字节码指令。那么这个时候就有可能出现问题。
例如代码中的add方法和sub方法我使用了两个线程去执行,那么执行某一行字节码指令的时候,如果时间片结束,那么CPU执行另一个线程的操作。
此时就可能出现add方法才执行到getstatic这个字节码指令然后就被sub方法抢占,然后sub方法刚刚好执行完毕了所有的字节码指令,那么此时money的值在执行sub方法的线程中,他的值就是5,然后sub方法将数据写入到主内存的money中的时候,此时money=5,但是此时执行add方法的线程开始继续执行他的指令,他的money值还是10,然后add操作完毕之后money值等于15,那么此时就会出现数据覆盖了。
public class VolatileTest {
private static volatile int money = 10;
public VolatileTest() {
}
public static void add() {
money += 5;
}
public static void sub() {
money -= 5;
}
public static void main(String[] args) {
(new Thread(() -> {
add();
})).start();
(new Thread(() -> {
add();
})).start();
System.out.println(money);
}
}
而出现这种情况的原因就是由于,一行Java代码会被分解为多行的字节码指令,而这些字节码指令不保证原子性,也就是他们执行的过程中可以被打断,然后打断完毕之后再继续执行,那么本来的一行代码却被方块执行了。
而想要解决这种问题也很简单,就是让这个线程必须执行完毕他的所有指令的时候才能被另一个线程抢占CPU资源,那么很明显要解决这里的原子性问题,只需要使用synchronized锁即可。
可见性
具体volatile关键字是如何解决可见性问题的可以先看这篇文章
上图可见,每个CPU对共享变量的操作都是将内存中的共享变量复制一份副本到自己高速缓存中,然后对这个副本进行操作。如果没有正确的同步,即使CPU0修改了某个变量,这个已修改的值还是只存在于副本中,此时CPU1需要使用到这个变量,从内存中读取的还是修改前的值,这就是其中一种可见性问题。
可见性的问题在于访问共享变量的问题,例如下面的代码,如果说while中的flag变量成功被修改为true(事实上他确实成功被修改为了true),那么这里就应该输出最后一句话,而不是卡死在死循环中,说明,虽然另一个线程修改了flag的值,但是主线程好像没有读取到,这就是一个很典型的可见性问题。
可能你会认为其实是因为线程没有修改flag的值,所以还卡死再死循环中,那么我延迟100ms再去读取一次这个flag的值,让我们来看看到底flag是什么值。
可以发现是true。
那么为什么明明是true,但是主线程还是卡在死循环呢?
用下图来解释,我们知道JVM中是有一个JIT(即时编译器)的,他负责优化我们的热点代码,也就是执行次数非常多的代码,而我的while语句由于一开始是flag是false,所以再sleep的100ms时间内,其实我的while语句由于我的机器性能,可能已经执行了几百万次了,所以此时while就是热点代码,但是我每次我的while都要去主内存中读取flag的值,那么效率是很低的,因为CPU的速度是(小于)ns级别,而内存是几十ns,所以此时内存反倒成为了速度的瓶颈,所以JIT就试图优化代码,JIT发现CPU读取了几百万次的flag值都是false,所以他就直接认为flag就是false了,然后就把下图中的while条件判断直接设定为了stop=false(这里JIT直接替换了代码,所以直接flag读取都不读取了,直接while里面写的就是一个!false,所以while直接死循环),那么这样子就会导致while的条件永远为真,即使其他线程已经修改了flag的值,stop依旧继续为false,所以就会导致可见性问题。
JIT优化之后
而其他线程由于没有优化,所以其他线程依旧可以读取到flag被修改后的值。
而如果认为其实不是由于JIT导致的上面的原因,那么我们可以再运行代码的时候设定JVM参数来关闭JIT。再VM options处添加-Xint,表示禁用JIT。
然后继续运行一样的代码,可以发现死循环结束。
上面说过JIT只会优化热点代码,那么如果循环次数不够多,那么就可能不是热点代码了,我们可以试一试,可以发现循环次数减少后,JIT就没有优化代码了,不会继续死循环。
注意,JIT优化对代码的性能提升巨大,所以我们不可能关闭JIT。
所以我们还可以使用volatile来解决这个问题。
有序性
CPU会对指令进行优化,如果几条指令之间没有关联性,那么这几条指令可能他们就不会按照原有的顺序执行,而是经过CPU的排序后进行执行。
而volatile是如何解决指令重排序的呢?
他使用的是内存屏障的方式,也就是他会为volatile变量的读和写加上内存屏障,
例如x是再y之前声明的,而y加上了volatile关键字,那么此时x就不可能越过y的屏障,也就是对x的操作一定要先于y完成。而对于读取,那么会防止下面的读语句跑到volatile变量的读语句之前。
所以volatile解决有序性的话有一个要求:
volatile给写变量的时候要把写变量放在语句的最后,也就是最后给volatile变量赋值。
读取volatile变量的时候,要把读取语句放在第一句。也就是吧volatile变量拿去读取的时候应该最早读取。
也就是 写尾读头
因此volatile的使用要求还挺高的,如果没有理解内存屏障,那么可能用不明白。