Java volatile关键字最全总结:原理剖析与实例讲解

2023年 10月 9日 55.8k 0

简介

volatile是java提供的一种轻量级的同步机制。

java语言中包含两种内在同步机制:同步块(方法)和volatile变量,相比synchronized(通常称为重量级的锁),volatile更加的轻量级,因为不会引起线程的上下文切换和调度。

但是volatile 变量的同步性较差(有时候再不同的场景下它更简单并且开销更低),而且其使用也更容易出错。

并发编程的3个特性

  • 原子性

    定义: 即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

    原子性是拒绝多线程操作的,不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作。简而言之,在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。例如 a=1是原子性操作,但是a++和a +=1就不是原子性操作。

    Java中的原子性操作包括:

    (1)基本类型的读取和赋值操作,且赋值必须是值赋给变量,变量之间的相互赋值不是原子性操作。

    (2)所有引用reference的赋值操作。

    (3)java.concurrent.Atomic.* 包中所有类的一切操作。

  • 可见性

    定义:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

    在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。Java提供了volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。当然,synchronize和Lock都可以保证可见性。synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

  • 有序性

    定义: 即程序执行的顺序按照代码的先后顺序执行。

    Java内存模型中的有序性可以总结为:

  • 如果在本线程内观察,所有操作都是有序的;即线程内表现为串行语义
  • 如果在一个线程中观察另一个线程,所有操作都是无序的。是指“指令重排序”现象和“工作内存和主内存同步延迟”现象。
  • 在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序不会影响单线程的运行结果,但是对多线程会有影响。Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。另外,可以通过synchronized和Lock来保证有序性,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

    锁的互斥性和可见性

    锁的两种主要的特征互斥和可见性

    互斥:一次只允许一个线程拥有锁,只可以让一个线程使用这个共享数据

    可见性:就是当释放锁的时候对共享数据的修改,在下一个线程获得锁后是可以见到的;在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的,如果没有同步机制提供可见性的保证,线程锁看到的共享数据可能是旧的值或者是已经修改的值也可能是不确定的值,这就会导致很多的线程麻烦

    这也是volatile的作用提供同步机制;

    volatile变量的特性

    1.保证可见性,不保证原子性

    (1)当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去。

    (2)这个写操作会导致其他线程中的volatile变量缓存无效。

    2.禁止指令重排

    重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。重排序需要遵守一定规则:

    (1)重排序操作不会对存在数据依赖关系的操作进行重排序。

      比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。

    (2)重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变

      比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系, 所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。

    public class volatileTest{
      int a = 1;
      boolean status = false;
      //状态切换为true
      public void changeStatus{
        a = 2;   //1
        status = true;  //2
      }
      //若状态为true,则为running
      public void run(){
        if(status){   //3
          int b = a + 1;  //4
          System.out.println(b);
        }
      }
    }
    

    重排序在单线程下一定能保证结果的正确性,但是在多线程环境下,可能发生重排序,影响结果,在上面changeStatus方法中1和2都是没有依赖关系,所有是可能发生重排序的,导致在run里面运行的时候 a还是等于1,所以b就可能等于2

    可以通过volatile关键字解决,主要就是执行到volatile变量时,其前面的所有语句都执行完,后面所有语句都未执行。且前面语句的结果对volatile变量及其后面语句可见。

    volatile原理

    volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用 “内存屏障” 来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

    (1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

    (2)它会强制将对缓存的修改操作立即写入主存;

    (3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

    JVM中采用了四类内存屏障指令:

    屏障类型 指令示例 说明
    LoadLoad Load1; LoadLoad; Load2 保证load1的读取操作在load2及后续读取操作之前执行
    StoreStore Store1; StoreStore; Store2 在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存
    LoadStore Load1; LoadStore; Store2 在stroe2及其后的写操作执行前,保证load1的读操作已读取结束
    StoreLoad Store1; StoreLoad;Load2 保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行

    volatile读的JMM内存屏障插入策略:

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

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

    volatile111.png

    图中的loadStore屏障是禁止下面的所有写操作和上面的volatile读重排序

    volatile写的JMM内存屏障插入策略:

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

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

    java内存模型

    Java内存模型由Java虚拟机规范定义,用来屏蔽各个平台的硬件差异。简单来说:

    • 所有变量储存在主内存。
    • 每条线程拥有自己的工作内存,其中保存了主内存中线程使用到的变量的副本。
    • 线程不能直接读写主内存中的变量,所有操作均在工作内存中完成

    volatile222.png

    内存间的交互操作有很多,和volatile有关的操作为:

    • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
    • load(载入): 作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
    • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
    • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
    • store(存储) :作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
    • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

    volatile的应用场景

    什么时候用?

    • 条件一: 写入变量时并不依赖变量的当前值;或者能够确保只有单一线程能够修改变量的值
    • 条件二: 变量不需要与其他的状态变量共同参与不变约束
    • 条件三: 变量访问不需要额外加锁

    通俗点: 当一个变量依赖其他变量或变量的新值依赖旧值时,不能用volatile

    volatile使用场景

    适用场合:多个线程读,一个线程写的场合 使用场景:通常被作为标识完成、中断、状态的标记,值变化应具有原子性 充分利用其可见性:即volatile能够保证在读取的那个时刻读到的肯定是最新值 重点: volatile主要使用的场合是在多线程中可以感知实例变量被变更了,并且可以获得最新的值使用,也就是用多线程读取共享变量时可以获得最新值使用,但不能保证你在使用最新值过程中最新值不发生变化!很可能在使用之后,最新值已经变更。原数据变成过期数据,这时候就会出现数据不一致(非同步)的问题

    下面是一个案例:

    两个线程访问共享变量时,值不同步(线程一需等待线程二数据准备好之后再执行业务逻辑,实际却是线程一死循环了)

    public class volatileDemo22 {
        //两个线程访问共享变量时,值不同步(线程一需等待线程二数据准备好之后再执行业务逻辑,实际却是线程一死循环了)
            private static boolean initFlag = false;//共享变量
            public static void main(String[] args) throws InterruptedException {
                //做业务的线程
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        System.out.println("等待数据。。。。");
                        while (!initFlag){
                        }
                        System.out.println("工作完成了");
                    }
                }).start();
                //保证第一个线程先执行
                Thread.sleep(2000);
                //准备数据的线程
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        System.out.println("开始准备数据。。。");
                        initFlag = true;
                        System.out.println("数据准备完毕。。。");
                    }
                }).start();
            }
    }
    

    运行结果:

    等待数据。。。。 开始准备数据。。。 数据准备完毕。。。 (程序就一直卡在这里了)

    原因:线程一在一直等待线程二准备数据就是修改initFlag,其实已经修改了,但是线程一拿不到更新的值,一直拿到的是线程一里面工作内存的值,而不是主内存里面更新的值,因为多线程中,共享变量,不可见

    解决方案:

    把共享变量通过volatile关键字修饰:

    public class volatileDemo22 {
        //两个线程访问共享变量时,值不同步(线程一需等待线程二数据准备好之后再执行业务逻辑,实际却是线程一死循环了)
        // 添加volatile关键字,让共享变量在多线程中可见
            private volatile static boolean initFlag = false;//共享变量 
            public static void main(String[] args) throws InterruptedException {
                //做业务的线程
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        System.out.println("等待数据。。。。");
                        while (!initFlag){
                        }
                        System.out.println("工作完成了");
                    }
                }).start();
                //保证第一个线程先执行
                Thread.sleep(2000);
                //准备数据的线程
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        System.out.println("开始准备数据。。。");
                        initFlag = true;
                        System.out.println("数据准备完毕。。。");
                    }
                }).start();
            }
    }
    

    运行结果:

    等待数据。。。。 开始准备数据。。。 数据准备完毕。。。 工作完成了

    成功运行,主要就是在多线程时,让volatile修饰的变量能够让每个线程都能看见,即可见性。

    单例模式双重校验锁变量为什么使用 volatile 修饰?

    禁止 JVM 指令重排序,保证各线程安全

    在单例模式中,双重校验锁是一种常用的线程安全实现方式。在这种实现方式中,需要使用volatile关键字来修饰单例对象的变量,以保证线程安全。

    原因是:双重校验锁中的第一次判空操作可能会出现指令重排的情况,导致多个线程同时进入第一个if语句块中,从而创建多个实例。使用volatile关键字可以禁止指令重排,保证单例对象的初始化在多线程环境下的安全性。

    public class Singleton {
        private static volatile Singleton instance;
    ​
        private Singleton() {}
    ​
        public static Singleton getInstance() {
             //第一次校验instance是否为空
            if (instance == null) {
                synchronized (Singleton.class) {
                     //第二次校验instance是否为空
                    if (instance == null) {
                        instance = new Singleton();
                    }
                }
            }
            return instance;
        }
    }
    ​
    

    在上面的代码中,instance变量使用了volatile关键字修饰,保证了其在多线程环境下的可见性和禁止指令重排。同时,双重校验锁的实现方式也保证了线程安全和单例对象的唯一性。

    为什么是双重校验锁实现单例模式呢?

    第一次校验:也就是第一个if(instance==null),这个是为了代码提高代码执行效率,由于单例模式只要一次创建实例即可,所以当创建了一个实例之后,再次调用getInstance方法就不必要进入同步代码块,不用竞争锁。直接返回前面创建的实例即可。

    第二次校验: 也就是第二个if(instance==null),这个校验是防止二次创建实例,假如有一种情况,当singleton还未被创建时,线程t1调用getInstance方法,由于第一次判断singleton==null,此时线程t1准备继续执行,但是由于资源被线程t2抢占了,此时t2页调用getInstance方法,同样的,由于singleton并没有实例化,t2同样可以通过第一个if,然后继续往下执行,同步代码块,第二个if也通过,然后t2线程创建了一个实例singleton。此时t2线程完成任务,资源又回到t1线程,t1此时也进入同步代码块,如果没有这个第二个if,那么,t1就也会创建一个singleton实例,那么,就会出现创建多个实例的情况,但是加上第二个if,就可以完全避免这个多线程导致多次创建实例的问题。

    所以说:两次校验都必不可少。

    怎么理解 volatile关键字可以防止jvm指令重排优化?

    这里的private static volatile Singleton singleton = null;中的volatile也必不可少,volatile关键字可以防止jvm指令重排优化,

    因为 singleton = new Singleton() 这句话可以分为三步: 1. 为 singleton 分配内存空间; 2. 初始化 singleton; 3. 将 singleton 指向分配的内存空间。 但是由于JVM具有指令重排的特性,执行顺序有可能变成 1-3-2。 指令重排在单线程下不会出现问题,但是在多线程下会导致一个线程获得一个未初始化的实例。例如:线程T1执行了1和3,此时T2调用 getInstance() 后发现 singleton 不为空,因此返回 singleton, 但是此时的 singleton 还没有被初始化。 使用 volatile 会禁止JVM指令重排,从而保证在多线程下也能正常执行。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论