java新生代用什么垃圾回收机制(Java程序员必会的垃圾回收)(1)

在 C/C 程序中,开发者需要自己手动管理程序的内存。也就是说当某个对象不再需要被使用,我们必须手动将其置为 null。这虽然为开发者提供了极大的自由度,但同时也导致了很多的问题。常用的问题有两类:

所以内存管理一直是 C/C 开发者非常头疼的问题。但在 Java 中就不会存在这样的事情,这得益于 Java 中出色的 GC(Garbage Collector) 机制,GC 会帮助我们自动回收不需要的对象。

本篇文章我们就来一起学习一下 Java 的 GC 算法。

java新生代用什么垃圾回收机制(Java程序员必会的垃圾回收)(2)

什么是垃圾?

在 Java 程序中,每 new 一个对象,就会在栈或堆中分配一块内存,比如这一行代码:

Object o = new Object();

变量 o 保存了这个对象的内存地址,我们称之为 o 持有这个 new Object() 的引用,当 o 被置为 null 时:

o = null;

栈或堆中,为这个 new Object() 分配的内存不再被任何变量引用,这块内存现在孤苦伶仃,没人知道它的存在,也没有人能够再访问到它,它就成为了一个垃圾。

垃圾:程序中的一块内存没有被任何变量持有引用,导致这块内存无法被这个程序再次访问时,这块内存被称为垃圾。

java新生代用什么垃圾回收机制(Java程序员必会的垃圾回收)(3)

怎么找到垃圾?2.1 引用计数法(Reference Count)

上文说到,没有被任何引用指向的对象称之为垃圾。所以我们可以想到一种算法:在某个对象被引用指向时,将其引用数量计数。每多一个引用指向这个对象,计数 1,每少一个引用指向这个对象,计数 -1,当计数为 0 时,表示这个对象成为了一个垃圾,将其回收掉。Python 语言的 GC 机制就是采用的此算法,它被称之为引用计数法。

循环引用的问题

引用计数法无法解决一个问题:循环引用。

看这样一个例子:

public class Client { public static void main(String[] args) { Test a = new Test(); Test b = new Test(); a.o = b; b.o = a; } } class Test { Object o; }

在这种情况下,a 引用了 b,b 又引用了 a。如果使用引用计数法,则它们的计数都为 1。当 main 方法执行完后,a 和 b 都不再被使用。但由于它们的引用计数不为 0,所以它们将无法被 GC 回收掉。如果采用引用计数法来寻找垃圾,必须小心这种循环引用的问题。所以 Java 中并没有采用引用计数法来进行内存回收。

2.2 可达性分析算法(Root Searching)

可达性分析算法又被称为根搜索算法,GC 定义了一些根(roots),从根开始不断搜索,能够被引用到的对象就不是垃圾,不能被引用到的对象就是垃圾。

可达性分析算法解决了循环引用的问题,即使有两个或多个对象循环引用,只要根访问不到它们,它们就是一对垃圾或一堆垃圾。

GC roots 包括:虚拟机栈(局部变量表)中引用的对象、本地方法栈中 JNI 引用的对象、方法区中静态引用的对象、存活的线程对象等等。

java新生代用什么垃圾回收机制(Java程序员必会的垃圾回收)(4)

怎么清除垃圾 垃圾回收算法(GC Algorithms)

垃圾回收算法一共有三种:

  • 标记清除(Mark-Sweep)
  • 拷贝(Copying)
  • 标记压缩(Mark-Compact)
3.1 标记清除(Mark-Sweep)

标记清除算法的思想是:先扫描一遍内存中的所有对象,将找到的垃圾做一个标记;回收时,再扫描一遍所有对象,将带有标记的垃圾清除。

优点:

  • 算法简单,容易理解
  • 在垃圾较少时,效率较高

缺点:

  • 需要扫描两次
  • 容易产生内存碎片,可能导致最后无法找到一块连续的内存存放大对象
3.2 拷贝(Copying)

拷贝算法的思想是:将内存空间一分为二,只在一半的内存上分配对象,GC 时,将正在使用的一半内存中,所有存活的对象拷贝到另一半中,然后将正在使用的这一半内存整个回收掉。

优点:

  • 只扫描一次,效率很高,尤其是垃圾较多的情况
  • 不会有内存碎片

