写点什么

【深入浅出 JVM 原理及调优】「搭建理论知识框架」全方位带你认识和了解 JMM 并发模型的基本原理

作者:洛神灬殇
  • 2024-01-20
    广东
  • 本文字数:5205 字

    阅读完需:约 17 分钟

【深入浅出JVM原理及调优】「搭建理论知识框架」全方位带你认识和了解JMM并发模型的基本原理

专栏介绍

学习 JVM 需要一定的编程经验和计算机基础知识,适用于从事 Java 开发、系统架构设计、性能优化、研究学习等领域的专业人士和技术爱好者。

前提准备

  • 编程基础:具备良好的编程基础,理解面向对象编程(OOP)的基本概念,熟悉 Java 编程语言。

  • 数据结构与算法:对基本的数据结构和算法有一定了解,理解内存管理、线程操作等基本概念。

面向人群

学习本专栏以及本章内容的前提和适用人群如下:


  • Java 开发人员:JVM 是 Java 程序的核心执行引擎,因此 Java 开发人员需要深入了解 JVM 的工作原理和运行机制,以优化程序性能并解决相关问题。

  • 系统架构师和高级工程师:对系统整体性能、稳定性有较高要求的人群,有必要深入理解 JVM 以优化系统性能。

  • Java 程序员和技术爱好者:具备一定 Java 编程经验,有意向深入了解 JVM 内部工作原理的人群。

  • 研究人员和学生:从事计算机科学相关研究或学习的人群,有兴趣深入研究 JVM 内部原理和优化方法。

  • JVM 运维工程师:负责 JVM 性能优化、故障排查和调优的专业人员,需要对 JVM 有深入的理解。

知识脉络

每位 Java 开发者都了解到 Java 字节码是在 Java 运行时环境(JRE)上执行的。JRE 包含了最为关键的组成部分:Java 虚拟机(JVM),它负责分析和执行 Java 字节码。通常情况下,大多数 Java 开发者无需深入了解虚拟机的内部运行原理。即使对虚拟机的运行机制不甚了解,也不会对开发工作产生太多影响。然而,对 JVM 有一定了解的话,将更有助于深入理解 Java 语言,并解决一些看似困难的问题。


本专栏全面系统地剖析了特定虚拟机产品(即 HotSpot,Oracle 官方虚拟机)的实现,本人不仅深刻地讲解了看似深奥的原理,还提供了大量易于上手的实践案例,下面是总体的 JVM 相关的知识拓扑架构。



tips:当然还有一些最新的 JVM 特性未在这张图并非展示本专栏的全部内容,另外还包含了最新的 JVM 特性。



JMM 基础

在并发编程中,我们需要处理两个关键问题:线程之间如何通信以及线程之间如何进行同步(这里的线程指的是并发执行的活动实体)。通信涉及线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制通常分为两种:共享内存和消息传递。

并发编程模型的分类

共享内存并发模型

共享内存的并发模型中,线程之间共享程序的公共状态,线程通过写入和读取共享内存中的公共状态来隐式进行通信。

消息传递并发模型

消息传递的并发模型中,线程之间没有公共状态,因此必须通过明确的发送消息来显式进行通信。

显示同步和隐式同步

同步涉及程序用于控制不同线程操作发生顺序的机制。


  • 共享内存并发模型中,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。

  • 消息传递的并发模型中,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。


在 Java 的并发编程中,采用的是共享内存模型,线程之间的通信总是隐式进行,整个通信过程对程序员来说完全透明。如果 Java 程序员在编写多线程程序时不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题

Java 内存模型的抽象

