收集器
调优的主要目标之一就是降低STW的时间,也就是减少Full GC的次数。那么从调优的角度来分析各个收集器的优势与不足。
年轻代的收集器开始(采用复制的收集算法):
Serial收集器:一个单线程收集器,在进行回收的时候,必须暂停其他所有的工作线程,直到收集结束。缺点:因为要完全暂停线程,所以用户体验不佳。但是由于新生代回收得较快,所以停顿的时间非常少,而且没有线程切换的开销,因此也简单高效。通过-XX:+UseSerialGC参数启用。
ParNew收集器:这个是Serial收集器的多线程版本,适用于多核CPU的设备。但对于单核的设备来说,需要进行线程之间的切换,效率反而没有单线程的高。通过-XX:ParallelGCThreads参数限制收集的线程数,-XX:+UseParNewGC参数启用。
Parallel Scavenge收集器:该收集器是的关注点和其他的收集器不同,其他的关注点是尽可能的缩短Full GC的时间。而该收集器关注的是一个可控的吞吐量。吞吐量=运行代码的时间/(运行代码的时间+GC的时间),通过参数-XX:MaxGCPauseMillis设置最大GC的停顿时间和-XX:GCTimeRatio 设置吞吐量的大小。-XX:+UseParallelGC参数启用。主要适合在后台运算而不需要太多交互的任务。
可以通过-XX:+UseAdaptiveSizePolicy参数开启自适应调节策略,可以免去我们自己设置堆内存的一些细节参数,比如新生代内存大小,Eden与Survivor之间的比例等等。这个参数适合对内存手工优化存在困难的时候使用,他能监控系统当前的状态,动态的调整以达到最大的吞吐量。

年老代收集器
老年代存活的一般是大对象以及生命很顽强的对象,因此新生代的复制算法很明显不能适应该区域的特性,所以老年代采用的是“标记-清除-整理”算法
Serila Old收集器:该收集器是Serial收集器的老年代版,同样是一个单线程的收集器,优劣势和Serial收集器一样
Parallel Old收集器:也是Parallel Scavenge收集器的老年代版本。关注点也和Parallel Scavenge收集器一样,注重系统的吞吐量,适合于CPU资源敏感的场合。
CMS(Concurrent Mark Sweep)收集器:是一种以最短停顿时间为目标的收集器。当应用尤其重视服务的响应速度,要适用于对响应时间的侧重性大于吞吐量的场景
并发地进行清理,所以必须预留部分堆空间给正在运行的应用程序,默认情况下在老年代使用了68%及以上的内存的时候就开始CMS。
CMS收集器的收集过程:初始标记、并发标记、重新标记、并发清除。
初始标记是需要进行STW的,但仅仅只是标记GC Roots能够直接关联的对象,如图,其中蓝色底纹的便是能够直接关联的对象。
一是标记老年代中所有的GC Roots所指的直接对象;二是标记被年轻代中存活对象引用的直接对象。因为仅标记少量节点,所以很快就能完成。

并发标记:在初始标记的基础上继续往下遍历其他的对象引用并进行标记,,该过程会和用户线程并发地执行,不会发生停顿。这个阶段会从initial mark阶段中所标记的节点往下检索,标记出所有老年代中存活的对象。注意此时会有部分对象的引用被改变,如上图中的current obj原本所引用的节点已经失去了关联。

并发预清理(concurrent preclean)

前一个阶段在并行运行的时候,一些对象的引用已经发生了变化,当这些引用发生变化的时候,JVM会标记堆的这个区域为Dirty Card,这就是 Card Marking。

在本阶段,那些能够从dirty card对象到达的对象也会被标记,这个标记做完之后,dirty card标记就会被清除,如上图所示。
总的来说,本阶段会并发地更新并发标记阶段的引用变化和查找在并发标记阶段新进入老年代的对象,如刚晋升的对象和直接被分配在老年代的对象。通过重新扫描,以减少下一阶段的工作。
可中止的并发预清理(concurrent abortable preclean)
这个阶段尝试着去承担STW的Final Remark阶段足够多的工作。这个阶段持续的时间依赖好多的因素,由于这个阶段是重复的做相同的事情直到发生aboart的条件之一(比如:重复的次数、多少量的工作、持续的时间等等)才会停止。
重新标记: 本阶段需要stop the world,通常来说此次暂时都会比较长,因为并发预清理是并发执行的,对象的引用可能会发生进一步的改变,需要确保在清理之前保持一个正确的对象引用视图,那么就需要stop the world来处理复杂的情况。
并发清除: 使用标记-清除法回收老年代的垃圾对象,与用户线程并发执行。

