Java 垃圾回收器

Java 垃圾回收器

判断对象是否可回收

如何判断一个对象属于垃圾对象呢?

引用计数法

对于一个对象 A,只要有任意一个对象引用了 A,则 A 的计数器加 1,当引用失效的时候,引用计数器就减 1。如果 A 的应用计数器为 0,则对象 A 就不可能再被使用。

缺点:无法处理循环引用的问题。

可达性分析算法

通过一系列的称为 GC Roots 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的循环称为引用链。当一个对象到 GC Roots 没有任何引用链的时候,则证明此对象是不可达的,因此它们会被判定为可回收对象

可以作为 GC Roots 的对象:

  • 类静态属性中引用的对象
  • 常量引用的对象
  • 虚拟机栈中引用的对象
  • 本地方法栈中引用的对象

finalize 方法中复活

finalize() 方法只会被调用一次:

@Override
protected void finalize() throws Throwable {
    super.finalize();
    obj = this;
}

下述代码在内存中如何放置的示例:

StringBuffer str = new StringBuffer("Hello world");

假设以上代码是在函数体内运行的,那么:

四个引用

  • 软引用: java.lang.ref.SoftReference 可被回收的引用
  • 弱引用: 发现即回收。由于垃圾回收器的线程通常优先级很大,因此并不一定很快地发现持有弱引用的对象。
  • 虚引用: 跟踪垃圾回收过程

内存泄露

while (true) {
  for (int i=0; i<10000; i++) {
    if (!m.contains(new Key(i))) {
      m.put(new Key(i), "Number:" + i);
    }
  }
}

垃圾回收算法

标记-清除算法

从每个 GC Roots 对象出发,依次标记有引用关系的对象,最后将没有被标记的对象清除。

缺点:带来大量空间碎片,导致需要分配一个较大连续空间时,容易触发 GC

标记-整理(标记-压缩)算法

从每个 GC Roots 对象出发,标记存活的对象,然后将存活的对象整理到内存空间的一端,形成连续的已使用空间,最后将已使用空间外的部分全部清理掉,消除空间碎片问题。

标记-复制算法

为了能够并行的标记和整理,将整个空间分为两块,每次只激活一块,垃圾回收只需把存活的对象复制到另一块未激活的空间上,将未激活空间标记为已激活,将已激活空间标记为未激活,然后清除原空间中的原对象。

分代收集算法

垃圾收集器一般根据对象存活周期的不同,将内存划分为几块,根据每块内存空间的特点,使用不同的回收算法,提供回收效率。

分区算法

将整个堆空间划分为连续的不同小空间,每一个小空间独立使用,独立回收。

优点:可以控制一次回收多少个小区间。

HotSpot 虚拟机垃圾收集器

Serial

新生代 Serial 收集器采用复制算法,使用单线程进行垃圾回收,回收时 Java 应用程序中的线程都需要暂停 (Stop-The-World),以等待回收完成。使用 -XX:+UseSerialGC 可以指定新生代采用 Serial 收集器,老年代采用 Serial Old 收集器。虚拟机在 Client 模式下运行时,它是默认的垃圾收集器。独占式回收

它的日志格式如下:

ParNew

新生代 ParNew 将 Serial 收集器多线程化,在并发能力强的 CPU 上,产生的停顿时间短于串行回收器。开启 ParNew 回收器:

  • -XX:+UseParNewGC:新生代 ParNew,老年代采用 Serial Old
  • -XX:+UseConcMarkSweepGC:新生代 ParNew,老年代采用 CMS

-XX:ParallelGCThreads 可以指定并行回收的线程数,这个线程数的默认值是:

threadsNum = 0;

if (CORE_OF_CPU < 8) {
	threadsNum = CORE_OF_CPU;
} else {
	threadsNum = 3 + 5 * CORE_OF_CPU / 8;
}

它的 GC 日志格式如下:

Parallel

新生代 Parallel 采用复制算法,多线程、独占式,它与 ParNew 的不同之处:

  • 关注系统的吞吐量
  • 支持自适应 GC 调节

以下参数启用 Parallel 回收器:

  • -XX:+UseParallelGC:新生代 ParallelGC,老年代:Serial Old
  • -XX:+UseParallelOldGC:新生代 ParallelGC,老年代 ParallelOldGC

用于控制吞吐量的两个重要参数:

  • -XX:MaxGCPauseMills: 设置最大垃圾收集停顿时间。
  • -XX:GCTimeRatio: 设置吞吐量大小,范围 0 ~ 100。假设这个值是 n,那么默认不超过 1 / (1 + n) 的时间百分比用于垃圾收集,n 默认为 99

用于控制自适应调节 GC 的参数:

  • -XX:+UseAdaptiveSizePolicy: 新生代、eden 和 survivor 的比例会动态调整。

它的 GC 日志格式如下:

Serial Old

