如果有,那你很可能体验过内存管理不善的后果:内存被占满,应用变慢。应用变慢并不总是内存的问题——比如从服务器处理大量数据、网络瓶颈等也会导致变慢——但内存管理问题往往是性能下降的“头号嫌疑人”。
你大概早就听说过“内存”这个计算机科学中的概念。这很正常——计算机有内存,用来在运行程序时存储与访问数据(而程序本身也是数据!)。
那应用什么时候会用到内存呢?举个例子:你要运行一个处理超大视频文件的应用。如果这时打开系统的活动监视工具(macOS 的 Activity Monitor 或 Windows 的任务管理器),会看到已用内存在你打开应用并加载视频后明显上升。内存是机器上的有限资源,一旦耗尽,电脑就会变慢。
要提升应用性能的方法有很多,其中一个重要方向是深入理解内存如何工作。遵循良好编码实践、高效使用内存,能显著提升应用性能。所以,关于内存管理,写好代码并理解内存原理应当是优先手段。除此之外,还有一种影响 Java 内存管理的方式:配置 JVM(Java 虚拟机) ,它负责 Java 的内存管理。我们会在第 6 章准备好之后再展开。
高效处理 Java 内存对 Java 应用性能至关重要。对于 Java 尤其如此,因为它带来了**垃圾回收(GC)**等相对昂贵的过程——等我们打好基础后会详细讲。
在并发环境下,内存管理对数据一致性同样重要。现在听起来也许有些复杂,但等读完本书你就会明白它的含义。
因此,为了优化应用对 Java 内存的使用,我们首先需要理解这块内存长什么样,以及与之相关的基本过程。本章将做这些铺垫:我们会探索 Java 内存的不同组成部分,以及在日常编码中如何使用它们。读完本章,你将对 Java 内存有一个清晰的概览,并为后续的深入学习做好准备。我们将覆盖以下主题:
- 计算机内存与 Java 内存的认识
- 在 Java 中创建变量
- 将变量存放在栈上
- 在 Java 中创建对象
- 将对象存放在堆上
- 了解 Metaspace(元空间)
技术要求
本章代码见 GitHub:PacktPublishing/B18762_Java-Memory-Management。
理解计算机内存与 Java 内存
先说最基本的——无论是不是 Java,运行应用都需要计算机内存。应用可用的内存来自计算机的物理内存。多了解一些计算机内存有助于理解 Java 内存。因此,我们先来更详细地谈谈“内存”与“Java 内存”。
计算机内存
你大概已经知道:计算机有内存(main memory / primary storage) ,用于存放执行过程所用的信息。需要强调的是,这和存储(storage)不同,后者用于长期保存信息。之所以说“长期”,是因为 HDD 通过磁记录保存数据,SSD 可视为 EEPROM(电可擦可编程只读存储器)一类,不需要持续供电也能保持数据。相比之下,主内存中常见的 RAM(随机存取存储器) 需要持续供电才能维持数据。
这有点像我们的大脑:有长期记忆和短期记忆。长期记忆用来存放一段段人生片段;短期记忆则适合记住两步验证的 6 位数字——而且最好几分钟后就忘了。
访问主内存
计算机(更准确地说是 CPU)访问主内存的速度,远快于访问永久存储。主内存里放着当前打开的程序及其正在使用的数据。
你或许有过这样的体验:每天第一次启动电脑并打开常用应用时,需要几秒钟;如果不小心关掉后立刻再开,就会快很多。主内存起到缓存/缓冲的作用,因此第二次打开时,可以直接从内存而不是存储加载,这也侧面说明了主内存更快。
好消息是,你无需掌握内存的所有微观细节,但大致了解会非常有帮助。
主内存概览
主内存中最常见的部分是 RAM。RAM 对计算机性能影响很大:正在运行的应用需要 RAM 来存储与访问数据,这些数据应用可以快速访问。如果 RAM 足够且操作系统管理得当,应用就能发挥应有的性能。
你可以通过系统监控工具查看可用 RAM。下面这张图就是我的 Activity Monitor 截图(macOS 12.5):
图 1.1 —— macOS 12.5 的活动监视器截图
我按内存占用从高到低排序。底部能看到可用与已用内存的统计。老实说占用看起来有点高,写完这一章我大概得查一查原因。
为什么要关心?因为一旦 RAM 过满,正在运行的应用就会显著变慢。当你开启的程序数量/体量超出机器规格能承载的范围时,这种情况尤为明显。
RAM 是易失性的(volatile) :断电就丢失信息。主内存并不只有 RAM,ROM(只读存储器) 也是主内存的一部分,但它是非易失性的,保存着机器启动所需的指令,断电也不会消失。
冷知识
我们日常把主内存统称为“RAM”,这很常见,但严格说并不完全准确。现在你知道了,这就是一个有点“较真”的小知识点。
Java 内存与 JVM
你可能会问:什么时候开始讲 Java 内存?答案是:现在!Java 的内存模型与计算机内存类似但并不相同。不过,在讨论 Java 内存之前,先简单说明一下 JVM 是什么。
JVM
JVM(Java 虚拟机)负责执行 Java 应用。这是否意味着 JVM“懂 Java 语言”?并不是!JVM 只理解字节码(.class 文件),也就是编译后的 Java 程序。其他语言(如 Kotlin)也能编译成 JVM 字节码,因此同样能由 JVM 执行——这就是常说的 JVM 语言(Java、Kotlin、Scala 等)。
流程如图 1.2 所示:
图 1.2 —— 一次编写,到处运行(Write once, run anywhere)
源码(这里假设是 Java)由Java 编译器编译,产生字节码(.class) 。JVM 解释执行字节码。每种平台(macOS/Windows/Linux)都有各自版本的 JVM 来执行字节码。这意味着应用本身无需修改即可在不同环境运行,因为平台相关的 JVM承担了对底层机器的适配。
这也是为什么 Java 曾经以 “一次编写,到处运行(WORA)” 闻名。如今其他语言也逐渐具备类似跨平台能力,所以这点不再那么“独家”。只要有 JVM 的平台都能跑 Java,因为 JVM 负责把字节码“翻译”给底层机器。
我常用旅行电源转换头来类比:世界各地电源插座不同,有了转换头,你就能在任何地方使用自己的设备。这里,JVM 就像转换头,“你所处的地方”就是运行 Java 的平台,而“你的设备”就是 Java 程序。
接下来,我们就看看 JVM 是如何处理内存管理的基础问题。
内存管理与 JVM
Java 内存用于存放运行 Java 应用所需的数据。应用中所有类的实例都存放在 Java 内存里。基本类型的值也是如此。常量呢?同样存放在 Java 内存!那方法代码、native 方法、字段数据、方法元数据,以及方法的执行顺序呢?你大概已经猜到:它们也都存放在 Java 内存中!
JVM 的一项重要职责就是管理 Java 内存。没有内存管理,就无法分配内存、对象也无法存放。即便能分配,也永远不会被清理。因此,清理内存(即对象的释放/回收)对运行 Java 代码至关重要。否则代码无法继续运行,或只进不出、内存迟早被占满,程序最终 OOM。具体机制我们将在第 4 章讨论释放过程——即垃圾回收(Garbage Collection, GC) 。
简而言之:内存管理很重要,而且是 JVM 的核心任务之一。如今我们对“自动内存管理”习以为常,但在早年这可新颖又“魔法”。先看看如果没有 JVM 替我们管理内存会发生什么。
Java 之前的内存管理
在早期语言(如 C/C++ )中,内存管理由开发者负责。也就是说,必须显式分配与释放内存。例如,在 C 里,下面的代码演示了如何分配一块内存并给它赋值(仅用于说明自动 GC 的“美妙”,并非 C 内存分配的完整指南):
int* x;
x = (int*)malloc(4 * sizeof(int));
int*表示x保存的是指向内存块起始地址的指针。malloc(memory allocation)用于按给定大小分配内存块,这里是4 * sizeof(int),返回基地址。
给这块内存赋值需要通过 *x,否则只是改了地址而不是地址中的值:
*x = 5;
printf("Our value: %d\n", *x);
上面把 x 指向的内存位置写成 5;打印 *x 会得到 5。注意 x 本身是地址,*x 才是该地址处的值。
当不再需要这块内存时,必须手动释放。否则这块内存会一直被占用、无法再利用:
free(x);
x = NULL;
free(x)让内存归还、以后可再次分配。- 但还没完:变量
x仍然指向那块地址。释放后该地址可能被其他数据覆盖,你却不知情;因此应把指针置为NULL。
那么释放后 x“指向”的是什么?还是原来的地址,但里面是什么就不确定了:可能是空、可能是旧值(若尚未被覆盖),也可能是后来写入的任意内容——充满不确定性。我平时挺喜欢惊喜,但在代码里的变量值上,可一点也不想“惊喜”。
手工内存管理的常见问题:
- 悬挂指针(dangling pointer) :释放内存后未将指针置空。
- 内存泄漏(memory leak) :不释放不再需要的内存,最终导致可用内存耗尽。
- 样板代码多:大量与分配/释放相关的代码,偏离业务逻辑,且需要维护。
- 易出错:明知要释放与置空,但一个小疏忽就会酿成问题。
还有其他坑,这些已经足以让我们感激 JVM 的自动分配与 GC。接下来看看 JVM 为此做了哪些设计。
JVM 的内存管理组件概览
为了执行应用,JVM 粗略可分为三类组件:
- 类加载器(Class Loader) :负责加载类并验证字节码。类的加载与字节码的执行都需要内存,用于存放类数据、分配信息与执行指令。
- 运行时数据区(Runtime Data Areas) :本书主要讨论的就是这部分,也就是我们口中的Java 内存。
- 执行引擎(Execution Engine) :在前两者把类加载进主内存后,执行字节码。执行引擎还会通过 JNI 使用执行所需的本地库(native libraries) 。
图 1.3 —— JVM 组件与应用执行流程概览(类加载 → 运行时数据区 → 执行引擎/JNI)
了解了“有哪些组成”,我们深入看与内存管理最相关的运行时数据区。
运行时数据区(Runtime Data Area)
JVM 的运行时数据区包括:
- 堆(Heap)
- 栈(Stack)
- 方法区 / 元空间(Method Area / Metaspace)
- 运行时常量池(Runtime Constant Pool)
- 程序计数器寄存器(PC Register)
- 本地方法栈(Native Method Stack)
图 1.4 —— 不同运行时内存区域示意
这些区域共同支撑 Java 应用的运行。逐个来看。
堆(Heap)
JVM 启动时会在 RAM 中预留一块区域供 Java 应用进行动态内存分配,即堆。运行期数据(如类实例)存放于此。JVM 负责在堆上分配(allocation)与回收(deallocation)空间;对象的释放由 GC 完成。堆里还细分了不同区域、对应不同 GC 策略与算法,这些在第 3/4 章详述。
栈(Stack)
更准确地说是 JVM 栈:用于存放基本类型的值以及指向堆上对象的引用(指针) 。每当调用方法,都会在相应线程的栈上创建一个栈帧(frame) ,其中保存该方法的局部变量、部分中间结果与返回值等。
不仅仅有一个栈:每个线程都有自己的栈(见图 1.5)。
图 1.5 —— 栈区:每个线程一条独立的栈
线程是一条执行路径;多线程意味着有多件事并发进行,这是并发(concurrency)的关键概念。栈区因此包含多条独立栈:线程间互不可见、彼此无链接。
方法区 / 元空间(Method Area / Metaspace)
方法区存放类在运行期的表示:包括运行时代码、静态变量、常量池、构造器代码等,也就是类的元数据。该区域所有线程共享。规范里称之为方法区;自 Java 8 起的 HotSpot 实现称为 Metaspace(早年称 PermGen,永久代)。PermGen 与 Metaspace 的差异会在后文细讲,这里先埋个伏笔。
程序计数器(PC Register)
PC(Program Counter)寄存器记录当前线程正在执行的指令地址(见图 1.6)。因此每个线程也有独立的 PC 寄存器(有时也口语化地与“调用栈”相提并论),以跟踪语句执行顺序。只有一个 PC 寄存器就无法并发执行多线程。
图 1.6 —— 每个线程拥有自己的 PC 寄存器
本地方法栈(Native Method Stack)
也称 C 栈,供本地代码(非 Java 代码,如 C)执行时使用,存放其调用所需的栈帧与数据,同样是每线程一份。其实现依赖具体 JVM;有些 JVM 不支持本地代码,自然也就没有本地方法栈。细节可查阅你所使用 JVM 的文档。
至此我们较为细致地浏览了 Java 运行时数据区的各个部分。信息量不小,先别着急——接下来我们会进一步讲解内存管理基础、栈与堆以及 Metaspace。不过在此之前,先从在 Java 中创建变量讲起。
在 Java 中创建变量
在 Java 中创建变量,首先需要声明(declare)变量;如果还要使用它,就需要初始化(initialize) 。你大概已经知道:声明就是为变量指定类型和名称;初始化就是为变量赋予一个实际的值:
int number = 3;
char letter = 'z';
上面是在同一行完成声明与初始化:类型分别是 int 和 char,变量名是 number 与 letter。当然也可以分多行写:
double percentage;
percentage = 8.6;
JVM 在运行时不再检查类型——类型检查在程序运行前由编译器完成。需要注意的是,基本类型与引用类型在存储方式上是有区别的,下面我们就来看看。
基本类型与引用类型
JVM 处理两类变量:基本类型(primitive)与引用类型(reference) 。
Java 一共有 8 种基本类型:
intbyteshortlongfloatdoublebooleanchar
基本类型的变量直接保存值本身,且仅限这 8 种类型。
引用类型则是类的实例。你可以自定义类,因此引用类型的种类没有固定上限。
当你创建变量时,变量中存放的值分为两类:基本类型的值和引用值。基本类型变量的类型就是上述八种之一;引用值则保存一个指向对象位置的指针。
引用又有四种形式:
- 类引用(Class references)
- 数组引用(Array references)
- 接口引用(Interface references)
null- 类引用:持有动态创建的类对象的引用。
- 数组引用:具有一个组件类型(component type) ,即数组的类型。如果该组件类型本身不是数组类型,就称为元素类型(element type) 。数组引用本身总是一维,但其组件类型可以再次是数组,从而形成多维数组。无论维度多少,最内层那个不再是数组类型的组件类型就是元素类型。元素类型可以是基本类型、类、或接口。
null:一个特殊值,表示引用不指向任何对象,此时引用的值为null。
这些变量如何存储?——基本类型变量和引用变量都存放在栈(stack)上,而实际的对象存放在堆(heap)上。接下来我们先看看将变量存放在栈上的细节。
将变量存放在栈上
方法中使用的变量会存放在栈(stack)上。栈内存是执行方法时使用的内存。在图 1.7 中,我们展示了 3 个线程的栈区,每个线程包含若干栈帧(frame) 。
图 1.7 —— 三个线程的栈区及其帧概览
在方法内部会有基本类型和值为引用的变量。应用中的每个线程都有自己的栈。栈由多个栈帧组成;每当有方法被调用,就会在栈上新建一个帧;当方法执行结束,该帧就会被移除。
如果栈内存小到无法容纳某个帧所需的数据,会抛出 StackOverFlowError。若为新线程创建新栈时没有足够空间,则会抛出 OutOfMemoryError。线程当前正在执行的方法称为当前方法,其数据存放在当前帧中。
当前帧与当前方法
“栈”之所以叫“栈”,是因为它只能访问栈顶的帧。可以把它类比为一摞盘子:你(安全地)只能从最上面取。栈顶帧就是当前帧,它对应当前正在执行的方法。
当正在执行的方法调用另一个方法时,会在其上方压入一个新帧。这个新帧随即成为当前帧,因为被调用的方法成为当前正在执行的方法。
在图 1.7 中,由于有 3 个线程,因此存在 3 个当前帧,它们都是各自栈的栈顶帧。例如:
- 线程 1 的当前帧:方法
y的帧 - 线程 2 的当前帧:方法
c的帧 - 线程 3 的当前帧:方法
k的帧
当某个方法执行完毕,其帧被移除;之前的帧又变回当前帧,因为调用它的那个方法重新获得控制并继续执行。
栈帧的组成
一个帧包含多种元素,用于存放执行方法所需的一切数据。图 1.8 给出了概览:
图 1.8 —— 栈帧结构示意
如图所示,一个帧包含:局部变量数组(local variable array) 、操作数栈(operand stack)以及帧数据(frame data) 。我们分别来看。
局部变量数组
帧中的局部变量以数组形式存放。这个数组的长度在编译期就确定了。数组的槽位分为单槽(single)与双槽(double) :
- 单槽用于
int、short、char、float、byte、boolean以及引用; - 双槽用于
long与double(它们是 64 位)。
局部变量通过索引访问。方法分为静态方法和实例方法:
- 对于实例方法,局部变量数组的第 0 位总是指向当前对象的引用,即
this;传入该方法的参数从索引 1开始存放。 - 对于静态方法,因为不隶属于实例,不需要
this,所以从索引 0就开始存放参数。
操作数栈(operand stack)
这个概念稍绕,但请跟上。每个栈帧内部都有一个操作数栈,用于临时存放参与运算的操作数与运算结果——“值都在这里飞来飞去”。
举个例子:新建帧时,操作数栈是空的。假设方法要计算 x + y。x、y 的值最初在局部变量数组里。为了执行加法,需要先把它们的值压入操作数栈:先压 x,再压 y。
操作数栈是“后进先出”,取值时只能从栈顶弹出:先弹出 y,再弹出 x。完成加法后,结果会再次压入操作数栈,供后续弹出使用。
操作数栈还用于许多重要操作,例如为方法调用准备入参、以及接收被调用方法的返回值。
帧数据(frame data)
帧数据包含执行方法所需的其他元数据,例如:
- 指向**常量池(constant pool)**的引用;
- 方法的正常返回方式;
- 异常(非正常完成)相关信息,等等。
其中“常量池引用”值得特别关注。类文件中的符号引用需要在运行时常量池中解析。运行时常量池由编译器生成,包含运行该类所需的各类常量与符号信息(如标识符名称等)。JVM 在运行期利用这些信息将该类链接到其他类。
每个帧都会保存当前方法所属类的运行时常量池引用。由于这是包含符号引用的“运行时”常量池,因而链接是在运行期动态完成的。
来看一个我们的小例子 Example 类的常量池。示例代码如下:
package chapter1;
public class Example {
public static void main(String[] args) {
int number = 3;
char letter = 'z';
double percentage;
percentage = 8.6;
}
}
编译(javac Example.java)后运行:
javap -v Example.class
得到的输出(节选)如下:
Classfile /Users/.../Example.class
Last modified 12 Jun 2022; size 298 bytes
SHA-256 checksum b2a6...
Compiled from "Example.java"
public class chapter1.Example
minor version: 0
major version: 61
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #9 // chapter1/Example
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Double 8.6d
#9 = Class #10 // chapter1/Example
#10 = Utf8 chapter1/Example
#11 = Utf8 Code
#12 = Utf8 LineNumberTable
#13 = Utf8 main
#14 = Utf8 ([Ljava/lang/String;)V
#15 = Utf8 SourceFile
#16 = Utf8 Example.java
{
public chapter1.Example();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
...
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=5, args_size=1
0: iconst_3
1: istore_1
2: bipush 122
4: istore_2
5: ldc2_w #7 // double 8.6d
8: dstore_3
9: return
...
}
可以看到,这里共有 16 个常量池条目,既有我们代码引入的,也有 Java 运行所需的(如类名、方法名等),它们都是程序执行必不可少的信息。
栈上的值
基本类型的局部变量会直接存放在栈上——更精确地说,存放在对应方法栈帧中的局部变量数组里。对象本身不会存放在栈上;栈里存放的是对象引用,即在堆上找到对象的地址。
基本类型与包装类
别把基本类型与其包装类(wrapper class)混淆。包装类名称首字母大写,并且它们是对象,因此不在栈上(对象在堆上)。当方法执行结束,与之关联的基本类型值会从栈中清除,不再存在。
来看一段代码:
int primitiveInt = 2;
Integer wrapperInt = 2;
char primitiveChar = 'A';
Character wrapperChar = 'A';
你会发现:包装类通常首字母大写且名字更长。但也有很多与基本类型拼写相同、只是大小写不同的,例如 Boolean 与 boolean(我常常被它们迷惑——在 C# 中对应的是 bool)。
再看其他成对示例:
short primitiveShort = 15;
Short wrapperShort = 15;
long primitiveLong = 8L;
Long wrapperLong = 8L;
double primitiveDouble = 3.4;
Double wrapperDouble = 3.4;
float primitiveFloat = 5.6f;
Float wrapperFloat = 5.6f;
boolean primitiveBoolean = true;
Boolean wrapperBoolean = true;
byte primitiveByte = 0;
Byte wrapperByte = 0;
注意它们名字完全相同,唯一区别是首字母大小写。包装类是对象,其创建与存放方式与基本类型不同。接下来我们将继续探究对象如何创建并存放在堆上。
在 Java 中创建对象
对象是一组数值的集合。在 Java 中,可以通过对类使用 new 关键字来实例化对象。
下面是一个非常基础的 Person 类:
public class Person {
private String name;
private String hobby;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getHobby() {
return hobby;
}
public void setHobby(String hobby) {
this.hobby = hobby;
}
}
如果我们要实例化它,可以这样写:
Person p = new Person();
这行代码会创建一个新的 Person 对象,并把它存储到**堆(heap)**上。把对象存到堆里值得再多解释一下——接下来就来详细看看。
将对象存放在堆上
把对象放到堆里与把值放到栈里完全不同。正如我们刚才所见,指向堆上位置的引用是存放在栈上的。引用是内存地址,它指向堆中的某个位置,即对象真正被存放的地方。没有对象引用,我们就无法访问堆上的对象。
对象引用有自己的类型。Java 提供了大量内置类型可用,比如 ArrayList、String、各种包装类等;当然我们也可以创建自定义类,这些对象同样都会存放在堆上。
堆内存保存着应用中所有存在的对象。应用中的任何地方都可以通过对象的**地址(对象引用)**来访问堆上的对象。对象内部就像栈上的“块”一样:基本类型的数值直接存放,对其他对象的引用则以地址形式存放(指向堆上的其他对象)。
在图 1.9 中,你可以看到栈与堆的一个概览,以及下面这段 Java 代码在(简化)内存视图中的样子:
public static void main(String[] args) {
int x = 5;
Person p = new Person();
p.setName("maaike");
p.setHobby("coding");
}
图 1.9 —— 栈与堆之间关系的概览
这里做了很多简化——比如,Person 对象里的两个 String 实际上各自也是独立的对象。我们会在第 3 章专门聚焦堆,获得对堆区更准确的理解。
那么,堆内存耗尽会发生什么?当应用需要的堆空间超过可用大小时,会抛出 OutOfMemoryError。
好了,栈与堆我们都看过了。这里还剩下一个需要讨论的内存区域:Metaspace(元空间) 。
探索 Metaspace(元空间)
Metaspace 是在运行期用于保存类元数据(class metadata)的内存区域。它对应 JVM 规范中的方法区(method area) ;在 Java SE 7 之后的大多数主流 Java 实现里,这块区域被称为 Metaspace。
如果你听说过 PermGen(永久代) ,或者在旧资料中遇到它,需要知道那是早期用于存放类元数据的内存区域。由于存在一些限制,它已被 Metaspace 所取代。
回到“类元数据”本身——它究竟是什么?类元数据是程序运行所需 Java 类在运行时的表示,包含了很多内容,例如:
- Klass 结构(第 5 章我们深挖 Metaspace 时会详细介绍!)
- 方法的字节码(bytecode)
- 常量池(constant pool)
- 注解(annotations) ,以及其他内容
以上就是要点!这些构成了 Java 内存管理的基础。当然,每个部分还有许多可展开之处。下一章我们将首先更深入地观察堆上的基本类型与对象;在此之前,先快速回顾一下本章内容。
小结
本章我们对 Java 内存 做了总体梳理。首先回顾了计算机内存:计算机有主存与二级存储,而与我们运行程序(包括 Java 程序)最相关的是主存。
主存由 RAM 与 ROM 构成。Java 应用运行依赖 RAM。Java 应用由 JVM 执行;为此,JVM 包含三大组件:类加载器(class loader) 、运行时数据区(runtime data areas)以及执行引擎(execution engine) 。
我们重点关注了运行时数据区中的各个部分:堆(heap) 、栈(stack) 、方法区/Metaspace、PC 寄存器与本地方法栈。
- 栈:以**栈帧(frame)**的形式存放方法的局部变量与运行时数值。
- 堆:用于存放对象;栈里保存的是指向堆上对象的引用。
- 访问性:堆在应用内全局可达,拿到对象在堆上的地址(引用)就能访问对象;而栈仅对创建该栈的线程可见。
- Metaspace:存放运行期所需的类元数据的内存区域。
下一章,我们将可视化并更细致地看看堆与栈如何协同工作。