Java内存模型的定义
Java 内存模型(Java Memory Model, JMM) 是 Java 虚拟机 (JVM) 定义的一种规范,用于描述多线程程序中变量(包括实例字段、静态字段和数组元素)如何在内存中存储和传递的规则。规范了线程何时会从主内存中读取数据、何时会把数据写回主内存。
操作系统有一套内存模型,而 Java 是跨平台实现的,因此它需要自己定义一套内存模型屏蔽各操作系统之间的差异。
JMM 定义了 Java 源码到 CPU 指令执行一套规范,我们仅需直接使用 Java 提供的并发类(synchronized
、volatile
等),知晓它定义的 happens-before 原则,即可写出并发安全的代码,无需关心底层的 CPU 指令重排、多级缓存等各种底层原理。
常见并发模型
Java 使用的是共享内存并发模型
三大特性
JMM 的核心目标是确保多线程环境下的可见性、有序性和原子性,从而避免由于硬件和编译器优化带来的不一致问题。
原子性
原子性 指的是一个操作或一系列操作要么全部执行成功,要么全部不执行,期间不会被其他线程干扰。
- 原子类与锁:Java 提供了
java.util.concurrent.atomic
包中的原子类,如AtomicInteger
,AtomicLong
,来保证基本类型的操作具有原子性。此外,synchronized
关键字和Lock
接口也可以用来确保操作的原子性。 - CAS(Compare-And-Swap):Java 的原子类底层依赖于
CAS
操作来实现原子性。CAS
是一种硬件级的指令,它比较内存位置的当前值与给定的旧值,如果相等则将内存位置更新为新值,这一过程是原子的。CAS
可以避免传统锁机制带来的上下文切换开销。
可见性
可见性 指的是一个线程对共享变量的修改,能够被其他线程及时看见。
- volatile:
volatile
关键字是 Java 中用来保证可见性的轻量级同步机制。当一个变量被声明为volatile
时,所有对该变量的读写操作都会直接从主内存中进行,从而确保变量对所有线程的可见性。 - synchronized:
synchronized
关键字不仅可以保证代码块的原子性,还可以保证进入和退出synchronized
块的线程能够看到块内变量的最新值。每次线程退出synchronized
块时,都会将修改后的变量值刷新到主内存中,进入该块的线程则会从主内存中读取最新的值。 - Java Memory Model(JMM):JMM 规定了共享变量在不同线程间的可见性和有序性规则。它定义了内存屏障的插入规则,确保在多线程环境下的代码执行顺序和内存可见性。
有序性
有序性 指的是程序执行的顺序和代码的先后顺序一致。但在多线程环境下,为了优化性能,编译器和处理器可能会对指令进行重排序。
- 指令重排序:为了提高性能,处理器和编译器可能会对指令进行重排序。尽管重排序不会影响单线程中的执行结果,但在多线程环境下可能会导致严重的问题。例如,单例模式 中的 双重检查锁定(DCL) 模式在没有正确同步的情况下,由于指令重排序可能导致对象尚未完全初始化就被另一个线程访问。
- happens-before 原则:JMM 定义了 happens-before 规则,用于约束操作之间的有序性。如果一个操作 A happens-before 操作 B,那么
A
的结果对于 B 是可见的,且 A 的执行顺序在 B 之前。这为开发者提供了在多线程环境中控制操作顺序的手段。 - 内存屏障:
volatile
变量的读写操作会在指令流中插入内存屏障,阻止特定的指令重排序。
线程不安全问题
内存可见性问题
共享变量
对于每一个线程来说,栈都是私有的,而堆是共有的。
在栈中的变量(局部变量、方法定义的参数、异常处理的参数)不会在线程之间共享,也就不会有内存可见性的问题,也不受内存模型的影响。
而在堆中的变量是共享的,一般称之为共享变量。
CPU缓存模型
CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。
可以把 内存看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。
工作方式:先复制一份数据到 CPU 缓存 中,当 CPU 需要用到的时候就可以直接从 CPU 缓存 中读取数据,当运算完成后,再将运算得到的数据写回 主存 中。
CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议(比如 MESI 协议)或者其他手段来解决。
问题定义
问题定义如下:
- CPU 缓存一致性 :每个 CPU 核心都可能对共享变量在本地缓存中进行修改,而不立即将修改的值写回到主存(RAM)。这意味着,一个线程对变量的修改可能只有它自己能看到,其他线程可能会看到旧的值,因为它们依然访问的是自己缓存中的旧值。
- 写回延迟 :当线程修改变量时,它通常会先将修改结果写入自己的 CPU 缓存,而不立即更新到主存。这种写回延迟可能导致其他线程不能及时看到变量的更新,因为其他线程从主存读取数据时并没有得到最新的值。
- 缓存刷新与同步 :由于缓存的写入并不是同步的,多个线程之间的修改没有立即同步到主存。线程 A 可能在自己的缓存中修改了变量 x,但线程 B 可能依然读取的是线程 A 修改前的值,直到缓存被刷新或被同步。
指令重排问题
问题定义
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序
- 编译器优化的重排序 :编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序 :现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应 机器指令的执行顺序。
- 内存系统的重排序 :由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
单线程情况下不会影响程序执行结果。多线程情况下,指令重排可能导致线程之间的数据不一致问题,影响并发的正确性。
指令重排可以保证串行语义一致,但是没有义务保证多线程间的语义也一致。所以在多线程下,指令重排序可能会导致一些问题。
示例:单例模式中的“双重检查锁定”就是为了避免指令重排的问题。在初始化单例对象时,由于编译器或 CPU 的指令重排,可能会导致另一个线程读取到未初始化完成的对象。
|
|
instance = new Singleton();
的执行步骤
- 分配内存空间。
- 初始化对象。
- 将对象指向内存地址。
如果没有 volatile
关键字,编译器或处理器可能会重排步骤 2 和步骤 3,这就会导致另一个线程可能读取到一个尚未初始化完成的对象
Java内存模型的结构
抽象的来看 JMM 会把内存分为 本地内存 和 主存 ,每个线程都有自己的私有化的 本地内存,然后还有个存储共享数据的 主存 。
主存:所有共享变量(实例变量、静态变量)存储在 主存(也就是所有线程共享的内存区域)。
本地内存:每个线程都有一个私有的本地内存,本地内存存储了该线程以读 / 写共享变量的 副本 。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。 如果线程间需要通信,必须通过 主存 来进行。
由图可知
- 所有的 共享变量 都存在 主存 中。
- 每个线程都保存了一份该线程使用到的 共享变量 的副本。
- 如果线程间需要通信,必须通过 主存 来进行
- 当一个线程更改了本地内存中共享变量的副本后,它需要将这些更改刷新到主内存中,以确保其他线程可以看到这些更改。
- 当一个线程需要读取共享变量时,它可能首先从本地内存中读取。如果本地内存中的副本是过时的,线程将从主内存中重新加载共享变量的最新值到本地内存中。
提示
为什么线程需要拥有自己的内存
-
在多线程环境中,如果所有线程都直接操作主内存中的共享变量,会引发更多的内存访问竞争,这不仅影响性能,还增加了线程安全问题的复杂度。通过让每个线程使用本地内存,可以减少对主内存的直接访问和竞争,从而提高程序的并发性能。
-
现代 CPU 为了优化执行效率,可能会对指令进行乱序执行(指令重排序)。使用本地内存(CPU 缓存和寄存器)可以在不影响最终执行结果的前提下,使得 CPU 有更大的自由度来乱序执行指令,从而提高执行效率。
内存可见性的保证
volatile 关键字
- 写入
volatile
变量时,JMM 强制把值 刷新到主存。 - 读取
volatile
变量时,JMM 强制从主存 获取最新值,而不是从缓存读取。 - 禁止指令重排序(确保
volatile
变量的读写顺序不会被 JVM 或 CPU 乱序执行)。
synchronized 关键字
- 进入
synchronized
方法或代码块时,线程必须从主存读取最新变量。 - 退出
synchronized
方法或代码块时,线程必须把修改的变量刷新回主存。 - 由于同一时间只有一个线程能进入
synchronized
代码块,它可以确保变量修改对所有线程可见。
顺序一致性的保证和happens-before规则
指令重排的限制条件
- 在单线程环境下不能改变程序运行的结果
- 存在数据依赖关系的不允许重排序。
happens-before规则的定义
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。
主要规则
- 程序次序规则:在一个线程中,代码的执行顺序是按照程序中的书写顺序执行的,即一个线程内,前面的操作 happens-before 后面的操作。
- 监视器锁规则:一个锁的解锁(
unlock
)操作 happens-before 后续对这个锁的加锁(lock
)操作。也就是说,在释放锁之前的所有修改在加锁后对其他线程可见。 - volatile 变量规则:对一个
volatile
变量的写操作 happens-before 后续对这个volatile
变量的读操作。它保证volatile
变量的可见性,确保一个线程修改volatile
变量后,其他线程能立即看到最新值。 - 线程启动规则:线程 A 执行
Thread.start()
操作后,线程 B 中的所有操作 happens-before 线程 A 的Thread.start()
调用。 - 线程终止规则:线程 A 执行
Thread.join()
操作后,线程 B 中的所有操作 happens-before 线程 A 从Thread.join()
返回。 - 线程中断规则:对线程的
interrupt()
调用 happens-before 线程检测到中断事件(通过Thread.interrupted()
或Thread.isInterrupted()
)。 - 对象的构造规则:对象的构造完成(即构造函数执行完毕) happens-before 该对象的
finalize()
方法调用。