之前说了volatile加在全局变量上, 可以保证变量的可见性. 那么volatile到底是怎么保证变量的可见性的呢?
首先, 我们来说一下, java代码是怎么执行的.
一、java代码从jvm虚拟机到底层cpu等硬件是如何交互运行的?
先来看看程序代码在jvm虚拟机层面是如何工作的
package com.alibaba.nacos.test; /** * Description * <p> * </p> * DATE 2020/8/31. * * @author luoxiaoli. */ public class CodeVisiable { private static boolean initFlag = false; public static void refresh() { System.out.println("refresh data....."); initFlag = true; System.out.println("refresh data success"); } public static void main(String[] args) { // 线程A Thread threadA = new Thread(() -> { while (!initFlag) { } System.out.println("线程:" + Thread.currentThread().getName() + "当前线程秀谈到initFlag的状态已经改变"); }, "threadA"); threadA.start(); // 中间休眠500hs try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } // 线程B Thread threadB = new Thread(() -> { while (!initFlag) { refresh(); } }, "threadB"); threadB.start(); } }
以这个代码为例来说明:
第一步. 类CodeVisiable会被类加载器ClassLoader加载, 加载完以后, 将类基本信息放入元数据区-->a.class, 然后会在堆里面创建一个class对象.
第二步: 程序如果要想运行, 首先要启动一个线程
然后加载元数据区的方法, 比如refresh()方法. 启动线程后, 首先, 会在线程栈开辟一块栈帧, 然后执行操作数栈
操作数栈第一步, 就是获取一个常量. 将其压入栈,
然后字节码执行引擎, 调用常量iconst0, 执行字节码操作
如上面的步骤, 这样程序代码在jvm中的流程就结束了
如果说从jvm的角度来说, jvm的流程是结束了, 但是, 仔细思考, 整个 JVM运行时数据区, 还有开辟的线程0, 都是在那里呢? 都是在内存里的.
但我们知道如果要想执行iconst0这个变量, 需要谁来调度? 需要cpu来调度. 忘内存里写入一个值, 需要有cpu来控制.
变量iconst0到底是怎么放进去的呢?
刚开始iconst0是在内存空间的.
iconst0是jvm字节码执行引擎才认识的代码, 如果想要往操作数栈中写值, 那么它对应的逻辑必须要放亏到cpu上, 而cpu只认0101的二进制.
jvm字节码执行引擎, 内置了两个解释器, 一个叫做JIT, 以及叫做解释执行器. 所以consts0这个常量的字节码, 会被解释执行器/JIT进行翻译, 翻译成汇编指令.
为什么说java慢, 相对于c来说, java很慢, 他慢就慢在这里了.
那么, 汇编指令能够直接在cpu上执行么?
当然是不可以的, 因为汇编指令是硬件原语. 还需要将汇编指令翻译成二进制代码, 也就是cpu能够识别的语言. 这个过程是很快的.
这过程中, 将字节码翻译成汇编指令也就是硬件原语, 是在软件层面上操作的,速度更慢. 将汇编指令翻译成二进制, 是在硬件成面的, 速度相对快一些
所以, 我们看到, 我们的一个java代码至少要被执行两次, 才能被放到cpu上执行.
此时. 只是具备了被cpu执行的可能. 还不能被执行. 什么时候才能被执行呢?
这里虽然准备好了指令以及数据, 但是cpu并不是说马上就会执行, 而是当二进制代码所在的线程被cpu调用了, cpu才会执行二进制代码
cpu怎么知道, 什么时候来调度线程呢?
我们知道cpu的内核又两种KLT和ULT, jvm使用的是klt
其实,在OS底层,有线程变量池, 线程变量池里的线程和我们的线程栈是1对1的关系.
当cpu调度到线程变量池的某个线程的时候, 就会去执行这个线程的二进制代码.
二. volatile的可见性问题: volatile是如何保证可见性的呢?
就是依赖硬件原语(汇编语言) 给我们提供的这个功能.
下面看看一个变量加了volatile以后, 底层到底做了什么. 想要看到底层的源码,
第一步: 我们需要下载一个额外的插件
这两个插件包, 第一个对应的是64位操作系统, 第二个对应的是32位操作系统.
第二步: 解压后, 有两个文件 ,将这两个文件放到如下目录下
第三步: 再启动配置上增加启动参数.
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly.*Jmm03_CodeVisibility.refresh
其中,标红的那段代码, 就是会把汇编指令打印出来
mac版本下载地址和使用方法参考: https://blog.csdn.net/iter_zc/article/details/41897137?readlog
运行程序, 我们看到, 会打印出来一个汇编指令码
其中, 加了volatile关键字的变量, 在执行到第31行, 写volatile的时候, 加了一个锁. 加锁的那一个行代码是第31行. 刚好就是initFlag=true这一行
我们来看看这个锁是什么意思呢?
查找手册, 我们发现, LOCK的含义是, 加了一个总线锁.
lock会触发硬件缓存锁定机制, 锁定机制有两种: 总线锁和缓存一致性协议
为什么会有两种锁呢? 这就和cpu的发展有关系了.
早期的cpu技术比较落后, 才使用的总线锁, 来保存缓存的一致性.
总线: cpu想要访问内存条, 必须要通过总线去访问, 如下图. 如果有多个cpu想要同时访问内存条, 就需要获取总线的锁, 谁获取到锁了, 谁就能访问内存条.
可以看到这种方法的缺点, 一旦抢到锁, 那么只有这个cpu可以执行,其他cpu就没有办法在访问内存里的这个变量了. 没有办法发挥多核并发的能力.
因此发展出来了缓存一致性协议. 现在使用最普遍的是mesi协议,
三. mesi协议的工作原理
四个字母分别代表在缓存里不同的四个状态: M:已修改 E:独占 S:共享 I:已失效
MESI 是4种状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:
状态
|
描述
|
监听任务
|
M 修改 (Modified)
|
该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。
|
缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。
|
E 独享、互斥 (Exclusive)
|
该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。
|
缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
|
S 共享 (Shared)
|
该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。
|
缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
|
I 无效 (Invalid)
|
该Cache line无效。
|
无
|
如上图, 一共有4个状态, 那么, 这四个状态之间是如何转换的呢?
计算机启动的时候, cpu会启动一个监控程序, 监控总线中被lock标记的变量. 我们知道加了volatile的变量, 就被lock标记了. 那么被lock标记后的变量是如何工作的呢? 一initFlag为例说明
1. 计算器启动, 有两个cpu, 那么两个cpu都会监听bus总线上带有lock标记的变量
2. 内存中有一个变量initFlag=false.
3. cpu core0 要调用initFlag, 这时候 ,首先拷贝一份initFlag 放入到bus总线, bus总线监控到initFlag带有lock标记, 于是所有cpu都监控到这个变量.
4. 然后将initFlag 拷贝到L3 cache-->L2 cache ,此时, 只有一个cpu使用到这个变量, 所以, initFlag此时的状态是独享的状态.
5. 另一个cpu core1 也要调用initFlag变量, 通过bus总线监控到已经有线程在使用这个变量了, 于是, cpu core0也监控到cpu core1 使用这个变量了, 此时, 将initFlag的状态由独占变为共享状态. 同时cpu core1的中initFlag的状态也是共享状态.
6. 接下来, cpu core1和cpu core2都想要去修改这个变量, 是如何操作的呢? 我们知道, 在缓存中, 有一个缓存行, 变量保存在缓存行里, 每个cpu需要抢占锁, 然后锁住缓存行, 并告诉bus总线, 我抢到锁了, 监听bus总线的所有cpu都将得知, 当前已经有一个线程获取的锁, 我们要将这个变量丢弃, 于是变量从共享状态变为丢弃状态.
7. 获得锁的cpu, 修改变量, 这时变量的状态从共享状态变为修改状态. 然后重新协会到主内存, 在经过bus总线的时候, 所有cpu都被告知initFlag变脸已经被修改, 需要重新获取新的initFlag变量.
8. 当两个线程同时修改initFlag, 并同时抢到锁, 怎么办呢? 他们会同时告诉bus总线, 我抢到锁了, 由bus总线裁决, 到底有谁来执行.
这就是volatile为何能够保证可见性的原因. 原因就是加了lock标记,
问题1: 一个缓存行64个字节, 那如果有个对象是128个字节, 怎么办呢?
缓存行本身是可以保证原子性的, 但是如果一个变量是128字节, 那怎么办呢? 跨缓存行就不是原子的了, 不是原子的, 缓存一致性协议就搞不定了, 缓存一致性协议就升级为总线锁了 ,谁抢到谁赢.
问题2: 既然最终都可以总线锁解决问题, 为什么还要用总线裁决呢?
因为: 总线裁决速度快, 效率高, 只需要裁决一下. 但总线锁要锁很久, 效率低. 总线裁决比总线锁快的多得多. 多数情况下, 总线裁决是可以解决问题的. 很少会遇到超过64字节的变量
四. volatile为什么不能保证原子性呢?
缓存一致性协议, 不能对寄存器生效.
上面那句话是什么意思呢?
比如: cpu core0 从内存里读取了一个volatile变量 counter = 0, 然后将其从L1缓存总将变量加载到寄存器进行计算. 计算完写回到L1 缓存, 此时, 变量的状态是修改, 然后通知bus总线, 所有的cpu都会监测到counter变量已经被修改, 丢弃自己现有的变量. 比如 cpu core1 此时会丢弃counter = 0, 但是如果counter已经被读取到寄存器进行计算了. 即使在L1内存中的数据被丢弃, 获取到了新的counter值, 当寄存器计算完以后, 会重新回写到L1缓存, 此时会覆盖刚刚读取到的counter=1, 将自己计算的counter=1写入内存中.
L1缓存中的变量有两种赋值方式, 一种是从内存加载进来, 另一种是从寄存器回写过来的.
因为缓存一致性协议只能失效缓存行的数据, 而不能失效寄存器的数据, 导致volatile不能做到原子性.
--------------------------------------以下是课件内容----------------------------------------------
1.1 MESI协议缓存状态
状态
|
描述
|
监听任务
|
M 修改 (Modified)
|
该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。
|
缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。
|
E 独享、互斥 (Exclusive)
|
该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。
|
缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
|
S 共享 (Shared)
|
该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。
|
缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
|
I 无效 (Invalid)
|
该Cache line无效。
|
无
|
1.2 MESI状态转换
1.触发事件
触发事件
|
描述
|
本地读取(Local read)
|
本地cache读取本地cache数据
|
本地写入(Local write)
|
本地cache写入本地cache数据
|
远端读取(Remote read)
|
其他cache读取本地cache数据
|
远端写入(Remote write)
|
其他cache写入本地cache数据
|
2.cache分类:
状态
|
触发本地读取
|
触发本地写入
|
触发远端读取
|
触发远端写入
|
M状态(修改)
|
本地cache:M 触发cache:M 其他cache:I
|
本地cache:M 触发cache:M 其他cache:I
|
本地cache:M→E→S 触发cache:I→S 其他cache:I→S 同步主内存后修改为E独享,同步触发、其他cache后本地、触发、其他cache修改为S共享
|
本地cache:M→E→S→I 触发cache:I→S→E→M 其他cache:I→S→I 同步和读取一样,同步完成后触发cache改为M,本地、其他cache改为I
|
E状态(独享)
|
本地cache:E 触发cache:E 其他cache:I
|
本地cache:E→M 触发cache:E→M 其他cache:I 本地cache变更为M,其他cache状态应当是I(无效)
|
本地cache:E→S 触发cache:I→S 其他cache:I→S 当其他cache要读取该数据时,其他、触发、本地cache都被设置为S(共享)
|
本地cache:E→S→I 触发cache:I→S→E→M 其他cache:I→S→I 当触发cache修改本地cache独享数据时时,将本地、触发、其他cache修改为S共享.然后触发cache修改为独享,其他、本地cache修改为I(无效),触发cache再修改为M
|
S状态(共享)
|
本地cache:S 触发cache:S 其他cache:S
|
本地cache:S→E→M 触发cache:S→E→M 其他cache:S→I 当本地cache修改时,将本地cache修改为E,其他cache修改为I,然后再将本地cache为M状态
|
本地cache:S 触发cache:S 其他cache:S
|
本地cache:S→I 触发cache:S→E→M 其他cache:S→I 当触发cache要修改本地共享数据时,触发cache修改为E(独享),本地、其他cache修改为I(无效),触发cache再次修改为M(修改)
|
I状态(无效)
|
本地cache:I→S或者I→E 触发cache:I→S或者I →E 其他cache:E、M、I→S、I 本地、触发cache将从I无效修改为S共享或者E独享,其他cache将从E、M、I 变为S或者I
|
本地cache:I→S→E→M 触发cache:I→S→E→M 其他cache:M、E、S→S→I
|
既然是本cache是I,其他cache操作与它无关
|
既然是本cache是I,其他cache操作与它无关
|
下图示意了,当一个cache line的调整的状态的时候,另外一个cache line 需要调整的状态。
M
|
E
|
S
|
I
|
|
M
|
×
|
×
|
×
|
√
|
E
|
×
|
×
|
×
|
√
|
S
|
×
|
×
|
√
|
√
|
I
|
√
|
√
|
√
|
√
|
举个例子: 现在有2个long 型变量 a 、b,如果有t1在访问a,t2在访问b,而a与b刚好在同一个cache line中,此时t1先修改a,将导致b被刷新!
怎么解决伪共享?
Java8中新增了一个注解:@sun.misc.Contended。加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在jvm启动时设置 -XX:-RestrictContended 才会生效。
@sun.misc.Contended public final static class TulingVolatileLong { public volatile long value = 0L; //public long p1, p2, p3, p4, p5, p6; }
MESI优化和他们引入的问题
value = 3;
void exeToCPUA(){
value = 10;
isFinsh = true;
}
void exeToCPUB(){
if(isFinsh){
//value一定等于10?!
assert value == 10;
}
}
- 对于所有的收到的Invalidate请求,Invalidate Acknowlege消息必须立刻发送
- Invalidate并不真正执行,而是被放在一个特殊的队列中,在方便的时候才会去执行。
- 处理器不会发送任何消息给所处理的缓存条目,直到它处理Invalidate。
asw