volatile底层原理

前言

volatile是轻量级的synchronized,是Java内部的一个轻量级锁

volatile具有三大特性

  1. 可见性:在多线程中保证了共享变量的“可见性”。(当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。)
  2. 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
  3. 禁止指令重排:volatile变量读/写操作会加“内存屏障”,防止指令重排。

可见性

volatile可以保证可见性,那它是如何保证的呢?

在此之前,先了解一下Java内存模型(JMM),JMM决定一个线程对共享变量的写入何时对另一个线程可见

多线程中,内存主要分为两种,一个是系统内存(主内存,线程之间的共享变量存储在主内存(Main Memory)中),一个是线程内部的本地内存(每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本)。

如图所示(Java内存模型的抽象结构):

image-20230707225640652

线程A与线程B之间要通信的话,必须要经历下面2个步骤。

  1. 线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 线程B到主内存中去读取线程A之前已更新过的共享变量。

如图:

image-20230707225844741

通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证

OK!了解完Java内存模型,我们来看下volatile到底是如何保证可见性的!

与volatile实现原理相关的CPU术语

image-20230705112327304

通过工具获取JIT编译器生成的汇编指令来查看对volatile进行写操作时,CPU会做什么事情。

Java代码:

// 变量由volatile修饰
private volatile Singleton instance = new Singleton();

转变成汇编代码,如下:

0x01a3de1d: movb $0×0,0×1104800(%esi);
0x01a3de24: lock addl $0×0,(%esp);

由volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码

Lock前缀的汇编指令在多核处理器下会引发了两件事情:

  1. 将当前处理器缓存行的数据写回到系统内存。(相当于将线程里的本地内存数据刷回主内存)
  2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效(使其他线程中本地缓存中该内存地址的数据无效)。

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。

声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存

就算将该线程处理过后的数据写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议MESI

每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里,就能保证每次处理的数据都是最后一次被写入的都是最新的。

volatile的两条实现原则:

  1. Lock前缀指令会引起处理器缓存回写到内存。Lock前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该信号期间,处理器可以独占任何共享内存。LOCK#信号一般不锁总线,而是锁缓存,锁总线开销的比较大。如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反,它会锁定这块内存区域的缓存并回写到内存,并用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”缓存一致性机制会阻止同时修改两个以上处理器缓存的内存区域数据。
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效。处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充(重新读取系统内存数据)。

总而言之,volatile变量被操作时,会第一时间把操作后的数据从线程本地内存刷会主内存,并使得其他线程该地址数据无效,其他线程下次操作该数据时,发现该数据无效会重新读取主内存数据,因而保证volatile变量在多线程中的可见性。

小demo:

public class VolatileDemo {

    private volatile int num = 0;

    public void startThread() {
        Runnable runnable = () -> {
            /*
                num变量不加volatile修饰,子线程永远不会停止,主线程修改num值,子线程无法感知num发生变化;
                一旦num加上volatile修饰,只要其他线程修改num值,子线程立马就能感知到,程序直接结束
             */
            while (num == 0) {
            }
        };

        // 开启一个线程
        new Thread(runnable).start();
    }

    public void updateNum() {
        num = 1;
    }

    public static void main(String[] args) {
        VolatileDemo demo = new VolatileDemo();
        demo.startThread();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        // 等待一秒,主线程修改num值
        demo.updateNum();
    }
}

原子性

对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步

class VolatileFeaturesDemo {
    volatile long vl = 0L;

    // 单个volatile变量的写
    public void set(long l) {
        vl = l;
    }

    // 复合(多个)volatile变量的读/写
    public void getAndIncrement() {
        vl++;
    }

    // 单个volatile变量的读
    public long get() {
        return vl;
    }
}

上面程序在语义上和下面程序等价。

class VolatileFeaturesExample {
    long vl = 0L;

    // 对单个的普通变量的写用同一个锁同步	
    public synchronized void set(long l) {  
        vl = l;
    }

    public void getAndIncrement() {        // 普通方法调用
        long temp = get();                  // 调用已同步的读方法
        temp += 1L;                         // 普通写操作
        set(temp);                          // 调用已同步的写方法
    }

    // 对单个的普通变量的读用同一个锁同步
    public synchronized long get() {        
        return vl;
    }
}

如上例子,一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都是使用同一个锁来同步,它们之间的执行效果相同。

锁的语义决定了临界区代码的执行具有原子性。volatile变量,对该变量的读/写就具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性

public class VolatileDemo1 {

    private static volatile int num = 0;

    public static void increase() {
        num++;
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            // 创建一百个线程,每个线程数字+1000
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    increase();
                }
            }).start();
        }

        // 确保10个子线程执行完毕
        while (Thread.activeCount() > 2) { // main gc
            Thread.yield();
        }

        // 期望10000,实际上到不了
        System.out.println(num);
    }
}

