JVM 自动内存管理机制及 GC 算法

1,149 阅读17分钟

读书笔记,如需转载,请注明作者:Yuloran (t.cn/EGU6c76)

前言(Preface)

《Java 虚拟机规范》读书笔记,部分内容摘自 The Java® Virtual Machine Specification Java SE 11 EditionJava Garbage Collection Basics ,部分内容摘自《深入理解Java虚拟机_JVM高级特性与最佳实践 第2版》(周志朋著)。

首先,咱们要明确一个概念:《Java 虚拟机规范》是独立于具体的编程语言以及具体的虚拟机实现的,Java 只是大家最为熟悉的一种 JVM 编程语言而已,目前比较火的其它 JVM 语言 还有:

实现这种语言无关性的基石就是平台无关的程序存储格式 - 字节码(ByteCode),即二进制文件 *.Class:

jvm language

另外,《Java 虚拟机规范》也没有说明 GC 该如何实现,所以在阐述 Java 虚拟机的 GC 算法、GC 收集器时,应当指明具体的虚拟机。原文:

THIS document specifies an abstract machine. It does not describe any particular implementation of the Java Virtual Machine. To implement the Java Virtual Machine correctly, you need only be able to read the class file format and correctly perform the operations specified therein. Implementation details that are not part of the Java Virtual Machine's specification would unnecessarily constrain the creativity of implementors. For example, the memory layout of run-time data areas, the garbage-collection algorithm used, and any internal optimization of the Java Virtual Machine instructions (for example, translating them into machine code) are left to the discretion of the implementor.

译文:

本文阐述了一个抽象机器。并未阐述任何 Java 虚拟机的特定实现。为了正确实现虚拟机,你只需要能够读取 Class 文件格式并执行其中的指定操作即可。实现细节并不是 Java 虚拟机规范的一部分,因为这可能会约束实现者的创造力。比如,运行时数据区的内存布局、GC 算法以及任何 Java 虚拟机指令集的内部优化(比如,翻译成机器码),都由实现者自行决定。

收购 Sun 使 Oracle 有了两种主要的 Java 虚拟机 (JVM) 实现,即 Java HotSpot VM 和 Oracle JRockit JVM。未作特殊说明,本文皆以 HotSpot JVM 为例来阐述《Java 虚拟机规范》的具体实现。

HotSpot JVM Architecture

摘自 Java SE HotSpot 概览Java Garbage Collection Basics

HotSpot 虚拟机是 Java SE 平台的一个核心组件,是 Java 虚拟机规范的实现之一,并作为 JRE 的一个共享库来提供。作为 Java 字节码执行引擎,它在多种操作系统和架构上提供 Java 运行时设施,如线程和对象同步。它包括自适应将 Java 字节码编译成优化机器指令的动态编译器,并使用为降低暂停时间和吞吐量而优化的垃圾收集器来高效管理 Java 堆。

HotSpot 虚拟机可根据平台配置,选择合适的编译器、Java 堆配置和垃圾收集器,以保证为大多数应用程序提供优良性能。下图为 HotSpot 虚拟机的架构:

HotSpot JVM Architecture

其主要组件包括:Class Loader, Runtime Data Areas 和 Execution Engine.

注:上图 Run-Time Data AreasJava Threads 指的是 Java Virtual Machine StacksNative Internal Threads 指的是 Native Method Stacks

Runtime Data Areas

为了便于理解,本人结合 JVM 规范,将上图的 Runtime Data Areas 重新绘制如下:

Java Virtual Machine Runtime Data Areas

上图为 JVM 规范的阐述,无关具体的虚拟机。比如,Native Method Stacks 可能不存在,HotSopt JVM 就将 JVM Stacks 与 Native Method Stacks 合二为一了。

The PC Register

Java 虚拟机支持多线程并发执行,每个线程都有自己的程序计数器(Program Counter Register)。任何确定的时刻,JVM 线程只能执行一个方法,称为当前方法。如果当前方法是 Java 方法,那么程序计数器里存储的就是 JVM 当前正在执行的指令的地址。如果当前方法是本地方法,程序计数器的值则为空(Undefined)。程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器,也是唯一一个不会产生 OutOfMemoryError 的区域。

Java Virtual Machine Stacks

每个 JVM 线程都用于一个私有的 Java 虚拟机栈,它随着进程的创建而分配,随着进程的退出而销毁。Java 虚拟机栈,描述的是 Java 方法执行的内存模型,所以也可以称之为 Java 方法栈。每次 Java 方法执行时都会创建一个栈帧,栈帧是描述虚拟机进行方法调用和方法执行的数据结构,用于存储方法的局部变量表、操作数栈、动态连接、方法返回地址和一些附加信息。方法从调用至完成的过程,对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定,并写入到方法表的 Code 属性之中。因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,仅取决于具体的虚拟机实现。

