java虚拟机系列(三)- 垃圾收集器与内存回收策略

java虚拟机系列(三)- 垃圾收集器与内存回收策略

一、HotSpot算法实现

1.1 枚举根节点

可达性分析从GC Roots节点查找引用链期间(枚举根节点),不允许出现分析过程中对象引用关系还在不断变化的情况,所以要让整个执行系统这时好像要停顿在某个时间点上。这就要求在GC在工作时必须停顿所有的java工作线程(Stop the World)。

目前主流的java虚拟机使用的都是准确式GC,虚拟机有办法直接得知哪些地方存放着对象引用。在HotSpot实现中是用一组称为OopMap的数据结构来达到目的的,在类加载完成后,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来存入到OopMap中,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。

GC时通过扫描OopMap就能够快速标识出存活的对象。

1.2 安全点

如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样子GC的空间成本将会很高。

虚拟机不可能为每个指令都生成OopMap,只有在 “特定的位置(安全点:Safepoint)” 才会记录OopMap,也就是程序执行到安全点才会停顿下来开始GC。

安全点的位置主要在这些特定的位置如:

  • 方法临返回前 / 调用方法的call指令后
  • 循环末尾(例如任何一个for循环末尾,不是for循环结束)
  • 可能抛异常的位置

平时OopMap是压缩在内存中,只要当要GC的时候才会解压出来,然后开始遍历来扫描对应的偏移量。

此时需要考虑的问题是如何在GC发生时让所有的线程都“跑”到最近的安全点上再停顿下来。两种方案如下:

  • 抢占式中断: 在GC时,就把所有的线程全部中断,如果发现有线程中断的地方不在安全点上,就“恢复线程”让它跑到安全点上(现在几乎没有虚拟机采用这种方式)。

  • 主动式中断: 当虚拟机需要GC时,不直接对线程进行操作,仅设置一个标志,各个线程去轮询这个标志,发现中断标志为真时就自己中断挂起(轮询标志的地方和安全点是重合的)。

1.3 安全区域

安全区域是安全点的扩展,为了解决程序没有执行时(典型案例如:所有线程都Sleep或Blocked),线程无法响应JVM的中断请求,“走”到安全点去中断挂起的场景。

安全区域: 是指一块代码片段中,引用关系不会发生变化,在这个区域的任意地方开始GC都是安全的。

当线程执行到安全区域的代码块时,首先标识自己已经进入了安全区域,当这段时间JVM发起GC的时候就不用管标识自己已经进入安全区域状态的线程了(哪怕此时线程在安全区域中睡眠或者阻塞也不会对GC造成任何影响)。

二、垃圾收集器

如果说手机算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。HotSpot虚拟机包含了 7 种作用于不同分代的收集器。虚拟机所包含的所有收集器如下图所示。
java虚拟机系列(三)- 垃圾收集器与内存回收策略
两个收集器之间存在连线,就说明可搭配使用。

2.1 Serial收集器

Serial收集器是最基本、发展历史最悠久的收集器,也是使用复制算法的收集器。它是一个单线程收集器,它进行垃圾收集时必须暂停其它所有的工作线程“Stop The World”,直到它收集结束。到现在为止,它其实还是依然是虚拟机运行在Client模式(桌面应用)下的默认新生代收集器。

2.2 ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了多线程之外,与Serial收集器并没有太多创新之处,也是使用复制算法的收集器。还有一个与性能无关的很重要的原因就是除了Serial收集器目前只有它能与CMS收集器工作。在如果单CPU环境中绝对不会取得比Serial收集器更好的效果(ParNew收集器多线程交互引起不必要开销)。

2.3 Parallel scavenge收集器

Parallel scavenge收集器是一个新生代收集器,也是使用复制算法的收集器,并行的多线程收集器。它的目标是让程序达到一个可控制的吞吐量。由于跟吞吐量密切相关,也称之为“吞吐量优先”收集器。它拥有自适应调节策略(是与parnew收集器的一个重要区别),可以把内存管理的任务交给虚拟机去完成。

2.4 Serial old收集器

Serial old收集器是Serial收集器的老年代版本,同样是一个单线程收集器,使用“标记-整理”算法,主要用于在client模式下的虚拟机使用。

2.5 Parallel Old收集器

