1. 新生代收集器
1.1 Serial 收集器
Serial 收集器在JDK 1.3之前是HotSpot虚拟机新生代收集器的唯一选择,也是一个单线程工作的垃圾收集器。
由于它没有线程交互的开销,专心做垃圾收集可以获得更高的单线程收集效率,所以Serial 收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择,也是至今Hotspot虚拟机运行在客户端模式下的默认新生代收集器。
1.2 ParNew 收集器
ParNew 收集器本质就是 Serial 收集器的多线程版本,除了同时使用多条垃圾回收线程进行收集之外,其余的行为包括 Serial收集器可用的参数、收集算法、Stop the World、对象分配规则等都与Serial收集器完全一致。
ParNew 收集器在单核心处理器的环境中不会有比Serial收集器更好的效果,甚至由于线程交互等开销,它的性能可能还会低于Serial收集器;
ParNew 收集器 是目前为止除了Serial收集器之外,唯一能与CMS收集器配合工作的新生代收集器;
JVM参数指定 ParNew 收集器:-XX:+UseParNewGC;
1.2.1 默认线程数量
现在一般我们部署应用的服务器都是多核CPU的,所以当使用 ParNew 收集器时,它默认的垃圾回收线程数量是跟CPU核数是一样的;这个参数一般不要手动去调节。
如果一定要手动调节,可以使用JVM 参数:-XX:ParallelGCThreads;
1.2.2 运行示意图
1.3 Parallel Scavenge 收集器
Parallel Scavenge 收集器(也叫做 吞吐量优先收集器)也是能够并行收集的多线程收集器,它的特点是:关注点跟其他收集器不同,它的目标是达到一个可控制的吞吐量(Throughput),也就是处理器用于运行用户代码的时间与总消耗时间的比值;即:
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 运行垃圾收集时间)
吞吐量越高,则可以最高效率的利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
1.3.1 控制吞吐量
Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量:
-XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间,收集器将尽力保证内存回收花费的时间不超过这个设定值;
-XX:GCTimeRatio:直接设置吞吐量大小,是一个大于0小于100的正数,也就是垃圾收集时间占总时间的比值;
1.3.2 运行示意图
2. 老年代收集器
2.1 Serial Old 收集器
Serial Old 收集器是Serial收集器的老年代版本,同样也是一个单线程收集器,使用 标记-整理算法。
这个收集器主要是也供客户端模式下的HotSpot虚拟机使用;如果在服务端模式下,有两种用途:
- 在JDK 5 及之前的版本中与 Parallel Scavenge 收集器 搭配使用;
- 在CMS 收集器并发收集时发生 Concurrent Mode Failure 时使用;
2.1.1 运行示意图
2.2 Parallel Old 收集器
Parallel Old 是Parallel Scavenge 收集器的老年代版本,同样支持多线程并发收集,是基于 标记-整理 算法实现的。
2.2.1 运行示意图
2.3 CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种 以获取最短回收停顿时间为目标的收集器,基于 标记-清除算法 实现。
2.3.1 CMS垃圾回收流程
示例代码:
回收流程:
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweep)
2.3.1.1 初始标记(CMS initial mark)
CMS要进行垃圾回收时,要先执行初始标记阶段,这个阶段会让系统的工作线程全部停止,进入“Stop the World”状态:
这里的初始标记,是标记出 所有的GC Roots 直接引用的对象;
示例代码中,静态变量 replicaManager这个GC Roots直接引用的 ReplicaManager 对象才会被标记,而 ReplicaFetcher对象不是被直接引用的就不会被标记。(因为类中的实例变量不是 GC Roots)
初始标记阶段 速度很快,耗时很短,因为只需要标记 GC Roots直接引用的对象。
2.3.1.2 并发标记(CMS concurrent mark)
并发标记阶段,不会进入 “Stop the World”状态,允许系统的工作线程继续工作;在这个阶段,可能会有新创建的对象,也有可能部分前面存活的对象失去引用变成垃圾对象。
在这个阶段中,会对老年代所有的对象进行 GC Roots追踪,也就是说 会标记出 所有的 GC Roots直接和间接引用的对象;
示例代码中,ReplicaFetcher对象被 replicaFetcher变量引用了,而 replicaFetcher变量是 ReplicaManager对象的实例变量;所以会继续追踪,ReplicaManager对象被谁引用了,又追踪到 ReplicaManager对象是被 replicaManager这个静态变量引用了;而这个静态变量是 GC Roots,所以可以判定 ReplicaFetcher对象是被 GC Roots间接引用的,所以也会把它标记为存活对象,不需要回收它。
并发标记阶段 速度很慢,非常耗时,因为要追踪所有对象是否从根源上被 GC Roots引用了;但是又因为允许系统并发运行,所以这个阶段不会对系统运行造成太大影响。
2.3.1.3 重新标记(CMS remark)
在并发标记阶段里,一边标记存活对象和垃圾对象,一边系统也在一直工作,会产生新对象和让老对象变成垃圾对象;所以在这个运行期间会有很多存活对象和垃圾对象是没有被标记的:
所以这个时候,需要让系统停下来,也就是进入“Stop the World”状态,重新去标记 在并发标记阶段状态变更了的一些对象:
重新标记阶段 速度很快,耗时很短,因为只需要标记并发阶段中被系统运行变动过的少数对象。
2.3.1.4 并发清除(CMS concurrent sweep)
并发清除阶段不会进入 “Stop the World”状态,允许系统随意运行,只需要清理掉之前标记为垃圾的对象即可。
并发清除阶段 非常耗时,因为需要进行对象的清理,但是允许系统并发运行,所以对系统程序的执行没有太大影响。
Q:为什么CMS要用 标记-清除 算法,而不用 标记-整理 算法?
A:这个可以从CMS的目标来思考,CMS收集器的目标是 获取最短回收停顿时间;
- 如果使用 标记-整理 算法的话,在标记阶段用户线程可以和垃圾回收线程并发执行,但是在整理阶段,用户线程不能和垃圾回收线程并发执行,这样就会导致停顿时间过长,不能达到CMS的追求目标;
- 对于CMS使用的 标记-清除 算法,在标记和清除阶段,用户线程都可以和垃圾回收线程并发执行,也就不会使得用户线程停顿时间过长,从而达到 获取最短回收停顿时间的目标;
- 这里贴上 R大对于这个问题的详细解答:并发垃圾收集器(CMS)为什么没有采用标记-整理算法来实现?
2.3.2 CMS 性能分析
从CMS的四个回收流程可以看出来,JVM已经尽可能的进行了性能优化了;
其中最耗时的两个阶段:
并发标记阶段,对老年代所有对象进行 GC Roots的追踪,标记出直接引用和间接引用的对象;
并发清除阶段,对各种垃圾对象进行清理;
但是这两个阶段都是允许用户线程并发运行的,所以对系统的运行影响也较小了;
初始标记 和 重新标记阶段,需要 “Stop the World”,但是这两个阶段只是进行简单的标记,执行速度非常快,所以基本上对系统运行影响也不大。
2.3.3 CMS 运行示意图
2.3.4 Parnew + CMS 的痛点
其实Parnew + CMS 的垃圾回收器组合最大的痛点还是 “Stop the World”。无论是新生代垃圾回收,还是老年代垃圾回收,都会产生“Stop the World”,然后对系统的运行造成影响。
即使JVM已经在尽力进行优化了,但还是存在不小的问题;在之后垃圾回收器的优化,都是朝着减少“Stop the World”的目标去做的。
3. G1 收集器
G1垃圾回收器是可以同时回收新生代和老年代的对象的,不再需要两个垃圾回收器配合起来运作,它自己就可以搞定所有的垃圾回收。
与其他的GC收集器相比,G1具备如下几个特点:
并行与并发:G1可以使用多个垃圾回收线程并行收集,缩短 Stop the World的时间;
G1可以通过与工作线程并发执行的方式,减少对系统的影响;
- 分代收集:分代的概念在G1中依然保留,虽然G1不需要与其他收集器配合,但是它能够采用不同的方式去处理 新生代和老年代中的不同对象;
- 空间整合:G1整体上是基于 标记-整理算法实现的,但是局部(两个Region之间)上是基于 复制算法实现的;所以G1在运行期间不会产生内存碎片,收集后能提供规整的可用内存;
- 可预测的停顿时间:G1在追求低停顿的同时,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒;
3.1 G1的实现方式
3.1.1 堆内存划分
G1收集器在内存划分的实现方式上和其他收集器几乎完全不同;在使用G1收集器时,将Java堆划分成为多个大小相等的独立区域(Region) ;虽然还保留了新生代和老年代的概念(逻辑概念),但是新生代和老年代都不再是隔离的了,而都是一部分Region的集合,并且可以动态变化。
每个 Region都被标记了 E、S、O、H,说明每个Region在运行时都充当了一种角色(新生代、老年代、大对象等);
H是以往算法中没有的,它代表Humongous,这表示这些Region存储的是巨型对象(humongous object,H-obj);
3.1.2 避免全堆扫描
在G1收集器中,Region之间的对象引用(以及其他收集器中的新生代与老年代之间的对象引用)是使用 Remembered Set 来避免全堆扫描的。
G1中每个Region都有一个与之对应的 Remembered Set,JVM虚拟机发现程序在对 Reference 类型的数据进行更新操作时,会产生一个 Write Barrier暂时中断这个更新操作,先 检查Reference引用的对象是否在不同的Region中都被引用(在分代情况下就是检查是否老年代中的对象引用了新生代中的对象),如果是则会通过 CardTable 把相关引用信息记录到被引用对象所属的 Region中的 Remembered Set中;
当进行垃圾回收时,在GC根节点的枚举范围中加入 Remembered Set 就可以保证不对全堆进行扫描也不会有遗漏的对象。
3.2 G1的可预测停顿时间模型
可预测的停顿时间模型:
-
能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒;
-
简单来说就是希望G1在垃圾回收的时候,可以保证在1小时内由G1垃圾回收导致的“Stop the World”时间,也就是系统停顿的时间,不能超过1分钟。
-
这样也就相当于我们就可以直接 控制垃圾回收对系统性能的影响了。
-
可预测的停顿时间模型的实现:
-
G1追踪各个Region里面的垃圾回收价值,并且在后台维护一个 优先列表 用来维护Region回收价值;
-
垃圾回收价值:它必须搞清楚每个Region里面有多少垃圾,如果对这个Region进行回收,需要消耗多少时间,可以回收掉多少垃圾;
-
每次根据允许的停顿时间,优先收集回收价值最大的Region;
3.3 G1 Region的动态分配
在G1中,每个Region既可能属于新生代,也可能属于老年代。
刚开始一个Region可能谁都不属于,然后被分配给了新生代,存放了很多属于新生代的对象;然后垃圾回收的时候回收了这个Region:
回收之后,这个Region就空出来了,在下一次内存分配的时候,这个Region可能就会被分配给老年代了,用来存放老年代的长生命周期的对象:
所以其实在G1对应的内存模型中,Region会随时属于新生代也会属于老年代,所以没有所谓新生代给多少内存,老年代给多少内存这一说了。
实际上新生代和老年代各自的内存区域及大小是不停的变动的,由G1根据当前应用的对象生成情况自动控制。
3.4 G1的内存分配
假设当前Java堆内存设置为 4G。
3.4.1 Region 内存分配
Region 个数:G1中默认有2048个Region;
Region 大小:
- 每个Region大小是固定相等的,Region的大小可以通过JVM参数
-XX:G1HeapRegionSize
设定,取值范围从1M到32M,且是2的指数; - 如果不设定,那么G1会根据Heap大小自动决定:size = 堆大小/2048;
- 在4G堆内存下,除以2048个Region,每个Region的大小就是2MB。
3.4.2 分代内存分配
在G1中虽然把内存划分为了很多的 Region,但是其实还是有新生代、老年代的区分。
G1中刚开始的时候,默认新生代占堆内存的比例是5%;也可以通过JVM参数 -XX:G1NewSizePercent
来设置新生代初始占比(建议维持这个默认值);
即在4G堆内存下,新生代占据200MB左右的内存,对应大概是100个Region;
在系统运行过程中,随着新生代对象的增多,JVM会不停的给新生代增加更多的Region,但是最多新生代的占比不会超过默认比例60%;也可以通过JVM参数 -XX:G1MaxNewSizePercent
来设置新生代最大占比;
一旦Region进行了垃圾回收,此时新生代的Region数量就会减少,所以这些都是动态的。
并且新生代里还是有Eden和Survivor的划分的,根据新生代的JVM参数 -XX:SurvivorRatio=8
,还是可以区分出来属于新生代的Region中,哪些属于Eden,哪些属于Survivor;
比如上面的新生代初始的时候,有100个Region,那么可能80个Region就是Eden,两个Survivor各自占10个Region:
随着对象不停的在新生代里分配,属于新生代的Region会不断增加,Eden和Survivor对应的Region也会不断增加。
3.5 G1的垃圾回收
3.5.1 新生代垃圾回收(YGC)
既然G1的新生代也有Eden和Survivor的区分,那么触发垃圾回收的机制也都是类似的;
随着系统运行生成越来越多的对象,JVM也就会不停的给新生代加入更多的Region,直到新生代内存达到堆内存的最大比例60%。
在新生代达到了设定占比60%时,比如有1200个Region了,里面的Eden可能占据了1000个Region,每个Survivor是100个Region,而且Eden区还装满了对象:
这个时候就会触发新生代的GC,G1会使用 复制算法 来进行垃圾回收(新生代回收过程跟其他垃圾回收器类似);进入“Stop the World”状态,然后把Eden区对应的Region中的存活对象放入S1对应的Region中,接着回收掉Eden区对应的Region中的垃圾对象:
由于G1是可以设定目标GC停顿时间的,也就是G1执行GC的时候最多可以让系统停顿多长时间,可以通过JVM参数 -XX:MaxGCPauseMills
参数来设定,默认值是200ms;
那么G1就会对每个Region追踪它的回收价值,保证在 指定的GC停顿时
间违范围内,尽可能多的回收掉一些对象。
3.5.2 老年代垃圾回收(MIXGC、FGC)
在G1的内存模型下,新生代和老年代各自都会占据一定的Region,所以老年代也会有自己的Region;按照默认新生代最多只能占据堆内存60%的Region来推算,老年代最多可以占据40%的Region,大概就是800个左右的Region。
G1中对象进入老年代的条件(除了大对象,跟其他收集器相同):
分配担保;
年龄阈值:当经过多次Minor GC之后,年龄阈值(-XX:MaxTenuringThreshold)达到15的对象,就会被移到老年代;
动态年龄判断:如果年龄从小到大的一批对象的总大小大于了Survivor区的50%,此时大于等于这批对象最大年龄的对象,则提前进入老年代;(即年龄1+年龄2+年龄n的多个对象大小总和超过了Survivor区的50%,此时就会把年龄n以上的对象提前放入老年代)
所以经历了一段时间的新生代使用的垃圾回收后,就可能会有一些对象进入了老年代:
3.5.2.1 混合GC(MIXGC)
在G1中,对于新生代保留了YGC,并加上了一种全新的MIXGC用于收集老年代;那什么时候会触发MIXGC呢?
当老年代占据了堆内存一定比例的Region的时候,此时就会尝试触发一个 新生代 + 老年代 + 大对象 一起回收的混合回收阶段;这个比例由JVM参数 -XX:InitiatingHeapOccupancyPercent
指定,默认值 45%。
按照之前说的,堆内存有2048个Region,如果老年代占据了其中45%的Region,也就是接近1000个Region的时候,就会触发混合回收:
混合回收流程:
- 初始标记(iniail Marking)
- 并发标记(Concurrent Marking)
- 最终标记(Final Marking)
- 筛选回收(Live Data Counting and Evacuation)
3.5.2.1.1 初始标记(iniail Marking)#
G1的初始标记阶段,同样会让系统的工作线程全部停止,进入“Stop the World”状态;并且仅仅 只标记GC Roots 直接引用的对象,整个过程速度很快:
还会修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确的Region中创建新对象。
3.5.2.1.2 并发标记(Concurrent Marking)#
G1的并发标记阶段,同样不会进入 “Stop the World”状态,允许系统的工作线程继续工作;
同时进行GC Roots追踪,从GC Roots开始追踪所有的存活对象,也就是说 会标记出所有的 GC Roots直接和间接引用的对象;(跟上面的CMS阶段类似)
JVM会在这个阶段记录对象的修改动作(比如哪个对象被新建了,哪个对象失去了引用),这些变化将被记录在线程 Remembered Set Logs 里面。
这个阶段因为要追踪全部的存活对象,速度是比较慢的,非常耗时;但是这个阶段是跟系统程序并发运行的,所以对系统程序的影响不大。
3.5.2.1.3 最终标记(Final Marking)#
G1的最终标记阶段,同样会进入“Stop the World”状态,禁止系统程序运行;
然后会根据 并发标记阶段 记录的对象修改动作,最终标记出哪些对象存活,哪些对象失去引用需要收集:
3.5.2.1.4 筛选回收(Live Data Counting and Evacuation)#
最后的筛选回收阶段,会计算老年代中每个Region中的存活对象数量,存活对象的占比,并对各个Region的回收价值和成本进行排序;然后停止用户线程,进入“Stop the World”状态,全力以赴地进行垃圾回收。
此时会按照Region的回收价值,选择部分Region进行回收,让垃圾回收的停顿时间控制在用户指定的范围内。
比如老年代此时有1000个Region都满了,但是因为根据预定目标,本次垃圾回收只能停顿200毫秒,那么通过之前的计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region,把GC导致的停顿时间控制在用户指定的范围内:
3.5.2.1.5 混合GC 运行示意图#
3.5.2.2 Full GC
在进行Mixed回收的时候,无论是年轻代还是老年代都基于复制算法进行回收,都要把各个Region的存活对象拷贝到别的Region里去。
如果在拷贝的过程中发现没有空闲Region可以存放存活对象了,就会触发一次 混合GC失败,然后使用Full GC 进行回收;
此时G1会停止应用程序的执行(Stop-The-World),使用 单线程(Serial Old) 的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。
3.5.3 大对象Region
在G1中,提供了专门的 大对象Region(Humongous) 来存放大对象,而不是让大对象进入老年代的Region中。
大对象的判定规则是超过了一个Region大小的50%,比如按照上面算的,每个Region是2MB,只要一个大对象超过了1MB,就会被放入大对象专门的Region中;而且一个对象如果太大,可能会横跨多个Region来存放:
也就是这里的 Humongous;
G1中新生代和老年代的Region是不停的变化的,比如新生代现在占据了1200个Region,但是一次垃圾回收之后,回收掉了1000个Region;此时这1000个Region也就不属于新生代了,它们就可以用来存放大对象。
在新生代、老年代执行垃圾回收的时候,会顺带着大对象Region一起回收。
3.6 G1的JVM参数
参数 | 描述 |
---|---|
-XX:+UseG1GC | 使用G1 垃圾收集器; |
-XX:MaxGCPauseMillis=n | 设置最大GC停顿时间,这是一个软目标,JVM会尽最大努力去达到它; |
-XX:InitiatingHeapOccupancyPercent=n | 启动并发标记循环的堆占用率的百分比,当整个堆的占用达到比例时,启动一个全局并发标记循环,0代表并发标记一直运行;(默认值45%) |
-XX:NewRatio=n | 新生代和老年代大小的比例,默认是2; |
-XX:SurvivorRatio=n | eden和survivor区域空间大小的比例;(默认值8) |
-XX:MaxTenuringThreshold=n | 晋升的阈值,一个存活对象经历多少次GC周期之后晋升到老年代;(默认1)5 |
-XX:ParallelGCThreads=n | 设置GC并发阶段的线程数,默认值与JVM运行平台相关; |
-XX:ConcGCThreads=n | 设置并发标记的线程数,默认值与JVM运行平台相关; |
-XX:G1ReservePercent=n | 设置保留java堆大小比例,用于防止晋升失败/Evacuation Failure;(默认值10%) |
-XX:G1HeapRegionSize=n | 设置Region的大小;(默认值根据堆的大小动态决定,大小范围 [1M,32M]) |
3.7 G1的使用场景
任何东西的使用场景都离不开它的特性,所以G1的使用场景也是由G1的特性决定的。
3.7.1 G1的优缺点
优点:G1最大的优点就是 可以建立可预测的停顿时间模型,可以让用户控制应用在垃圾回收时的停顿时间;
缺点:如果应用的内存使用情况非常吃紧,在垃圾回收时对于部分内存回收根本不够(也就是说经常需要对整个堆进行回收);这种时候由于G1本身的算法更复杂,可能性能就会比其他回收器差;
3.7.2 G1适合的使用场景
根据以上G1的优越点,就可以得出G1更适用以下场景:
堆大小较大(4G以上)的应用;(由于堆大小较大,在等到堆积了很多垃圾对象后开始回收;如果是ParNew+CMS收集器,它们没法控制停顿时间,就会停顿很长时间进行回收)
对GC停顿时间更敏感的应用;(要求更少的停顿时间,如即时通讯等)