概述
Java 与 C++ 之间有一堵由动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。说起 GC,大部分人都把这项技术当做 Java 语言的半生产物。事实上,GC 的历史比 Java 久远,1960 年诞生于 MIT 的 Lisp 是第一门真正使用内存动态分配和垃圾收集技术的语言。当 Lisp 还在胚胎时期时,人们就在思考 GC 需要完成的三件事:
- 那些内存需要回收?
- 什么时候回收?
- 如何回收?
回收的对象
首先肯定的是回收的对象应该是死了的,也就是说已经不会再被使用到的,回收活着的对象那不就会出现问题了嘛。如何判断对象是否还活着呢,算法有两种:
- 引用计数算法
- 可达性分析算法
下面便具体看下这两种算法。
引用计数算法
简单来说就是给对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值加一;反之,如果引用失效时,则计数器的值减一;任何时刻计数器为 0 的对象就是不可能再被使用的。
该算法实现简单,效率也高,但是无法解决对象间相互循环引用的问题,因此主流的 Java 虚拟机没有使用该算法来管理内存。栗子如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ReferenceCountingGC {
public Object instance = null;
public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instanct = objA;
objA = null;
objB = null;
/**
* 假设这里发生 GC, objA 和 objB 时无法被回收的
*/
System.gc();
}
}
可达性分析算法
该算法的基本思路是通过一系列的成为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径成为引用链,当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。如果下图,虽然 object4,5,6 虽然互相有关联,但是他们是 GC Roots 不可达的,所以他们将会被判定为可以被回收的对象。
Java 中,可以成为 GC Roots 的对象包括如下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法去中累静态属性引用的对象
- 方法区中敞亮引用的对象
- 本地方法中 JNI (Native 方法)引用的对象
垃圾收集算法
标记-清除算法
该算时收集算法最基础的算法,之后的算法都是基于这种思路不断改进得到的。该算法分为“标记”和“清除”两个阶段:首先标记所有需要回收的对象,在标记完成后统一回收所有被标记的对象,而标记至少经过两次:如果对象在进行可达性分析后发现没有于 GC Roots 相连接的引用链,那它将会被第一次标记并且判断是否需要执行 finalize() 方法,如果没有覆盖 finlize() 方法,活着已经被虚拟机调用过,都不会被再次执行。
该算法主要有两点不足:一个是效率问题,递归和全堆对象遍历,而且在进行 GC 的时候,需要停止应用程序;另一个是空间问题,标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后再程序运行过程中需要分配较大对象时,无法找到足够连续的内存而不得不提前触发一次垃圾收集动作。示意图如下:
复制算法
为了解决效率问题,复制收集算法出现了,它将可用内存划分为大小相等的两等分,每次只在一块内存上进行分配,等到没有足够空间时,会先将存活的对象复制到另一块未使用的区域,然后将已使用过的内存区域一次清清理掉就好了。每次只回收半个内存区域,内存分配时也不用考虑内存碎片的问题了,只需要移动堆指针,按顺序分配内存即可。实现简单,运行高效;只是将内存缩小为原来的一半,会有很大的浪费。
经研究发现,大部分对象都是“朝生夕死”的,因此现在的商业虚拟机在回收新生代时,并没有按照 1:1 的比例来划分内存空间,而是将内存划分为了一块较大的 Eden 区和两块空间较小的 Survivor 空间,每次使用 Eden 区和一块 Survivor 。当内存不够需要进行回收时,只需要将两块内存中存活的对象复制到另一块 Survivor 中去,然后将 Eden 和已使用过的 Survivor 一次性清理掉即可。HotSpot 虚拟机默认的 Eden 和 Survivor 的大小比例是 8:1(可以使用参数 -XX:SurviorRation 配置比例),因此是会有 10% 的内存会被“浪费”掉。
在特殊情况下,如果存活的对象所占内存大于 10% 的话,此时的 Survivor 内存空间不够容下这么多对象的,因此需要依赖老年代进行分配担保。如果另一块 Survivor 空间没有足够的空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。关于分配担保机制,在介绍垃圾收集器执行规则时会有更详细的介绍。
标记-整理算法
复制收集算法在对象存活率较高的时候就要进行较多的复制操作,效率会变低。更关键的是如果不想浪费 50% 的空间,就要有额外的空间进行分配担保,以应对使用的内存中 100% 对象都存活的极端情况,所以在老年代一般不能直接选中复制收集算法。
根据老年的特点,提出了“标记-整理”算法,标记过程仍然与“标记-清除”算法一样,后续步骤不是直接对可回收的对象清理,而是将存活对象向一端移动,然后直接清除端边界以外的内存。
分代收集算法
现在的商业虚拟机都是采用这种算法来进行垃圾回收的,该算法并没有什么新的思想,而是根据对象的存活周期将内存划分为几块,一般是把 Java 堆划分为新生代和老年代,然后根据各个代的特点采用最合适的收集算法。在新生代中进行垃圾回收时大部分对象都会死亡,只有小部分会存活下来,因此采用复制收集算法,而老年对象存活率较高且没有额外空间进行分配担保,因此采用“标记-清除”活着“标记-整理”算法进行回收。
垃圾收集器
如果说垃圾回收算法是内存回收的方法论,那么垃圾收集器便是内存回收的具体实现。Java 虚拟机规范中对垃圾收集器应该如何实现没有任何规定,因此不同的厂商、不同的版本的虚拟机所提供的垃圾收集器都可能会有很大的差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。这里讨论的收集器基于 JDK 1.7 Update 14 之后的 Hotspot 虚拟机(在这个版本中正式提供了上用的 G1 收集器,之前 G1 仍处于实验状态),这个虚拟机包含了所有收集器如图所示。
上图展示了作用于不同分带的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于某个新生代收集器还是老年代收集器。到目前为止,还没有最好的收集器出现,更没有万能的收集器,因此我们需要对具体应用选择更合适的收集器。
Serial 收集器
Serial 收集器是一个单线程的收集器,“单线程”的意义不仅仅说明他只会会用一个 CPU 或一条收集线程区完成垃圾收集工作,更重要的是在他进行垃圾收集是,必须暂停其他所有的工作线程,直到他收集结束。这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说都是很难接受的。
但它也有着由于其他收集器的地方:简单而高效(与其他收集器的单线程相比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。在用户的桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本上不会再大了),停顿时间完全可以控制在几十毫秒最多一百多毫秒以内,只要不频繁发生,这点停顿是可以接受的。所有,Serial 收集器对于运行在 Client 模式下的虚拟机来说是一个很好的选择。
ParNew 收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本,其余行为都与 Serial 收集器完全一样,实现上,这两种收集器也共用了相当多的代码。ParNew 收集器的工作过程如图所示。
ParNew 收集器除了多线程收集外,其他没有与 Serial 收集器相比并没有太多创新之处,但它是除了 Serial 收集器之外,目前只有它能与 CMS 收集器配合工作。使用 -XX:UseConcMarkSweepGC 选项后的默认新生代收集器便是 ParNew 收集器,也可以使用 -XX:UseParNewGC 选项来强制指定它。ParNew 收集器在单 CPU 的环境中绝不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器再通过超线程技术实现的两个 CPU 的环境中多不能百分之百保证可以超越 Serial 收集器。当然,随着可以使用 CPU 的数量增加,他对于 GC 时系统资源的有效利用还是很有好处的。
Parallel Scavenge 收集器
新生代收集器,也是使用复制算法,并行的多线程收集器,但是该收集器的目标是达到一个可控制的吞吐量。所谓的吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量=运行用代码时间/(运行用户代码时间+垃圾收集时间)。
Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的 -XX:GCTimeRatio 参数。MaxGCPauseMillis 参数允许的值是一个大于 0 的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。GC 停顿时间缩短是以牺牲吞吐量和新生代空间换区的:新生代的大小跟用户线程最大停顿值是成正比,跟收集时间成反比的,新生代的内存越小,收集的时间越快,收集的次数越多,吞吐量也会降下来。
收集器还有一个参数 -XX:+UseAdaptiveSizePolicy 值得关注,这是一个开关参数,这个参数打开后,就不需要手工设置新生代的大小、Eden 和 Survivor 区的比例、晋升老年代对象大小等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式成为 GC 自适应的调节策略。
Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本,单线程收集器,使用“标记-整理”算法。主要意义也是在于给 Client 模式下的虚拟机使用。如果在 Server 模式下,他还有两大主要用途:
- 在 JDK 1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用。
- 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
执行流程与 Serial 收集器完全一致。
Parallel Old 收集器
Parallel Scavenge 收集器的老年代版本,执行流程与 Parallel Scavenge 收集器完全一致。解决了如果新生代使用了 Parallel Scavenge 收集器老年代只能使用 Serial Old 收集器的尴尬境地。
CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。大部分应用在互联网站活着 B/S 系统的服务端上。从名字就可以看出,CMS 收集器是基于“标记-清除”算法的实现的,运行过程分为四个步骤:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
初始标记、重新标记这两个步骤仍需要“Stop The World”。初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,并发标记阶段就是进行 GC Roots Tracing 的过程,而重新标记阶段是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长些,但远比并发标记时间段。由于整个过程耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。
总体来说 CMS 收集器的有点便是:并发收集、低停顿。但是也有三个明显的缺点:
- 对 CPU 资源敏感,在并发阶段,虽然不会导致用户进程停顿,但是引用占用一部分线程(或者说 CPU 资源)而导致应用程序变慢,总吞吐量降低。
- 无法处理浮动垃圾,可能会出现 “Concurrent Mode Failure” 失败而导致另一次 Full GC 的产生。有 CMS 并发清理阶段用户线程还在运行,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法再档次收集中处理掉它们,只好留待下一次 GC 时在清理掉。而且 CMS 需要预留一部分空间提供并发收集时的程序运作使用,所以当老年代使用了一定空间后 CMS 就会被激活,这个阈值是通过参数 -XX:CMSInitiatingOccupancyFraction 来控制的,如果 CMS 运行期间预留的内存无法满足程序需要,就会出现一次 “Concurrent Model Failure” 失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿的时间就很长了。因此 -XX:CMSInitiatingOccupancyFraction 参数值如果设置的太高就容易导致大量 “Concurrent Mode Failure”失败,性能反而降低。
- CMS 收集器是基于 “标记-清除” 算法实现的收集器,就意味着收集结束后会产生大量内存碎片。当老年代还有很大剩余空间,但无法找到足够大的连续内存空间分配当前对象,不得不提前出发 Full GC。CMS 收集器提供了一个 -XX:+UseCMSCompactAtFullCollection 开关参数(默认开启),用于在 CMS 收集器顶不住要进行 Full GC 时开启内存碎片的整理过程,内存整的过程是无法并发的,从而导致停顿时间变长。虚拟机还设置了参数 -XX:CMSFullGCBeforeCompaction 用于设置多少次不压缩的 Full GC 后,跟着来一次带压缩(默认0,每次 Full GC都进行随便整理)
G1 收集器
G1 垃圾收集器的是一款面向服务端的垃圾收集器,他的使命是(在比较长期的)未来可以替换掉 CMS 垃圾收集器,。与其他 GC 收集器相比,G1 收集器具备如下特点:
- 并行与并发,充分利用多 CPU,多核环境下的硬件优势,使用多个 CPU(CPU或者CPU核心)来缩短 Stop-The-World 停顿的时间。
- 分代收集,不需要其他收集器配合就能独立管理整个 Java 堆。
- 空间整合:G1运行期间不会产生内存碎片,收集后能提供规整的可用内存。
- 可预测的停顿:见了可预测的停顿时间模型,让使用者指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不超过 N 毫秒。
使用 G1 收集器时,Java 堆的内存布局就与其他收集器有很大差别,将整个 Java 堆划分为多个大小的相等的独立区域(Region),新生代与老年代不再是物理隔离,他们都是 Region(不需要连续)的集合。G1收集器 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要的时间的经验值,)在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
在使用 G1 收集器中,Region 之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用 Remembered Set 来避免全堆扫描的。G1 每个 Region 都有一个与只对应的 Remember Set,虚拟机发现程序在对 Reference 类型的数据进行写操作时,会产生一个Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中(在分代的例子中就是检查是否是老年代中的对象引用了新生代中的对象),如果是便通过 CardTable 把相关引用信息记录到被引用对象所述的 Region 的 Remember Set 中。当进行内存回收时,在 GC 根节点的枚举范围中加入 Remember Set 即可保证不对全堆扫描页不会有遗漏。
如果不计算维护 Remember Set 的操作,G1 收集器的运作大致可划分为如下几个步骤:
- 初始标记(Initial Marking)
- 并发标记(Concurrent Marking)
- 最终标记(Final Marking)
- 筛选回收(Live Data Counting and Evacuation)
前三个步骤可以说与 CMS 收集器的执行步骤一致,最后 筛选回收 首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来指定回收计划。因为只回收一部分 Region,时间是用户可控制的,且停顿用户线程将大幅度提升效率。执行示意图如下:
ps: 以上知识点整理自 《深入理解 Java 虚拟机—JVM 高级特性与最佳实践》。