volatile修饰的变量_volatile如何保证并发编程中的可见性和有序性?原子性为何不行?...

上次我们学习了volatile是如何解决多线程环境下共享变量的内存可见性问题,并且简单介绍了基于多核CPU并发缓存架构模型的Java内存模型。
详情见文章:

volatile很难?由浅入深怼到CPU汇编,彻底搞清楚它的底层原理

在并发编程中,有三个重要的特性:

  • 内存可见性
  • 原子性
  • 有序性

volatile解决了并发编程中的可见性有序性,解决不了原子性的问题,原子性的问题需要依赖synchronized关键字来解决。

关于并发编程三大特性的详细介绍,大家可以点击下方卡片搜索查看

内存可见性在上一篇文章中已经验证,本文我们继续通过代码学习如下内容:1、volatile为什么解决不了原子性问题?2、缓存行、缓存行填充3、CPU优化导致的乱序执行4、经典面试题:DCL必须要有volatile关键字吗?5、valatile关键字是如何禁止指令重排序的?

以上每一步都会有一段代码来验证,话不多说,开始输出干货!

1、volatile为什么解决不了原子性问题?

如果你对volatile了解的还可以,那么咱们继续往下看,如果不是太熟悉,请先行阅读上一篇文章。

老规矩,先来一段代码:

83afecbc63116ab45467cb70352c2085.png

volatile原子性问题代码验证

这段代码的输出结果是多少?10000?大于10000?小于10000?

程序执行10次输出的结果如下:

join:10000join:10000join:10000join:9819join:10000join:10000join:10000join:9898join:10000join:10000

会有小几率的出现小于10000的情况,因此volatile是无法保证原子性的,那么到底在什么地方出问题了呢
还是用上篇文章的图来说明一下程序的整体流程:

2d80ac37255a3ea65830132ae16e6195.png

当线程1从内存读取num的值到工作内存,同时线程2也从内存读取num的值到工作内存了,他俩各自操作自己的num++操作,但是关键点来了:

当线程1、2都执行完num++,线程1执行第五步store操作,通过总线将新的num值写回内存,刷新了内存中的num值,同时触发了总线嗅探机制,告知线程2其工作内存中的num不可用,因此线程2的num++得到的值被抛弃了,但是线程2的num++操作却是执行了。

2、CPU缓存行

写上篇文章的时候,有朋友问到了CPU的三级缓存以及缓存行相关的问题,然后我就找了一些资料学习,形成了下面的一张图:

9c45fa52cca83f4e60498c8270070f81.png

CPU缓存行

CPU和主内存RAM之间会有三级缓存,因为CPU的速度要比内存的速度要快的多,大概是100:1,也就是CPU的速度比内存要快100倍,因此有了CPU三级缓存,那为什么是三级缓存呢?不是四级、五级呢?四个大字送给你:工业实践!相关概念:

  • ALU:CPU计算单元,加减乘除都在这里算
  • PC:寄存器,ALU从寄存器读取一次数据为一个周期,需要时间小于1ns
  • L1:1级缓存,当ALU从寄存器拿不到数据的时候,会从L1缓存去拿,耗时约1ns
  • L2:2级缓存,当L1缓存里没有数据的时候,会从L2缓存去拿,耗时约3ns
  • L3:3级缓存,一颗CPU里的双核共用,L2没有,则去L3去拿,耗时约15ns
  • RAM内存:当缓存都没有数据的时候,会从内存读取数据
  • 缓存行:CPU从内存读取数据到缓存行的时候,是一行一行的缓存,每行是64字节(现代处理器)

问题来了:

1、缓存行存在的意义?好处是什么?

空间的考虑:一个地址被访问,相连的地址很大可能也被访问;

时间的考虑:最近访问的会被频繁访问好处:比如相连的地址,典型的就是数组,连续内存访问,很快!

2、缓存行会带来什么问题?

缓存行会导致缓存失效的问题,从而导致程序运行效率低下。例如下图:

5d8aebdc8755bf93201d00de8d0371c8.png

x,y两个变量在一个缓存行的时候:

1、线程1执行x++操作,将x和y所在的缓存行缓存到cpu core1里面去,

2、线程2执行y++操作,也将x和y所在的缓存行缓存到cpu core2里面去,

3、线程1执行了x++操作,写入到内存,同时为了保证cpu的缓存一致性协议,需要使其他内核x,y所在的缓存行失效,意味着线程2去执行y++操作的时候,无法从自己的cpu缓存拿到数据,必须从内存获取。

这就是缓存行失效!

一段代码来验证缓存行失效的问题:

ca305485bf2c29b2961ee722972241fe.png

缓存行失效例程

耗时:2079ms

这个时候我们做一个程序的改动,在x变量的前面和后面各加上7个long类型变量,如下:

1c9aa6e607b29ba0fd3feba5a916ef61.png

再次运行,看耗时输出:

耗时:671ms

大约三倍的速度差距!

