在之前的文章中,我们了解 JVM
大体上结构模型以及垃圾回收的作用,今天就让我们将目光放到垃圾回收的具体实现上,了解垃圾回收器究竟是如何进行工作。
一、回收算法
开始之前,先介绍一下 JVM
中最基础的几类工作模式。
在 JVM
中当经过判断确实需要回收对象时,有三种常见的空间回收方式:标记-清除
、标记-复制
、标记-整理
,下面分别介绍三种的回收方式与其之间的差异。
1. 分代介绍
在开始之前先简单介绍一下垃圾回收中的分代概念,Java堆
执行 GC
操作时经常涉及到两个概念 新生代
与 老年代
,二者定义如下:
- 新生代,当新创建一个对象则归属于新生代,对应垃圾回收中的
Minor GC
。- 老年代,新生代中存活下的对象则逐步晋升到老年代,即在
YGC
后未能及时回收的对象,对应垃圾回收中的Major GC
。
(1) 记忆集
假如现在进行一次只局限于新生代区域内的收集(Minor GC
),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots
之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。
为此,在新生代上建立一个全局的数据结构(该结构被称为记忆集,Remembered Set
),这个结构把老年代划分成若干小块,并标识出老年代的哪一块内存会存在跨代引用。此后当发生 Minor GC
时,只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots
进行扫描。
2. 标记-清除
首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
- 如果
Java
堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过
程的执行效率都随对象数量增长而降低。- 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
3. 标记-复制
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
当新生代中标识完成后存活的对象大于另一半内存空间,即另一半空间无法存储时,则移至老年代。
- 解决了空间碎片化,但会浪费一半的内存空间。
- 如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。
4. 标记-整理
标记-整理其标记过程仍然与 标记-清除
算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
但是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。
二、经典收集器
1. Serial
Serial
收集器是最基础、历史最悠久的收集器,基于 标记清除
模式实现,在垃圾收集过程中需要停止所有用户工程进程。
Serial Old
是 Serial
收集器的老年代版本,基于 标记整理
模式,它同样是一个单线程收集器,主要供客户端模式下的 HotSpot
虚拟机使用。
2. ParNew
ParNew
收集器实质上是 Serial
收集器的多线程并行版本。
3. Parallel
Parallel Scavenge
收集器也是一款新生代收集器,它基于 标记复制
模式实现的收集器,也是能够并行收集的多线程收集器。其提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis
参数以及直接设置吞吐量大小的 -XX:GCTimeRatio
参数。
Parallel Old
是 Parallel Scavenge
收集器的老年代版本,基于 标记整理
模式实现,支持多线程并发收集。
三、Appel回收
1. 模式介绍
Appel
回收模式基于标记-复制模式,是当下 JVM
中较为流行的手段,在 HotSpot
虚拟机的 Serial、ParNew
等新生代收集器均采用了这种策略来设计新生代的内存布局。
在 Appel
中 JVM
的内存堆 (Heap)
被分为新生代与老年代,二者比例为 1:2
。其中新生代内存空间又分为一块较大的 Eden
和两块较小的 Survivor
空间:From Space
和 To Space
,每次分配内存只使用 Eden
和其中一块 Survivor
,默认的 Eden
与 Survivor
二者比例为 8 : 2
。
Appel
式回收执行步骤如下:
- 将
Eden
和From Space
中仍然存活的对象一次性复制到To Space
空间上。 - 清理掉
Eden
和及From Space
空间,若存活的对象To Space
无法存放则移至老年代。 - 每经历过一次复制到
To Space
空间相对应的对象年龄即增长一次,增长至15
时即移至老年代。 - 通过参数
MaxTenuringThreshold
设置垃圾的最大年龄,默认为15
,取值为0~15
。 - 当老年代中的空间不足以存放新生代中晋升的对象时即触发
Full GC
,应降低Full GC
发生频次。
2. 参数配置
在 Appel
式回收方式中较为关键的 JVM
参数为下表中三类,前两个就不再过多阐述上述已经详细介绍了,让我们关注一下第三项。
参数 | 描述 |
---|---|
-XX:SurvivorRatio=8 | 设置新生代中 Eden 与 Survivor 的比例,默认为 8:2。 |
-XX:MaxTenuringThreshold=15 | 设置 To Space 最大复制次数,取值 0~15,默认为 15。 |
-XX:PretenureSizeThreshold=1m | 设置忽略 MaxTenuringThreshold 直接晋升到老年代的对象大小。 |
通过刚才的了解我们知道在垃圾回收时一个存活的对象会被先复制到 To Space
区域,当达到 MaxTenuringThreshold
所配置的次数或 To Space
空间不足时将被移到老年代,而 PretenureSizeThreshold
参数则可跳过此阶段。
例如将其值配置为 1m
,那么当对象超过 1m
时则会直接移至老年代而跳过年轻代中 To Space
的复制动作,这样的好处就是对于大对象可以降低操作频次从而提高性能。
3. 示例分析
在 JDK 8
中的虚拟机默认为 Parallel GC
,其正是基于 Appel
式回收实现,通过 jmap
命令: jmap -heap <pid>
即可查询当前虚拟机的堆内存分布,结果如下:
我们主要关注下表中的几个参数,可以看到 NewRatio=2
且 NewSize
与 OldSize
的比例正好为 1:2
。
需要注意一点老年代 (OldSize)
并非直接设置而来,而是通过 NewSize
与 NewRatio
共同进行控制,因此参数中并无 MaxOldSize
项。
参数 | 描述 |
---|---|
NewSize | 新生代的初始大小。 |
MaxNewSize | 新生代的最大值。 |
OldSize | 老年代的初始大小。 |
NewRatio | 新生代与老年代的大小比例,默认 1:2。 |
SurvivorRatio | Eden 与 Survivor 的比例,默认 8:2。 |
四、CMS收集器
CMS(Concurrent Mark Sweep)
是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿,一些官方公开文档里面也称之为并发低停顿收集器 (Concurrent Low Pause Collector)
。
CMS
垃圾收集的目标范围为整个新生代 (Minor GC)
、老年代 (Major GC)
,或整个 Java
堆 (Full GC)
。
1. 工作流程
CMS
收集器的执行流程共分为四步,具体工程流程如下:
初始标记:
CMS initial mark
,需要停止所有用户线程,仅标记GC Roots
能直接关联到的对象,整个过程耗时很短。并发标记:
CMS concurrent mark
,不需要停顿用户线程,从GC Roots
的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是可以与垃圾收集线程一起并发运行。重新标记:
CMS remark
,需要停止所有用户线程,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。并发清除:
CMS concurrent sweep
,不需要停顿用户线程,清理删除掉标记阶段判断的已经死亡的对象。
2. 触发阈值
在 CMS
中有一个较为关键的参数配置 -XX:CMSInitiatingOccupancyFraction
用于配置在什么时候触发 CMS
垃圾回收,当老年代的内存占用达到参数配置的百分比数值后将执行 GC
。
参数存在的原因很简单,在上面提到了 CMS
是并发标记即程序工作线程并未与 GC
线程是同时在运行,因此若待空间已满再执行显然 GC
线程将无法运行。参数具体的取值也十分重要,如果过小则会造成 GC
过度频繁,倘若太大又有可能空间不足导致 GC
执行失败,因此需要根据程序实际运行状态进行抉择。
3. 缺点不足
CMS
默认启动的回收线程数是 (处理器核心数量+3)/4
,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过 25%
的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,CMS
对用户程序的影响就可能变得很大。
CMS 回收器的缺点主要包含下述几点
CMS
在最后一步执行并发清除是基于标记清除的回收模式,因此会造成内存空间碎片化,后续若需要存入大对象时则会再次触发GC
造成性能损耗。- 在并发阶段它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。
- 在并发标记与并发清除的过程中用户线程仍在运行中,若在此期间的新生成的对象在本轮
GC
中无法被回收,只能等待下一次GC
回收。 - 在垃圾收集阶段用户线程仍在运行状态,则需要预留足够内存空间提供给用户线程使用,因此
CMS
收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用,通过XX:CMSInitiatingOccu-pancyFraction
参数设置GC
触发阈值(百分比)。
五、G1收集器
G1(Garbage First)
收集器基于标记整理模式实现,面向堆内存任何部分来组成回收集 (Collection Set)
进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1
收集器的 Mixed GC
模式。
1. 名词解释
(1) Region
G1
收集器将连续的 Java
堆划分为多个大小相等的独立区域(Region
),每一个 Region
都可以根据需要,扮演新生代的 Eden
空间、 Survivor
空间,或者老年代空间。
收集器能够对扮演不同角色的 Region
采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
(2) 记录集
由于 G1
收集器的回收集不再之前的完成的 Java堆
而是单独的 Region
,因此就会出现划分的不同 Region
之间可能存在相互引用的状况,为此 G1
中引入了记录集的结构。
每个 Region
都维护有自己的记忆集,这些记忆集会记录下别的 Region
指向自己的指针(记录每个 Region
间的相互引用),并标记这些指针分别在哪些卡页的范围之内。G1
的记忆集在存储结构的本质上是一种哈希表,Key
是别的 Region
的起始地址,Value
是一个集合,里面存储的元素是卡表(“我指向谁”与“谁指向我”)的索引号。
记录集的工作方式也让 G1
至少要耗费大约内存堆容量 10%
至 20%
的额外内存来维持收集器工作。
(3) Humongous
Region
中还有一类特殊的 Humongous
区域专门用来存储大对象,G1
收集器认为只要大小超过了一个 Region
容量一半的对象即可判定为大对象。
每个 Region
的大小可以通过参数 -XX:G1HeapRegionSize
设定,取值范围为 1MB~32MB
,对于那些超过了整个 Region
容量的超级大对象,将会被存放在 N
个连续的 Humongous Region
之中, G1
的大多数行为都把 Humongous Region
作为老年代的一部分来进行看待。
2. 工作流程
与 CMS
收集器类, G1
收集器的工作同样分为下列四步:
初始标记: 需要停顿线程,但耗时很短,仅仅只是标记一下
GC Roots
能直接关联到的对象。并发标记: 从
GC Root
开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。最终标记: 对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的
SATB
记录。筛选回收: 负责更新
Region
的统计数据,对各个Region
的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region
构成回收集,然后把决定回收的那一部分Region
的存活对象复制到空的Region
中,再清理掉整个旧Region
的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
3. 示例分析
同样,这边以具体的示例来介绍 G1
回收器的具体信息。
以 JDK 17
运行一个 demo
工程,通过 jhsdb jmap --heap --pid <pid>
命令打印程序的堆内存信息如下。
从图中可以看出在 Heap Configuration
部分与上述提到的 Appel
式回收大体上一致,额外多了一个 G1HeapRegionSize
用于配置单个 Region
的大小。
让我们将目光放到 G1
内存使用上,可以看到由于堆内存最大值为 8128MB
且单个 Region
大小为 4MB
,因此总共划分了 2032
个 Regison
块。
与 Appel
式回收相同的是 G1
也分为新生代 (Young Generation)
与老年代 (Old Generation)
,且新生代中又分 Eden
与 Survivor
区域,但不同是的每个区域以 Region
为单位划分更为细致。
如下图中所示当前 Eden
区域大小为 3
个 Region
,即内存空间为 3*4MB=12MB
。但你可能会发现 Eden
实际空间并不是 12MB
而是 32MB
,这是因为显示的内存空间还额外包含之前提到的记录集及其它内部结构,因此实际的内存空间相较于 Region
数量计算值会更大。
六、ZGC收集器
ZGC
收集器是一款在 JDK 11
中新加入的具有实验性质的低延迟垃圾收集器,是由 Oracle
公司研发的,目的是在尽可能对吞吐量影响不太大的前提下实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。
1. Region
ZGC
收集器中对 Region
实现了更细的划分,分为小、中、大型三种。
- 小型:容量固定为
2MB
,用于放置小于256KB
的小对象。- 中型:容量固定为
32MB
,用于放置大于等于256KB
但小于4MB
的对象。- 大型:容量不固定可以动态变化,但必须为
2MB
的整数倍,用于放置4MB
或以上的大对象,每个大型Region
中只会存放一个大对象。
2. 染色指针
染色指针是 ZGC
收集器的核心,在 64
位虚拟机中有 18
位是无法寻址的,ZGC
的染色指针技术将剩下的 46
位指针宽度中其高 4
位用于存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动 Remapped
)、是否只能通过 finalize()
方法才能被访问到。
ZGC
能够管理的内存不可以超过 4TB
(2 的 42次幂)。
3. 工作流程
(1) 并发标记
并发标记是遍历对象图做可达性分析的阶段,ZGC
的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的 Marked 0
、Marked 1
标志位。
(2) 并发预备重分配
这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些 Region
,将这些 Region
组成重分配集(Relocation Set
)。
ZGC
每次回收都会扫描所有的 Region
,用范围更大的扫描成本换取省去 G1
中记忆集的维护成本。因此 ZGC
的重分配集只是决定了里面的存活对象会被重新复制到其他的 Region
中,里面的 Region
会被释放,而并不能说回收行为就只是针对这个集合里面的 Region
进行,因为标记过程是针对全堆的。
(3) 并发重分配
这个过程要把重分配集中的存活对象复制到新的 Region
上,并为重分配集中的每个 Region
维护一个转发表(Forward Table
),记录从旧对象到新对象的转向关系。
得益于染色指针的支持,ZGC
收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据 Region
上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC
将这种行为称为指针的自愈(Self-Healing
)能力。
(4) 并发重映射
重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用。
但是 ZGC
的并发重映射并不是一个必须要迫切去完成的任务,因为前面说过,即使是旧引用,它也是可以自愈的,最多只是第一次使用时多一次转发和修正操作。重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束后可以释放转发表这样的附带收益),所以说这并不是很迫切。
参考文档:
- 《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》