缺点:

  • 浪费空间,可用内存减少
  • 移动时,需要复制对象,并调整对象引用
3.3 标记压缩(Mark-Compact)

标记压缩算法的思想是:先扫描一遍内存中的所有对象,将垃圾做一个标记;回收时,先清除垃圾,然后将存活的对象移动到被回收的位置。

优点:

  • 不会有内存碎片
  • 不会使内存减少

缺点:

  • 需要扫描两次
  • 移动时,需要复制对象,并调整对象引用

java新生代用什么垃圾回收机制(Java程序员必会的垃圾回收)(5)

内存分代模型

分代模型并不是一种垃圾回收算法,而是一种内存管理模型。它将 Java 中的内存分为不同的区域,在 GC 时,不同的区域采取不同的算法,可以提高回收效率。

内存分代模型将内存中的区域分成两部分:新生代(new/young)、老年代(old/tenuring)。两块区域的比例默认是 1:2,我们也可以自己设置这个比例(通过 -Xms 初始化堆的大小,通过 -Xmx 设置堆最大分配的内存大小,通过 -Xmn 设置新生代的内存大小)。

顾名思义,对象存活的时间较短,则属于新生代,存活时间较长,则属于老年代。那么如何去衡量对象存活的时间呢?JVM 的做法是:每经过一次 GC,没被回收掉的对象年龄 1,大约 15 岁之后,新生代的对象到达老年代。

新生代又分为一个伊甸区(eden),两个存活区(survivor)。当对象刚 new 出来时,通常分配在伊甸区,伊甸区的对象大多数生命周期都比较短,据不完全统计,每次 GC 时,伊甸区存活对象只占 5% - 10%,由于存活对象较少,所以伊甸区的 GC 采用的是 拷贝算法,但这里的拷贝算法并不是将内存一分为二,因为伊甸区存活的对象数量较少,所以存活区只需要较小的内存(伊甸区和存活区的默认比例是 8:1:1,通过 -XX:SurvivorRatio 可以自定义此比例)。

新生代的 GC 被称之为 YGC(Young Garbage Collector,年轻代垃圾回收)或者 MinorGC(Minor Garbage Collector,次要垃圾回收),整个回收过程类似这样:

  • 对象在伊甸区中被创建出来
  • 伊甸区经过一次 GC 之后,存活的对象到达存活 1 区,清空伊甸区
  • 伊甸区和存活 1 区的对象经历第二次 GC,存活的对象到达存活 2 区,清空伊甸区和存活 1 区
  • 伊甸区和存活 2 区经历第三次回收,存活的对象到达存活 1 区,清空伊甸区和存活 2 区
  • 循环往复...
  • 每经过一次 GC,没被回收掉的对象年龄 1。当存活的对象到达一定年龄之后,新生代的对象到达老年代。

新生代转移到老年代的年龄根据垃圾回收器的类型而有所不同,CMS(Concurrent Mark Sweep,一种垃圾回收器) 设置的默认年龄是 6,其他的垃圾回收器默认年龄都是 15。这个年龄我们可以自己设置(通过参数 -XX:MaxTenuringThreshold 配置),但不可超过 15,因为对象头中用于记录对象分代年龄的空间只有四位。

老年代的 GC 采用的是 标记清除 或者 标记整理,因为老年代的空间较大,所以老年代的 GC 并不像新生代那样频繁。

整个内存回收称之为 FGC(Full Garbage Collector,完整垃圾回收),或者 MajorGC (Major Garbage Collector,重要垃圾回收)。YGC/MinorGC 在新生代空间耗尽时触发。FGC/MajorGC 在老年代空间耗尽时触发,FGC/MajorGC 触发时,新生代和老年代会同时进行 GC。在 Java 程序中,也可以通过 System.gc() 来手动调用 FGC。

小结

整个内存回收过程如下图所示:

java新生代用什么垃圾回收机制(Java程序员必会的垃圾回收)(6)

当对象刚创建时,优先考虑在栈上分配内存。因为栈上分配内存效率很高,当栈帧从虚拟机栈 pop 出去时,对象就被回收了。但在栈上分配内存时,必须保证此对象不会被其他栈帧所引用,否则此栈帧被 pop 出去时,就会出现对象逃逸,产生 bug。

如果此对象不能在栈上分配内存,则判断此对象是否是大对象,如果对象过大,则直接分配到老年代(具体多大这个阈值可以通过-XX:PretenureSizeThreshold参数设置)。

