Java并发之 volatile详解

本文深入探讨了Java中的volatile关键字,解释了其确保共享变量对所有线程的可见性,防止指令重排序,并分析了其在并发编程中的限制,如无法保证原子性。通过示例展示了volatile如何影响多线程环境中的变量读写,以及在双重检查锁定单例模式中的应用。此外,还讨论了内存屏障在禁止重排序中的作用,确保volatile的内存语义得以实现。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

volatile关键字的作用

  1. 保证被volatile修饰的共享 变量对所有线程总是可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
  2. 禁止指令重排序

volatile可见性

volatile的可见性作用,我们必须意思到被volatile修饰的变量对所有线程总是可见的,对volatile变量的所有写操作总是能立刻反应到其他线程的

public class VolatileVisibilitySample {
    volatile boolean initFlag = false;

    public void save(){
        this.initFlag = true;
        String threadName = Thread.currentThread().getName();
        System.out.println("线程:"+threadName +":修改共享变量的initFlag");
    }

    public void load(){
        String  threadName = Thread.currentThread().getName();

        while(!initFlag){
            //在此处循环,等待initFlag状态改变
        }
        System.out.println("线程:"+ threadName +"当前线程嗅到了initFlag的状态的改变");
    }

    public static void main( String[] args ) {
        VolatileVisibilitySample sample = new VolatileVisibilitySample();

        Thread threadA = new Thread(()->{
            sample.save();
        },"threadA");

        Thread threadB = new Thread(()->{
            sample.load();
        },"threadB");

        threadB.start();

        try {
            Thread.sleep(1000);
        }catch (Exception e){

        }
        threadA.start();
    }

}

测试结果:

  1. initFlag加上volatile关键字
    线程A改变initFlag属性之后,线程B马上感知到
  2. initFlag不加volatile关键字
    线程B感知不到

volatile无法保证原子性

代码示例如下:

public class AtomicDemo2 {
  private static volatile  int count = 0;
      public static void inc(){
          try {
              Thread.sleep(100);
          } catch (InterruptedException e) {
              e.printStackTrace();
          }
          count++ ;
      }
    public static void main( String[] args ) throws InterruptedException {
          for(int i=0;i<1000;i++){
              new Thread(AtomicDemo2::inc).start();

          }
        Thread.sleep(4000);

        System.out.println("运行结果:"+count);

    }

} 

运行结果
在这里插入图片描述
从JMM内存分析:
在这里插入图片描述
问题1: 为什么单个变量不用volatile修饰就会有问题?
这里说的单个变量不用volatile修饰的有问题的特指long和double类型修饰的变量(64bit)。在一些32位的处理器上,如果要求对64位数据的写操作具有原子性,会有比较大的开销,为了照顾这种处理器,Java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的写操作具有原子性。当JVM在这种处理器上运行时,可能会把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行。这两个32位的写操作可能会被划分到不同的总线事务中执行,此时对这个64位变量的写操作将不具有原子

问题2:为什么i++这种的用volatile修饰不能保证其原子性呢?
javap : 字节码查看
在这里插入图片描述
其实i++这种操作主要可以分为3步:(汇编)

  1. 读取volatile变量值到local
  2. 增加变量的值
  3. 把local的值写回,让其它的线程可见
mov    0xc(%r10),%r8d ; Load
inc    %r8d           ; Increment
mov    %r8d,0xc(%r10) ; Store
lock addl $0x0,(%rsp) ; StoreLoad Barrier

Load到store到内存屏障,一共4步,其中最后一步jvm让这个最新的变量的值在所有线程可见,也就是最后一步让所有的CPU内核都获得了最新的值,但中间的几步(从Load到Store)是不安全的,中间如果其他的CPU修改了值将会丢失。

示例代码2

public class VolatileVisibility {  
  public static volatile int i =0;   
    public static void increase(){ 
         i++;   
           } 
}

在并发场景下,i变量的任何改变都会立马反应到其他线程,但是如果存在多个线程同时调用 increase()方法的话,就会出现线程安全问题,毕竟i++操作并不具备原子性,该操作是 先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败。

因此对于increase方法必须使用synchronized修饰,以便保证线程安全,需要注意 的是一旦使用synchronized修饰方法后,由于synchronized本身也具备 与volatile相同的特性,即可见性,因此在这种情况下就完全可以省去volatile修饰变量。