Parallel Old收集器是Parallel scavenge收集器的老年代版本,使用多线程“标记-整理”
算法,与Parallel scavenge收集器搭配使用形成了“吞吐量优先”的收集器应用组合,在注重吞吐量和CPU资源敏感的场合,都可以采用这种应用组合。

2.6 CMS收集器

CMS(Concurrent Mark Sweep)收集器是在JVM在GC时获取最短停顿时间为目标的收集器。CMS收集器是基于“标记-清除”算法实现的,过程分为以下四个步骤:

  • 初始标记(CMS initial mark): 标记GC Roots能直接关联到的对象,速度很快,需要“Stop The World”。
  • 并发标记(CMS concurrent mark): 对GC Roots 进行追踪的过程,找出存活的对象,耗时较长,不需要“Stop The World”,与工作(用户)线程并发执行。
  • 重新标记(CMS remark): 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要“Stop The World”,停顿时间一般会比初始标记阶段稍长。
  • 并发清除(CMS concurrent sweep): 清除死亡对象,与工作(用户)线程并发执行。

其中,初始标记和重新标记这两个步骤仍然需要“Stop The World”。
并发标记和并发清除两个阶段则不需要“Stop The World”,与用户线程并发工作。

CMS的优点:并发收集,低停顿。

CMS的缺点:

1. CMS收集器对cpu资源非常敏感,在并发阶段,它会因为占用一部分线程减少工作线程数量从而导致应用程序变慢,总吞吐量降低。

2. CMS收集器无法处理浮动垃圾,可能出现“concurrent mode failure”(并发模式失败)而导致另一次Full GC的产生。由于 CMS在并发清除阶段用户线程还在运行着,所以就会不断有新的垃圾产生,这部分“垃圾”出现在标记过程之后,CMS无法在当次集中处理掉他们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。

也是由于在垃圾收集阶段用户线程还在运行,所以需要预留有足够的内存空间给用户线程使用,因此无法像其它收集器那样等到老年代几乎被填满了再收集,需要预留一部分内存空间提供并发收集时程序运作使用。当老年代内存空间超过阈值时,就会触发**CMS收集器进行垃圾收集,可以通过 -XX:CMSInitiatingOccupancyFraction 的值来提高触发百分比。如果CMS运行期间,预留的内存无法程序所需,就会触发“concurrent mode failure”失败,这时虚拟机将启用后备预案:启用Serial Old收集器来重新进行老年代的垃圾收集。

3. CMS收集器收集后空间内存是不连续的,由于CMS是基于“标记-清除”算法实现的收集器,垃圾收集时有大量的空间碎片产生,当大对象来临时如果没有足够连续的内存空间将无法分配内存给大对象,不得不触发一次Full GC。

可以通过设置 -XX:+UseCMSCompactAtFullCollection 开关参数(默认就是开启),用于在CMS收集器顶不住要要进行Full GC时启动内存碎片的合并整理过程,内存碎片整理是没法并发的,空间碎片问题没有了,但停顿时间不得不变长。我们还可以通过 -XX:CMSFullGCsBeforeCompaction 参数来设置执行多少次不压缩的Full GC后,跟着来一次压缩(默认值为0,即每次进入Full GC时都进行碎片整理)。

2.7 G1收集器

G1(Garbage First)收集器是当今收集器技术最前沿成果之一,G1是一款面向服务端的垃圾收集器。它的使命是在未来可以替换CMS收集器。

G1收集器的特点:

  • 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势来缩短STW停顿时间,部分收集器原本需要停顿java线程执行的GC动作,G1收集器仍然可以并发的方式让java程序继续执行。
  • 分代收集:G1收集器中仍然保留着分代收集的概念,但不需要像其它收集器一样需要配合而是独立管理GC堆,它能够采用不同的方式去处理新创建的对象和已经存活的几个GC年代的旧对象以或得更好的效果。
  • 空间整合:从整体上看是基于“标记-整理”算法实现的收集器,从局部上看是基于复制算法实现的收集器。这两种算法垃圾收集后都能够提供规整连续的可用内存空间。分配大对象的时候不会一因为无法找到连续内存空间提前而提前触发下一次GC。
  • 可预测的停顿:是G1相对于CMS收集器的一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内消耗在垃圾集上的时间不能超过N毫秒。

在G1收集器之前的其它收集器进行收集的范围全部都是新生代和老年代,G1不再是这样。使用G1收集器时,java堆的内存布局就与其它收集器有很大的区别,它将整个java堆划分为多个大小相等的独立区域,虽然还存在新生代和老年代的概念,但却不再是物理隔离的,它们都是一部分区域的集合。

