Java - JMM (下)

224 阅读15分钟

Java Memory Model  

  Java内存模型用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程 如何 并且 何时 可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步访问共享变量。

Java 1.5重新修订,延用至Java 8。

  Java内存模型(不仅JVM内存分区):调用栈和本地变量存放在线程栈上,对象存放在堆上。 imageimage

  • 原始类型本地变量,存放在线程栈上。
  • 当本地变量指向一个对象的一个引用,引用存放在线程栈,对象存放在
  • 当对象包含方法,且方法包含本地变量时,本地变量存放在线程栈,无论方法所属的对象是否存放在
  • 当对象的成员变量随着自身存放在时,不管成员变量是原始类型还是引用类型。
  • 静态成员变量跟随类定义一起存放在
  • 存放在的对象可以被所有持有对这个对象引用的线程访问。当一个线程访问一个对象时,也可以访问这个对象的成员变量;当两个线程同时调用同一个对象上的同一个方法时,都将会访问这个对象的成员变量,但每个线程都拥有这个成员变量的私有拷贝。

1. 硬件

image

  • CPU 多CPU计算机运行多个线程。多线程Java程序每颗CPU上可能并发运行一个线程。
  • CPU Register CPU内存基础。执行速度远大于在主存上执行速度。
  • CPU cache Memory 读写速度接近处理器运算速度的高速缓存,内存与处理器之间的缓冲。将运算需要使用到的数据复制到缓存中,当运算结束后再从缓存同步回内存之中,非等待缓慢的内存读写。每颗CPU可能有多层缓存,一个或者多个缓存行 cache lines 可能被读到缓存,再被刷新回主存。
  • Main Memory 主存(内存)。

1.1. 运作原理

  当一个CPU读取主存(内存)时,部分读取至CPU缓存中,并可能将缓存中的部分内容读取至内部寄存器中,然后在寄存器中执行操作。当CPU将结果写回主存中时,将内部寄存器的值刷新至缓存中,某个时间点将值刷新回主存。

1.2. Cache Coherence

缓存一致性 

  在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),当多个处理器的运算任务都涉及同一块主内存区域时,可能导致各自的缓存数据不一致,为了解决一致性的问题,各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,包括 MSI MESI(IllinoisProtocol) MOSI Synapse Firefl Dragon Protocol 等等。

image

1.3. 指令重排

  处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,使处理器内部的运算单元被充分利用,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。如果存在一个计算任务依赖另一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。
  Java虚拟机的即时编译器中也有类似的指令重排序(Instruction Reorder)优化。

2. 关联

  JMM与硬件内存架构存在差异,硬件内存架构没有区分线程栈和堆。对于硬件,所有的线程栈和堆都分布在主内存中。部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。

image

2.1. 线程&主内存

  • 线程间共享变量存储在主内存(Main Memory)。
  • 每个线程拥有私有本地内存(Local Memory),本地内存 JMM的抽象概念非真实存在,涵盖了缓存、写缓冲区、寄存器以及其他硬件和编译器优化。本地内存存储该线程以读/写共享变量的拷贝副本。
  • 虚拟机及硬件系统可能会让工作内存(Working Memory)优先于寄存器和高速缓存, 工作内存 是CPU寄存器和高速缓存的抽象描述。
  • JVM的静态内存储模型只是一种对内存的物理划分,局限在内存(只局限在JVM内存)。

imageimage

2.2. 线程通信

  线程间通信必须经过主内存。

执行步骤:

  1. 线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 线程B到主内存中去读取线程A之前已更新过的共享变量。

image

主内存与工作内存之间的具体交互协议,JMM定义了8种操作:

  • lock 锁定作用于主内存变量,将变量标识为线程独占状态。
  • unlock 解锁作用于主内存变量,将处于锁定状态的变量释放,释放后的变量才可以被其他线程锁定。
  • read 读取作用于主内存变量,将变量值从主内存传输到线程的工作内存中,用于 load 动作使用。
  • load 载入作用于工作内存的变量,将 read 操作从主内存中得到的变量值放入工作内存的变量副本。
  • use 使用作用于工作内存的变量,将工作内存中的变量值传递给执行引擎,当虚拟机需要使用变量值的字节码指令时将会执行该操作。
  • assign 赋值作用于工作内存的变量,将执行引擎接收的值赋值工作内存的变量,当虚拟机给变量赋值的字节码指令时执行该操作。
  • store 存储作用于工作内存的变量,将工作内存中的变量值传送到主内存中,用于 write 操作。
  • write 写入作用于主内存的变量,将 store 操作从工作内存中的变量值传送到主内存的变量。   执行上述操作时,必须满足规则:
  • 如果把变量从主内存中复制到工作内存,需按顺寻执行 read 和 load 操作, 如果把变量从工作内存中同步回主内存中,需按顺序执行 store 和 write 操作。JMM只要求上述操作必须按顺序执行,而没有保证必须连续执行
  • 不允许 read 和 load 或 store 和 write 操作单独出现。
  • 不允许线程丢弃最近 assign 操作,即变量在工作内存中改变之后必须同步到主内存中。
  • 不允许一个线程无原因地(无任何assign操作)把数据从工作内存同步回主内存中。
  • 新变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化( load 或 assign )的变量。即对一个变量实施 use 和 store 操作之前,必须执行过 assign 和 load 操作。
  • 某个变量在同一时刻只允许一条线程对其进行 lock 操作,但可以被同一条线程重复执行多次, lock 和 unlock 必须成对出现。
  • 如果变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值。
  • 如果变量没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许 unlock 一个被其他线程锁定的变量。
  • 对变量执行 unlock 操作前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。

