写点什么

volatile 关键字最全原理剖析

  • 2024-11-16
    福建
  • 本文字数:2647 字

    阅读完需:约 9 分钟

介绍


volatile 是轻量级的同步机制,volatile 可以用来解决可见性和有序性问题,但不保证原子性。


volatile 的作用:


  1. 保证了不同线程对共享变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

  2. 禁止进行指令重排序。


底层原理


内存屏障


volatile 通过内存屏障来维护可见性和有序性,硬件层的内存屏障主要分为两种 Load Barrier,Store Barrier,即读屏障和写屏障。对于 Java 内存屏障来说,它分为四种,即这两种屏障的排列组合。


  1. 每个 volatile 写前插入 StoreStore 屏障;为了禁止之前的普通写和 volatile 写重排序,还有一个作用是刷出前面线程普通写的本地内存数据到主内存,保证可见性;

  2. 每个 volatile 写后插入 StoreLoad 屏障;防止 volatile 写与之后可能有的 volatile 读/写重排序;

  3. 每个 volatile 读后插入 LoadLoad 屏障;禁止之后所有的普通读操作和 volatile 读操作重排序;

  4. 每个 volatile 读后插入 LoadStore 屏障。禁止之后所有的普通写操作和 volatile 读重排序;


插入一个内存屏障,相当于告诉 CPU 和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。对一个 volatile 字段进行写操作,Java 内存模型将在写操作后插入一个写屏障指令,这个指令会把之前的写入值都刷新到内存。


可见性原理


当对 volatile 变量进行写操作的时候,JVM 会向处理器发送一条 Lock#前缀的指令



而这个 LOCK 前缀的指令主要实现了两个步骤:


  1. 将当前处理器缓存行的数据写回到系统内存;

  2. 将其他处理器中缓存了该数据的缓存行设置为无效。


原因在于缓存一致性协议,每个处理器通过总线嗅探和 MESI 协议来检查自己的缓存是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存中。


缓存一致性协议:当 CPU 写数据时,如果发现操作的变量是共享变量,即在其他 CPU 中也存在该变量的副本,会发出信号通知其他 CPU 将该变量的缓存行置为无效状态,因此当其他 CPU 需要读取这个变量时,就会从内存重新读取。


总结一下:


  1. 当 volatile 修饰的变量进行写操作的时候,JVM 就会向 CPU 发送 LOCK#前缀指令,通过缓存一致性机制确保写操作的原子性,然后更新对应的主存地址的数据。

  2. 处理器会使用嗅探技术保证在当前处理器缓存行,主存和其他处理器缓存行的数据的在总线上保持一致。在 JVM 通过 LOCK 前缀指令更新了当前处理器的数据之后,其他处理器就会嗅探到数据不一致,从而使当前缓存行失效,当需要用到该数据时直接去内存中读取,保证读取到的数据时修改后的值。


有序性原理


volatile 的 happens-before 关系


happens-before 规则中有一条是 volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。


//假设线程A执行writer方法,线程B执行reader方法class VolatileExample {    int a = 0;    volatile boolean flag = false;        public void writer() {        a = 1;              // 1 线程A修改共享变量        flag = true;        // 2 线程A写volatile变量    }         public void reader() {        if (flag) {         // 3 线程B读同一个volatile变量        int i = a;          // 4 线程B读共享变量        ……        }    }}
复制代码


根据 happens-before 规则,上面过程会建立 3 类 happens-before 关系。


  • 根据程序次序规则:1 happens-before 2 且 3 happens-before 4。

  • 根据 volatile 规则:2 happens-before 3。

  • 根据 happens-before 的传递性规则:1 happens-before 4。



因为以上规则,当线程 A 将 volatile 变量 flag 更改为 true 后,线程 B 能够迅速感知。


volatile 禁止重排序


为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序。


Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。

JMM 会针对编译器制定 volatile 重排序规则表。



为了实现 volatile 内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。


对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守的策略。


  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。

  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。

  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。

  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。


volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。




为什么不能保证原子性


在多线程环境中,原子性是指一个操作或一系列操作要么完全执行,要么完全不执行,不会被其他线程的操作打断。


volatile 关键字可以确保一个线程对变量的修改对其他线程立即可见,这对于读-改-写的操作序列来说是不够的,因为这些操作序列本身并不是原子的。考虑下面的例子:


public class Counter {    private volatile int count = 0;        public void increment() {        count++; // 这实际上是三个独立的操作:读取count的值,增加1,写回新值到count    }}
复制代码


在这个例子中,尽管 count 变量被声明为 volatile,但 increment()方法并不是线程安全的。当多个线程同时调用 increment()方法时,可能会发生以下情况:


  1. 线程 A 读取 count 的当前值为 0。

  2. 线程 B 也读取 count 的当前值为 0(在线程 A 增加 count 之前)。

  3. 线程 A 将 count 增加到 1 并写回。

  4. 线程 B 也将 count 增加到 1 并写回。


在这种情况下,虽然 increment()方法被调用了两次,但 count 的值只增加了 1,而不是期望的 2。这是因为 count++操作不是原子的;它涉及到读取 count 值、增加 1、然后写回新值的多个步骤。在这些步骤之间,其他线程的操作可能会干扰。


为了保证原子性,可以使用 synchronized 关键字或者 java.util.concurrent.atomic 包中的原子类(如 AtomicInteger),这些机制能够保证此类操作的原子性:


public class Counter {    private AtomicInteger count = new AtomicInteger(0);        public void increment() {        count.getAndIncrement(); // 这个操作是原子的    }}
复制代码


在这个修改后的例子中,使用 AtomicInteger 及其 getAndIncrement()方法来保证递增操作的原子性。这意味着即使多个线程同时尝试递增计数器,每次调用也都会正确地将 count 的值递增 1。


文章转载自:seven97_top

原文链接:https://www.cnblogs.com/seven97-top/p/18438306

体验地址:http://www.jnpfsoft.com/?from=infoq

用户头像

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
volatile关键字最全原理剖析_字段_快乐非自愿限量之名_InfoQ写作社区