老年代串行收集器 Serial Old 采用**标记-整理 (标记-压缩)**算法,也使用单线程进行垃圾回收。使用如下参数开启 Serial Old 回收器:

  • -XX:+UseSerialGC:新生代、老年代都使用 Serial 回收器 (老年代用的是 Serial Old)
  • -XX:+UseParNewGC:新生代采用 ParNew,老年代采用 Serial Old
  • -XX:+UseParallelGC:新生代采用 ParallelGC,老年代采用 Serial Old

它的日志格式如下:

Parallel Old

老年代 Parallel Old 回收器采用标记-整理算法,多线程进行垃圾回收。使用 -XX:+UseParallelOldGC 可以在新生代采用 Parallel,老年代采用 Parallel Old 收集器。参数 -XX:ParallelGCThreads 可以用于设置垃圾回收时的线程数量。

它的 GC 日志格式如下:

CMS

CMS 是一个基于标记-清除的算法,启用 CMS 的参数是 -XX:+UseConcMarkSweepGC,默认启动的工作线程数是 (ParallelGCThreads + 3) / 4。 CMS 不会等到堆内存饱和的时候才进行垃圾回收,而是当老年代的堆内存使用率达到某个阈值 -XX:CMSInitiatingOccupancyFraction (默认是 68%) 的时候便开始进行回收。CMS 基于标记-清除算法,因此执行垃圾回收完毕之后,会出现大量内存碎片,造成如果需要将内存分配给较大的对象,则必须被迫进行一次垃圾回收,以换取连续的内存空间。未解决这个问题,可以使用 -XX:+UseCMSCompactAtFullCollection 开关,使得 CMS 垃圾收集完毕之后,进行一次内存碎片整理;-XX:CMSFullGCsBeforeCompaction 参数可以用于设定进行多少次 CMS 回收后,执行一次内存压缩。

它的 GC 日志格式如下:

CMS 的代价:应用程序消耗更多的 CPU。

G1

G1 是专门针对以下应用场景设计的:

  • 像 CMS 收集器一样,能与应用程序线程并发执行。
  • 整理空闲空间更快。
  • 需要 GC 停顿时间更好预测。
  • 不希望牺牲大量的吞吐性能。
  • 不需要更大的 Java Heap。

使用 -XX:+UseG1GC 可以打开 G1 收集器开关。参数 -XX:MaxGCPauseMills 可以调整最大停顿时间,另外一个参数 -XX:ParallelGCThreads 可以设置并行回收时,GC 的工作线程数量。

G1 引入的目的?

是为了缩短处理超大堆的停顿时间

G1 相比 CMS?

  • G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片
  • G1的 Stop The World(STW) 更可控,G1 在停顿时间上添加了预测机制,用户可以指定期望停顿时间。

G1 和 CMS 执行的操作其实是一样的:

  • 并发全局扫描标记检查存活的对象
  • 哪些区域垃圾对象最多,G1 就先收集哪些区域,这也是它为什么称为 Garbage-First 的原因

G1 如何做到可预测的暂停时间?

G1 回收的第4步,它是“选择一些内存块”,而不是整代内存来回收,这是 G1 跟其它GC非常不同的一点,其它GC每次回收都会回收整个Generation 的内存(Eden, Old), 而回收内存所需的时间就取决于内存的大小,以及实际垃圾的多少,所以垃圾回收时间是不可控的;而 G1 每次并不会回收整代内存,到底回收多少内存就看用户配置的暂停时间,配置的时间短就少回收点,配置的时间长就多回收点,伸缩自如。

G1 坏处

应用的内存非常吃紧,对内存进行部分回收根本不够,始终要进行整个Heap的回收,那么G1要做的工作量就一点也不会比其它垃圾回收器少,而且因为本身算法复杂了一点,可能比其它回收器还要差。

G1 的 Region

G1 的各代存储地址是不连续的,每一代都使用了 n 个不连续的大小相同的 Region,每个 Region 占有一块连续的虚拟内存地址。如下图所示:

在上图中,我们注意到还有一些Region标明了H,它代表Humongous,这表示这些Region存储的是巨大对象(humongous object,H-obj),即大小大于等于region一半的对象。

一个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围从 1M32M,且是 2 的指数。如果不设定,那么G1会根据Heap大小自动决定。

ZGC

默认垃圾收集器

参考 1 说: 对于 server-class 机器,默认 GC 从 serial 改为了 parallel 收集器。

参考 2 says: 从 Java 5.0 开始就会检测运行在 server-vm 还是 client-vm。对于 Java SE 6, 拥有 2 个 CPU、2GB 物理内存的机器属于 server-class 的机器。

Java 7 和 Java 8 使用的都是 Parallel GC,Java 9 使用的是 G1 垃圾收集器。

垃圾回收器怎么选择

  • 最小化地使用内存和并行开销,请选择 Serial GC
  • 最大化应用程序的吞吐量,请选择Parallel GC
  • 最小化 GC 的中断或者停顿时间,请选择 CMS GC

并发和并行都可以表示两个或者多个任务一起执行,但是偏重点不同。并发偏重于多个任务交替执行,而多个任务之间有可能还是串行的。而并行是真正意义上的“同时执行”。

FullGC

