Java 内存管理——Java 内存的不同组成部分

108 阅读24分钟

如果有,那你很可能体验过内存管理不善的后果:内存被占满,应用变慢。应用变慢并不总是内存的问题——比如从服务器处理大量数据、网络瓶颈等也会导致变慢——但内存管理问题往往是性能下降的“头号嫌疑人”。

你大概早就听说过“内存”这个计算机科学中的概念。这很正常——计算机有内存,用来在运行程序时存储与访问数据(而程序本身也是数据!)。

那应用什么时候会用到内存呢?举个例子:你要运行一个处理超大视频文件的应用。如果这时打开系统的活动监视工具(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):

image.png

图 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 所示:

image.png

图 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 粗略可分为三类组件:

  1. 类加载器(Class Loader) :负责加载类验证字节码。类的加载与字节码的执行都需要内存,用于存放类数据、分配信息与执行指令
  2. 运行时数据区(Runtime Data Areas) :本书主要讨论的就是这部分,也就是我们口中的Java 内存
  3. 执行引擎(Execution Engine) :在前两者把类加载进主内存后,执行字节码。执行引擎还会通过 JNI 使用执行所需的本地库(native libraries)

image.png

图 1.3 —— JVM 组件与应用执行流程概览(类加载 → 运行时数据区 → 执行引擎/JNI)

了解了“有哪些组成”,我们深入看与内存管理最相关的运行时数据区

运行时数据区(Runtime Data Area)

JVM 的运行时数据区包括:

  • 堆(Heap)
  • 栈(Stack)
  • 方法区 / 元空间(Method Area / Metaspace)
  • 运行时常量池(Runtime Constant Pool)
  • 程序计数器寄存器(PC Register)
  • 本地方法栈(Native Method Stack)

image.png

图 1.4 —— 不同运行时内存区域示意

这些区域共同支撑 Java 应用的运行。逐个来看。

堆(Heap)

JVM 启动时会在 RAM 中预留一块区域供 Java 应用进行动态内存分配,即运行期数据(如类实例)存放于此。JVM 负责在堆上分配(allocation)回收(deallocation)空间;对象的释放由 GC 完成。堆里还细分了不同区域、对应不同 GC 策略与算法,这些在第 3/4 章详述。

栈(Stack)

更准确地说是 JVM 栈:用于存放基本类型的值以及指向堆上对象的引用(指针) 。每当调用方法,都会在相应线程的栈上创建一个栈帧(frame) ,其中保存该方法的局部变量、部分中间结果与返回值等。

不仅仅有一个栈:每个线程都有自己的栈(见图 1.5)。

image.png

图 1.5 —— 栈区:每个线程一条独立的栈

线程是一条执行路径;多线程意味着有多件事并发进行,这是并发(concurrency)的关键概念。栈区因此包含多条独立栈线程间互不可见彼此无链接

方法区 / 元空间(Method Area / Metaspace)

方法区存放类在运行期的表示:包括运行时代码、静态变量、常量池、构造器代码等,也就是类的元数据。该区域所有线程共享。规范里称之为方法区;自 Java 8 起的 HotSpot 实现称为 Metaspace(早年称 PermGen,永久代)。PermGen 与 Metaspace 的差异会在后文细讲,这里先埋个伏笔。

程序计数器(PC Register)

PC(Program Counter)寄存器记录当前线程正在执行的指令地址(见图 1.6)。因此每个线程也有独立的 PC 寄存器(有时也口语化地与“调用栈”相提并论),以跟踪语句执行顺序。只有一个 PC 寄存器就无法并发执行多线程。

image.png

图 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';

上面是在同一行完成声明与初始化:类型分别是 intchar,变量名是 numberletter。当然也可以分多行写:

double percentage;
percentage = 8.6;

JVM 在运行时不再检查类型——类型检查在程序运行前由编译器完成。需要注意的是,基本类型引用类型在存储方式上是有区别的,下面我们就来看看。

基本类型与引用类型

JVM 处理两类变量:基本类型(primitive)引用类型(reference)

Java 一共有 8 种基本类型

  • int
  • byte
  • short
  • long
  • float
  • double
  • boolean
  • char

基本类型的变量直接保存值本身,且仅限这 8 种类型。

引用类型则是类的实例。你可以自定义类,因此引用类型的种类没有固定上限

当你创建变量时,变量中存放的值分为两类:基本类型的值引用值。基本类型变量的类型就是上述八种之一;引用值保存一个指向对象位置的指针

引用又有四种形式

  • 类引用(Class references)
  • 数组引用(Array references)
  • 接口引用(Interface references)
  • null
  • 类引用:持有动态创建的类对象的引用。
  • 数组引用:具有一个组件类型(component type) ,即数组的类型。如果该组件类型本身不是数组类型,就称为元素类型(element type) 。数组引用本身总是一维,但其组件类型可以再次是数组,从而形成多维数组。无论维度多少,最内层那个不再是数组类型的组件类型就是元素类型。元素类型可以是基本类型、类、或接口
  • null:一个特殊值,表示引用不指向任何对象,此时引用的值为 null

