Java 内存布局与垃圾回收归纳

本文基于由周志明所著的《深入理解 Java 虚拟机》一书的第二部分的内容,同时加入了 JVM 规范以及 Oracle 官方 GC 性能调优指南中的内容,旨在能让读者更好地理解这部分知识。目前本文只会包含 JVM 内存布局与垃圾回收相关的归纳内容,如果以后有机会我会继续更新 JVM GC 调优相关的内容。如果读者对本文的内容组织有更好的建议,欢迎在下方评论处提出。

Java 内存布局

JVM 在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。有的区域依赖线程的启动和结束而建立和销毁,这样的区域可看作是线程私有的区域(Per-Thread Data Area);其他的区域则随着虚拟机进程的启动而存在,可以看做是线程间共享的区域。

具体而言,JVM 的运行时数据区域包括如下:

  • 程序计数器(Program Counter Register):线程私有的数据区域,保存当前正在执行的虚拟机指令的地址;
  • Java 虚拟机栈(Java Virtual Machine Stack):线程私有的数据区域,每个栈帧中存放当前执行方法的本地变量及返回地址;
  • Java 堆(Heap):线程共享的数据区域,对象及数组的分配空间;
  • 方法区(Method Area):线程共享的数据区域,存放由类加载器加载的类型信息、常量及静态变量;
  • 本地方法栈(Native Method Stack):用于 Native 方法的方法栈

本章将分别讲述JVM的内存区域划分以及它们各自可能导致的内存溢出异常。

详见《JVMS》第 2.5 节

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器,而每个线程都有其独立的程序计数器。
如果线程正在执行的不是 native 方法,这个计数器记录的是正在被执行的虚拟机指令的地址;如果正在执行的是一个 native 方法,则该计数器的内容不确定(undefined)。

JVM的程序计数器区域应足够大以容纳方法返回地址 returnAddress 或具体平台特有的 native 指针,因此此内存区域是唯一一个在《JVMS》中没有规定任何OutOfMemoryError 情况的区域。

详见《JVMS》第 2.5.1 节

Java 虚拟机栈

Java 虚拟机栈(Java Virtual Machine Stack)是线程私有的数据区域。每个 Java 方法被调用的时候都会创建一个栈帧(Frame)放入到 Java 虚拟机栈中,其中存储了局部变量表、操作数栈、动态链接、方法出口等信息。方法从调用直至执行完成的过程就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

《JVMS》允许将栈帧直接于堆空间上进行分配,即栈帧所处的内存空间并不需要是连续的。《JVMS》还允许具体的 JVM 实现定义虚拟机栈的大小是静态的还是动态的,JVM 随时可以按需地扩大或压缩虚拟机栈的大小。静态虚拟机栈的大小则可以在虚拟机栈创建时确定。

如果线程请求的栈深度超出 JVM 所允许的深度,JVM 将抛出 StackOverflowError 异常;如果 JVM 的虚拟机栈是可以动态扩展的,但在扩展式无法申请到足够的内存,那么 JVM 将抛出 OutOfMemoryError 异常。

详见《JVMS》第 2.5.2 节。有关栈帧的细节详见《JVMS》第 2.6 节

(Heap)是线程间共享的数据区域,于 JVM 启动时被创建,所有的类实例以及数组都要在堆上分配。除此之外,堆是 JVM 垃圾收集器主要管理的区域。

根据《JVMS》的规定,堆的内存空间也不需要是连续的,其大小可以是静态的也可以是动态的。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,JVM 将抛出 OutOfMemoryError 异常。

详见《JVMS》第 2.5.3 节

方法区

方法区(Method Area)是线程间共享的数据区域,用于存储已被虚拟机加载的类信息、常量、静态变量以及 JIT 编译后的代码等数据。

方法区在逻辑上属于堆的一部分,因此方法区可以和堆一样不需要连续的内存地址,也可以选择固定大小或可扩展。除此之外,JVM也可以选择不对方法区进行垃圾回收。该须臾的内存回收目标主要是针对常量池的回收和对类的卸载,因此一般来说,这个区域的回收“成绩”比较难以令人满意。