划分内存与优先级区域回收机制:G1之所以能够能建立可预测的停顿时间模型,是因为它可以有计划的避免在整个java堆中进行全区域的垃圾收集。G1追踪各个区域里面垃圾堆积的价值大小(即回收所获得的空间大小与回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的时间,优先回收价值最大的区域。

Remembered Set避免全堆扫描机制: 在G1收集器中,区域之间的对象引用及其他收集器中的新生代和老年代之间的对象引用虚拟机都是使用Remembered Set来避免全堆扫描。即在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。

G1收集器的大致步骤:

  • 初始标记:
    标记GC Roots能直接关联到的对象,速度很快,需要“Stop The World”
  • 并发标记: 对GC Roots 进行追踪的过程,找出存活的对象,耗时较长,不需要“Stop The World”,与工作(用户)线程并发执行。
  • 最终标记: 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(变化记录在Remembered Set Logs里面,并合并到Remembered Set中),需要“Stop The World”,停顿时间一般会比初始标记阶段稍长。
  • 筛选回收:
    对各个区域的回收价值和成本进行排序,根据用户期望的GC停顿时间来指定回收计划(Sun公司透漏出的消息,其实这个阶段可做到和用户程序并发执行)。

2.8 常见垃圾收集器参数总结

  • -XX:SurvivorRatio: 设置Eden区域与某Survivor区域容量的比值,默认为8(即Eden:Survivor = 1:8)。
  • -XX:NewRatio: 新生代和老年代的比值。
  • -XX:MaxTenuringThreshold: 晋升到老年代的年龄,默认为15,每个对象在坚持过Minor GC年龄就增加 1,当超过这个参数值就进入老年代。
  • -XX:CMSInitiatingOccupancyFraction: CMS收集器在老年代空间被使用多久后会触发垃圾收集器,默认是68%。
  • -XX:PretenureSizeThreshold: 超过该值的对象为大对象
  • -XX:MaxtenuringThreshold: 对象晋升老年代的年龄阈值,默认为15

三、内存分配与回收策略

java虚拟机自动内存管理机制所解决的两个问题:给对象分配内存,回收分配给对象的内存。

对象的内存分配,就是在堆上分配,对象主要分配在新生代的Eden区,如果启动了本地线程分配缓存(TLAB),将按线程优先在TLAB上分配,少数情况下会直接分配在老年代。

3.1 对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配,当Eden区没有足够的空间分配时,虚拟机将发起一次Minor GC。此时会有如下三种情况:

  • 长期存活的对象进入老年代: 正常情况下Minor GC之后对象被复制到Survivor空间(两个中的某一个Survivor),对象的年龄加 1,经过多次在Minor GC之后,对象年龄超过一定的值(晋升到老年代的年龄阈值,默认是15)时,对象将进入老年代。
  • Survivor空间不足存活对象进入老年代: Minor GC之后若Survivor没有足够的内存空间存放存活对象,那么将会通过分配担保机制提前将对象转移到老年代中。
  • 动态对象年龄判定: 为了更好的适应不同程序的内存情况,虚拟机并不是必须要求对象的年龄必须达到MaxTenuringThreshold中要求的年龄才能晋升老年代,如果经过minor GC之后将要进入Survivor空间中相同年龄的所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄。

3.2 大对象直接分配在老年代

所谓大对象是指需要大量连续内存空间的对象(很长的字符串以及数组如Byte数组),超过 -XX:PretenureSizeThreshold设置值的对象将会直接被分配到老年代。这样做的目的是避免了Eden区与两个Survivor区之间产生大量的内存复制。

3.3 空间分配担保

在发生minor GC之前,虚拟机会先检查老年代最大可用连续内存空间是否大于新生代所有对象的总空间。

  • 如果条件成立,那么minor gc就可以确保是安全的。
  • 如果条件不成立,虚拟机会查看HandlePromotionFailure是否允许担保失败(防止新生代所有对象都存活的情况)。

    (1)如果允许,那么虚拟机会继续检查老年代最大可用连续内存空间是否大于历次晋升到老年代对象的平均大小。如果大于将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于那么就改为进行一次Full GC。

    (2)如果不允许,那么就改为进行一次Full GC。

  • 参考资料:《深入理解java虚拟机》