volatile

volatile

1. 线程之间可见性

1.1 问题引出

Volatile到底是用来干嘛的,来看一段程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class Volatile问题引出 {

private static /*volatile*/ boolean flag = false;

public static void main(String[] args) {

A a = new A();
a.start();

try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}

flag = true;

}

static class A extends Thread{

@Override
public void run() {
while (true) {
if (flag){
System.out.println("知道主线程将flag变为true了");
break;
}
}
}
}
}

两个线程共同维护一个变量flag,一个睡1s后修改flag为true,另一个等待它变为true后输出一句话

运行就会发现永远不能输出,因为一个线程感受不到另一个线程对它的改变,这是为什么呢?

因为JMM中有规定,线程不能直接操作主存中的值,只能拷贝到自己的工作内存再进行操作

也就是说线程改变了自己工作内存中的flag的值,并没有影响到另外一个线程的工作内存。明白了这点后,再把上面的代码volatile注释放开,这时候就能输出并结束了,这就是volatile的其中一个作用:线程间的可见性


误区:缓存一致性协议

其实volatile跟缓存一致性协议根本没什么关系,网上很多文章会将其和缓存一致性协议放在一起说,其实他们不是一回事

再看刚才的这一句,还是注释掉了volatile,但是while循环中多了一句打印System.out.println(“随便打印一条语句”);其他地方完全一样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class Volatile问题引出 {

private static /*volatile*/ boolean flag = false;

public static void main(String[] args) {

A a = new A();
a.start();

try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}

flag = true;

}

static class A extends Thread{

@Override
public void run() {
while (true) {
System.out.println("随便打印一条语句");
if (flag){
System.out.println("知道主线程将flag变为true了");
break;
}
}
}
}
}

运行,看到可以经历很多次循环后还是打印了flag

这就是因为缓存一致性,更新了主存里面的东西,比如同步锁,可以看到volatile被注释掉了还是能更新,跟volatile没有半毛钱关系



2. 禁止指令重排序

2.1 什么是指令重排序

就是说cpu为了运行效率可能会将两条没有依赖性的指令顺序进行对调

这里有一段很巧妙的程序证明指令重排序存在

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class 证明指令重排序 {
private static int a = 0, b = 0, x = 0, y = 0;

public static void main(String[] args){
long time = 0;
while (true) {
time++;
a = 0; b = 0; x = 0; y = 0;
Thread thread1 = new Thread(() -> {
a = 1;
x = b;
});
Thread thread2 = new Thread(() -> {
b = 1;
y = a;
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (Exception e) {}
if (x == 0 && y == 0) {
break;
}
}
System.out.println("time=" + time + ",x=" + x + ",y=" + y);
}
}

这段程序巧妙在哪呢?看两个线程的任务逻辑,可以分析出不管哪个线程先执行,或者交叉执行,只要a = 1在x = b之前,b = 1在y = a之前,都不可能出现x = 0, y = 0的

说的再简单点:

如果a = 1先执行,那么y必为1

如果b = 1先执行,那么x必为1

如果a = 1, b = 1先执行,那么x, y都为1

只有被重排序为

1
2
3
4
5
6
7
//线程1
x = b;
a = 1;

//线程2
y = a;
b = 1;

才有可能x = 0, y = 0,明白了吗?

运行多久就看造化了


我们来看下指令重排序的底层原因

在java中Object o = new Object();产生了以上汇编指令

1
2
3
new -> 生成一个初始值m=0
invokespecial -> 赋值m=8
astore -> 将指针t指向这个m

指令重排序,可能会调整为如下

1
2
3
new -> 生成一个初始值m=0
astore -> 将指针t指向这个m
invokespecial -> 赋值m=8

这就有可能会造成我们拿到的指针不为null,但是此时的值还为0


2.2 DCL单例要不要加volatile

单例模式的一种实现:double-checked locking

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton {  
private volatile static Singleton singleton;
private Singleton (){}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}

这种方式高效,且实现了线程安全和懒加载

  • 懒加载就是说定义的时候没有直接new,而是在调用getInstance的时候new
  • 线程安全是说synchronized包裹了判空和new,
  • 高效是说锁的粒度很细,只锁住了判空和new,因为外层可能会有别的业务代码。另一方面,DCL中外面的那层判空,避免了直接的synchronized锁竞争,提高了效率

好了,简单介绍完后,来说为什么要加volatile,一定要加,因为之前说了指令重排序,可能造成指针先存在,后赋值的现象。如果线程1发生指令重排序,先astore有了指针,此时停下,第二个线程走完整个流程,判空不为空导致直接return,但是又还没有new,因此return了个半初始化状态的对象

因此DCL单例一定要加volatile,禁止指令重排序


2.3 volatile如何做到禁止指令重排序的?

内存屏障,在JVM级别,内存屏障只是一个规范,就像是接口一样,交给不同的CPU实现,这个东西不同的硬件实现不一样。

JVM中规定有4中屏障

  • LoadLoadBarrier读读屏障
  • LoadStoreBarrier读写屏障
  • StoreLoadBarrier写读屏障
  • StoreStoreBarrier写写屏障

在volatile写之前加写写屏障,之后加写读屏障

在volatile读之后加读读屏障和读写屏障

不同的硬件厂商有自己不同的实现,其实并没有严格遵循规范(比如因特尔的sfence、ifence、mfence指令),但不管怎样都遵循两个最基本的原则:

happens before:销毁必不可能在创建前

as-if-serial:这个单次翻译过来就是“看起来像顺序执行”,也就是说不管你怎么重排序,单线程下执行出来的结果永远不变


再看看hotspot的实现

用的是lock,为什么?因为偷懒,没有针对不同的厂商进行不同的指令,而是汇编lock

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×