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大小自动决定。

G1 采用的算法

从 GC 算法的角度,G1 选择的是复合算法,可以简化理解为:

  • 新生代,G1 采用的仍然是并行的复制算法,所以同样会发生 Stop-The-World 的暂停。
  • 老年代,大部分情况下都是并发标记,而整理(Compact)则是和新生代 GC 时捎带进行,并且不是整体性的整理,而是增量进行的。

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

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

Full GC

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

Full GC vs MajorGC

针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:

  • Partial GC:并不收集整个GC堆的模式
    • Young GC:只收集young gen的GC
    • Old GC:只收集old gen的GC。只有CMS的concurrent collection是这个模式
    • Mixed GC:收集整个young gen以及部分old gen的GC。只有G1有这个模式
  • Full GC:收集整个堆,包括young gen、old gen、perm gen(如果存在的话)等所有部分的模式。

Major GC通常是跟full GC是等价的,收集整个GC堆。但因为HotSpot VM发展了这么多年,外界对各种名词的解读已经完全混乱了,当有人说“major GC”的时候一定要问清楚他想要指的是上面的full GC还是old GC。

GC 触发策略

最简单的分代式GC策略,按HotSpot VM的serial GC的实现来看,触发条件是:

  • young GC:当young gen中的eden区分配满的时候触发。注意young GC中有部分存活对象会晋升到old gen,所以young GC后old gen的占用量通常会有所升高。
  • full GC:当准备要触发一次young GC时,如果发现统计数据说之前young GC的平均晋升大小比目前old gen剩余的空间大,则不会触发young GC而是转为触发full GC(因为HotSpot VM的GC里,除了CMS的concurrent collection之外,其它能收集old gen的GC都会同时收集整个GC堆,包括young gen,所以不需要事先触发一次单独的young GC);或者,如果有perm gen的话,要在perm gen分配空间但已经没有足够空间时,也要触发一次full GC;或者System.gc()、heap dump带GC,默认也是触发full GC。

HotSpot VM里其它非并发GC的触发条件复杂一些,不过大致的原理与上面说的其实一样。当然也总有例外。Parallel Scavenge(-XX:+UseParallelGC)框架下,默认是在要触发full GC前先执行一次young GC,并且两次GC之间能让应用程序稍微运行一小下,以期降低full GC的暂停时间(因为young GC会尽量清理了young gen的死对象,减少了full GC的工作量)。控制这个行为的VM参数是-XX:+ScavengeBeforeFullGC。并发GC的触发条件就不太一样。以CMS GC为例,它主要是定时去检查old gen的使用量,当使用量超过了触发比例就会启动一次CMS GC,对old gen做并发收集。

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 堆

第三方

参考