金石原创 |【JVM盲点补漏系列】「并发编程的难题和挑战」深入理解JMM及JVM内存模型知识体系机制(1)

简介: 金石原创 |【JVM盲点补漏系列】「并发编程的难题和挑战」深入理解JMM及JVM内存模型知识体系机制(1)

并发编程的难题和挑战

在并发编程的技术领域中,对于我们而言的难题主要有两个:

  1. 多线程之间如何进行通信和线程之间如何同步,通信是指线程之间以何种机制来交换信息。

多线程的线程通信机制

在命令式编程中,线程之间的通信机制有两种:共享内存消息传递

  • 共享内存的方式,多线程之间共享公共的状态(变量),那么线程之间通过写/读内存中的公共状态(变量)来隐式进行通信。在此模式下,同步实现是隐式进行的,由于消息的发送必须在消息的接收之前。
  • 消息传递的方式,多线程之间没有公共的状态(变量),那么线程之间必须通过明确的传递状态(变量)来显式进行通信。在此模式下,同步实现是显式进行的,必须显式指定某个方法或某段代码需要在线程之间互斥执行。

Java中的同步模式是什么?

同步机制是指程序用于控制不同线程之间操作发生相对顺序的机制。

Java生态中的并发编程模型采用的是共享内存模型,因此在Java线程之间的通信总是隐式进行, 整个通信过程对开发者是黑盒的,如果编写多线程程序的开发者不深入理解这种隐式模式下的线程之间通信机制,就会会出现内存可见性和一致性的问题,我们统称为线程不安全问题。

存在内存可见问题

Java应用程序中, 所有实例域、静态域和数组元素存储在堆内存中, 堆内存在线程之间共享。会存在这内存可见性问题。

不存在内存可见问题

局部变量(Local variables) , 方法定义参数(java语言规范称之为formal method parameters) 和异常处理器参数(exception handler parameters) 不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

所以,我们在开发多线程场景下的程序的时候主要需要关注的就是内存可见问题变量,包含:实例域、静态域和数组元素。

而为了降低并发编程的难度和门槛,这些线程之间的数据同步和通信控制就交由一个特定的数据模型进行控制和管理,我们称之为Java内存模型(JMM)。

Java内存模型(JMM)

JMM决定在程序运行中,一个线程对共享变量的写入何时对另一个线程可见。

JMM定义了线程和主内存之间的抽象关系

线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存 , 本地内存中存储了该线程以读/写共享变量的副本。

本地内存是JMM的一个抽象概念, 并不真实存在。它涵盖了缓存, 写缓冲区, 寄存器以及其他的硬件和编译器优化。

Java 内存模型的抽象示意图如下:



由上图可见,线程A与线程B之间如要数据通信,需要有以下两个步骤:

  1. 线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 线程B到主内存中去读取线程A之前已更新过的共享变量

下面通过示意图来说明这两个步骤:



如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。

  1. 线程A在执行时,把更新后的x值,临时存放在自己的本地内存A中。
  2. 线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变了。
  3. 线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变了。

总结一下就是,这两个步骤数据角度而言是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互, 来为程序提供内存可见性保证。

线程不安全因素之一(指令重排序问题)

基于上述所说的场景之下,JVM为了在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。在此我们将按照重排序的执行时间前后分为重排序分三种类型,如下图所示。



  • 第一步属于编译器重排序:编译器优化的重排序,编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 第二步属于处理器重排序:指令级并行的重排序,现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP) 来将多条指令重叠执行。如果不存在数据依赖性, 处理器可以改变语句对应机器指令的执行顺序。
  • 第三步属于处理器重排序:内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行,此处特别是针对与本地内存和共享主存之间的更新操作的一致性和可见性

这些重排序都可能会导致多线程程序出现内存可见性问题。

JMM解决重排序的线程不安全问题

解决编译器级别重排序

  • JMM的编译器重排序规则会禁止特定类型的编译器重排序,此处注意:不是所有的编译器重排序都要禁止

解决处理器级别重排序

  • JMM的处理器重排序规则会要求java编译器在生成指令序列时, 插入特定类型的内存屏障(memory barriers, 也可以称之为memory fence)指令, 通过 内存屏障 指令来禁止特定类型的处理器重排序此处注意:不是所有的处理器重排序都要禁止)

总结一下,针对于JMM属于语言级的内存模型, 它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,从而实现了内存的可见性以及一致性。

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

上面说了其实是通过插入了内存屏障指令,从而控制住了对应的处理器级别的指令重排。

线程不安全因素之一(写缓存处理模式)

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

这个特性会对内存操作的执行顺序产生重要的影响,处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。



  1. 处理器A处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1)
  2. 从内存中读取另一个共享变量(A2,B2)
  3. 最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。

从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2->A1。此时,处理器A的内存操作顺序被重排序了(处理器B的情况和处理器A一样)。

由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作重排序。常见的处理器都允许Store-Load重排序,常见的处理器都不允许对存在数据依赖的操作做重排序。

内存屏障指令

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