关于缓存行的更多概念,大家也可以点击下方卡片直接搜索更多信息:

3、CPU优化导致的乱序执行

上文在说缓存行的时候,主要是因为CPU的速度大约是内存的速度的100倍,因此CPU在执行指令的时候,为了不等待内存数据的读取,会存在CPU指令优化而导致乱序执行的情况

看下面这段代码:

c5c7d747dbd2c31603887c9ed48f9dfa.png

cpu乱序执行例程

执行后输出(我执行了900多万次才遇到x=0,y=0的情况,可以试试你的运气哦~):

ce89ce458edda1d7fa11f1b17a19007a.png

因为CPU的速度比内存要快100倍,所以当有两行不相关的代码在执行的时候,CPU为了优化执行速度,是会乱序执行的,所以上面的程序会输出:x=0,y=0的情况,也就是两个线程的执行顺序变成了:

x = b;y = a;a = 1;b = 1;

这个时候我们就需要加volatile关键字了,来禁止CPU的指令重排序

4、DCL单例模式需要加volatile吗?

一道经典的面试题:DCL单例模式需要加volatile字段吗?先来看DCL单例模式的一段代码:

69270eb0e227e0eb420e6f242928c865.png

DCL单例模式

DCL全称叫做Double Check Lock,就是双重检查锁来保证一个对象是单例的。

核心的问题就是这个INSTANCE变量是否需要加volatile关键字修饰?答案肯定是需要的。

首先我们来看new一个对象的字节码指令:

833ba016053056ade97ac2f590942ebc.png

查看其字节码指令:

NEW java/lang/ObjectDUPINVOKESPECIAL java/lang/Object. ()VASTORE 1

即:

1、创建并默认初始化Object对象;

2、复制操作数栈对该对象的引用;

3、调用Object对象的初始化方法;

4、将变量Object o指向创建的这个对象,此时变量o不再为null;

根据上文描述我们知道因为CPU和内存速度不匹配的问题,CPU在执行命令的时候是乱序执行的,即CPU在执行第3步初始化方法时候如果需要很长的时间,CPU是不会等待第3步执行完了才去执行第4步,所以执行顺序可能是1、2、4、3。

那么继续看DCL单例程序,当线程1执行new DCLStudy()的顺序是先astoreinvokespecial,但是invokespecial方法还没有执行的时候,线程2进来了,这个时候线程2拿到的就是一个半初始化的对象

因此,DCL单例模式需要加volatile关键字,来禁止上述new对象的过程的指令重排序!

valatile关键字是如何禁止指令重排序的

JVM规范中规定:凡是被volatile修饰的变量,在进行其操作时候,需要加内存屏障!

JVM规范中定义的JSR内存屏障定义:

  • LoadLoad屏障:
    对于语句Load1;LoadLoad;Load2;Load1和Load2语句不允许重排序。
  • StoreStore屏障:
    对于语句Store1;StoreStore;Store2;Store1和Store2语句不允许重排序。
  • LoadStore屏障:
    对于语句Load1;StoreStore;Store2;Load1和Store2语句不允许重排序。
  • StoreLoad屏障:
    对于语句Store1;StoreStore;Load2;Store1和Load2语句不允许重排序。

JVM层面volatile的实现要求:

df6c295a3aede87d58299dddcda974cf.png

如果对一个volatile修饰的变量进行写操作:

前面加StoreStoreBarrier屏障,保证前面所有的store操作都执行完了才能对当前volatile修饰的变量进行写操作;

后面要加StoreLoadBarrier,保证后面所有的Load操作必须等volatile修饰的变量写操作完成。

ebbec2f38f9adcd399f8c2bf1d5adeb1.png

如果对一个volatile修饰的变量进行读操作:

后面的读操作LoadLoadBarrier必须等当前volatile修饰变量读操作完成才能读;

后面的写操作LoadStoreBarrier必须等当前的volatile修饰变量读操作完成才能写。

上篇文章我们通过一定的方式看到了程序执行的volatile修饰的变量底层汇编码:

0x000000010d3f3203: lock addl $0x0,(%rsp)     ;*putstatic flag                                                ; - com.java.study.VolatileStudy::lambda$main$1@9 (line 31)

也就是到CPU的底层执行的命令其实就是这个lock,这个lock指令既完成了变量的可见性还保证了禁止指令充排序:

LOCK用于在多处理器中执行指令时对共享内存的独占使用。它的作用是能够将当前处理器对应缓存的内容刷新到内存,并使其他处理器对应的缓存失效;另外还提供了有序的指令无法越过这个内存屏障的作用。

end

至此,对volatile的学习就到这里了,通过两篇文章来对volatile这个关键字有了一个系统的学习。

学无止境,对volatile的学习还只是一个基础学习,还有更多的知识等待我们去探索学习,例如:

  • 什么是as-if-serial?什么是happens-before?
  • Java的哪些指令可以重排序呢?重排序的规则是什么?

我们下期再见!

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值