否则考虑在 TLAB (Thread Local Allocation Buffer,线程本地分配缓冲区)上分配内存,这块内存是伊甸区为每个线程分配的一块区域,它的大小是伊甸区的 1% (可以通过-XX:TLABWasteTargetPercent设置),作用是减少线程间互相争抢伊甸区空间,以减少同步操作。

伊甸区的对象经过 GC,存活的对象在 Survivor 1 区和 Survivor 2 区不断拷贝,到达一定年龄后到达老年代。

老年代的垃圾在 FGC 时被回收。

这就是 Java 中的整个 GC 过程。

java新生代用什么垃圾回收机制(Java程序员必会的垃圾回收)(7)

Garbage Collectors

随着 Java 的不断发展,垃圾回收器也在不断地更新。在 JDK 5 及之前,主要采用 Serial/Serial Old 进行垃圾回收,它们分别用于回收新生代/老年代,从名字就可以看出,两者都是单线程的。

在 JDK 6 中,引入了 Parallel Scavenge/Parallel Old,简称 PS/PO,分别用于回收新生代/老年代,在 JDK 6 到 JDK 8 中,采用 PS/PO 进行垃圾回收,它们都是多线程的。

在 JDK8 之后,出现过一个承上启下的垃圾回收器 CMS,它开启了并发回收的先河,主要用于老年代的垃圾回收,与其搭配使用的新生代垃圾回收器名为 ParNew。

之前的 PS/PO 虽然也使用了多线程,但多线程回收和并发回收的区别在于:多线程回收是指多个线程同时执行垃圾回收,而并发回收的意思是垃圾回收线程和工作线程同时执行。可惜的是,CMS 使用起来有一个非常大的问题,但它开启了 GC 的新思路,之后的并发垃圾回收器,如 G1(Garbage-First) 、ZGC( Z Garbage Collector)、Shenandoah 等都是由它启发出来的。

JDK11 引入了 ZGC,JDK12 引入了 Shenandoah。但在 JDK 9 之后,默认都是采用 G1 进行垃圾回收,G1 是一个非常高效的并发垃圾回收器。

5.1 Serial/Serial Old

Serial: a stop-the-world, copying collector which uses a single GC thread.

Stop-the-world 简称 STW,意思是 GC 操作中,所有的线程必须停止所有工作,等待 GC 完成后再继续工作,STW 会造成界面的卡顿。

从定义中可以看出,Serial 采用的是拷贝算法,并且是单线程运行。

Serial Old:a stop-the-world, mark-sweep-compact collector that uses a single GC thread.

和 Serial 类似,但它主要用于老年代垃圾回收,采用的是标记压缩算法,也是单线程运行。

这两个最早的垃圾回收器现在已经不实用了, 因为它们的效率实在太低。并且随着程序内存越来越大,STW 的时间也会越来越长,最终导致界面卡死的时间越来越长。

5.2 Parallel Scavenge/Parallel Old

Parallel Scavenge: a stop-the-world, copying collector which uses multiple GC threads.

从定义中可以看出,Parallel Scavenge 采用拷贝算法,多线程运行。

Parallel Old: a compacting collector that uses multiple GC threads.

Parallel Old 采用标记压缩算法,多线程运行。

5.3 CMS/ParNew

CMS(Concurrent Mark Sweep):a mostly concurrent, low-pause collector.

CMS 采用的是标记清除算法,并且是并发执行的。

并发虽好,但使用不当也会带来很多问题。核心问题有两类:

  • 某个对象将要被当成垃圾回收时,工作线程中突然有一个引用准备指向它,导致 标记了不该回收的对象
  • 某个对象在 GC 扫描时没有被当成垃圾,扫描过后又变成了垃圾,导致 没有标记到应该回收的对象

这两个问题是并发垃圾回收器需要解决的关键问题。以 CMS 为例,我们来看下它是怎么解决这两类问题的。

CMS 主要分为四个阶段:初始标记(initial mark), 并发标记(concurrent mark), 重新标记(remark),并发清理(concurrent sweep)。

初始标记阶段:通过 GC roots 将根上的对象找到,这时会触发 STW,但由于根上的对象相对较少,这里的 STW 时间不会很长。

