温故知新-多线程-深入刨析volatile关键词

时间:2022-06-01 07:17:31



摘要

本文主要涉及Java中的volatile,将从volatile的作用开启,再分析volatile实现的从而深刻立即理解volatile的作用;最后通过《volatile DCL单例需不需要加volatile?》这样一个问题结束volatile的温习;

volatile的作用

我在前几篇的文章编程语言&性能优化已经提到了volatile的作用;温故知新-多线程-深入刨析volatile关键词
概括一下就是:

  1. 线程可见
  2. 防止指令重排

volatile如何解决线程可见?

  • 下面的代码flag只有加了volatile修饰后,才会打印出here,很好验证;
private static volatile boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() - > {
while (flag) {
// do nothing
}
log.info("here");
}, "name").start();
Thread.sleep(1000);
flag = false;
}

CPU Cache

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

  • 一次主内存的访问通常在几十到几百个时钟周期
  • 一次L1高速缓存的读写只需要1~2个时钟周期
  • 一次L2高速缓存的读写也只需要数十个时钟周期
    这种访问速度的显著差异,导致CPU可能会花费很长时间等待数据到来或把数据写入内存。

它的工作简要原理如下:

  • 当CPU要读取一个数据时,首先从缓存中查找,如果找到就立即读取并送给CPU处理;
  • 如果没有找到,就用相对慢的速度从内存中读取并送给CPU处理,同时把这个数据所在的数据块调入缓存中,可以使得以后对整块数据的读取都从缓存中进行,不必再调用内存。

为了充分发挥CPU的计算性能和吞吐量,现代CPU引入了一级缓存(一级数据缓存Data Cache,D-Cache和一级指令缓存InstructionCache,I-Cache)、二级缓存和三级缓存,结构如下图所示:温故知新-多线程-深入刨析volatile关键词

  • CPU到各缓存和内存之间的大概速度:温故知新-多线程-深入刨析volatile关键词
  • 在Linux中可以通过如下命令查看CPU Cache:
    温故知新-多线程-深入刨析volatile关键词
    这里的index0和index1对应着L1 D-Cache和L1 I-Cache。

CPU Cache & 主内存

温故知新-多线程-深入刨析volatile关键词

当系统运行时,CPU执行计算的过程如下

  • 程序以及数据被加载到主内存
  • 指令和数据被加载到CPU缓存
  • CPU执行指令,把结果写到高速缓存
  • 高速缓存中的数据写回主内存

单核处理,问题不是很大,但是在多核的情况下,问题就来了;
温故知新-多线程-深入刨析volatile关键词
eg:

  • 核0读取了一个字节,根据局部性原理,它相邻的字节同样被被读入核0的缓存
  • 核3做了上面同样的工作,这样核0与核3的缓存拥有同样的数据
  • 核0修改了那个字节,被修改后,那个字节被写回核0的缓存,但是该信息并没有写回主存
  • 核3访问该字节,由于核0并未将数据写回主存,数据不同步

缓存一致性协议

为了解决这一问题,CPU制造商规定了一个缓存一致性协议。

每个CPU都有一级缓存,但是,我们却无法保证每个CPU的一级缓存数据都是一样的。 所以同一个程序,CPU进行切换的时候,切换前和切换后的数据可能会有不一致的情况。那么这个就是一个很大的问题了。 如何保证各个CPU缓存中的数据是一致的。就是CPU的缓存一致性问题。

如何解决呢?

  1. 总线锁

一种处理一致性问题的办法是使用Bus Locking(总线锁)。当一个CPU对其缓存中的数据进行操作的时候,往总线中发送一个Lock信号。 这个时候,所有CPU收到这个信号之后就不操作自己缓存中的对应数据了,当操作结束,释放锁以后,所有的CPU就去内存中获取最新数据更新。
用锁,那么性能问题就来了,所以出现了MESI;

  1. MESI

MESI是保持一致性的协议。它的方法是在CPU缓存中保存一个标记位,这个标记位有四种状态:
M: Modify,修改缓存,当前CPU的缓存已经被修改了,即与内存中数据已经不一致了;
E: Exclusive,独占缓存,当前CPU的缓存和内存中数据保持一致,而且其他处理器并没有可使用的缓存数据;
S: Share,共享缓存,和内存保持一致的一份拷贝,多组缓存可以同时拥有针对同一内存地址的共享缓存段;
I: Invalid,失效缓存,这个说明CPU中的缓存已经不能使用了。

