JMM(Java Memory Model)

217 阅读14分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第8天,点击查看活动详情

前言

我们先需要了解一下为啥需要了解JMM。并发编程中线程之间的通信可以通过共享内存和消息传递,JMM解决的就是共享内存的数据安全问题,定义了共享内存系统中多线程程序读写操作行为的规范,同时需要屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

并发编程为了保证数据的安全,需要满足三个特性:原子性可见性有序性,我们下面将围绕这三点进行展开。

Java内存模型

Java内存模型的主要目的是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

image.png

主内存(对应的空间为物理硬件的主内存)中存放共享变量。工作内存(寄存器和高速缓存)放了Java线程中需要的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存的变量。

原子性

原子性操作包括lock、unlock、read、load、use、assign、store和write。

  • lock:作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock:作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read:把read操作的值从主内存传输到工作内存中,以便随后的load操作。
  • load:把read操作从主内存中得到的变量值放入工作内存的变量的副本中。
  • use:把工作内存中的变量传给执行引擎。
  • assign:把执行引擎接收到的值赋给工作内存中的变量。
  • store:把工作内存中的一个变量的值传递给主内存,以便随后的write操作。
  • write:把store操作从工作内存中得到的变量的值写到主内存中的变量。

JMM还规定了在执行上述8种基本操作时必须满足如下规则:

  • 不允许read和load、store和write操作之一单独出现,即不允许从主内存读取了变量后工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步会主内存。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说,就是一个变量实施use、store操作之前,必须先执行过了load和assign操作。(新变量作为共享的肯定要在主内存中先产生。)
  • 一个变量在同一时刻只允许一个线程对其执行lock操作,但lock操作可以被同一个线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,那么将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量值。
  • 如果一个变量实现没有被lock操作锁定,那么不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。
  • 一个变量执行unlock操作之前,必须先把此变量同步会主内存中(执行store、write操作)。

除了long和double的非原子性协定(也可以使用volatile解决)其他基本类型大致认为是原子性的。如果应用场景需要一个更大范围的原子性保证,可以使用lock/unlock或者synchronized来实现对数据一系列的读写进行同步。

笔者这里理解的原子性,指的是在操作(可能是多个)数据的过程中,数据不能被多个线程同时操作,只能一起完成才能被读写,不然会读到操作中的数据甚至用来写,会导致数据串乱。总之数据最终需以一个完整的形式呈现

long、double为啥是非原子协定的

我们可以看下oracle官方的描述

For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write.

Writes and reads of volatile long and double values are always atomic.

Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values.

Some implementations may find it convenient to divide a single write action on a 64-bit long or double value into two write actions on adjacent 32-bit values. For efficiency's sake, this behavior is implementation-specific; an implementation of the Java Virtual Machine is free to perform writes to long and double values atomically or in two parts.

Implementations of the Java Virtual Machine are encouraged to avoid splitting 64-bit values where possible. Programmers are encouraged to declare shared 64-bit values as volatile or synchronize their programs correctly to avoid possible complications.

大致意思:在JMM模型中,对于非volatile修饰的long或者double变量的写是被当作两次单独写(在32位机器上,64位不会):每次写一半即32位,所以在并发下无法保证long写入的原子性。如下图:

image.png

可以使用volatile或者synchronize来保证原子性。

为啥使用volatile可以解决

需要了解下volatile的两条实现原则:

  • Lock前缀指令会引起处理缓存回写到内存。Lock前缀指令导致在执行指令期间,声言处理器的LOCK#信号。在多处理器环境中,LOCK#信号确保在声言该声言期间,处理器可以独占任何共享。这需要进行锁总线开销会很大,在最近的处理器里,LOCK#信号一般不锁总线,而是锁缓存,在锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制,缓存一致性会阻止同时修改由两个以上处理器缓存的内存区域数据。

总线事务包括读事务和写事务,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其他所有所有处理器和I/O设备执行内存的读/写。

image.png

  • 一个处理器的缓存回写到内存回导致处理器的缓存无效。IA-32处理器和Intel 64处理器使用MESI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32和Intel 64处理器能嗅探其他处理器访问系统内存和它们的内部缓存。(应该是通过主内存的地址进行关联,缓存中也会保留变量主内存的地址)处理器使用嗅探技术保证它的内部缓存、系统内存和其他处理器缓存的数据在总线上保持一致。例如,在Pentium和P6 family处理器中,如果通过探嗅一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在探嗅将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充。

