垃圾收集器详解

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。


一、HotSpot 虚拟机的垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。如下图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。
垃圾收集器


Serial 收集器

  • Serial 收集器是最基本、发展历史最悠久的收集器,曾经是新生代收集的唯一选择。这个收集器是一个单线程的收集器,“单线程”的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到他收集结束,即“Stop The World”。
  • 虽然 Serial 收集器看起来是个“食之无味弃之可惜”鸡肋,但实际上到现在为止,它依然是虚拟机运行在 Client 模式下的默认新生代收集器
  • 它也有着优于其他收集器的地方:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。所以,Serial 收集器对于运行在 Client 模式下的虚拟机来说是一个很好的选择。

Serial


ParNew 收集器

  • ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括 Serial 收集器可用的所有控制参数(如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:+HandlePromotionFailure 等)、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一样。
  • 虽然除了多线程收集之外相比 Serial 收集器并没有太多创新之处,但它却是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作
  • ParNew 收集器在单 CPU 环境中绝对不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个 CPU 环境中都不能百分百地保证可以超越 Serial 收集器。但是随着可以使用的CPU 的数量增加,它对于 GC 时系统资源的有效利用还是很有好处的。它默认开启的收集线程数与 CPU 数量相同,在 CPU 非常多的环境下,可以使用 -XX:ParallelGCThreads 参数来限制垃圾收集的线程数。

ParNew


Parallel Scavenge 收集器

  • Parallel Scavenge 收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器,看上去和 ParNew 一样,那它有什么特别之处呢?
  • 这个收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能低缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标是达到一个可控制的吞吐量。所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。
  • 停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率的利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务
  • Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis 参数,以及直接设置吞吐量大小的 -XX:GCTimeRatio
    • -XX:MaxGCPauseMillis:参数允许的值是一个大于 0 的毫秒数,收集器将尽可能保证内存回收花费的时间不超过设定值。不过这个值不是越小越好,GC 停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小些,收集 300MB 新生代肯定比收集 500MB 快,这也直接导致垃圾收集发生得更频繁一些,原来 10 秒收集一次、每次停顿 100 毫秒,现在变成 5 秒收集一次,每次停顿 70 毫秒。停顿时间在下降,但是吞吐量也降下来了。
    • -XX:GCTimeRatio:参数的值应当是一个大于 0 小于 100 的整数,也就是垃圾收集时间占总时间的比率,相当于是吞吐量的倒数。
  • 由于与吞吐量关系密切,Parallel Scavenge 收集器也经常称为“吞吐量优先”收集器。该收集器还提供了 -XX:UseAdaptiveSizePolicy 参数,当这个参数打开之后,就不需要手动指定新生代的大小、Eden 与 Survivor 区的比例、晋升老年代对象大小等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC 自适应的调节策略(GC Ergonomics)
  • 如果对于收集器运作原来不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成将是一个不错的选择。只需要把基本的内存数据设置好(如 -Xmx 设置最大堆),然后使用 MaxGCPauseMillis 参数或 GCTimeRatio 参数给虚拟机设立一个优化目标,其他具体细节参数的调解工作就由虚拟机完成了。自适应调节策略也是 Parallel Scavenge 收集器与 ParNew 收集器的一个重要区别。

Parallel Old


Serial Old 收集器

  • Serial Old 是 Serial 收集器的老年代版本,同样是一个单线程收集器,使用“标记-整理”算法。

Serial


Parallel Old 收集器

  • Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法。
  • 在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

Parallel Old


CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它是老年代收集器。目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用看重的是服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。

CMS 收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为 4 个步骤,包括:

  • 初始标记 (CMS initial mark):主要是标记 GC Root 开始的下级(注:仅下一级)对象,这个过程会 STW,但是跟 GC Root 直接关联的下级对象不会很多,因此这个过程其实很快。
  • 并发标记 (CMS concurrent mark):根据上一步的结果,继续向下标识所有关联的对象,直到这条链上的最尽头。这个过程是多线程的,虽然耗时理论上会比较长,但是其它工作线程并不会阻塞,没有 STW。
  • 重新标记(CMS remark):顾名思义,就是要再标记一次。为啥还要再标记一次?因为第 2 步并没有阻塞其它工作线程,其它线程在标识过程中,很有可能会产生新的垃圾。这个过程会 STW。
  • 并发清除(CMS concurrent sweep):清除阶段是清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发进行的,没有 STW。