当方法区无法满足内存分配需求时,JVM 将抛出 OutOfMemoryError 异常。

详见《JVMS》第 2.5.4 节

本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈类似,是用于服务 Native 方法的方法栈。在于虚拟机栈相同的情形下,本地方法栈也能抛出 StackOverflowErrorOutOfMemoryError 异常。

对象存活判断算法

在了解过JVM的总体内存布局后,我们便开始讨论JVM堆的垃圾回收机制。首先,JVM会将所有的对象和数组存放在堆中。垃圾收集器在对堆进行回收前,要做的第一件事就是要先确定这些对象之中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。

本章首先介绍两种常见的用于判断对象是否应被回收的算法。具体包括如下:

  • 引用计数算法(Reference Counting)
  • 可达性分析算法(Reachability Analysis)

引用计数算法

引用计数算法(Reference Counting)的机制十分简单:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;计数器为 0 的对象就不可能再被使用了。

引用计数算法实现简单,判定效率高,部分语言的运行时选用了这种算法,但主流的JVM没有使用这种算法来管理内存,其中最主要的原因是它难以解决对象之间的相互循环引用问题

可达性分析算法

可达性分析算法(Reachability Analysis)很好地解决了对象间循环引用的问题。该算法首先会使用一系列被称为 “GC Root” 的对象作为判定的起始点,从这些结点开始不断地向下搜索。搜索时所走过的路径则代表着从 GC Root 到当前结点的引用链(Reference Chain),当内存中的某个对象不存在从 GC Root 到该对象的引用链时,此对象即为不可用的对象。如果从图论的角度来思考,将对象作为图的结点,将对象间的引用作为图的有向边,那么这个问题实际上就可以变为从 GC Root 结点到其他所有结点的多源可达性问题。

对于 Java 而言,可作为 GC Root 的对象包括如下四种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象(即方法的本地变量)
  • 方法区中类静态属性引用的对象(即静态成员变量)
  • 方法区中常量引用的对象(即基本数据类型常量)
  • 本地方法栈中 JNI(即一般说的 native 方法)引用的对象

内存回收算法

在解决了对象存活性的判断问题后,接下来就可以开始讨论如何回收已经死亡的对象了。

本章将介绍三种内存回收算法。具体包括如下:

  • 标记 – 清除算法(Mark - Sweep)
  • 复制收集算法(Copying)
  • 标记 – 整理算法(Mark - Compact)

标记 – 清除算法

标记 – 清除算法(Mark - Sweep)分为了“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

标记 – 清除算法是最基础的收集算法,但其主要的不足有两点:

  • 效率:标记和清除这两个过程的时间效率并不高
  • 空间:标记 – 清除算法对内存进行直接回收,会导致回收后产生大量不连续的内存碎片。过多的空间碎片会导致以后程序在运行过程中需要为一个较大的对象分配内存空间时无法找到足够大的连续内存空间,而不得不因此提前触发另一次垃圾收集动作

我们很快就能看到,标记 – 清除算法作为最基础的收集算法,后续的两种算法实际上都是基于其原本的思路并对其不足进行改进而得出的。

复制收集算法

为了解决标记 – 清除算法的时间效率问题,人们发明了复制收集算法(Copying)。

复制收集算法将可用的内存划分为了大小相等的两块,每次只使用其中一个块。当当前内存块用完时,复制收集算法就会将还存活着的对象复制到另一块内存上,再把已经使用过的内存块一次性清理掉。

复制收集算法将标记 – 清除算法的清除操作从对若干个分散的小内存区域的回收变为了对一块大内存区域的一次性回收,有效提高了回收的时间效率。除此之外,每次复制发生时都会把存活对象连续地复制到另一块内存中,因此内存分配时也就不需要考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配即可。可惜,这种算法会使得总体可用的内存缩小为原本的一半,成本也十分巨大。

标记 – 整理算法

实际上前文所提及的复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。