简单的来说volatile是通过LOCK#信号锁缓存,锁定内存区间会使线程同步读写,所以使用volatile来修饰long/double可以保证原子性。

可见性

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

volatile

前面有提到,volatile在写的时候会刷新到主内存中,使用了锁总线或者锁缓存来实现同步,写完了之后会使其他处理器缓存失效,这样就能保证修改了之后其他线程立即得到修改。

synchronized

《Java之synchronized详解》中有提到,在原子性操作中也有提到,在加锁时会清空工作内存中的缓存重新同步主内存中的,释放锁的时候会把写的值刷新到主内存中。通过这样的机制保障了可见效。

final

final修饰的成员变量需要在初始化完成之后才能被其他线程可见。

有序性

在本线程内观察,所有操作都是有序的,但是在并发场景下会因为“指令重排序”和“工作内存和主内存同步延迟”而带来的无序问题。

指令重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序,重排序分3种类型:

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

image.png

重排序对线程的影响

class ReorderExample {
    int a = 0;
    boolean flag = false;

    public void writer() {
        a = 1;                   //1
        flag = true;             //2
    }

    Public void reader() {
        if (flag) {                //3
            int i =  a * a;        //4
            ……
        }
    }
}

flag变量是个标记,用来标识变量a是否被写入。假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入。

不一定,看下图:

image.png 因为flag=true被重排序到a=1前面,所以B线程读到的是a的初始值。

注意:本文统一用虚箭线标识错误的读操作,用实箭线标识正确的读操作。

除了1和2重排序,3和4也可以重排序,但是因为3和4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令并行度。为此编译器和处理器会采用猜测执行克服控制相关性对并行度的影响。以处理器的猜测执行为例,线程B可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。当操作3的条件判断为真时,就把该计算结果写入变量i中。

image.png

数据依赖

如果两个操作访问同一个变量,且这两个操作中有一个写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:

image.png

编译器和处理器可能会对操作进行重排序,但是不会对有数据依赖的两个操作进行排序,因为改变了之后会影响执行结果,可能能因为优化而导致执行结果发生变化。

注意: 这里说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程执行的操作,不同处理器之间和不同处理器之间的数据依赖性不被编译器和处理器考虑。

happers-before

数据依赖是对单线程的描述,happers-before则不是。happers-before定义的目的是只要不影响执行结果,就不必禁止重排序。

double pi = 3.14; // A 
double r = 1.0; // B 
double area = pi * r * r; // C

根据代码存在三个happens-before关系:

  • A happers-before B
  • B happers-before C
  • A happers-before C 实际上除了A和B必须happers-before C,其实A和B之间的顺序是无所谓的。

image.png

为了让编译器和处理器进行优化,对不影响结果的重排序不做约束。比如编译器经过细致的分析后,认定一个锁只会被单个线程访问,那么这个锁可以被消除。再如编译器经过细致的分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。

定义如下

  • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在happers-before关系,并不意味着java平台的实现必须要按照happens-before关系制定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法。

happens-before规则

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对于一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happends-before于线程B的任意操作。
  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happends-before于线程A从ThreadB.join()操作成功返回。

工作内存和主内存同步延迟

image.png 为啥会出现x=y=0呢?直接看下图:

image.png

因为写入是先存放在工作内存中刷新缓存存在延迟,会导致理解的顺序和实际的顺序不一致的问题。

内存屏障

下面是常见处理器允许的重排序类型的列表:

image.png

从上表中可以看出:常见的处理器都允许Store-Load重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。sparc-TSO和X86拥有相对较强的处理器内存模型,它们仅允许对写-读操作做重排序(因为它们都使用了写缓冲区)。

为了保证内存的可见效,java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为下列四类:

image.png

StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代大多数支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区的数据全部刷新到内存中。

除了在单线程下不能影响执行结果而使用内存屏障,在多线程的情况下也可以使用内存屏障保证有序性,比如上面的《工作内存和主内存同步延迟》,就可以使用volatile避免。

参考

Java面试官告诉你JMM是什么和面什么

《深入理解Java虚拟机 JVM高级特性与最佳实践》

《Java并发编程的艺术》