并发编程的三大特性:
可见性、有序性、原子性。
而 volatile 作为Java的一个轻量级关键字,它可以保证可见性和有序性。
那么它底层是怎么一回事呢? 跟着我的思路 几分钟带你搞懂。
我们先看一段代码:
public class test1 {
private static boolean initFlag = false;
public static void main(String[] args) throws InterruptedException {
//创建一个新的线程,让他进入死循环。
new Thread(() -> {
System.out.println("thread work...");
while (!initFlag) {
}
System.out.println("=====================end");
}).start();
//保证它进入死循环
Thread.sleep(2000);
//创建一个新的线程,修改死循环的判断条件 flag
new Thread(() -> prepareData()).start();
}
public static void prepareData() {
System.out.println("prepare data ...");
initFlag = true;
System.out.println("prepare date end ...");
}
}
创建一个新线程,主线程sleep一会儿,确保线程1进入死循环后,再开启一个线程2去修改 initFlag
运行一下:
我们可以看到卡在这里了,并不能打出 ====end。这是因为,他们的内存空间,不是同一份。
1、JMM内存模型
每个线程,在工作的时候,会从主内存中,弄一个副本放在自己的工作内存中。
每个线程的工作内存,其它线程无法感知。
也就是说,比如有个变量,initFlag = false; 线程1将它改为了 true,从上面的测试我们也可以发现,别的线程是无法感知的。
那我现在希望一个线程修改完之后,另一个线程感知的到,要怎么做。
我们在flag上加一个关键字 volatile 再运行
private static volatile boolean initFlag = false;
可以看到被感知了。
很多朋友应该都说的出来,是因为这个关键字保证了多线程可见性。
那volatile的可见性,是怎么实现的呢?
我们先来讲讲:
2、JMM数据原子操作
我们结合上面的代码来说。
没有加 volatile 的情况:
线程1,理解成我们代码中第一个 new 的 Thread。 它实际上流程是这样:
它要用到initFlag嘛。所以它先去主内存中获取initFlag变量。这个操作就是JMM的原子操作 read
然后它会把这个变量的副本 放到自己的工作内存中, 这个操作是 load
线程1就开始工作,这个时候是 use
操作
现在到线程2 同样的 它也是 read
、load
、use
而它不同的是,它修改了一下initFlag的值嘛,所以它有个 assign
操作,等它执行完之后,它又会有 store
和 write
两个操作。这之后,主内存中的initFlag才改为了true
而线程1的工作内存中的initFlag,是一早就load的副本,所以它一直为false,所以才导致一直在死循环,别的线程修改了也感知不到。
那么 volatile 是怎么实现可见性,也就是一个线程修改,别的线程能感知到呢?
直接跟别的线程说肯定不可能的,因为线程间是不能通讯的。
它其实是跟缓存一致性协议有关:
2.1 缓存一致性协议
对于Intel的CPU 它叫MESI
别的平台不同叫法的。意思都差不多。
前面我们说到了几个原子操作图中我们也可以看出来,cpu和内存之间,它们都是会经过总线的。
硬件级别的机制,有个总线嗅探机制。它就是一个监听机制,类似MQ那种。
当你修改了这个数据并把它写入到主内存的时候,会经过总线,别的线程在监听嘛。就会发现,这个数据本线程有用到,那么它就会把自己的工作内存中的数据失效掉。等线程中下次再用到这个数据怎么办?重新从主内存中加载这个数据。也就是再 read
load
3、volatile缓存可见性的实现原理
当有volatile关键字的变量,被修改了。
它在汇编层面,会加上一个lock前缀指令,它会立即锁定这块内存区域的缓存(缓存行锁定),并立即回写到主内存。
汇编代码是基于硬件平台的代码,也就是说 比如Intel有一套 Amd也有一套。
其它线程,通过总线嗅探机制可以感知到变化,而将自己缓存里的数据失效掉。
简单点说,volatile是让变量可以触发CPU的缓存一致性协议,让总线可以触发总线嗅探机制。当某个线程修改了volatile变量时,其它线程会监听并失效自己线程内工作内存的这个变量。等下次用到了该变量,就从主存中取。从而实现了内存可见性。
4、volatile有序性的实现原理
首先讲到有序性,就要从计算机内部的一个机制说起
4.1 指令重排序
它是指计算机在不影响单线程程序执行结果的前提下,计算机为了最大限度的发挥机器性能,它可能会对计算机指令进行重排序。比如代码是: a=100, b=200
cpu觉得 先执行 b=200 再执行 a = 100快。它就会指令重排。
说白话就是:cpu在保证单线程程序执行结果不会有错的情况下,它会为了执行快点,而修改一下执行顺序。
4.2 as-if-serial和happens-before 原则
重排序不是随便重排的,它会遵循 as-if-serial
和happens-before
两个原则。
as-if-serial
原则:
计算机系统在执行程序时,可以进行各种优化,只要最终的结果与按照程序顺序执行得到的结果一致即可。也就是说,系统可以以任意顺序重新排列指令的执行,只要保证程序的语义不变。这个原则是为了允许编译器、处理器和操作系统对代码进行性能优化,提高程序的执行效率。
happens-before
原则:
该原则用于指定在并发环境中,不同的操作之间的执行顺序。如果操作A在操作B之前执行,我们可以说操作A "happens-before" 操作B。这个原则用于建立并发编程模型中的偏序关系,以确保程序在并发情况下的正确执行。happens-before
关系是通过同步操作(如锁、原子操作和线程的启动/终止)来建立的,它们提供了一种确定不同操作之间执行顺序的方法,避免了并发访问数据时出现的竞态条件等问题。
这两个原则都是为了确保程序的正确性和可靠性,在并发编程中起着重要的作用。as-if-serial
原则允许系统进行优化,提高性能,而happens-before
原则则管理并发操作的顺序,保证程序的语义不受到破坏。
那么有没有什么实际工作中的影响呢?
在阿里巴巴规范手册里面就有一个:
4.3 双重检测锁 DCL对象半初始化问题
下面这段代码是单例模式的DCL的实现,很简单:
public class Singleton {
private static Singleton singleton;
private Singleton (){
}
public static Singleton getSingleton() {
if(singleton == null) {
synchronized(Singleton.class) {
if(singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
可能从代码层面很难看出来,我们直接看字节码文件。
为了方便,我直接贴到注释中了。
这里又得讲一个知识点:
4.4 一个对象new的全过程
它并不是原子操作的。 它的底层是怎么实现的呢?
它先加载类,它会分配一块内存空间。堆内存嘛,大家都知道。
然后初始化零值。这是什么意思呢。就是它会把成员变量都设置为零值。
这个零值不是指0。 比如:
int
类型的零值是0 long
类型是0L double
是0.0d char
:初始化为'\u0000'
boolean
是默认 false
String或者其它引用类型,都初始化为null
然后它会设置对象头,对象头可能包含:
-
Mark Word:标记字,包含对象的锁状态、GC状态、分代年龄等信息。
-
Class Pointer:指向这个对象所属的类的指针,用于确定这个对象的类型。
-
Array Length:如果是数组对象,则包含数组长度信息。
最后执行init方法,也就是真正的赋值。
回到代码中的例子:
这两行,实际上是满足指令重排的两个原则的。而这两行重排会导致什么后果?
singleton = new Singleton();
重排之后,它还没init。就返回了。简单点说,就是 它还没init完,singleton变量就已经被修改了。它就已经不为null了,因为它被赋予了堆内存地址。但是 它的字段,才刚刚初始化零值,也就是都还是 null
。执行init之后,它才有对应的属性。 这就是 半初始化问题。
那么bug是不是就出来了:
if(singleton == null)
这个if就不成立了,然后就return了。那用null去运算,是不是就会有很多问题。
那解决办法就非常简单:
对变量加一个volatile修饰符。禁止指令重排。完事
5、那么为什么volatile可以禁止指令重排呢
前面我们说到了,它在汇编层面,会加上一个lock前缀指令。它提供了内存屏障功能。使lock前后指令不能重排序。
5.1 啥是内存屏障
就是指,两行代码间,如果你不想让计算机去重排序,可以在两行代码间加上内存屏障。这个内存屏障是和计算机约定好的。
Java规范定义的内存屏障有4个:
读读、读写、写写、写读
不同CPU硬件对JVM的内存屏障规范实现指令不一样。
Intel CPU的指令,由 lfence、sfence啥的。
JDK内部,大部分实现屏障,都是通过汇编的lock指令来实现的,它简化了内存屏障。