标记 – 整理算法(Mark - Compact)在某种程度上与标记 – 清除算法类似,但它在标记完成后不会直接对可回收对象进行清理,而是将所有存活的对象移动到内存的一端,然后直接清理掉边界以外的所有内存。

HotSpot JVM 分代收集机制与收集器

在讲述完常见的对象存活判定算法和垃圾收集算法后,我们接下来将以 Oracle HotSpot JVM 为例来巩固大家对这些算法的运用的理解。

本章将首要介绍 Oracle HotSpot JVM 所使用的内存分代收集机制,继而详细介绍 HotSpot JVM 所实现的所有垃圾收集器。

分代收集机制

在上一章中,我们讨论了复制收集算法和标记 – 整理算法。其中,复制收集算法有着极高的时间效率,通过将内存空间划分为两块区域有效地避免了内存碎片的问题。不过,当对象存活率较高时,复制收集算法的回收率下降,自然就会导致复制操作频繁发生,继而降低效率。标记 – 整理算法则是在标记 – 回收算法的基础上改进了回收操作,使得内存碎片的问题得以避免,无奈其相比于复制收集算法仍然显得更为低效,不过它的执行效率并不会因对象存活率的升高而降低。

目前,主流的商业 JVM 所采用的都是分代收集机制(Generational Collection)。这种机制并没有什么新的思想,只是根据对象存活周期的长短将内存划分为了新生代(Young Generation)和老年代(Tenured Generation),并根据各个分代的特点采用最适当的收集算法。

对于新生代内存空间而言,所存储对象的存活周期较短,每次垃圾收集时都可以发现较大量的无用对象,因此在该空间上可以采用更为高效的复制收集算法;而老年代因为对象存活率高,则应使用标记 – 清理算法或标记 – 整理算法来进行回收。

当新生代内存空间渐满时便会触发针对新生代的垃圾回收,该操作被称为 Minor GC(Minor Collection)。这个过程中部分处于新生代的对象可能会被移入老年代。当老年代渐满时,JVM 就会触发 Major GC(Major Collection,也被称为 Full GC),对整个堆空间(包括新生代空间)进行回收。因此,通常来讲 Major GC 持续的时间会比 Minor GC 长很多。

上图即为 JVM 的分代内存空间布局。可以看到,除了两个年代空间各有的 Virtual 空间(代表预留但未真正占用的内存空间)以外,新生代内存空间包含一块 Eden 区和两块 Survivor 区。新创建的对象都会被分配到 Eden 区中,每次新生代内存空间只有 Eden 区和其中一块 Survivor 区被占用,而当 Minor GC 发生时,存活的对象就会被复制到另一块未被占用的 Survivor 区中,部分升级为老年代的对象也会被移入到老年代内存空间中。

可达性分析与 Stop the World

在开始讨论 Oracle HotSpot JVM 所实现的垃圾收集器前,我们先简单介绍一下 HotSpot JVM 所实现的垃圾收集算法的特点,方便大家对后续内容的理解。

在前文中,我们提到主流的 JVM 均采用可达性分析算法来判断对象的存活。可达性分析算法的第一步是枚举 GC Root 结点。可作为 GC Root 的结点主要包括全局性引用(如常量和静态成员变量)以及执行上下文(如栈帧中的本地变量表)。第二步则是根据当前对象间的引用关系来分析所有对象的可达性。

实际上,可达性分析算法对执行时间是敏感的,因为这项分析工作必须在一个能确保一致性的快照中进行:在整个分析期间,整个执行系统应看起来像是被冻结在某个时间点上一般,不可以出现分析过程中对象引用关系还在不断变化的情况,否则分析结果的准确性就无法得到保证。因此,当 JVM 进行内存回收时需要停顿所有的执行线程,也就是所谓的 Stop the World。

垃圾收集器

接下来我们将开始详细讲述 Oracle HotSpot JVM 所支持的垃圾收集器。我们将会看到其中不乏可完全避免 Stop the World 现象发生的垃圾收集器。

