文章目录
Volatile关键字
volatile:adj易变的;不稳定的;用来修饰变量的。翻译里的易变是针对多线程的,多线程修改同一个变量有可能会造成某些问题,就要使用volatile
关键字。其最主要的两个作用是:线程可见性与禁止指令重排。
线程可见性(禁止寄存器优化)
我们首先来看一个程序
public class Visiable implements Runnable {
private static boolean flag = true;
// private static volatile boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(new Visiable(), "线程1").start();
Thread.sleep(2000);
flag = false;
}
@Override
public void run() {
while (flag) {
}
System.out.println("结束了");
}
}
没有加volatile的情况下:该程序会一直跑while那个死循环,停不下来。
加了volatile才会出现最后的结束了的输出。
解释这种原因就要说到JMM(Java Memory Model)Java内存模型了。
主存(Memory)中有A资源,一个线程(线程1)要访问这个数据就将它移入线程本地内存,其组成就是CPU相关的缓存Cache和寄存器。另一个线程(线程2)也读了这个数据,对于线程1修改的内容根本看不见。因为对于值的修改只是相关本地寄存器的值修改,而不是真正的主存。
对于上面的程序。线程1将从主存中的flag的值(true)保存在自己的线程本地内存中,在这个线程中没有感到主线程对于flag值的修改,因此一直在跑死循环。
所以volatile
线程可见性就是,对于volatile
修饰的变量,每个线程都去主存读取相应的值,而不用自己的线程本地内存进行寄存器优化。
禁止指令重排序
首先要讲一个概念,CPU的乱序执行。乱序执行不是说随便执行,而是最后你看起来一致性是一样的,不过执行的过程在单线程里前后的顺序调换了。
举个例子,假如要执行两条语句,一个是读取外存一个数,一个是仅仅把一个数进行自增。我们就简单认为两件事是一条语句就能执行的(实际并不是,汇编级别中赋值都要三条语句)。读取外存肯定慢一点,一个数自增肯定较快。我们知道CPU是个急性子,运行速度十分之快,为了更快完成任务,他可能会首先把第二件快的事做完,第一件还没完成呢,但两件事并无直接联系,在单线程最后结果不论顺序是怎样都是一致的。
乱序执行的优点:在单线程前后两条语句无直接联系情况下,提高了效率。
乱序执行的缺点:对于多线程,顺序是很重要的,可能因为某些提前运行造成大的错误,差之毫厘,谬之千里。
引入一个专业术语as if serial(看上去像序列化),其是本质是在单线程里面,完成一项任务前后语句执行顺序可以发生变化,但不影响最终结果,看上去语句好像还是顺序执行,只不过内在是较快的先执行完了。
一个程序证明CPU乱序执行
程序如下
public class CPUOutofOrderExecute {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
for(; ;) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
Thread thredOne = new Thread(new Runnable() {
@Override
public void run() {
a = 1; //1
x = b; //2
}
});
Thread thredTwo = new Thread(new Runnable() {
@Override
public void run() {
b = 1; //3
y = a; //4
}
});
thredOne.start();
thredTwo.start();
thredOne.join();
thredTwo.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if (x == 0 && y == 0) {
System.out.println(result);
break;
} else {
// System.out.println(result);
}
}
}
}
注释标记的语句1:a = 1; 语句2:x = b; 语句3:b = 1; 语句四:y = a;
如果不存在乱序执行,则一定是顺序执行,也就意味着1一定在2之前执行,3一定在4之前执行。其输出结果就是多种排列组合,只要保证我们的顺序执行规则1在2前,3在4前。其可以是1234,13324,1342,3124等,那么它的x和y的值就绝对不可能是0,0。如果出现了0,0,那么就证明一定出现了乱序执行。
输出结果
第413130次 (0,0)
终于在41万次等待后,我们终于遇到了这种情况,通过坚持不懈的等待,终于让我们抓到了。这就很强有力的证明了,CPU具有乱序执行的特点。
虽然说CPU运行了这么久才进行了一次乱序执行,但只要有一次,就证明它对于多线程是存在这种问题和漏洞了,就算你CPU很厉害41万次都没错,但只要我抓住一次错,就是这万分之一的概率也不能忽视!
CPU乱序执行会产生严重后果吗
答案是肯定会的,经典例子就是我们的单例模式DCL(Doucle check lock)。
这里我们首先给个分析字节码的例子。
class T {
int m = 8;
}
T t = new T();
所翻译的汇编代码
0 new #2 <T>
3 dup
4 invokespecial #3 <T.<init>>
7 astore_1
8 return
当我们执行T t = new T();
,会有这五条语句。
new #2 <T>
这句就和C语言里的malloc函数一样,去堆内存申请了一段空间,t对象多大他就多大 。在java中空对象占八个字节,对象的引用(String成员)占四个字节,还需要是8的倍数。注意这条语句执行代表刚刚new出来,m是默认值也就是0。
invokespecial #3 <T.<init>>
这句话说明调用了T的构造方法,只有调用了构造方法之后,m的值才会变成8。
astore_1
这句话是把栈空间的t和堆空间的内存建立起来连接。
整体来说new一个对象分为以下步骤,先申请内存,赋值默认值,构造方法赋值初始值,最后建立连接!
DCL单例模式代码
public class DCLSingleton {
private static volatile DCLSingleton me;
private DCLSingleton() {
}
public static DCLSingleton getInstance() {
if (me == null) { //这个if为了效率更高,加volatile就是为了这里
synchronized (DCLSingleton.class) {
if (me == null) {
me = new DCLSingleton();
}
}
}
return me;
}
}
//1.new //半初始化都是默认值
//2.invoke...<init> //调用构造方法
//3.astore //连接堆和栈
假设第一个线程1刚上来用DLC单例模式,只执行到汇编代码第一句new
,算半初始化(相关数值还没填好),这个时候发生了指令重排序,2和3交换了,首先进行连接,是半初始化对象进行连接,假设正好执行到这里这线程1停下来了。线程2来了,第一个if (me == null),此时me已经经过连接了,它是实际指向堆空间的值不为null,直接就返回了,返回了个什么?返回了个半初始化的对象,这当然不可以,显然不是线程2想要的(相关数值没填好)。
饿汉模式不会出现这样的问题,因为饿汉是ClassLoad的时候,JVM级别保证只来一次。
volatile禁止指令重排的方法:内存屏障
JVM保障被volatile修饰的变量(内存)不可以进行指令重排序,如何保障两条语句不可以乱挪动位置呢?答:**内存屏障。**不可重排序的语句中间加堵墙。
JSR(Java规范提案)内存屏障:
- LoadLoad:对于这样的语句Load1;LoadLoad;Load2,在Load2及后续的读操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕;
- StoreStore:对于这样的语句Store1;StoreStore;Store2,在Store2及后续的写操作执行前,保证Store1的写入操作对其他处理器可见;
- LoadStore:对于这样的语句Load1;LoadStore;Store2,在Store2及后续的写入操作被刷出前,保证Load1要读取的数据被读取完毕;
- StoreLoad:对于这样的语句Store1;StoreLoad;Load2,在Load2及后续的读操作要读取的数据被访问前,保证Store1的写入操作对其他处理器可见;
上面的规范不用死记硬背,理解一个就很容易了。例如LoadLoad,对于前一个有load操作,后一个还有load操作,加个屏障。
volatile实现细节(JVM层面)
对于volatile修饰的内存空间,如果有写操作前面加StoreStore屏障(上下Store不许换位置),下面加 StoreLoad(上Store下Load不许换位置)屏障。
对于volatile修饰的内存空间,如果有读操作前面加 LoadLoad屏障(上下Load不许换位置),下面加 LoadStore(上Load下Store不许换位置)屏障。
volatile的底层实现(汇编层面)
volatile底层实现汇编是一条指令lock addl
,给某个寄存器加个0,就相当于空语句,但是指令nop
不可以加lock
。
一条空语句怎么能实现volatile的两大功能呢?答案在于lock。
Lock用于在多处理器中执行指令时对共享内存的独占使用。
它的作用是能够将当前处理器对应的缓存的内容刷新到内存,并且使其他处理器对应的缓存失效。
另外还提供了有序的指令无法越过这个内存屏障的作用。
使得其他处理器缓存失效:我们知道处理器和主存之间存在多级缓存的,各级缓存速度不尽相同,当有lock指令时使得某一处理器的缓存失效,那它只能去内存去重新读取数据了,这就实现了线程可见性(禁止寄存器优化)。
有序的指令无法越过这个内存屏障:这条指令的前面指令和后面指令不可以换位置。实现了禁止指令重排序。