加Lock锁或者是synchronized可以保证increase方法的原子性,可以得到预期结果,这种情况还可以使用原子类(CAS

重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

重排序分3种类型。

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如图:

image-20230708005024752

1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。

对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。

对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。

JMM把内存屏障指令分为4类:

image-20230708005522087

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性

数据依赖分为下列3种类型:

image-20230708003336859

上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。

编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

单线程情况

单线程情况下,具有数据依赖性的操作,重排序会影响结果,因此不能重排序;如果操作之间不存在数据依赖关系(重排序不影响最后的结果),这些操作就可能被编译器和处理器重排序。

多线程情况

多线程情况下,单一线程时,没有数据依赖关系操作的重排序可能会影响最后多线程的结果,导致多线程得不到预期结果。

如下例子:

class ReorderExample {
    int a = 0;
    boolean flag = false;

    public void writer() {
        a = 1;                  // 1
        flag = true;            // 2
    }

    public void reader() {
        if (flag) {            // 3
            int i = a * a;     // 4
               
        }
    }
}

假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。

会有下面这种情况出现:

image-20230708005732036

操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入,在这里多线程程序的语义被重排序破坏了

为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。

重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型

volatile重排序规则表

image-20230708010410473

由表可知:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

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

基于保守策略的JMM内存屏障插入策略。

  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

插入内存屏障,保证指令不会被重排,保证语意的正确性

volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

class VolatileBarrierExample {
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;

    void readAndWrite() {
        int i = v1;      // 第一个volatile读
        int j = v2;      // 第二个volatile读
        a = i + j;       // 普通写
        v1 = i + 1;      // 第一个volatile写
        v2 = j * 2;      // 第二个volatile写
    }
}

针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化。

image-20230708011819453

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
### 回答1: Volatile是一种Java中的关键字,用于标识变量是易变的,即该变量的值可能会在不同的线程中发生改变。Volatile底层原理涉及到Java内存模型。 Java内存模型定义了线程如何与内存交互以及线程之间如何共享内存。Java内存模型将内存分为主内存和线程工作内存。主内存是所有线程共享的内存区域,而线程工作内存是每个线程独立拥有的内存区域。 当一个线程访问一个volatile变量时,它会从主内存中读取最新的值。而当一个线程更新一个volatile变量时,它会将新的值立即写入主内存中。这保证了所有线程对volatile变量的读写操作都是可见的。 此外,volatile还具有禁止指令重排序的作用。在多线程并发编程中,编译器为了提高程序执行效率可能会对指令顺序进行重排序,但是这种重排序可能会导致并发问题。使用volatile可以禁止编译器对volatile变量的指令进行重排序,保证了程序的正确性。 总之,volatile底层原理是基于Java内存模型的,它保证了多线程环境下对volatile变量的可见性和禁止指令重排序的特性。 ### 回答2: Volatile是Java中的关键字之一,用于修饰变量,主要用于多线程编程中,以保证线程间变量的可见性和顺序性。 Volatile底层原理主要是通过内存屏障(Memory Barrier)和禁止重排序来实现的。内存屏障是一种CPU指令,能够强制刷新处理器缓存并保证读/写操作顺序的一致性。当一个线程修改了一个被volatile修饰的变量的值时,会立即将该值刷新到主内存,并通知其他线程对对应变量的缓存失效,强制其他线程从主内存重新读取最新值。 此外,volatile还可以禁止指令重排,保证代码的有序执行。在有volatile修饰的变量之前的指令一定会在其后的指令之前执行。这样可以避免了由于指令重排导致的数据不一致问题。 总之,Volatile底层原理主要通过内存屏障以及禁止指令重排来保证线程间变量的可见性和顺序性。它能够确保一个变量在多个线程之间的可见性,尤其用于一个线程修改了变量值时,其他线程能够立即感知到变量的变化,并从主内存中重新读取最新值,从而避免了线程间数据不一致的问题。同时,它还通过禁止指令重排,保证了代码的有序执行,避免了由于指令重排导致的逻辑错误。因此,在多线程编程中,合理使用Volatile关键字能够确保程序的正确性和稳定性。 ### 回答3: Volatile是Java中的关键字,用于修饰变量。它的底层原理是通过禁止线程内部的缓存变量副本,直接访问主存中的变量值,保证了多线程环境中的可见性和有序性。下面详细解释其底层原理。 在多线程环境下,每个线程都有自己的工作内存(线程的私有内存),存放变量的副本。由于性能原因,线程在执行操作时,通常会先将变量从主存中读取到工作内存中进行操作,然后再将修改的结果写回主存。这种操作称为“读写操作的优化”。 当一个变量被volatile修饰时,它的读写操作会具有特殊的语义。当一个线程对volatile修饰的变量进行写操作时,它会首先将值写入工作内存,然后立即刷新到主存中,并且通知其他线程该变量的值已经被修改。而当一个线程对volatile变量进行读操作时,它会立即从主存中读取最新的值,并且在读之前使自己的工作内存失效,以保证读操作获取的是最新值。 这种特殊的语义使得volatile能够保证多线程环境下的可见性和有序性。通过禁止线程内部的缓存变量副本,保证了每个线程对volatile变量的读写操作都是基于主存中最新的值,从而避免了数据不一致的问题。同时,由于读操作会使工作内存失效,写操作会立即刷新到主存,保证了变量的修改对其他线程的可见性和顺序性。 总结起来,volatile底层原理是通过禁止线程内部的变量副本,直接访问主存中的变量值,保证了在多线程环境下的可见性和有序性。它对于一些简单的变量操作可以替代锁,同时也可以用于线程间的通信,但并不能保证原子性。因此,在使用volatile时,需要根据具体的场景和需求来判断是否合适。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不进大厂不改名二号

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值