3. 核心

3.1. 读同步与可见性

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

3.1.1. 线程缓存

   多线程没有正确使用 volatile 声明或者同步情况下共享一个对象,某线程更新共享对象可能对其它线程不可见,共享对象被初始化在主存中。运行在 CPU 的线程将共享对象读至CPU缓存中,并修改对象。只要 CPU缓存 没有被刷新会主存,对象修改后的版本对其它CPU的线程不可见。这种方式可能导致每个线程拥有这个共享对象的私有拷贝,每个拷贝停留在不同的CPU缓存中。

image

线程缓存可见性解决方法:

  • volatile 保证直接从主存(内存)中读取变量。JMM通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。
  • synchronized 同步块的可见性。
  1. 变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前重新执行 load 或 assign 操作初始化变量值。
  2. 变量执行 unlock 操作之前,必须先把此变量同步回主内存中,执行 storewrite 操作。
  • final 可见性是指被final修饰的字段在构造器中完成初始化,并且构造器没有把 this 的引用传递出去,那么final字段值对其他线程可见。(无须同步)( this 引用逃逸:其他线程有可能通过这个引用访问到“初始化了一半”对象)

3.1.2. 重排序

  线程内表现为串行(Within-Thread As-If-Serial Semantics)。
  在一个线程中观察另一个线程,所有操作都是无序的, 指令重排序 和 线程工作内存与主内存同步延迟 现象。volatile 和 synchronized 保证线程有序性。

  • volatile 包含禁止指令重排序的语义。
  • synchronized 由于一个变量在同一个时刻只允许一条线程对其进行lock操作的规则,决定持有相同锁的同步块只能串行执行。

原理 

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

image

  每个处理器上的写缓冲区,仅仅对它所在的处理器可见。导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于处理器都会使用写缓冲区,因此处理器都会允许对写-读操作进行重排序。 image

数据依赖 

  编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。(此处仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑)。

image

影响 

  当 操作1 和 操作2 之间没有数据依赖关系时,操作1 和 操作2 之间就可能被重排序(操作3 和 操作4 类似)。当 读线程B 执行 操作4 时,不一定能看到 写线程A 在执行1时对共享变量的修改。

image

指令重排序示例 

class ReorderExample {
    int a = 0;
    boolean flag = false;
    
    public void write() {
        a = 1;  // 1
        flag = true;    // 2
    }
    
    public void reader() {
        if(flag) {  // 3
            int i = a * a;  // 4
        }
    }
}

  flag 用来标识 变量a 是否已被写入,当线程A执行 writer() 方法,线程B 接着执行 reader() 方法。 线程B 执行 操作4 时,不一定看到 线程A 在 操作1 对共享 变量a 的写入,由于 操作1 和 操作2 没有数据依赖关系,编译器和处理器可以对这两个操作重排序(操作3操作4 同理)。

as-if-serial 

  无论编译器和处理器怎样提高并行度,程序的执行结果不能被改变。(编译器、runtime 和处理器都必须遵守 as-if-serial 语义)

happens before 

  JDK 5开始使用新JSR-133内存模型,使用 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。 内存屏障禁止特定类型的处理器重排序 

  重排序可能导致多线程程序出现内存可见性问题。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
  为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。 image   StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。

3.2. 写同步与原子性

多线程竞争(Race Conditions)

  当读 / 写和检查共享变量时出现。例如:线程A将共享对象 count 读至CPU缓存,线程B也做相同操作,但在不同的CPU缓存。线程A 和 B同时将 count 加1。如果操作顺序执行,count应该增加两次,然后写回到主存中去。实际两次增加都是在没有适当的同步下并发执行的。无论是线程A还是B将count修改后的版本写回到主存中取,修改后的值仅会被原值大1。 image   使用 synchronized 同步块解决。一个同步块可以保证在同一时刻仅有一个线程可以进入代码的临界区。同步块还可以保证代码块中所有被访问的变量将会从主存中读入,当线程退出同步代码块时,所有被更新的变量都会被刷新回主存中去,无论变量是否声明 volatile。

3.2.1. 多线程写同步

原子性: 要么该操作不被执行;要么以原子方式执行,即执行过程中不会被其它线程中断。

  • Reads and writes are atomic for reference variables and for most primitive variables (all types except long and double).
  • Reads and writes are atomic for all variables declared volatile (including long and double variables)

实现原子性:

  • 由 JMM 来直接保证的原子性变量操作包括 read load assign use store write 我们大致可以认为基本数据类型变量、引用类型变量、声明为volatile 的任何类型变量的访问读写是具备原子性的(long和double的非原子性协定:对于64位的数据,如long和double,JMM 规范允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的 load store read 和 write 这四个操作的原子性,即如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值。)。这些类型变量的读、写天然具有原子性,但类似于 “基本变量++” / “volatile++” 这种复合操作并没有原子性。
  • 如果应用场景需要一个更大范围的原子性保证,需要使用同步块技术。JMM 提供了 lock 和 unlock 操作来满足这种需求。虚拟机提供了字节码指令 monitorenter 和 monitorexist 来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步快——synchronized关键字。

4. 其他

4.1. 压缩指针 - 32G指针失效

  寄存器中3的35次方只能寻址到32g左右,当内存超过32g时,JVM默认停用压缩指针,用64位寻址来操作,这样可以保证能寻址到所有内存,但所有的对象都会变大,实际对比后,40G的对象存储个数比不上30G的存储个数。