查看详细的思维导图和更好的文档排版,请访问语雀:第15章 - 垃圾回收算法
文章目录
- 查看详细的思维导图和更好的文档排版,请访问语雀:[第15章 - 垃圾回收相关算法](https://www.yuque.com/docs/share/edca9323-5f59-4207-a40d-f2b2be0d4197?# 《第15章 - 垃圾回收相关算法)
- 1.标记阶段:引用计数算法
-
- 1.1 垃圾标记阶段:对象生存判断
- 1.2 引用计数算法
- 1.3 小结
- 2.标记阶段:可达性分析算法
-
- 2.1 可达性分析算法(根搜索算法,跟踪垃圾收集)
- 2.2 基本思路
- 2.3 GC Roots
- 3.对象的finalization机制
-
- 3.1 生存还是死亡?
- 3.2 具体过程
- 4.MAT与JProfiler的GC Roots溯源
-
- 4.1 MAT是什么?
- 4.2 获取dump文件
-
- 方法一:使用命令行 jmap
- 方法二:使用JVisualVM导出
- 方式三:使用MAT打开Dump文件
- 4.3 JProfiler的GC Roots溯源
-
- 如何判断是什么原因? OOM?
- 5.清除阶段:标记-清除算法(Mark-Sweep)
-
- 5.1 背景
- 5.2 执行过程
- 5.3 缺点
- 5.4 注:什么是清除?
- 6.清除阶段:复制算法(Copying)
-
- 6.1 背景
- 6.2 核心思想
- 6.3 优缺点
- 6.4 注意
- 6.5 应用场景
- 7.清除阶段:标记-压缩(整理)算法(Mark - Compact)
-
- 7.1 背景
- 7.2 执行过程
- 7.3 标记清除与标记压缩的区别
- 7.4 优缺点
- 8.小结
- 9.分代收集算法
- 10.增量收集算法
-
- 10.1 概述
- 10.2 基本思想
- 10.3 缺点
- 11.分区算法
- 12.写在最后
1.标记阶段:引用计数算法
1.1 垃圾标记阶段:对象生存判断
- 几乎所有的堆都存放在堆里。Java对象实例,在GC在实施垃圾回收之前,首先要区分哪些是存活对象,哪些是死亡对象。只有被标记为死亡对象,GC实施垃圾回收时,会释放其占用的内存空间,所以这个过程可以称为垃圾标记阶段。
- 那么在JVM如何标记死亡对象?简单来说,当一个对象不再被任何生存对象引用时,就可以判定为死亡。
- 判断对象存活一般有两种方式:引用计数算法和可达性分析算法。
1.2 引用计数算法
- 引用计数算法(Reference Counting)相对简单,为每个对象保存一个完整的引用计数器属性。记录引用对象的情况。
- 对一个对象A,只要引用任何对象A,A引用计数器加1;当引用失效时,引用计数器减少1。只要对象A引用计数器的值为0,即对象A不能再使用,就可以回收。
- 优点:实现简单,易于识别垃圾对象;判断效率高,回收不延迟(不用等内存满了再回收,可以随时)。
- 缺点:
- 它需要单独的字段存储计数器,这增加了存储空间的成本
- 计数器每次赋值都需要更新,加减操作增加了时间开支
- 引用计数器有一个严重的问题,即循环引用无法处理。这是一个致命的缺陷,导致在Java这种算法没有用于垃圾回收器。
何为?
当p的指针断开时,内部引用形成循环,即循环引用
代码实例:测试Java引用计算算法是否使用?
/** * -XX: PrintGCDetails * 证明:java不引用计数算法 */ public class RefCountGC {
///这个成员属性的唯一是占用一点内存 private byte[] bigSize = new byte[5 * 1024 * 1024]; //5MB
Object reference = null;
public static void main(String[] args) {
RefCountGC obj1 = new RefCountGC();
RefCountGC obj2 = new RefCountGC();
obj1.reference = obj2;
obj2.reference = obj1;
obj1 = null;
obj2 = null;
//显式的执行垃圾回收行为
//这里发生GC,obj1和obj2能否被回收?
System.gc();
}
}
程序运行结果我们能够看到,上述进行了 GC 收集的行为,将上述的新生代中的两个对象都进行回收了。如果使用引用计数算法,那么这两个对象将会无法回收。而现在两个对象被回收了,说明 JVM中采用的一定不是 引用计数算法 来进行标记的。
1.3 小结
- 引用计数算法,是很多语言的资源回收选择,例如因人工智能而更加火热的Python,它更是同时支持引用计数和垃圾收集机制。
- 具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。
- Java并没有选择引用计数,是因为其存在一个基本的难题,也就是很难处理循环引用关系。
- Python如何解决循环引用?
- 手动解除:很好理解,就是在合适的时机,解除引用关系。
- 使用弱引用weakref,weakref是Python提供的标准库,旨在解决循环引用。
2.标记阶段:可达性分析算法
2.1 可达性分析算法(根搜索算法、追踪性垃圾收集)
- 相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决在引用计数算法中循环引用的问题,防止内存泄漏的发生。
- 相较于引用计数算法,这里的可达性分析就是Java、C#选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbage Collection)
- 所谓"GCRoots”根集合就是一组必须活跃的引用。
2.2 基本思路
- 可达性分析算法是以根对象集合(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
- 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)。
- 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
- 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。
。
2.3 GC Roots
- 虚拟机栈中引用的对象
- 比如:各个线程被调用的方法中使用到的参数、局部变量等。
- 本地方法栈内JNI(通常说的本地方法)引用的对象
- 方法区中类静态属性引用的对象
- 比如:Java类的引用类型静态变量
- 方法区中常量引用的对象
- 比如:字符串常量池(String Table)里的引用
- 所有被同步锁synchronized持有的对象
- Java虚拟机内部的引用。
- 基本数据类型对应的Class对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError),系统类加载器。
- 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
- 除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。比如:分代收集和局部回收(PartialGC)。
- 如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GCRoots集合中去考虑,才能保证可达性分析的准确性。
- 典型的只针对新生代:因为新生代除外,还有关联的老年代,所以需要将老年代也一并加入GC Roots集合中
- 如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GCRoots集合中去考虑,才能保证可达性分析的准确性。
小技巧:
由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。
红色的都没有被GC Roots所引用,所以都是垃圾

