抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

摘要:本文学习了内存模型及其相关的知识。

环境

Windows 10 企业版 LTSC 21H2
Java 1.8

1 内存模型

1.1 简介

Java内存模型(Java Memory Model,JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过规范定制了程序中各个变量的访问方式。

内存模型规定:

  • 所有的变量都存储在主内存(内存条),每条线程都有着自己独立的工作内存(寄存器,L1、L2、L3缓存)。
  • 线程的工作内存中保存了被该线程使用的变量的主内存副本,简单来说就是把变量从主内存拷到自己的工作内存中。
  • 线程对变量的所有操作都必须在工作内存中进行,而不能直接操作主内存中的变量。
  • 不同线程之间无法访问对方的工作内存中的变量,线程之间变量值的传递均需要通过主内存来完成。

内存模型示例图如下:
20250710092311-内存模型

这里假定一个CPU有多核,一个线程使用一个内核。

1.2 意义

在一个计算机系统中,数据存储的位置主要有硬盘和内存,以及多级缓存。因为访问速度的问题,CPU的运行并不是直接操作内存,而是先将内存中的数据读取到缓存,内存的读操作和写操作产生的时间差异就会造成不一致的问题。

内存模型的主要目的,就是定义程序中各种变量的访问规则,来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

1.3 八种操作

内存模型定义了八种操作来实现变量在主内存和工作内存之间的拷贝和同步:

  1. lock(锁定):作用于主内存的变量,把变量标识为锁定状态。
  2. unlock(解锁):作用于主内存的变量,把锁定状态的变量释放,释放的变量才可以被其他线程锁定。
  3. read(读取):作用于主内存的变量,把变量值从主内存传输到工作内存,以便随后load操作使用。
  4. load(载入):作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存。
  5. use(使用):作用于工作内存的变量,把工作内存的变量值传递给执行引擎,执行使用变量的字节码指令时执行这个操作。
  6. assign(赋值):作用于工作内存的变量,把从执行引擎接收到的值赋给工作内存的变量,执行赋值变量的字节码指令时执行这个操作。
  7. store(存储):作用于工作内存的变量,把变量值从工作内存传输到主内存,以便随后write操作使用。
  8. write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量值放入主内存。

除此之外,内存模型还规定了在执行上诉八种操作时必须满足的规则:

  • 如果要把变量从主内存中复制到工作内存中,就需要按顺序执行read操作和load操作,必须成对使用read操作和load操作。
  • 如果要把变量从工作内存中同步到主内存中,就需要按顺序执行store操作和write操作,必须成对使用store操作和write操作。
  • 不能丢弃最近的assign操作,变量在工作内存中改变之后必须同步回主内存。
  • 变量在发生assign操作才可以从工作内存同步回主内存。
  • 新的变量只能在主内存中诞生。
  • 不允许在工作内存中直接使用未被初始化的变量,执行use操作前必须先执行load操作,执行store操作前必须先执行assign操作。
  • 变量在同一个时刻只允许一条线程对其进行lock操作,lock操作可以被执行多次,解锁需要执行相同次数的unlock操作,lock操作和unlock操作必须成对出现。
  • 执行lock操作会清空工作内存中的变量值,在使用变量前需要重新初始化变量值。
  • 执行unlock操作前必须先同步到主内存中,即执行unlock操作前必须先执行store操作和write操作。

1.4 三大特性

1.4.1 可见性

可见性是指在多线程坏境下,线程在工作内存中修改了某一个共享变量的值,其他线程能够立即获取该共享变量变更后的值。

一般情况下,共享变量不能保证可见性,因为数据修改后被写入内存的时机是不确定的,而线程间变量值的传递均需要通过主内存来完成。

可以使用volatile关键字保证共享变量的可见性,也可以使用同步锁。

1.4.2 原子性

原子性是指在多线程坏境下,线程对数据的操作要保证全部成功或者全部失败,并且不能被其他线程干扰。

线程在读取主内存变量、操作变量、写回主内存变量的一系列过程中,其他线程不能对该内存变量进行修改,或者在发现变量被修改后应重新读取该变量。

一般情况下,共享变量不能保证原子性,因为存在多个线程同时写入共享变量到主内存的情况,这就会导致前一个线程写入的值会被后一个线程写入的值覆盖。

可以使用自旋锁保证共享变量的原子性,也可以使用同步锁。

1.4.3 有序性

有序性是指在多线程环境下,禁止指令重排序,保证结果的一致性。

指令重排序指的是计算机在执行程序时,为了提高性能,会对指令的执行顺序进行调整,并不是按照代码编写顺序执行。

指令重排序图示:
20250713113605-指令重排序

指令重排序分为以下三种:

  • 编译器优化重排序:编译器在不改变程序执行结果的情况下,为了提升效率,会重新安排指令的执行顺序。
  • 指令级并行重排序:处理器在不影响程序执行结果的情况下,为了提升效率,使用指令级并行技术,将多条机器指令重叠执行。
  • 内存系统重排序:为了提升性能,在CPU和主内存之间设置了高速缓存,得加载和存储操作看上去可能是在乱序执行。

指令重排序需要遵守的规则:

  • 在进行重排序时,必须要考虑指令之间的数据依赖性,即有依赖关系的程序不会发生重排序。
  • 在进行重排序后,可以保证在单线程环境中执行的结果一致,不能保证在多线程环境中一致。

可以使用volatile关键字保证共享变量的有序性,也可以使用同步锁。

2 缓存一致性

2.1 背景

计算机核心组件:CPU、内存、IO设备(硬盘)。三者在处理速度上存在巨大差异,CPU速度最快,其次是内存,硬盘速度最慢。

为了提升计算性能,CPU从单核提升到了多核,甚至用到了超线程技术最大化提高CPU处理性能,然而内存和硬盘的发展速度远远不及CPU。

为了平衡三者之间的速度差异,最大化的利用CPU提升性能,做出了很多优化:

  • 硬件层面优化:CPU增加高速缓存。
  • 操作系统层面优化:增加了进程和线程,通过CPU时间片切换最大化提升CPU的使用率。
  • 编译器层面优化:优化指令,更合理的利用CPU高速缓存。

2.2 高速缓存

使用高速缓存作为内存和处理器之间的缓冲,可以很好的解决处理器与内存的速度矛盾。

高速缓存的工作原理如下:

  1. 加载程序及数据到主内存。
  2. 加载程序及数据到高速缓存。
  3. 处理器执行程序,将结果存储在高速缓存。
  4. 高速缓存将数据写回主内存。

带有高速缓存的CPU执行流程如下:
20250713113845-高速缓存

由于CPU运算速度超过了普通高速缓存的处理能力,CPU厂商又引入了多级缓存:
20250713113924-多级缓存

2.3 缓存一致性问题

高速缓存很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,如果CPU里有多个内核,而每个内核都维护了自己的缓存,那么这时候多线程并发就会产生缓存一致性问题。

2.4 总线锁和缓存锁

2.4.1 总线锁(总线控制协议)

为了解决缓存一致性的问题,操作系统提供了总线锁定的机制。

总线(Bus)是一组信号线,用来在计算机各种功能部件之间传送信息。

按照所传输的信息种类,计算机的总线可以划分:

  • 数据总线(Data Bus)用来在处理器和内存之间传输数据。
  • 地址总线(Address Bus)用于在内存中存储数据的地址。
  • 控制总线(Control Bus)用二进制信号对所有连接在系统总线上设备的行为进行同步。

在多线程环境下,当线程要对共享内存进行操作的时候,在总线上发出一个LOCK#信号,这个信号会使其他线程无法通过总线来访问共享内存中的数据。

总线锁定把处理器和内存之间的通信锁住了,这使得锁定期间其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的,后来的处理器都提供了缓存一致性协议。

2.4.2 缓存锁(缓存一致性协议)

相比总线锁,缓存锁即降低了锁的力度,其核心机制是基于缓存一致性协议来实现的。

常用的缓存一致性协议都是属于窥探协议,各个核能够时刻监控自己和其他核的状态,从而统一管理协调。

最常见的协议是MESI协议,MESI表示缓存行(缓存存储数据的单元)的四种状态:

  • M(Modify)表示缓存行是被修改状态,只在当前CPU中有缓存,并且被修改了,还没有更新到主内存中。
  • E(Exclusive)表示缓存行是独占状态,只在当前CPU中有缓存,并且没有被修改。
  • S(Shared)表示缓存行是共享状态,在多个CPU中有缓存,并且没有被修改。
  • I(Invalid)表示缓存行是无效状态,当前CPU中缓存的数据是无效的。

在MESI协议中,每个缓存行都需要监听其它缓存行对共享数据的读写操作。

在多线程环境下,MESI协议的流程如下:

  • 当线程1读取共享数据到缓存行中存储,会将状态设为E。
  • 当线程2读取该共享数据到缓存行中存储,会将状态设为S。线程1监听到线程2读取该共享数据后,会将状态由E改为S。
  • 当线程1修改该共享数据后,会将状态由S改为M,在其他线程读取该共享数据前写回到主内存。线程2监听到线程1修改该共享数据后,会将状态由S改为I。
  • 当线程2修改该共享数据时,发现状态为I,会重新读取共享数据到缓存行,并将状态由I改为E,修改该共享数据后,会将状态由E改为M,在其他线程读取该共享数据前写回到主内存。

如果被操作的数据不能被缓存在处理器内部,或者操作的数据跨越多个缓存行(状态无法标识),处理器会使用总线锁。

另外,当处理器不支持缓存锁时,自然也只能用总线锁了,比如说奔腾486以及更老的处理器。

2.5 内存操作的原子性

原子操作是指不可被中断的一个或者一组操作。

处理器会自动保证基本的内存操作的原子性,也就是一个处理器从内存中读取或者写入一个字节时,其他内存是不能访问这个字节的内存地址。但处理器不能自动保证复杂的内存操作的原子性,比如跨总线宽度、跨多个缓存行或者跨页表的操作。

总线锁和缓存锁是处理器保证复杂内存操作原子性的两个机制。

2.6 存储缓存和无效队列

2.6.1 MESI协议的缺陷

虽然MESI协议保证了缓存的强一致性,但是实现强一致性还需要对CPU提出两点要求:

  • CPU缓存要及时响应总线事件。
  • CPU严格按照程序顺序执行内存操作指令。

只要保证了以上两点,缓存一致性就能得到绝对的保证。但是由于效率的原因,CPU不可能保证以上两点:

  • 总线事件到来之际,缓存可能正在执行其他的指令,例如向CPU传输数据,那么缓存就无法马上响应总线事件了。
  • CPU如果严格按照程序顺序执行内存操作指令,意味着回写数据之前,必须要等到所有其他缓存的失效确认,这个等待的过程严重影响CPU的计算效率。

2.6.2 存储缓存

为了在写回数据时,避免等待其他缓存的失效确认,对每个线程都维护了一个存储缓存(Store Buffer)来暂时缓存要回写的数据。

CPU在将数据写入存储缓存之后就认为写操作已完成,不等待其他缓存返回失效确认继续执行其他指令,等所有的失效确认完成之后,再向存储缓存的数据写回到内存中。

正是因为使用了存储缓存,导致一些数据的内存写入操作可能会晚于程序中的顺序,也就是重排序。

2.6.3 无效队列

因为存储缓存大小是有限制的,并且失效操作比较耗时,于是对每个线程维护了一个失效队列(Invalidation Queue)来存储失效操作。

对于到来的失效请求,失效确认消息马上发出,同时将失效操作放入失效队列,但并不马上执行。

由于使用了失效队列,失效操作不会立即执行,读操作就会读取到过时的数据,导致可见性的问题。

2.7 伪共享

2.7.1 说明

伪共享指的是多个线程同时读写同一个缓存行的不同变量时,会导致CPU缓存失效。看起来是并发执行的,但实际在CPU处理的时候,是串行执行的,并发的性能大打折扣。

伪共享的原因就是CPU在处理失效的时候,是直接废除整个缓存行的操作。

比如:

  • 如果变量A和变量B都在同一个缓存行上,线程1和线程2都缓存了这两个变量。
  • 假如线程1修改了变量A,那么线程2的缓存行就失效了,如果线程2需要修改变量B,就需要等待该缓存行失效后,重新读取主内存中的数据。
  • 这就意味着对变量A和变量B的操作只能是串行的,频繁的多线程操作会导致CPU缓存彻底失效,降级为CPU和主内存直接交互。

2.7.2 解决办法

增大共享变量的内存大小,使得不同线程存取的变量位于不同的缓存行上,典型的空间换时间。

在JDK1.8中,新增了@sun.misc.Contended注解,来使各个变量在缓存行中分隔开,避免伪共享问题,但使用该注解会增加目标实例大小。

默认情况下使用注解无效,需要在JVM中添加参数-XX:-RestrictContended才能开启此功能。

该注解在Thread类、ConcurrentHashMap类、LongAddr类中均有使用。

3 内存屏障

3.1 乱序访问

程序在运行时,为了提升程序运行时的性能,内存实际的访问顺序和程序代码编写的访问顺序不一定一致,这就是内存乱序访问。

乱序访问主要发生在两个阶段:

  • 运行时,多处理器间交互引起内存乱序访问(MESI协议)。
  • 编译时,编译器优化导致内存乱序访问(指令重排)。

3.2 内存屏障

内存屏障能够让处理器或编译器在内存访问上有序,一个内存屏障之前的内存访问操作必定先于其之后的完成。

3.2.1 处理器内存屏障

处理器内存屏障分为两种:

  • Store Memory Barrier(ST, SMB, SMP_WMB):写屏障,CPU在执行屏障之后的指令之前,先执行所有已经在存储缓存中保存的指令。
  • Load Memory Barrier(LD, RMB, SMP_RMB):读屏障,CPU在执行任何的加载指令之前,先执行所有已经在失效队列中的指令。

3.2.2 编译器内存屏障

为了提高性能,编译器会对指令重排序,通过插入内存屏障,可以避免编译器对指令进行重排序。

编译器内存屏障分为四种:

  • LoadLoad(LL)屏障:对于语句Load1; LoadLoad; Load2,保证Load1的读操作在Load2的读操作之前执行。
  • StoreStore(SS)屏障:对于语句Store1; StoreStore; Store2,保证Load1的写操作在Store2的写操作之前执行。
  • LoadStore(LS)屏障:对于语句Load1; LoadStore; Store2,保证Load1的读操作在Load2的写操作之前执行。
  • StoreLoad(SL)屏障:对于语句Store1; StoreLoad; Load2,保证Load1的写操作在Store2的读操作之前执行。

需要注意的是,StoreLoad(SL)屏障同时具备其他三个屏障的效果,因此也称之为全能屏障,是目前大多数处理器所支持的,但是相对其他屏障,该屏障的开销相对昂贵。

3.3 使用场景

3.3.1 volatile

volatile的内存屏障策略非常严格保守:

  • 在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。
  • 在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。

由于内存屏障的作用,避免了volatile变量和其它指令重排序,并且在多线程之间实现了通信,使得volatile表现出了轻量锁的特性。

3.3.2 final

对于final域,必需保证一个对象的所有final域被写入完毕后才能引用和读取。

4 先行发生原则(Happen-Before)

HappenBefor解决的是可见性问题,即前一个操作的结果对于后续操作是可见的。

在内存模型中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在HappenBefor关系。

这两个操作可以是同一个线程,也可以是不同的线程。

八条规则:

  • 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支和循环等结构。
  • 管程锁定规则(Monitor Lock Rule):一个解锁操作先行发生于后面对同一个锁的加锁操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
  • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的其他方法。
  • 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测。
  • 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法。
  • 传递性规则(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

5 volatile

5.1 可见性

可见性是指在多线程坏境下,线程在工作内存中修改了某一个共享变量的值,其他线程能够立即获取该共享变量变更后的值。

示例:

java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class Demo {
public static void main(String[] args) {
try {
DemoThread thread = new DemoThread();
thread.start();
Thread.sleep(100);
thread.setRunning(false);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

class DemoThread extends Thread {
private boolean isRunning = true;

public void setRunning(boolean isRunning) {
this.isRunning = isRunning;
}

@Override
public void run() {
System.out.println("进入方法");
while (isRunning) {
}
System.out.println("执行完毕");
}
}

结果:

log
1
进入方法

线程一直在运行,并没有因为调用了setRunning()方法就停止。

现在有两个线程,分别是main线程和thread线程,它们都在访问isRunning变量。

按照内存模型,main线程将isRunning变量读取到本地线程内存空间,修改后再刷新回主内存。

但是main线程在修改后,还没来得及写入主内存就去做其他事情了,thread线程无法读到main线程改变的isRunning变量,从而出现了死循环,导致thread线程无法终止。

解决办法就是在isRunning变量上加上volatile关键字修饰,强制main线程将修改后的值写回主内存,强制thread线程从主内存中取值。

示例:

java
1
private volatile boolean isRunning = true;

结果:

log
1
2
进入方法
执行完毕

5.2 原子性

原子性是指在多线程坏境下,线程对数据的操作要保证全部成功或者全部失败,并且不能被其他线程干扰。

使用volatile关键字只能保证对单次读写的原子性,不能保证复合操作的原子性。

示例:

java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class Demo {
public static void main(String[] args) {
DemoThread thread = new DemoThread();
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(thread);
threads[i].start();
}
try {
Thread.sleep(1000);
System.out.println(thread.count);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

class DemoThread extends Thread {
public volatile int count = 0;

@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
add();
}

private void add() {
for (int i = 0; i < 100; i++) {
count++;
}
}
}

结果:

log
1
972

在多线程环境下,有两个线程分别将count读取到本地内存。

线程1抢到CPU执行权,执行自增操作后,在尚未写回到主内存前,线程2抢到CPU执行权,执行自增操作后将结果写回到主存,并通知线程1读取的count失效。

线程1再次抢到CPU执行权,将自增操作后的结果写回到主内存,此时覆盖了线程2的写操作,最终导致了count的结果不合预期,并非是1000。

自增操作是由三个指令构成的操作,所以在这三个指令执行期间,线程只会读取一次主内存的数据。

如果想要在复合类的操作中保证原子性,可用使用synchronized关键字来实现,还可以通过并发包中的循环CAS的方式来实现。

5.3 有序性

有序性是指在多线程环境下,禁止指令重排序,保证结果的一致性。

重排序在单线程模式下是一定会保证最终结果的正确性,不能保证多线程环境下结果的正确性。

示例:

java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Demo {
private int count = 1;
private boolean flag = false;

public void write() {
count = 2;
flag = true;
}

public void read() {
if (flag) {
System.out.println(count);
}
}

public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
Demo demo = new Demo();
Thread write = new Thread(() -> {demo.write();});
Thread read = new Thread(() -> {demo.read();});
write.start();
read.start();
}
}
}

运行代码后,控制台打印的数据中应该有1出现,但实际情况却只有2出现,并不能看出程序作了重排序,所以这个地方以后还需要补充。

预测的结果是有1出现,原因是指令进行了重排序,而在write()方法中由于第一步count = 2;和第二步flag = true;不存在数据依赖关系,有可能会被重排序。

使用volatile关键字修饰共享变量便可以禁止这种重排序。若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

评论