JAVA并发系列 - 深入分析JMM

JMM是2019年在基础平台组内分享的,年底转到数据组后,应团队TL的要求,最近又在数据组分享了一次,现将分享PPT内容整理到博客上。

从以下四个角度去透视JMM,1)并发编程问题的源头。2)什么是JMM。3)Happens-Before原则。4)实际案例

并发编程问题源头

并发编程为何容易出问题?不妨来看一下计算机发展的背景:CPU,内存,IO设备等硬件不断迭代,但是三者的速度有天壤之别,为了合理利用CPU高性能,平衡三者之间的速度差异,操作系统,硬件等做出了很多改进,例如:1) CPU增加缓存,L1,L2等。2)操作系统增加进程,线程,分时复用CPU,均衡CPU与IO设备的速度差异。3)编译程序优化指令执行顺序,使得缓存得到更合理的应用。

源头之一,缓存导致的可见性问题

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称之为可见性。

单核时代,A线程,B线程,操作同一个共享变量,CPU的缓存与内存的数据一致性问题,容易得到解决,因为所有线程操作的是同一个CPU缓存。A线程操作变量V的值,对于线程B是可见的。

多核时代,每颗CPU都有自己的缓存,这时的CPU缓存与内存的数据一致性就没有那么容易解决了,当多个线程在不同的CPU上执行时,这些线程操作的是不同的CPU缓存。如上图,线程A操作的是CPU-A的缓存,线程B操作的是CPU-B的缓存,很明显,这个时候线程A对变量V的操作,对于线程B而言就不具备可见性。

源头之二,线程切换带来的原子性问题

我们把一个或者多个操作在CPU执行的过程中不能被中断的特性称之为原子性,这里说的是CPU指令级别的原子性。

CPU的线程切换,线程切换的时机大多数是时间片结束的时候,操作系统做线程切换,可以发生在任何一条CPU指令执行完成。例如:CPU指令:count+=1,高级语言是一行代码,对于CPU来说是三条指令。1)首先,需要把变量count从内存加载到CPU寄存器。2)在CPU寄存器中执行加1操作。3)最后将结果写入内存。本来,在高级语言中,我们认为的一个原子操作,因为CPU的线程上下文切换,造成的是非原子性操作。如下图:

源头之三,编译优化带来的有序性问题

有序性指的是程序要按照代码的先后顺序执行,编译器为了优化性能,有时候会改变程序中语句的先后顺序。例如程序中,a=6,b=7,编译器优化后可能变成,b=7,a=6,在这个例子中,编译器调整了语句的顺序,但是不影响程序最终的结果。不过有时候编译器以及解释器的优化可能会导致意想不到的问题,如下的代码:

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

常见的双重单例写法,看下来不会有问题,甚至在绝大多数程序运行中都不会有问题,其实这里潜在一个问题。CPU线程切换的时机是,完成一个CPU指令后,进行切换,这就是最有可能出问题的地方。例如:instance = new Singleton(),正常操作:1)分配一块内存M。2)在内存M上初始化Singleton现象。3)然后M的地址赋值给instance变量。如果经过编译器优化,可能指令会变成:1) 分配一块内存M。2)将M的地址赋值给instance变量。3)最后在M上初始化Singleton对象。

假设,总共有A,B个线程,A线程则执行instance = new Singleton(),执行完第二个CPU命令(将M的地址赋值给instance变量)后,因为上下文切换,切换到了B线程,B线程的正常逻辑是判断 if (instance == null) ,此时不为空,instance对象会返回。但是该对象是没有经过初始化操作的,这个时候外围程序访问instance变量就会出现问题。

什么是JMM

可见性导致的原因是缓存,有序性导致的原因是编译优化,那么我们是不是只要禁用缓存,禁用编译优化就能解决问题呢?

最好的做法是按需禁用。JMM就是Java内存模型,整体规范比较复杂,站在程序员的视角去解读,本质上可以理解为:Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法或者说是规范。接地气点,就是:volatile,synchronized,final,以及六项Happens-Before原则。

JMM个人感觉是从传统计算机硬件内存模型抽象而来,JMM中的多线程即多个CPU,JMM中的工作内存,即相当于各个CPU的一级二级缓存,JMM的可见性,数据一致性即总线锁机制。

Volatile

可见性:对于一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。

原子性:对任意单个volatile变量的读/写,具有原子性,但是类似于volatile++这种复合操作不具备原子性。

当把一个共享变量声明为volatile后,对于这个变量的读/写都会很特别,可以看成是使用同一个锁对这些单个读/写做了同步。下面两个图的操作是相等的。

final

对于final域,编译器和处理器要遵守两个重排序规则:

1.在构造函数内对一个final域的写入,与随后把这个构造函数的引用赋值给一个引用变量,这两个操作直接不能重排序。

2.初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作直接不能重排序。

写final域的重排序规则

1.在构造函数内对一个final域的写入,与随后把这个构造函数的引用赋值给一个引用变量,这两个不能重排序。

剖析writer()方法,obj = new FinalExample,这行代码包含两个步骤:

1.构造一个FinalExample类型的对象;

2.把这个对象的引用赋值给引用变量obj;

int i; // 普通变量

final int j; // final变量

static FinalExample obj;

public FinalExample() { // 构造函数

     i=1; //写普通域

     j=2; //写final域
}

public static void wirter() { // 写线程A执行

     obj = new FinalExample();
}

