垃圾回收机制
分代回收理论
- 新生代:绝大部分的对象都是朝生夕死
- 老年代:熬过多次垃圾回收的对象就越难回收
GC 分类
- 新生代回收(Minor GC/Young GC)
- 老年代回收(Major GC/Old GC)
- 只有 CMS 垃圾回收器会有这个单独的回收老年代的行为
- Major GC 有说指是老年代,有说是做整个堆的收集
- 整堆回收(Full GC):收集整个 Java 堆和方法区(注意包含方法区)
STW (Stop The World)
进行垃圾回收时,必须暂停所有的工作线程,直到它回收结束,这个暂停称之为 STW,JVM 开发团队一直努力消除或降低 STW 的时间。
垃圾回收算法
新生代
复制算法(copying)- 1:1
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。
但是要注意:内存移动是必须实打实的移动(复制),所以对应的引用(直接指针)需要调整。
复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去的对象比较少,效率自然就高,另外一半的一次性清理是很快的。
复制算法 - Appel 式回收 - 8:1:1
一种更加优化的复制回收分代策略:具体做法是分配一块较大的Eden 区和两块较小的Survivor 空间(你可以叫做From 或者To,也可以叫做 Survivor1 和 Survivor2)
专门研究表明,新生代中的对象 98% 是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。
HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有10%的内存会被“浪费”。当然,98% 的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)
老年代
标记-清除算法(Mark-Sweep)
算法分为**“标记”和“清除”两个阶段:首先扫描所有对象标记出需要回收的对象,在标记完成后扫描回收所有被标记的对象,所以需要扫描两遍**。回收效率略低,如果大部分对象是朝生夕死,那么回收效率降低,因为需要大量标记对象和回收对象,对比复制回收效率要低。
它的主要问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。回收的时候如果需要回收的对象越多,需要做的标记和清除的工作越多,所以标记清除算法适用于老年代。
标记-整理算法(Mark-Compact)
首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。标记整理算法虽然没有内存碎片,但是效率偏低。
我们看到标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有引用对象的地方都需要更新(直接指针需要调整)。
三色标记
三色标记最大的好处是可以异步执行,可以以极少的中断时间或者没有中断来进行整个 GC。
三色
- 黑色:根对象,或者该对象与它的子对象都被扫描过
- 灰色:本身被扫描,但是还没扫描完该对象的子对象
- 白色:未被扫描对象,如果扫描完所有对象之后,最终为白色的为不可达对象,既垃圾对象
并发情况下的漏标问题
对比:G1 在处理并发标记的过程比 CMS 效率要高
-
G1 如果使用 Incremental Update 算法,因为变成灰色的成员还要重新扫,重新再来一遍,效率太低了。
常见的垃圾回收器
- 并行:垃圾收集的多线程的同时进行
- 并发:垃圾收集的多线程和应用的多线程同时进行
- 吞吐量:运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )
- 垃圾收集时间:垃圾回收频率 * 单次垃圾回收时间
- 垃圾回收器的三项指标:延时、吞吐量、内存占用
收集器 | 收集对象和算法 | 收集器类型 | 说明 | 场景 |
---|---|---|---|---|
Serial | 新生代 复制算法 | 单线程 | 简单高效;适合内存不大的情况 | |
ParNew | 新生代 复制算法 | 并行的多线程收集器 | 是 Serial 的多线程版 | 与 CMS 搭配 |
Parallel Scavenge | 新生代 复制算法 | 并行的多线程收集器 | 类似 ParNew,面向吞吐量 | 是 Server 级别多 cpu 机器上的默认 GC 方式,主要适合后台运算且不需要太多交互的任务 |
Serial Old | 老年代 标记 - 整理算法 | 单线程 | Client 模式下虚拟机使用 | |
Parrallel Old | 老年代 标记 - 整理算法 | 并行的多线程收集器 | 面向吞吐量 | 在注重吞吐量以及 cpu 资源敏感的场合使用 |
CMS | 老年代 标记 - 清除算法 | 并行与并发收集器 | 尽可能地缩短 STW,缺点: 1. 至少 4 核 cpu 2. 内存碎片,使用 Serial Old 整理碎片 3. 浮动垃圾,需要更大的堆空间,6G 以上 | 重视服务的响应速度、系统停顿时间和用户体验的互联网网站或者B/S系统。互联网后端目前 CMS 是主流的垃圾回收器。 |
G1 | 全部 化整为零 Region 标记 - 整理 & 复制 | 并行与并发收集器 | 采用分区回收的思维,基本不牺牲吞吐量的前提下完成低停顿的内存回收;可预测的停顿是其最大的优势 | 面向服务端应用的垃圾回收器,目标为取代 CMS |
Serial/Serial Old(SerialGC)
开启:-XX:+UseSerialGC
,串行垃圾收集器,几十兆 ~ 一两百兆
JVM 刚诞生就只有这种,最古老的,单线程,独占式,成熟,适合单 cpu,一般用在客户端模式下使用。这种垃圾回收器只适合几十兆到一两百兆的堆空间进行垃圾回收,可以控制停顿时间再100ms 左右,但是对于超过这个大小的内存回收速度很慢。
Parallel Scavenge/Parallel Old(ParallerGC)
开启:-XX:+UseParallelGC
,并行垃圾收集器(吞吐量优先垃圾回收器),上百兆~几G
为了提高回收效率,从 JDK1.3 开始,JVM 使用了多线程的垃圾回收机制,关注吞吐量的垃圾收集器,高吞吐量则可以高效率地利用 cpu 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。适合回收堆空间上百兆~几个G。
参数
-XX:MaxGCPauseMillis
最大停顿时间-XX:GCTimeRatio
垃圾收集时间占总时间的比率-XX:+UseAdaptiveSizePolicy
动态调整(默认开启)- 不需要人工指定新生代的大小(
-Xmn
)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio
)、晋升老年代对象大小(-XX:PretenureSizeThreshold
)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
- 不需要人工指定新生代的大小(
ParNew/Concurrent Mark Sweep(CMSGC)
开启:-XX:+UseConcmarkSweepGC
,第一个并发垃圾收集器,几G ~ 20G
-
ParNew 是多线程垃圾回收器。和 Serial 唯一的区别是多线程,停顿时间比 Serial 少。
- 在 JDK9 以后,把 ParNew 合并到了 CMS 了。
-
CMS 是一种以获取最短回收停顿时间为目标的收集器,重视服务的响应速度。由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以总体上看CMS 的内存回收过程是与用户线程一起并发执行的。
- 在 JDK1.8 以后,CMS 不能与 Serial 配对使用,只能调用 Serial Old。在 JDK9 CMS 进入废弃倒计时,在 JDK14 正式移除 CMS。
流程
缺点
参数
-XX:+UseCMSCompactAtFullCollection
无法分配大对象时使用 Serial Old 进行碎片整理(默认开启)- CMS 会有内存碎片,给大对象的分配带来很大的麻烦
- 如果分配不了大对象,就进行内存碎片的整理过程。这个地方一般会使用 Serial Old ,因为 Serial Old 是一个单线程,所以如果内存空间很大、且对象较多时,CMS 发生这样的情况会很卡。
为什么 CMS 采用标记 -清除
在实现并发的垃圾回收时,如果采用标记整理算法,那么还涉及到对象的移动,对象的移动必定涉及到引用的变化,这个需要暂停业务线程来处理栈信息,这样使得并发收集的暂停时间更长,所以使用简单的标记-清除算法才可以降低 CMS 的 STW 的时间。
Garbage First(G1)
开启:-XX:+UseG1GC
,并发垃圾收集器,6G ~ 上百G
大于 6G 直接用 G1,不用 CMS,省事,没那么多烦恼。
设计思想(采用 Region)
为了实现 STW 的时间可预测,G1 将堆内存“化整为零”,将堆内存划分成多个大小相等独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。回收器能够对扮演不同角色的 Region 采用不同的策略去处理。
Region
- 新生代(Eden,Survivor)
- 老年代
- Old
- Humongous
- 存储大对象,大小超过了一个 Region 容量一半的对象即可判定为大对象
- 超过了 Region 容量的大对象,会被存放在 N 个连续的 Humongous Region 之中
流程
- 初始标记(Initial Marking),STW:短暂;TAMS
- 仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。
- 这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。
- TAMS:要达到 GC 与用户线程并发运行,必须要解决回收过程中新对象的分配,所以 G1 为每一个 Region 区域设计了两个名 TAMS (Top at Mark Start)的指针,从 Region 区域划出一部分空间用于记录并发回收过程中的新对象。这样的对象认为它们是存活的,不纳入垃圾回收范围。
- 并发标记( Concurrent Marking):三色标记产生漏标
- 最终标记( Final Marking),STW:短暂;处理 SATB 记录
- 处理并发阶段结后仍遗留下来的最后那少量的 SATB 记录(漏标对象)。
- 筛选回收( Live Data Counting and Evacuation),STW:根据用户所期望的停顿时间来制定回收计划
特点
- 并行与并发
- 分代收集
- 空间整合
- 追求停顿时间
- G1 尝试调整新生代和老年代的比例,堆大小,晋升年龄来达到这个目标时间。
参数
-XX:G1HeapRegionSize
: Region 大小,1MB ~ 32MB ,2 的 N 次幂-XX:MaxGCPauseMillis
: 最大 GC 暂停时间
低延迟的垃圾回收器
- Eplison:不能进行垃圾回收
- ZGC:类似于G1 的 Region;染色指针;STW 小于 10ms
- Shenandoah:类似于G1 的 Region;染色指针;STW 几十毫秒
- 第一款非 Oracle 公司开发的垃圾回收器,有类似于 G1 的 Region,但是没有分代。也用到了染色指针 ColoredPointers。效率没有 ZGC 高,大概几十毫秒的目标。
- JDK 15 上线
RSet/CardTable
处理跨代引用或 G1 中的跨 Region 引用的问题。
RSet(记忆集)
RSet 本身就是一个 Hash 表。RSet 的价值在于使得垃圾收集器不需要扫描整个堆,找到谁引用了当前分区中的对象,只需要扫描 RSet 即可。
- G1:每个 Region 都有,非常消耗空间
- CMS:只有一份
CardTable(卡表)
如果一个老年代 CardTable 中有对象指向新生代, 就将它设为 Dirty(标志位1), 下次扫描时,只需要扫描 CardTable 上是 Dirty 的内存区域即可。
字节数组 CARDTABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以 2 的 N 次幂的字节数,假设使用的卡页是 2 的 10 次幂,即 1K,内存区域的起始地址是 0x0000 的话,数组 CARD_TABLE 的第 0、1、2 号元素,分别对应了地址范围为 0x0000~0x03FF、0x0400 ~ 0x07FF 0x0800~0x011FF 的卡页内存。
- G1:按 Region 划分,所以要求 Region 大小为 2 的 N 次幂
- CMS:按 2 的 N 次幂划分
安全点与安全区域
安全点
用户线程暂停,GC 线程要开始工作,但是要确保用户线程暂停的这行字节码指令是不会导致引用关系的变化。所以 JVM 会在字节码指令中,选一些指令,作为“安全点”,比如方法调用、循环跳转、异常跳转等,一般是这些指令才会产生安全点。
为什么它叫安全点,是这样的,GC 时要暂停业务线程,并不是抢占式中断(立马把业务线程中断)而是主动是中断。
主动式中断是设置中断标志,各业务线程在运行过程中会不停的主动去轮询这个标志,一旦发现中断标志为True,就会在自己最近的“安全点”上主动中断挂起。
安全区域
要是业务线程都不执行(业务线程处于Sleep 或者是Blocked 状态),那么程序就没办法进入安全点,对于这种情况,就必须引入安全区域。
安全区域是指能够确保在某一段代码片段之中, 引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区城看作被扩展拉伸了的安全点。
- 如果完成了,那线程就当作没事发生过,继续执行。
- 否则它就必须一直等待, 直到收到可以离开安全区域的信号为止。
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 [email protected] 举报,一经查实,本站将立刻删除。