深入理解java虚拟机之垃圾收集器
前言
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同的版本的虚拟机所提供的垃圾收集器都有可能会有很大的区别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。
相关系列博客:
上图中展示了不同年龄代的收集器,其中Serial、ParNew和Parallel Scavenge收集器作用于新生代,CMS、Parallel Old 和 Serial Old作用于老年代,G1在新生代和老年代都可以使用。不同的收集器之间如果有连线,则说明他们可以相互搭配使用。
相关概念
并行:指的是多条垃圾收集线程一起公共,但是此时用户工作线程仍处于等待状态。
并发:指的是用户线程和垃圾收集线程同时工作,也有可能是交替执行,用户程序在继续执行,而垃圾收集程序运行与另一个CPU上。
吞吐量:吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
Serial收集器
Serial收集器是一款串行执行的收集器,它是历史最悠久,也是最基本的收集器,采用复制算法实现的新生代收集器。在jdk1.3以前,Serial收集器是新生代唯一的选择。它是一个单线程执行的收集器,工作时只会知用一个cpu或线程区执行,更重要的是Serial在工作期间必须停掉所有的用户线程,直至垃圾收集完成,这一过程我们称之为“stop the world”。这项工作是由虚拟机自动执行和自动完成的,用户在不知情的情况下停掉了所有的线程,这对于一个最求响应速度来说简直是无法接受的。下图展示了Serial收集器在工作时的运行流程:
由于Serial收集器的工作模式是单线程的,自然就没有了多线程环境下线程切换带来的性能开销,所以该收集器在单线程环境下更加简单高效。
ParNew 收集器
Parnew是Serial收集器的多线程版本,也是新生代收集器。ParNew收集器和Serial收集器除了多线程工作外几乎是相同的,包括所有控制参数、收集算法、stop the world,对象分配规则,回收策略等都是一样的。运行流程如下图:
虽然与Serial收集器相比仅仅多了多线程特性外,没有其它的创新之处,但是它却是许多Server模式下的虚拟机新生代收集器的首选,原因在于目前为止只有Serial和ParNew两个新生代收集器能够与性能优异的CMS配合使用。关于CMS介绍将在下文展开描述。
Parallel Scavenge 收集器
Parallel Scavenge也是一款使用复制算法的新生代收集器。该收集器与其它收集器不同的是,它关注的目标是达到一个可控制的吞吐量,而CMS等收集器的关注点则是尽可能地减少用户线程地停顿时间,提高用户体验。Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。也因此,Parallel Scavenge 被成为“吞吐量优先”收集器。
停顿时间越短就越适合与用户交互较多地程序,这样用户体验才更好。而高吞吐量则可以让出更多的cpu资源给用户线程,让程序更快的完成运算任务,更适合后台运算较多而不需要与用户交互的程序。
自适应调节策略是Parallel Scavenge收集器的特点,也是与ParNew收集器的区别。Parallel Scavenge通过打开-XX:+UseAdaptiveSizePolicy的设置,就不需要手动地调节新生代(-Xmn)大小,Eden和Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数,而是根据当前系统运行情况来确定这些参数,从而提高程序地吞吐量和缩短停顿时间,这一过程称之为GC自适应的调节策略(GC Ergonomics)。
另外值得注意的一点是,Parallel Scavenge无法已CMS配合使用,如果新生代选择了Parallel Scavenge收集器,那么老年代的收集器只能选用Serial Old或者Parallel Old来配置使用。
Serial Old收集器
Serial Old是Serial收集器的老年代版本,也是单线程工作的,使用的是“标记-整理”算法。
该收集器主要用于Client模式下的虚拟机使用,如果在Server模式下可以与Parallel Scavenge收集器配合使用;作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。运行流程如下:
Parallel Old收集器
Parallel Old是Parallel Scavenge的老年代版本,也是一个并行收集器,使用“标记-整理”算法。该收集器在jdk1.6后对外提供使用,Parallel Scavenge 和 Parallel Old配合使用的话,更加适合应用于高吞吐量和cpu敏感资源的场合。下面是这两个收集器配合使用的运行流程:
CMS收集器
CMS(Concurrent Mark Sweep)是一个并发收集器,使用了“标记-清除”算法来实现的。该收集器最求的更短的停顿时间,从而提升用户体验,因此也非常符合使用在网站、B/S系统的服务端的应用。
CMS收集器的工作流程大概可以分为以下4个步骤:
- 初始标记:这个阶段仅仅标记能够和gc roots直接关联的对象,速度很快,但是需要“stop the world”。
- 并发标记:这个阶段开始进行gc roots tracing标记,与用户线程一起执行的,消耗时间很多。
- 重新标记:这个阶段是要是修正在并发标记期间由于用户线程也在运行而产生标记变动的那部分对象的标记,比较耗费的时间比初始标记阶段要长,但是远比并发标记阶段要短,这个过程也是需要“stop the world”的。
- 并发清除:对无用对象进行回收操作。这个过程与用户线程并行执行。
由于标记和清除阶段可以和用户线程一起工作,因此几乎可以把CMS收集器的工作是并发的:
CMS是一款优秀的收集器,它的主要优点是低停顿,并发收集,因此也被成为并发低停顿收集器(Concurrent Low Pause Collector)。
当然,CMS收集器也有一定的缺点,主要包括一下几点:
- CMS收集器使用“标记-清除”算法实现,因此不可避免地有内存碎片地问题。当内存碎片过多时,在分配大对象地过程中即使有足够的空间,但是找不到足够地连续的空间来放该对象,那么就有可能触发一次full gc。
- 无法处理浮动垃圾(Floating Garbage) 可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。这是因为在标记的过程中用户线程也在运行着,那么在这一过程中出现的垃圾无法立即回收,而是等下一次gc才能清理,我这部分的垃圾就叫做“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
- 对cpu资源非常敏感。其实,只要是面对并发的情况下都会有这个问题,在并发阶段虽然不会中断用户线程,但是因为占用了部分用户的资源而导致程序变慢,总吞吐量降低。CMS搜集器默认的线程数 = (cpu核数 + 3) / 4,当cpu数量大于4时,垃圾回收线程数不少于25%,随着线程数的增加而下降,当cpu数量小于4时对线程的执行效率有显著的影响。
运行示意图如下:
G1收集器
G1(Garbage-First)是一款面向服务端应用的垃圾收集器,JDK 7 Update4 后开始进入商用。HotSpot开发团队赋予它的使命是未来可以替换掉JDK 1.5中发布的CMS收集器。之前提供的收集器都是仅作用于新生代或者是老年代,但是G1收集器可以作用于新生代和老年代,因为使用G1收集器是java heap的内存结构有很大的不同,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但是他们已经没有了物理上的隔阂了,它们都是region的一部分的集合。
G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一,与其他收集器相比,G1收集器具有以下特征:
- 并行与并发: G1能充分利用多CPU,多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿时间,部分其他收集器原本需要停顿java线程执行的GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。
- 分代收集: 与其他收集器一样,分代概念在G1中仍然得以保留。虽然G1可以不需要其他收集器配合能够独立管理整个堆,但它能够采用不用的方式去处理新创的对象和已经存活了一段世纪那、熬过多次GC的旧对象以获得更好的收集效果。
- 空间整合: 与CMS的“标记-清除”算法不同,G1整体来看采用了“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的。无论使用哪一种方法,都意味着G1运作期间不会产生内存空间碎片的问题,收集后能提供规整的可用空间。这种特性有利于程序长时间运行,分配大对象是不会因为无法得到连续内存空间而提前处罚一次GC。
- 可预测的停顿: 这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了最求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎是java(RTSJ)的垃圾收集器的特征了。
G1收集器之所以能够建立可预测的停顿时间模型,因为他能够有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需要的经验值),在后台维护一个优先表,每次根据允许的收集时间,优先回收价值最大的Region。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
在G1收集器中,Region之间的对象引用以及其他收集器中的新生代和老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region中,如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region中的Remembered Set之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描,也不会有遗漏。
如果不计算维护Remembered Set的操作,G1收集器的运作大致分为以下几个步骤:
- 初始标记(Initial Marking): 这阶段仅仅只是标记GC Roots能直接关联到的对象并修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确的可用的Region中创建新对象,这阶段需要停顿线程,但是耗时很短。
- 并发标记(Concurrent Marking): 从GC Roots 开始对堆的对象进行可达性分析,找出存活的对象,这阶段耗时长,但是可以与用户程序并发执行。
- 最终标记(Final Marking): 为了修正在并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录记录在线程Remembered Set Logs里面。
- 筛选回收(Live Data Counting and Evacuation): 首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,这一阶段是可以与用户程序一起并发执行的,但是因为只回收部分Region,时间是用户可控的,而且停顿用户线程将大幅度提高收集效率。
执行流程如下图:
总结
收集器 | 串行、并行or并发 | 新生代/老年代 | 算法 | 目标 | 适用场景 |
---|---|---|---|---|---|
Serial | 串行 | 新生代 | 复制算法 | 响应速度优先 | 单CPU环境下的Client模式 |
Serial Old | 串行 | 老年代 | 标记-整理 | 响应速度优先 | 单CPU环境下的Client模式、CMS的后备预案 |
ParNew | 并行 | 新生代 | 复制算法 | 响应速度优先 | 多CPU环境时在Server模式下与CMS配合 |
Parallel Scavenge | 并行 | 新生代 | 复制算法 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
Parallel Old | 并行 | 老年代 | 标记-整理 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
CMS | 并发 | 老年代 | 标记-清除 | 响应速度优先 | 集中在互联网站或B/S系统服务端上的Java应用 |
G1 | 并发 | both | 标记-整理+复制算法 | 响应速度优先 | 面向服务端应用,将来替换CMS |
参考资料: 《深入理解Java虚拟机-JVM高级特性与最佳实践》 -周志明
喜欢我写的博客的同学可以关注订阅号【Java解忧杂货铺】,里面不定期发布一些技术干活,也可以免费获取大量最新最流行的技术教学视频