java-jvm-3-JMM

108 阅读14分钟

JMM

JVM内存结构可以理解为jvm的逻辑内存管理;

JMM可以理解为JVM逻辑内存到物理内存的映射

CPU缓存结构

cpu_2.jpeg

mac的cpu硬件:

  • 一个CPU
  • 2个核,每个核内有L1级缓存(cache line,即寄存器内存)
  • 2个L2级缓存,256K,在核外
  • 一个共享缓存L3,3M
  • 一个主内存8G

cpu_1.png

这里的cpu应该是core更准确....

单线程执行i = i + 1为例,当线程执行到这条语句时,会先从主存中读取i的值,然后复制一份到缓存中,CPU 读取缓存数据(取数指令),进行 i + 2 操作(中间数据放寄存器),然后把结果写入缓存,最后将缓存中i最新的值刷新到主存当中(写回时间不确定)

i如果是共享变量(heap内,实例属性),但是多线程中运行就有可能出问题。例如:有A、B二个线程,在不同的CPU 上运行,因为每个线程运行的CPU 都有自己的缓存,A 线程从内存读取i 的值存入缓存,B 线程此时也读取i 的值存入自己的缓存,A 线程对i 进行+1操作,i变成了1,B线程缓存中的变量 i 还是0,B线程也对i 进行+1操作,最后A、B线程先后将缓存数据写入内存,内存预期正确的结果应该是2,但是实际是1。 这个就是非常著名的缓存一致性问题。

cpu_3.png

说明:单核CPU的多线程也会出现上面的线程不安全的问题,只是产生原因不是多核CPU缓存不一致的问题导致,而是CPU调度线程切换,多线程局部变量不同步引起的(线程A,B在同一块缓私有存内); 这个是需要通过code解决的线程安全问题..

缓存一致性

CPU的结构就说明了缓存一致性存在的原因...

缓存一致性解决

总线锁

lock#

因为CPU都是通过总线来读/写主存中的数据,因此对总线加Lock# 锁的话,其他CPU访问主存就被阻塞了,这样防止了对共享变量的竞争(写)。但是锁总线对CPU的性能损耗非常大,把多核CPU并行的优势直接给干没了!

cpu不停触发总线锁可能导致内存风暴

MSEI

出现了总线锁的替代产品..MESI

后面研究人员就搞出了一套协议:缓存一致性协议。协议的类型很多(MSI、MESI、MOSI、Synapse、Firefly)·最常见的就是Intel 的MESI 协议

通过内存指令标记cache-line的状态..应该能减少内存风暴的发生.

mesi_1.png

缓存一致性协议主要规范了CPU读写主存、管理缓存数据的一系列规范(作用于缓存和主内存之间)

MESI 协议的核心思想:(像是基于状态都锁)

  1. 定义了缓存中的数据状态只有四种,MESI是四种状态的首字母。
  2. 当CPU写数据时,如果写的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态;
  3. 当CPU读取共享变量时,发现自己缓存的该变量的缓存行是无效的,那么它就会从内存中重新读取。

缓存中数据都是以缓存行(Cache Line)为单位存储;MESI各个状态描述如下表所示:

mesi_2.png

volatile是Java中标识变量可见性的关键字,说直接点:使用volatile修饰的变量是有内存可见性的,这是Java 语法定的,Java 不关心你底层操作系统、硬件CPU 是如何实现内存可见的,我的语法规定就是volatile修饰的变量必须是具有可见性的

java语法逻辑硬性规定。

CPU有X86(复杂指令集)、ARM(精简指令集)等体系架构,版本类型也有很多种,CPU可能通过锁总线、MESI协议实现多核心缓存的一致性。因为有硬件的差异以及编译器和处理器的指令重排优化的存在,所以Java 需要一种协议来规避硬件平台的差异,保障同一段代表在所有平台运行效果一致,这个协议叫做Java内存模型(Java Memory Model)。

Java通过Java内存模型(JMM)实现volatile平台无关

JMM

其实就是jvm内存模型, 通过这里理解2个问题:缓存一致、指令重排

  1. c/cpp程序理解执行过程

所有的变量都存储在主内存中,每个线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量(主内存的拷贝),线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。 不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成.

  1. 线程执行

mem_1.png

  1. 初始变量首先存储在主内存中;
  2. 线程操作变量需要从主内存拷贝到线程本地内存中;
  3. 线程的本地工作内存是一个抽象概念,包括了缓存、store buffer(后面会讲到)、寄存器等。
  1. 线程通信