并发标记重置(concurrent reset):清空现场,为下一次GC做准备。
缺陷:大量使用了并发操作,因此会占用一部分CPU的资源,导致吞吐量下降;当在并发清除垃圾的时候,也就是第四步的时候,是与当前主线程并发执行的,在回收的时候,主线程又会产生新的垃圾,而这些垃圾在这次回收过程已经回收不了,只能等待下一次回收了。这些垃圾又叫做“浮动垃圾”。
GC参数
-XX:+UseConcMarkSweepGC - 启用CMS,同时-XX:+UseParNewGC会被自动打开
-XX:CMSInitiatingOccupancyFraction - 设置第一次启动CMS的阈值,默认是68%
-XX:+UseCMSInitiatingOccupancyOnly - 只是用设定的回收阈值,如果不指定,JVM仅在第一次使用设定值,后续则自动调整。
-XX:+CMSPermGenSweepingEnabled - 回收perm区
-XX:+CMSClassUnloadingEnabled - 相对于并行收集器,CMS收集器默认不会对永久代进行垃圾回收(在1.7中是默认关闭,但是在1.8中是默认打开的)。如果希望对永久代进行垃圾回收,则可以打开此标志,同时打开-XX:+CMSPermGenSweepingEnabled。
-XX:+CMSConcurrentMTEnabled - 并发的CMS阶段将会启动多个GC线程和其他线程并发工作,默认为true。
-XX:+UseCMSCompactAtFullCollection - 在full GC的时候进行整理(mark sweep compact),默认为true。
-XX:+CMSFullGCsBeforeCompaction - 在上一次CMS并发GC执行过后,到底还要再执行多少次**full GC**(注意不是CMS GC)才会做压缩,默认是0。如果增大这个参数,会使full GC更少做压缩,但也就更容易使CMS的老年代受碎片化问题的困扰。 本来这个参数就是用来配置降低full GC压缩的频率,以期减少某些full GC的暂停时间。CMS回退到full GC时用的算法是mark-sweep-compact,但compaction是可选的,不做的话碎片化会严重些但这次full GC的暂停时间会短些,这是个取舍。
-XX:+CMSParallelRemarkEnabled - 并行remark,以减少remark的等待时间。默认为true。
-XX:+CMSScavengeBeforeRemark - 强制remark之前开始一次minor GC,可以减少remark的等待时间,因为老生代的对象有的会依赖于新生代的对象,当增加了这个命令时会在remark之前执行一次minor GC的操作,从而可以减少老生代到新生代的可到达对象数。默认为false。
Promotion Failed
由于CMS没有任何的碎片整理机制,所以会产生大量的堆碎片。因此可能会发生即使堆的大小没有耗尽,但是从新生代晋升至老年代却失败的情况。此时会fallback为Serial Old从而引起一次full GC(会进行碎片整理)。可以增加老年代的大小和Survivor区的大小以减少full GC的发生。
Concurrent Mode Failed
如果对象分配率高于CMS回收的效率,将导致在CMS完成之前老年代就被填满,这种状况成为“并发模式失败”,同样也会引起full GC。可以调节-XX:CMSInitiatingOccupancyFraction和新生代的堆大小。
一个收集范围涵盖整个堆的收集器——G1收集器。
G1收集器(或者垃圾优先收集器)的设计初衷是为了尽量缩短处理超大堆时产生的停顿。在回收的时候将对象从一个小堆区复制到另一个小堆区,这意味着G1在回收垃圾的时候同时完成了堆的部分内存压缩,相对于CMS的优势而言就是内存碎片的产生率大大降低。
G1同时回收老年代和年轻代,而CMS只能回收老年代,需要配合一个年轻代收集器。另外G1的分代更多是逻辑上的概念,G1将内存分成多个等大小的region,Eden/ Survivor/Old分别是一部分region的逻辑集合,物理上内存地址并不连续。
CMS在old gc的时候会回收整个Old区,对G1来说没有old gc的概念,而是区分Fully young gc和Mixed gc,前者对应年轻代的垃圾回收,后者混合了年轻代和部分老年代的收集,因此每次收集肯定会回收年轻代,老年代根据内存情况可以不回收或者回收部分或者全部。
在年轻代依然采用复制算法;年老代也同样采用“标记-清除-整理”算法。但是,新生代与老年代在堆内存中的布局就和以往的收集器有着很大的区别:heap被划分为一系列大小相等的“小堆区”,也称为region。每个小堆区(region)的大小为1~32MB,整个堆大致要划分出2048个小堆区。

