【Java JVM】内存模型
【Java JVM】内存模型
Metadata
title: 【Java JVM】内存模型
date: 2022-12-14 14:50
tags:
- 行动阶段/未完成
- 主题场景/编程
- 笔记空间/KnowladgeSpace/ProgramSpace
- 细化主题/Java/JVM
categories:
- Java
keywords:
- Java/JVM
description: JAVA 内存模型
JMM 引入
从堆栈说起
JVM 内部使用的 Java 内存模型在线程栈和堆之间划分内存。 此图从逻辑角度说明了 Java 内存模型:
堆栈里面放了什么?
线程堆栈还包含正在执行的每个方法的所有局部变量 (调用堆栈上的所有方法)。 线程只能访问它自己的线程堆栈。 由线程创建的局部变量对于创建它的线程以外的所有其他线程是不可见的。 即使两个线程正在执行完全相同的代码,两个线程仍将在每个自己的线程堆栈中创建该代码的局部变量。 因此,每个线程都有自己的每个局部变量的版本。
基本类型的所有局部变量 (boolean,byte,short,char,int,long,float,double) 完全存储在线程堆栈中,因此对其他线程不可见。 一个线程可以将一个基本类型变量的副本传递给另一个线程,但它不能共享原始局部变量本身。
堆包含了在 Java 应用程序中创建的所有对象,无论创建该对象的线程是什么。 这包括基本类型的包装类 (例如 Byte,Integer,Long 等)。 无论是创建对象并将其分配给局部变量,还是创建为另一个对象的成员变量,该对象仍然存储在堆上。
局部变量可以是基本类型,在这种情况下,它完全保留在线程堆栈上。
局部变量也可以是对象的引用。 在这种情况下,引用 (局部变量) 存储在线程堆栈中,但是对象本身存储在堆 (Heap) 上。
对象的成员变量与对象本身一起存储在堆上。 当成员变量是基本类型时,以及它是对象的引用时都是如此。
静态类变量也与类定义一起存储在堆上。
线程栈如何访问堆上对象?
所有具有对象引用的线程都可以访问堆上的对象。 当一个线程有权访问一个对象时,它也可以访问该对象的成员变量。 如果两个线程同时在同一个对象上调用一个方法,它们都可以访问该对象的成员变量,但每个线程都有自己的局部变量副本。
两个线程有一组局部变量。 其中一个局部变量 (局部变量 2) 指向堆上的共享对象(对象 3)。 两个线程各自对同一对象具有不同的引用。 它们的引用是局部变量,因此存储在每个线程的线程堆栈中(在每个线程堆栈上)。 但是,这两个不同的引用指向堆上的同一个对象。
注意共享对象 (对象 3) 如何将对象 2 和对象 4 作为成员变量引用(由对象 3 到对象 2 和对象 4 的箭头所示)。 通过对象 3 中的这些成员变量引用,两个线程可以访问对象 2 和对象 4.
该图还显示了一个局部变量,该变量指向堆上的两个不同对象。 在这种情况下,引用指向两个不同的对象 (对象 1 和对象 5),而不是同一个对象。 理论上,如果两个线程都引用了两个对象,则两个线程都可以访问对象 1 和对象 5。 但是在上图中,每个线程只引用了两个对象中的一个。
JMM 与硬件内存结构关系
硬件内存结构简介
现代硬件内存架构与内部 Java 内存模型略有不同。 了解硬件内存架构也很重要,以了解 Java 内存模型如何与其一起工作。 本节介绍了常见的硬件内存架构,后面的部分将介绍 Java 内存模型如何与其配合使用。
这是现代计算机硬件架构的简化图:
现代计算机通常有 2 个或更多 CPU。 其中一些 CPU 也可能有多个内核。 关键是,在具有 2 个或更多 CPU 的现代计算机上,可以同时运行多个线程。 每个 CPU 都能够在任何给定时间运行一个线程。 这意味着如果您的 Java 应用程序是多线程的,线程真的在可能同时运行.
每个 CPU 基本上都包含一组在 CPU 内存中的寄存器。 CPU 可以在这些寄存器上执行的操作比在主存储器中对变量执行的操作快得多。 这是因为 CPU 可以比访问主存储器更快地访问这些寄存器。
每个 CPU 还可以具有 CPU 高速缓存存储器层。 事实上,大多数现代 CPU 都有一些大小的缓存存储层。 CPU 可以比主存储器更快地访问其高速缓存存储器,但通常不会像访问其内部寄存器那样快。 因此,CPU 高速缓存存储器介于内部寄存器和主存储器的速度之间。 某些 CPU 可能有多个缓存层 (级别 1 和级别 2),但要了解 Java 内存模型如何与内存交互,这一点并不重要。 重要的是要知道 CPU 可以有某种缓存存储层。
计算机还包含主存储区 (RAM)。 所有 CPU 都可以访问主内存。 主存储区通常比 CPU 的高速缓存存储器大得多。同时访问速度也就较慢.
通常,当 CPU 需要访问主存储器时,它会将部分主存储器读入其 CPU 缓存。 它甚至可以将部分缓存读入其内部寄存器,然后对其执行操作。 当 CPU 需要将结果写回主存储器时,它会将值从其内部寄存器刷新到高速缓冲存储器,并在某些时候将值刷新回主存储器。
JMM 与硬件内存连接 - 引入
如前所述,Java 内存模型和硬件内存架构是不同的。 硬件内存架构不区分线程堆栈和堆。 在硬件上,线程堆栈和堆都位于主存储器中。 线程堆栈和堆的一部分有时可能存在于 CPU 高速缓存和内部 CPU 寄存器中。 这在图中说明:
JMM 与硬件内存连接 - 对象共享后的可见性
如果两个或多个线程共享一个对象,而没有正确使用 volatile 声明或同步,则一个线程对共享对象的更新可能对其他线程不可见。
想象一下,共享对象最初存储在主存储器中。 然后,在 CPU 上运行的线程将共享对象读入其 CPU 缓存中。 它在那里对共享对象进行了更改。 只要 CPU 缓存尚未刷新回主内存,共享对象的更改版本对于在其他 CPU 上运行的线程是不可见的。 这样,每个线程最终都可能拥有自己的共享对象副本,每个副本都位于不同的 CPU 缓存中。
下图描绘了该情况。 在左 CPU 上运行的一个线程将共享对象复制到其 CPU 缓存中,并将其 count 变量更改为 2. 对于在右边的 CPU 上运行的其他线程,此更改不可见,因为计数更新尚未刷新回主内存中.
要解决此问题,您可以使用 Java 的 volatile 关键字。 volatile 关键字可以确保直接从主内存读取给定变量,并在更新时始终写回主内存。
要解决此问题,您可以使用 Java synchronized 块。 同步块保证在任何给定时间只有一个线程可以进入代码的给定关键部分。 同步块还保证在同步块内访问的所有变量都将从主存储器中读入,当线程退出同步块时,所有更新的变量将再次刷新回主存储器,无论变量是不是声明为 volatile
基础
并发编程模型的分类
在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的活动实体)。通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写 - 读内存中的公共状态来隐式进行通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。
同步是指程序用于控制不同线程之间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
Java 的并发采用的是共享内存模型,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 之间如要通信的话,必须要经历下面 2 个步骤:
- 首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
- 然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。
下面通过示意图来说明这两个步骤:
如上图所示,本地内存 A 和 B 有主内存中共享变量 x 的副本。假设初始时,这三个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1。
从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 java 程序员提供内存可见性保证。
重排序
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
处理器重排序与内存屏障指令
现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读 / 写操作的执行顺序,不一定与内存实际发生的读 / 写操作顺序一致!
常见的处理器都允许 Store-Load 重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。sparc-TSO 和 x86 拥有相对较强的处理器内存模型,它们仅允许对写 - 读操作做重排序(因为它们都使用了写缓冲区)。
- ※注 1:sparc-TSO 是指以 TSO(Total Store Order) 内存模型运行时,sparc 处理器的特性。
- ※注 2:上表中的 x86 包括 x64 及 AMD64。
- ※注 3:由于 ARM 处理器的内存模型与 PowerPC 处理器的内存模型非常类似,本文将忽略它。
- ※注 4:数据依赖性后文会专门说明。
为了保证内存可见性,java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM 把内存屏障指令分为下列四类:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1; LoadLoad; Load2 | 确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。 |
StoreStore Barriers | Store1; StoreStore; Store2 | 确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。 |
LoadStore Barriers | Load1; LoadStore; Store2 | 确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。 |
StoreLoad Barriers | Store1; StoreLoad; Load2 | 确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。 |
StoreLoad Barriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。
StoreLoad Barriers 是一个 “全能型” 的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)。
happens-before
从 JDK5 开始,java 使用新的 JSR -133 内存模型(本文除非特别说明,针对的都是 JSR- 133 内存模型)。JSR-133 提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 与程序员密切相关的 happens-before 规则如下:
- 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
- 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
- volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。
- 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。
注意,两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。happens- before 的定义很微妙,后文会具体说明 happens-before 为什么要这么定义。
happens-before 与 JMM 的关系如下图所示:
如上图所示,一个 happens-before 规则通常对应于多个编译器重排序规则和处理器重排序规则。对于 java 程序员来说,happens-before 规则简单易懂,它避免程序员为了理解 JMM 提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。