mem_2.png

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

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

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作(单一操作都是原子的)来完成:

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量解除锁定,解除锁定后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(有的指令是save/存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

java_mem_1.png

java_mem_2.png

java_mem_3.png

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

  • 如果要把一个变量从主内存中复制到工作内存,需要顺序执行read 和load 操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store 和write 操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行,也就是操作不是原子的,一组操作可以中断。
  • 不允许read和load、store和write操作之一单独出现,必须成对出现。
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)

jvm内存映射

jmm_1.png

JMM是内存模型,是逻辑的协议。首先物理的内存架构是没有区分堆和栈的,这个Java的JVM来做的划分,另外线程私有的本地内存线程栈可能包括CPU寄存器、缓存和主存。堆亦是如此

volatile

  1. 可见性问题:如果对象obj 没有使用volatile 修饰,A 线程在将对象count读取到本地内存,从1修改为2,B 线程也把obj 读取到本地内存,因为A 线程的修改对B 线程不可见,这是从Java内存模型层面看可见性问题(前面从物理内存结构分析的)

volatile_1.png

  1. 有序性问题:重排序发生的地方有很多,编译器优化、CPU 因为指令流水批处理而重排序、内存因为缓存以及store buffer 而显得乱序执行。如下图所示:

volatile_2.png

2.1 CPU重排序

seq_cpu.png

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

2.2 内存重排序

seq_mem.png

happens-before原则保障有序性

JSR-333 规范JDK5定义的内存模型规范,

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。

  1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  2. 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
  3. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  4. 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
  5. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
  6. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  7. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
  8. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  9. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  10. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

内存屏障保证了共享内存变量的可见性

mem_cmd_1.png

JVM中提供了四类内存屏障指令。。。

mem_cmd_2.png

JSR-133 定义的相应的内存屏障,在第一步操作(列)和第二步操作(行)之间需要的内存屏障指令如上。。。

mem_cmd_3.png

synchronized 也可以实现有序性和可见性,但是是通过锁让并发串行化实现有序,内存屏障实现可见。 final关键字需要StoreStore屏障 x.finalField = v; StoreStore; sharedRef = x;

As-if-serial

As-if-serial语义的意思是,所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义。

并发:多个程序可能同时运行的现象,例如刷微博和听歌同时进行,可能你电脑只有一颗CPU,但是通过时间片轮转的方式让你感觉在同时进行。

并行:多核CPU,每个CPU 内运行自己的线程,是真正的同时进行的,叫并行。

keyword

JMM

  1. 缓存一致性(可见性)
  2. 指令重排(happen-before)

happens-before

主要解决内存访问重排序问题(volatile/final)

volatile-final如何禁止重排序

java内部解决可见性指令重排的方式(目前):

  1. 通过volatile解决可见性(多线程),指令重排(初始化)
  2. 通过final解决指令重排(初始化)

Memory-Barrier(内存屏障)

是一种CPU指令(4个),用于控制特定条件下的重排序内存可见性问题;

几个概念对比

这些应该是可以解释多线程和锁的关键词

  1. 可见性(volatile):可见性是指当多个线程访问同一个共享变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改后的值
  2. 原子性(Atomic):原子性指的一个操作或一组操作要么全部执行,要么全部不执行
  3. 有序性(happen-before):有序性是指程序执行的顺序按照代码的先后顺序执行;执行顺序不会被优化
  4. 线程同步(sync):多线程协调运行

单例

// 双重校验单例
public class Singleton {
    // 1.private: 不能被外部不能构造
    private Singleton() {}
    // 2. static: jvm中只有一个单例,
    // 3. volatile: 内存可见,禁止指令重排
    private static volatile Singleton uniqueInstance;
    
    public static Singleton getUniqueInstance() {
        // 4. 判断是否需要入锁,重量级的锁
        if (uniqueInstance == null) {
            // 5. 类对象加锁,uniqueInstance此时=null
            synchronized (Singleton.class) {
                // 6. 判读是否重复初始化
                if (uniqueInstance == null) {
                    // 7.使用volatile禁止指令重排
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

// 
// 步骤7初始化对象可简单分位三步(Classloader的过程)
// 1. 分配空间
// 2. 初始化空间(构造)
// 3. 引用赋值, 空间指向引用

volatile可以实现内存的可见性和防止指令重排序,但是volatile不保证操作的原子性。那么volatile是怎么实现可见性和有序性的呢?其实volatile的这些内存语意是通过内存屏障指令技术实现的。

  1. synchronized 解决并发
  2. volatile 解决内存可见

ps

  1. mp.weixin.qq.com/s/R1D5tfuMV…
  2. tech.meituan.com/2014/09/23/…