CPU的读取遵循下面几点,来保证CPU的效率

  • 如果缓存状态是I,那么就从内存中读取,否则就从缓存中直接读取。
  • 如果缓存处于M或E的CPU读取到其他CPU有读操作,就把自己的缓存写入到内存中,并将自己的状态设置为S。
  • 只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为M。
  • Volatile的汇编码

生成汇编参考文章: 从汇编看Volatile的内存屏障

Java代码如下

public class VolatileTest {
private static volatile Integer flag = 0;
public static void main(String[] args) {
flag++;
}
}

生成汇编

flag汇编:
0x00000001156e7d65: movb $0x0,(%rsi,%rbx,1)
0x00000001156e7d69: lock addl $0x0,(%rsp) ;*putstatic flag
; - com.yangsc.juc.VolatileTest::main@16 (line 21)
---
flag2汇编:
0x00000001156e7e58: mov 0x38(%rsp),%rbx
0x00000001156e7e5d: movb $0x0,(%rdi,%rbx,1) ;*putstatic flag2
; - com.yangsc.juc.VolatileTest::main@38 (line 22)

有volatile修饰的共享变量进行写操作时会多出第二行汇编代码,该句代码的意思是对原值加零,其中相加指令addl前有lock修饰。通过查IA-32架构软件开发者手册可知,lock前缀的指令在多核处理器下会引发两件事情:

  1. 将当前处理器缓存行的数据写回到系统内存。
    Lock前缀指令导致在执行指令期间,声言处理器的LOCK# 信号。在多处理器环境中,LOCK# 信号确保在声言该信号期间,处理器可以独占任何共享内存(因为它会锁住总线,导致其他CPU不能访问总线,也就不能访问系统内存,在Intel486和Pentium处理器中都是这种策略)。但是,在最近的处理器里,LOCK# 信号一般不锁总线,而是锁缓存,因为锁总线开销的比较大。在P6和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK# 信号。相反,它会锁定这块区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上的处理器缓存的内存区域数据。

  2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
    IA-32处理器和Intel 64处理器使用MESI控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32和Intel 64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。例如,在Pentium和P6 family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强行执行缓存行填充。

volatile如何解决指令重排序?

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排,一般分一下3种:源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行的指令

  • 单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。(单线程没有影响)

  • 处理器在进行重排序时必须考虑指令之间的数据依赖性。(int a=1; int b=0; 没有依赖,可以进行指令重排)

  • 多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

  • 验证指令

public class VolatileTest2 {
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 one = new Thread(() -> {
shortWait(100);
a = 1;
x = b;
}); Thread two = new Thread(() -> {
b = 1;
y = a;
});
one.start();
two.start();
one.join();
two.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if (x == 0 && y == 0) {
log.info(result);
break;
} else {
log.info(result);
}
}
}
public static void shortWait(long interval) {
long start = System.nanoTime();
long end;
do {
end = System.nanoTime();
} while (start + interval >= end);
}
}
  • 执行结果
    温故知新-多线程-深入刨析volatile关键词
    加上volatile修饰后直接结果,跑了一上午,没有出现x、y为0的情况
    private static volatile int x = 0, y = 0;
private static volatile int a = 0, b = 0;

温故知新-多线程-深入刨析volatile关键词

volatile 字节码标记

当我们对一个变量用volatile修饰时,字节码中会标记为volatile,后续交由虚拟机处理;
温故知新-多线程-深入刨析volatile关键词

volatile 虚拟机规范

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题。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的写入对所有处理器可见。它的开销是四种屏障中最大的。

hotspot实现方法,通过查资料了解到,大致有两种方式实现:

  • 内存屏障sfence mfence lfence等指令,缺点:不一定跨平台;
  • 锁总线,好处:跨平台;
    通过JDK的bytecodeinterpreter.cpp源码可以看到,通过调用OrderAccess::fence()实现;
    温故知新-多线程-深入刨析volatile关键词
    再跳转到fence函数,发现底层使用了lock,锁总线的方式,这种方式好处就在于,跨平台可移植性;
    温故知新-多线程-深入刨析volatile关键词

