文章目录
- Java内存模型
- 前言
- Java内存模型基本介绍
- 指令重排相关概念
- 主存和本地内存相关介绍
- JMM中的8种同步规则和8种同步操作
- happens-before 原则
- 内存屏障
- 总结
Java内存模型
前言
本文主要介绍一下JMM中的一些常见概念,通过本文让你能够快速的对JMM有一个大致的了解
Java内存模型基本介绍
-
Java内存模型是什么?
Java内存模型一般简称 JMM(Java Memeory Model),是为了规范 Java 程序多线程之间共享数据的访问规则而定义的。它主要关注程序中的变量在内存中的可见性、顺序性和操作原子性等方面的问题。Java内存模型定义了一系列的规则和保证,用于确保多线程程序中的共享变量能够正确、可靠地被访问和修改。
温馨提示:一定不要和 JVM内存结构、JVM内存模型、Java内存模型三个概念给搞混了,看网上好多人直接认为 JVM 内存模型就算 JMM,三者之间的区别:
- JVM内存结构:JVM内存结构也称 JVM内存区域、Java内存区域,是指 JVM 的内存组成,其中包括虚拟机栈、堆、本地方法栈、元空间(方法区)、程序计数器、直接内存
- JVM内存模型:是比 JVM内存结构 更大的一个概念,它不经包含了JVM内存结构,还包括了JVM内存工作方式
- Java内存模型:是针对多线程编程中共享数据的可见性、指定重排等问题而提供的一套规范,已解决多线程之间内存的一致性问题,JMM决定了一个线程堆共享变量的写入合适对另一个线程可见
总结:JVM内存结构是JVM内存模型的子集,JVM内存模型侧重概念,Java内存模型侧重规范
-
什么需要JMM?
因为多线程开发中会存在很多问题,比如共享数据的可见性问题、指令重排等问题,为了解决这些问题,就需要制定一些规范,也就是所谓的内存模型。一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java的早期创建者为了确保 Java程序的可移植性,就直接设计了一套专属于 Java 的内存模型(规范),Java开发者只需要遵循这一套,就可以编写正确、可靠且高效的多线程程序,避免常见的并发问题,确保数据的一致性和线程安全性。
总结:为了解决多线程种数据的一致性问题、指令重排问题,Java提供了一套规范也就是 JMM,通过这套规范,Java开发者在不需要了解底层原理就能很简单(直接使用并发相关的一些关键字和类)解决Java多线程开发种的一些问题。
-
JMM的作用?
JMM 说白了就是定义了一些规范来解决并发编程中的常见问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如
volatile
、synchronized
、各种Lock
)即可开发出并发安全的程序总结:解决多线程开发种遇到的指令重排、可见性问题
指令重排相关概念
-
什么是指令重排?
指令重排(Instruction Reordering)是指编译器或处理器在执行程序时,为了优化性能而改变原始指令序列的顺序。这种重排可能会导致代码的执行顺序与源代码中编写的顺序不一致。
-
为什么需要指令重排?
指令重排的出现是为了提高程序的执行效率和性能。处理器或编译器会尽量利用现代计算机体系结构的特性,如流水线执行、乱序执行、寄存器重命名等技术进行优化。通过重排指令,可以更好地利用处理器的资源,减少潜在的数据依赖和等待时间,从而加快程序的执行速度。
-
导致指令重排的原因有哪些?
- 数据依赖性:某些指令的执行依赖于前面指令的结果。如果处理器或编译器认为这些依赖关系不会产生冲突,并且执行后续指令不受影响,就可以对指令进行重排。例如,A依赖C,B依赖C,A和B互不依赖,就会重排A和B,但是C和A、B是无法重排
- 内存屏障(Memory Barriers):内存屏障是一种同步操作,用于控制内存访问的顺序。当处理器或编译器遇到内存屏障时,会按照屏障的指令序列来确保指令重排不会破坏程序的语义一致性。但是,如果没有内存屏障或者内存屏障的使用不正确,可能会导致指令重排。
- 编译器优化:编译器在生成目标代码时,会进行各种优化以提高执行效率。其中包括指令重排。编译器根据机器体系结构的特性和优化策略,尽可能地改变指令的执行顺序,以减少潜在的数据依赖和等待时间。这种优化可能会引起指令重排。
- 处理器的乱序执行:现代处理器通常具有乱序执行(Out-of-Order Execution)的能力。它们可以对指令进行重排,以最大程度地利用处理器资源并提高执行效率。在乱序执行期间,处理器会根据依赖关系和可用资源来决定指令的实际执行顺序。
总结:导致指令重排序的原因无非分为三大类
- 编译器导致的指令重排,比如:编译器优化
- 处理器导致的指令重排,比如:数据依赖性重排、处理器的乱序执行
- 内存系统导致的指令重排,比如:内存屏障
Java 源代码会经历 编译器优化重排 → 指令并行重排 → 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。
注意:指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
- 指令重排会带来哪些问题?
- 可见性问题:指令重排可能导致多线程环境下的可见性问题。如果在重排后,一个线程修改了某个共享变量的值,另一个线程可能无法立即看到这个修改,从而导致出现数据不一致的情况。
- 有序性问题:指令重排可能破坏原本程序中的顺序关系,导致结果与预期不符。例如,如果两条指令之间有依赖关系且被重排了,可能会产生错误的结果。
- 并发安全问题:指令重排可能导致并发安全问题,如竞态条件(Race Condition)和死锁(Deadlock)。当多个线程同时对共享资源进行读写操作时,由于重排可能改变了指令的执行顺序,可能会导致意料之外的结果或死锁的发生。
- 如何解决重排带来的问题?
- 使用合适的同步机制来确保可见性和有序性,如使用volatile关键字、synchronized关键字、Lock等。
- 合理使用内存屏障(Memory Barriers)来控制重排序行为。
- 编写线程安全的代码,避免竞态条件和其他并发问题的发生。
- 进行测试和调试,确保程序在多线程环境下的正确性。
主存和本地内存相关介绍
-
什么是主存?
主存在 JMM 中是一个逻辑上的概念1,它定义了多线程程序中共享变量的可见性和一致性规则,要求所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量)
注意:不要将 JMM 中的 ”主存“ 这个逻辑概念和平常所说的”主存“这个物理概念搞混了,物理概念上的主存是指RAM(随机存取器),也称”内存“
-
什么是本地内存?
每个线程都有一个私有的本地内存来存储共享变量的副本,并且,每个线程只能访问自己的本地内存(也称工作内存),无法访问其他线程的本地内存。本地内存是 JMM 抽象出来的一个概念,存储了主内存中的共享变量副本。
注意:线程对于共享变量的读写都是发生在工作内存中的
-
JMM为什么要划分主存和内存?
在 JDK1.2之前 ,Java虚拟机(JVM)使用的是主存模型。在主存模型中,所有线程都直接读写主存中的共享变量,这样做会产生一些问题,例如,由于缓存一致性协议2的存在,不同的处理器可能会有自己的缓存,导致线程间无法及时看到对共享变量的修改,导致出现数据一致性问题。此外,编译器也可能对代码进行指令重排序,进一步破坏多线程之间的一致性。
在 JDK1.2时,Java引入了本地内存这个概念概念,本地内存位于每个线程的工作内存中,用来存储线程私有的数据。当一个线程访问共享变量时,首先会将共享变量从主存复制到本地内存中进行操作,然后再将修改后的值刷新回主存。借助本地内存,可以解决缓存一致性问题。当一个线程修改了本地内存中的共享变量后,其他线程可以通过主存来感知到这个变化。此外,通过限制对本地内存的访问,可以避免编译器过度优化和指令重排序,从而保证多线程程序的正确执行。
添加了本地内存后读写共享变量的具体流程:
- 线程 1 想要修改共享变量,判断本地内存中的共享变量是否存在
- 如果共享变量不存在就直接从主存中刷新出来
- 如果共享变量存在,利用缓存一致性协议判断共享变量的数据是否过期了
- 共享变量过期了,则刷新共享变量(将主存中的共享变量数据拷贝到本地内存中),然后修改
- 共享变量未过期,直接修改
- 线程1修改完后将共享变量的数据保存到主存中
- 线程 2 想要读取共享变量,判断本地内存中的共享变量是否存在
- 如果共享变量不存在就直接从主存中刷新出来
- 如果共享变量存在,利用缓存一致性协议判断共享变量的数据是否过期了
- 共享变量过期了,则刷新共享变量(将主存中的共享变量数据拷贝到本地内存中),然后读取
- 共享变量未过期,直接读取
- 线程 1 想要修改共享变量,判断本地内存中的共享变量是否存在
JMM中的8种同步规则和8种同步操作
前面介绍了 JMM 是什么?说到 JMM 就是一套规范,而这套规范的核心就算 8种同步规则 和 8种同步操作,通过遵循这8种同步规则和8种同步操作,我们就能够很大程度上避免多线程开发中出现数据一致性问题和可见性问题。本小节我们将介绍以下这8种同步规则和8种同步操作
-
8种同步规则
- 不允许 read 和 load、store 和 write 操作之一单独出现。即使用了 read 必须 load,使用了 store 必须 write
- 不允许线程丢弃他最近的 assign 操作,即工作变量的数据改变了之后,必须告知主存
- 不允许一个线程将没有 assign 的数据从工作内存同步回主内存
- 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施 use、store操作之前,必须经过 assign 和 load 操作
- 一个变量同一时间只有一个线程能对其进行 lock。多次 lock 后,必须执行相同次数的 unlock 才能解锁
- 如果对一个变量进行 lock 操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新 load 或 assign 操作初始化变量的值
- 如果一个变量没有被 lock,就不能对其进行 unlock 操作。也不能 unlock 一个被其他线程锁住的变量
- 对一个变量进行 unlock 操作之前,必须把此变量同步回主内存
-
8种同步操作
-
锁定(lock): 作用于主内存中的变量,将他标记为一个线程独享变量。
-
解锁(unlock): 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
-
read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
-
load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
-
use(使用):把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
-
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
-
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
-
write(写入):作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。
-
Java官方基于这8种同步规则和8种同步操作进行内部实现,我们平常使用的如:synchronized
关键字、volatile
修饰符、Lock
等都是Java官方提供的内部实现,他们都遵循了这两个8。
那么是不是我们这些 API 调用工程师是不是就只需要使用这些提供好的内部实现即可,不用理会这两个8?非也非也,仅仅依赖于内部实现是不够的。作为开发人员,了解并遵守JMM的同步规则以及合理地使用同步操作是非常重要的,以确保编写出具有正确并发行为的多线程程序。通过遵循这些规则和操作,可以避免潜在的并发问题,例如数据竞争、死锁、活锁等。此外,合理地使用同步机制还能提高程序的性能和效率。
happens-before 原则
-
happens-before原则是什么?
happens-before 原则是用于描述多线程程序中操作之间的时间顺序关系的一套规则,具体可以分为8种不同的规则。
-
happens-before原则的包含的八种规则
-
程序顺序规则(Program Order Rule):在同一个线程中,按照程序代码的先后顺序执行的操作,前一个操作的结果对后续操作可见。
-
锁定规则(Lock Rule):一个释放锁的操作(unlock)happens-before 后续相同锁的获取锁操作(lock)。这意味着,在解锁之前对共享变量所做的修改对于后续获取该锁的线程是可见的。
-
volatile变量规则(Volatile Variable Rule):对一个volatile变量的写入操作happens-before 后续对该变量的读取操作。这保证了对volatile变量的修改对所有线程是可见的。
-
传递性规则(Transitive Rule):如果操作A happens-before 操作B,且操作B happens-before 操作C,则操作A happens-before 操作C。这个规则可以推导出更复杂的happens-before关系。
-
线程启动规则(Thread Start Rule):一个线程的启动操作happens-before 该线程中的任何操作。
-
线程终止规则(Thread Termination Rule):一个线程中的任何操作happens-before 其他线程检测到该线程已经终止的操作。
-
线程中断规则(Thread Interruption Rule):对于任意线程,调用interrupt()方法的操作happens-before 被中断线程检测到中断事件的操作。
-
对象终结规则(Finalizer Rule):一个对象的构造函数执行happens-before 该对象的finalize()方法。
-
-
happens-before原则的作用是什么?
- 保证可见性:根据happens-before原则,如果一个操作A happens-before另一个操作B,那么操作A的结果对操作B来说是可见的。这意味着,通过正确使用同步机制(如volatile、synchronized、Lock等),可以确保对共享变量的修改对其他线程是可见的,从而避免了数据不一致性和竞态条件。
- 确保顺序性:happens-before原则定义了操作之间的时间顺序关系,可以确保按照预期的顺序执行操作。例如,一个线程的启动操作happens-before该线程中的任何操作,这样就可以确保子线程中的操作在子线程开始运行之前完成。
- 避免编译优化问题:happens-before原则还能够防止编译器和处理器对指令进行重排序,以保证指令的执行顺序符合预期。这样可以避免由于编译优化导致的程序行为异常。
- 提供并发安全性:通过happens-before原则,程序员可以使用同步机制来创建临界区,保护共享资源的访问和修改。这样可以避免多线程并发访问共享资源时出现不一致的情况,提供并发安全性。
主要作用是确保操作的顺序性,当满足的操作的顺序性时,自然可以满足可见性和指令重排等问题
-
happens-before原则和JMM的关系是什么?
JMM是对于多线程开发的一套规范,happens-before原则是JMM呈现给程序员的抽象视图,让程序员通过合理地设置操作之间的 happens-before 关系,来确保代码在多线程环境下的正确性,从而不必要过度关注底层的实现。
内存屏障
-
什么是内存屏障?
内存屏障(Memory Barrier),也称为内存栅栏,是一种硬件或软件指令,用于控制处理器和内存之间的操作顺序和可见性。
-
为什么需要内存屏障?
在多线程并发执行的环境下,由于处理器和内存的优化机制,可能会对代码的执行顺序进行重排序,导致线程间的可见性和有序性问题。为了解决这些问题,内存屏障被引入。
-
JMM和内存屏障的关系
内存屏障是实现 JMM 的一种手段。JMM 规定了编译器和处理器如何通过插入内存屏障来确保多线程间操作的顺序性和可见性。内存屏障可以控制指令重排序和数据在处理器缓存和主内存之间的同步,从而保证了线程间的互相可见性和正确的执行顺序。
在 JMM 中,通过使用
volatile
关键字或锁机制(如synchronized
和Lock
)来实现内存屏障的效果。当一个线程通过volatile
变量或锁来与主内存进行交互时,会自动插入适当的内存屏障,以确保变量的可见性和有序性。 -
内存屏障的分类
- 读-写屏障(Load-Store Barrier):确保在屏障之前的读操作不会被重排到屏障之后的写操作之后。即,在读-写屏障之前的读取操作必须先于屏障之后的写入操作完成。
- 写-写屏障(Store-Store Barrier):确保在屏障之前的写操作不会被重排到屏障之后的写操作之前。即,在写-写屏障之前的写入操作必须先于屏障之后的写入操作完成。
-
内存屏障的作用
- 强制刷新缓存:内存屏障可以强制将处理器缓存中的数据刷新回主内存,保证共享变量的可见性。
- 禁止指令重排序:内存屏障可以限制指令的重排序,确保指令按照程序的原有顺序执行,从而保证程序的正确性。
- 控制内存访问顺序:内存屏障可以控制不同线程对内存的读写操作的顺序,保证操作的有序性。
总结
- JMM是多线程开发种的一套规范,这套规范能够很大程度避免因为指令重排、可见性问题导致的问题
- 指令重排是为了提高程序执行的效率,指令重排包括编译器重排、处理器重排、缓存系统重排,指令重排能过保障串行执行的语义化一致,但是不会保障并发执行时的语义化一致
- 为了解决可见性问题 JDK1.2 引入本地内存的概念(为什么引入本地内存后就能够解决可见性问题)
参考资料
- JMM(Java 内存模型)详解 | JavaGuide(Java面试 + 学习指南)
- 【并发编程的艺术】详解指令重排序与数据依赖-腾讯云开发者社区-腾讯云 (tencent.com)
逻辑概念:逻辑概念是指在抽象层面上对事物或概念进行定义、描述和理解的方式或观点。它不涉及具体的物理实体或实际操作,而是通过逻辑推理和概念抽象来表达和处理思想、关系、规则等 ↩︎
缓存一致性协议:指多处理器系统或多核系统中用于保持共享数据一致性的协议。在这样的系统中,每个处理器或核心都有自己的高速缓存,用于提高访问速度 ↩︎