并发标记阶段:从 GC roots 开始,通过可达性分析算法找到所有的垃圾,这个阶段是最耗时的,但由于并发执行,所以不会触发 STW。这里会用到黑白灰三色扫描算法。

黑色:自己已经标记,且 fields 已经标记完成

灰色:自己标记完成,但 fields 还没标记

白色:没有遍历到的节点

并发标记是最困难的一步,难点在于标记对象的过程中,对象的引用关系正在发生改变,白色对象可能会被错误回收。

重新标记阶段:这个阶段主要用于纠错,也就是修复上文提到的 标记了不该回收的对象 没有标记到应该回收的对象 这两个错误,这时会触发 STW,但时间也不会很长,因为出错的对象毕竟是少数。

并发清理阶段:清除所有的垃圾,不会触发 STW。

由于 CMS 采用的是标记清除算法,所以不可避免地会产生较多的内存碎片。当老年代中内存碎片过多,导致无法为大对象分配内存时,CMS 会使用 Serial Old 对老年代进行垃圾回收。这会出现一次非常长时间的 STW,这也是前文说到的使用 CMS 最大的一个问题。所以,没有任何一个JDK版本采用CMS 作为默认垃圾回收器。

ParNew:a stop-the-world, copying collector which uses multiple GC threads. It differs from "Parallel Scavenge" in that it has enhancements that make it usable with CMS.

从定义中可以看出,ParNew 是 PS 的一个变种,采用拷贝算法,多线程运行,主要是为了配合 CMS。

5.4 G1、ZGC、Shenandoah

三者都是比较高效的并发垃圾回收器。在 CMS 的 Remark 阶段,为了修复并发标记过程中的错误标记,CMS 采用了一种 Increment Update 的算法,但这种算法在并发时可能会产生漏标。在 G1 中,此阶段采用的方案是 SATB(Snapshot At The Begining),ZGC 和 Shenandoah 采用的方案是 Colored Pointers。这几种算法都比较复杂,感兴趣的读者可以自行查阅资料了解这些算法的具体实现。

java新生代用什么垃圾回收机制(Java程序员必会的垃圾回收)(8)

引用

聊完了 Java 内存回收,再来看看 Java 中的四种引用类型。引用类型由强到弱分别为:

  • 强引用:Object obj = new Object(); 这样 new 出来的对象就属于强引用类型。GC 不会回收强引用对象。
  • 软引用:SoftReference<Object> softObj = new SoftReference(); 当内存实在不足时,GC 就会回收软引用对象。
  • 弱引用:WeakReference<Object> weakObj = new WeakReference(); 当 GC 回收时,遇到弱引用对象就会将其回收。
  • 虚引用:不会被使用

java新生代用什么垃圾回收机制(Java程序员必会的垃圾回收)(9)

总结

本文介绍了 Java 内存回收算法的知识体系,包括什么是垃圾,如何找到垃圾以及如何回收垃圾。介绍了回收垃圾时用到的三种回收算法:标记清除、拷贝、标记整理。然后介绍了历史上的几种垃圾回收器,以及 Java 中的四种引用类型。

Java 内存回收机制在面试中经常出现,但能够将其叙述清楚的开发者实在不多。当然,学习内存回收算法的意义不仅在于应付面试,在实际工作中,掌握内存回收算法可以帮助我们更好的理解对象的生命周期,防止出现内存泄漏。

Java 中的内存泄漏:某个生命周期长的对象持有了生命周期短的对象的引用,导致生命周期短的对象无法被及时回收。

所以,并不是说有了 GC 机制我们就完全不用操心内存回收问题了。在有的情况下,当某个强引用对象不再需要被使用时,我们应该手动将其置为 null,使 GC 能够识别出这段内存已经成为了垃圾。

并且,由前文可知,方法区中静态引用的对象属于 GC roots,所以使用静态变量和静态方法时需要小心,这些对象一旦创建,就会一直存在于内存中,直到程序退出或者变量被手动置为 null 之后,这段内存才能被回收掉。

Stay Hungry, Stay Foolish。在日常工作中,不要只满足于完成业务。多了解程序背后的原理和运行机制,对我们自身能力的提升大有裨益。读完本文您有什么意见或建议,欢迎在留言区分享。

本文作者:Alpinist Wang

声明:本文归 “力扣” 版权所有,如需转载请联系。文中部分图片来源于网络,如有侵权联系删除。

,