《Java并发编程的艺术》之Java内存模型

时间:2023-01-08 18:01:51

《Java并发编程的艺术》之Java内存模型

整体层次思路:Java采用的是内存共享模型,该模型会遇到内存可见性的问题,而内存可见性通常都是由 重排序写缓冲区 引发的,重排序又分为 处理器重排序编译器重排序。面对 写缓冲区的问题,像Java这样的高级语言一般无能为力,所以从重排序 入手,在重排序里,JVM通过内存屏障提供了一层最低限度的保障(比如初始化保证默认值,静态类第一次加载等等)。但是需要更高的保障(比如顺序一致性)还是需要更高的性能就由程序员自行定夺,我将面向程序员的那部分划分为了三块,一块是happens-before规则,一块是同步原语(volatile、synchrnoized等等),最后则是Doug Lea 大大写的JUC包。我想这些规则及工具在多线程开发中会起到至关重要的作用。也是我们学习的重中之重。


相信许多在多线程前线作战的伙伴们经常会遇到一种叫做内存可见性的大麻烦。这个麻烦可以简单粗暴的用synchronized解决,也可以很巧妙的用一些轻量级的同步原语解决。虽然能解决问题的都是好方案,但是在程序人生的旅途上,后者才是更为远见的选择。

在面对内存可见性这个问题时,我们不得不先去学习一个叫做内存模型的东西。这个模型解释了 如何解决多线程间的通讯如何实现多线程间的同步 两个问题。
传统上有两种模型,一种叫做内存共享模型,另外一种则时消息传递模型。而Java采用了内存共享模型,后文简称JMM。这个抽象概念主要描述了:

线程间的共享变量存储在主内存中,每个线程拥有自己的本地内存,每个本地内存上都有该线程读/写变量的副本。

《Java并发编程的艺术》之Java内存模型

然而了解这个模型还不够,我们还要知道在Java编译、运行阶段都会有一种优化手段——重排序。重排序分为以下三种类型:

  • 编译器优化的重排序。在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 指令集并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism)来将多条指令重叠执行,如果不存在数据以来,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器适用缓存和读/写缓冲区,这使得加载和存储操作看上去是乱序执行的。

第1类属于编译器重排序,第2、3类型属于处理器重排序。而JMM作为语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保障。

另外Java尽可能地满足处理器级别的重排序优化,又让程序员自己把握优化程度。正如我们前面所讲的,遇到内存可见性问题,可以简单的用synchronized 大范围的禁止重排序,也可以根据实际情况,选用volatile、final等轻量级同步原语 仅仅禁止关键部分的重排序。

总而言之,发生内存可见性问题的原因不外乎两点:

  1. 本地内存(在CPU那个层面理解为缓冲区)在作怪
  2. 重排序

第一点属于硬件架构问题,基本上是无法从语言层面进行解决。故通过解决重排序问题来解决内存可见性问题

深入底层,了解本地内存

现代处理器都会配备一个写缓冲区,该缓冲区用来暂存写入的变量,保证指令流水线持续运行,避免CPU停下来等待向内存写入数据而产生的延迟;同时也能合并对同一地址的多次写。虽然写缓冲区好处多多,但是只对自己的CPU可见。这个特性在重排序的加持下,容易发生CPU对内存的读/写顺序 和实际内存发生的读/写顺序 不一致的情况。

假设现在有这样一个情况,线程A和线程B并发执行:

  • 处理器A:
    • int a = 1;
    • int a = b;
  • 处理器B:
    • int b = 2;
    • int b = a;

这样的程序偶尔会出现预料之外的结果,比如a和b均为0,或者a=1,b=1等等。具体原因如下图所示

这里CPU0 和 CPU1 均往自己的缓冲区写入数据,然后从内存中读取共享变量,最后才把写缓冲区中的数据刷新至主内存。当以这种时许执行时,就会出现a = y = 0的情况。

《Java并发编程的艺术》之Java内存模型

从内存理想执行角度看,下面的图例可能更符合直观感受:先写入缓冲区,缓冲区刷新到主内存,最后CPU从主内存中读取。对处理器来说,它认为执行顺序是①、②、③,但是实际操作情况却是①、③、②。此时CPU0的内存操作顺序被重排序了。

