提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
1. 什么是Java内存模型
Java 内存模型(JMM
)指定了 Java 虚拟机(JVM
)如何与计算机内存 (RAM
) 配合使用。由于Java虚拟机与内存交互的时候,是通过Java线程去做的,因此Java内存模型其实就是针对Java的线程模型进行研究的,因此Java内存模型其实也叫“Java线程模型
”。
Java 内存模型(JMM
) 控制Java 中的线程如何与内存交互。它保证一个线程所做的更改对其他线程可见,即Java 内存模型(JMM
) 可以指定不同线程如何以及何时可以看到其他线程写入共享变量的值,从而为安全的多线程提供框架。
JMM通过“synchronized
”块、“volatile
”变量和“内存屏障
”等构造确保正确同步。它对于防止数据争用和确保多线程 Java 程序中的一致行为至关重要。了解 JMM 是编写可靠、高效的并发代码的基础。
原有的 Java 内存模型存在不足,因此在 Java 1.5
中对 Java 内存模型进行了修订。此版本的 Java 内存模型在今天的 Java 中仍在使用(Java 14+
)。
2. 线程堆栈
2.1 什么是线程堆栈?
线程堆栈是提供给Java虚拟机中每个线程正常运行的数据结构,即线程堆栈是一种数据结构,Java 虚拟机中运行的每个线程都有自己的线程堆栈。基本类型的局部变量(boolean, byte, short, char, int, long, float, double
)完全存储在线程堆栈中,对其他线程不可见。即使两个线程正在执行相同的代码,它们也会在各自的线程堆栈中为该代码创建自己的局部变量副本。
2.2 什么是调用堆栈?
线程堆栈包含有关线程调用了哪些方法以到达当前执行点的信息。我将其称为“调用堆栈”。当线程执行其代码时,调用堆栈会发生变化。
堆包含 Java 应用程序中创建的所有对象,无论哪个线程创建了该对象。这包括Java基本数据类型的对象版本(例如Byte、Short、Integer、Long
等)。无论对象是创建并分配给局部变量,还是作为另一个对象的成员变量创建,该对象仍存储在堆中。
下面的图表说明了存储在线程堆栈上的调用堆栈和局部变量以及存储在堆上的对象:
2.3 局部变量
关于局部变量,需要注意两点:
- 如果局部变量是原始的基本数据类型(
boolean, byte, short, char, int, long, float, double
),在这种情况下,它会完全保存在线程堆栈上。 - 如果局部变量是引用类型。引用的指针变量会存储在线程堆栈上,但指针变量指向的引用对象本身存储在堆上。
所有引用该对象的线程都可以访问堆上的对象。当一个线程可以访问某个对象时,它也可以访问该对象的成员变量。如果两个线程同时调用同一对象上的方法,它们都可以访问该对象的成员变量,但每个线程都有自己的局部变量副本。
Java 内存模型显示从局部变量到对象以及从对象到其他对象的引用。
下图说明了上述观点:
两个线程有一组局部变量。其中一个局部变量(Local Variable 2
)指向堆上的共享对象(Object 3
)。两个线程对同一对象有不同的引用。它们的引用是一个局部变量,因此存储在每个线程的线程堆栈中(每个线程上)。但是,两个不同的引用指向堆上的同一个对象
。
注意共享对象(Object 3
)如何将对“Object 2 和Object 4” 的引用作为成员变量(由从Object 3 到Object 2 和Object 4 的箭头
表示)。通过Object 3 中的这些成员变量引用,两个线程可以访问Object 2 和Object 4
。
该图还显示了一个局部变量,它指向堆上的两个不同对象。在这种情况下,引用指向两个不同的对象(Object 1 和Object 5
),而不是同一个对象。理论上,如果两个线程都引用了这两个对象,那么这两个线程都可以访问Object 1
和Object 5
。但在上图中,每个线程只引用这两个对象中的一个。
2.4 局部变量代码示例
那么,什么样的 Java 代码可能导致上述内存图?好吧,代码就像下面的代码一样简单:
public class MyRunnable implements Runnable() {
public void run() {
methodOne();
}
public void methodOne() {
int localVariable1 = 45;
MySharedObject localVariable2 =
MySharedObject.sharedInstance;
//... 使用局部变量做更多事情。
methodTwo();
}
public void methodTwo() {
Integer localVariable1 = new Integer(99);
//... 使用局部变量做更多事情。
}
}
上述线程methodOne方法中创建的MySharedObject
对象代码:
public class MySharedObject {
//指向 MySharedObject 实例的静态变量
public static final MySharedObject sharedInstance =
new MySharedObject();
//指向堆上的两个对象的成员变量
public Integer object2 = new Integer(22);
public Integer object4 = new Integer(44);
public long member1 = 12345;
public long member2 = 67890;
}
2.4.1 代码示例分析
如果两个线程都在执行该run()方法,那么结果将如上图所示。该run()方法调用methodOne()并methodOne()调用methodTwo()。
methodOne()声明一个基本数据类型局部变量(localVariable1 类型int
)和一个对象引用局部变量(localVariable2
)。
-
localVariable1(基本数据类型变量)
执行methodOne()
的每个线程都会在各自的线程堆栈上创建自己的localVariable1
和localVariable2
副本。localVariable1变量将彼此完全分离,只存在于每个线程的线程堆栈上。一个线程无法看到另一个线程对其localVariable1副本所做的更改。 -
localVariable2(引用数据类型变量)
执行methodOne()的每个线程也将创建自己的localVariable2
副本。然而,localVariable2的两个不同副本最终都指向堆上的同一个对象。代码将localVariable2
设置为指向静态变量引用的对象。静态变量只有一个副本,该副本存储在堆上。因此,localVariable2
的两个副本最终都指向静态变量指向的MySharedObject的同一个实例。MySharedObject实例也存储在堆上。它对应于上图中的Object 3。
2.4.2 关键点分析
-
该类MySharedObject还包含两个成员变量。成员变量本身与对象一起存储在堆上。这两个成员变量指向另外两个Integer 对象。这些Integer对象对应于上图中的
Object 2
和Object 4
。 -
还要注意
methodTwo()
是如何创建一个名为localVariable1的局部变量。此局部变量是对Integer对象的对象引用。该方法将localVariable1引用设置为指向新的Integer实例。localVariable1引用将存储在每个执行methodTwo()的线程的一个副本中。两个线程分别实例化的两个Integer对象将存储在堆上,但由于该方法每次执行时都会创建一个新的Integer对象,因此执行此方法的两个线程将创建单独的Integer实例。在methodTwo()中创建的Integer对象对应于上图中的Object 1
和Object 5
。 -
还要注意MySharedObject类中的两个long类型的成员变量,long是一个基元类型(基本数据类型)。但由于这些变量是成员变量,因此它们仍然与对象一起存储在堆上。只有
局部变量
存储在线程堆栈
上。
3. 堆栈内存的主要特性
有了上面对线程堆栈的清晰认识后,我们来看看堆栈内存的主要特征。
3.1 动态增长和收缩
堆栈内存会随着新方法的调用和返回而增长和收缩。调用方法时会创建一个新的堆栈框架,而方法返回时会删除相应的堆栈框架。
3.2 有限生命周期
在堆栈中声明的变量 仅当创建它们的方法正在运行时才存在。一旦方法完成执行,堆栈框架及其局部变量将被自动释放。
3.3 自动分配和释放
当方法被调用时,堆栈内存会自动分配,当方法执行完毕时,堆栈内存会自动释放。你不需要手动管理内存。
3.4 StackOverflowError
如果由于过多的方法调用而导致堆栈内存已满(例如,无基本情况的递归),Java 会抛出 StackOverflowError
。此错误表明堆栈已达到其限制。如下代码示例,将抛出StackOverflow异常:
public class StackOverflowExample {
public static void main (String[] args) {
recursiveMethod();
}
public static void recursiveMethod () {
recursiveMethod(); // 没有基本情况的递归调用。
}
}
3.5 快速访问
与堆内存相比,堆栈内存提供快速访问,因为它采用简单的后进先出
(LIFO
) 机制。访问堆栈上的变量非常高效。
3.6 线程安全
堆栈内存本质上是线程安全的,因为每个线程都在自己的堆栈中运行。每个线程的方法调用和局部变量都与其他线程隔离。
4. 硬件内存架构
现代硬件内存架构与 Java 内部内存模型有些不同。只有很好的了解了硬件内存架构,才能了解 Java 内存模型如何与硬件内存架构协同工作。本节介绍常见的硬件内存架构,后面的部分将介绍 Java 内存模型如何与硬件内存架构协同工作。
4.1 硬件内存架构简化图
以下是现代计算机硬件架构的简化图:
现代计算机通常有2个或多个CPU。其中一些CPU也可能有多个内核。关键在于,在具有2个或更多CPU的现代计算机上,可以同时运行多个线程。每个CPU在任何给定时间都能够运行一个线程。这意味着,如果您的Java应用程序是多线程的,则每个CPU的一个线程可能会在Java应用程序内同时(并发)运行。
4.1.1 CPU 寄存器 Registers
每个CPU都包含一组寄存器(registers
),这些寄存器基本上位于CPU内存中。CPU在这些寄存器上执行操作的速度
比在主存中的变量上快得多
。这是因为CPU访问
这些寄存器的速度
比访问主存储器
快得多。
4.1.2 CPU缓存层 Cache
每个CPU还可以具有CPU缓存层(CPU Cache
)。事实上,大多数现代CPU都有一定大小的缓存层。CPU可以比主存储器更快地访问其缓存,但通常不如访问其内部寄存器快。因此,CPU缓存的速度介于内部寄存器
和主存储器
之间。一些CPU可能有多个缓存层(Level 1
和Level 2
),但对了解Java内存模型如何与内存交互并不重要。重要的是要知道CPU可以有某种缓存层。
4.1.3 主存储区 RAM
计算机还包含一个主存储区(RAM
)。所有CPU都可以访问主存储器。主存储器区域
通常比CPU的高速缓冲
存储器大
得多。
通常,当CPU需要访问主存储器时,它会将主存储器的一部分读取到CPU缓存中
。它甚至可以将缓存的一部分读入其内部寄存器
,然后对其执行操作。当CPU需要将结果写回主内存(主存储区)时,它会先将值从其内部寄存器刷新到缓存层内存
,并在某个时候将值再刷新回主内存
(主存储区)。
当CPU需要在缓存中存储其他内容时,存储在缓存中的值通常会被刷新回主内存。CPU缓存可以一次将数据写入其部分内存,并一次刷新其部分内存。它不必每次更新时都读/写完整的缓存。通常,缓存在称为“缓存行”的较小内存块中更新。一个或多个缓存行可以被读入缓存存储器,一个或更多缓存行可以再次被刷新回主存储器。
4.2 Java 内存模型与硬件内存架构协同工作
如前所述,Java内存模型和硬件内存架构是不同的。硬件内存架构不区分线程堆栈和堆。在硬件上,线程堆栈和堆都位于主内存中。部分线程堆栈和堆有时可能存在于CPU缓存
和内部CPU寄存器中
。如图所示:
当对象和变量可以存储在计算机中各种不同的内存区域中时,可能会出现某些问题。两个主要问题是:
- 线程对共享变量的更新(写入)的可见性。
- 读取、检查和写入共享变量时的竞争条件。
以下章节将解释这两个问题,请继续阅读本文下一章节:共享对象的可见性
5. 共享对象的可见性
5.1 volatile关键字的作用
如果两个或多个线程共享一个对象,而没有正确使用volatile
声明或同步,则一个线程对共享对象的更新可能对其他线程不可见
。
想象一下,共享对象最初存储在主内存中。然后,在CPU 1上运行的线程将共享对象读取到其CPU缓存中。在那里,它对共享对象进行了更改。只要CPU缓存没有被刷新回主内存,在其他CPU上运行的线程就看不到共享对象的更改版本。这样,每个线程最终都可能拥有共享对象的自己的副本,每个副本都位于不同的CPU缓存中。
如上图,说明了粗略的情况。在左CPU上运行的一个线程将共享对象(obj
)复制到其CPU缓存中,并将其count
变量更改为2。此更改对在正确CPU上运行的其他线程不可见,因为obj对象的count变量
的更新尚未刷新回主内存。
为了解决这个问题,你可以使用Java 的 volatile
关键字。volatile
关键字可以确保给定变量直接从CPU主内存
中读取,并在更新时始终写回CPU主内存
。
5.2 竞争条件
如果两个或多个线程共享一个对象,并且多个线程更新该共享对象中的变量,则可能会出现竞争条件。
想象一下,如果线程A将共享对象obj的变量count读取到其CPU缓存中。再想象一下,线程B也做了同样的事情,但读取count进入了不同的CPU缓存。现在线程A将count值加一,线程B也执行相同操作。现在count已经增加了两次,每个CPU缓存中增加一次。
如果这些增量是按顺序执行的,则变量计数将递增两次,并将原始值+2写回主存储器。
但是,两次递增是并发进行的,没有进行适当的同步。无论线程 A 和线程 B 中的哪一个将count更新后的版本写回到主内存,更新后的值都只会比原始值高 1
,尽管进行了两次递增。
此图说明了上述竞赛条件问题的发生:
为了解决这个问题,您可以使用Java synchronized 同步块
。synchronized 同步块保证在任意给定时间只有一个线程可以进入代码的给定关键部分。同步块还保证同步块内访问的所有变量都将从主内存中读取,并且当线程退出同步块
时,所有更新的变量都将再次刷新回主内存
,无论变量是否声明为 volatile
。
6. 总结
本文内容到此结束,以上就是有关Java内存模型的详细剖析,一路往下读的小伙伴们,你们是不是觉得神清气爽啊?
Java内存模型
是Java程序员爱好者们深入了解底层最难
的部分之一,相信聪明的你,都会知道本文内容对java程序员的职业生涯至关重要,尤其当Java程序员走向高级进阶,架构师路线时,深入理解Java内存模型至关重要。