JUC并发编程——Happens-Before/Volatile原理

437 阅读10分钟

摘要

熟悉 Java 并发编程的都知道,JMM(Java 内存模型) 中的 happen-before规则,该规则定义了 Java 多线程操作的有序性和可见性,防止了编译器重排序对程序结果的影响。happen-before 概念其实就是保证多线程环境中,上一个操作对下一个操作的有序性和操作结果的可见性。

当一个变量被多个线程读取并且至少被一个线程写入时,如果读操作和写操作没有 happens-before 关系,则会产生数据竞争问题。要想保证操作 B 的线程看到操作 A 的结果(无论 AB 是否在一个线程),那么在 AB 之间必须满足 HB 原则,如果没有,将有可能导致重排序。当缺少 HB 关系时,就可能出现重排序问题。

volatile是一种轻量且在有限的条件下线程安全技术,它保证修饰的变量的可见性和有序性,但非原子性。相对于synchronize高效,而常常跟synchronize配合使用。同时voilate关键字在JDK中的大量使用,例如在单例模式中的double check的对变量使用的voilate等,同时该关键字在面试的中比例非常高,因此本博文详细的介绍Volatile底层原理,帮助大家更加深入的回答面试官的问题。

image.png

一、happens-before的规则

  • 程序顺序规则:某个线程中的每个动作都happens-before该线程中该动作后面的动作。

  • 监视器锁规则:某个管程(对象锁)上的unlock动作happens-before同一个管程上后续的lock动作。

  • volatile变量规则:对某个volatile字段的写操作happens-before每个后续对该volatile字段的读操作。

  • Join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

  • 传递性:如果某个动作a happens-before动作b,且b happens-before动作c,则有a happens-before c。

  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。

  • 程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。

  • 对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。

1.1 如何实现同步

通过组合happens-before 的一些规则,可以实现对某个未被锁保护变量的可见性。但由于这个技术对语句的顺序很敏感,因此容易出错

/**
* 两个线程间隔打印出 0 – 100 的数字
**/
class ThreadPrintDemo {

  static int num = 0;
  static volatile boolean flag = false;

  public static void main(String[] args) {

    Thread t1 = new Thread(() -> {
      for (; 100 > num; ) {
        if (!flag && (num == 0 || ++num % 2 == 0)) {
          System.out.println(num);
          flag = true;
        }
      }
    }
    );

    Thread t2 = new Thread(() -> {
      for (; 100 > num; ) {
        if (flag && (++num % 2 != 0)) {
          System.out.println(num);
          flag = false;
        }
      }
    }
    );
    t1.start();
    t2.start();
  }
}

这个 num 变量没有使用 volatile,会有可见性问题,即:t1线程更新了num,t2线程无法感知。

注意:happens-before 规则保证上一个操作的结果对下一个操作都是可见的。所以,上面的小程序中,线程 A 对 num 的修改,线程 B 是完全感知的 —— 即使 num 没有使用 volatile 修饰。

借助happens-before原则实现了对一个变量的同步操作,也就是在多线程环境中,保证了并发修改共享变量的安全性。并且没有对这个变量使用 Java 的原语:volatile 和 synchronized 和 CAS(假设算的话)。

这可能看起来不安全(实际上安全)。因为这一切都是 happens-before 底层的 cache protocol 和 memory barrier 实现的。

二、JMM内存模型

Java内存模型是java虚拟机规范中所定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别。(JMM与JVM的内存模型不是一个,大家一定好好的理解!)