《Java并发编程的艺术》之Java内存模型

为了解决一些重要操作被重排序导致的问题,处理器提供了一种被称作内存屏障(Memory Fence) 的CPU指令,该指令可以处理重排序和可见性问题。Java编译器在生成指令序列时,会在适当位置中插入内存屏障来禁止特定类型的处理器重排序(所以那些同步原语本质都是内存屏障在其作用)。JVM把内存屏障分为4类,如下所示:

屏障类型 指令示例 说明
LoadLoad Load1;LoadLoad;Load2 在Load2及所有后续读取指令之前,Load1读取数据完毕
StoreStore Store1;StoreStore;Store2 在Store2及所有后续写入指令执行前,Store1写入的数据对其他处理器可见(将修改的变量都刷新到主内存中,毕竟刷新不可能只刷新Store1这一个数据,而是Store1及前面所有的修改后的共享变量)
LoadStore Load1;LoadStore;Store2 在Store2及所有后续写入指令刷新到主内存前,Load1读取数据完毕
StoreLoad Store1;StoreLoad;Load2 在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见(将修改的变量都刷新到主内存中)

最后一个StoreLoad指令是万能指令(有些处理器不支持前三种指令),兼具前三种指令的功能,且开销最大。
内存屏障在禁止重排序、保证内存可见性方面作用极大,为后续JMM的规则打下了基础。

解决内存可见性问题

为了能让JVM在一定程度上(因为重排序就是优化)保持重排序,又能解决内存可见性问题。Java 在 JSR-133 里推出了Happens-Before规则,修改了volatile、final等同步原语

程序顺序规则(as-if-serial)

上面我们都了解到了处理器重排序 对 CPU的影响,现在我们看看编译器重排序对单线程的影响

int a = 1; // ①
int b = 3; // ②
int c = a * b; // ③

在单线程的环境下运行这段代码,会和直观感受一样,它的结果是3,但是它的执行顺序是否和想象中一样就不得而知了。这就引出了一个这么概念——if as serial,这个概念主要描述了 不管如何重排序,单线程程序的执行结果不能被改变,编译器、runtime和处理器都必须遵守这个as-if-serial语义(处理器本身是不会遵守的,但是有JMM的控制)。

因为③依赖①、②的数据,所以为了保证结果一致,无论如何③都不会被排到①或②之前。

as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。这是JMM对程序员作的第一个保证:在单线程下,你不用管重排不重排,结果肯定给你保证一样。

但是面对多线程的情况下,这个语义显得有点单薄,无法保证 多线程的重排序不会对程序有影响。为了能让多线程也无需担心重排序和内存可见性问题,我们需要同步

顺序一致性模型