假如,执行构造方法,obj = new FinalExample(),这里面可能会发生指令重排序,但是假设,把这个对象的引用赋值给引用变量obj,这一步骤未发生指令重排序,保证B线程读对象引用和读对象的普通域未发生重排序。

上图的中现象:

1.B线程读取到的普通域i的值是0,不符合预期。

2.B线程读取到的final域的值是2,是构造方法内赋值给j的值,符合预期。

上图发生这个现象的原因:

1.JMM禁止编译器把final域的写重排序到构造函数之外。

2.编译器会在final域的写之后,构造函数return之后,插入一个storeStore屏障,这个屏障禁止处理器把final域的写重排序到构造函数之外。

读final域的重排序规则

在一个线程中,初次读对象引用与初次读该对象所包含的final域,JMM禁止处理器重排序这两个操作(这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个load屏障。

reader()方法包含三个操作:

1.初次读引用变量obj。

2.初次读引用变量obj指向对象的普通域j。

3.初次读引用变量obj指向对象的final域j。

public static void reader() { // 读线程B执行
    FinalExample object = obj; // 读对象引用
    int a = object.i; // 读普通域
    int b = object.j; // 读final域
}

上图的现象:

1.读普通域时,该域还没有被写线程写入,这是一个错误操作。

2.读final域的重排序规则,会把读对象final域的操作做,限定在读对象引用之后,所以此时final域已经被线程A初始过了,这是一个正确的读取操作。

原因:

读final域的重排序规则可以保证:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。在这个示例程序中,如果该引用不为null,那么引用对象的final域一定已经被A线程初始化过了。套上原则,在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作,编译器会在读final域操作的前面插入一个load屏障。

Happens-Before

什么是happens-before规则呢?happens-before等于:前面的一个操作结果对于后续的操作是可见的。happens-before约束了编译器的优化行为,虽然允许编译器优化,但是要求编译器优化后,一定要符合happens-before原则。

happens-before比较晦涩难懂,和程序相关的规则,提炼了6项,是关于可见性和传递性的。

程序的顺序性规则

这条规则是指在一个线程中,按照程序顺序,前面的操作Happens-before于后续的任意操作。

class VolatileExample {

   int x = 0;
   volatile boolean v = false;
   
   public void writer() {
    
      x= 42;
      v= true;

   }
   
   public void reader() {

      if(v) {

        // 这里x会是多少?
      }
   }

}

假设,A线程执行writer,B线程执行reader,按照volatile语义,原子性,可见性,v的值具备可见性,x的值,可能是0,也可能是42,正确的值是42。后面具体解释为啥是42。

Volatile变量规则

这条规则是指对一个volatile变量的写操作,happens-before于后续对这个变量的读操作。

传递性

传递性指的是,如果A Happens-before B,且B Happens-before C,那么A Happens-before C。

其实volatile和传递性结合起来,可以很完美的解释x为什么等于42

假设线程A执行writer()方法之后,线程B执行reader()方法。根据happens before规则,这个过程建立的happens before 关系可以分为两类:

1.根据程序次序规则,x=42  happens before v=true。(第一步 happens before 第二步)

2.根据程序次序规则,if (v) happens before后续的操作。(第三步 happens before 第四步)

3.根据volatile规则,v=true happens before if (v), 写操作,对于读操作可见。(第二步 happens before 第三步)

4.根据happens before的传递规则,所以第一步happens before 第四步,第四步读取x的值的话,读到的一定是42.

管程这种的锁规则

这条规则是指对于一个锁的解锁 Happens-before于对后续对这个锁的加锁。

管程是一种通用的同步原语,在这里暂时指synchronized。

int x = 10;
// 此处自动加锁
synchronized(this) {
  if (this.x < 12) {
    this.x = 12
  }
}

// 此处自动解锁

结合规则4,可以这样理解,假设X初始值是10,线程A执行完后,变成12,执行完成自动释放锁。线程B进入代码块,能够看到线程A对于X的写操作,读到的X就是12。一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须是同一个锁,后面指的是时间上的先后顺序。

线程start规则

这条规则是说线程启动的。主线程A,启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作。

如果线程A调用线程B的start方法(即在线程A中启动线程B),那么该线start()操作Happens-before于线程B中的任意操作。

Thread B = new Tread(() -> {
   System.out.print(var);

}

int var = 77;
B.start();

B线程读取var,读取到的是77

线程join规则

这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。
换句话说就是,如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回。

Thread B = new Thread(()->{
  // 此处对共享变量var修改
  var = 66;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程B可见
// 主线程启动子线程
B.start();
B.join()
// 子线程所有对共享变量的修改
// 在主线程调用B.join()之后皆可见
// 此例中,var==66

实际案例

案例一

如果不声明final,可能会出现空指针异常,结合之前所说,为何要声明final?

在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。其实进一步理解,通过禁止指令重排序,final声明的这个对象,可以确保这个对象已经真正的被初始化完成,对于后续所有的线程都是可见的。

案例二

单例类的正确写法

public class Singleton {
    private static class Holder {
        private static Singleton singleton = new Singleton();
    }

    private Singleton(){}

    public static Singleton getSingleton(){
        return Holder.singleton;
    }
}

总结

造成并发编程问题的原因:1)缓存的不一致性(cpu缓存,机器内存)。2)CPU指令的切换。3)JVM编译器对代码的优化,对部分指令改变了执行顺序。

如何解决这些问题,JMM规范应声而出,基本中心思想:按需禁用缓存,JVM编译器对代码优化需要符合JMM的规范。

JMM规范:volatile,final,synchronized,happens-before原则等。

 

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值