栈帧的概念结构

局部变量表

用于存储方法参数和方法内定义的局部变量,以 Variable Slot 为最小单位,可以存放一个 boolean、byte、char、short、int、float、reference 或 returnAddress 类型(可按照 Java 语言对应的类型理解,但本质上是不一样的)的数据。

  • reference 类型:虚拟机规范既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但是虚拟机的实现至少要做到两点:
    • 可以从此引用中直接或间接查找到对象在 Java 堆中的数据存放地址的起始地址索引;
    • 可以直接或间接从此引用查到对象所属数据类型在方法区中存储的类型信息。
  • returnAddress 类型:指向一条字节码指令的地址,目前已经很少用了,很古老的虚拟机曾用来实现异常处理。

在 Java 程序编译为 Class 文件时,就在方法的 Code 属性的 max_locals 数据项中确定了该方法所需要分配的局部变量表的最大容量。

操作数栈

也常称为操作栈,其最大深度在编译期写入 Code 属性的 max_stacks 中。栈元素可以是 Java 语言的任意数据类型。在方法刚执行时,该栈是空的。在方法执行过程中,会有各种字节码指令对操作栈进行读写操作,比如算术运算是通过操作栈来完成的,或者调用其它方法时,使用操作栈来传递参数。

动态连接

字节码中的方法调用指令以常量池中指向该方法的符号引用作为参数,这些符号引用有一部分会在类加载阶段或第一次使用时转为直接引用,还有一部分在运行期才转为直接引用,这称为动态连接。每个栈帧中都包含一个指向运行时常量池中该栈帧所属方法的引用,以实现动态连接。

方法返回地址

方法退出后,需要返回方法被调用的位置。方法正常退出时,调用者的 PC 值可作为返回地址,栈帧中很可能保存了这个值。方法异常退出时,返回地址需要通过异常处理器表来确定,栈帧中一般不会保存这部分信息。

附加信息

虚拟机规范里没有描述的信息,比如调试信息等。

可能产生的异常:

  • StackOverflowError:线程请求的栈深度大于虚拟机栈深度,就会抛出该异常
  • OutOfMemoryError:如果虚拟机栈支持动态扩展,但是扩展时申请不到足够内存,或者创建线程时没有足够内存初始化虚拟机栈,就会抛出该异常

Native Method Stacks

与 Java 虚拟机栈非常相似,只不过是描述 Java 本地方法执行的内存模型,所以称之为本地方法栈。虽然虚拟机规范没有规定本地方法栈中方法使用的语言、使用方式和与数据结构,不过一般指 C 语言。说到这儿,就不得不说,其实从进程角度来看,无论什么编程语言编写的程序,其内存模型或者说内存布局都差不多。下图为 Linux 进程中,C 程序的内存布局

A typical memory layout of a running process

Java 本地方法栈在功能上就类似于 C 程序的 C Stack。同样,Java 本地方法栈也会抛出 StackOverflowError 和 OutOfMemoryError。

Method Area

Java 方法区是所有线程共享的,在功能上类似于 C 程序的 Text Segment。该区域存储了运行时常量池、已加载的 Class、静态变量的引用、成员变量和成员方法的引用,以及即时编译器(JIT Compiler)编译后的代码等数据。

方法区在虚拟机启动时创建。虽然逻辑上属于 Java Heap(GC重点区域),但是可以不实现垃圾回收或者压缩整理。方法区的大小可以是固定的、动态扩展的和可压缩的(无需这么大的方法区时),而且内存不要求连续。

HotSpot 虚拟机将 GC 分代收集扩展到了方法区,或者说他们用永久(Permanent Generation)代实现了方法区。这会出现一些问题,一是方法区的 GC 没什么效果,二是永久代内存有上限,容易出现 OOM,三是极少数方法会因为这个原因在不同虚拟机下有不同表现,比如String.intern():

public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        String str1 = new StringBuilder("计算机").append("软件").toString();
        System.out.println(str1.intern() == str1);

        String str2 = new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern() == str2);
    }
}

这段代码在 JDK 1.6 中运行会得到两个 false,在 JDK 1.7 中运行,会得到一个 true,一个 false。原因是:JDK 1.6 中会将首次遇到的字符串实例复制到永久代,返回的也是永久代中这个字符串实例的引用,而 StringBuilder 创建的字符串实例位于 Java 堆,所以必然不是同一个引用。而 JDK 1.7(已经将字符串常量池移出了永久代) 的 intern() 则不会再复制实例,只是在常量池中记录首次出现的实例引用,所以 intern() 返回的引用和 StringBuilder 创建的字符串实例的引用是同一个。而 ”java“ 这个字符串虚拟机启动时,已经由 JDK 的 Version 类加载到常量池了,所以不是同一个引用。