在讲同步之前,需要了解下顺序一致性模型,这个模型是一个理论参考模型,它为程序提供了极强的内存可见性,该模型有以下两个特点:

  1. 一个线程中的所有操作必须按照程序的顺序来执行
  2. (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致内存模型中,每个操作都必须原子执行且立刻对所有线程可见

在顺序一致性内存模型中,假设现有A、B两个线程,A线程先获取锁再执行A1、A2、A3,B线程同上。它的执行顺序图如下所示
《Java并发编程的艺术》之Java内存模型
当线程A获取锁时,按顺序依次执行A1到A3;在A释放锁,B获取时,按顺序依次执行B1到B3。

假设现在不用锁这些同步工具,它的执行顺序如下所示
《Java并发编程的艺术》之Java内存模型
虽然整体执行顺序发生了改变,但是再每个线程依然能看到一个一致的执行顺序,之所以能保证它一致就是因为上述的两个特点:必须按程序顺序执行、每个操作立即对所有线程可见。
但是在JMM上却没有这个保证,首先JMM不是顺序一致性模型,而且未同步程序的执行顺序是无需的,而且所有线程看到的执行顺序也可能不一样。就比如A线程先写入了一个数值,缓存在本地内存,A线程以为写进去了,但是本地内存只对A线程可见。其他线程仍然是原来的数值,只有等本地内存将数据刷新到主内存,其他线程才可见。这种情况下,当前线程看到的执行顺序和其他线程顺序将不一致。

JMM里正确同步地顺序

讲完了理想的顺序一致性模型,我们回归到现实,看看JMM里的同步程序执行顺序。

int a;
int result;
boolean flag;
public synchronized void init(){
    a = 1;
    flag = true;
}
public synchronized void doTask(){
    if(flag){
        result = a;
    }
}

在这个代码里,假设A线程执行init()方法,B线程执行doTask()方法。根据JMM规范,只要正确同步的程序,结果都会和顺序一致性内存模型的结果一致。下面是该程序在两个内存模型的时序图。
对于JMM来说,在临界区内的代码可任意重排序(但不允许临界区内的代码溢出到临界区外,那样会破坏监视器语言),JMM会在进入临界区和退出临界区时做一些特别的处理,使程序在这两个时间点具有和顺序一致性相同的内存视图。虽然线程A在临界区内进行了重排序,但是在监视器互斥执行的特性下,线程B根本不知道过程,只能观测到结果。这种重排序既提高了效率,又没改变程序的执行结果。

《Java并发编程的艺术》之Java内存模型

从这里我们可以看到JMM在不改变(正确同步)程序执行结果的前提下,尽可能地为编译器和处理器地优化打开了方便之门。

JMM里没有同步地执行顺序

在JMM里,对没有正确同步地程序只提供了最小安全性:线程执行时读取到的值,要么时默认值(0,null,false),JMM保证线程读取到的值不会无中生有。为了实现最小安全性,JVM在堆上分配对象时,首先会对内存清零,然后才会在上面分配对象(JVM内部会同步该操作)。因此,在已清零的内存空间中分配对象时,域的默认初始化已经完成了。

在JMM里,未同步的程序不能保证执行结果和顺序一致性模型的结果一样。因为JVM要这么做的话需要大量禁止重排序,极其影响性能。未同步程序在JMM中的执行顺序整体上是无序的,其执行结果也是不可预测的,在两个模型中的执行特性有如下几个差异。

  • 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程的操作会按程序顺序执行
  • 顺序一致性模型保证所有线程只能看到一致的操作顺序,而JMM不保证所有线程能看到一致的操作执行顺序
  • JMM不保证对64位的long、double型变量的写操作具有原子性,而顺序一致性保证所有对内存的读写操作都是原子性

第三点与处理器总线工作机制密切相关。这里放出书上的原话

JVM在这点上也只是鼓励去做但不强求,因为在一些32位处理器上,要64位的写操作保持原子性是会需要大开销的。所以当JVM在这种处理器上运行时,会把64位long/double型变量拆做两个32位的写操作执行。此时,这种64位变量的写操作将不具有原子性。当单个内存操作不存在原子性时,可能会产生意想不到的后果。

class Target{
    public long a;
}
public static void main(String[] args){
    Target target = new Target();
    new Thread(() -> while(true) target.a = 0l).start();
    new Thread(() -> while(true) target.a = Long.MAX_VALUE).start();

    while(true){
        String binary = toBinary(target.a);
        // 出现不是0,又不是MAX_VALUE的二进制字串是就输出
        if(!binary.equals("00000000000000000000000000000000") && 
        !binary.equals("01111111111111111111111111111111")){
            System.out.println("long不是原子性操作..");
            break;
        }
    }
}
private static String toBinary(long l) {
    StringBuilder sb = new StringBuilder(Long.toBinaryString(l));
    while (sb.length() < 64) {
        sb.insert(0, "0");
    }
    return sb.toString();
}

代码如上所示,假设处理器A写一个了long型变量,同时B处理器要读取这个long型变量。处理器A中的64位操作被拆分为两个32位的写操作,且这两个32位的写操作被分配到不同的事务中执行。同时处理器B 64位的读操作被分配到单个读事务中处理,执行顺序如下图所示时就会发生处理器A写到一半的数据被处理器B看见。
《Java并发编程的艺术》之Java内存模型