垃圾收集基础概念
垃圾回收器的简单工作流程
- 标记出哪些对象是存活的,哪些是可回收的垃圾
- 进行回收(清除/复制/整理),如果有移动过对象(复制/整理),还需要更新引用
三色标记法
从GC Roots开始进行可达性分析,可达的为存活对象。按“是否访问过”,标记成三种颜色:
白色:尚未访问过
黑色:本对象已访问过,本对象引用到的其他对象也全部访问过了
灰色:本对象已访问过,本对象引用到的其他对象尚未全部访问完
多标-浮动垃圾
假设已经遍历到E(变为灰色了),此时应用执行了objD.fieldE = null:
此时,E/F/G是应该被回收的,但因为E已经变成灰色了,它会继续被当做存活对象继续遍历下去=>这部分对象仍会被标记为存活,本轮GC不会回收这部分内存
漏标-读写屏障
假设GC线程已经遍历到E(变为灰色了),此时应用线程先执行了:
var G = objE.fieldG; // 1.读 读取对象E的成员变量fieldG的引用值(对象G)
objE.fieldG =null; // 2.写 对象E往其成员变量fieldG写入null值 灰色E 断开引用 白色G
objD.fieldG = G; // 3.写 对象D往其成员变量fieldG写入对象G 黑色D 引用 白色G
此时切回GC线程继续运行,因为E已经没有对G的引用了,所以不会将G放到灰色集合里;尽管D重新引用了G,但因为D已经是黑色了,不会再重新遍历
=>G会一直留在白色集合中,最后当做垃圾清理掉。会直接影响到程序的正确性
漏标只有同时满足下面两个条件才会发生:
1)灰色对象断开了白色对象的引用,即灰色对象原来成员变量的引用发生了变化
2)黑色对象重新引用了该白色对象,即黑色对象成员变量增加了新的引用
读写屏障的目的:在读写前后,将对象G记录起来,然后作为灰色对象再进行遍历。比如放入一个特定的集合,等初始的GC Roots遍历完(并发标记),对该集合进行遍历即可(重新标记)
写屏障
void oop_field_store(oop* field, oop new_value) {
// 写屏障-写前操作
pre_write_barrier(field);
*field = new_value;
// 写屏障-写后操作
post_write_barrier(field, value);
}
1)写屏障+SATB(Snapshot At The Beginning) - G1
当原来成员变量的引用发生变化之前,记录下原来的引用对象
思路:尝试保留开始时的对象图,即原始快照SATB,当某个时刻的GC Roots确定后,当时的对象图就已经确定了
SATB破坏了条件1:灰色对象断开了白色对象的引用
void pre_write_barrier(oop* field) {
// 处于GC并发标记阶段 且 该对象没有被标记(访问)过
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
// 获取旧值
oop old_value = *field;
// 记录 原来的引用对象
remark_set.add(old_value);
}
}
2)写屏障+增量更新 - CMS
当有新引用插入进来时,记录下新的引用对象
思路:不要求保留原始快照,而是针对新增的引用,将其记录下来等待遍历,即增量更新
增量更新破坏了条件二:黑色对象重新引用了该白色对象
void post_write_barrier(oop* field, oop new_value) {
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
// 记录新引用的对象
remark_set.add(new_value);
}
}
读屏障 - ZGC
当读取成员变量的时候,一律记录下来
条件二:黑色对象重新引用了该白色对象,重新引用的前提:获取该白色对象,此时读屏障就发挥作用了
oop oop_field_load(oop* field) {
// 读屏障-读取前操作
pre_load_barrier(field);
return*field;
}
void pre_load_barrier(oop* field, oop old_value) {
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
oop old_value = *field;
// 记录读取到的对象
remark_set.add(old_value);
}
}
垃圾收集器分类
经典垃圾收集器分代图
Serial收集器
单线程工作的新生代收集器,也就是垃圾收集的时候,必须暂停其他所有工作线程
是HotSpot虚拟机在客户端模式下的默认新生代收集器,对于内存资源受限的环境,是所有收集器里额外内存消耗最小的
对于单核处理器或处理器核心数较少的环境,由于没有线程交互的开销,可以获得最高的单线程收集效率
指定参数:-XX:+UseSerialGC
ParNew收集器
Serial收集器的多线程并行版本
除了Serial收集器外,只有它能有CMS收集器配合。是激活CMS后的默认新生代收集器
指定参数:-XX:+UseParNewGC
Parallel Scavenge收集器
基于标记-复制算法实现的新生代收集器,吞吐量优先收集器
目标是达到一个可控制的吞吐量(运行用户代码的时间/(运行用户代码的时间+运行垃圾收集时间)
高吞吐量可以最高效率地利用处理器资源,尽快完成程序的运算任务。适合在后台计算而不需要太多交互的分析任务
指定参数:-XX:+UseParallelGC
控制吞吐量的参数:
- 控制垃圾收集器最大停顿时间:-XX:MaxGCPauseMillis。收集器会尽力保证内存回收消耗的时间不超过用户设定值【停顿时间缩短以牺牲吞吐量和新生代空间为代价。新生代调小,收集会更快,但也会更频繁】
- 直接设置吞吐量大小: -XX:GCTimeRatio。垃圾收集占总时间的比率。默认99,即允许最大1% (1/(1+99))大垃圾收集时间
Serial Old收集器
Serial收集器的老年代版本
单线程收集器
标记-整理算法
Parallel Old收集器
Parallel Scavenge收集器的老年代版本
支持多线程并发收集
基于标记-整理算法
指定参数:-XX:+UseParallelOldGC
CMS(Concurrent Mark Sweep)收集器
以获取最短回收停顿时间为目标的收集器
基于标记-清除算法
指定参数:-XX:+UseConcMarkSweepGC
运作过程
- 初始标记(CMS initial mark)
STW,标记GC Roots能直接关联到的对象 - 并发标记 (CMS concurrent mark)
从GC Roots的直接关联对象开始遍历整个对象图的过程 - 重新标记 (CMS remark)
STW,修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录 - 并发清除 (CMS concurrent sweep)
清理删除标记阶段判断的已经死亡的对象。由于不需要移动存活对象,可以和用户线程同时并发
缺点
对处理器资源非常敏感
默认启动的回收线程数是(处理器核心数+3)/4,当处理器核心数量不足4个时,CMS对用户程序的影响会很大-
无法处理浮动垃圾
浮动垃圾:并发标记、并发清理阶段,用户线程还在继续运行,会伴随新的垃圾对象产生。一部分垃圾对象是在标记过程结束后,无法在档次收集中处理掉,只好留到下一次GC再清理因为GC时用户线程还持续运行,所以需要预留足够的内存空间给用户线程,不能像其他收集器那样等到老年代几乎被填满再进行收集。当CMS运行期间预留的内存无法满足分配新对象的需要,会出现一次“并发失败”(Concurrent Mode Failure),就需要冻结用户线程,临时启用Serial Old来重新进行老年代的垃圾收集
因为基于“标记-清除”算法,所以收集结束的时候可能会大量空间碎片。
可能会出现老年代还有很多空间,但无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。可以再Full GC的时候开启内存碎片的合并整理过程
G1(Garbage First)收集器
局部收集(可以面向堆内存任何部分来组成回收集)
基于Region的内存布局。把连续的Java堆划分为多个大小相等的独立区域(Region),每个region都可以根据需要扮演新生代的Edge、Survivor或者老年代空间
运作过程
- 初始标记(Initial Marking)
STW,标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。借助Minor GC的时候同步完成
注:TAMS, Top at Mark Start, 每个region有两个这样的指针,并发回收时新分配的对象都必须要在这两个指针上 - 并发标记(Concurrent Marking)
从GC Root开始对堆中对象进行可达性分析 - 最终标记(Final Marking)
STW,用于处理并发标记阶段期间因用户程序继续运行而导致标记变动的那一部分对象的标记记录 - 筛选回收(Live Data Counting and Evacuation)
STW,更新Region的统计数据,对各个Region的回收价值和成本进行排序;
根据用户期望的停顿时间制定回收计划,自由选择任意多个region构成回收集;
把决定回收的region的存活对象复制到空的region中,再清理掉整个旧Region的全部空间
指定参数:-XX:+UseG1GC(JDK 7u4 版本后可用)
优点
- 可以指定最大停顿时间
- 分Region的内存布局
- 按收益动态确定回收集
- 整体基于“标记-整理”算法,局部基于“标记-复制”算法,运行期不会产生内存空间碎片,有利于程序长时间运行
注:将Region作为单次回收的最小单元,每次收集到的内存空间都是Region大小的整数倍,可以有计划地避免在整个Java堆中进行全区域的垃圾收集
缺点
为了垃圾收集产生的内存占用、程序运行时的额外执行负载都比CMS高
注:每个region都必须有一份卡表,导致G1的记忆集会占用整个堆容量的20%+,而CMS的卡表只有唯一一份,只需要处理老年代到新生代的引用
小内存应用上CMS的表现大概率会优于G1, 大内存应用上G1则大多能发挥其优势。平衡点在6GB~8GB之间。后续G1还在不断优化中
ZGC收集器
基于Region内存布局的,不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的垃圾收集器
-XX:+UseZGC
从JDK11开始可用
内存布局:
和G1一样都是基于Region设计的
和G1的不同点:
G1的每个Region大小完全一样,而ZGC的Region大小分为3类:2MB, 32MB,N*2MB
- 小型Region: 2MB,用于放置小于256KB的小对象
- 中型Region: 32MB,用于放置大于等于256KB但小于4MB的对象
- 大型Region: 容量不固定,为2MB的整数倍,用于放置4MB或以上的大对象
染色指针(Colored Pointers)
以前的垃圾回收器的GC信息都保存在对象头中,而ZGC的GC信息保存在指针中,每个对象有一个64位指针
- 高18位:预留。1位(第46位)暂时没有使用,最高17位(第47~63位)固定为0
- 1位:Finalizable标识,与并发引用处理有关,表示这个对象只能通过finalizer才能访问
- 1位:Remapped标识,表示是否进入了重分配集(是否被移动过)
- 1位:Marked1标识
- 1位:Marked0标识,和上面的Marked1都是标记对象用于辅助GC
- 低42位(0~41位):对象的地址(可支持2^42=4T内存),用于描述真正的虚拟地址,应用程序可以使用的堆空间
标记记录位置
- 对象头(如Serial收集器)
- 与对象相互独立的结构(如G1)
- 引用对象指针上(ZGC)
优势
- 可以使得一个某个Region的存活对象被移走后,这个Region立即就能被释放和重用掉,不必等待整个堆中所有指向该Region的引用都被修正后才能清理
- 可以大幅减少垃圾收集过程中内存屏障的使用数量。设置内存屏障,尤其是写屏障,通常是为了记录对象引用的变动情况,维护在指针中,就可以省去一些专门的记录操作
- 可以作为一种可扩展的存储结构用于记录更多与对象标记、重定位过程相关的数据,以便日后进一步提供性能
// Address Space & Pointer Layout
// ------------------------------
//
// +--------------------------------+ 0x00007FFFFFFFFFFF (127TB)
// . .
// . .
// . .
// +--------------------------------+ 0x0000140000000000 (20TB)
// | Remapped View |
// +--------------------------------+ 0x0000100000000000 (16TB)
// | (Reserved, but unused) |
// +--------------------------------+ 0x00000c0000000000 (12TB)
// | Marked1 View |
// +--------------------------------+ 0x0000080000000000 (8TB)
// | Marked0 View |
// +--------------------------------+ 0x0000040000000000 (4TB)
// . .
// +--------------------------------+ 0x0000000000000000
//
//
// 6 4 4 4 4 4 0
// 3 7 6 5 2 1 0
// +-------------------+-+----+-----------------------------------------------+
// |00000000 00000000 0|0|1111|11 11111111 11111111 11111111 11111111 11111111|
// +-------------------+-+----+-----------------------------------------------+
// | | | |
// | | | * 41-0 Object Offset (42-bits, 4TB address space)
// | | |
// | | * 45-42 Metadata Bits (4-bits) 0001 = Marked0 (Address view 4-8TB)
// | | 0010 = Marked1 (Address view 8-12TB)
// | | 0100 = Remapped (Address view 16-20TB)
// | | 1000 = Finalizable (Address view N/A)
// | |
// | * 46-46 Unused (1-bit, always zero)
// |
// * 63-47 Fixed (17-bits, always zero)
多重映射
将多个不同虚拟内存地址映射到同一个物理内存地址上。是染色指针技术的伴生产物,自定义内存中某些指针中的几位(染色指针)时需要用到的(需要取消着色,屏蔽信息位)
染色指针中的标志位可以看做是地址的分段符,只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址
Marked0\Markd1\Remapped这三个视图都会映射到操作系统的同一个物理地址,通过染色指针来区别不同的虚拟视图
Marked0\Marked1\Remapped分别将第42、43、44位设置为1,就表示采用对应的视图。这三个空间在同一时间点有且仅有一个空间有效,空间切换是由垃圾回收的不同阶段触发的
运作过程
1. 并发标记(Concurrent Mark)
遍历对象图做可达性分析。前后也要经历类似于G1的初始标记、最终标记的短暂停顿。标记阶段会更新染色指针中的Marked0、Marked1标志位
停顿时间和堆大小无关,只能GC Roots数量有关
2. 并发预备重分配(Concurrent Prepare for Relocate)
根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本
3. 并发重分配(Concurrent Relocate)
把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表,记录从旧对象到新对象的转向关系。
ZGC能仅从引用上就得知一个对象是否处于重分配集中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问会被预置的内存屏障截获,立即根据Region上的转发表记录将访问转发到新复制的对象上,同时修正该引用的值,使其直接指向新对象。称为指针的“自愈”能力
4)并发重映射(Concurrent Remap)
修正整个堆中指向重分配集中旧对象的所有引用。
由于对象引用存在“自愈”功能,ZGC将并发重映射的工作合并到下一次垃圾收集循环中的并发阶段里取完成,节省了一次遍历对象图的开销
优点
- 低停顿 几乎所有过程都是并发的,只有短暂的STW
- 高吞吐量
- ZGC收集过程中额外耗费的内存小没有写屏障,卡表之类的
- 支持"NUMA-Aware"的内存分配
NUMA(Non-Uniform Memory Access,非统一内存访问架构),是一种为多处理器或者多核处理器的计算机所设计的内存架构。在NUMA架构下,ZGC会优先尝试在请求线程当前所处的处理器的本地内存上分配对象,以保证高效内存访问
缺点
浮动垃圾(没有分代的概念,每次都需要进行全堆扫描,导致一些“朝生暮死”的对象没能及时被回收。如果对象的分配速率很高,目前唯一的办法是增大堆的容量,使得程序得到更多的喘息时间)
垃圾收集器选择
除非应用有相当严格的暂停时间要求,否则可以直接运行应用,让VM自己选择一个收集器
有必要的话,调整堆大小来提升性能。如果性能仍然不能满足要求,可以使用下面的规则:
- 如果应用的数据集比较小(最多100MB), 使用-XX:+UseSerialGC选择serial收集器
- 如果应用运行在单核处理器上,并且没有暂停时间要求,使用-XX:+UseSerialGC选择serial收集器
- 如果吞吐量优先考虑,并且没有暂停时间要求,或者1秒或更长的暂停是可接受的,可以让VM选择收集器或者使用-XX:+UseParallelGC选择parallel收集器
- 如果响应时间比总吞吐量更重要,并且垃圾收集暂停时间必须保持较短的时间,使用-XX:+UseG1GC选择主要并发的收集器
- 如果响应时间优先级很高,并且/或者使用一个非常大的堆,使用-XX:+UseZGC选择完全并发的收集器
参考:
https://docs.oracle.com/en/java/javase/14/gctuning/introduction-garbage-collection-tuning.html
//www.greatytc.com/p/12544c0ad5c1 三色标记与读写屏障
《深入理解JAVA虚拟机》