上图展示了 Oracle HotSpot JVM 中的 7 种作用于不同分代的收集器。收集器之间的连线代表着这两种收集器可以搭配使用,而它们所处的区域则表示了它们是属于新生代收集器还是老年代收集器。这些收集器采用不同的机制实现了不同的垃圾收集算法,它们的侧重点也各有不同,因此并不存在一种在任何场景下都适用的完美收集器。根据实际需要选取最佳的垃圾收集器也是 JVM 调优师的必备技能之一。

Serial 与 Serial Old 收集器

Serial 收集器是最基本最古老的收集器,曾是 JVM 新生代收集的唯一选择。这个收集器是一个单线程新生代收集器,采用复制收集算法,必须在进行垃圾收集时暂停其他所有工作线程,直至收集结束。

Serial Old 收集器则是 Serial 收集器的老年代版本,同样也是一个单线程收集器,采用标记 – 整理算法进行回收,同样会触发 Stop the World。

相比于后来出现的各种新式收集器,Serial 与 Serial Old 收集器的功能显得比较鸡肋,但仍然有着它们独有的优势:实现简单,在单 CPU 的环境下更为高效。由于 Serial 与 Serial Old 收集器采用单线程进行垃圾收集,在单 CPU 环境下不会引入线程切换的开销,因此要更为高效。

实际上,Serial收集器是 Client 模式下的默认新生代收集器,因为在桌面应用场景中,JVM 所管理的内存往往不会很大,因此停顿的时间完全可以被控制在 100 毫秒以内。

ParNew收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,使用多条线程进行垃圾收集。

相比于 Serial 收集器,ParNew 收集器适用于多 CPU 环境,其多线程的特性能更好地利用多个 CPU 来加速垃圾收集操作。不过在单 CPU 环境下,由于 ParNew 收集器引入了线程切换的开销,其性能要低于 Serial 收集器。

作为除 Serial 收集器外唯一一个能与 CMS 收集器配合工作的新生代收集器,ParNew 收集器是 Server 模式虚拟机首选的新生代收集器

Parallel Scavenge 与 Parallel Old 收集器

与 ParNew 收集器类似,Parallel Scavenge 收集器也是使用复制算法的多线程新生代收集器。不同于 ParNew 收集器和 Serial 收集器,Parallel Scavenge 收集器专注于提高程序的吞吐量(即没有用于进行 GC 的 CPU 时间比例)而不是降低停顿时间。

Parallel Old 收集器则为 Parallel Scavenge 收集器的老年代版本,采用标记 - 整理算法。注意到 Parallel Scavenge 收集器不能和 CMS 收集器搭配工作,在 Parallel Old 收集器出现前 Parallel Scavenge 收集器只能与 Serial Old 收集器搭配,因此 Parallel Old 收集器的出现实际上使得吞吐量优先的程序终于有了完整的收集器组合。

因此,在注重吞吐量及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 和 Parallel Old 收集器。

CMS 收集器

CMS 收集器(Concurrent Mark-Sweep Collector)是一种专注降低回收停顿时间老年代收集器,使用的是标记 – 清除算法。

CMS 收集器的执行过程分为 4 个步骤:

  1. 初始标记(CMS Initial Mark):暂停所有用户线程(STW),标记所有与 GC Root 直接关联的对象;
  2. 并发标记(CMS Concurrent Mark):恢复用户线程的执行,使用独立的 GC 线程标记所有可达对象
  3. 重新标记(CMS Remark):暂停所有用户线程(STW),使用多条 GC 线程修正因用户程序继续执行导致可达性发生变化的对象的标记
  4. 并发清除(CMS Concurrent Sweep):恢复用户线程的执行,使用独立的 GC 线程回收死亡对象

可见,CMS 收集器进行垃圾回收时,大多数时候用户线程都仍在执行,有效降低了程序因垃圾收集所导致的停顿时间。

CMS 收集器的主要缺点包括以下 4 点:

  • 对 CPU 资源敏感
  • 存在浮动垃圾
  • Concurrent Mode Failure
  • 存在空间碎片