内存屏障类型 指令示例 备注
LoadLoad Barries Load1\LoadLoad\Load2 确保Load1数据的装载,之前于Load2及所有后续装载指令的装载
StoreStore Barries Store1\StoreStore\Store2 确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。
LoadStore Barriers Load1\ LoadStore\Store2 确保Load1数据装载, 之前于Store2及所有后续的存储指令刷新到内存
StoreLoad Barriers Store1\StoreLoad\Load2 确保Storel数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

**StoreLoad Barriers是一个“全能型”的屏障, 它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush) **。

相关文章
|
6天前
|
Java 编译器 开发者
深入理解Java内存模型(JMM)及其对并发编程的影响
【9月更文挑战第37天】在Java的世界里,内存模型是隐藏在代码背后的守护者,它默默地协调着多线程环境下的数据一致性和可见性问题。本文将揭开Java内存模型的神秘面纱,带领读者探索其对并发编程实践的深远影响。通过深入浅出的方式,我们将了解内存模型的基本概念、工作原理以及如何在实际开发中正确应用这些知识,确保程序的正确性和高效性。
|
24天前
|
监控 算法 Java
Java中的内存管理:理解Garbage Collection机制
本文将深入探讨Java编程语言中的内存管理,特别是垃圾回收(Garbage Collection, GC)机制。我们将从基础概念开始,逐步解析垃圾回收的工作原理、不同类型的垃圾回收器以及它们在实际项目中的应用。通过实际案例,读者将能更好地理解Java应用的性能调优技巧及最佳实践。
67 0
|
2月前
|
Java Docker 索引
记录一次索引未建立、继而引发一系列的问题、包含索引创建失败、虚拟机中JVM虚拟机内存满的情况
这篇文章记录了作者在分布式微服务项目中遇到的一系列问题,起因是商品服务检索接口测试失败,原因是Elasticsearch索引未找到。文章详细描述了解决过程中遇到的几个关键问题:分词器的安装、Elasticsearch内存溢出的处理,以及最终成功创建`gulimall_product`索引的步骤。作者还分享了使用Postman测试接口的经历,并强调了问题解决过程中遇到的挑战和所花费的时间。
|
2月前
|
安全 前端开发 Java
【JVM的秘密揭秘】深入理解类加载器与双亲委派机制的奥秘!
【8月更文挑战第25天】在Java技术栈中,深入理解JVM类加载机制及其双亲委派模型是至关重要的。JVM类加载器作为运行时系统的关键组件,负责将字节码文件加载至内存并转换为可执行的数据结构。其采用层级结构,包括引导、扩展、应用及用户自定义类加载器,通过双亲委派机制协同工作,确保Java核心库的安全性与稳定性。本文通过解析类加载器的分类、双亲委派机制原理及示例代码,帮助读者全面掌握这一核心概念,为开发更安全高效的Java应用程序奠定基础。
83 0
|
2天前
|
存储 Java Linux
【JVM】JVM执行流程和内存区域划分
【JVM】JVM执行流程和内存区域划分
15 1
|
4天前
|
存储 安全 NoSQL
driftingblues9 - 溢出ASLR(内存地址随机化机制)
driftingblues9 - 溢出ASLR(内存地址随机化机制)
20 1
|
4天前
|
存储 安全 Java
JVM锁的膨胀过程与锁内存变化解析
在Java虚拟机(JVM)中,锁机制是确保多线程环境下数据一致性和线程安全的重要手段。随着线程对共享资源的竞争程度不同,JVM中的锁会经历从低级到高级的膨胀过程,以适应不同的并发场景。本文将深入探讨JVM锁的膨胀过程,以及锁在内存中的变化。
10 1
|
14天前
|
存储 算法 Java
深入解析 Java 虚拟机:内存区域、类加载与垃圾回收机制
本文介绍了 JVM 的内存区域划分、类加载过程及垃圾回收机制。内存区域包括程序计数器、堆、栈和元数据区,每个区域存储不同类型的数据。类加载过程涉及加载、验证、准备、解析和初始化五个步骤。垃圾回收机制主要在堆内存进行,通过可达性分析识别垃圾对象,并采用标记-清除、复制和标记-整理等算法进行回收。此外,还介绍了 CMS 和 G1 等垃圾回收器的特点。
57 0
深入解析 Java 虚拟机:内存区域、类加载与垃圾回收机制
|
24天前
|
Arthas Java 测试技术
JVM —— 类加载器的分类,双亲委派机制
类加载器的分类,双亲委派机制:启动类加载器、扩展类加载器、应用程序类加载器、自定义类加载器;JDK8及之前的版本,JDK9之后的版本;什么是双亲委派模型,双亲委派模型的作用,如何打破双亲委派机制
JVM —— 类加载器的分类,双亲委派机制
|
28天前
|
消息中间件
共享内存和信号量的配合机制
【9月更文挑战第16天】本文介绍了进程间通过共享内存通信的机制及其同步保护方法。共享内存可让多个进程像访问本地内存一样进行数据交换,但需解决并发读写问题,通常借助信号量实现同步。文章详细描述了共享内存的创建、映射、解除映射等操作,并展示了如何利用信号量保护共享数据,确保其正确访问。此外,还提供了具体代码示例与步骤说明。