• Volatile介绍


    引言—并发编程的艺术

    JavaGuide

    在多线程并发编程中synchronized和Volatile都扮演着重要的角色,Volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。它在某些情况下比synchronized的开销更小。

    Volatile的官方定义

    Java语言规范第三版中对volatile的定义如下: java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。

    为什么要使用Volatile

    Volatile变量修饰符如果使用恰当的话,它比synchronized的使用和执行成本会更低,因为它不会引起线程上下文的切换和调度。

    术语定义

    术语英文单词描述
    共享变量在多个线程之间能够被共享的变量被称为共享变量。共享变量包括所有的实例变量,静态变量和数组元素。他们都被存放在堆内存中,Volatile只作用于共享变量。
    内存屏障Memory Barriers是一组处理器指令,用于实现对内存操作的顺序限制
    缓冲行Cache line缓存中可以分配的最小存储单位。处理器填写缓存线时会加载整个缓存线,需要使用多个主内存读周期。
    原子操作Atomic operations不可中断的一个或一系列操作。
    缓存行填充cache line fill当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存行到适当的缓存(L1,L2,L3的或所有)
    缓存命中cache hit如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存。
    写命中write hit当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中。
    写缺失write misses the cache一个有效的缓存行被写入到不存在的内存区域。

    JMM(Java 内存模型)

    JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。

    在 JDK1.2 之前,Java 的内存模型实现总是从主存(即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致

    要解决这个问题,就需要把变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

    所以,volatile 关键字 除了防止 JVM 的指令重排 ,还有一个重要的作用就是保证变量的可见性。

    JMM 体现在以下几个方面

    1. 原子性 : 一个的操作或者多次操作,要么所有的操作全部都得到执行并且不会收到任何因素的干扰而中断,要么所有的操作都执行,要么都不执行。synchronized 可以保证代码片段的原子性。
    2. 可见性 :当一个变量对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。volatile 关键字可以保证共享变量的可见性。
    3. 有序性 :代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序。volatile 关键字可以禁止指令进行重排序优化。

    System.out.println会影响内存可见性

    println源代码中使用了synchronized锁,在获取锁的时候会清空工作内存,重新从主内存中读取数据。

    synchronized 关键字和 volatile 关键字的区别

    synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

    • volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块
    • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
    • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

    Volatile原理

    汇编指令来查看对volatile进行写操作时,CPU会做什么事情。volatile是如何来保证可见性的呢?让我们在X86处理器下通过工具获取JIT编译器生成的Java代码如下。

    instance = new Singleton(); // instance是volatile变量
    
    • 1

    转变成汇编代码,如下。

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

    有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,通过查IA-32架构软件开发者手册可知,Lock前缀的指令在多核处理器下会引发了两件事情[1]。

    1. 将当前处理器缓存行的数据写回到系统内存。
    2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

    为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部
    缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令, 将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里

    volatile的使用优化

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KvB7LJq7-1660577510673)(https://b3logfile.com/file/2021/07/image-aa3ad6d1.png)]

    追加64字节能够提高并发编程的效率 为什么?

    处理器会将它们都读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头、尾节点,当一个处理器试图修改头节点时,会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中的尾节点,而队列的入队和出队操作则需要不停修改头节点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。Doug lea使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行,使头、尾节点在修改时不会互相锁定。

    那么是不是在使用volatile变量时都应该追加到64字节呢?不是的。在两种场景下不应该使用这种方式。
    ·缓存行非64字节宽的处理器。如P6系列和奔腾处理器,它们的L1和L2高速缓存行是32个字节宽。
    ·共享变量不会被频繁地写。因为使用追加字节的方式需要处理器读取更多的字节到高速
    缓冲区,这本身就会带来一定的性能消耗,如果共享变量不被频繁写的话,锁的几率也非常小,就没必要通过追加字节的方式来避免相互锁定。
    不过这种追加字节的方式在Java 7下可能不生效,因为Java 7变得更加智慧,它会淘汰或重新排列无用字段,需要使用其他追加字节的方式

    具体详细可以查看java并发编程的艺术。

    volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)

    1. 对 volatile 变量的写指令后会加入写屏障
    2. 对 volatile 变量的读指令前会加入读屏障

    如何保证可见性

    写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中

    public void actor2(I_Result r) {
        num = 2;
        ready = true; // ready 是 volatile 赋值带写屏障
        // 写屏障
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据

    public void actor1(I_Result r) {
        // 读屏障
        // ready 是 volatile 读取值带读屏障
        if(ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4vyUTIMj-1660577510673)(https://b3logfile.com/file/2021/07/image-f89e267c.png)]

    如何保证有序性

    写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后

    public void actor2(I_Result r) {
        num = 2;
        ready = true; // ready 是 volatile 赋值带写屏障
        // 写屏障
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

    public void actor1(I_Result r) {
        // 读屏障
        // ready 是 volatile 读取值带读屏障
        if(ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tjd4izvq-1660577510673)(https://b3logfile.com/file/2021/07/image-b30d28b8.png)]

    还是那句话,不能解决多个线程指令交错:

    • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其他线程的读跑到它前面去
    • 而有序性的保证也只是保证了本线程内相关代码不被重排序

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3izmuCWl-1660577510674)(https://b3logfile.com/file/2021/07/image-7eb6f38b.png)]

    double-checked locking 问题

    以著名的 double-checked locking 单例模式为例

    public final class Singleton {
        private Singleton() { }
        private static Singleton INSTANCE = null;
        public static Singleton getInstance() {
            if(INSTANCE == null) { // t2
                // 首次访问会同步,而之后的使用没有 synchronized
                synchronized(Singleton.class) {
                    if (INSTANCE == null) { // t1
                        INSTANCE = new Singleton(); //这里有两步操作,赋值,执行构造方法,可能会发生重排序
                    }
                }
            }
            return INSTANCE;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    以上的实现特点是:

    • 懒惰实例化
    • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
    • 有隐含的,但很关键的一点:第一个 if 使用了 INSTANCE 变量,是在同步块之外

    但在多线程环境下,上面的代码是有问题的,getInstance 方法对应的字节码为:

    0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
    3: ifnonnull 37
    6: ldc #3 // class cn/itcast/n5/Singleton
    8: dup
    9: astore_0
    10: monitorenter
    11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
    14: ifnonnull 27
    17: new #3 // class cn/itcast/n5/Singleton
    20: dup
    21: invokespecial #4 // Method "":()V
    24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
    27: aload_0
    28: monitorexit
    29: goto 37
    32: astore_1
    33: aload_0
    34: monitorexit
    35: aload_1
    36: athrow
    37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
    40: areturn
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    其中

    正常创建一个对象并赋值给一个变量的情况(没有发生指令重排序):

    17 表示创建对象,将对象引用入栈 // new Singleton

    20 表示复制一份对象引用 // 引用地址

    21 表示利用一个对象引用,调用构造方法

    24 表示利用一个对象引用,赋值给 static INSTANCE

    也许 jvm 会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行:

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lNvqSaQE-1660577510674)(https://b3logfile.com/file/2021/07/image-529629b5.png)]

    关键在于 0: getstatic 这行代码在 monitor 控制之外,它就像之前举例中不守规则的人,可以越过 monitor 读取INSTANCE 变量的值

    这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例

    对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效

    解决 double-checked locking 问题

    public final class Singleton {
        private Singleton() { }
        private static volatile Singleton INSTANCE = null;//增加 volatile 关键字,会有读,写屏障
        public static Singleton getInstance() {
            // 实例没创建,才会进入内部的 synchronized代码块
            if (INSTANCE == null) {
                synchronized (Singleton.class) { // t2
                    // 也许有其它线程已经创建实例,所以再判断一次
                    if (INSTANCE == null) { // t1
                        INSTANCE = new Singleton();
                    }
                }
            }
            return INSTANCE;
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    字节码看不出 volatile指令的效果:

    // -------------------------------------> 加入对 INSTANCE 变量的读屏障
    0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
    3: ifnonnull 37
    6: ldc #3 // class cn/itcast/n5/Singleton
    8: dup
    9: astore_0
    10: monitorenter -----------------------> 保证原子性、可见性
    11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
    14: ifnonnull 27
    17: new #3 // class cn/itcast/n5/Singleton
    20: dup
    21: invokespecial #4 // Method "":()V
    24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
    // -------------------------------------> 加入对 INSTANCE 变量的写屏障
    27: aload_0
    28: monitorexit ------------------------> 保证原子性、可见性
    29: goto 37
    32: astore_1
    33: aload_0
    34: monitorexit
    35: aload_1
    36: athrow
    37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
    40: areturn
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    如上面的注释内容所示,读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面两点:

    • 可见性
      • 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中,保证写屏障之前的代码不会重排到写屏障之后
      • 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据,保证读屏障之后的代码不会重排到读屏障之前
    • 有序性
      • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
      • 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
    • 更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QBxK3BMK-1660577510675)(https://b3logfile.com/file/2021/07/image-5a3da0fd.png)]

    happens-before

    并发编程的艺术中也很详细,可以再看看。😳

    happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见

    1. 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见

      static int x;
      static Object m = new Object();
      new Thread(()->{
          synchronized(m) {
              x = 10;
          }
      },"t1").start();
      new Thread(()->{
          synchronized(m) {
              System.out.println(x);
          }
      },"t2").start();
      
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
    2. 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见

      volatile static int x;
      new Thread(()->{
          x = 10;
      },"t1").start();
      new Thread(()->{
          System.out.println(x);
      },"t2").start();
      
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
    3. 线程 start 前对变量的写,对该线程开始后对该变量的读可见

      static int x;
      x = 10;
      new Thread(()->{
          System.out.println(x);
      },"t2").start();
      
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
    4. 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)

      static int x;
      Thread t1 = new Thread(()->{
          x = 10;
      },"t1");
      t1.start();
      t1.join();
      System.out.println(x);
      
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
    5. 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)

      static int x;
      public static void main(String[] args) {
          Thread t2 = new Thread(()->{
              while(true) {
                  if(Thread.currentThread().isInterrupted()) {
                      System.out.println(x);
                      break;
                  }
              }
          },"t2");
          t2.start();
          new Thread(()->{
              sleep(1);
              x = 10;
              t2.interrupt();
          },"t1").start();
          while(!t2.isInterrupted()) {
              Thread.yield();
          }
          System.out.println(x);
      }
      7. 对变量默认值( 0falsenull)的写,对其它线程对该变量的读可见具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z,配合 volatile 的防指令重排,有下面的例子
      
         ```java
         volatile static int x;
         static int y;
         new Thread(()->{
             y = 10;
             x = 20;
         },"t1").start();
         new Thread(()->{
             // x=20 对 t2 可见, 同时 y=10 也对 t2 可见
             System.out.println(x);
         },"t2").start();
      
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35

    变量都是指成员变量或静态成员变量

  • 相关阅读:
    外汇天眼:亏亏亏,为什么亏损的总是我?大数据分析报告告诉你答案
    电脑重装系统后当前安全设置不允许下载该文件
    NISP-SO安全运维需要学习什么呢?学习的价值又是什么
    JS中数组的遍历方法有那些?
    如何接入淘宝电商平台item_review-API接口获得淘宝商品评论
    Python学习笔记(九)——常用内置函数
    掌握这五点建议,Linux学习不再难
    C#——委托
    动态顺序表C++实现
    A,B=input().split(“,“)A=eval(A)A=str(A)B=eval(B)print(A*B)
  • 原文地址:https://blog.csdn.net/m0_46669446/article/details/126356877