现代计算机系统都加入一层读写速度尽可能接近处理器运算速度的高速缓存(cache)来作为内存与处理器之间的缓冲。将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(CacheCoherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。

image.png

Java内存模型(JavaMemoryModel)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量,存储到内存和从内存中读取变量这样的底层细节。

所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

三、volatile可见性原理

  • 操作use之前必须先执行read和load操作。
  • 操作assign之后必须执行store和write操作。

由特性性保证了read、load和use的操作连续性,assign、store和write的操作连续性,从而达到工作内存读取前必须刷新主存最新值;工作内存写入后必须同步到主存中。读取的连续性和写入的连续性,看上去像线程直接操作了主存。

lock和unlock操作并不直接开放给用户使用,而是提供给像Synchronize关键字指定monitorenter和monitorexit隐式使用。关于Synchronize的监听器锁monitor,javac编译后会在作用的方法前后增加monitorenter和monitorexit指令,详细的可以查看Synchronize原理。

package com.zhuangxiaoyan.java.base.javabase.VolatileTest;
 
/**
 * @Classname VolatileVisibility
 * @Description TODO
 * @Date 2022/5/15 18:40
 * @Created by xjl
 */
public class VolatileVisibility {
    public static class TestData {
        volatile int num = 0;
 
        public void updateNum() {
            num = 1;
        }
    }
 
    public static void main(String[] args) {
        final TestData testData = new TestData();
        new Thread(new Runnable() {
 
            @Override
            public void run() {
                System.out.println("ChildThread num-->" + testData.num);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                testData.updateNum();
                System.out.println("ChildThread update num-->" + testData.num);
            }
        }).start();
 
        while (testData.num == 0) {
        }
        System.out.println("MainThread num-->" + testData.num);
    }
}

(1)TestData中的num不添加volatile关键字,System.out.println("MainThread num-->"+testData.num);这一句一直不会执行。表示while中的条件testData.num == 0一直为0,子线程修改了num对主线程不起作用。

(2)TestData中的num添加volatile关键字,System.out.println("MainThread num-->"+testData.num);会执行,结果如下。

四、volatile非原子性原理

use和assign这两个操作整体上不是一个连续性的原子操作。

volatile本身并不对数据运算处理维持原子性,强调的是读写及时影响主存。

volatile修饰num,num++;num = num+1;这种就是非原子性操作。

非原子性操作

  • 主存读取num的值;
  • 进行num++运算;
  • 将num值写到主存。

像种操作在多线程环境中,use和assign是多次出现,如果有两个线程中读取到主存的num都是2,且同时执行num++,两个线程的结果都是3,这样就产生了脏数据,再写入主存中都是3。核心num++运算并没保证先后顺序执行。为了保证执行运算的线程顺序,可以选择Synchronize。

package com.zhuangxiaoyan.java.base.javabase.VolatileTest;
 
/**
 * @Classname ValatileAtomic
 * @Description TODO
 * @Date 2022/5/15 18:46
 * @Created by xjl
 */
public class ValatileAtomic {
 
    public static class TestData {
        volatile int num = 0;
 
        //synchronized
        public void updateNum() {
            num++;
        }
    }
 
    public static void main(String[] args) {
        final TestData testData = new TestData();
        for (int i = 1; i <= 10; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 1; j <= 1000; j++) {
                        testData.updateNum();
                    }
                }
            }).start();
        }
 
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println("最终结果:" + testData.num);
    }
}

按我们的意愿10个线程,每个线程累加线程累加1000,一共是10 * 1000=10000。但是volatile int num = 0; 使用volatile与否都是体现非原子性,运行的结果都比10000小:

image.png

五、volatile禁止指令重排

指令重排:为了提高性能,编译器和和处理器通常会对指令进行指令重排序。

图中的三个重排位置可以调换的,根据系统优化需要进行重排。遵循的原则是单线程重排后的执行结果要与顺序执行结果相同。

内存屏障指令:volatile在指令之间插入内存屏障,保证按照特定顺序执行和某些变量的可见性。

volatile就是通过内存屏障通知cpu和编译器不做指令重排优化来维持有序性。

synchronize串行控制 synchronize无禁止指令重排,一个变量在同一时刻只允许一条线程对其进行lock操作,获取对象锁,互斥排他性达到两个同步块串行执行。

volatile线程安全适用范围:由于volatile的非原子性原因,所以它的线程安全是有条件的:运算结果不依赖但前置,或者能保证自由一个单一线程修改变量值。变量不需要与其他的状态变量共同参与不变的约束。

六、volatile与synchronize配合使用

public class Singleton {
 
private volatile static Singleton instance = null;
    private Singleton(){
}
 
public static Singleton getInstance(){
	if(instance == null){ // 第1处
		synchronized (Singleton.class) {
			if(instance == null){  // 第2处
				instance = new Singleton();
			}
		}
	}
	return instance;
  }
}

按照上边的写法已经对new Singleton();这个操作进行了synchronize操作,已经保证了多线程只能串行执行这个实例化代码。事实上,synchronize保证了线程执行实例化这段代码是串行的,但是Synchronize并不具备禁止指令重排的特性。而instance = new Singleton(); 主要做了3件事情:

  1. java虚拟机为对象分配一块内存x。
  2. 在内存x上为对象进行初始化 。
  3. 将内存x的地址赋值给instance 变量。

如果编译器进行重排为:

  1. java虚拟机为对象分配一块内存x。
  2. 将内存x的地址赋值给instance 变量。
  3. 在内存x上为对象进行初始化。

第一种情况,无volatile修饰:此时,有两个线程执行getInstance()方法,加入线程A进入代码的注释中的第②处,并执行到了重排指令的(2),与其同时线程B刚好代码注释中的第①处的if判断。此时,instance有线程A把内存地址x地址赋值给了instance,那么instance已经不为空只是没有初始化完成,线程B就返回了一个没有完成初始化的instance,最终使用时候会出现空指针的错误。

第二种情况,有volatile修饰:instance因为被volatile的禁止指令重排的特性,那只会安装先初始化对象再赋值给instance这样顺序执行,这样就能保证返回正常的实例化的对象。

volatile总结

  1. volatile具有可见性和有序性,不能保证原子性。
  2. volatile在特定情况下线程安全,比如自身不做非原子性运算。
  3. synchronize通过获取对象锁,保证代码块串行执行,无禁止指令重排能力。
  4. DCL单例操作需要volatile和synchronize保证线程安全。

博文参考