首先,CMS 收集器作为一种并发收集器对 CPU 资源是敏感的,可用 CPU 资源的下降会严重影响 CMS 的回收效率。GC 线程对 CPU 资源的占用也会导致用户程序的执行速度有所下降。

而后,CMS 收集器在并发标记阶段难以避免会因用户程序的持续执行导致可达性分析不完全正确,部分对象会在被标记后变为可回收对象。CMS 收集器无法在当次收集中处理这样的对象,这样的对象就成为了“浮动垃圾”,需要等待下一次收集才能被清理。

除此之外,由于标记阶段用户程序的持续执行,新对象需要申请更多的内存空间,因此 CMS 收集器不能在老年代空间即将填满时才开始进行收集,而是需要预留一部分空间给用户程序。我们可以通过 -XX:CMSInitiatingOccupancyFraction 参数来设置触发 CMS GC 的老年代空间使用百分比。

当 CMS 运行期间预留的内存无法满足程序需要,就会出现一次 Concurrent Mode Failure。此时 JVM 就会启动后备预案,启用 Serial Old 收集器重新进行老年代的垃圾收集(Full GC),这样就导致 GC 停顿时间变长了。过高的 -XX:CMSInitiatingOccupancyFraction 参数容易导致 Concurrent Mode Failure 频繁发生,反而降低性能。

最后,CMS 收集器采用的是标记 – 清除算法,这样的算法在收集后会产生大量的空间碎片,后续出现的大对象可能难以找到可用的连续空间,导致提前触发 Major GC。值得注意的是,CMS 收集器还提供了 -XX:+UseCMSCompactAtFullCollection-XX:CMSFullGCsBeforeCompaction 参数来在满足一定条件时进行内存整理。

G1 收集器

G1 收集器(Garbage-First Collector)和 CMS 类似,专注于减少 GC 停顿时间,是一种能在多数时候与用户程序并发的垃圾收集器。不同的是,G1 收集器可以管理整个 JVM 堆,有着比 CMS 更为优秀的特性,并且能建立 GC 停顿时间的预测模型,根据使用者的需要将 GC 停顿时间控制在指定的范围内。实际上,G1 收集器的出现本身就是为了替换 CMS 收集器的。

G1 收集器会把整个 JVM 堆分割为若干个大小相同的区域(Region),并将这些区域划分给新生代和老年代。此时新生代和老年代便不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。

G1 收集器会根据以往进行回收所获得的空间大小以及回收所需时间的经验值来跟踪每个 Region 里面的垃圾堆积的价值大小,优先回收价值最大的 Region,也因此得名Garbage-First。

G1 收集器的收集过程同样分为 4 个步骤:

  1. 初始标记:停止所有用户线程,标记与 GC Root 直接关联的对象,并指定新的 Region,使用户线程恢复运行后便开始使用新的 Region
  2. 并发标记:恢复用户线程,并发地从 GC Root 开始对堆中对象进行可达性分析
  3. 最终标记:停止所有用户线程,修正在并发标记期间因用户程序继续运作而导致标记发生变化的那一部分对象标记
  4. 筛选回收:对各个 Region 和回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来回收某些 Region。回收时,多个 Region 中的存活对象会被复制到某个新的 Region 中

实际上,G1 收集器仍然有着和 CMS 收集器类似的缺点:

  • 浮动垃圾仍然存在
  • JVM 仍有可能在收集过程中耗尽剩余空间,此时 G1 收集器仍然需要触发一次 STW 来完成回收。

不过,G1 收集器不会产生内存碎片,因为从 Region 的角度上看,G1 收集器所采用的算法类似于复制回收算法,从整个堆的角度上看又类似于标记 – 整理算法。无论怎样,G1 收集器都不会产生内存碎片。

Java 内存布局与垃圾回收归纳

https://mr-dai.github.io/jvm_gc_summary/

作者

Robert Peng

发布于

2019-09-24

更新于

2019-09-24

许可协议

评论