young GC
young GC主要是对Eden区进行GC,在Eden空间耗尽时会被触发。在这种情况下,Eden空间存活的对象会被**撤离**(代表复制或者移动)到另外一个或是多个Survivor小堆区,如果Survivor空间不够,Eden空间的部分数据会直接晋升到年老代空间。Survivor区的数据移动到新的Survivor区中,也有部分数据晋升到老年代空间中。最终Eden空间的数据为空,GC停止工作,应用线程继续执行。
G1的young GC规范如下:
- 堆从一个单一的内存空间被划分为众多的小堆区(region)。
- 新生代的内存由一系列不连续的小堆区所组成。这使得在需要的时候更加容易进行resize。
- young GC是一个STW事件,所有应用程序线程都会被暂停。
- young GC会使用多线程并行执行。
- 存活的对象将会复制到新的Survivor小堆区或者老年代小堆区。
回收过程也分为四个部分:初始标记、并发标记、最终标记、筛选回收
初始标记阶段 - Initial Marking Phase(STW)
存活对象的初始标记是捎带在新生代垃圾收集里面,在GC日志里被记录为GC pause (young)(inital-mark)。

并发标记阶段 - Concurrent Marking Phase
本阶段会与应用程序并发地查找存活的对象,如果找到了空的小堆区(下图中标记为红叉的),会在”重新标记阶段“被马上清除。还有决定活跃度的”accounting“信息也是在本阶段计算的。

重新标记阶段 - Remark Phase(STW)
对于G1,它短暂地停止应用线程,停止并发更新日志的写入,处理其中的少量信息,并标记所有在并发标记开始时未被标记的存活对象。这一阶段也执行某些额外的清理,如引用处理(参见 Evacuation Pause log)或者类卸载(class unloading)。空的小堆区被清除和回收,并且现在会计算所有小堆区的活跃度。

复制/清除阶段 - Copying/Cleanup Phase(部分STW)