什么情况下会触发 FullGC ? 参考

System.gc()

默认情况下(即未开启 -XX:+DisableExplictGC 参数的情况下),调用 System.gc() 会显示触发 FullGC,同时对新生代和老年代进行回收。

老年代空间不足

当老年代空间新生代对象转入创建大对象大数组时,空间不足,会触发 FullGC,如果触发完依然不足,则抛出如下错误:

java.lang.OutOfMemoryError: Java heap space

永生区空间不足

Permanet Generation 中存放的为一些 class 的信息、常量、静态变量等数据,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。

CMS 晋升失败

CMS GC时出现 promotion failed 和 concurrent mode failure

promotion failed是在进行Minor GC时,survivor space放不下、对象只能放入老年代,而此时老年代也放不下造成的;concurrent mode failure是在

执行CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足造成的(有时候“空间不足”是CMS GC时当前的浮动垃圾过多导致暂时性的空间不足触发Full GC)

堆中分配很大的对象

所谓大对象,是指需要大量连续内存空间的java对象,例如很长的数组,此种对象会直接进入老年代,而老年代虽然有很大的剩余空间,但是无法找到足够大的连续空间来分配给当前对象,此种情况就会触发JVM进行Full GC。

jmap -histo:live

执行 jmap -histo:live [强制 Full GC,不加 live 就不会] 或者 jmap -dump:live [强制 full gc,不加 live 就不会]

对象何时进入老年代

老年对象达到年龄

新生代的对象,每经历一次 GC,年龄加 1,当年龄的最大值最多达到 MaxTenuringThreshold (默认值 15) 的情况下,就可以晋升到老年代。

对象的实际晋升年龄是根据 survivor 区的使用情况动态计算的。

大对象

新生代空间无法容纳大对象,则会直接晋升到老年代。

参数 PretenureSizeThreshold 可以设置对象直接晋升到老年代的阈值,单位是字节,不过只对 SerialParNew 收集器有效,默认值为 0,即不指定最大晋升大小。

垃圾调优策略

面对不同的业务场景,垃圾回收的调优策略也不一样。例如,在对内存要求苛刻的情况下,需要提高对象的回收效率;在 CPU 使用率高的情况下,需要降低高并发时垃圾回收的频率。可以说,垃圾回收的调优是一项必备技能。

降低 Minor GC 频率

通常情况下,由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC,因此我们可以通过增大新生代空间 (-Xmn) 来降低 Minor GC 的频率。

可能你会有这样的疑问,扩容 Eden 区虽然可以减少 Minor GC 的次数,但不会增加单次 Minor GC 的时间吗?如果单次 Minor GC 的时间增加,那也很难达到我们期待的优化效果呀。

我们知道,单次 Minor GC 时间是由两部分组成:T1(扫描新生代)和 T2(复制存活对象)。假设一个对象在 Eden 区的存活时间为 500ms,Minor GC 的时间间隔是 300ms,那么正常情况下,Minor GC 的时间为 :T1+T2

当我们增大新生代空间,Minor GC 的时间间隔可能会扩大到 600ms,此时一个存活 500ms 的对象就会在 Eden 区中被回收掉,此时就不存在复制存活对象了,所以再发生 Minor GC 的时间为:两次扫描新生代,即 2T1

可见,扩容后,Minor GC 时增加了 T1,但省去了 T2 的时间。通常在虚拟机中,复制对象的成本要远高于扫描成本

如果在堆内存中存在较多的长期存活的对象,此时增加年轻代空间,反而会增加 Minor GC 的时间。如果堆中的短期对象很多,那么扩容新生代,单次 Minor GC 时间不会显著增加。因此,单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。

Minor GC 时间太长

减小新生代空间大小

降低 Full GC 的频率

  • 减少创建大对象:在平常的业务场景中,我们习惯一次性从数据库中查询出一个大对象用于 web 端显示。例如,我之前碰到过一个一次性查询出 60 个字段的业务操作,这种大对象如果超过年轻代最大对象阈值,会被直接创建在老年代;即使被创建在了年轻代,由于年轻代的内存空间有限,通过 Minor GC 之后也会进入到老年代。这种大对象很容易产生较多的 Full GC。

  • 增大堆内存空间:在堆内存不足的情况下,增大堆内存空间,且设置初始化堆内存为最大堆内存,也可以降低 Full GC 的频率。

老年代 GC 时间过长

通常使用 parallelGC 或者 parallelOldGC 的话,增加老年代空间无法显著降低 GC 时间,可以改用 CMS

选择合适的 GC 回收器

假设我们有这样一个需求,要求每次操作的响应时间必须在 500ms 以内。这个时候我们一般会选择响应速度较快的 GC 回收器,CMS(Concurrent Mark Sweep)回收器和 G1 回收器都是不错的选择。

而当我们的需求对系统吞吐量有要求时,就可以选择 Parallel Scavenge 回收器来提高系统的吞吐量。

JVM 命令

查看上次 GC 原因

查看当前对象数量

Dump Java 堆

第三方

参考