LOCK 用于在多处理器中执行指令时对共享内存的独占使用。 它的作用是能够将当前处理器对应缓存的内容刷新到内存,并使其他处理器对应的缓存失效。 另外还提供了有序的指令无法越过这个内存屏障的作用。
这个跟上一篇《温故知新-多线程-深入刨析CAS》提到的CAS也是一样的原理,都是用了锁;


至此,volatile的实现原理也都讲完了,来看一下简单的应用

volatile DCL单例需不需要加volatile?

  • code0 一个最简单的单例,问题在于提前就实例化好了,占用内存,一般叫做饿汉模式;
public class SingletonDemo {
public static final SingletonDemo instance = new SingletonDemo();
private SingletonDemo(){
}
public static SingletonDemo getInstance(){
return instance;
}
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread(()->{
SingletonDemo singletonDemo = SingletonDemo.getInstance();
log.info(singletonDemo);
}).start();
}
}
}
  • code1 懒汉模式,问题在于,这种方式锁的粒度太大;
public class SingletonDemo1 {
public static SingletonDemo1 instance; private SingletonDemo1() {
} public static synchronized SingletonDemo1 getInstance() {
if (instance == null) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
instance = new SingletonDemo1();
}
return instance;
}
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread(() -> {
SingletonDemo1 singletonDemo = SingletonDemo1.getInstance();
log.info(singletonDemo);
}).start();
}
}
}
  • code2 懒汉模式2(有问题的)
    因为 if (instance == null) 和synchronized(SingletonDemo2.class)加锁之间有时间间隙,所以这种方式行不通;
public class SingletonDemo2 {
public static SingletonDemo2 instance; private SingletonDemo2() {
} public static SingletonDemo2 getInstance() {
if (instance == null) {
synchronized(SingletonDemo2.class){
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
instance = new SingletonDemo2();
}
}
return instance;
} public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread(() -> {
SingletonDemo2 singletonDemo = SingletonDemo2.getInstance();
log.info(singletonDemo);
}).start();
}
}
}
  • code3 于是就有DCL的懒汉模式
    DCL是(Double Check Lock)的缩写,也就是加锁后再判断一遍,保证过程是单例的,似乎一切都很完美,但要讨论的问题是:“volatile DCL单例需不需要加volatile?”,继续看;
public class SingletonDemo3 {
public static SingletonDemo3 instance; private SingletonDemo3() {
} public static SingletonDemo3 getInstance() {
if (instance == null) {
synchronized(SingletonDemo3.class){
if (instance == null) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
instance = new SingletonDemo3();
}
}
}
return instance;
} public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
new Thread(() -> {
SingletonDemo3 singletonDemo = SingletonDemo3.getInstance();
log.info(singletonDemo);
}).start();
}
}
}

回答这个问题:volatile DCL单例需不需要加volatile?我们需要知道new一个对象的过程,借助idea插件:jclasslib is a bytecode viewer看一下new 一个对象的字节码;
代码如下:

public class ObjectLayout {
public static void main(String[] args) {
Object o = new Object();
}
}

温故知新-多线程-深入刨析volatile关键词
从图中可以看到,new一个对象包括以下几个步骤:

  1. new:在内存中new 一个对象
  2. dup:一个伪指令
  3. invokespecial:构造方法,初始化相关value;
  4. astore_1 将栈帧指向这个对象

根据我们上面文章所讲讲到的知识,可能会发生指令重排,如果3和4步骤发生指令重排,那么就有可能拿到了一个半初始化的对象;
以SingletonDemo3举例,可能会产生步骤1不为null,但是2还没进行,线程直接取走了一个半初始化的对象,这问题可能就会很严重了;

温故知新-多线程-深入刨析volatile关键词


所以需要使用volatile修饰单例,防止指令重排 public static volatile SingletonDemo3 instance;

当然,单例的写法还有其它,比如枚举类等等,这不在本文的讨论的范畴,可以搜索其它文章了解更多的单例写法;

参考

Java volatile 关键字底层实现原理解析
理解CPU Cache
KVM之CPU虚拟化
从汇编看Volatile的内存屏障
就是要你懂Java中volatile关键字实现原理
JVM内存模型、指令重排、内存屏障概念解析


你的鼓励也是我创作的动力

打赏地址