方法区无法满足内存分配需求时,将会抛出 OutOfMemoryError。

Run-Time Constant Pool

运行时常量池是方法区的一部分。Class 文件除了有类的版本、字段、方法、接口等描述信息,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量(整型字面量、字符串字面量等)和符号引用,类似于 C 程序的 Symbol Table,不过数据类型要比 C 语言丰富的多,这部分内容将在类加载后,进入方法区的常量池。

Java 虚拟机对 Class 文件的每一部分都有严格规定,每一个字节用于存放哪种数据都必须符合规范才能被虚拟机认可、装载和执行。但是对于运行时常量池却没有任何细节要求, 所以运行时常量池被实现成了具备动态性,即常量也可以在运行时生成,比如 String 的 intern() 方法。

当运行时常量池无法再申请到内存时,会抛出 OutOfMemoryError。

Heap

Java 堆是所有 JVM 线程共享的区域。所有类的实例以及数组都在堆上分配。

Java 堆在虚拟机启动时创建。堆上的对象由垃圾收集器(Garbage Collector)自动管理。

从内存回收角度看,由于现在垃圾收集器大多采用分代收集算法,所以 Java 堆还可以细分为年轻代(Young Generation)和老年代(Old Generation),其中年轻代还可以按 8:1:1 的比例再分为 Eden、From Survivor、To Survivor。

从内存分配角度看,Java 堆可能划分出多个线程私有的内存分配缓冲区(Thread Local Allocation Buffer,TLAB)。

Java 堆内存不要求物理上连续,只要逻辑上连续即可。如果堆中没有内存完成实例分配,且堆也无法继续扩展时,将抛出 OutOfMemoryError。

Automatic Garbage Collection

不像 C 或 C++,内存由开发人员手动分配和释放,JVM 中的内存由垃圾收集器自动管理,基本步骤如下:

Step1:标记(Mark)

标记哪些对象是被使用的,哪些是不再使用的:

Step2:清除(Sweep)

删除不再引用的对象,保留存活的对象,并维护一个空闲内存的引用列表 :

Step2a:清除并整理(Sweep-Compact)

为了提高性能,有些收集器使用 "标记-清除-整理" 算法来回收内存。将仍然存活的对象移至一端,以便下次更容易找到连续可用内存:

Generational Garbage Collection

使用 "标记-清除" 法回收对象的效率是比较低的,尤其是在对象越来越多的时候,将需要更长的时间来执行垃圾回收。这是很恐怖的,因为 GC 触发时,Java 程序需要被冻结,否则对象的引用关系将无法追踪。研究表明,大部分对象的存活时间都很短。所以现在的 JVM 大多采用分代收集算法来提高性能:

年轻代(Young Generation)

所有新对象分配和变老的地方。年轻代用完时,将会触发一次 Minor Garbage Collection。GC 后,仍然存活的对象会变老并最终进入老年代。年轻代使用 "标记-复制" 法进行 GC。

Stop the World Event:所有的 Minor Garbage Collection 都是 "Stop the World Event",这意味着所有的线程都要暂停直至 GC 完成。

老年代(Old Generation)

用来存放长时间存活的对象。通常,年轻代会设置一个年龄阈值,当对象年龄超过这个阈值时,就会被移至老年代。最终老年代的对象也会被回收,称之为 Major Garbage Collection,它也是 "Stop the World Event" 。通常,Major GC 是比较慢的,因为涉及到所有的存活对象。所以,HotSpot 虚拟机同时使用多种垃圾收集器,来降低 GC 时间。老年代使用 "标记-整理" 法进行 GC。

永久代(Permanent generation)

对于 HotSpot 虚拟机来说,就是方法区。该区域具备动态性,可以在运行时添加常量,也可以对不再使用的常量进行丢弃,对不再使用的类进行卸载,但是类的卸载条件非常苛刻:

  1. 该类所有的实例都被回收;
  2. 加载该类的 ClassLoader 已经被回收;
  3. 该类对应的 Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

分代收集的步骤

Step1. 新分配的对象进入 Eden 空间,两个 Survivor 开始时都是空的:

Step2. 当 Eden 空间满时,触发一次 Minor GC:

Step3. 仍然存活的对象移至 Survivor 空间,年龄为1,不再引用的删除,清理 Eden 空间:

Step4. 下一次 Minor GC 触发时,重复以上操作。不过这次需要将仍然存活的对象移至另一个 Survivor 空间,并且在上一次 Minor GC 中存活下来的对象的年龄要 +1。然后清理原来的 Survivor 和 Eden 空间:

