深入理解JVM垃圾回收机制
前言
对于一个 JAVA 或者Android 程序员来说,JVM再不陌生了,我刚开始对这一块也是懵懵懂懂的,之前面试被问到也是支支吾吾的说不出来,现在想好好的整体复习一下。
首先我们来介绍一下 JVM,Java Virtual Machine (JVM) 是 Java 生态系统的核心组成部分,它是 Java 程序的运行时环境。JVM 的主要职责是将 Java 字节码(即 Java 编译后的代码)转换为机器码,以便在具体的硬件平台上执行。
Android 上使用了 JVM,但它有一些独特的实现方式。Android 的 Java 运行环境基于一个名为 Android Runtime (ART) 的虚拟机,之前是 Dalvik。ART 和 Dalvik 是 Android 平台上的虚拟机,它们执行 Android 应用中的字节码。
Android 的虚拟机实现
- Dalvik VM
- 早期 Android 版本(Android 4.4 及以前) 使用 Dalvik VM。
- Dalvik 是一个为移动设备优化的虚拟机,旨在降低内存占用和提高性能。
- 它使用了一种名为 DEX(Dalvik Executable) 的字节码格式。DEX 文件包含了为 Dalvik VM 编译的代码,这种格式在设备上加载和执行时更高效。
- Android Runtime (ART)
- 从 Android 4.4 KitKat 开始,Android 引入了 ART 作为默认的运行时环境,Dalvik 仍然被保留以供兼容。
- ART 改善了性能和内存使用,主要通过将应用的字节码提前编译成机器码来实现(称为 AOT(Ahead-Of-Time) 编译),而不是在运行时解释执行字节码。
- ART 还支持 JIT(Just-In-Time) 编译,在运行时动态编译代码,以进一步提高应用性能。
- ART 使用 ODEX(Optimized Dalvik Executable) 文件,这是一种优化过的字节码格式,专门为 ART 设计。
Android 上的字节码与 JVM 的关系
- Java 字节码与 DEX:Android 开发中使用的 Java 源代码(.java 文件)在编译时被编译成 Java 字节码(.class 文件),然后通过 dx 工具或 d8 工具转换成 DEX 文件(.dex),供 Dalvik 或 ART 执行。
- 与标准 JVM 的区别:虽然 Android 上的 ART 和 Dalvik 虚拟机与标准 JVM 类似,都是虚拟机运行环境,但它们有一些不同之处,特别是在字节码格式和内存管理方面。标准 JVM 使用的是 Java 字节码(.class 文件),而 Android 使用的是 DEX 文件格式。
DEX 文件的特点
- 优化的字节码格式:DEX 文件格式经过优化,以便在资源受限的设备上更高效地执行。
- 多类支持:一个 DEX 文件可以包含多个类的定义,而标准的 Java 字节码文件(.class 文件)通常是一个文件一个类。
- 更小的文件大小:由于 DEX 格式的优化,通常 DEX 文件比等效的 Java 字节码文件更小,减少了 APK 的体积。
DEX 文件的结构
- Header:包含 DEX 文件的基本信息,如文件版本、文件大小等。
- String Pool:存储文件中的所有字符串常量。
- Type Pool:存储所有类和接口的类型信息。
- Method Pool:包含所有方法的定义。
- Field Pool:包含所有字段的定义。
- Class Defs:包含类的定义和它们的结构信息。
下面我们真正的分析jvm!!
上图是JVM的内存区域分布图,这张图展示了 Java 程序的生命周期,从类的加载、链接、初始化到运行时数据区的内存管理,直到执行引擎的字节码执行及垃圾回收的全过程。它体现了 Java 虚拟机如何协调各个组件,使 Java 程序得以执行。
字节码加载过程
-
加载:
- 类加载器(Class Loader)读取.class文件,这些文件包含编译后的Java源代码的字节码。
- 类加载器负责将这些字节码从各种来源(如文件系统、网络等)加载到JVM中。
-
链接:
- 验证:确保加载的字节码对JVM是有效的和安全的,不会违反JVM的内部规范。
- 准备:为类变量分配内存,并设置类变量的默认初始值。
- 解析:将字节码中的符号引用转换成直接引用。
-
初始化:
- 执行类构造器
<clinit>()
方法,这个方法由编译器自动收集类中所有类变量的赋值动作和静态代码块中的语句合并产生。
- 执行类构造器
为什么要有垃圾回收站呢?
垃圾回收器(Garbage Collector)的存在是为了自动管理内存,释放那些不再被程序使用的对象所占用的内存空间。也就是说你new出来的对象其实java已经帮你自动回收了,在C++中,内存管理主要由程序员手动控制,语言本身并不提供内置的垃圾回收机制。
以下是C++中常用的内存管理方式:
- 手动内存管理:
- 使用
new
和delete
操作符来动态分配和释放内存。 - 程序员必须确保每一个
new
都有对应的delete
,否则会导致内存泄漏。 - 不正确的内存释放可能导致悬空指针(dangling pointer)和程序崩溃。
- 使用
- 智能指针(Smart Pointers)(C++11引入):
std::unique_ptr
:独占式所有权的智能指针,自动在超出作用域时释放内存,防止内存泄漏。std::shared_ptr
:共享式所有权的智能指针,采用引用计数机制,当引用计数为零时自动释放内存。std::weak_ptr
:与std::shared_ptr
配合使用,解决共享指针间的循环引用问题。- 智能指针在一定程度上实现了自动内存管理,但仍需要程序员正确使用。
- RAII(Resource Acquisition Is Initialization):
- 利用对象的生命周期来管理资源,即在对象构造时获取资源,析构时释放资源。
- 适用于管理各种资源,如文件句柄、网络连接等,不仅限于内存。
可以看出java相对于c++来说可以自动内存管理。
什么是垃圾?
垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。
如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。
啥是内存溢出?
内存溢出(OutOfMemory)是指程序在运行过程中,所需要的内存超出了系统所能提供的最大内存空间,导致程序无法正常运行,甚至崩溃。内存溢出通常是由于程序中存在内存泄漏、无限递归调用、处理过大的数据集等原因引起的。
在 JVM 进行垃圾回收之前,首先就是判断哪些对象是垃圾,也就是说,要判断哪些对象是可以被销毁的,其占有的空间是可以被回收的。根据 JVM 的架构划分,我们知道, 在 Java 世界中,几乎所有的对象实例都在堆中存放,所以垃圾回收也主要是针对堆来进行的。
在 JVM 的眼中,垃圾就是指那些在堆中存在的,已经“死亡”的对象。比如我们在Android开发中,一个Activity调用了onDestroy()方法,那么这个类的所有对象都该“死亡”了,这个类的所有对象的内存都应该被回收才对,如果没有回收,那么这个对象将一直占用系统的内存,进而引发了“内存泄漏”!!
而对于“死亡”的定义,我们可以简单的将其理解为“不可能再被任何途径使用的对象”。那怎样才能确定一个对象是存活还是死亡呢?这就涉及到了垃圾判断算法,其主要包括引用计数法和可达性分析法。
判断是否是垃圾的算法
我们都知道,java的对象实例都存在堆里,在Gc执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因此这个过程我们可以称为垃圾标记阶段。
那么在JVM中究竟是如何标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
下面我们举一个例子解释一下:一个对象已经不再被任何的存活对象继续引用的情况
public class GarbageCollectionDemo {
public static void main(String[] args) {
// 创建一个对象并持有其引用
MyObject obj = new MyObject();
// 对象此时仍然被引用,因此是存活的
obj.sayHello();
// 取消引用
obj = null;
// 现在,MyObject实例不再被任何存活对象引用
// 因此,它被认为是“死亡”的,垃圾回收器可以回收它
// 建议JVM进行垃圾回收(不保证立即执行)
System.gc();
// 为了给垃圾回收器时间运行,暂停一下(仅用于演示)
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
class MyObject {
public void sayHello() {
System.out.println("Hello, I'm still alive!");
}
// 覆盖finalize方法以查看对象何时被回收(注意:在Java 9中已被弃用)
@Override
protected void finalize() throws Throwable {
System.out.println("MyObject被垃圾回收了!");
super.finalize();
}
}
判断对象存活一般有两种方式:引用计数算法和可达性分析算法。
引用计数法
引用计数算法(Reference Counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。
对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;
当引用失效时,引用计数器就减1。
只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
缺点:
① 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
② 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
③ 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命缺陷,导致在Java的垃圾回收器中没有使用这类算法。
循环引用问题:
测试代码:
public class ReferenceCountingDemo {
public static void main(String[] args) {
// 创建两个对象,互相引用
Node nodeA = new Node("Node A");
Node nodeB = new Node("Node B");
nodeA.next = nodeB;
nodeB.next = nodeA;
// 取消对这两个对象的外部引用
nodeA = null;
nodeB = null;
// 此时,nodeA和nodeB对象不再被程序的其他部分引用
// 但由于它们互相引用,引用计数器不会为零
// 建议JVM进行垃圾回收
System.gc();
// 为了观察垃圾回收的效果,暂停一下
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
class Node {
String name;
Node next;
public Node(String name) {
this.name = name;
}
@Override
protected void finalize() throws Throwable {
System.out.println(name + " 被垃圾回收了");
super.finalize();
}
}
如果使用引用计数法来进行垃圾回收,这段代码将导致内存泄漏
- nodeA 和 nodeB 相互引用:
• nodeA.next = nodeB 将 nodeB 赋值给nodeA 的 next 属性。
• nodeB.next = nodeA 将 nodeA 赋值给 nodeB 的 next 属性。
• 这导致 nodeA 和 nodeB 形成了一个循环引用,nodeA 的引用计数器和 nodeB 的引用计数器都大于零(为 1)。 - 当 nodeA 和 nodeB 被设置为 null 时:
• 代码中的 nodeA = null 和 nodeB = null 删除了它们在 main 方法栈中的引用,但它们之间的循环引用仍然存在。
• 使用引用计数法,这两个对象的引用计数器都不会变为零(因为它们互相引用),因此垃圾回收器认为它们仍然是活动对象,无法回收。
可达性分析法
- 从一组称为GC Roots的对象开始(如栈中的引用、静态变量等),遍历整个对象图。
- 如果对象不可从GC Roots到达,则认为是不可达的,可以被回收。
- 优势:
- 能够正确处理循环引用的问题。
- 更高效地管理内存,减少内存泄漏的风险。
相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
在这里,我们引出了一个专有名词,即GC Roots
常见的 GC Roots 类型包括:
- 虚拟机栈(栈帧)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
-
public class GCRootsDemo { public static void main(String[] args) { Object obj = new Object(); // obj 是局部变量,存放在虚拟机栈中 doSomething(obj); } public static void doSomething(Object param) { Object localObj = new Object(); // localObj 是局部变量,存放在虚拟机栈中 // 执行其他操作 } }
-
- 方法区中的常量引用的对象
-
public class GCRootsDemo { private static final String constStr = "This is a constant string"; public static void main(String[] args) { // constStr 引用了一个字符串常量对象,被视为 GC Roots } }
-
- 方法区中的类静态属性引用的对象
-
public class GCRootsDemo { private static Object staticObj = new Object(); // staticObj 是类的静态属性 public static void main(String[] args) { // staticObj 引用了一个 Object 实例,被视为 GC Roots } }
-
- 本地方法栈中 JNI(Native 方法)的引用对象
-
public class GCRootsDemo { static { System.loadLibrary("nativeLib"); // 加载本地库 } public native void nativeMethod(); public static void main(String[] args) { GCRootsDemo demo = new GCRootsDemo(); demo.nativeMethod(); // 调用本地方法 } }
-
- 活跃线程(已启动且未停止的 Java 线程)
-
public class Example { public static void main(String[] args) { Thread t = new Thread(() -> { MyObject obj = new MyObject(); // `obj` 在线程 `t` 的生命周期内是 GC Root // 执行一些操作 }); t.start(); } }
-
可达性分析算法基本思路:
- 起点:GC Roots
- 从一组特殊的根节点开始,这些节点被认为是存活的。
- 常见的 GC Roots 包括虚拟机栈中的局部变量、方法区中的静态变量和常量、本地方法栈中的引用等。
- 遍历:深度优先或广度优先搜索
- 从 GC Roots 出发,遍历对象引用图。
- 采用深度优先搜索(DFS)或广度优先搜索(BFS)的方法。
- 标记所有可达的对象。
- 回收:清理不可达对象
- 在遍历完成后,未被标记的对象即为不可达对象,可以被垃圾回收器回收。
我们可以看到上面的图中,蓝色的是可达的对象,灰色是不可达的对象,也就是说,可达的对象是存活对象,不可达的对象是可回收的对象.
在可达性分析法中,对象有两种状态,要么是可达的、要么是不可达的,在判断一个对象的可达性的时候,就需要对对象进行标记。关于标记阶段,有几个关键点是值得我们注意的,分别是:
-
开始进行标记前,需要先暂停应用线程,否则如果对象图一直在变化的话是无法真正去遍历它的。暂停应用线程以便 JVM 可以尽情地收拾家务的这种情况又被称之为安全点(
Safe Point
)。安全点(
Safe Point
)是一个在Java虚拟机(JVM)中用于垃圾收集的概念,它指的是程序执行中的特定位置,在这些位置上线程可以安全地暂停,以便JVM执行垃圾收集或其他系统级任务。安全点的主要目的是确保JVM在执行诸如垃圾回收等管理任务时,应用程序的状态是一致的,没有执行中的复杂操作。安全点通常在以下几种情况下设置:
- 方法调用:方法调用返回时,局部变量和执行栈的状态相对稳定。
- 循环的迭代:在循环迭代的末尾或开始,特别是在长时间运行的循环中。
- 可能长时间运行的指令后:例如,访问大量数据或进行大量计算后。
这会触发一次 Stop The World(
STW
)暂停。另一个需要考虑的问题就是如何在 GC 发生时让所有线程(这里不包括执行 JNI 调用的线程)都“跑”到最近的安全点上再停顿下来。JVM 采用了几个关键技术来实现这一点。
- 发出暂停信号
当JVM决定进行垃圾收集时,首先会发出一个全局的暂停信号。这通常是通过设置一个全局的标志或发送一个信号来实现的,让所有线程知道它们需要在到达下一个安全点时停止执行。
- 线程状态检查
每个线程在执行过程中会定期检查这个全局的暂停标志,这种检查通常发生在:
- 方法返回前
- 循环迭代中
- 长时间运行的指令后
- 其他预设的检查点
- 到达安全点
一旦线程检测到暂停标志,它会运行到最近的安全点,如果已经在安全点上,则直接停在那里。安全点是预先定义的代码位置,通常是那些对程序状态影响较小的点,如方法调用、循环迭代点等。
- 线程挂起
在到达安全点后,线程会自我挂起,等待进一步的指令。JVM将检查所有线程是否都已经挂起在安全点上。
- 执行垃圾收集
一旦确认所有线程都停在安全点,JVM就会开始垃圾收集过程。在这个过程中,执行线程(包括JNI调用之外的线程)都会停止,而执行JNI调用的线程可以继续运行,因为它们通常不直接受JVM内存管理的影响。
- 恢复执行
垃圾收集完成后,JVM会清除暂停标志,并通知所有挂起的线程继续执行。
-
暂停时间的长短并不取决于堆内对象的多少也不是堆的大小,而是存活对象的多少。因此,调高堆的大小并不会影响到标记阶段的时间长短。
-
在根搜索算法中,要真正宣告一个对象死亡,至少要经历两次标记过程:
- 第一次标记:
• 在 GC 的过程中,如果发现一个对象从根节点(GC Root)不可达,会对该对象进行第一次标记。此时,对象仍然有机会通过它的 finalize() 方法自救。
• GC 不会立即回收该对象,而是将其放入一个称为“F-Queue”的队列中。然后,会有一个单独的线程来执行这些对象的 finalize() 方法。 - 第二次标记:
• finalize() 方法提供了对象最后一次自救的机会。如果该对象在 finalize() 方法中重新与其他对象建立了引用链,那么它将再次变得可达,从而逃过这次回收。
• 如果对象在 finalize() 方法执行后,仍然没有与任何可达对象建立关联,GC 将对其进行第二次标记。
• 如果在第二次标记后,对象依然不可达,那么它将被确定为不可回收的对象,GC 会真正回收其占用的内存。
- 第一次标记:
PS:finalize() 是 Java 中的一个方法,它定义在 java.lang.Object 类中。finalize() 方法是对象在垃圾回收器(Garbage Collector, GC)回收前调用的一种回调方法,用于对象在被销毁前执行一些清理工作(例如释放非 Java 资源:文件句柄、网络连接等)。
GC 判断对象是否可达看的是强引用。
当标记阶段完成后,GC 开始进入下一阶段,删除不可达对象。当然,可达性分析法有优点也有缺点,
- 优点:可以解决循环引用的问题,不需要占用额外的空间
- 缺点:多线程场景下,其他线程可能会更新已经访问过的对象的引用
在上面的介绍中,我们多次提到了“引用”这个概念,在此我们不妨多了解一些引用的知识,在 Java 中有四种引用类型,分别为:
- 强引用(Strong Reference):如
Object obj = new Object()
,这类引用是 Java 程序中最普遍的。只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。 - 软引用(Soft Reference):它用来描述一些可能还有用,但并非必须的对象。在系统内存不够用时,这类引用关联的对象将被垃圾收集器回收。JDK1.2 之后提供了
SoftReference
类来实现软引用。 - 弱引用(Weak Reference):它也是用来描述非必须对象的,但它的强度比软引用更弱些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK1.2 之后,提供了
WeakReference
类来实现弱引用。 - 虚引用(Phantom Reference):也称为幻引用,最弱的一种引用关系,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的是希望能在这个对象被收集器回收时收到一个系统通知。JDK1.2 之后提供了
PhantomReference
类来实现虚引用。
垃圾回收算法
通过上面的介绍,我们已经知道了什么是垃圾以及如何判断一个对象是否是垃圾。那么接下来,我们就来了解如何回收垃圾,这就是垃圾回收算法和垃圾回收器需要做的事情了。
标记-清除算法
标记-清除(Tracing Collector
)算法是最基础的收集算法,为了解决引用计数法的问题而提出。它使用了根集的概念,它分为“标记”和“清除”两个阶段:
- 标记阶段
在这个阶段,垃圾收集器遍历所有从根集合(root set)可达的对象。根集合包括全局变量、活跃的线程栈上的变量等,这些都是垃圾收集的起点。垃圾收集器从这些根开始,通过引用关系遍历整个对象图。所有能够被访问到的对象都被标记为“活动”的,即这些对象目前至少有一个引用指向它们,因此它们不应该被回收。
- 清除阶段
在完成所有活动对象的标记之后,垃圾收集器进入清除阶段。在这个阶段,收集器会检查内存中的每个对象,移除那些在标记阶段未被标记的对象。未被标记的对象被视为“垃圾”,因为它们不再被任何活动的对象引用。这些对象所占用的内存随后被释放,使得这些内存空间可供程序再次使用。
优点:不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效。
缺点:标记和清除过程的效率都不高,这种方法需要使用一个空闲列表来记录所有的空闲区域以及大小,对空闲列表的管理会增加分配对象时的工作量;标记清除后会产生大量不连续的内存碎片,虽然空闲区域的大小是足够的,但却可能没有一个单一区域能够满足这次分配所需的大小,因此本次分配还是会失败,不得不触发另一次垃圾收集动作。
下图为“标记-清除”算法的示意图:
标记-整理算法
- 标记阶段
这个阶段与标记-清除算法中的标记阶段相似。垃圾收集器遍历所有从根集合可达的对象。根集合通常包括全局变量、活跃的线程栈上的变量等。垃圾收集器从这些根开始,通过引用关系遍历整个对象图。所有能够被访问到的对象都被标记为“活动”的,表示这些对象当前被程序中的其他对象或变量引用,因此它们是有用的,不应该被回收。
- 整理阶段
与标记-清除算法的清除阶段不同,标记-整理算法的整理阶段不仅仅是简单地移除未被标记的对象。在这一阶段,垃圾收集器会对所有存活的对象进行重新排列,将它们移动到内存的一端,从而将所有的空闲空间集中到内存的另一端。这样做的目的是为了减少内存碎片,使得未来的内存分配更加高效,可以快速地为新对象分配连续的内存空间。
优点:经过整理之后,新对象的分配只需要通过指针碰撞便能完成,比较简单;使用这种方法,空闲区域的位置是始终可知的,也不会再有碎片的问题了。
缺点:GC 暂停的时间会增长,因为你需要将所有的对象都拷贝到一个新的地方,还得更新它们的引用地址。
下图为“标记-整理”算法的示意图:
下图为使用“标记-整理”算法回收前后的状态:
复制算法
复制(Copying Collector
)算法的提出是为了克服句柄的开销和解决堆碎片的垃圾回收。它将内存按容量分为大小相等的两块,每次只使用其中的一块(对象面),当这一块的内存用完了,就将还存活着的对象复制到另外一块内存上面(空闲面),然后再把已使用过的内存空间一次清理掉。
复制算法比较适合于新生代(短生存期的对象),在老年代(长生存期的对象)中,对象存活率比较高,如果执行较多的复制操作,效率将会变低,所以老年代一般会选用其他算法,如“标记-整理”算法。一种典型的基于复制算法的垃圾回收是 stop-and-copy
算法,它将堆分成对象区和空闲区,在对象区与空闲区的切换过程中,程序暂停执行。
- 优点:标记阶段和复制阶段可以同时进行;每次只对一块内存进行回收,运行高效;只需移动栈顶指针,按顺序分配内存即可,实现简单;内存回收时不用考虑内存碎片的出现。
- 缺点:需要一块能容纳下所有存活对象的额外的内存空间。因此,可一次性分配的最大内存缩小了一半。
下图为复制算法的示意图:
下图为使用复制算法回收前后的状态:
分代收集算法
分代收集(Generational Collector
)算法是将堆内存划分为新生代、老年代和永久代。
- 年轻代(Young Generation):
- 这里是新创建的对象的存放地。年轻代中的对象通常寿命较短,因此这个区域频繁进行垃圾回收,这种回收称为Minor GC。
- 年轻代通常进一步细分为一个或多个区域,如Eden区、一或两个幸存者区(Survivor Spaces)。
- 老年代(Old Generation 或 Tenured Generation):
- 存活过多次垃圾回收的对象被移动到老年代。这里的对象通常寿命较长,或者体积较大。
- 老年代的回收频率低于年轻代,涉及的过程称为Major GC 或 Full GC。
- 永久代/元空间(Permanent Generation/Metaspace,取决于JVM版本):
- 存放类的元数据、常量以及JVM内部结构。这一区域的回收不频繁,但对JVM的稳定性和性能非常重要。
- 新生代(Young Generation):几乎所有新生成的对象首先都是放在年轻代的。新生代内存按照 8:1:1 的比例分为一个 Eden 区和两个 Survivor(Survivor0,Survivor1)区。
- 大部分对象在 Eden 区中生成。当新对象生成,Eden 空间申请失败(因为空间不足等),则会发起一次 GC(Scavenge GC)。
- 这样整个Eden区就被清理干净了,可以继续创建新的对象,当Eden区再次被用完,就再触发一次YoungGC,然后呢,注意,这个时候跟刚才稍稍有点区别。这次触发YoungGC后,会将Eden区与Survivor0区还在被使用的对象复制到Survivor1区,然后清空 Eden 和这个 Survivor0 区
- 再下一次YoungGC的时候,则是将Eden区与Survivor1区中的还在被使用的对象复制到Survivor0区。
- 经过若干次YoungGC后,有些对象在From与To之间来回游荡,这时候From区与To区亮出了底线(阈值),这些家伙要是到现在还没挂掉,对不起,一起滚到(复制)老年代吧。
- 当对象在 Survivor 区躲过一次 GC 的话,其对象年龄便会加 1,默认情况下,如果对象年龄达到 15 岁,就会移动到老年代中。若是老年代也满了就会触发一次 Full GC,也就是新生代、老年代都进行回收。新生代大小可以由
-Xmn
来控制,也可以用-XX:SurvivorRatio
来控制 Eden 和 Survivor 的比例。
- 老年代(Old Generation):在新生代中经历了 N 次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。内存比新生代也大很多(大概比例是 1:2),当老年代内存满时触发 Major GC 即 Full GC,Full GC 发生频率比较低,老年代对象存活时间比较长,存活率高。一般来说,大对象会被直接分配到老年代。所谓的大对象是指需要大量连续存储空间的对象,最常见的一种大对象就是大数组。当然分配的规则并不是百分之百固定的,这要取决于当前使用的是哪种垃圾收集器组合和 JVM 的相关参数。
- 永久代(Permanent Generation):用于存放静态文件(
class
类、方法)和常量等。永久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class
,例如 Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。对永久代的回收主要回收两部分内容:废弃常量和无用的类。永久代在 Java SE8 特性中已经被移除了,取而代之的是元空间(MetaSpace
),因此也不会再出现java.lang.OutOfMemoryError: PermGen error
的错误了。
特别地,在分代收集算法中,对象的存储具有以下特点:
- 对象优先在 Eden 区分配。
- 大对象直接进入老年代。
- 长期存活的对象将进入老年代,默认为 15 岁。
对于晋升老年代的分代年龄阈值,我们可以通过 -XX:MaxTenuringThreshold
参数进行控制。在这里,不知道大家有没有对这个默认的 15 岁分代年龄产生过疑惑,为什么不是 16 或者 17 呢?实际上,HotSpot 虚拟机的对象头其中一部分用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32bit 和 64bit,官方称它为 Mark word
。
例如,在 32 位的 HotSpot 虚拟机中,如果对象处于未被锁定的状态下,那么 Mark Word
的 32bit 空间中 25bit 用于存储对象哈希码,4bit 用于存储对象分代年龄,2bit 用于存储锁标志位,1bit 固定为 0,其中对象的分代年龄占 4 位,也就是从 0000
到 1111
,而其值最大为 15,所以分代年龄也就不可能超过 15 这个数值了。
除此之外,我们再来简单了解一下 GC 的分类:
- 新生代 GC(Minor GC / Scavenge GC):发生在新生代的垃圾收集动作。因为 Java 对象大多都具有朝生夕灭的特性,因此 Minor GC 非常频繁(不一定等 Eden 区满了才触发),一般回收速度也比较快。在新生代中,每次垃圾收集时都会发现有大量对象死去,只有少量存活,因此可选用复制算法来完成收集。
- 老年代 GC(Major GC / Full GC):发生在老年代的垃圾回收动作。Major GC 经常会伴随至少一次 Minor GC。由于老年代中的对象生命周期比较长,因此 Major GC 并不频繁,一般都是等待老年代满了后才进行 Full GC,而且其速度一般会比 Minor GC 慢10倍以上。另外,如果分配了 Direct Memory,在老年代中进行 Full GC 时,会顺便清理掉 Direct Memory 中的废弃对象。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清除”算法或“标记-整理”算法来进行回收。
新生代采用空闲指针的方式来控制 GC 触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发 GC。当连续分配对象时,对象会逐渐从 Eden 到 Survivor,最后到老年代。
再多说一句,在某些场景下,老年代的对象可能引用新生代的对象,那标记存活对象的时候,需要扫描老年代中的所有对象。因为该对象拥有对新生代对象的引用,那么这个引用也会被称为 GC Roots
。那是不是要做全堆扫描呢?成本也太高了吧?
HotSpot 给出的解决方案是一项叫做卡表(Card Table
)的技术,该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。
在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots
里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。
想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么 Java 虚拟机需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。
卡表能用于减少老年代的全堆空间扫描,这能很大的提升 GC 效率。
垃圾回收器
垃圾回收(GC)线程与应用线程保持相对独立,当系统需要执行垃圾回收任务时,先停止工作线程,然后命令 GC 线程工作。以串行模式工作的收集器,称为 Serial Collector
,即串行收集器;与之相对的是以并行模式工作的收集器,称为 Paraller Collector
,即并行收集器。
Serial 收集器
串行收集器采用单线程方式进行收集,且在 GC 线程工作时,系统不允许应用线程打扰。此时,应用程序进入暂停状态,即 Stop-the-world。Stop-the-world 暂停时间的长短,是衡量一款收集器性能高低的重要指标。Serial 是针对新生代的垃圾回收器,采用“复制”算法。
ParNew 收集器
并行收集器充分利用了多处理器的优势,采用多个 GC 线程并行收集。可想而知,多条 GC 线程执行显然比只使用一条 GC 线程执行的效率更高。一般来说,与串行收集器相比,在多处理器环境下工作的并行收集器能够极大地缩短 Stop-the-world 时间。ParNew 是针对新生代的垃圾回收器,采用“复制”算法,可以看成是 Serial 的多线程版本
Parallel Scavenge 收集器
Parallel Scavenge 是针对新生代的垃圾回收器,采用“复制”算法,和 ParNew 类似,但更注重吞吐率。在 ParNew 的基础上演化而来的 Parallel Scanvenge 收集器被誉为“吞吐量优先”收集器。吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即 吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)
。如虚拟机总运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%
。
Parallel Scanvenge 收集器在 ParNew 的基础上提供了一组参数,用于配置期望的收集时间或吞吐量,然后以此为目标进行收集。通过 VM 选项可以控制吞吐量的大致范围:
-XX:MaxGCPauseMills
:期望收集时间上限,用来控制收集对应用程序停顿的影响。-XX:GCTimeRatio
:期望的 GC 时间占总时间的比例,用来控制吞吐量。-XX:UseAdaptiveSizePolicy
:自动分代大小调节策略。
但要注意停顿时间与吞吐量这两个目标是相悖的,降低停顿时间的同时也会引起吞吐的降低。因此需要将目标控制在一个合理的范围中。
Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本,单线程收集器,采用“标记-整理”算法。这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。
Parallel Old 收集器
Parallel Old 是 Parallel Scanvenge 收集器的老年代版本,多线程收集器,采用“标记-整理”算法。
CMS收集器
CMS(Concurrent Mark Swee
)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS 收集器仅作用于老年代的收集,采用“标记-清除”算法,它的运作过程分为 4 个步骤:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
其中,初始标记、重新标记这两个步骤仍然需要 Stop-the-world。初始标记仅仅只是标记一下 GC Roots
能直接关联到的对象,速度很快,并发标记阶段就是进行 GC Roots Tracing
的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始阶段稍长一些,但远比并发标记的时间短。
CMS 以流水线方式拆分了收集周期,将耗时长的操作单元保持与应用线程并发执行。只将那些必需 STW 才能执行的操作单元单独拎出来,控制这些单元在恰当的时机运行,并能保证仅需短暂的时间就可以完成。这样,在整个收集周期内,只有两次短暂的暂停(初始标记和重新标记),达到了近似并发的目的。
- CMS 收集器优点:并发收集,低停顿。
- CMS 收集器缺点: - CMS 收集器对 CPU 资源非常敏感; - CMS 收集器无法处理浮动垃圾; - CMS 收集器是基于“标记-清除”算法,该算法的缺点都有。
CMS 收集器之所以能够做到并发,根本原因在于采用基于“标记-清除”的算法并对算法过程进行了细粒度的分解。前面已经介绍过“标记-清除”算法将产生大量的内存碎片这对新生代来说是难以接受的,因此新生代的收集器并未提供 CMS 版本。
G1 收集器
G1(Garbage First
)重新定义了堆空间,打破了原有的分代模型,将堆划分为一个个区域。这么做的目的是在进行收集时不必在全堆范围内进行,这是它最显著的特点。区域划分的好处就是带来了停顿时间可预测的收集模型:用户可以指定收集操作在多长时间内完成,即 G1 提供了接近实时的收集特性。G1 与 CMS 的特征对比如下:
G1 具备如下特点:
- 并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU 来缩短 Stop-the-world 停顿的时间,部分其他收集器原来需要停顿 Java 线程执行的 GC 操作,G1 收集器仍然可以通过并发的方式让 Java 程序继续运行。
- 分代收集:打破了原有的分代模型,将堆划分为一个个区域。
- 空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的。但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。
- 可预测的停顿:这是 G1 相对于 CMS 的一个优势,降低停顿时间是 G1 和 CMS 共同的关注点。
在 G1 之前的其他收集器进行收集的范围都是整个新生代或者老年代,而 G1 不再是这样。在堆的结构设计时,G1 打破了以往将收集范围固定在新生代或老年代的模式,G1 将堆分成许多相同大小的区域单元,每个单元称为 Region,Region 是一块地址连续的内存空间,G1 模块的组成如下图所示:
堆内存会被切分成为很多个固定大小的 Region,每个是连续范围的虚拟内存。堆内存中一个 Region 的大小可以通过 -XX:G1HeapRegionSize
参数指定,其区间最小为 1M、最大为 32M,默认把堆内存按照 2048 份均分。
每个 Region 被标记了 E、S、O 和 H,这些区域在逻辑上被映射为 Eden,Survivor 和老年代。存活的对象从一个区域转移(即复制或移动)到另一个区域,区域被设计为并行收集垃圾,可能会暂停所有应用线程。
如上图所示,区域可以分配到 Eden,Survivor 和老年代。此外,还有第四种类型,被称为巨型区域(Humongous Region
)。Humongous 区域是为了那些存储超过 50% 标准 Region 大小的对象而设计的,它用来专门存放巨型对象。如果一个 H 区装不下一个巨型对象,那么 G1 会寻找连续的 H 分区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC。
G1 收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。G1 会通过一个合理的计算模型,计算出每个 Region 的收集成本并量化,这样一来,收集器在给定了“停顿”时间限制的情况下,总是能选择一组恰当的 Region 作为收集目标,让其收集开销满足这个限制条件,以此达到实时收集的目的。
对于打算从 CMS 或者 ParallelOld 收集器迁移过来的应用,按照官方的建议,如果发现符合如下特征,可以考虑更换成 G1 收集器以追求更佳性能:
- 实时数据占用了超过半数的堆空间;
- 对象分配率或“晋升”的速度变化明显;
- 期望消除耗时较长的GC或停顿(超过 0.5 ~ 1 秒)。
G1 收集的运作过程大致如下:
- 初始标记(Initial Marking):仅仅只是标记一下
GC Roots
能直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这阶段需要停顿线程,但耗时很短。 - 并发标记(Concurrent Marking):是从
GC Roots
开始堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。 - 最终标记(Final Marking):是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但是可并行执行。
- 筛选回收(Live Data Counting and Evacuation):首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。这个阶段也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。
G1 的 GC 模式可以分为两种,分别为:
- Young GC:在分配一般对象(非巨型对象)时,当所有 Eden 区域使用达到最大阀值并且无法申请足够内存时,会触发一次 YoungGC。每次 Young GC 会回收所有 Eden 以及 Survivor 区,并且将存活对象复制到 Old 区以及另一部分的 Survivor 区。
- Mixed GC:当越来越多的对象晋升到老年代时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 Mixed GC,该算法并不是一个 Old GC,除了回收整个新生代,还会回收一部分的老年代,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些 Old 区域进行收集,从而可以对垃圾回收的耗时时间进行控制。G1 没有 Full GC概念,需要 Full GC 时,调用 Serial Old GC 进行全堆扫描。
查看 JVM 使用的默认垃圾收集器
在 Mac 终端或者 Windows 的 CMD 执行如下命令:
java -XX:+PrintCommandLineFlags -version
由此可知,JDK 8 默认打开了 UseParallelGC
参数,因此使用了 Parallel Scavenge + Serial Old
的收集器组合进行内存回收。
到这里,关于 JVM 垃圾回收机制及其实现原理,我们就讲完了,希望能够对大家有所帮助!
面试题:
(1)蚂蚁金服:
你知道哪几种垃圾回收器,各自的优缺点,重点讲一下 CMS和 G1
一面: JVM GC算法有哪些,目前的JDK版本采用什么回收算法?
一面:G1回收器讲下回收过程?
GC是什么?为什么要有GC?
一面:GC的两种判定方法?
CMS收集器与 G1 收集器的特点。
(2)百度:
说一下GC算法,分代回收说下垃圾收集策略和算法
(3)天猫:
一面: JVM GC原理,JVM怎么回收内存
一面:CMS 特点,垃圾回收算法有哪些?各自的优缺点,他们共同的缺点是什么?
(4)滴滴:
一面: java的垃圾回收器都有哪些,说下G1的应用场景,平时你是如何搭配使用垃圾回收器的
(5)京东:
你知道哪几种垃圾收集器,各自的优缺点。
重点讲下 CMS 和 G1,包括原理,流程,优缺点。垃圾回收算法的实现原理。
(6)阿里:
讲一讲垃圾回收算法。什么情况下触发垃圾回收?
如何选择合适的垃圾收集算法?
JVM有哪三种垃圾回收器?
(7)字节跳动:
常见的垃圾回收器算法有哪些,各有什么优劣?
System.gc() 和 Runtime.gc() 会做什么事情?
一面: Java GC机制?GC Roots有哪些?
二面: Java对象的回收方式,回收算法。
CMS和G1了解么,CMS解决什么问题,说一下回收的过程。CMS回收停顿了几次,为什么要停顿两次。
参考资料:
评论区