CMS
CMS 是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿,Sun 公司的一些官方文档中也称之为并发低停顿收集器。但他还是有以下几个明显的缺点:

  • 并发回收导致 CPU 资源紧张:
    • 在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低程序总吞吐量。CMS 默认启动的回收线程数是:(CPU 核数 + 3)/ 4,当 CPU 核数不足 4 个时,CMS 对用户程序的影响就可能变得很大。
  • 无法清理浮动垃圾:
    • 在 CMS 的并发清除阶段,用户线程还在继续运行,就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS 无法在当次收集中处理掉它们,只好留到下一次垃圾收集时再清理掉。这一部分垃圾称为“浮动垃圾”。
  • 并发失败(Concurrent Mode Failure):
    • 由于在垃圾回收阶段用户线程还在并发运行,那就还需要预留足够的内存空间提供给用户线程使用,因此 CMS 不能像其他回收器那样等到老年代几乎完全被填满了再进行回收,必须预留一部分空间供并发回收时的程序运行使用。默认情况下,当老年代使用了 68% 的空间后就会触发 CMS 垃圾回收,这个值可以通过 -XX:CMSInitiatingOccupancyFraction 参数来设置。
    • 这里会有一个风险:要是 CMS 运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:Stop The World,临时启用 Serial Old 来重新进行老年代的垃圾回收,这样一来停顿时间就很长了。
  • 内存碎片问题:
    • CMS 是一款基于“标记-清除”算法实现的回收器,这意味着回收结束时会有内存碎片产生。内存碎片过多时,将会给大对象分配带来麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次 Full GC 的情况。
    • 为了解决这个问题,CMS 收集器提供了一个 -XX:+UseCMSCompactAtFullCollection 开关参数(默认开启),用于在 Full GC 时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,是无法并发的,这样停顿时间就会变长。还有另外一个参数 -XX:+CMSFullGCBeforeCompaction ,这个参数的作用是要求 CMS 在执行过若干次不整理空间的 Full GC 之后,下一次进入 Full GC 前会先进行碎片整理(默认值为 0,表示每次进入 Full GC 时都进行碎片整理)。

G1 收集器

G1(Garbage-First)是一款面向服务端应用的垃圾收集器。HotSpot 开发团队赋予他的使命是未来可以替换掉 JDK 1.5 发布的 CMS 收集器。G1 在 JDK9 之后成为服务端模式下的默认垃圾回收器,取代了 Parallel Scavenge 加 Parallel Old 的默认组合,而 CMS 被声明为不推荐使用的垃圾回收器。G1 从整体来看是基于标记-整理算法实现的收集器,但从局部(两个 Region 之间)上看又是基于标记-复制算法实现的。

在 G1 之前的其他收集器进行收集的范围都是整个新生代或者老年代,而 G1 不再是这样。使用 G1 收集器时,Java 堆的内存布局就与其他收集器有很大差别,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region 的集合。

G1 之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。G1 把内存“化整为零”的思路,理解起来似乎很容易,但其中的实现细节却远远没有想象中那样简单,比如:

把 Java 堆分为多个 Region 后,垃圾收集是否就真的能以 Region 为单位进行了?其实并不,Region 不可能是孤立的。一个对象分配在某个 Region 中,它并非只能被本 Region 中的其他对象引用,而是可以与整个 Java 堆任意对象发生引用关系。那在做可达性判定确定对象是否存活的时候,岂不是还得扫描整个 Java 堆才能保证准确性?

其实这个问题并非在 G1 中才有,只不过更加突出而已。在 G1 收集器中,Region 之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用 Remembered Set 来避免全堆扫描。
G1 收集器的运作过程大致可分为四个步骤:

  • 初始标记(会 STW):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
  • 并发标记:从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理在并发时有引用变动的对象。
  • 最终标记(会 STW):对用户线程做短暂的暂停,处理并发阶段结束后仍有引用变动的对象。
  • 清理阶段(会 STW):更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,必须暂停用户线程,由多条回收器线程并行完成的。

G1


二、理解 GC 日志

设置 GC 日志

  • 已知了性能衡量指标,现在我们需要通过工具查询 GC 相关日志,统计各项指标的信息。
    首先,我们需要通过 JVM 参数预先设置 GC 日志,通常有以下几种 JVM 参数设置:
-XX:+PrintGC 输出 GC 日志
-XX:+PrintGCDetails 输出 GC 的详细日志
-XX:+PrintGCTimeStamps 输出 GC 的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出 GC 的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进行 GC 的前后打印出堆的信息
-Xloggc:../logs/gc.log 日志文件的输出路径

