volatile底层实现原理详解

大家都知道生产中可以使用 volatile 达到保证可见性和指令重排的目的。但是对其实现原理并不是很清楚,为了加深学习和理解感觉很有必要来写篇博客总结一下。

JMM—java 内存模型

想知道 volatile 实现原理首先得去了下解 JMM,我们都知道 JVM 会为每一个 thread 开辟一块自己的工作空间,在我们操作变量时是从主内存拿到变量的一个副本,然后对副本进行操作后再刷新到主内存中这么一个总体的流程。

img

先简单来看一下如果要改变一个变量值需要经过哪些操作:
\1. 首先会执行一个 read 操作将主内存中的值读取出来
\2. 执行 load 操作将值副本写入到工作内存中
\3. 当前线程执行 user 操作将工作内存中的值拿出在经过执行引擎运算
\4. 将运算后的值进行 assign 操作写会到工作内存。
\5. 线程将当前工作内存中新的值存储回主内存,注意只是此处还没有写入到主内存的共享变量,主内存中的值还没有改变。
\6. 最后一步执行 write 操作将主内存中的值刷新到共享变量,到此处一个简单的流程就走完了。

下图的 8 种操作是定义在 java 内存模型当中的,我们的任何操作都需要通过这几种方式来进行。

img

简单看了一下操作流程后继续回到 volatile 关键字,在多个个线程工作内存看起来互无关联的情况下是怎么做到保证变量的可见性的?

这里我们不得不先去了解一个名词:总线 ------ 什么是总线?它是干什么的?

度娘给出的解释: 由于总线是连接各个部件的一组信号线。通过信号线上的信号表示信息,通过约定不同信号的先后次序即可约定操作如何实现。简单来说就是我们的 cpu 和内存进行交互就得通过总线,它们不能隔空产生连接。总线就是一条共享的通信链路,它用一套线路来连接多个子系统。

总线按功能和规范可分为五大类型:

  • 数据总线(Data Bus):在 CPU 与 RAM 之间来回传送需要处理或是需要储存的数据。
  • 地址总线(Address Bus):用来指定在 RAM(Random Access Memory)之中储存的数据的地址。
  • 控制总线(Control Bus):将微处理器控制单元(Control Unit)的信号,传送到周边设备。
  • 扩展总线(Expansion Bus):外部设备和计算机主机进行数据通信的总线,例如 ISA 总线,PCI 总线。
  • 局部总线(Local Bus):取代更高速数据传输的扩展总线。

最初实现就是通过总线加锁的方式也就是上面的 lock 与 unlock 操作,但是这种方式存在很大的弊端。会将我们的并行转换为串行,从而失去了多线程的意义。这里不详细展开了解一下即可。下面才是我们真正需要认识的

MESI 缓存一致性协议:

CPU 在摩尔定律的指导下以每 18 个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及 CPU。这就造成了高性能能的内存和硬盘价格及其昂贵。然而 CPU 的高度运算需要高速的数据。为了解决这个问题,CPU 厂商在 CPU 中内置了少量的高速缓存以解决 I\O 速度和 CPU 运算速度之间的不匹配问题。为了解决这个问题 CPU 厂商采用了缓存的解决方案,知道目前我们正在使用的多级的缓存结构。我们可以到任务管理器看一下:

img

目前流行的多级缓存结构:

img

多核 CPU 的情况下有多个一级缓存,如何保证缓存内部数据的一致, 不让系统数据混乱。这里就引出了一个一致性的协议 MESI。这里我们大概只需要有这么一个概念就可以。而当我们共享变量用 volatile 修饰后就会帮我们在总线开启一个 MESI 缓存协议。同时会开启 CPU 总线嗅探机制 (监听),当其中一个线程将修改后的值传输到总线时,就会触发总线的监控告诉其他正在使用当前共享变量的线程使当前工作内存中的值失效。这个时候工作空间中的副本变量就没有了,从而需要重新去获取新的值。

底层实现主要是通过汇编 Lock 前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)并写回到主内存。总的来说就是 Lock 指令会将当前处理器缓存行数据立即写回到系统内存从而保证多线程数据缓存的时效性。这个写回内存的操作同时会引起在其它 CPU 里缓存了该内存地址的数据失效(MESI 协议)。

为了保证在从工作内存刷新回主内存这个阶段主内存数据的安全性,在 store 前会使用内存模型当中的 lock 操作来锁定当前主内存中的共享变量。当主内存变量在 write 操作后才会将当前 lock 释放掉,别的线程才能继续进来获取新的值。

查看 Java 的汇编指令: 想要实际去看下底层的汇编指令,需要在 jre/bin 目录下添加额外的两个包
下载链接:百度网盘链接,提取码:d753

