在之前的几篇博客中,我们大致介绍了,常见的 垃圾回收算法 及 JVM
中常见的分类回收算法。这些都是从算法和规范上分析 Java
中的垃圾回收,属于方法论。在 JVM
中,垃圾回收的具体实现是由 垃圾回收器 ( Garbage Collector
)负责的。
在了解 垃圾回收器 之前,首先得了解一下垃圾回收器的几个名词。
CPU
用于运行用户代码的时间与 CPU
总消耗时间的比值。比如说虚拟机总运行了 100
分钟, 用户代码 时间 99
分钟, 垃圾回收 时间 1
分钟,那么吞吐量就是 99%
。
吞吐量 = 运行用户代码时间/(运行用户代码时间 + 垃圾回收时间)
停顿时间指垃圾回收器正在运行时, 应用程序 的 暂停时间 。对于 独占回收器 而言,停顿时间可能会比较长。使用 并发回收器 时,由于垃圾回收器和应用程序 交替运行 ,程序的 停顿时间 会变短,但是,由于其 效率 很可能不如独占垃圾回收器,故系统的 吞吐量 可能会较低。
指发生在 新生代 的垃圾回收动作,因为 Java
对象大多都具备 朝生夕死 的特性,所以 Minor GC
通常 非常频繁 ,一般回收速度也比较快。
指发生在 老年代 的垃圾回收动作,出现了 Major GC
,经常会伴随至少一次的 Minor GC
(发生这种情况,那么 整个堆 都 GC
一遍,通常称为 Full GC
)。 Major GC
的速度一般会比 Minor GC
慢 10
倍以上。
单线程进行垃圾回收工作,但此时 用户线程 仍然处于 等待状态 。
这里的并发指 用户线程 与 垃圾回收线程 交替执行。
这里的并行指 用户线程 和多条 垃圾回收线程 分别在不同 CPU
上同时工作。
根搜索算法是从 离散数学 中的图论引入的,程序把所有引用关系看作一张图,从一个节点 GC ROOT
开始,寻找对应的 引用节点 ,找到这个节点后,继续寻找 这个节点 的 引用节点 。当所有的引用节点寻找完毕后, 剩余的节点 则被认为是 没有被引用到 的节点,即 无用 的节点。
上图 红色 为无用的节点,可以被 回收 。目前 Java
中可以作为 GC ROOT
的对象有:
虚拟机栈中引用的对象(本地变量表);
方法区中 静态变量 引用的对象;
方法区中 常量 引用的对象;
本地方法栈中引用的对象( Native
对象)。
基本所有 GC
算法都引用 根搜索算法 这种概念。
标记-清除算法从 根集合 进行扫描,对 存活的对象 进行 标记 。标记完毕后,再扫描整个空间中 未被标记 的对象进行 直接回收 ,如下图所示:
标记-清除算法不需要进行 对象的移动 ,并且仅对 不存活 的对象进行处理,在 存活 的对象 比较多 的情况下 极为高效 。但由于 标记-清除算法 直接回收不存活的对象,并没有对还存活的对象进行 整理 ,因此会导致 内存碎片 。
复制算法将内存划分为 两个区间 ,使用此算法时,所有 动态分配 的对象都只能分配在 其中一个 区间( 活动区间 ),而 另外一个 区间( 空间区间 )则是 空闲 的。
复制算法同样从 根集合 扫描,将 存活 的对象 复制 到 空闲区间 。当扫描完毕活动区间后,会的将 活动区间 一次性全部 回收 。此时原本的 空闲区间 变成了 活动区间 。下次 GC
时候又会重复刚才的操作,以此循环。
复制算法在存活对象 比较少 的时候,极为高效,但是带来的成本是 牺牲一半的内存空间 用于进行 对象的移动 。所以 复制算法 的使用场景,必须是对象的 存活率非常低 才行。最重要的是,我们需要克服 50%
的 内存浪费 。
标记-整理算法采用 标记-清除算法 一样的方式进行对象的 标记 ,但在回收 不存活的对象 占用的空间后,会将所有 存活的对象 往 左端空闲空间 移动 ,并更新对应的指针。
标记-整理是在 标记-清除 之上,又进行了 对象的移动排序整理 ,因此 成本更高 ,但却解决了 内存碎片 的问题。
JVM
为了 优化内存 的回收,使用了 分代回收 的方式。对于 新生代内存 的回收( Minor GC
)主要采用 复制算法 。而对于 老年代内存 的回收( Major GC
),大多采用 标记-整理算法 。
在 JVM
中,具体实现有 Serial
、 ParNew
、 Parallel Scavenge
、 CMS
、 Serial Old(MSC)
、 Parallel Old
、 G1
等。在下图中,你可以看到 不同垃圾回收器 适合于 不同的内存区域 ,如果两个垃圾回收器之间 存在连线 ,那么表示两者可以 配合使用 。
如果当 垃圾回收器 进行垃圾清理时,必须 暂停 其他所有的 工作线程 ,直到它完全收集结束。我们称这种需要暂停工作线程才能进行清理的策略为 Stop-the-World
。以上回收器中, Serial
、 ParNew
、 Parallel Scavenge
、 Serial Old
、 Parallel Old
均采用的是 Stop-the-World
的策略。
图中有 7
种不同的 垃圾回收器 ,它们分别用于不同分代的垃圾回收。
新生代回收器:Serial、ParNew、Parallel Scavenge
老年代回收器:Serial Old、Parallel Old、CMS
两个 垃圾回收器 之间有连线表示它们可以 搭配使用 ,可选的搭配方案如下:
新生代 | 老年代 |
---|---|
Serial | Serial Old |
Serial | CMS |
ParNew | Serial Old |
ParNew | CMS |
Parallel Scavenge | Serial Old |
Parallel Scavenge | Parallel Old |
G1 | G1 |
Serial
回收器是最基本的 新生代 垃圾回收器,是 单线程 的垃圾回收器。由于垃圾清理时, Serial
回收器 不存在 线程间的切换 ,因此,特别是在单 CPU
的环境下,它的 垃圾清除效率 比较高。对于 Client
运行模式的程序,选择 Serial
回收器是一个不错的选择。
Serial
新生代回收器 采用的是 复制算法 。
Serial Old
回收器是 Serial
回收器的 老生代版本 ,属于 单线程回收器 ,它使用 标记-整理 算法。对于 Server
模式下的虚拟机,在 JDK1.5
及其以前,它常与 Parallel Scavenge
回收器配合使用,达到较好的 吞吐量 ,另外它也是 CMS
回收器在 Concurrent Mode Failure
时的 后备方案 。
Serial
回收器和 Serial Old
回收器的执行效果如下:
Serial Old
老年代回收器 采用的是 标记 - 整理算法 。
ParNew
回收器是在 Serial
回收器的基础上演化而来的,属于 Serial
回收器的 多线程版本 ,同样运行在 新生代区域 。在实现上,两者共用很多代码。在不同运行环境下,根据 CPU
核数,开启 不同的线程数 ,从而达到 最优 的垃圾回收效果。对于那些 Server
模式的应用程序,如果考虑采用 CMS
作为 老生代回收器 时, ParNew
回收器是一个不错的选择。
ParNew
新生代回收器 采用的是 复制算法 。
和 ParNew
回收一样, Parallel Scavenge
回收器也是运行在 新生代区域 ,属于 多线程 的回收器。但不同的是, ParNew
回收器是通过控制 垃圾回收 的 线程数 来进行参数调整,而 Parallel Scavenge
回收器更关心的是 程序运行的吞吐量 。即一段时间内, 用户代码 运行时间占 总运行时间 的百分比。
Parallel Scavenge
新生代回收器 采用的是 复制算法 。
Parallel Old
回收器是 Parallel Scavenge
回收器的 老生代版本 ,属于 多线程回收器 ,采用 标记-整理算法 。 Parallel Old
回收器和 Parallel Scavenge
回收器同样考虑了 吞吐量优先 这一指标,非常适合那些 注重吞吐量 和 CPU
资源敏感 的场合。
Parallel Old
老年代回收器 采用的是 标记 - 整理算法 。
CMS(Concurrent Mark Sweep)
回收器是在 最短回收停顿时间 为前提的回收器,属于 多线程回收器 ,采用 标记-清除算法 。
相比之前的回收器, CMS
回收器的运作过程比较复杂,分为四步:
初始标记仅仅是标记 GC Roots
内 直接关联 的对象。这个阶段 速度很快 ,需要 Stop the World
。
并发标记进行的是 GC Tracing
,从 GC Roots
开始对堆进行 可达性分析 ,找出 存活对象 。
重新标记阶段为了 修正 并发期间由于 用户进行运作 导致的 标记变动 的那一部分对象的 标记记录 。这个阶段的 停顿时间 一般会比 初始标记阶段 稍长一些,但远比 并发标记 的时间短,也需要 Stop The World
。
并发清除阶段会清除垃圾对象。
初始标记( CMS initial mark
)和 重新标记 ( CMS remark
)会导致 用户线程 卡顿, Stop the World
现象发生。
在整个过程中, CMS
回收器的 内存回收 基本上和 用户线程 并发执行,如下所示:
由于 CMS
回收器 并发收集 、 停顿低 ,因此有些地方成为 并发低停顿回收器 ( Concurrent Low Pause Sweep Collector
)。
CMS
回收器的缺点:
CMS
回收器过分依赖于 多线程环境 ,默认情况下,开启的 线程数 为 (CPU 的数量 + 3)/ 4
,当 CPU
数量少于 4
个时, CMS
对 用户查询 的影响将会很大,因为他们要分出一半的运算能力去 执行回收器线程 ;
由于 CMS
回收器 清除已标记的垃圾 (处于最后一个阶段)时, 用户线程 还在运行,因此会有新的垃圾产生。但是这部分垃圾 未被标记 ,在下一次 GC
才能清除,因此被成为 浮动垃圾 。
由于 内存回收 和 用户线程 是同时进行的,内存在被 回收 的同时,也在被 分配 。当 老生代 中的内存使用超过一定的比例时,系统将会进行 垃圾回收 ;当 剩余内存 不能满足程序运行要求时,系统将会出现 Concurrent Mode Failure
,临时采用 Serial Old
算法进行 清除 ,此时的 性能 将会降低。
CMS
回收器采用的 标记清除算法 ,本身存在垃圾收集结束后残余 大量空间碎片 的缺点。 CMS
配合适当的 内存整理策略 ,在一定程度上可以解决这个问题。
G1
是 JDK 1.7
中正式投入使用的用于取代 CMS
的 压缩回收器 。它虽然没有在物理上隔断 新生代 与 老生代 ,但是仍然属于 分代垃圾回收器 。 G1
仍然会区分 年轻代 与 老年代 ,年轻代依然分有 Eden
区与 Survivor
区。
G1
首先将 堆 分为 大小相等 的 Region
,避免 全区域 的垃圾回收。然后追踪每个 Region
垃圾 堆积的价值大小 ,在后台维护一个 优先列表 ,根据允许的回收时间优先回收价值最大的 Region
。同时 G1
采用 Remembered Set
来存放 Region
之间的 对象引用 ,其他回收器中的 新生代 与 老年代 之间的对象引用,从而避免 全堆扫描 。 G1
的分区示例如下图所示:
这种使用 Region
划分 内存空间 以及有 优先级 的区域回收方式,保证 G1
回收器在有限的时间内可以获得尽可能 高的回收效率 。
G1
和 CMS
运作过程有很多相似之处,整个过程也分为 4
个步骤:
初始标记仅仅是标记 GC Roots
内 直接关联 的对象。这个阶段 速度很快 ,需要 Stop the World
。
并发标记进行的是 GC Tracing
,从 GC Roots
开始对堆进行 可达性分析 ,找出 存活对象 。
重新标记阶段为了 修正 并发期间由于 用户进行运作 导致的 标记变动 的那一部分对象的 标记记录 。这个阶段的 停顿时间 一般会比 初始标记阶段 稍长一些,但远比 并发标记 的时间短,也需要 Stop The World
。
首先对各个 Region
的 回收价值 和 成本 进行排序,根据用户所期望的 GC
停顿时间 来制定回收计划。这个阶段可以与用户程序一起 并发执行 ,但是因为只回收一部分 Region
,时间是用户可控制的,而且停顿 用户线程 将大幅提高回收效率。
与其它 GC
回收相比, G1
具备如下 4
个特点:
使用多个 CPU
来缩短 Stop-the-World
的 停顿时间 ,部分其他回收器需要停顿 Java
线程执行的 GC
动作, G1
回收器仍然可以通过 并发的方式 让 Java
程序继续执行。
与其他回收器一样, 分代概念 在 G1
中依然得以保留。虽然 G1
可以不需要 其他回收器配合 就能独立管理 整个GC堆 ,但它能够采用 不同的策略 去处理 新创建的对象 和 已经存活 一段时间、 熬过多次 GC
的旧对象,以获取更好的回收效果。 新生代 和 老年代 不再是 物理隔离 ,是多个 大小相等 的独立 Region
。
与 CMS
的 标记—清理 算法不同, G1
从 整体 来看是基于 标记—整理 算法实现的回收器。从 局部 (两个 Region
之间)上来看是基于 复制算法 实现的。
但无论如何,这 两种算法 都意味着 G1
运作期间 不会产生内存空间碎片 ,回收后能提供规整的可用内存。这种特性有利于程序长时间运行, 分配大对象 时不会因为无法找到 连续内存空间 而提前触发 下一次 GC
。
这是 G1
相对于 CMS
的另一大优势, 降低停顿时间 是 G1
和 CMS
共同的关注点。 G1
除了追求 低停顿 外,还能建立 可预测 的 停顿时间模型 ,能让使用者明确指定在一个 长度 为 M
毫秒的 时间片段 内,消耗在 垃圾回收 上的时间不得超过 N
毫秒。(后台维护的 优先列表 ,优先回收 价值大 的 Region
)。
周志明,深入理解Java虚拟机:JVM高级特性与最佳实践,机械工业出版社
欢迎关注技术公众号:零壹技术栈
本帐号将持续分享后端技术干货,包括虚拟机基础,多线程编程,高性能框架,异步、缓存和消息中间件,分布式和微服务,架构学习和进阶等学习资料和文章。