阅读 GC 日志

  • 阅读 GC 日志是处理 Java 虚拟机内存问题的基础技能,它只是一些人为确定的规则,没有太多技术含量。举例以下两端典型的 GC 日志:
	33.125: [GC [DefNew: 3324K->152K(3712K), 0.0025925 secs] 3324K->152K(11904K),
0.0031680 secs]
	100.667: [Full GC [Tenured: 0K->210K(10240K), 0.0149142 secs]
4603K->210K(19456K), [Perm : 2999K->2999K(21248K)], 0.0150007 secs] [Times:
user=0.01 sys=0.00, real=0.02 secs]
  • 最前面的数字 33.125100.667 代表了 GC 发生的时间,这个数字的含义是从 Java 虚拟机启动以来经过的秒数。
  • [GC[Full GC 开头说明了这次垃圾收集的停顿类型,而不是用来区分新生代 GC 还是老年代 GC 的。如果有 Full 说明这次 GC 是发生了 Stop-The-World 的。如果是调用 System.gc() 方法所触发的收集,那么在这里将显示 [Full GC (System)
  • 接下来的 [DefNew[Tenured[Perm 表示 GC 发生的区域,这里显示的区域名称与使用的 GC 收集器是密切相关的。
    • [DefNew:表示 Serial 收集器
    • [ParNew:表示 ParNew 收集器
    • [PSYoungGen:表示 Parallel Scavenge 收集器
  • 后面方括号内部的 3324K->152K(3712K) 含义是 GC 前该内存区域已使用容量 -> GC 后该内存区域已使用容量(该内存区域总容量)
  • 而方括号之外的 3324K->152K(11904K) 含义是 GC 前 Java 堆已使用容量 -> GC 后 Java 堆已使用容量(Java 堆总容量)
  • 再往后,0.0025925 secs 表示该内存区域 GC 所占用的时间,单位是秒。有的收集器会给出更具体的时间数据,如 [Times : user=0.01 sys=0.00, real=0.02 secs],这里面的 user、sys 和 real 与 Linux 的 time 命令所输出的时间含义一致,分别表示用户态消耗的CPU时间、内核态消耗的CPU时间和内核态消耗的CPU时间和操作从开始到结束所经过的墙钟时间(Wall Clock Time)。

三、垃圾收集器参数总结

参数描述
-XX:+UseSerialGC虚拟机运行在Client模式下的默认值,打开此开关后,使用Serial + Serial Old的收集器组合进行内存回收
-XX:+UseParNewGC使用ParNew + Serial Old的收集器组合进行内存回收
-XX:+UseConcMarkSweepGC使用ParNew + CMS + Serial Old的收集器组合进行内存回收。Serial Old作为CMS出现 Concurrent Mode Failure 失败后的后备收集器使用。
-XX:+UseParallelGC虚拟机运行在Server模式下的默认值,打开此开关后,使用Parallel + Scavenge + Serial Old的收集器组合进行内存回收
-XX:+UseParallelOldGC使用Parallel Scavenge + Parallel Old的收集器组合进行内存回收
-XX:SurvivorRatio新生代中Eden区域与Survivor区域的容量比值,默认为8,代表Eden:Subrvivor = 8:1
-XX:PretenureSizeThreshold直接晋升到老年代对象的大小,设置这个参数后,大于这个参数的对象将直接在老年代分配
-XX:MaxTenuringThreshold晋升到老年代的对象年龄,每次Minor GC之后,年龄就加1,当超过这个参数的值时进入老年代
-XX:UseAdaptiveSizePolicy动态调整java堆中各个区域的大小以及进入老年代的年龄
-XX:+HandlePromotionFailure是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个Eden和Survivor区的所有对象都存活的极端情况
-XX:ParallelGCThreads设置并行GC时进行内存回收的线程数
-XX:GCTimeRatioGC时间占总时间的比列,默认值为99,即允许1%的GC时间,仅在使用Parallel Scavenge 收集器时有效
-XX:MaxGCPauseMillis设置GC的最大停顿时间,在Parallel Scavenge 收集器时有效
-XX:CMSInitiatingOccupancyFraction设置CMS收集器在老年代空间被使用多少后触发垃圾收集,默认值为68%,仅在CMS收集器时有效
-XX:+UseCMSCompactAtFullCollection由于CMS收集器会产生碎片,此参数设置在垃圾收集器后是否需要一次内存碎片整理,仅在CMS收集器时有效
-XX:+CMSFullGCBeforeCompaction设置CMS收集器在进行若干次垃圾收集后再进行一次内存碎片整理,仅在CMS收集器时有效

笔记来源:《深入理解Java虚拟机》第三章 3.5 垃圾收集器(P75)。

  • 6
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

发飙的蜗牛咻咻咻~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值