3、垃圾收集与内存分配策略(2)(JVM笔记)
四、HotSpot的算法实现
4.1 枚举根节点
从可达性分析中从GC Roots
节点找引用链这个操作为例,可作为GC Roots
的节点主要在全局的引用(例如常量或类静态属性)与执行上下文(如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。
另外,可达性分析对执行时间的敏感还体现在GC
停顿上,因为这项工作必须在一个能确保一致性的快照中进行——这里“一致性”的意思是指整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。这点是导致GC
进行时必须停顿所有Java
执行线程的其中一个重要原因,机试在号称(几乎)不会发生停顿的CMS
收集器中,枚举根节点时页式必须停顿的。
由于目前的主流Java
虚拟机使用的都是准确式GC
(虚拟机可以知道内存中某个位置的数据具体是什么类型),所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。在HotSpot
的实现中,是使用一组称为OopMap
的数据结构来达到这个目的的,在类加载完成的时候,虚拟机就把对象内什么偏移量上是什么类型的数据计算出来,在JIT
编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC
在扫描时就可以直接得知这些信息了。
4.2 安全点
在OopMap
的协助下,HotSpot
可以快速且准确地完成GC Roots
枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说OopMap
内容变化的指令非常多,如果为每一条指令都生成对应的OopMap
,那将会需要大量的额外空间,这样GC
的空间成本将会变得很高。
实际上,HotSpot
也的确没有为每条指令都生成OopMap
,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置称为安全点(Safepoint
),即程序执行时并非在所有地方都能停顿下来开始GC
,只有在到达安全点时才能暂停。安全点的选定既不能太少以致于让GC
等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都非常短,所以程序不太可能因为指令流太长而长时间运行,能让程序长时间运行最明显的特征就是指令序列复用,如方法调用、循环跳转等,所以具有这些功能的指令才会产生安全点。
对于安全点,另一个需要考虑的问题是如何在GC
发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension
)和主动式中断(Voluntary Suspension
):
- 抢先式中断:不需要线程代码主动配合,在
GC
发生时,首先把所有线程全部中断,如果发现有线程中断的地方不再安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机采用。 - 主动式中断:是当
GC
需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
4.3 安全区域
在程序执行时,程序在不太长的时间内就可能遇到可进入GC
的安全点。但是当程序“不执行”的时候呢?所谓程序不执行就是没有分配CPU
时间,典型的例子就是处于睡眠或Blocked
状态,这种时候程序无法响应JVM
的中断请求。对于这种情况居需要安全区域来解决。
安全区域就是指在一段代码中,引用关系不会发生变化。在这个区域中的任意地方开始GC
都是安全的,可以将Safe Region
看作被扩展的Safepoint
。在线程执行到Safe Region
中的代码时,首先标识自己已经进入了Safe Region
,那样,当在这段时间里JVM
要发起GC
时,就不用管标识自己为Safe Region
状态的线程了。当线程要离开Safe Region
时,它要检查系统是否已经完成了根节点枚举,如果完成了,那线程继续执行,否则它就必须等待直到可以安全离开Safe Region
的信号为止。
五、垃圾收集器
Java
虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大差别。这里讨论的收集器基于JDK1.7 Update 14
之后的HotSpot
虚拟机(这个版本中正是提供了商用的G1
收集器,之前G1
处于实验状态)。这个虚拟机所包含的收集器如图所示。
图中如果两个收集器之间存在连线,就说明它们可以搭配使用。
5.1 Serial 收集器
此收集器是最基本、发展历史最悠久的收集器,曾经(在JDK 1.3.1
之前)是虚拟机新生代收集的唯一选择。这个收集器是一个单线程的收集器,但“单线程”的意义并不仅仅说明它只会使用一个CPU
或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。下图示意了Serial/Serial Old
收集器的运行过程。
从
JDK 1.3
开始,一直到新的JDK 1.7
,HotSpot
虚拟机开发团队为消除或者减少工作线程因内存回收而导致停顿的努力一直在进行着,开发了很多优秀的收集器,用户线程的停顿时间在不断缩短,但是仍然没有办法完全消除(这里暂不包括RTSJ
中的收集器)。
虽然此收集器有缺点,但是到现在为止,依然是虚拟机运行在Client
模式下的默认新生代收集器。有着优于其他收集器的地方:简单而高效(与其他收集器的单线程比),对于限定单个CPU
的环境来说,Serial
收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。对于运行在Client
模式下的虚拟机来说是一个很好的选择。
5.2 ParNew收集器
ParNew
收集器其实就是Serial
收集器的多线程版本(也是一个新生代收集器),除了使用多条线程进行垃圾收集外,其余行为包括Serial
收集器可用的所有控制参数(如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure
等)、收集算法、Stop The World
、对象分配规则、回收策略等都与Serial
收集器完全一样,共用了相当多的代码。工作过程如下:
此收集器除了多线程之外,与Serial
收集器相比并没有太多创新之处,但是它却是许多运行在Server
模式下的虚拟机中首选的新生代收集器,因为目前只有(除Serial
之外)它能与CMS
收集器(是HotSpot
中第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作)配合工作。
ParNew
收集器也是使用-XX:UseConcMarkSweepGC
选项后的默认新生代收集器,也可以使用-XX:UseParNewGC
选项来强制指定。此收集器在单CPU
的环境中绝对不会有比Serial
收集器更好的效果,可以使用-XX:ParallelGCThreads
参数来限制垃圾收集的线程数。
注意:
- 并行(
Parallel
):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态 - 并发(
Concurrent
):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会发生交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU
上。
5.3 Parallel Scavenge收集器
此收集器是一个新生代收集器,它也是使用复制算法的收集器,看上去和ParNew
收集器一样,但是有什么不同?
此收集器的特定是它的关注点与其他收集器不同,CMS
等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge
收集器的目标是达到一个可控制的吞吐量(Throughput
)。所谓吞吐量就是CPU
用于运行用户代码的时间与CPU
总消耗时间的比值,即吞吐量=运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间),虚拟机总共运行了一百分钟,其中垃圾收集花掉一分钟,那吞吐量就是99%
。
停顿时间越短越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量可以高效率利用CPU
时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
Parallel Scavenge
收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis
参数以及直接设置吞吐量大小的-XX:GCTimeRatio
参数。
MaxGCPauseMillis
参数允许的值是一个大于零的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。但是不是说这个值设置的越小,垃圾收集的速度就越快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间换取的:系统把新生代调小一些,收集300M
新生代肯定比收集500M
快,这也导致垃圾收集发生的更频繁,原来十秒收集一次、每次停顿一百毫秒,现在变成五秒收集一次、每次停顿七十秒,停顿时间的确下降了,但是吞吐量也下降了。
GCTimeRatio
参数的值应当是一个大于零小于一百的整数,相当于吞吐量的倒数。如果此参数值为19
,那允许垃圾收集的时间就占总时间的5%
(即1/(1+19)
),默认值是99
。
这个收集器是一个“吞吐量优先”的收集器,除了上述两个参数之外,还有一个参数-XX:UseAdaptiveSizePolicy
值得关注。这是一个开关参数。当这个参数打开之后,就不需要手工指定其他参数等细节了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最何时的停顿时间或者最大的吞吐量,这种调节方式称为GC
自适应的调节策略(GC Ergonomics
)。这是一个不错的选择,只需要把基本的内存数据设置好(如-Xmx
设置最大堆),然后使用MaxGCPauseMillis
参数(更关注最大停顿时间)或GCTimeRatio
(更关注吞吐量)参数给虚拟机设立一个优化目标,具体细节就不用管了。
5.4 Serial Old收集器
这个收集器是Seria
l收集器的一个老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。其主要意义是在于给Client
模式下的虚拟机使用。如果在Server
模式下,那么它主要还有两大用途:一种用途是在JDK 1.5
以及之前的版本中与Parallel Scavenge
搭配使用;另一用途就是作为CMS
收集器的后台预案,在并发收集发生oncurrent Mode Failure
时使用。其运行过程和Serial
收集器类似。
5.5 Parallel Old收集器
此收集器是Parallel Scavenge
收集器的老年代版本,使用多线程和“标记-整理”算法。在JDK 1.6
之前,如果新生代选择了Parallel Scavenge
收集器,则老年代必须选择Serial Old
收集器(Parallel Scavenge
无法与CMS
配合使用)。由于Serial Old
的拖累,这两者的组合在单线程的效率可能比不上ParNew
和CMS
的组合。这个收集器出现之后,“吞吐量优先”收集器终于有了名副其实的应用组合。其运行过程和Parallel Scavenge
类似。
5.6 CMS收集器
此收集器是一种以获得最短回收停顿时间为目标的收集器。大量用在B/S
系统的服务器上,因为这类应用注重响应速度,给用户较好的体验。
此收集器是基于“标记-清除”算法实现的,运行过程较为复杂,分为四个步骤:
-
初始标记(
CMS initial mark
)
这个步骤仍然需要“Stop The World”
,这一步仅仅只是标记一下GC Roots
能直接关联到的对象,速度很快。 -
并发标记(
CMS concurrent mark
)
这个步骤仍然需要“Stop The World”
,这个阶段是进行GC Roots Tracing
过程,就是以上一个阶段为基础,以上一阶段标记的所有对象为起点进行搜索,标记所有关联对象。时间最长的阶段之一。 -
重新标记(
CMS remark
)
这个阶段是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般比初始标记阶段稍长一些,但远比并发标记的时间短。 -
并发清除(
CMS concurrent sweep
)
对之前进行的步骤后的内存区进行清除。
由于整个过程中耗时最长的并发标记和并发清除过程,收集器都可以与用于线程一起工作,所以,总体上看,CMS
收集器的内存回收过程是与用户线程一起并发执行的。运行过程如下:
CMS
还远达不到完美的程度,它有一下三个明显的缺点:
-
CMS
收集器对CPU
资源非常敏感。其实,面向并发设计的程序都对CPU
资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说是CPU
资源)而导致应用程序变慢,总吞吐量会降低。CMS
默认启动的回收线程数是(CPU数量 + 3)/4
,也就是当CPU
在四个以上时(此时回收一个线程),并发回收时垃圾收集线程不少于25%
的CPU
资源,并随着CPU
数量的增加而下降。但是当CPU
不足四个时,CMS
对用户程序的影响就可能变得很大,为了应对这种情况,虚拟机提供了一种称为“增量式并发收集器”的CMS
收集器变种,其实就是一种抢占式来模拟多任务机制,让多个线程交替运行,尽量减少GC
线程独占资源的时间,这样整个垃圾收集的过程会变长,但是对用户程序的影响会变小,速度下降也就没那么明显了。实践证明,此变种很一般,现在已不再提倡使用。 -
CMS
收集器无法处理浮动垃圾(Floating Garbage
),可能出现“Concurrent Mode Failure”
失败而导致另一次Full GC
产生。由于CMS
并发清理阶段用户线程还在运行,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS
无法在档次收集中处理掉它们,只好留待下一次GC
时再清理掉。这一部分垃圾称为“浮动垃圾”。正是由于垃圾收集过程中,用户线程还要执行,那么也就需要预留足够的内存空间给用户线程使用,因此CMS
收集器不能像其他收集器那样等到老年代几乎完全填满后再进行垃圾收集,需要预留一部分空间提供并发收集时的程序运作使用。可以使用参数-XX:CMSInitiatingOccupancyFraction
来设置老年代的阈值,在JDK1.5
中设置为当老年代使用了68%
就会被激活进行垃圾收集,JDK1.6
中设置为92%
。要是CMS
运行期间预留的内存无法满足程序需要,居会出现一次“Concurrent Mode Failure”
失败,这时虚拟机将启动后备预案:临时启用Serial Old
收集器重新进行老年代的垃圾收集,此时停顿时间就会很长了。所以该参数设置得太高可能反而会降低性能。 -
由于
CMS
是一款基于“标记-清除”算法实现的收集器,在收集结束后会有大量的空间碎片产生,这将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象不得不提前触发一次Full GC
。为了解决此问题,CMS
收集器提供了一个-XX:+UseCMSCompactAtFullCollection
开关参数(默认是开启),用于在CMS
收集器顶不住要进行Full GC
时开启内存碎片的合并整理过程,内存整理的过程是无法并发执行的,空间碎片问题没有了,但停顿时间不得不变长。还提供了参数-XX:CMSFullGCsBeforeCompaction
,这个参数用于设置执行多少次不压缩(不整理)的Full GC
后,跟着来一次带压缩的(默认为零,表示每次进入Full GC
时都进行碎片整理)。