img

然后配置 idea,在 VM options 选项中输入:-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,* 类名. 方法名或者 - XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly 就可以在控制台看到输出的 lock 汇编指令。

img

我们都知道 volatile 并不能保证原子性,到这里也可以解释通为什么了。假设当前有两个线程同时对共享变量进行 + 1 运算,Thread1 比 Thread2 先进行了 Lock 操作拿到了锁,此时由于我们的总线嗅探机制 Thread2 就会知道共享变量值已经修改过了,从而导致当前 Thread2 工作内存中的副本变量失效。只能再次去主内存中取新的值,但这样无形之中 Thread2 就已经浪费掉了一次操作机会。从而导致最终结果小于预期的情况出现。(比如最常用到的那种两个线程同时对一个 volatile 修饰的 int 进行加减运算的例子)

比如:for( int i=0; i<10; i++ )中,由于浪费了机会,thread2第一次拿到i=2却没有作加操作时,下一次i变成3了,又重新从主存拿到i=3,就从i=3开始加了,第二次循环就被浪费掉了

提示:
如 long a = 100L long b = a+1
在这里 a+1 并不是我们想象中的原子操作因为 long 在 java 中占 8 个子节一个 64 位写操作实际上将会被拆分为 2 个 32 位的操作,这一行为的直接后果将会导致最终的结果是不确定的并且缺少原子性的保证。
在 Java 虚拟机规范中同样也有类似的描述:“For the purposesof the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each32-bit half. This can result in a situation where a thread sees the first 32 bitsof a 64-bit value from one write, and the second 32 bits from anotherwrite.”

翻译:对于 Java 编程语言内存模型来说,对非易失性长值或双值的一次写操作被视为两次单独的写操作: 一次写 32 位的一半。这可能导致这样一种情况,一个线程看到一个 64 位值的前 32 位从一个写,和第二个 32 位从另一个写。

官网地址

指令重排

在之前很经典的单例设计模式中为了防止 DCL 在指令重排后导致线程不安全的情况,就使用了 volatile 来防止指令重排。

我们知道为了提高程序执行的性能,编译器和执行器 (处理器) 通常会对指令做一些优化(重排序)。volatile 通过内存屏障实现了防止指令重排的目的。同时 lock 前缀指令相当于一个内存屏障,它会告诉 CPU 和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。内存屏障另一个作用是强制更新一次不同 CPU 的缓存。例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个 cpu 核心或者哪颗 CPU 执行的。

不同硬件实现内存屏障的方式不同,Java 内存模型屏蔽了这种底层硬件平台的差异,由 JVM 来为不同的平台生成相应的机器码。
Java 内存屏障主要有 Load 和 Store 两类:

  • 对 Load Barrier 来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据
  • 对 Store Barrier 来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存
    为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。然而,对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java 内存模型采取保守策略:
  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

转自:java基础—volatile底层实现原理详解

synchronized与volatile对比

synchronized(不做过多解释)

同步块大家都比较熟悉,通过 synchronized 关键字来实现,所有加上synchronized 和 块语句,在多线程访问的时候,同一时刻只能有一个线程能够用

synchronized 修饰的方法 或者 代码块。

volatile

用volatile修饰的变量,线程在每次使用变量的时候,都会读取变量修改后的最的值。volatile很容易被误用,用来进行原子性操作。

可见性例子

下面写个程序:

public class VolatileTest extends Thread {

    private boolean flag = false;

    private int i = 1;

    @Override
    public void run() {
        while (!flag) {
            i++;
        }
    }

    public static void main(String[] args) throws Exception {
        VolatileTest vt = new VolatileTest();
        vt.start();
        Thread.sleep(2000);
        vt.flag = true;
        System.out.println("result: " + vt.i);
    }
}
result: 1357713240
主线程结束

该例子,main(主线程)方法执行完了,程序并未结束运行,说明while循环一直没有退出,虽然设置了flagtrue,但是由于没有可见性,vt线程flag还是false

改造一下:

public class VolatileTest extends Thread {

    private volatile boolean flag = false;

    private int i = 1;

    @Override
    public void run() {
        while (!flag) {
            i++;
        }
        System.out.println("vt线程结束");
    }

    public static void main(String[] args) throws Exception {
        VolatileTest vt = new VolatileTest();
        vt.start();
        Thread.sleep(2000);
        vt.flag = true;
        System.out.println("result: " + vt.i);
        System.out.println("主线程结束");
    }
}
result: 1438470120
主线程结束
vt线程结束

此时由于加了volatile,则有了可见性,则vt线程判断到true,结束了循环。vt线程可以成功结束

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值