Step5. 下一次 Minor GC 触发时,重复以上操作,即切换 Survivor 空间,年龄自增等:

Step6. 多次 Minor GC 触发后,部分存活对象的年龄超过了年轻代的年龄阈值(这里假设为8),晋升为老年代:

Step7. 随着 Minor GC 不断触发,年轻代的存活对象也不断的晋升为老年代:

Step8. 如上过程几乎涵盖了年轻代的整个过程。最终,老年代也会触发 Major GC 来进行垃圾回收:

对象分析

对象访问定位

Java 程序通过栈上的 reference 来操作堆上的具体对象。由于 JVM 规范只规定了 Reference 类型是一个指向对象的引用,并没有定义这个引用该通过何种方式去定位、访问堆中的对象的具体位置。目前主流的访问方式有使用句柄和直接指针两种:

  • 使用句柄:

  • 直接指针:

HotSpot 虚拟机使用的是直接指针方式,最大好处就是速度更快,节省了一次指针定位的开销。

对象引用分析

  • 引用计数法:两个对象相互引用时,无法判断其中对象是否不再使用
  • 可达性分析法:引入 GC Roots 概念,如果一个对象到 GC Roots 没有任何引用链,这个对象就是可以被回收的。在 Java 中,可作为 GC Roots 的对象有:
    • Java 方法栈中引用的对象
    • 方法区中类静态属性引用的对象
    • 方法区常量引用的对象
    • 本地方法栈中引用的对象

引用细分

  • 强引用:类似于 "Object obj = new Object();" 都是强引用,GC 永远不会回收;
  • 软引用:有用但不必要的对象,在发生 OOM 之前,会对这些对象进行第二次回收,如果回收后,内存仍然不足,才抛出 OOM。实现类为 SoftReference;
  • 弱引用:比软引用弱,只能活到下一次 GC 前。下次 GC 发生时,无论内存是否紧张,都会回收掉只被弱引用关联的对象。实现类为 WeakReference
  • 虚引用:最弱的一种引用关系,无法通过它获取被引用对象。唯一作用就是被回收时收到一个系统通知。实现类为 PhantomReference

finalize()

Java 编程规范中明确指出,不要重写 finalize() 方法,除非你知道自己在干什么。finalize() 方法只会被 JVM 执行一次。一个对象被第一次被标记为死亡时,会进行一次筛选,筛选条件是是否需要执行 finalize() 方法,如果需要(对象重写了 finalize() 方法),这个对象就会被扔到一个叫做 F-Queue 的队列中,等待有 JVM 自动创建的、低优先级的 Finalizer 线程去执行。稍后,GC 将对 F-Queue 中的对象进行第二次小规模的标记,如果此时还没有逃脱(在 finalize() 中 将 this 赋给其他类的成员变量),基本上就真的被回收了。

Garbage Collectors

Java 垃圾收集器有很多,JDK 1.7 Update 14 之后的 HotSpot JVM 就同时有 7 个垃圾收集器,而且年轻代和老年代用的收集器还不一样。为什么要用这么多的垃圾收集器呢?就是为了提高虚拟机的性能。不过无论怎么优化, "Stop The World" 都是无法避免的,时间长短而已。Android 的 Dalvik 或者 ART 虚拟机也是如此。所以一是要减少 GC 的时间,二是避免频繁触发 GC。

HotSpot 虚拟机在 JDK 1.7 Update 14 之后使用的垃圾收集器:

连线表示可以搭配使用。

Serial

发展历史最悠久的单线程收集器,使用 "标记-复制" 算法。GC 时,必须暂停其它所有工作线程,直至 GC 结束。

ParNew

是 Serial 收集器的多线程版本,使用 "标记-复制" 算法。

Parallel Scavenge

类似于 ParNew 收集器,不过关注点是达到一个可控制的吞吐量,使用 "标记-复制" 算法。

Serial Old

Serial 收集器的老年代版本,使用 "标记-整理" 算法。

Parallel Old

Parallel Scavenge 的老年代版本,使用 "标记-整理" 算法。

CMS

Concurrent Mark Sweep,是一种以获取最短回收停顿时间为目标的收集器,使用 "标记-清除" 算法。

G1

Garbage First,是当今收集器技术发展的最前沿成果之一,是一款面向服务端应用的收集器。具有并行与并发、分代收集、空间整合和可预测的停顿等特点。

如何获取 JVM 规范?

  1. 进入 Oracle 官网,按图所示:

  1. 点击 Java SE documentation

  1. 点击 Language and VM

  1. 选择版本