volatile禁止重排序

硬件层的内存屏障

Intel硬件提供了一系列的内存屏障

  • Ifen,是一种Load Barrier读屏障
  • sfence,是一种store Barrier写屏障
  • mfence,是一种全能型的屏障,具备Ifence和sfnece的能力
  • Lock前缀,Lock不是 一种内存屏障但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。

JVM中提供了四类内存屏障指令

  • LoadLoad–Load1; LoadLoad; Load2: 保证load1的读取操作在load2及后续读取操作之前执行
  • StoreStore–(Store1; StoreStore; Store2): 在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存。
  • LoadStore(Load1; LoadStore; Store2):在store2及其后的写操作执行前,保证load1的读操作已读取结束
  • StoreLoad(Store1; StoreLoad; Load2):保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行

内存屏障

内存屏障是一个CPU指令,作用有两个:

  1. 保证特定操作的执行顺序。
  2. 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

由于编译器和处理器都能执行指令重排序,如果在指令间插入一条Memory barrier则会告诉编译器和CPU,不管什么指令都不能和 这条Memory Barrier指令重排序 ,也就是说通过插入内存屏障禁止再内存屏障前后的指令执行重排序优化。

Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能 读取到 这些数据 的 最新版本。

禁止重排序的例子

双重加锁单例模式代码
public class DoubleCheckLock {
    private volatile static DoubleCheckLock instance;
    private DoubleCheckLock(){}
    public static DoubleCheckLock getInstance(){
        //第一次检测
        if (instance==null){
            //同步
            synchronized (DoubleCheckLock.class){
                if (instance == null){
                    //多线程环境下可能会出现问题的地方
                    instance = new  DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

这段代码在单线程环境下并没有什么问题,但是如果在多线程环境下就会 出现安全问题。原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能还没有初始化 。

instance = new DoubleCheckLock() 可以分为以下3步骤完成

  1. memory = allocate() 分配对象内存空间

  2. instance(memory) 初始化对象)

  3. instance = memory 设置instance指向刚分配的内存地址,此时instance != null

上面的步骤有可能重排序

1.memory = allocate() 分配对象内存空间。
3.instance = memory 设置instance指向刚分配的内存地址,此时instance != null。
2.instance(memory) 初始化对象。

由于2和3之间不存在数据依赖关系,而且无论重排序 前还是重排序后程序的执行结果在单线程并没有改变,因此这种重排序优化是允许的。

但是指令重排只会保证 串行语义的执行的 一致性(单线程),单并不会关心 多线程间的语义一致性。

所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。

那么如何解决呢?很简单,我们使用volatile禁止instance变量被执行指令重排优化即可;

private volatile static DoubleCheckLock instance;
重排序对多线程的影响

在这里插入图片描述
在这里插入图片描述

volatile内存语义

volatile重排序的规则表

在这里插入图片描述
当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。

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

当第一个操作是 volatile 写,第二个操作是volatile读或写时,不能重排序

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。

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

volatile写插入内存屏障后生成的指令序列示意图

在这里插入图片描述

  1. StoreStore屏障
  • 上面的StoreStore屏障可以保证在volatle写之前,其前面的所有普通写操作已经对任意处理器可见了
  • 这里的StoreStore屏障将保障上面所有的普通写在volatile写 之前刷新到主内存
  1. StoreLoad屏障
  • 此屏障的作用是避免volatile写后面可能有volatile读/写操作的重排序。因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障。
  • 为了能保证正确实现volatile的内存语义,JMM在采取了保守策略,在每个volatile写的后面,或者在每个volatile读的前面插入一个StroeLoad屏障。
  • 从整体执行效率的角度考虑,JMM最终选择了在每个volatile写的后面插入一个StoreLoad屏障。
  • 因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个 读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

volatile读插入内存屏障

在这里插入图片描述
LoadLoad屏障用来禁止处理器把上面的 volatile 读与下面的普通读重排序。

LoadStore屏障用来禁止处理器把上面的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()方法,编译器在生成字节码时可以做如下的优化。在这里插入图片描述
注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编 译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插 入一个StoreLoad屏障。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

半夏_2021

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

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

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

打赏作者

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

抵扣说明:

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

余额充值