JUC(4)Volatile

Volatile是Java虚拟机提供的轻量级的同步机制,它的三大特性:

  • 保证可见性

  • 不保证原子性

  • 禁止指令重排

    JMM的三大特性,volatile只保证了两个,即可见性和有序性,不满足原子性

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。

  • 当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量

  • 所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取

volatile凭什么可以保证可见性和有序性——内存屏障 (Memory Barriers / Fences)

内存屏障

内存屏障(也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性,但volatile无法保证原子性。
内存屏障之前的所有写操作都要回写到主内存,内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性),对一个volatile域的写, happens-before于任意后续对这个volatile域的读,也叫写后读

源码

我们在上一章讨论的happens-before先行发生原则类似接口,是一种规范,落地的实现呢?—volatile。

而volatile底层靠的是StoreStore、StoreLoad 、LoadLoad、LoadStore四条屏障指令。

当我们的Java程序的变量被volatile修饰之后,会添加一个ACC_VOLATI LE,JVM会把字节码生成为机器码的时候,发现操作是volatile变量的话,就会根据JVM要求,在相应的位置去插入内存屏障指令。


Unsafe.java

Unsafe.cpp

OrderAccess.hpp

orderAccess_linux_x86.inline.hpp

四大屏障

happens-before之volatile 变量规则

能否重排序 第二个操作 第二个操作 第二个操作
第一个操作 普通读/写 volatile 读 volatile 写
普通读/写 YES YES NO
volatile 读 NO NO NO
volatile 写 YES NO NO
  • 当第一个操作为volatile读时,不论第二个操作是什么,都不能重排序。这个操作保证了volatile读之后的操作不会被重排到volatile读之前
  • 当第二个操作为volatile写时,不论第一个操作是什么,都不能重排序。这个操作保证了volatile写之前的操作不会被重排到volatile写之后
  • 当第一个操作为volatile写时,第二个操作为volatile读时,不能重排

也就是说JMM将内存屏障插入策略分为4种:

  • 在每个volatile写操作的前⾯插⼊⼀个StoreStore屏障
    在每个volatile写操作的后⾯插⼊⼀个StoreLoad屏障

  • 在每个volatile读操作的后⾯插⼊⼀个LoadLoad屏障
    在每个volatile读操作的后⾯插⼊⼀个LoadStore屏障

可见性代码验证

如前面所说,我们知道各个线程各自拷贝到自己的工作内存进行操作后再回写到主内存的。

这就可能导致一个线程A修改了共享变量X的值但还未写回主内存时,另外一个线程B又对主内存中同一个共享变量X进行操作,但此时A线程工作内存中共享变量X对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题。

public class VolatileDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t come in");
            try {TimeUnit.SECONDS.sleep(3);} catch (InterruptedException e) {e.printStackTrace();}
            myData.addTo60();
            System.out.println(Thread.currentThread().getName() + "\t update number");
        }, "A").start();
        while (myData.number == 0) {}
        System.out.println("end");
    }
}

class MyData{
    Integer number = 0;

    public void addTo60() {
        this.number = 60;
    }
}

以上代码运行后,我们发现,控制台始终没有结束,并且没有打印end,证明了存在共享变量存在可见性的问题。

而当我们给number加上volatile关键字后,再次运行,发现3秒后,程序运行结束,同时打印出了end。说明volatile修饰的变量,是具备JVM轻量级同步机制的,能够感知其它线程的修改后的值。

原子性

不可分割,完整性,也就是说某个线程正在做某个具体业务时,中间不可以被加塞或者被分割,需要整体完整,要么同时成功,要么同时失败。

如下代码

public class VolatileDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();
        for (int i = 1; i <= 20; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    myData.addPlusPlus();
                }
            }, "thread" + i).start();
        }
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(myData.number);
    }
}

class MyData{
    volatile int number = 0;

    public void addPlusPlus() {
        number++;
    }
}

当我们去运行程序时,发现打印结果并不是我们预期的200000,而是一个每次都各不相同的值,这说明了volatile修饰的变量不保证原子性。

原因:A和B线程拿到主内存中的number值为0并拷贝到工作内存中进行+1操作,自增完毕后,同步到主内存,此时,A线程和B线程再次同时去主内存同步自己各自更新后的值,A同步的时候,B被挂起,A同步为1后,B再次同步为1,这就导致了原本两次自增应为2的情况却结果是1。各自线程在写入主内存的时候,出现了数据的丢失,而引起的数值缺失的问题

下面我们将一个简单的number++操作,转换为字节码文件一探究竟

public class T1 {
    volatile int n = 0;
    public void add() {
        n++;
    }
}
  public void add();
    Code:
       0: aload_0
       1: dup
       2: getfield      #2                  // Field n:I
       5: iconst_1
       6: iadd
       7: putfield      #2                  // Field n:I
      10: return
}

附:JVM 虚拟机字节码指令表

我们能够发现 n++这条命令,被拆分成了3个指令

  • 执行getfield 从主内存拿到原始n
  • 执行iadd 进行加1操作
  • 执行putfileld 把累加后的值写回主内存

