👏作者简介:大家好,我是笙一X,java大二练习生,喜欢算法和Java相关知识。
📕正进行的系列:算法 、JUC从入门到成神、Spring
目录
volatile
可见性
我们先根据一段代码分析一下:
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while(run){
//....
}
});
t.start();
sleep(1);
run = false; //线程t不会如预想的停下来
}
main线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止
按步骤分析:
1.在初始状态时,t 线程刚开始从主内存读取到了 run 的值到工作内容。
2.因为 t 线程要频繁地从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率。
3.1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
如何解决?
volatile(易变关键字)
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
volatile static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while(run){
//....
}
});
t.start();
sleep(1);
run = false;
}
其实 synchronized 也可以保证变量的可见性(如下),但我们还是推荐使用volatile
//易变
static boolean run = true;
//锁对象
final static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while(true){
//...
synchronized(lock){
if(!run){
break;
}
}
}
});
t.start();
sleep(1);
log.debug("停止 t");
synchronized(lock){
run = false;
}
}
分析不用 synchronized的原因
前面的例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见,不能保证原子性,仅用在一个写线程,多个读线程的情况。比较一下之前线程安全时的例子:两个线程一个 i++,一个 i--,如果是volatile,只能保证看到最新值,不能解决指令交错。
synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是 synchronized 是属于重量级操作,性能相对更低
如果在前面示例中的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到 run 变量的修改。这是因为这个操作可能会导致内存的刷新,从而使得其他线程能够看到之前的修改
有序性
JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码
static int i;
static int j;
//在某个线程内执行如下赋值操作
i = ...;
j = ...;
可以看到,至于是先执行 i 还是先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时,既可以是
i = ...;
j = ...;
也可以是
j = ...;
i = ...;
这种特性称之为【指令重排】,多线程下【指令重排】会影响正确性。为什么要有重排指令这项优化呢?从 CPU 执行指令的原理来理解一下:
指令重排优化
为了充分利用计算资源、减少处理器的空闲时间、提高程序的执行效率,从而使得计算机系统能够更快速地完成任务,现代处理器会设计为有个时钟周期完成一条执行时间最长的 CPU 指令。指令还可以划分成一个个更小的阶段,例如,每条指令都可以分为:取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 这5个阶段
现代CPU支持多级指令流水线,例如支持同时执行 取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理器,就可以称之为五级指令流水线。这时CPU可以在有个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),流水线技术并不能缩短单条指令的执行时间,指令重排优化的主要目的是通过重新安排计算机指令的执行顺序,以最大程度地利用计算资源、减少数据依赖、减少处理器流水线的停顿,从而提高程序的执行速度和吞吐量。
通过指令重排序和组合来实现并行操作。
指令重排的前提是,重排指令不能影响结果,例如
//可以重排的例子
int a = 10; //指令1
int b = 20; //指令2
sout(a + b);
//不能重排的例子
int a = 10; //指令1
int b = a - 5; //指令2
我们可以明白,如果不保证指令的有序性,在多线程情况下,会存在问题:
int num = 0;
boolean ready = false;
//线程1 执行此方法
public void actor1(I_Result r){
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
//线程2 执行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}
可能的结果有几种?
情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
情况2:线程2 执行 num = 2,但没来得及执行 ready = = true,线程1 执行,还是进入 else 分支,结果为1
情况3:线程2 执行到 ready = true,线程1 执行,这会进入if 分支,结果为4(因为num已经执行过了)
还有最离谱的情况4:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为0,再切回线程2 执行num = 2,结果为0
这种现象叫做指令重排,是JIT 编辑器在运行时的一些优化,这个现象需要大量测试才能复现
解决
给 ready 加上 volatile 修饰,防止 ready 操作之前的代码被重排序
原理
volatile 的底层实现原理是内存屏障, Memory Barrier(Memory Fence)
-
对 volatile 变量的写指令后会加入写屏障
-
对 volatile 变量的读指令前会加入读屏障
保证可见性
写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
public void actor2(I_Result r) {
num = 2;
ready = true; //ready 是 volatile 赋值带写屏障
//写屏障
}
而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
public void actor1(I_Result r) {
//读屏障
//ready 是 volatile 读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
保证有序性
写屏障会确保指令重排序时,不会将写屏障之前的代码排到写屏障之后
public void actor2(I_Result r) {
num = 2;
ready = true; //ready 是 volatile 赋值带写屏障
//写屏障
}
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
public void actor1(I_Result r) {
//ready 是 volatile 读取值带读屏障
if(ready){
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
但是不能解决指令交错
-
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
-
而有序性的保证也只是保证了本线程内相关代码不被重排序
总结
本次我们主要研究了多线程操作中非常重要的 volatile 关键字,分析其作用和原理。我是笙一X,一个正在努力拼搏的人,感谢你的支持,同时期待交流和指导。