在 Java 中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在多线程之间是共享的(本文使用“共享变量”这个术语代指实例域、静态域和数组元素)。而局部变量(Local variables)、方法定义参数(Java 语言规范称之为 formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会产生内存可见性问题,也不受内存模型的影响。


Java 线程之间的通信由 Java 内存模型(本文简称为 JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程对共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java 内存模型的抽象示意图如下所示:



根据上图,要使线程 A 和线程 B 之间进行通信,需要经历以下两个步骤:


  1. 线程 A 将本地内存 A 中更新的共享变量刷新到主内存中:

  2. 当线程 A 修改了共享变量时,首先将更新的值保存在自己的本地内存 A 中。

  3. 线程 A 会将更新过的共享变量从本地内存 A 刷新到主内存中。这将确保其他线程能够看到线程 A 对共享变量的更新。

  4. 线程 B 从主内存中读取线程 A 之前已更新的共享变量:

  5. 当线程 B 需要访问共享变量时,它会先从主内存中读取共享变量的最新值。

  6. 线程 B 读取到的是线程 A 之前已更新过的共享变量的值。


通过这两个步骤,线程 A 和线程 B 之间可以实现共享变量的通信,并确保线程 B 能够读取到线程 A 已经更新过的最新值。



在 Java 的多线程编程中,需要注意确保线程之间的数据同步和可见性。通过使用关键字 volatilesynchronizedLock 等同步机制,可以实现对共享变量的线程安全访问,保证线程之间的通信正确性和一致性。

共享内存同步案例分析

根据上面的内容说明,线程 A 和线程 B 有各自的本地内存 A 和 B,以及主内存中的共享变量 X 的副本。初始时,这三个内存中的 X 值都为 0。当线程 A 执行时,它将更新后的 X 值(假设为 1)临时存储在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,以下两个步骤发生:


  1. 线程 A 将修改后的 X 值从本地内存 A 刷新到主内存中:

  2. 线程 A 将本地内存 A 中的更新后的值 1 刷新到主内存中的共享变量 X。

  3. 这个刷新操作确保了主内存中的 X 值变为了 1。

  4. 线程 B 从主内存中读取线程 A 更新后的 X 值:

  5. 当线程 B 需要访问共享变量 X 时,它首先从主内存中读取 X 的最新值。

  6. 线程 B 能够读取到线程 A 更新后的 X 值,将其存储在本地内存 B 中。

总结归纳分析

通过这样的方式,线程 A 向线程 B 发送了一个消息,这个通信过程必须经过主内存来实现。通过控制主内存与每个线程的本地内存之间的交互,Java 语言为程序员提供了内存可见性的保证。

JMM 重排序

JMM 属于语言级的内存模型, 它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。


在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。重排序可以分为以下三种类型:


编译器优化的重排序

编译器优化的重排序主要指的是:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。编译器在生成目标代码时可以重新排列源代码中的指令,但必须保证程序的最终执行结果与源代码的语义一致。编译器重排序的目的是为了优化性能,提高指令的并行以下是对内容的润色和优化

指令级并行的重排序

现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP),可以将多条指令重叠执行,从而提高执行效率。在不存在数据依赖性的情况下,处理器可以改变语句对应机器指令的执行顺序。

内存系统的重排序

由于处理器使用缓存和读/写缓冲区等机制,从外部视角看,加载和存储操作可能会出现乱序执行的现象。这是因为处理器会根据内存系统的延迟、缓存一致性协议(MESI)等因素对内存访问指令进行重排序,以优化执行效率和内存带宽的利用。

重排序的执行流程


上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题


  • 对于编译器重排序, JMM 的编译器重排序规则会禁止特定类型的编译器重排序。

  • 对于处理器重排序, JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时, 插入特定类型的内存屏障(memory barriers, intel 称之为 memory fence) 指令, 通过内存屏障指令来禁止特定类型的处理器重排序。

处理器重排序与内存屏障指令

写缓冲区

现代的处理器使用写缓冲区来临时保存向内存写入的数据,写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。

写缓冲区的问题

写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!为了具体说明,请看下面示例:



假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终却可能得到 x=y=0 的结果。具体的原因如下图所示:



在多处理器系统中:


  1. 处理器 A 和处理器 B 可以同时将共享变量写入各自的写缓冲区(A1,B1)

  2. 从内存中读取另一个共享变量(A2,B2)

  3. 再将自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。


当按照这个时序进行执行时,程序最终可以得到 x=y=0 的结果。


当多处理器系统中处理器之间的写缓冲区和内存之间的交互。该过程中可能存在处理器缓冲区和内存之间的数据不一致性问题。为了解决这个问题,可以使用合适的内存屏障指令或同步机制来确保数据的一致性和可见性

问题原因剖析

从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区之前,写操作 A1 并没有真正执行。尽管处理器 A 的内存操作顺序是 A1->A2,但实际发生的内存操作顺序却是 A2->A1。因此,处理器 A 的内存操作顺序被重排序了。由于处理器的写缓冲区仅对自身可见,因此处理器在执行内存操作时可能会出现与内存实际操作顺序不一致的情况。


现代处理器广泛采用写缓冲区技术以提高性能,这可能导致处理器在执行写操作和读操作时发生重排序。这种重排序现象是由于写缓冲区的存在,它允许处理器将写操作推迟到稍后的时间执行。而读操作可能会在写操作之前完成,导致内存操作的顺序实际上与程序中指定的顺序不一致。

内存屏障(禁止重排序)

为了保证内存可见性,Java 编译器会在生成指令序列的适当位置插入内存屏障指令,以禁止特定类型的处理器重排序。Java 内存模型(JMM)将内存屏障指令分为以下四类:


  • LoadLoad 屏障:确保在该屏障之前的读操作完成后,后续的读操作才能开始。它防止随后的读操作提前执行,以确保读取到最新的值。

  • 指令案例:Load1; Load Load; Load2


确保 Load 1 数据的装载, 之前于 Load 2 及所有后续装载指令的装载。


  • StoreStore 屏障:确保在该屏障之前的写操作完成后,后续的写操作才能开始。它防止随后的写操作提前执行,以确保写入的值对其他线程可见。

  • 指令案例:Store1; Store Store;Store2


确保 Store 1 数据对其他处理器可见(刷新到内存),之前于 Store 2 及所有后续存储指令的存储。


  • LoadStore 屏障:确保在该屏障之前的读操作完成后,后续的写操作才能开始。它防止之后的写操作与之前的读操作重排序。

  • 指令案例:Load1; Load Store;Store2


确保 Load 1 数据装载, 之前于 Store 2 及所有后续的存储指令刷新到内存。


  • StoreLoad 屏障:是最强效的屏障形式,它确保在该屏障之前的写操作完成后,后续的读操作才能开始。它会禁止在该屏障之后的读操作与之前的写操作重排序,从而确保读取到的是最新的值。

  • 面向指令:Store1; Store Load;Load2


确保 Store 1 数据对其他处理器变得可见(指刷新到内存),之前于 Load 2 及所有后续装载指令的装载。Store Load Barriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。


Store Load Barrier 是一种“全能型”屏障,它具有 LoadLoad、StoreStore 和 LoadStore 三种屏障的效果。大多数现代多处理器都支持 Store Load Barrier(并非所有处理器都支持其他类型的屏障)。然而,执行 Store Load Barrier 的开销通常很高,因为当前处理器通常需要将写缓冲区中的所有数据刷新到内存中(全量刷新)。


这些内存屏障指令的使用,以及编译器和处理器对其的处理,确保了 Java 程序在多线程环境下的内存可见性和正确性。

Happen-Before 原则

JSR-133 使用 happens-before 的概念来阐述操作之间的内存可见性。在 JMM 中, 如果一个操作执行的结果需要对另一个操作可见, 那么这两个操作之间必须要存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

Happen-Before 的规则

Happen-Before 的本质体现

  1. Happens-before 关系并不要求前一个操作必须在后一个操作之前执行,而是要求前一个操作(执行的结果)对后一个操作可见,并且按顺序排在第二个操作之前。

  2. Happens-before 关系的定义非常微妙,它确保了多线程执行中的可见性和正确的顺序性,使得线程间的同步和交互得以正确进行。


通过 happens-before 关系的定义,保证了多线程编程中的正确性和可靠性。理解 happens-before 关系的含义和影响对于编写并发程序非常重要,可以帮助我们正确处理线程同步、内存可见性和指令重排序等问题。

JMM 模型和 HB 原则的关系

Happens-before 规则是 Java 内存模型(JMM)提供的一组简单易懂的规则,用于保证内存可见性和正确的多线程执行行为。



每个 happens-before 规则对应于一个或多个编译器和处理器重排序规则。这种对应关系使得 Java 程序员无需深入研究复杂的重排序规则及其实现,而只需理解简单的 happens-before 规则即可。

未完待续

由于篇幅过长,因此暂时写到这里,后续内容会在后面的文章中继续体现和分析,下一篇文章会针对于 JVM 体系的细节进行深入分析和探索。

发布于: 刚刚阅读数: 3
用户头像

洛神灬殇

关注

🏆 InfoQ写作平台-签约作者 🏆 2020-03-25 加入

👑 后端技术架构师,前优酷资深工程师 📕 个人著作《深入浅出Java虚拟机—JVM原理与实战》 💻 10年开发经验,参与过多个大型互联网项目,定期分享技术干货和项目经验

评论

发布
暂无评论
【深入浅出JVM原理及调优】「搭建理论知识框架」全方位带你认识和了解JMM并发模型的基本原理_Java_洛神灬殇_InfoQ写作社区