这些变量如何存储?——基本类型变量引用变量存放在栈(stack)上,而实际的对象存放在堆(heap)上。接下来我们先看看将变量存放在栈上的细节。

将变量存放在栈上

方法中使用的变量会存放在栈(stack)上。栈内存是执行方法时使用的内存。在图 1.7 中,我们展示了 3 个线程的栈区,每个线程包含若干栈帧(frame)

image.png

图 1.7 —— 三个线程的栈区及其帧概览

在方法内部会有基本类型和值为引用的变量。应用中的每个线程都有自己的栈。栈由多个栈帧组成;每当有方法被调用,就会在栈上新建一个帧;当方法执行结束,该帧就会被移除。

如果栈内存小到无法容纳某个帧所需的数据,会抛出 StackOverFlowError。若为新线程创建新栈时没有足够空间,则会抛出 OutOfMemoryError。线程当前正在执行的方法称为当前方法,其数据存放在当前帧中。

当前帧与当前方法

“栈”之所以叫“栈”,是因为它只能访问栈顶的帧。可以把它类比为一摞盘子:你(安全地)只能从最上面取。栈顶帧就是当前帧,它对应当前正在执行的方法。

当正在执行的方法调用另一个方法时,会在其上方压入一个新帧。这个新帧随即成为当前帧,因为被调用的方法成为当前正在执行的方法。

在图 1.7 中,由于有 3 个线程,因此存在 3 个当前帧,它们都是各自栈的栈顶帧。例如:

  • 线程 1 的当前帧:方法 y 的帧
  • 线程 2 的当前帧:方法 c 的帧
  • 线程 3 的当前帧:方法 k 的帧

当某个方法执行完毕,其帧被移除;之前的帧又变回当前帧,因为调用它的那个方法重新获得控制并继续执行。

栈帧的组成

一个帧包含多种元素,用于存放执行方法所需的一切数据。图 1.8 给出了概览:

image.png

图 1.8 —— 栈帧结构示意

如图所示,一个帧包含:局部变量数组(local variable array)操作数栈(operand stack)以及帧数据(frame data) 。我们分别来看。

局部变量数组

帧中的局部变量以数组形式存放。这个数组的长度在编译期就确定了。数组的槽位分为单槽(single)双槽(double)

  • 单槽用于 intshortcharfloatbyteboolean 以及引用
  • 双槽用于 longdouble(它们是 64 位)。

局部变量通过索引访问。方法分为静态方法实例方法

  • 对于实例方法,局部变量数组的第 0 位总是指向当前对象的引用,即 this;传入该方法的参数索引 1开始存放。
  • 对于静态方法,因为不隶属于实例,不需要 this,所以从索引 0就开始存放参数

操作数栈(operand stack)

这个概念稍绕,但请跟上。每个栈帧内部都有一个操作数栈,用于临时存放参与运算的操作数与运算结果——“值都在这里飞来飞去”。

举个例子:新建帧时,操作数栈是空的。假设方法要计算 x + yxy 的值最初在局部变量数组里。为了执行加法,需要先把它们的值压入操作数栈:先压 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';

你会发现:包装类通常首字母大写且名字更长。但也有很多与基本类型拼写相同、只是大小写不同的,例如 Booleanboolean(我常常被它们迷惑——在 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 提供了大量内置类型可用,比如 ArrayListString、各种包装类等;当然我们也可以创建自定义类,这些对象同样都会存放在堆上。

堆内存保存着应用中所有存在的对象。应用中的任何地方都可以通过对象的**地址(对象引用)**来访问堆上的对象。对象内部就像栈上的“块”一样:基本类型的数值直接存放,对其他对象的引用则以地址形式存放(指向堆上的其他对象)。

在图 1.9 中,你可以看到栈与堆的一个概览,以及下面这段 Java 代码在(简化)内存视图中的样子:

public static void main(String[] args) {
    int x = 5;
    Person p = new Person();
    p.setName("maaike");
    p.setHobby("coding");
}

image.png

图 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 程序)最相关的是主存

主存由 RAMROM 构成。Java 应用运行依赖 RAM。Java 应用由 JVM 执行;为此,JVM 包含三大组件:类加载器(class loader)运行时数据区(runtime data areas)以及执行引擎(execution engine)

我们重点关注了运行时数据区中的各个部分:堆(heap)栈(stack)方法区/MetaspacePC 寄存器本地方法栈

  • :以**栈帧(frame)**的形式存放方法的局部变量与运行时数值。
  • :用于存放对象里保存的是指向堆上对象的引用
  • 访问性:堆在应用内全局可达,拿到对象在堆上的地址(引用)就能访问对象;而仅对创建该栈的线程可见。
  • Metaspace:存放运行期所需的类元数据的内存区域。

下一章,我们将可视化并更细致地看看堆与栈如何协同工作。