:
- 如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性 (某一刻的静止状态) 的快照中进行。这点不满足的话分析结果的准确性就无法保证。
- 这点也是导致GC进行时必须“stop The World”的一个重要原因。
- 即使是号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
3.对象的finalization机制
- Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
- 当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的finalize( )方法。
- finalize( ) 方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
- 永远不要主动调用某个对象的finalize( )方法,应该交给垃圾回收机制调用。理由包括下面三点:
- 在finalize( )执行时可能会导致对象复活。
- finalize( )方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize( )方法将没有执行机会。
- 一个糟糕的finalize( )会严重影响GC的性能。
- 从功能上来说,finalize( )方法与C++中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize( )方法在本质上不同于C++中的析构函数。
- 由于finalize( )方法的存在,虚拟机中的对象一般处于三种可能的状态。
3.1 生存还是死亡?
- 如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:
- 可触及的:从根节点开始,可以到达这个对象。
- 可复活的:对象的所有引用都被释放,但是对象有可能在 finalize() 中复活。
- 不可触及的:对象的 finalize() 被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为 finalize() 只会被调用一次。
- 以上3种状态中,是由于 finalize() 方法的存在,进行的区分。只有在对象不可触及时才可以被回收。
3.2 具体过程
- 如果对象 objA 到 GC Roots 没有引用链,则进行第一次标记。
- 进行筛选,判断此对象是否有必要执行 finalize() 方法
- 如果对象 objA 没有重写 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA 被判定为不可触及的。
- 如果对象 objA 重写了 finalize() 方法,且还未执行过,那么 objA 会被插入到 F-Queue 队列中,由一个虚拟机自动创建的、低优先级的 Finalizer 线程触发其 finalize() 方法执行。
- finalize() 方法是对象逃脱死亡的最后机会,稍后 GC 会对 F-Queue 队列中的对象进行第二次标记。如果 objA 在 finalize() 方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA 会被移出“即将回收”集合。之后,对象如果再次出现没有引用存在的情况。在这个情况下, finalize() 方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的 finalize() 方法只会被调用一次。
上图就是我们看到的 Finalizer 线程。
我们重写 finalize() 方法,然后在方法的内部,重写将其存放到 GC Roots 中。
/** * 测试Object类中finalize()方法,即对象的finalization机制。 */
public class CanReliveObj {
public static CanReliveObj obj; //类变量,属于 GC Root
//此方法只能被调用一次
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("调用当前类重写的finalize()方法");
obj = this;//当前待回收的对象在finalize()方法中与引用链上的一个对象obj建立了联系
}
public static void main(String[] args) {
try {
obj = new CanReliveObj();
// 对象第一次成功拯救自己
obj = null;
System.gc();//调用垃圾回收器
System.out.println("-----------------第一次gc操作------------");
// 因为Finalizer线程优先级很低,暂停2秒,以等待它
Thread.sleep(2000);
if (obj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is still alive");
}
System.out.println("-----------------第二次gc操作------------");
// 下面这段代码与上面的完全相同,但是这次自救却失败了
obj = null;
System.gc();
// 因为Finalizer线程优先级很低,暂停2秒,以等待它
Thread.sleep(2000);
if (obj == null) {
System.out.println("obj is dead");
} else {
System.out.println("obj is still alive");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
程序运行结果在进行第一次清除的时候,我们会执行 finalize() 方法,然后对象进行了一次自救操作,但是因为 finalize() 方法只会被调用一次,因此第二次该对象将会被垃圾清除。
4.MAT与JProfiler的GC Roots溯源
4.1 MAT是什么?
- MAT 是 Memory Analyzer 的简称,它是一款功能强大的 Java 堆内存分析器。用于查找内存泄漏以及查看内存消耗情况。
- MAT 是基于 Eclipse 开发的,是一款免费的性能分析工具。
- 大家可以在 http://www.eclipse.org/mat/ 下载并使用MAT
4.2 获取dump文件
方式一:命令行使用 jmap

方式二:使用JVisualVM导出
- 捕获的 Heap Dump 文件是一个临时文件,关闭 JVisualVM 后自动删除,若要保留,需要将其另存为文件。
- 可通过以下方法捕获 Heap Dump:
- 在左侧“Application"(应用程序)子窗口中右击相应的应用程序,选择 Heap Dump(堆Dump)。
- 在 Monitor(监视)子标签页中点击Heap Dump(堆Dump)按钮。
- 本地应用程序的 Heap Dumps 作为应用程序标签页的一个子标签页打开。同时,Heap Dump 在左侧的 Application(应用程序)栏中对应一个含有时间戳的节点。右击这个节点选择 Save As(另存为)即可将 Heap Dump 保存到本地。
public class GCRootsTest {
public static void main(String[] args) {
List<Object> numList = new ArrayList<>();
Date birth = new Date();
for (int i = 0; i < 100; i++) {
numList.add(String.valueOf(i));
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("数据添加完毕,请操作:");
new Scanner(System.in).next();
numList = null;
birth = null;
System.out.println("numList、birth已置空,请操作:");
new Scanner(System.in).next();
System.out.println("结束");
}
}
启动VisualVM,点击Heap Dumpdump出两个文件,把它另存到桌面
方式三:使用MAT打开Dump文件

4.3 JProfiler的GC Roots溯源
我们在实际的开发中,一般不会查找全部的 GC Roots,可能只是查找某个对象的整个链路,或者称为 GC Roots 溯源,这个时候,我们就可以使用 JProfiler。
public class GCRootsTest {
public static void main(String[] args) {
List<Object> numList = new ArrayList<>();
Date birth = new Date();
for (int i = 0; i < 100; i++) {
numList.add(String.valueOf(i));
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("数据添加完毕,请操作:");
new Scanner(System.in).next();
numList = null;
birth = null;
System.out.println("numList、birth已置空,请操作:");
new Scanner(System.in).next();
System.out.println("结束");
}
}
运行程序,打开JProfiler
如何判断什么原因造成 OOM?
/** * 内存溢出排查 * -Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError */
public class HeapOOM {
byte[] buffer = new byte[1 * 1024 * 1024];//1MB
public static void main(String[] args) {
ArrayList<HeapOOM> list = new ArrayList<>();
int count = 0;
try {
while (true) {
list.add(new HeapOOM());
count++;
}
} catch (Throwable e) {
System.out.println("count = " + count);
e.printStackTrace();
}
}
}
设置JVM参数 -Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
- 上述代码就是不断的创建一个 1M 小字节数组,然后让内存溢出,我们需要限制一下内存大小,同时使用
-XX:+HeapDumpOnOutOfMemoryError
将出错时候的 dump 文件输出。 - 程序运行结果
我们将生成的 dump 文件打开,然后点击 Biggest Objects 就能够看到超大对象。然后我们通过线程,还能够定位到哪里出现 OOM。
5.清除阶段:标记-清除算法(Mark-Sweep)
当成功区分出内存中存活对象和死亡对象后,GC 接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。目前在 JVM 中比较常见的三种垃圾收集算法是:
- 标记一清除算法(Mark-Sweep)
- 复制算法(Copying)
- 标记-压缩算法(Mark-Compact)
5.1 背景
标记-清除算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法,该算法被 J.McCarthy 等人在 1960 年提出并并应用于 Lisp 语言。
5.2 执行过程
当堆中的有效内存空间(Available Memory)被耗尽的时候,就会停止整个程序(也被称为 Stop The World),然后进行两项工作,第一项则是标记,第二项则是清除。
- :Collector 从引用根节点开始遍历,。一般是在对象的 Header 中记录为可达对象。(标记的是引用的对象,不是垃圾)
- :Collector 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收。

5.3 缺点
- 标记清除算法的效率不算高
- 在进行 GC 的时候,需要停止整个应用程序,导致用户体验较差
- 这种方式清理出来的空闲内存是不连续的,产生内存碎片,需要维护一个空闲列表
5.4 注意:何为清除?
- 这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放覆盖原有的地址。
- 关于空闲列表是在为对象分配内存的时候:
- 如果内存规整
- 采用指针碰撞的方式进行内存分配
- 如果内存不规整
- 虚拟机需要维护一个列表
- 空闲列表分配
- 如果内存规整
6.清除阶段:复制算法(Copying)
6.1 背景
为了解决标记-清除算法在垃圾收集效率方面的缺陷,M.L.Minsky 于 1963 年发表了著名的论文,“使用双存储区的 Lisp 语言垃圾收集器 CA LISP Garbage Collector Algorithm Using Serial Secondary Storage)”。M.L.Minsky 在该论文中描述的算法被人们称为复制(Copying)算法,它也被 M.L.Minsky 本人成功地引入到了Lisp 语言的一个实现版本中。
6.2 核心思想
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收
6.3 优缺点
优点
- 没有标记和清除过程,实现简单,运行高效
- 复制过去以后保证空间的连续性,不会出现“碎片”问题
缺点
- 此算法的缺点也是很明显的**,**就是需要两倍的内存空间。
- 对于 G1 这种拆分成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象的引用关系,不管是内存占用或者时间开销也不小
6.4 注意
- 如果系统中的存活对象非常多,复制算法可能不会很理想
- 因为复制算法需要复制的存活对象数量并不会太大,或者说非常低才行。
6.5 应用场景
在新生代,对常规应用的垃圾回收,一次通常可以回收 70% - 99% 的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。
7.清除阶段:标记-压缩(整理)算法(Mark - Compact)
7.1 背景
- 复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法**。**
- 标记 - 清除算法的确