摘要

本文主要涉及Java中的volatile,将从volatile的作用开启,再分析volatile实现的从而深刻立即理解volatile的作用;最后通过《volatile DCL单例需不需要加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)、二级缓存和三级缓存,结构如下图所示:

  • CPU到各缓存和内存之间的大概速度:
  • 在Linux中可以通过如下命令查看CPU Cache:

    这里的index0和index1对应着L1 D-Cache和L1 I-Cache。

CPU Cache & 主内存

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

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

单核处理,问题不是很大,但是在多核的情况下,问题就来了;

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修饰后直接结果,跑了一上午,没有出现x、y为0的情况
    private static volatile int x = 0, y = 0;
private static volatile int a = 0, b = 0;

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()实现;

    再跳转到fence函数,发现底层使用了lock,锁总线的方式,这种方式好处就在于,跨平台可移植性;

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();
}
}


从图中可以看到,new一个对象包括以下几个步骤:

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

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


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

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

参考

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


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

打赏地址

温故知新-多线程-深入刨析volatile关键词的更多相关文章

  1. 温故知新-多线程-深入刨析CAS

    文章目录 摘要 CAS是什么? CAS是如何实现的? CAS存在的问题? 你的鼓励也是我创作的动力 Posted by 微博@Yangsc_o 原创文章,版权声明:自由转载-非商用-非衍生-保持署名 ...

  2. 温故知新-多线程-深入刨析park、unpark

    文章目录 摘要 park.unpark 看一下hotspot实现 参考 你的鼓励也是我创作的动力 Posted by 微博@Yangsc_o 原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | ...

  3. 温故知新-多线程-深入刨析synchronized

    Posted by 微博@Yangsc_o 原创文章,版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0 文章目录 摘要 synchroniz ...

  4. 温故知新-多线程-Cache Line存在验证

    文章目录 简述 缓存行Cache Line 验证CacehLine存在? 参考 你的鼓励也是我创作的动力 Posted by 微博@Yangsc_o 原创文章,版权声明:自由转载-非商用-非衍生-保持 ...

  5. C/C++ Volatile关键词深度剖析(转)

    本文转载自博文C/C++ Volatile关键词深度剖析. 背景 前几天,发了一条如下的微博 (关于C/C++ Volatile关键词的使用建议): 此微博,引发了朋友们的大量讨论:赞同者有之:批评者 ...

  6. c++Volatile关键词

    看到的一篇文章觉得还不错吧,文章具体位置也找不到了,复制一下,留着日后复习 背景 此微博,引发了朋友们的大量讨论:赞同者有之:批评者有之:当然,更多的朋友,是希望我能更详细的解读C/C++ Volat ...

  7. C/C++ Volatile关键词深度剖析

    文章来源:http://hedengcheng.com/?p=725 背景 此微博,引发了朋友们的大量讨论:赞同者有之:批评者有之:当然,更多的朋友,是希望我能更详细的解读C/C++ Volatile ...

  8. 对象中属性 加锁 用:volatile 关键词修饰 而 不用 synchronized 加锁

    一个对象中有一个状态 属性,现在业务需求 存在多线程来修改 和 拿去 这个状态 的值,这种情况如果加锁怎么加? 一种是 在 set 和get 这个状态的 方法那加 synchronized . 还有一 ...

  9. zz剖析为什么在多核多线程程序中要慎用volatile关键字?

    [摘要]编译器保证volatile自己的读写有序,但由于optimization和多线程可以和非volatile读写interleave,也就是不原子,也就是没有用.C++11 supposed会支持 ...

随机推荐

  1. vue 升级element-ui woff文件404

    一.build文件下utils.js下增加 publicPath:'../../' 二. 同样的代码环境,用yarn来安装依赖后启动运行正常,而采用npm安装依赖则有类似问题.当然,这个和yarn或者 ...

  2. block和delegate的选择

    block和delegate均为常用回调方式 (暂不讨论通知)   代理 优点: 设置某个对象的代理,代理对象可以与被代理对象不直接相关,即使两个对象距离较远,传值也比较方便. 代理方法内可以方便调用 ...

  3. queue.Queue()

    一.构造方法 Queue是构造方法,函数签名是Queue(maxsize=0) ,其中maxsize设置队列的大小. 二.实例方法 Queue.qsize(): 返回queue的近似值.注意:qsiz ...

  4. TP5.0验证器使用方法

    比如我要在分类做一个验证器,首先要在你的后台模块先建立一个文件夹,比如我的后台模块是admin,那么你要在admin里面建立一个validate的文件夹然后再建立一个对应的php文件如下图 文件里面的 ...

  5. 关于VMware问题:无法获得 VMCI 驱动程序的版本: 句柄无效。驱动程序“vmci.sys”的版本不正确

    有的童鞋可能安装虚拟机时出现了下边这样的错误,莫慌,下面咋们来解决!!! 1.首先,找到你的安装虚拟机的目录下有一个.vmx的文件 找到这个文件,用编辑器打开,将该值改为FALSE即可!

  6. 要小心 JavaScript 的事件代理

    我们知道,如果给 form 里面的 button 元素绑定事件,需要考虑它是否会触发 form 的 submit 行为.除此之外,其它场合给 button 元素绑定事件,你几乎不用担心这个事件会有什么 ...

  7. [PHP学习教程 - 类库]001.全局唯一ID(GUID)

    GUID: 即Globally Unique Identifier(全球唯一标识符) 也称作 UUID(Universally Unique IDentifier) . GUID 是一个通过特定算法产 ...

  8. Python数据分析:pandas玩转Excel(三)

    将对象写入Excel工作表. 要将单个对象写入 Excel .xlsx 文件,只需指定目标文件名即可.要写入多个工作表,必须创建具有目标文件名的ExcelWriter对象,并在文件中指定要写入的工作表 ...

  9. java第十三周课后作业 0529

    1.把多个企鹅的信息添加到集合中查看企鹅的数量及所有企鹅的信息删除集合中部分企鹅的元素判断集合中是否包含指定企鹅 package homework; import java.util.ArrayLis ...

  10. "锁定文件失败 打不开磁盘或它所依赖的某个快照磁盘。模块启动失败。未能启动虚拟机"--解决方法

    今天正在使用kali的时候,电脑突然死机了..强制重启,在进入虚拟机发现报错: "锁定文件失败 打不开磁盘或它所依赖的某个快照磁盘.模块启动失败.未能启动虚拟机." 1.问题起因 ...