- 清除阶段
- 执行存活对象的accounting和完全释放空的小堆区(STW);
- 擦除RSets(RSets全称为Remembered Sets,作用是跟踪从外部指向本小堆区的所有引用。主要是记录老年代到新生代之间的引用的一个集合,至于新生代之间的引用记录会在每次GC时被扫描,所以不用记录新生代到新生代之间的引用)(STW)
- 重置空的小堆区并将他们归还给free list,也就是空闲表。(Concurrent)
- 复制阶段
- 本阶段有STW停顿去撤离存活对象到新的未被使用的区域。在新生代小堆区完成时会被记录为
[GC pause (young)],如果在新生代和老年代的小堆区一起执行时会被记录为[GC Pause (mixed)]
- 本阶段有STW停顿去撤离存活对象到新的未被使用的区域。在新生代小堆区完成时会被记录为
G1会优先选择活跃度最低的小堆区,因为这些区域会被最快地的回收。还有新生代和老年代都会在本阶段被回收。
G1收集器对老年代的收集
- 并发标记阶段
- 在应用程序运行时并发地计算活跃度信息
- 活跃度信息甄别出哪个小堆区是在撤离暂停时最适合回收的
- 重新标记阶段
- 使用Snapshot-at-the-Beginning (SATB) 算法,这个算法比CMS所使用的要快得多
- 回收空的小堆区
- 复制/清除阶段
- 新生代和老年代同时被回收
- 老年代的小堆区会根据活跃度而进行部分的选定
-XX:+UseG1GC 启用G1垃圾收集器
-XX:MaxGCPauseMillis=n 指定期望的最大停顿时间,有大概率保证在该范围内,但并非一定能实现。
-XX:G1HeapRegionSize=n 设置的 G1 区域的大小。值是 2 的幂,范围是 1 MB 到 32 MB 之间。目标是根据最小的 Java 堆大小划分出约 2048 个区域。
-XX:ParallelGCThreads=n 设置 STW 工作线程数的值。将 n 的值设置为逻辑处理器的数量。n 的值与逻辑处理器的数量相同,最多为 8。如果逻辑处理器不止八个,则将 n 的值设置为逻辑处理器数的 5/8 左右。这适用于大多数情况,除非是较大的 SPARC 系统,其中 n 的值可以是逻辑处理器数的 5/16 左右。
-XX:ConcGCThreads=n 设置并行标记的线程数。将 n 设置为并行垃圾回收线程数 (ParallelGCThreads) 的 1/4 左右。
-XX:InitiatingHeapOccupancyPercent=45 设置触发标记周期的 Java 堆占用率阈值。默认占用率是整个 Java 堆的 45%。
-XX:G1ReservePercent=n 设置堆内存保留为假天花板的总量,以降低提升失败的可能性,默认值是 10。
NOTE:避免使用 -Xmn 选项或 -XX:NewRatio 等其他相关选项显式设置年轻代大小。固定年轻代的大小会覆盖暂停时间目标。
初始标记需要STW;并发标记不需要;最终标记就是做一些小修改,需要STW;而筛选回收则有些不同,在众多的region中,每个region可回收的空间各不相同,但是回收所消耗的时间是需要控制的,不能太长,因此G1就会筛选出一些可回收空间比较大的region进行回收,这就是G1的优先回收机制。这也是保证G1收集器能在有限的时间内能够获得最高回收效率的原因。通过-XX:MaxGCPauseMills=50毫秒设置有限的收集时间。
每个region之间的对象引用通过remembered set来维护,每个region有一个remembered set,remembered set中包含了引用当前region中对象的指针。虚拟机正是通过这个remembered set去避免对整个堆进行扫描来确认可回收的对象。
如何处理跨代引用
在垃圾回收的时候都是从Root开始搜索,这会先经过年轻代再到老年代,对于年轻代引用老年代的这种跨代不需要单独处理。但是老年代引用年轻代的会影响young gc,这种跨代需要处理。
为了避免在回收年轻代的时候,扫描整个老年代,需要记录老年代对年轻代的引用,young gc的时候只要扫描这个记录。CMS和G1都用到了Card Table,但是用法不太一样。JVM将内存分成一个个固定大小的card,然后有一个专门的数据结构(即这里的Card Table)维护每个Card的状态,一个字节对应一个Card,有点像内存page的概念,只是page是硬件上的,Card Table是软件上的。当一个Card上的对象的引用发生变化的时候,就将这个Card对应的Card Table上的状态置为dirty,young gc的时候扫描状态是dirty的Card即可。这是基本的用法,CMS基本上就是这么使用。
G1在Card Table的基础上引入的remembered set(下面简称RSet)。每个region都会维护一个RSet,记录着引用到本region中的对象的其他region的Card。比如A对象在regionA,B对象在regionB,且B.f = A,则在regionA的RSet中需要记录B所在的Card的地址。这样的好处是可以对region进行单独回收,这要求RSet不只是维护老年代到年轻代的引用,也要维护这老年代到老年代的引用,对于跨代引用的每次只要扫描这个region的RSet上的Card即可。
上面说过年轻代到老年代的引用不需要单独处理,这带来了很大的性能上的提升,因为年轻代的对象引用变化很大,如果都需要记录下来成本会很高。同时也说明只需要在老年代维护Card Table。

参考:
https://blog.csdn.net/sunhuaqiang1/article/details/61913430
https://tech.meituan.com/2016/09/23/g1.html
https://blog.chriscs.com/2017/06/20/g1-vs-cms/
浙公网安备 33010602011771号