假设我们没有加 synchronized那么第一步就可能存在着,三个线程同时通过getfield命令,拿到主存中的 n值,然后三个线程,各自在自己的工作内存中进行加1操作,但他们并发进行 iadd 命令后执行putfield,因为只能一个进行写,所以其它操作会被挂起,假设1线程,先进行了写操作,在写完后,volatile的可见性,应该需要告诉其它两个线程,主内存的值已经被修改了,但是因为太快了,其它两个线程,陆续执行 iadd命令,进行写入操作,这就造成了其他线程没有接受到主内存n的改变,从而覆盖了原来的值,出现写丢失,这样也就让最终的结果少于20000

多线程环境下,"数据计算"和"数据赋值"操作可能多次出现,即操作非原子。若数据在加载之后,若主内存count变量发生修改之后,由于线程工作内存中的值在此前已经加载,从而不会对变更操作做出相应变化,即私有内存和公共内存中变量不同步,进而导致数据不一致,对于volatile变量,JVM只是保证从主内存加载到线程工作内存的值是最新的,也就是数据加载时是最新的。由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改共享变量的场景必须使用加锁同步。

read-load-use 和 assign-store-write 成为了两个不可分割的原子操作,但是在use和assign之间依然有极小的一段真空期,有可能变量会被其他线程读取,导致写丢失一次

解决方案

  • 在方法上加入 synchronized(重量级锁,如果仅仅是为了+1的原子性,太重了)
  • JUC包下的原子包装类(AtomicXXX)

指令重排序

volatile修饰的变量能禁止指令重排,来保证线程安全性

Volatile针对指令重排做了啥

Volatile实现禁止指令重排优化,从而避免了多线程环境下程序出现乱序执行的现象

首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:

  • 保证特定操作的顺序
  • 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说 通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。 内存屏障另外一个作用是刷新出各种CPU的缓存数,因此任何CPU上的线程都能读取到这些数据的最新版本。

也就是过在Volatile的写和读的时候,加入屏障,防止出现指令重排的

正确使用volatile

  • 单一赋值可以,but含复合运算赋值不可以(i++之类)

  • 状态标志,判断业务是否结束

    public class UseVolatileDemo{
      /**
       * 使用:作为一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或任务结束
       * 理由:状态标志并不依赖于程序内任何其他状态,且通常只有一种状态转换
       * 例子:判断业务是否结束
     */
        private volatile static boolean flag = true;
        public static void main(String[] args){
            new Thread(() -> {
                while(flag) {
                    //do something......
                }
            },"t1").start();
    
            //暂停几秒钟线程
            try { TimeUnit.SECONDS.sleep(2L); } catch (InterruptedException e) { e.printStackTrace(); }
    
            new Thread(() -> {
                flag = false;
            },"t2").start();
        }
    }
    
  • 开销较低的读,写锁策略

    public class UseVolatileDemo{
        /**
         * 使用:当读远多于写,结合使用内部锁和 volatile 变量来减少同步的开销
         * 理由:利用volatile保证读取操作的可见性;利用synchronized保证复合操作的原子性
         */
        public class Counter{ 
            private volatile int value;
            public int getValue(){
                return value;   //利用volatile保证读取操作的可见性
             }
            public synchronized int increment(){
                return value++; //利用synchronized保证复合操作的原子性
             }
        }
    }
    

总结

工作内存与主内存延迟现象导致的可见性问题,可以使用synchronize或volatile关键字解决,他们都可以使一个线程修改后的变量立即对其他线程可见。

对于指令重排导致的可见性问题和有序性问题,可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化。

单例模式中的volatile

直接上标准的单例模式DCL(Double Check Lock)代码:

class Singleton{
    private static volatile Singleton instance = null;

    private Singleton(){}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class){
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

为什么要使用volatile关键字呢?

DCL(双端检锁)机制不一定是线程安全的,原因是有指令重排的存在,加入volatile可以禁止指令重排

原因是在某一个线程执行到第一次检测的时候,读取到 instance 不为null,instance的引用对象可能没有完成实例化。因为 instance = new SingletonDemo();可以分为以下三步进行完成:

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

但是我们通过上面的三个步骤,能够发现,步骤2 和 步骤3之间不存在 数据依赖关系,而且无论重排前 还是重排后,程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。

  • memory = allocate(); // 1、分配对象内存空间
  • instance = memory; // 3、设置instance指向刚刚分配的内存地址,此时instance != null,但是对象还没有初始化完成
  • instance(memory); // 2、初始化对象

这样就会造成什么问题呢?

				if (instance == null) { 
          // 线程A执行了上述的instance=memory指令,使得线程B进来判断不为null,
          // 但实际上线程A尚未对该实例进行初始化操作,但线程B返回的对象其实是个半成品
            synchronized (Singleton.class){
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
指令重排只会保证串行语义的执行一致性(单线程),但并不会保证多线程间的语义一致性

所以需要引入volatile,来保证出现指令重排的问题,从而保证单例模式的线程安全性

posted @ 2021-08-05 23:29  Zoran0104  阅读(42)  评论(0编辑  收藏  举报