volatile的用途

问题: DCL单例需不需要加volatile?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Instance {
//此处加volatile 和 不加的区别
private volatile static Instance ins = null;

private Instance() {
}

public static Instance getInstance() {
if (ins == null) {
synchronized (Instance.class) {
if (ins == null) {
ins = new Instance();
}
}
}
return ins;
}
}

1.防止指令重排序

  • 证明指令重排序存在
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
34
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;
while (true) {
i++;
x = 0;
y = 0;
a = 0;
b = 0;
Thread one = new Thread(() -> {
a = 1;
x = b;
});
Thread other = new Thread(() -> {
b = 1;
y = a;
});
one.start();
other.start();
one.join();
other.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
// 出现00组合 证明cpu乱序执行了
if (x == 0 && y == 0) {
System.err.println(result);
break;
} else {
// System.out.println(result);
}
}
}

  • 不存乱序的情况下应该输出的值
1
2
3
4
5
6
7
8
x = 1;
y = 1;

x = 0;
y = 1;

x = 1;
y = 0;
  • 存在乱序的情况下出现的值
1
461233次 (0,0
  • 对象创建的过程
1
2
3
4
5
6
7
8
9
//https://blog.csdn.net/u010737756/article/details/104843152
class C {
int i = 10;
}
public static void main(String[] args) {
C o = new C();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}

1.使用 idea 的插件jclasslib Bytecode Viewer 即可查看对象实例化过程的字节码

new C();实列化过程

1
2
3
4
5
0 new #2 <com/yzw/juc/markWord/C>
3 dup
4 invokespecial #3 <com/yzw/juc/markWord/C.<init>>
7 astore_1
8 return

new C();字节码解释

1
2
3
4
5
6
0 new                         创建一个对象,并且其引用进栈
3 dup 复制栈顶数值,并且复制值进栈
4 invokespecial 调用超类构造方法、实例初始化方法、私有方法
7 astore_1 将栈顶数值(objectref)存入当前
8 return 从当前方法返回void

  • 字节码

0.new ->申请内存。堆里有了一个新的内存。(半初始化。成员变量设置默认值

graph LR
A(c)    
B[i = 0]

3 dup 因为invokespecial会消耗一份,所以必须先复制一份
4 invokespecial T initlize 初始化,调用他的构造方法

graph LR
A(c)  
B[i = 8]

7 astore_1 把c与i建立关联

graph LR
A(c)  --> B[i = 8]

8 return 从当前方法返回void

  • 结论

由上述步骤可以看出:在多线程的环境下,假设线程1拿到锁Instance.class,线程1执行锁中的代码时有可能会发生 c与i先建立联系 , 然后给i赋值,此时线程2如果出现进入第一个判断ins!= null,此时的线程2就会直接 return ins,所以这里就会出现问题

2.保证线程的可见性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//证明线程的可见性
class V {
/*volatile*/ boolean running = true; //对比一下有无volatile的情况下,整个程序运行结果的区别

void m() {
System.out.println("m start");
while (running) {
//System.out.println("runing.....");
}
System.out.println("m end!");
}

public static void main(String[] args) throws InterruptedException {
V v = new V();
new Thread(v::m, "t1").start();
TimeUnit.SECONDS.sleep(1);
v.running = false;
System.out.println("min stop");
}
}

3.cpu为什么会出现乱序执行

CPU缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,因为CPU运算速度要比内存读写速度快得多,举个例子:

  • 一次主内存的访问通常在几十到几百个时钟周期
  • 一次L1高速缓存的读写只需要1~2个时钟周期
  • 一次L2高速缓存的读写也只需要数十个时钟周期

这种访问速度的显著差异,导致CPU可能会花费很长时间等待数据到来或把数据写入内存。

基于此,现在CPU大多数情况下读写都不会直接访问内存(CPU都没有连接到内存的管脚),取而代之的是CPU缓存,CPU缓存是位于CPU与内存之间的临时存储器,它的容量比内存小得多但是交换速度却比内存快得多。而缓存中的数据是内存中的一小部分数据,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可先从缓存中读取,从而加快读取速度。

按照读取顺序与CPU结合的紧密程度,CPU缓存可分为:

  • 一级缓存:简称L1 Cache,位于CPU内核的旁边,是与CPU结合最为紧密的CPU缓存
  • 二级缓存:简称L2 Cache,分内部和外部两种芯片,内部芯片二级缓存运行速度与主频相同,外部芯片二级缓存运行速度则只有主频的一半
  • 三级缓存:简称L3 Cache,部分高端CPU才有

每一级缓存中所存储的数据全部都是下一级缓存中的一部分,这三种缓存的技术难度和制造成本是相对递减的,所以其容量也相对递增。

当CPU要读取一个数据时,首先从一级缓存中查找,如果没有再从二级缓存中查找,如果还是没有再从三级缓存中或内存中查找。一般来说每级缓存的命中率大概都有80%左右,也就是说全部数据量的80%都可以在一级缓存中找到,只剩下20%的总数据量才需要从二级缓存、三级缓存或内存中读取。

缓存一致性协议
为了解决这个问题,在早期的CPU当中,是通过在总线上直接加锁的形式来解决缓存不一致的问题。

但是正如Java中Synchronized一样,直接加锁太粗暴了,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。很明显这样做是不可取的。

所以就出现了缓存一致性协议。缓存一致性协议有MSI,MESI,MOSI,Synapse,Firefly及DragonProtocol等等。

MESI协议
最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。

Modify(修改):当缓存行中的数据被修改时,该缓存行置为M状态
Exclusive(独占):当只有一个缓存行使用某个数据时,置为E状态
Shared(共享):当其他CPU中也读取某数据到缓存行时,所有持有该数据的缓存行置为S状态
Invalid(无效):当某个缓存行数据修改时,其他持有该数据的缓存行置为I状态
它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

而这其中,监听和通知又基于总线嗅探机制来完成。

4. volatile 是如何保证线程之间的可见性的

总线嗅探机制
嗅探机制其实就是一个监听器,回到我们刚才的流程,如果是加入MESI缓存一致性协议和总线嗅探机制之后:

CPU1读取数据a=1,CPU1的缓存中都有数据a的副本,该缓存行置为(E)状态
CPU2也执行读取操作,同样CPU2也有数据a=1的副本,此时总线嗅探到CPU1也有该数据,则CPU1、CPU2两个缓存行都置为(S)状态
CPU1修改数据a=2,CPU1的缓存以及主内存a=2,同时CPU1的缓存行置为(S)状态,总线发出通知,CPU2的缓存行置为(I)状态
CPU2再次读取a,虽然CPU2在缓存中命中数据a=1,但是发现状态为(I),因此直接丢弃该数据,去主内存获取最新数据
当我们使用volatile关键字修饰某个变量之后,就相当于告诉CPU:我这个变量需要使用MESI和总线嗅探机制处理。从而也就保证了可见性。

5.volatile是如何禁止指令重排序的

  1. 字节码层面
    ACC_VOLATILE

  2. JVM层面
    volatile内存区的读写 都加屏障

    StoreStoreBarrier

    volatile 写操作

    StoreLoadBarrier

    LoadLoadBarrier

    volatile 读操作

    LoadStoreBarrier

  3. OS和硬件层面
    https://blog.csdn.net/qq_26222859/article/details/52235930
    hsdis - HotSpot Dis Assembler
    windows lock 指令实现 | MESI实现