zl程序教程

您现在的位置是:首页 >  后端

当前栏目

Java高并发:Java内存模型

2023-06-13 09:15:24 时间

一、CPU物理缓存结构

1 高速缓存

为了缓解CPU和内存访问速度的矛盾,增加了速度更快的多级高速缓存。

CPU通过高速缓存进行数据读写有以下优势:

  1. 写缓冲区可以保证指令流持续运行,避免CPU停顿下来等待向内存写回数据的延迟;
  2. 可以以批处理的方式刷新写缓冲区,以及写缓冲区对同一地址的多次写,减少内存总线的占用。
CPU缓存架构

因为缓存脏数据写回主内存一般采用的是写回法,而非直写法,所以缓存和主存之间会有数据一致性问题。

2 并发编程的三大问题

原子性问题:不可中断的一系列动作,不会被线程调度机制打断,也不会被CPU响应中断打断。

可见性问题:CPU修改本地缓存的数据,采用写回法刷新缓存,在刷回之前,其他CPU无法看到最新的版本。

有序性问题:编译器重排序和CPU重排序调整了指令顺序,发挥指令并行能力,优化程序性能。如果排序后执行结果和程序顺序执行不一样,则存在有序性问题。

3 MESI协议原理

3.1 缓存锁

MESI协议采用缓存锁而非总线锁,锁粒度更小,CPU并行能力更强。

每个CPU会通过嗅探在总线上传播的数据来检查自己高速缓存中的值是否过期,当CPU发现自己缓存行对应内存地址被修改时,就会将当前CPU行设置为无效。当CPU对这个数据进行修改时就需要重新从主内存读取。

3.2 状态

  1. M:Modified,该缓存行只在本CPU私有缓存中有,其他CPU没有,本地被修改的数据,与内存不一致。如果该行数据在某个时间点被刷回主存,则将状态修改为E。
  2. E:Exclusive,该行数据只有本CPU私有缓存拥有,其他CPU并未读取,并且该数据并未被修改,与主存一致。如果该缓存行被其他CPU读取,那么状态就会修改为S。如果该行数据被修改,则将状态修改为M。
  3. S:Shared,在多个CPU的私有缓存中有,并且没有被修改过,当一个CPU修改这行数据,其他CPU就需要将这行修改为无效状态。
  4. I:Invalid,可能某个CPU修改过这行数据,其他CPU置为无效。

CPU1读取数据a,这一行数据状态为E独占状态,如果其他CPU读取数据a,那么这一行修改为S共享状态。如果CPU1修改了数据a,那么在CPU1中这一行修改为M状态,CPU2中这一行修改为I无效状态。如果CPU2再次读写数据a,需要CPU1将这一行刷回主存,CPU2再次从主存读取,确保可见性。

4 重排序

4.1 编译器重排序

在代码编译阶段进行指令重排,不改变程序执行结果的情况下,为了提升效率,编译器对指令进行乱序编译。目的是与其等待阻塞指令完成,不如先去执行其他指令。与CPU重排序相比,编译器重排序能够完成更大范围、效果更好的乱序优化。

4.2 CPU重排序

流水线和乱序执行是现代CPU基本都具有的特性。机器指令在流水线中经历取指、译码、执行、访存、写回等操作,每个阶段交给不同部件完成,可以由这些部件执行不同指令的不同阶段,提高并行能力。只要满足as-if-serial规则,处理顺序和程序顺序可以不一致。但是只能保证指令之间显式因果关系,无法保证隐式因果关系,即无法保证语义上不相关但程序逻辑上相关的操作序列按序执行。

as-if-serial规则可以保证单CPU执行时保证结果正确,不能保证多CPU情况下的执行结果正确。

5 内存屏障

  1. 读屏障:让高速缓存中相应数据失效。在指令前插入读屏障,可以让高速缓存中数据失效,强制从主存加载数据。并且,先于这个屏障的指令必须先执行。
  2. 写屏障:在指令后插入写屏障指令能够让高速缓存中的最新数据立刻刷新到主存,其他线程可见。并且,后于屏障指令的指令必须后执行。
  3. 全屏障:具备读屏障和写屏障能力,又称为StoreLoad。

内存屏障作用

  1. 禁止屏障两侧指令重排;
  2. 强制让高速缓存数据失效。

volatile在x86处理器上被JVM编译后,汇编代码中会插入一条lock前缀指令,实现全屏障的作用。

二、JMM

1 目的

JMM是一套规范,该规范定义了一个线程对共享变量写入时,如何确保对另一个线程可见,提供了合理的禁用缓存以及禁止重排序的方法核心价值是解决可见性和有序性。另外,JMM定义了一套抽象指令,由JVM编译为具体的机器指令,用于屏蔽不同硬件的差异性,保证Java程序在不同平台下对内存访问是一致的。

2 JMM与硬件内存架构的关系

对于硬件内存来说只有寄存器、高速缓存、主存等概念,没有工作内存(线程私有数据区域,虚拟机栈)、主存(堆内存)之分。也就是说Java内存模型对内存的划分对硬件内存没有任何影响,因为JMM只是一种抽象,是一组规则,并不实际存在,对硬件来说都会存储到主存、寄存器或者高速缓存中。

