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

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

专栏介绍

学习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中更新的共享变量刷新到主内存中:

    • 当线程A修改了共享变量时,首先将更新的值保存在自己的本地内存A中。
    • 线程A会将更新过的共享变量从本地内存A刷新到主内存中。这将确保其他线程能够看到线程A对共享变量的更新。
  2. 线程B从主内存中读取线程A之前已更新的共享变量:

    • 当线程B需要访问共享变量时,它会先从主内存中读取共享变量的最新值。
    • 线程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刷新到主内存中:

    • 线程A将本地内存A中的更新后的值1刷新到主内存中的共享变量X。
    • 这个刷新操作确保了主内存中的X值变为了1。
  2. 线程B从主内存中读取线程A更新后的X值:

    • 当线程B需要访问共享变量X时,它首先从主内存中读取X的最新值。
    • 线程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的规则

规则 说明
程序顺序规则 一个线程中的每个操作,happens-before于该线程中的任意后续操作。
监视器锁规则 对一个监视器的解锁,happens-before于随后对这个监视器的加锁。
volatile变量规则 对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
传递性 如果A happens-before B,且B happens-before C,那么A happens-before C。

Happen-Before的本质体现

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

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

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

JMM模型和HB原则的关系

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

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

未完待续

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

相关文章
|
5天前
|
监控 Java
【JVM】深入理解JVM调优
【JVM】深入理解JVM调优
6 0
|
14天前
|
监控 前端开发 安全
JVM工作原理与实战(十四):JDK9及之后的类加载器
JVM作为Java程序的运行环境,其负责解释和执行字节码,管理内存,确保安全,支持多线程和提供性能监控工具,以及确保程序的跨平台运行。本文主要介绍了JDK8及之前的类加载器、JDK9及之后的类加载器等内容。
19 2
|
14天前
|
监控 Java 关系型数据库
JVM工作原理与实战(十三):打破双亲委派机制-线程上下文类加载器
JVM作为Java程序的运行环境,其负责解释和执行字节码,管理内存,确保安全,支持多线程和提供性能监控工具,以及确保程序的跨平台运行。本文主要介绍了打破双亲委派机制的方法、线程上下文类加载器等内容。
15 2
|
14天前
|
监控 安全 前端开发
JVM工作原理与实战(十二):打破双亲委派机制-自定义类加载器
JVM作为Java程序的运行环境,其负责解释和执行字节码,管理内存,确保安全,支持多线程和提供性能监控工具,以及确保程序的跨平台运行。本文主要介绍了打破双亲委派机制的方法、自定义类加载器等内容。
17 1
|
14天前
|
Arthas 安全 Java
JVM工作原理与实战(八):类加载器的分类
JVM作为Java程序的运行环境,其负责解释和执行字节码,管理内存,确保安全,支持多线程和提供性能监控工具,以及确保程序的跨平台运行。本文主要介绍了类加载器、类加载器的分类等内容。
18 4
|
14天前
|
监控 安全 Java
JVM工作原理与实战(七):类的生命周期-初始化阶段
JVM作为Java程序的运行环境,其负责解释和执行字节码,管理内存,确保安全,支持多线程和提供性能监控工具,以及确保程序的跨平台运行。本文主要介绍了类的生命周期、类的初始化阶段等内容。
24 5
|
14天前
|
存储 安全 Java
JVM工作原理与实战(六):类的生命周期-连接阶段
JVM作为Java程序的运行环境,其负责解释和执行字节码,管理内存,确保安全,支持多线程和提供性能监控工具,以及确保程序的跨平台运行。本文主要介绍了类的生命周期、类的连接阶段等内容。
29 4
|
14天前
|
存储 监控 安全
JVM工作原理与实战(五):类的生命周期-加载阶段
JVM作为Java程序的运行环境,其负责解释和执行字节码,管理内存,确保安全,支持多线程和提供性能监控工具,以及确保程序的跨平台运行。本文主要介绍了类的生命周期、类的加载阶段等内容。
24 5
|
14天前
|
Arthas 运维 监控
JVM工作原理与实战(四):字节码常用工具
JVM作为Java程序的运行环境,其负责解释和执行字节码,管理内存,确保安全,支持多线程和提供性能监控工具,以及确保程序的跨平台运行。本文主要介绍了字节码常用工具javap、jclasslib、Arthas等内容。
33 3
|
14天前
|
存储 XML 监控
JVM工作原理与实战(三):字节码文件的组成
JVM作为Java程序的运行环境,其负责解释和执行字节码,管理内存,确保安全,支持多线程和提供性能监控工具,以及确保程序的跨平台运行。本文主要介绍了字节码文件的基础信息、常量池、方法、字段、属性等内容。
26 6