Java内存模型

3 JMM的八个操作

JMM定义了一套主存和工作内存的交互协议,包含八种操作,并要求JVM具体实现必须保证每一种操作是原子的。

JMM交互协议
  1. 将一个变量从主存读到工作内存,需要按照顺序执行read、load指令,可以不连续,但不能少;
  2. 将一个变量从工作内存写回主存,需要按照顺序执行store、write指令,可以不连续,但不能少;

4 可见性问题

valatile的lock前缀指令能够触发CPU的MESI缓存一致性协议,锁住缓存行并通知其他CPU本地缓存中该行失效,确保不会有多个CPU同时修改共享变量。其他CPU访问共享变量时发现该缓存行失效,就会从主存重新加载,因此保证了可见性。

5 重排序

5.1 as-if-serial

as-if-serial语义:无论如何重排序,都必须保证代码在单线程下运行正确。编译器和CPU不会对存在数据依赖的指令进行重排序。

5.2 happens-before

happens-before语义:无论如何重排序,都必须保证在多线程下运行正确。为此,JMM会禁止特定类型的编译器重排序和指令重排序,提供跨线程的内存可见性。编译器会插入内存屏障指令,特定指令和两侧指令发生重排序,确保执行结果与程序顺序执行一致。

本质上,这些规则是解决各种场景在并发时的可见性问题:

  1. 程序顺序规则:一个线程中的每个操作都应该happens-before该线程任何后续操作。即线程内每个操作的结果对该线程所有后续操作都可见。
  2. 监视器锁规则:对一个锁的解锁,happens-before随后对这个锁的加锁。即线程解锁时对共享变量的修改结果能够被后续加锁的线程看到。
  3. volatile变量规则:对于一个volatile变量的写happens-before后续对这个变量的读。即volatile写的结果能够被后续volatile读看到。
  4. 传递性规则:如果a happens-before b,b happens-before c,那么a happens-before c。
  5. start()规则:如果线程A执行操作threadb.start(),那么线程A的threadb.start()操作happens-before于线程B的任意操作。即线程A启动线程B,线程B能够看到线程A的threadb.start()操作时的状态。
  6. join(): 如果线程A执行操作threadb.join()并成功返回,那么线程B的任意操作happens-before于线程A从threadb.join()操作成功返回。即线程B对共享变量的修改状态对于从threadb.join()成功返回的线程A来说可见。

6 volatile

valatile的lock前缀指令能够触发CPU的MESI缓存一致性协议,锁住缓存行并通知其他CPU本地缓存中该行失效,确保不会有多个CPU同时修改共享变量。其他CPU访问共享变量时发现该缓存行失效,就会从主存重新加载,因此保证了可见性。

为了实现volatile关键字语义的有序性,JVM编译器在生成字节码时会在指令序列插入内存屏障来禁止特定类型的处理器重排序。

1 可见性是通过CPU缓存一致性协议MESI来保证的。 2 原子性无法保证,CPU缓存一致性MESI的缓存锁可以保证单条volatile写指令是原子的,但是多线程修改共享变量时不止一条指令,比如i++就有三条指令,无法保证原子性。 3 有序性是通过内存屏障指令来保证的,可以在volatile操作与两侧指令发生重排。注:volatile读操作要求read、load、use要连续执行,不能插入其他指令,可以确保每次volatile读操作可以从主存读取到最新数据。 volatile写操作要求assign、store、write要连续执行,不能插入其他指令,可以确保每次volatile写操作可以立刻从工作内存写回主存。

volatile修饰的变量前面会有一条lock前缀指令,该指令有三个功能:

  1. 将当前CPU缓存行立刻写回主内存,lock指令可以激活缓存锁,阻止多个CPU同时修改共享内存的数据,只锁住了缓存写回主内存的写回操作,而CPU对共享数据的运算赋值过程并没有锁住。
  2. 会引起其他CPU中缓存了该内存地址的数据无效。写回操作经过总线传播,其他CPU嗅探到该数据检查自己缓存的值是否过期。
  3. 禁止重排序,作为内存屏障使用。

7 synchronize

synchronize是互斥锁,由JVM实现,实际上是调用了操作系统的pthread_mutex_lock系统调用。每个Java对象都有一个监视器对象同生共死,获取锁失败的线程会进入监视器对象的阻塞队列等待被唤醒。

  1. synchronize可以修改方法,方法入口会有ACC_SYNCHRONIZE标志来显示该方法是一个互斥访问的方法。
  2. synchronize修饰代码块的话会有monitorenter和monitorxit指令。

ACC_SYNCHRONIZE和monitorenter、monitorxit都是访问监视器对象的方式。

为什么synchronize可以保证原子性、可见性和有序性问题呢?

synchronize是互斥锁,可以保证原子性。 synchronize使用后unlock时会强制将修改的共享变量刷回主存,保证可见性。 synchronize修饰的临界区入口和出口这两个时间点是拥有和程序顺序执行一致的状态,并且禁止临界区和两侧指令发生重排序,但是不能禁止临界区内部指令重排。