垃圾回收
术语:
JVM中的并行,指多个垃圾回收相关线程在操作系统之上并发运行,这里的并行强调的是只有垃圾回收线程工作,Java应用程序都暂停执行,因此ParNew工作的时候一定发生了STW。本书提到的***ParTask(例如G1ParTask)指的就是在这些任务运行的时候应用程序都必须暂停。
JVM中的并发,指垃圾回收相关的线程并发运行(如果启动多个线程),同时这些线程会和Java应用程序并发运行。本书提到的***Concurrent***Thread(例如ConcurrentG1RefineThread)就是指这些线程和Java应用程序同时运行。
·Stop-the-world(STW),直译就是停止一切,在JVM中指停止一切Java应用线程。
·安全点(Safepoint),指JVM在执行一些操作的时需要STW,但并不是任何线程在任何地方都能进入STW,例如我们正在执行一段代码时,线程如何能够停止?设计安全点的目的是,当线程进入到安全点时,线程就会主动停止。
·Mutator,在很多英文文献和JVM源码中,经常看到这个单词,它指的是我们的Java应用线程。Mutator的含义是可变的,在这里的含义是因为线程运行,导致了内存的变化。GC中通常需要STW才能使Mutator暂停。
·记忆集(Remember Set),简称为RSet。主要记录不同代际对象的引用关系。
·Refine,尚未有统一的翻译,有时翻译为细化,但是不太准确,本书中不做翻译。G1中的ConcurrentG1RefineThread主要指处理RSet的线程。
·Evacuation,转移、撤退或者回收,简称为Evac,本书中不做翻译。在G1中指的是发现活跃对象,并将对象复制到新地址的过程。
·回收(Reclaim),通常指的是分区对象已经死亡或者已经完成Evac,分区可以被JVM再次使用。
·GC Root,垃圾回收的根。在JVM的垃圾回收过程中,需要从GC Root出发标记活跃对象,确保正在使用的对象在垃圾回收后都是存活的。
·根集合(Root Set)。在JVM的垃圾回收过程中,需要从不同的GC Root出发,这些GC Root有线程栈、monitor列表、JNI对象等,而这些GC Root就构成了RootSet。
·Full GC,简称为FGC,整个堆的垃圾回收动作。通常Full GC是串行的,G1的FullGC不仅有串行实现,在JDK10中还有并行实现。
·再标记(Remark)。在本书中指的是并发标记算法中,处理完并发标记后,需要更新并发标记中Mutator变更的引用,这一步需要STW。
根据垃圾收集回收的区域不同,垃圾收集主要分为:
-
Young GC(Minor GC)
-
Old GC(Old GC)
-
Full GC
-
Mixed GC
①Young GC
新生代内存的垃圾收集事件称为 Young GC(又称 Minor GC ) ,当 JVM 无法为新对象分配在新生代内存空间时总会触发 Young GC。
比如 Eden 区占满时,新对象分配频率越高,Young GC 的频率就越高。
Young GC 每次都会引起全线停顿(Stop-The-World ) ,暂停所有的应用线程,停顿时间相对老年代 GC 造成的停顿,几乎可以忽略不计。
YGC垃圾排查 YGC问题排查,又让我涨姿势了!
②Old GC 、Full GC、Mixed GC
Old GC: 只清理老年代空间的 GC 事件,只有 CMS 的并发收集 是这个模式。
Full GC: 清理整个堆的 GC 事件,包括新生代、老年代、元空间等 。
Mixed GC: 清理整个新生代以及 部分老年代 的 GC,只有 G1 有这个模式。
GC原因:
一个文档: Java之CMS GC Causes - 掘金
一、 Allocation Failure
Allocation Failure happens when there isn't enough free space to create new objects in Young generation. Allocation failures triggers Young GC.
On Linux, the JVM can trigger a GC if the kernel notifies there isn't much memory left via mem_notify.
-
产生原因:新生代没有足够的空间分配对象。
-
触发GC类型:Young GC。
二、 Full GC - Allocation Failure
不确定是不是直接分配在老年代上的Allocation Failure
三、 Concurrent Mode Failure
并发模式失败: 当 CMS 在执行回收时,新生代发生垃圾回收,同时老年代又没有足够的空间容纳晋升的对象时,CMS 垃圾回收就会退化成单线程的 Full GC。 所有的应用线程都会被暂停 ,老年代中所有的无效对象都被回收。
The CMS collector uses one or more garbage collector threads that run simultaneously with the application threads with the goal of completing the collection of the tenured generation before it becomes full. In normal operation, the CMS collector does most of its tracing and sweeping work with the application threads still running, so only brief pauses are seen by the application threads. However, if the CMS collector is unable to finish reclaiming the unreachable objects before the tenured generation fills up, or if an allocation cannot be satisfied with the available free space blocks in the tenured generation, then the application is paused and the collection is completed with all the application threads stopped. The inability to complete a collection concurrently is referred to as concurrent mode failure and indicates the need to adjust the CMS collector parameters. Concurrent mode failure typically triggers Full GC.
四、 Promotion Failure
Promotion Failure happens when there is no continuous memory space to promote larger object, even though total free memory is large enough. This is problem is called as heap fragmentation. Promotion Failure typically triggers Full GC.
看空间分配担保的内容: 空间分配担保的内容
晋升失败:在CMS中 当新生代发生垃圾回收,老年代 有足够的空间 可以容纳晋升的对象,但是由于 空闲空间的碎片化 ,导致晋升失败,此时会触发单线程且带压缩动作的 Full GC。
五、 GCLocker Initiated GC
The GC locker prevents GC from occurring when JNI code is in a critical region. If GC is needed while a thread is in a critical region, then it will allow them to complete, i.e. call the corresponding release function. Other threads will not be permitted to enter a critical region. Once all threads are out of critical regions a GC event will be triggered.
六、 Metadata GC Threshold
Metadata区不够用了。
七、 Ergonomics
Ergonomics 是 “JVM” 和 “GC调优” 提高应用程序性能的过程(如,基于行为的调优)。JVM 根据所运行的平台提供了 GC、堆大小、运行时编译器 等方面的默认选择。这些选择符合不同类型应用程序的需求,可减少命令行(参数)调整。此外,基于行为的调优会动态调整堆大小,来满足应用程序的特殊行为。
一、判断对象是否存活的算法
1.引用计数算法
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器+1,当引用失效,计数器-1.任何时刻计数器为0的对象就是不可能再被使用的。
优点:实现简单,判定效率高效,被actionscript3和python中广泛应用。
缺点:无法解决对象之间的相互引用问题。java没有采纳
2.可达性分析算法
通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GCRoots没有任何引用链相连的时候,则证明此对象是不可用的。
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
· 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
· 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
· 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
· 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
· Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
· 所有被同步锁(synchronized关键字)持有的对象。
· 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。譬如后文将会提到的分代收集和局部回收(Partial GC),如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性。
目前最新的几款垃圾收集器无一例外都具备了局部回收的特征,为了避免GC Roots包含过多对象而过度膨胀
基于以上,我们可以知道,当当前对象到GCRoot中不可达时候,即会满足被垃圾回收的可能。
那么是不是这些对象就非死不可,也不一定,此时只能宣判它们存在于一种“缓刑”的阶段,要真正的宣告一个对象死亡。至少要经历两次标记:
第一次:对象可达性分析之后,发现没有与GCRoots相连接,此时会被第一次标记并筛选。
第二次:对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,此时会被认定为没必要执行。
对象在内存中的状态
1.可达状态:从GCRoots 可以遍历到
2.可恢复状态:刚失去引用。系统调用finalize()方法,如果该方法是一个变量引用它,则变为可达状态,否则不可达状态。
3.不可达状态:垃圾回收
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的 Finalizer线程去执行它们的finalize()方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
finalize()已经不推荐使用。
三色标记算法
Java 如何有效地避免OOM:善于利用软引用和弱引用 - Matrix海子 - 博客园
Java中的四种引用 Reference的子类
1.强引用 = 可达状态不能被回收
强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
2.软引用 java.lang.ref.SoftReference 只有在内存不足的时候JVM才会回收该对象,可达状态也可能被回收。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被JVM回收,这个软引用就会被加入到与之关联的引用队列中。
如何判断内存不足?
-XX:SoftRefLRUPolicyMSPerMB从名字看不出什么意思?【官文解读】_liangdu_Zuker的博客-CSDN博客
-XX:SoftRefLRUPolicyMSPerMB 参数
3.弱引用 java.lang.ref.WeakReference 垃圾回收一执行,就会回收
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被JVM回收,这个软引用就会被加入到与之关联的引用队列中。
4.虚引用 java.lang.ref.PhantomReference 必须和引用队列一起使用,用于跟踪对象被垃圾回收的状态
虚引用和前面的软引用、弱引用不同,它并不影响对象的生命周期。在java中用java.lang.ref.PhantomReference类表示。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。要注意的是,虚引用必须和引用队列关联使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之 关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
虚引用也称为幽灵引用或者幻影引用,它是 最弱 的一种引用关系。 一个对象是否有虚引用的存在, 完全不会对其生存时间构成影响 ,也无法通过虚引用来取得一个对象实例。 为一个对象设置虚引用关联的 唯一目的就是能在这个对象被收集器回收时收到一个系统通知 。
HotSpot算法实现细节
根节点枚举
迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问根节点枚举与之前提及的整理内存碎片一样会面临相似的“Stop The World”的困扰。现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发(具体见3.4.6节),但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足的话,分析结果准确性也就无法保证。这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的CMS、G1、ZGC等收集器,枚举根节点时也是必须要停顿的。
由于目前主流Java虚拟机使用的都是准确式垃圾收集(这个概念在第1章介绍Exact VM相对于Classic VM的改进时介绍过),所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。在HotSpot的解决方案里,是使用一组称为 OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译(见第11章)过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了, 并不需要真正一个不漏地从方法区等GC Roots开始查找。
下面代码清单3-3是HotSpot虚拟机客户端模式下生成的一段String::hashCode()方法的本地代码,可以看到在0x026eb7a9处的call指令有OopMap记录,它指明了EBX寄存器和栈中偏移量为16的内存区域中各有一个普通对象指针(Ordinary Object Pointer,OOP)的引用,有效范围为从call指令开始直到0x026eb730(指令流的起始位置)+142(OopMap记录的偏移量)=0x026eb7be,即hlt指令为止。
代码清单3-3 String.hashCode()方法编译后的本地代码
[Verified Entry Point]
0x026eb730: mov %ea
x,-0x8000(%esp)…………;; ImplicitNullCheckSt
ub slow case0x026eb7a9: call 0x0
26e83e0 ; OopMap{ebx=Oop [16]=Oo
p off
=142}
; *caload
; - java.lang.String::ha
shCod
e@48 (line 1489)
; {runtime_call} 0x026eb7ae: push
$0x83c5c18 ; {external_word} 0x026eb7b3: call
0x026eb7b8 0x026eb7b8: pusha 0x026eb7b9: call
0x0822bec0 ; {runtime_call} 0x026eb7be: hlt
安全点
在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得无法忍受的高昂。
实际上HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置被称为 安全点(Safepoint)。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。
对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension),抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。
而主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。
由于轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。下面代码清单3-4中的test指令就是HotSpot生成的轮询指令,当需要暂停用户线程时,虚拟机把0x160100的内存页设置为不可读,那线程执行到test指令时就会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样仅通过一条汇编指令便完成安全点轮询和触发线程中断了。
代码清单3-4 轮询指令
0x01b6d627: call 0x01b2b210 ; OopMap{[60]=Oop off=460}
; *invokeinterface size ; - Client1::main@113 (lin
e 23) ; {virtual_call} 0x01b6d62c: nop ; OopMap{[60]=Oop off=461}
; *if_icmplt ; - Client1::main@118 (lin
e 23) 0x01b6d62d: test %eax,0x160100 ; {poll} 0x01b6d633: mov 0x50(%esp),%esi 0x01b6d637: cmp %eax,%esi
安全区域
使用安全点的设计似乎已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,但实际情况却并不一定。安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
记忆集 (Remembered Set,RSet) 与卡表
看JVM G1源码分析与调优
这个结构 把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
通常有两种方法记录引用关系,第一成为Point Out,第二是Point in(G1使用)。如ObjA.Field=ObjB,对于Point out来说在ObjA的RSet中记录ObjB的地址,对于Point in来说在ObjB的RSet中记录ObjA的地方,这相当于一种反向引用。这两者的区别在于处理有所不同,Point Out记录操作简单,但是需要对RSet做全部扫描;Point In记录操作复杂,但是在标记扫描时直接可以找到有用和无用的对象,不需要额外的扫描,因为RSet里面的对象可以认为就是根对象。
卡表: jvm-卡表_IRON_MAN_KD的博客-CSDN博客_jvm 卡表
卡表帮助记录哪些老年代对象可能指向新生代对象。卡表本质上是一个由JVM管理的字节数组,该数组的每个元素对应于一个老年的中512字节的区域。
Rset G1才有。是每个Region都有的条目,它们会记录指向当前堆区域的外部引用。这意味着G1不需要通过遍历整个堆来寻找指向某个区域的引用,而是只需要检查RSets,然后扫描这些区域来寻找引用。
讲解分代收集理论的时候,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为 记忆集(Remembered Set)的数据结构,用以 避免把整个老年代加进GC Roots扫描范围。事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,典型的如G1、ZGC和Shenandoah收集器,都会面临相同的问题,因此我们有必要进一步理清记忆集的原理和实现方式,以便在后续章节里介绍几款最新的收集器相关知识时能更好地理解。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。如果我们不考虑效率和成本的话,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构,如代码清单3-5所示:
代码清单3-5 以对象指针来实现记忆集的伪代码
Class RememberedSet {
Object[] set[OBJE
CT_INTERGENERATIONAL_REFERENCE_SIZE];}
这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度:
· 字长精度:
每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
· 对象精度:
每个记录精确到一个对象,该对象里有字段含有跨代指针。
· 卡精度:
每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
其中,第三种“卡精度”所指的是用一种称为“ 卡表”(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式,一些资料中甚至直接把它和记忆集混为一谈。前面定义中提到记忆集其实是一种“抽象”的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的具体实现。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。关于卡表与记忆集的关系,读者不妨按照Java语言中HashMap与Map的关系来类比理解。
卡表最简单的形式可以只是一个字节数组,而HotSpot虚拟机确实也是这样做的。以下这行代码是HotSpot默认的卡表标记逻辑:
CARD_TABLE [this address >> 9] = 0;
字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可以看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。那如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块,如图3-5所示。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。
卡表(CardTable)在CMS中是最常见的概念之一,G1中不仅保留了这个概念,还引入了RSet。卡表到底是一个什么东西?GC最早引入卡表的目的是为了对内存的引用关系做标记,从而根据引用关系快速遍历活跃对象。举个简单的例子,有两个分区,假设分区大小都为1MB,分别为A和B。如果A中有一个对象objA,B中有一个对象objB,且objA.field=objB,那么这两个分区就有引用关系了,但是如果我们想找到分区A,要如何引用分区B?做法有两种:·遍历整个分区A,一个字一个字的移动(为什么以字为单位?原因是JVM中对象会对齐,所以不需要按字节移动),然后查看内存里面的值到底是不是指向B,这种方法效率太低,可以优化为一个对象一个对象地移动(这里涉及JVM如何识别对象,以及如何区分指针和立即数),但效率还是太低。
·借助额外的数据结构描述这种引用关系,例如使用类似位图(bitmap)的方法,记录A和B的内存块之间的引用关系,用一个位来描述一个字,假设在32位机器上(一个字为32位),需要32KB(32KB×32=1M)的空间来描述一个分区。那么我们就可以在这个对象ObjA所在分区A里面添加一个额外的指针,这个指针指向另外一个分区B的位图,如果我们可以把对象ObjA和指针关系进行映射,那么当访问ObjA的时候,顺便访问这个额外的指针,从这个指针指向的位图就能找到被ObjA引用的分区B对应的内存块。通常我们只需要判定位图里面对应的位是否有1,有的话则认为发生了引用。
以位为粒度的位图能准确描述每一个字的引用关系,但是一个位通常包含的信息太少,只能描述2个状态:引用还是未引用。实际应用中JVM在垃圾回收的时候需要更多的状态,如果增加至一个字节来描述状态,则位图需要256KB的空间,这个数字太大,开销占了25%。所以一个可能的做法位图不再描述一个字,而是一个区域,JVM选择512字节为单位,即用一个字节描述512字节的引用关系。选择一个区域除了空间利用率的问题之外,实际上还有现实的意义。我们知道Java对象实际上不是一个字能描述的(有一个参数可以控制对象最小对齐的大小,默认是8字节,实际上Java在JVM中还有一些附加信息,所以对齐后最小的Java对象是16字节),很多Java对象可能是几十个字节或者几百个字节,所以用一个字节描述一个区域是有意义的。但是我没有找到512的来源,为什么512效果最好?没有相应的数据来支持这个数字,而且这个值不可以配置,不能修改,但是有理由相信512字节的区域是为了节约内存额外开销。按照这个值,1MB的内存只需要2KB的额外空间就能描述引用关系。这又带来另一个问题,就是512字节里面的内存可能被引用多次,所以这是一个粗略的关系描述,那么在使用的时候需要遍历这512字节。
再举一个例子,假设有两个对象B、C都在这512字节的区域内。为了方便处理,记录对象引用关系的时候,都使用对象的起始位置,然后用这个地址和512对齐,因此B和C对象的卡表指针都指向这一个卡表的位置。那么对于引用处理也有可有两种处理方法:·处理的时候会以堆分区为处理单位,遍历整个堆分区,在遍历的时候,每次都会以对象大小为步长,结合卡表,如果该卡表中对应的位置被设置,则说明对象和其他分区的对象发生了引用。具体内容在后文中介绍Refine的时候还会详细介绍。·处理的时候借助于额外的数据结构,找到真正对象的位置,而不需要从头开始遍历。在后文的并发标记处理时就使用了这种方法,用于找到第一个对象的起始位置。在G1除了512字节粒度的卡表之外,还有bitMap,例如使用bitMap可以描述一个分区对另外一个分区的引用情况。在JVM中bitMap使用非常多,例如还可以描述内存的分配情况。
再举一个例子,假设有两个对象B、C都在这512字节的区域内。为了方便处理,记录对象引用关系的时候,都使用对象的起始位置,然后用这个地址和512对齐,因此B和C对象的卡表指针都指向这一个卡表的位置。那么对于引用处理也有可有两种处理方法:·处理的时候会以堆分区为处理单位,遍历整个堆分区,在遍历的时候,每次都会以对象大小为步长,结合卡表,如果该卡表中对应的位置被设置,则说明对象和其他分区的对象发生了引用。具体内容在后文中介绍Refine的时候还会详细介绍。·处理的时候借助于额外的数据结构,找到真正对象的位置,而不需要从头开始遍历。在后文的并发标记处理时就使用了这种方法,用于找到第一个对象的起始位置。在G1除了512字节粒度的卡表之外,还有bitMap,例如使用bitMap可以描述一个分区对另外一个分区的引用情况。在JVM中bitMap使用非常多,例如还可以描述内存的分配情况。
G1在混合收集算法中用到了并发标记。在并发标记的时候使用了bitMap来描述对象的分配情况。例如1MB的分区可以用16KB(16KB×ObjectAlignmentInBytes×8=1MB)来描述,即16KB额外的空间。其中ObjectAlignmentInBytes是8字节,指的是对象对齐,第二个8是指一个字节有8位。即每一个位可以描述64位。例如一个对象长度对齐之后为24字节,理论上它占用3个位来描述这个24字节已被使用了,实际上并不需要,在标记的时候只需要标记这3个位中的第一个位,再结合堆分区对象的大小信息就能准确找出。其最主要的目的是为了效率,标记一个位和标记3个位相比能节约不少时间,如果对象很大,则更划算。这些都是源码的实现细节,大家在阅读源码时需要细细斟酌。
写屏障
我们已经解决了如何使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。
卡表元素何时变脏的答案是很明确的——有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景中呢?经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。
在HotSpot虚拟机里是 通过写屏障(Write Barrier)技术维护卡表状态的。先请读者注意将这里提到的“写屏障”,以及后面在低延迟收集器中会提到的“读屏障”与解决并发乱序执行问题中的“内存屏障”区分开来,避免混淆。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障。下面这段代码清单3-6是一段更新卡表状态的简化逻辑:
代码清单3-6 写后屏障更新卡表
void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作 *field = new_value; // 写后屏障,在这里完成卡表状态更新 post_write_barrier(field, new_value);}
应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。
除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。
假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,即将卡表更新的逻辑变为以下代码所示:
if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;
在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。
并发的可达性分析
在3.2节中曾经提到了当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。在根节点枚举(见3.4.1节)这个步骤中,由于GC Roots相比起整个Java堆中全部的对象毕竟还算是极少数,且在各种优化技巧(如OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。可从GC Roots再继续往下遍历对象图,这一步骤的停顿时间就必定会与Java堆容量直接成正比例关系了:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长,这听起来是理所当然的事情。
要知道包含“标记”阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器,同理可知,如果能够削减这部分停顿时间的话,那收益也将会是系统性的。
想解决或者降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?为了能解释清楚这个问题,我们引入三色标记(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:
· 白色:
表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
· 黑色:
表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
· 灰色:
表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
关于可达性分析的扫描过程,读者不妨发挥一下想象力,把它看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程,如果用户线程此时是冻结的,只有收集器线程在工作,那不会有任何问题。但如果用户线程与收集器是并发工作呢?收集器在对象图上标记颜色,同时用户线程在修改引用关系——即修改对象图的结构,这样可能出现两种后果。一种是把原本消亡的对象错误标记为存活,这不是好事,但其实是可以容忍的,只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好。另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了,程序肯定会因此发生错误,下面表3-1演示了这样的致命错误具体是如何产生的。
Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:
· 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
· 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和 原始快照(Snapshot At The Beginning,SATB)。
增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。
二、如何回收
分代收集工作原理 看完这篇垃圾回收,和面试官扯皮没问题了
分代收集理论
当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
1)弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。这里笔者提前提及了一些新的名词,它们都是本章的重要角色,稍后都会逐一登场,现在读者只需要知道,这一切的出现都始于分代收集理论。
把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。如果读者有兴趣阅读HotSpot虚拟机源码的话,会发现里面存在着一些名为“*Generation”的实现,如“DefNewGeneration”和“ParNewGeneration”等,这些就是HotSpot的“分代式垃圾收集器框架”。原本HotSpot鼓励开发者尽量在这个框架内开发新的垃圾收集器,但除了最早期的两组四款收集器之外,后来的开发者并没有继续遵循。导致此事的原因有很多,最根本的是分代收集理论仍在不断发展之中,如何实现也有许多细节可以改进,被既定的代码框架约束反而不便。其实我们只要仔细思考一下,也很容易发现分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。
假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:
3)跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。
依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构 把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
注意
刚才我们已经提到了“Minor GC”,后续文中还会出现其他针对不同分代的类似名词,为避免读者产生混淆,在这里统一定义:
· 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
■ 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
■ 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。 目前只有CMS收集器会有单独收集老年代的行为。另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。
■ 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
· 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
1.分代回收
我们从一个object1来说明其在分代垃圾回收算法中的回收轨迹。
1、object1新建,出生于新生代的Eden区域。
2、Eden区满触发minor GC,object1 还存活,移动到Fromsuvivor空间,此时还在新生代。
3、Eden区满触发minor GC,object1 仍然存活,此时会通过复制算法,将object1移动到ToSuv区域,此时object1的年龄age+1。
4、Eden区满触发minor GC,object1 仍然存活,此时survivor中和object1同龄的对象并没有达到survivor的一半,所以此时通过复制算法,将fromSuv和Tosuv 区域进行互换,存活的对象被移动到了Tosuv。
5、Eden区满触发minor GC,object1 仍然存活,此时survivor中和object1同龄的对象已经达到survivor的一半以上(toSuv的区域已经满了),object1被移动到了老年代区域。
6、object1存活一段时间后,发现此时object1不可达GcRoots,而且此时老年代空间比率已经超过了阈值,触发了majorGC(也可以认为是fullGC,但具体需要垃圾收集器来联系),此时object1被回收了。fullGC会触发 stop the world。
1、对象在新生代的分配与回收
由以上的分析可知,大部分对象在很短的时间内都会被回收,对象一般分配在 Eden 区
当 Eden 区将满时,触发 Minor GC
我们之前怎么说来着,大部分对象在短时间内都会被回收, 所以经过 Minor GC 后只有少部分对象会存活,它们会被移到 S0 区(这就是为啥空间大小 Eden: S0: S1 = 8:1:1, Eden 区远大于 S0,S1 的原因,因为在 Eden 区触发的 Minor GC 把大部对象(接近98%)都回收了,只留下少量存活的对象,此时把它们移到 S0 或 S1 绰绰有余)同时对象年龄加一(对象的年龄即发生 Minor GC 的次数),最后把 Eden 区对象全部清理以释放出空间,动图如下
当触发下一次 Minor GC 时,会把 Eden 区的存活对象和 S0(或S1) 中的存活对象(S0 或 S1 中的存活对象经过每次 Minor GC 都可能被回收)一起移到 S1(Eden 和 S0 的存活对象年龄+1), 同时清空 Eden 和 S0 的空间。
若再触发下一次 Minor GC,则重复上一步,只不过此时变成了 从 Eden,S1 区将存活对象复制到 S0 区,每次垃圾回收, S0, S1 角色互换,都是从 Eden ,S0(或S1) 将存活对象移动到 S1(或S0)。也就是说在 Eden 区的垃圾回收我们采用的是 复制算法 ,因为在 Eden 区分配的对象大部分在 Minor GC 后都消亡了,只剩下极少部分存活对象(这也是为啥 Eden:S0:S1 默认为 8:1:1 的原因),S0,S1 区域也比较小,所以最大限度地降低了复制算法造成的对象频繁拷贝带来的开销。
2、对象何时晋升老年代
对象什么时候会被分配到老年代
在以上的新生代中,我们有提到对象的age,对象存活于survivor状态下,不会立即晋升为老生代对象,以避免给老生代造成过大的影响,它们必须要满足以下条件才可以晋升:
1、长期存活对象进入老年代:minor gc 之后,存活于survivor 区域的对象的age会+1,超过(-XX:MaxTenuringThreshold的时候,转移到老年代。
2、动态计算:如果survivor空间中相同年龄所有的对象大小的总和大于survivor空间的(- XX:TargetSurvivorRatio默认50%)一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold中要求的年龄。
3、大量连续内存空间的Java对象(例如大数组)被分配到老年代。 -XX:PretenureSizeThreshold= (字节数)来代表当对象超过多大时就不在新生代分配,而是直接在老年代上分配内存空间。
-XX:PretenureSizeThreshold参数只对Serial和ParNew两款新生代收集器有效,HotSpot的其他新生代收集器,如Parallel Scavenge并不支持这个参数。如果必须使用此参数进行调优,可考虑ParNew加CMS的收集器组合。
-
当对象的年龄达到了我们设定的阈值,则会从S0(或S1)晋升到老年代 如图示:年龄阈值设置为 15, 当发生下一次 Minor GC 时,S0 中有个对象年龄达到 15,达到我们的设定阈值,晋升到老年代!
-
大对象 当某个对象分配需要大量的连续内存时,此时对象的创建不会分配在 Eden 区,会直接分配在老年代,因为如果把大对象分配在 Eden 区, Minor GC 后再移动到 S0,S1 会有很大的开销(对象比较大,复制会比较慢,也占空间),也很快会占满 S0,S1 区,所以干脆就直接移到老年代.
-
还有一种情况也会让对象晋升到老年代,即在 S0(或S1) 区相同年龄的对象大小之和大于 S0(或S1)空间一半以上时,则年龄大于等于该年龄的对象也会晋升到老年代。
3、空间分配担保(发生Minor GC时)
HandlePromotionFailure 相当于从JDK7开始就不生效了,相当于默认打开了。
36.堆空间的参数设置和-XX:HandlePromotionFailure_simpleGq的博客-CSDN博客
JAVA垃圾回收:HandlePromotionFailure不打开的话,是不是就永不会发生minor gc? - SegmentFault 思否
在 发生Minor GC之前,虚拟机必须先检查老年代 最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次MinorGC,尽管这次Minor GC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。
解释一下“冒险”是冒了什么风险:前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况——最极端的情况就是内存回收后新生代中所有对象都存活,需要老年代进行分配担保,把Survivor无法容纳的对象直接送入老年代,这与生活中贷款担保类似。老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,但一共有多少对象会在这次回收中活下来在实际完成内存回收之前是无法明确知道的,所以只能取之前每一次回收晋升到老年代对象容量的平均大小作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。
取历史平均值来比较其实仍然是一种赌概率的解决办法,也就是说假如某次Minor GC存活后的对象突增,远远高于历史平均值的话,依然会导致担保失败。如果出现了担保失败,那就只好老老实实地重新发起一次Full GC,这样停顿时间就很长了。虽然担保失败时绕的圈子是最大的,但通常情况下都还是会将-XX:HandlePromotionFailure开关打开,避免Full GC过于频繁。
空间分配担保(Handle Promotion):当进行 Young GC 之前,JVM 需要预估:老年代是否能够容纳 Young GC 后新生代晋升到老年代的存活对象,以确定是否需要提前触发 GC 回收老年代空间,基于空间分配担保策略来计算。
continueSize,老年代最大可用连续空间:
Young GC 之后如果成功(Young GC 后晋升对象能放入老年代),则代表担保成功,不用再进行 Full GC, 提高性能。
如果失败,则会出现“promotion failed”错误,代表担保失败,需要进行 Full GC。
4、Stop The World
如果老年代满了,会触发 Full GC, Full GC 会同时回收新生代和老年代(即对整个堆进行GC),它会导致 Stop The World(简称 STW),造成挺大的性能开销。
什么是 STW ?所谓的 STW, 即在 GC(minor GC 或 Full GC)期间,只有垃圾回收器线程在工作,其他工作线程则被挂起。
画外音:为啥在垃圾收集期间其他工作线程会被挂起?想象一下,你一边在收垃圾,另外一群人一边丢垃圾,垃圾能收拾干净吗。
一般 Full GC 会导致工作线程停顿时间过长(因为Full GC 会清理 整个堆 中的不可用对象,一般要花较长的时间),如果在此 server 收到了很多请求,则会被拒绝服务!所以我们要尽量减少 Full GC(Minor GC 也会造成 STW,但只会触发轻微的 STW,因为 Eden 区的对象大部分都被回收了,只有极少数存活对象会通过复制算法转移到 S0 或 S1 区,所以相对还好)。
现在我们应该明白把新生代设置成 Eden, S0,S1区或者给对象设置年龄阈值或者默认把新生代与老年代的空间大小设置成 1:2 都是为了 尽可能地避免对象过早地进入老年代,尽可能晚地触发 Full GC 。想想新生代如果只设置 Eden 会发生什么,后果就是每经过一次 Minor GC,存活对象会过早地进入老年代,那么老年代很快就会装满,很快会触发 Full GC,而对象其实在经过两三次的 Minor GC 后大部分都会消亡,所以有了 S0,S1的缓冲,只有少数的对象会进入老年代,老年代大小也就不会这么快地增长,也就避免了过早地触发 Full GC。
由于 Full GC(或Minor GC) 会影响性能,所以我们要在一个合适的时间点发起 GC,这个时间点被称为 Safe Point,这个时间点的选定既不能太少以让 GC 时间太长导致程序过长时间卡顿,也不能过于频繁以至于过分增大运行时的负荷。一般当线程在这个时间点上状态是可以确定的,如确定 GC Root 的信息等,可以使 JVM 开始安全地 GC。Safe Point 主要指的是以下特定位置:
以上采用分代垃圾收集的思想,对一个对象从存活到死亡所经历的历程。期间,在新生代的时刻,会用到复制算法,在老年代时,有可能会用到标记-清除算法(mark-sweep)算法或者标记-整理算法,这些都是垃圾回收算法基于不同区域的实现,我们看下这几种回收算法的实现原理。
三、垃圾回收算法
0.引用计数法(Reference Counting)
很多教科书判断对象是否存活的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。笔者面试过很多应届生和一些有多年工作经验的开发人员,他们对于这个问题给予的都是这个答案。客观地说,引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。也有一些比较著名的应用案例,例如微软COM(Component ObjectModel)技术、使用ActionScript 3的FlashPlayer、Python语言以及在游戏脚本领域得到许多应用的Squirrel中都使用了引用计数算法进行内存管理。但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。
1.标记清除法(Mark-Sweep) CMS就是标记清除算法。
标记清除法是垃圾回收算法的思想基础。标记清除算法将垃圾分为两个阶段:标记阶段和清除阶段。
标记阶段,通过根节点,标记所有从根节点开始的可达对象,未标记过的对象就是未被引用的垃圾对象。
清除阶段,清除所有未被标记的对象。
要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2.复制算法(Copying)
复制算法是,将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在适用的内存中存活对象复制到未使用的内存块,然后清除使用的内存块中所有的对象。
缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点
3.标记压缩或叫标记整理算法(Mark-Compact)
标记压缩算法是一种老年代的回收算法。
标记阶段和标记清除算法一致,对可达对象做一次标记。
清理阶段,为了避免内存碎片产生,将所有的存活对象压缩到内存的一端。
如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”。
但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。
基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。 从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。此语境中,吞吐量的实质是赋值器(Mutator,可以理解为使用垃圾收集的用户程序,本书为便于理解,多数地方用“用户程序”或“用户线程”代替)与收集器的效率总和。即使不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的,这也从侧面印证这点。
另外,还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。
4.分代算法(Generation Collecting)
5.分区算法(Region)
四、垃圾收集器
并行和并发
吞吐量(Throughput)
吞吐量就是 CPU用于运行用户代码的时间与 CPU总消耗时间的比值,即 吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。假设虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
垃圾收集器指标
停顿时间是指进行垃圾收集时,用户线程的暂停时间
吞吐量是指用于执行用户线程的时间占总应用时间的比率,对于无需和用户进行交互的纯后台应用来说,停顿时间没那么重要,更看重的是吞吐量的大小,吞吐量越大,说明执行用户线程的时间更长,处理速度就越高。
大多数情况下对 Java 程序进行 GC 调优,主要关注两个目标:
-
响应速度(Responsiveness ) : 响应速度指程序或系统对一个请求的响应有多迅速。
比如,用户订单查询响应时间,对响应速度要求很高的系统,较大的停顿时间是不可接受的。调优的重点是在短的时间内快速响应。
-
吞吐量(Throughput ) : 吞吐量关注在一个特定时间段内应用系统的最大工作量。
例如每小时批处理系统能完成的任务数量,在吞吐量方面优化的系统,较长的 GC 停顿时间也是可以接受的,因为高吞吐量应用更关心的是如何尽可能快地完成整个任务,不考虑快速响应用户请求。
GC 调优中,GC 导致的应用暂停时间影响系统响应速度,GC 处理线程的 CPU 使用率影响系统吞吐量。
|
新生代GC策略
|
年老代GC策略
|
说明
|
组合1
|
Serial
|
Serial Old
|
Serial和Serial Old都是单线程进行GC,特点就是GC时暂停所有应用线程。
|
组合2
|
Serial
|
CMS+Serial Old
|
CMS(Concurrent Mark Sweep)是并发GC,实现GC线程和应用线程并发工作,不需要暂停所有应用线程。另外,当CMS进行GC失败时,会自动使用Serial Old策略进行GC。
|
组合3
|
ParNew
|
CMS
|
使用-XX:+UseParNewGC选项来开启。ParNew是Serial的并行版本,可以指定GC线程数,默认GC线程数为CPU的数量。可以使用-XX:ParallelGCThreads选项指定GC的线程数。
如果指定了选项-XX:+UseConcMarkSweepGC选项,则新生代默认使用ParNew GC策略。
|
组合4
|
ParNew
|
Serial Old
|
使用-XX:+UseParNewGC选项来开启。新生代使用ParNew GC策略,年老代默认使用Serial Old GC策略。
|
组合5
|
Parallel Scavenge
|
Serial Old
|
Parallel Scavenge策略主要是关注一个可控的吞吐量:应用程序运行时间 / (应用程序运行时间 + GC时间),可见这会使得CPU的利用率尽可能的高,适用于后台持久运行的应用程序,而不适用于交互较多的应用程序。
|
组合6
|
Parallel Scavenge
|
Parallel Old
|
Parallel Old是Serial Old的并行版本
|
组合7
|
G1GC
|
G1GC
|
-XX:+UnlockExperimentalVMOptions -XX:+UseG1GC #开启
-XX:MaxGCPauseMillis =50 #暂停时间目标
-XX:GCPauseIntervalMillis =200 #暂停间隔目标
-XX:+G1YoungGenSize=512m #年轻代大小
-XX:SurvivorRatio=6 #幸存区比例
|
通用垃圾回收参数
G1 常用参数
并行(Parallel)与并发(Concurrent) 仅一字之差,
并行指多条垃圾收集线程并行
并发指用户线程与垃圾收集线程并发,程序在继续运行,而垃圾收集程序运行于另一个CPU上。并行指多条垃圾收集线程并行,并发指用户线程与垃圾收集线程并发.
设置GC方式:
Parallel收集器就是Parallel Scanvenge GC。
GC组合
|
Minor GC
|
Full GC
|
描述
|
-XX:+UseSerialGC
|
Serial收集器串行回收
|
Serial Old收集器串行回收
|
该选项可以手动指定Serial收集器+Serial Old收集器组合执行内存回收
|
-XX:+UseParNewGC
|
ParNew收集器并行回收
|
Serial Old收集器串行回收
|
该选项可以手动指定ParNew收集器+Serilal Old组合执行内存回收
|
-XX:+UseParallelGC
|
Parallel收集器并行回收
|
Serial Old收集器串行回收
|
该选项可以手动指定Parallel收集器+Serial Old收集器组合执行内存回收
|
-XX:+UseParallelOldGC
|
Parallel收集器并行回收
|
Parallel Old收集器并行回收
|
该选项可以手动指定Parallel收集器+Parallel Old收集器组合执行内存回收
|
-XX:+UseConcMarkSweepGC
|
ParNew收集器并行回收
|
缺省使用CMS收集器并发回收,备用采用Serial Old收集器串行回收
|
该选项可以手动指定ParNew收集器+CMS收集器+Serial Old收集器组合执行内存回收。优先使用ParNew收集器+CMS收集器的组合,当出现ConcurrentMode Fail或者Promotion Failed时,则采用ParNew收集器+Serial Old收集器的组合
|
-XX:+UseConcMarkSweepGC-XX:-UseParNewGC
|
Serial收集器串行回收
|
-XX:+UseG1GC
|
G1收集器并发、并行执行内存回收
|
暂无
|
垃圾收集器
1.Serial GC 串行GC( 新生代)( 复制算法) 收集器
执行时其他线程必须暂停 Stop the world。 它在进行垃圾收集时,必须暂停其他所有的工作线程,直至Serial收集器收集结束为止(“Stop The World”)。在整个扫描和复制过程中均采用单线程的方式来进行,更加适合于单CPU、新生代空间较小及对暂停时间要求不高的应用上。也是client级别(CPU核数小于2或物理内存小于2GB)或32位Windows机器上默认采用的GC方式
触发条件:是否够放新对象
最基本、发展历史最久的收集器,这个收集器是一个采用复制算法的单线程的收集器,单线程一方面意味着它只会使用一个CPU或一条线程去完成垃圾收集工作,另一方面也意味着它进行垃圾收集时必须暂停其他线程的所有工作,直到它收集结束为止。后者意味着,在用户不可见的情况下要把用户正常工作的线程全部停掉,这对很多应用是难以接受的。不过实际上到目前为止,Serial收集器依然是虚拟机运行在Client模式下的默认新生代收集器,因为它简单而高效。用户桌面应用场景中,分配给虚拟机管理的内存一般来说不会很大,收集几十兆甚至一两百兆的新生代停顿时间在几十毫秒最多一百毫秒,只要不是频繁发生,这点停顿是完全可以接受的。
2.ParNew GC 并行GC( 新生代)( 复制算法)( 只有它能与 CMS 收集器配合工作) 收集器
Serial收集器的简单多线程版
触发条件:是否够放新对象
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集外,其余行为和Serial收集器完全一样,包括使用的也是复制算法。ParNew收集器除了多线程以外和Serial收集器并没有太多创新的地方,但是它却是Server模式下的虚拟机首选的新生代收集器,其中有一个很重要的和性能无关的原因是, 除了Serial收集器外,目前只有它能与CMS收集器配合工作 (看图)。 CMS收集器是一款几乎可以认为有划时代意义的垃圾收集器,因为它第一次实现了让垃圾收集线程与用户线程基本上同时工作。 ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果,甚至由于线程交互的开销,该收集器在两个CPU的环境中都不能百分之百保证可以超越Serial收集器。当然,随着可用CPU数量的增加,它对于GC时系统资源的有效利用还是很有好处的。它默认开启的收集线程数与CPU数量相同,在CPU数量非常多的情况下,可以使用-XX:ParallelGCThreads参数来设置垃圾收集的线程数。一般最好与CPU数量相当,避免过多的线程数影响垃圾回收效果。在默认情况下,当CPU数量小于8时,PararllelGCThreads的值等于CPU数量,当CPU数量大于8时,ParallelGCThreads的值等于3+((5xCPU_Count)/8)
3.Parallel Scavenge GC 并行回收GC( 新生代收集器)( 吞吐量优先)收集器 Parallel收集器是虚拟机运行在Server模式下的默认垃圾收集器。 基于标记-整理算法。
ParallelGC与ParNew的区别就是它是吞吐量优先的。
Parallel Scavenge收集器也是一个 并行的 多线程新生代收集器。Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是 达到一个可控制的吞吐量(Throughput)。 停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而 高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合 在后台运算而不需要太多交互的任务。
扫描和复制时均采用多线程方式进行。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。
-XX:MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。不过大家不要异想天开地认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。
-XX:GCTimeRatio参数的值则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5%(即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间。
由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”。除上述两个参数之外,Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy值得我们关注。这是一个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的 自适应的调节策略(GC Ergonomics)。如果读者对于收集器运作不太了解,手工优化存在困难的话,使用Parallel Scavenge收集器配合自适应调节策略,把内存管理的调优任务交给虚拟机去完成也许是一个很不错的选择。 只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用-XX:MaxGCPauseMillis参数(更关注最大停顿时间)或-XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。 自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性。
另外值得注意的一点是,Parallel Scavenge收集器无法与CMS收集器配合使用,所以在JDK 1.6推出Parallel Old之前,如果新生代选择Parallel Scavenge收集器,老年代只有Serial Old收集器能与之配合使用。
-XX:+UseAdaptiveSizePolicy:设置此选项后,并行收集器会自动选择年轻代区大小和相应的Survivor区比例,以达到目标系统规定的最低相应时间或者收集频率等,此值建议使用并行收集器时,一直打开。
JDK 8 是默认开启 -XX:+UseAdaptiveSizePolicy 很多垃圾收集器都可以用
-XX:+PrintAdaptiveSizePolicy 打印自适应收集的大小
Parallel Scavenge收集器提供了几个参数用于精确控制吞吐量和停顿时间:
4.Serial Old( 老年代、持久代)( 标记-整理算法)
收集器 执行时,其他线程必须暂停 Stop the world
基于Mark-Sweep-Compact 实现。 client级别(CPU核数小于2或物理内存小于2GB)或32位Windows机器上默认采用的GC方式
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。如果在服务端模式下,它也可能有两种用途:一种是在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用,另外一种就是作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。
5.Parallel Old( 老年代、持久代)( 标记-整理算法)
基于Mark-Compact 实现。 server级别(CPU核数超过2且物理内存超过2GB)的机器(32位Window机器除外)上都是用这种方式作为默认GC.
Parallel Scavenge收集器的老年代版本( 不是ParNew哦! ),也关注吞吐量,使用多线程和“标记-整理”算法。这个收集器在JDK 1.6之后的出现,“吞吐量优先收集器”终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel收集器+Parallel Old收集器的组合。
老年代除了Serial Old以外别无选择,所以在Parallel Old诞生以后, “吞吐量优先”收集器终于有了比较名副其实的应用组合,在 注重吞吐量以及 CPU资源敏感的场合,都可以 优先考虑Parallel Scavenge加Parallel Old收集器。
参数-XX:ParallelGCThreads也可以用与设置垃圾回收时的线程数。
每一次FullGC之前都会伴随一次新生代GC(与串行回收器相比很不同),为了避免将所有回收工作同时交给一次Full GC进行,从而尽可能地缩短一次停顿时间。使用-XX:-ScavengeBeforeFullGC去掉。
JDK8 默认的垃圾收集器就是 Parallel Scavenge + Parallel Old
6.CMS (Cocurrent Mark-Sweep GC( 老年代、持久代)( 标记-清除算法)) 从JDK9开始已经是Deprecated的了
让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。
CMS(Concurrent Mark and Swee 并发-标记-清除),是一款基于并发、使用标记清除算法的垃圾回收算法,只针对老年代进行垃圾回收。CMS收集器工作时,尽可能让GC线程和用户线程并发执行,以达到降低STW时间的目的
能与CMS搭配使用的新生代垃圾收集器有Serial收集器和ParNew收集器。
https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/cms.html
启动时候由于MetaspaceSize 配置的不好引发的Full GC
CMS(Concurrent Mark Sweep)收集器是一种以 获取最短回收停顿时间为目标的收集器,它非常符合那些集中在互联网站或者B/S系统的服务端上的Java应用,这些应用都非常重视服务的响应速度。从名字上(“Mark Sweep”)就可以看出它是基于 “标记-清除”算法实现的。
CMS收集器工作的整个流程分为以下步骤: 英文单词很重要,因为gclog里的阶段都是这些词。
-
初始标记(CMS initial mark) :仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“ Stop The World ”。
-
并发标记(CMS concurrent mark) :进行 GC Roots Tracing 的过程,在整个过程中耗时最长。本质是在堆上执行三色标记法。
-
预清理 : (CMS concurrent preclean) 清理前准备及控制停顿时间。可以通过 -XX:-CMSPrecleaningEnabled 不进行预清理。预清理是并发的,除了为正式清理做准备和检查,还会尝试控制一次停顿时间。由于重新标记是独占CPU的,如果新生代GC发生后,立即触发一次重新标记,那么一次停顿的时间可能很长。为了避免这种情况,预处理时会刻意等待一次新生代GC的发生,然后根据历史新能数据预测下一次新生代GC可能发生的时间,在当前时间和预测时间的中间时刻进行重新标记。这样可尽量避免新生代GC和重新标记重合,尽可能减少一次停顿的时间。
-
重新标记(CMS remark) :为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要“ Stop The World ”。使用卡表来修正可能会在并发标记阶段受Mutator线程(用户线程)影响的标记。
-
并发清除(CMS concurrent sweep):垃圾清理
-
并发重置 (CMS concurrent reset):垃圾回收完成后,重新初始化CMS数据结构和数据,为下一次垃圾回收做好准备。
由于整个过程中 耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作。所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。通过下图可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的时间:
由于CMS回收器不是独占式的回收器,在CMS回收过程中,应用程序任然在不停地工作。在应用程序工作过程中,又会不断地产生垃圾。这些新生成的垃圾在当前CMS回收过程中是无法清除的。同时,因为应用程序没有中断,所以在CM回收过程中,还应确保应用程序有足够的内存可用。因此,CMS回收器不会等待堆内存饱和时才进行垃圾回收,而是当堆内存使用率达到某一阈值时便开始进行回收,以确保应用程序在CMS工作过程中,依然有足够的空间支持应用运行。
这个回收阈值可以使用参数-XX:CMSInitiatingOccupancyFraction来指定,默认是68,即当老年代的空间使用率达到68%时,会执行一次CMS回收。
如果应用程序的内存使用率增长很快,在CMS的执行过程中,已经出现了内存不足的情况,CMS回收就会失败,虚拟机将启动老年代串行垃圾回收器进行垃圾回收。此时,应用程序将完全中断,知道垃圾回收完成。这时,应用程序的暂停时间可能会更长。JVM 也会基于之前的GC的频率及旧生代的增长趋势来评估什么时候执行CMS GC(只是第一次参考CMSInitiatingOccupancyFraction)(怎么看现在是多少呢???),如果不希望JVM自动触发,可以设置-XX:UseCMSInitiatingOccupancyOnly=true。
CMS是一个基于垃圾标记清除法的回收器,会产生大量的内存碎片,离散的可用空间无法分配给较大的对象。在这种情况下,即使堆内存依然有较大的剩余空间,也可能会被迫进行一次垃圾回收(ParalleOld),以换取一块可用的连续内存。这种现象对系统性能是相当不利的,为了解决这个问题,CMS回收器还提供了几个用于内存压缩整理的参数。
-XX:+UseCMSCompactAtFullCollection参数可以使CMS在垃圾收集完成后,进行一次内存碎片整理, 内存碎片的整理不是并发进行的。
-XX:CMSFullGCsBeforeCompaction参数可以用于设定进行多少次CMS回收后,进行一次内存压缩。
-XX:+CMSIncrementalMode:设置为增量收集模式。一般适用于单CPU情况。JDK8中标记为已废弃,JDK9中彻底移除。
-XX:+CMSClassUnloadingEnabled 这个参数表示在使用CMS垃圾回收机制的时候是否启用类卸载功能。默认这个是设置为不启用的.
-XX:+CMSPermGenSweepingEnabled 已经被-XX:+CMSClassUnloadingEnabled取代。
优点CMS是一款优秀的收集器,它的主要 优点在名字上已经体现出来了: 并发收集、 低停顿,因此CMS收集器也被称为 并发低停顿收集器(Concurrent Low Pause Collector)。
缺点
-
对CPU资源非常敏感 其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。
-
CMS默认启动的并发线程数是(ParallelGCThreads+3)/4 ,ParallelGCThreads表示GC并行时使用的线程数量,如果新生代使用ParNew,那么ParallelGCThreads也就是新生代GC的线程数量。这意味着有4个ParallelGCThreads时,只有1个并发线程,而有两个并发线程时,有5~8个ParallelGCThreads线程。 并发线程数也可以通过-XX:ConsGCThreads或者-XX:ParallelCMSThreads参数手工指定。
-
无法处理浮动垃圾(Floating Garbage) 可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。
-
由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。 这一部分垃圾出现在标记过程之后,CMS无法再当次收集中处理掉它们,只好留待下一次GC时再清理掉。 这一部分垃圾就被称为 “浮动垃圾” 。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。 JDK 1.5 默认当老年代使用了68%空间后就会被激活,当然这个比例可以通过 -XX:CMSInitiatingOccupancyFraction 来设置,但是如果设置地太高很容易导致在 CMS 运行期间预留的内存无法满足程序要求,会导致 Concurrent Mode Failure 失败,这时会启用 Serial Old 收集器来重新进行老年代的收集,而我们知道 Serial Old 收集器是单线程收集器,这样就会导致 STW 更长了。
-
标记-清除算法导致的空间碎片 CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有 大量空间碎片产生 。 空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象 ,将会触发 Full GC,这会影响应用的性能。当然我们可以开启 -XX:+UseCMSCompactAtFullCollection(默认是开启的),用于在 CMS 收集器顶不住要进行 FullGC 时开启内存碎片的合并整理过程,内存整理会导致 STW,停顿时间会变长,还可以用另一个参数 -XX:CMSFullGCsBeforeCompation 用来设置执行多少次不压缩的 Full GC 后跟着带来一次带压缩的。
CMS 常用参数 java -XX:+PrintFlagsFinal -version|grep cms -i
CMS回收器相关的参数。
·-XX:+UseConcMarkSweepGC:新生代使用并行回收器,老年代使用CMS+串行回收器。
·-XX:ParallelCMSThreads:设定CMS的线程数量。
·-XX:CMSInitiatingOccupancyFraction:设置CMS回收器在老年代空间被使用多少后触发,默认为68%。
·-XX:+UseCMSCompactAtFullCollection:设置CMS回收器在完成垃圾收集后是否要进行一次内存碎片的整理。
·-XX:CMSFullGCsBeforeCompaction:设置进行多少次CMS垃圾回收后,进行一次内存压缩。
·-XX:+CMSClassUnloadingEnabled:允许对类元数据进行回收。
·-XX:+CMSParallelRemarkEnabled:启用并行重标记。
·-XX:CMSInitiatingPermOccupancyFraction:当永久区占用率达到这一比例时,启动CMS回收(前提是-XX:+CMSClassUnloadingEnabled被激活了)。
·-XX:UseCMSInitiatingOccupancyOnly:表示只在到达阈值的时候才进行CMS回收。
·-XX:+CMSIncrementalMode:使用增量模式,比较适合单CPU。
CMS的缺点:
CMS是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿,一些官方公开文档里面也称之为“并发低停顿收集器”(Concurrent Low Pause Collector)。CMS收集器是HotSpot虚拟机追求低停顿的第一次成功尝试,但是它还远达不到完美的程度,至少有以下三个明显的缺点:
首先,CMS收集器对处理器资源非常敏感。事实上,面向并发设计的程序都对处理器资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。CMS默认启动的回收线程数是(处理器核心数量+3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。为了缓解这种情况,虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)的CMS收集器变种,所做的事情和以前单核处理器年代PC机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样,是在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些,直观感受是速度变慢的时间更多了,但速度下降幅度就没有那么明显。实践证明增量式的CMS收集器效果很一般,从JDK 7开始,i-CMS模式已经被声明为“deprecated”,即已过时不再提倡用户使用,到JDK9发布后i-CMS模式被完全废弃。
然后,由于CMS收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。在JDK 5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在实际应用中老年代增长并不是太快,可以适当调高参数-XX:CMSInitiatingOccu-pancyFraction的值来提高CMS的触发百分比,降低内存回收频率,获取更好的性能。到了JDK 6时,CMS收集器的启动阈值就已经默认提升至92%。但这又会更容易面临另一种风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。所以参数-XX:CMSInitiatingOccupancyFraction设置得太高将会很容易导致大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。
还有最后一个缺点,在本节的开头曾提到,CMS是一款基于“标记-清除”算法实现的收集器,如果读者对前面这部分介绍还有印象的话,就可能想到这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMS-CompactAtFullCollection开关参数(默认是开启的,此参数从JDK 9开始废弃),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象,(在Shenandoah和ZGC出现前)是无法并发的。这样空间碎片问题是解决了,但停顿时间又会变长,因此虚拟机设计者们还提供了另外一个参数-XX:CMSFullGCsBefore-Compaction(此参数从JDK 9开始废弃),这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定)不整理空间的Full GC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。
CMS GC 以获取最小停顿时间为目的,尽可能减少 STW 时间,可以分为 7 个阶段:
阶段 1:初始标记(Initial Mark )
此阶段的目标是标记老年代中所有存活的对象, 包括 GC Root 的直接引用, 以及由新生代中存活对象所引用的对象,触发第一次 STW 事件。
这个过程是支持多线程的(JDK7 之前单线程,JDK8 之后并行,可通过参数 CMSParallelInitialMarkEnabled 调整)。
阶段 2:并发标记(Concurrent Mark )
此阶段 GC 线程和应用线程并发执行,遍历阶段 1 初始标记出来的存活对象,然后继续递归标记这些对象可达的对象。
阶段 3:并发预清理(Concurrent Preclean )
此阶段 GC 线程和应用线程也是并发执行,因为阶段 2 是与应用线程并发执行,可能有些引用关系已经发生改变。
通过卡片标记(Card Marking ) ,提前把老年代空间逻辑划分为相等大小的区域(Card ) 。
如果引用关系发生改变,JVM 会将发生改变的区域标记为“脏区”(Dirty Card ) ,然后在本阶段,这些脏区会被找出来,刷新引用关系,清除“脏区”标记。
阶段 4:并发可取消的预清理(Concurrent Abortable Preclean )
此阶段也不停止应用线程。本阶段尝试在 STW 的最终标记阶段(Final Remark ) 之前尽可能地多做一些工作,以减少应用暂停时间。
在该阶段不断循环处理:标记老年代的可达对象、扫描处理 Dirty Card 区域中的对象,循环的终止条件有:
-
达到循环次数
-
达到循环执行时间阈值
-
新生代内存使用率达到阈值
阶段 5:最终标记(Final Remark )
这是 GC 事件中第二次(也是最后一次 ) STW 阶段,目标是完成老年代中所有存活对象的标记。
在此阶段执行:
-
遍历新生代对象,重新标记
-
根据 GC Roots,重新标记
-
遍历老年代的 Dirty Card,重新标记
阶段 6:并发清除(Concurrent Sweep )
此阶段与应用程序并发执行,不需要 STW 停顿,根据标记结果清除垃圾对象。
阶段 7:并发重置(Concurrent Reset )
此阶段与应用程序并发执行,重置 CMS 算法相关的内部数据, 为下一次 GC 循环做准备。
CMS 常见问题
①最终标记阶段停顿时间过长问题
CMS 的 GC 停顿时间约 80% 都在最终标记阶段(Final Remark ) ,若该阶段停顿时间过长,常见原因是新生代对老年代的无效引用,在上一阶段的并发可取消预清理阶段中,执行阈值时间内未完成循环,来不及触发 Young GC,清理这些无效引用。
通过添加参数: -XX:+CMSScavengeBeforeRemark。
在执行最终操作之前先触发 Young GC,从而减少新生代对老年代的无效引用,降低最终标记阶段的停顿。
但如果在上个阶段(并发可取消的预清理 ) 已触发 Young GC,也会重复触发 Young GC。
②并发模式失败(concurrent mode failure)&晋升失败(promotion failed)问题。 都会触发单线程(也有的书说是ParallelOld)FullGC。
并发模式失败: 当 CMS 在执行回收时,新生代发生垃圾回收,同时老年代又没有足够的空间容纳晋升的对象时,CMS 垃圾回收就会退化成单线程 ( 也有的书说是ParallelOld ) 的 Full GC。 所有的应用线程都会被暂停 ,老年代中所有的无效对象都被回收。
晋升失败: 当新生代发生垃圾回收,老年代 有足够的空间 可以容纳晋升的对象,但是由于 空闲空间的碎片化 ,导致晋升失败,此时会触发单线程( 也有的书说是ParallelOld )且带压缩动作的 Full GC。
并发模式失败和晋升失败都会导致长时间的停顿,常见解决思路如下:
③内存碎片问题
通常 CMS 的 GC 过程基于标记清除算法,不带压缩动作,导致越来越多的内存碎片需要压缩。
常见以下场景会触发内存碎片压缩:
可通过参数 CMSFullGCsBeforeCompaction 的值,设置多少次 Full GC 触发一次压缩。
默认值为 0,代表每次进入 Full GC 都会触发压缩,带压缩动作的算法为上面提到的单线程 Serial Old 算法,暂停时间(STW ) 时间非常长,需要尽可能减少压缩时间。
7.G1(Garbage First) 1.7+
看Java虚拟机 JVM故障诊断与性能优化5.4 这个写的简单扼要
看《JVM G1 源码分析与调优》
不建议使用Java 8u40之前版本
文档: Java Hotspot G1 GC的一些关键技术 - 美团技术团队
每个对象被分配到不同的格子,随后GC执行。当一个区域装满之后,对象被分配到另一个区域,并执行GC。这中间不再有从新生代移动到老年代的三个步骤。这个类型是为了替代CMS GC而被创建的,因为CMS GC在长时间持续运作时会产生很多问题。
G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一,JDK 7 Update 4后开始进入商用。在G1收集器之前的其他收集器进行收集的范围都是 整个新生代或者老年代 ,而G1收集器不再是这样,使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但 新生代和老年代不再是物理隔离的 了,它们都是一部分Region的集合。 G1收集器跟踪各个Region里面的垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大(垃圾比例最高)的Region(这也是Garbage-First名称的由来) 。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
G1 最主要的设计目标是:实现可预期及可配置的 STW 停顿时间。
G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果之一,它是一款 面向服务端应用的垃圾收集器,HotSpot开发团队赋予它的使命是(在比较长期的)未来可以替换掉JDK 1.5中发布的CMS收集器。与其他GC收集器相比,G1具备如下特点:
-
并行与并发 G1 能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短“Stop The World”停顿时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
-
分代收集 与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次GC的旧对象来获取更好的收集效果。
-
空间整合 G1从整体来看是基于 “标记-整理” 算法实现的收集器,从局部(两个Region之间)上来看是基于 “复制” 算法实现的。这意味着G1运行期间 不会产生内存空间碎片 ,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
-
可预测的停顿 这是G1相对CMS的一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了降低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。
G1 把堆内存划分成一个个 Region 的意义在于:
映射G1分区的虚拟内存随着时间的推移在不同的代之间切换。例如一个G1分区最初被指定为新生代,经过一次新生代的回收之后,会将整个新生代分区都划入未使用的分区中,那它可以作为新生代分区使用,也可以作为老生代分区使用。很可能在完成一个新生代收集之后,一个新生代的分区在未来的某个时刻可用于老生代分区。同样,在一个老生代分区完成收集之后,它就成为了可用分区,在未来某个时候可作为一个新生代分区来使用。
G1 堆空间划分
分区(Heap Region,HR)或称堆分区,是G1堆和操作系统交互的最小管理单位。
G1的分区类型(HeapRegionType)大致可以分为四类:
1.自由分区(Free Heap Region,FHR)
2.新生代分区(Young Heap Region,YHR)
3.大对象分区(Humongous Heap Region,HHR)
4.老生代分区(Old Heap Region,OHR)
其中新生代分区又可以分为Eden和Survivor;
大对象分区又可以分为:大对象头分区和大对象连续分区。
HR的大小直接影响分配和垃圾回收效率。如果过大,一个HR可以存放多个对象,分配效率高,但是回收的时候花费时间过长;如果太小则导致分配效率低下。为了达到分配效率和清理效率的平衡,HR有一个上限值和下限值,目前上限是32MB,下限是1MB(为了适应更小的内存分配,下限可能会被修改,在目前的版本中HR的大小只能为1MB、2MB、4MB、8MB、16MB和32MB),默认情况下,整个堆空间分为2048个HR(该值可以自动根据最小的堆分区大小计算得出)。
HR大小可由以下方式确定:
·可以通过参数G1HeapRegionSize来指定大小,这个参数的默认值为0。
·启发式推断,即在不指定HR大小的时候,由G1启发式地推断HR大小。HR启发式推断根据堆空间的最大值和最小值以及HR个数进行推断,设置InitialHeapSize(默认为0)等价于设置Xms,设置MaxHeapSize(默认为96MB)等价于设置Xmx。
有regions这样的工具可以看G1的区域布局
巨型对象
Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。
当对象大小超过 Region 的一半,则认为是巨型对象(Humongous Object ) ,直接被分配到老年代的巨型对象区(Humongous Regions ) 。 这样超大对象就直接分配到了老年代,防止了反复拷贝移动。
这些巨型区域是一个连续的区域集,每一个 Region 中最多有一个巨型对象,巨型对象可以占多个 Region。
新生代
新生代大小新生代大小指的是新生代内存空间的大小,前面提到G1中新生代大小按分区组织,即首先计算整个新生代的大小,然后根据上一节中的计算方法计算得到分区大小,两者相除得到需要多少个分区。G1中与新生代大小相关的参数设置和其他GC算法类似,G1中还增加了两个参数G1MaxNewSizePercent和G1NewSizePercent用于控制新生代的大小,整体逻辑如下:·如果设置新生代最大值(MaxNewSize)和最小值(NewSize),可以根据这些值计算新生代包含的最大的分区和最小的分区;注意Xmn等价于设置了MaxNewSize和NewSize,且NewSize=MaxNewSize。·如果既设置了最大值或者最小值,又设置了NewRatio,则忽略NewRatio。·如果没有设置新生代最大值和最小值,但是设置了NewRatio,则新生代的最大值和最小值是相同的,都是整个堆空间/(NewRatio+1)。·如果没有设置新生代最大值和最小值,或者只设置了最大值和最小值中的一个,那么G1将根据参数G1MaxNewSizePercent(默认值为60)和G1NewSizePercent(默认值为5)占整个堆空间的比例来计算最大值和最小值。值得注意的是,如果G1推断出最大值和最小值相等,则说明新生代不会动态变化。不会动态变化意味着G1在后续对新生代垃圾回收的时候可能不能满足期望停顿的时间,具体内容将在后文继续介绍。
按照默认值计算,G1可以管理的最大内存为2048×32MB=64GB。假设设置xms=32G,xmx=128G,则每个堆分区的大小为32M,分区个数动态变化范围从1024到4096个。
如果G1是启发式推断新生代的大小,那么当新生代变化时该如何实现?简单地说,使用一个分区列表,扩张时如果有空闲的分区列表则可以直接把空闲分区加入到新生代分区列表中,如果没有的话则分配新的分区然后把它加入到新生代分区列表中。G1有一个线程专门抽样处理预测新生代列表的长度应该多大,并动态调整。另外还有一个问题,就是分配新的分区时,何时扩展?一次扩展多少内存?G1是自适应扩展内存空间的。参数-XX:GCTimeRatio表示GC与应用的耗费时间比,G1中默认为9,计算方式为_gc_overhead_perc=100.0×(1.0/(1.0+GCTimeRatio)),即G1 GC时间与应用时间占比不超过10%时不需要动态扩展,当GC时间超过这个阈值的10%,可以动态扩展。扩展时有一个参数G1ExpandByPercentOfAvailable(默认值是20)来控制一次扩展的比例,即每次都至少从未提交的内存中申请20%,有下限要求(一次申请的内存不能少于1M,最多是当前已分配的一倍)
老年代
·G1老生代的垃圾回收方式与其他JVM垃圾回收器对老生代处理有着极大的不同。G1老生代的收集不会为了释放老生代的空间对整个老生代做回收。相反,在任意时刻只有一部分老生代分区会被回收,并且, 这部分老生代分区将在下一次增量回收时与所有的新生代分区一起被收集。这就是我们所说的 混合回收(Mixed GC)。在选择老生代分区的时候,优先考虑垃圾多的分区,这也正是垃圾优先这个名字的由来。
预测停顿模型
G1是一个响应时间优先的GC算法,用户可以设定整个GC过程的期望停顿时间,由参数MaxGCPauseMillis控制,默认值200ms。不过它不是硬性条件,只是期望值,G1会努力在这个目标停顿时间内完成垃圾回收的工作,但是它不能保证,即也可能完不成(比如我们设置了太小的停顿时间,新生代太大等)。那么G1怎么满足用户的期望呢?就需要停顿预测模型了。G1根据这个模型统计计算出来的历史数据来预测本次收集需要选择的堆分区数量(即选择收集哪些内存空间),从而尽量满足用户设定的目标停顿时间。如使用过去10次垃圾回收的时间和回收空间的关系,根据目前垃圾回收的目标停顿时间来预测可以收集多少的内存空间。比如最简单的办法是使用算术平均值建立一个线性关系来预测。如过去10次一共收集了10GB的内存,花费了1s,那么在200ms的停顿时间要求下,最多可以收集2GB的内存空间。G1的预测逻辑是基于 衰减平均值和 衰减标准差。衰减平均(Decaying Average)是一种简单的数学方法,用来计算一个数列的平均值,核心是给近期的数据更高的权重,即强调近期数据对结果的影响。衰减平均计算公式如下所示:
式中α为历史数据权值,1-α为最近一次数据权值。即α越小,最新的数据对结果影响越大,最近一次的数据对结果影响最大。不难看出,其实传统的平均就是α取值为(n-1)/n的情况。同理,衰减方差的定义如下:
在G1中提供了3种收集算法。新生代回收、混合回收和FullGC。新生代回收总是收集所有新生代分区,混合回收会收集所有的新生代分区以及部分老生代分区,而Full GC则是对所有的分区处理。
G1的回收阶段可能有4个阶段:
1.新生代GC
2.并发标记周期
3.混合回收(有的资料把并发标记周期和混合回收统称为混合回收)
4.如果需要,可能会进行Full GC。
1.新生代GC
G1新生代的收集方式是并行收集,采用复制算法。与其他JVM垃圾回收器一样,一旦发生一次新生代回收,整个新生代都会被回收,这也就是我们常说的新生代回收(Young GC)。但是G1和其他垃圾回收器不同的地方在于:
·G1会根据 预测时间动态改变新生代的大小。其他垃圾回收新生代的大小也可以动态变化,但这个变化主要是根据内存的使用情况进行的。G1中则是以预测时间为导向,根据内存的使用情况调整新生代分区的数目。
3.混合回收
G1老生代的垃圾回收方式与其他JVM垃圾回收器对老生代处理有着极大的不同。G1老生代的收集不会为了释放老生代的空间对整个老生代做回收。相反,在任意时刻只有一部分老生代分区会被回收,并且,这部分老生代分区将在下一次增量回收时与所有的新生代分区一起被收集。这就是我们所说的混合回收(Mixed GC)。
针对新生代和老年代,G1 提供 2 种 GC 模式,Young GC 和 Mixed GC, 两种会导致 Stop The World 。
Young GC: 当新生代的空间不足时,G1 触发 Young GC 回收新生代空间。
Young GC 主要是对 Eden 区进行 GC,它在 Eden 空间耗尽时触发,基于分代回收思想和复制算法,每次 Young GC 都会选定 所有 新生代的 Region。
同时计算下次 Young GC 所需的 Eden 区和 Survivor 区的空间,动态调整新生代所占 Region 个数来控制 Young GC 开销。
Mixed GC: 当老年代空间达到阈值会触发 Mixed GC,选定所有新生代里的 Region,根据 全局并发标记阶段 (下面介绍到)统计得出收集收益高的若干老年代 Region。
在用户指定的开销目标范围内,尽可能选择收益高的老年代 Region 进行 GC,通过选择哪些老年代 Region 和选择多少 Region 来控制 Mixed GC 开销。
有的书是四个阶段,有的书是五个阶段,还有的书是6各阶段
横跨整个堆内存在G1之前的其他收集器进行收集的范围都是整个新生代或者老生代,而G1不再是这样 。G1在使用时,Java堆的内存布局与其他收集器有很大区别,它 将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但 新生代和老年代不再是物理隔离的了,而都是一部分Region(不需要连续)的集合。 建立可预测的时间模型G1收集器之所以能建立可预测的停顿时间模型,是因为它可以 有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值), 在后台维护一个优先列表,每次根据允许的收集时间, 优先回收价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。 避免全堆扫描——Remembered SetG1把Java堆分为多个Region,就是“化整为零”。但是Region不可能是孤立的,一个对象分配在某个Region中,可以与整个Java堆任意的对象发生引用关系。在做可达性分析确定对象是否存活的时候,需要扫描整个Java堆才能保证准确性,这显然是对GC效率的极大伤害。为了避免全堆扫描的发生,虚拟机 为G1中每个Region维护了一个与之对应的Remembered Set。虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作。检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过CardTable 把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:
· 初始标记(Initial Marking): STW,仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
· 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象。
· 最终标记(Final Marking): STW,对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
· 筛选回收(Live Data Counting and Evacuation): STW,负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
通过下图可以比较清楚地看到G1收集器的运作步骤中并发和需要停顿的阶段(Safepoint处):
全局并发标记 主要是为 Mixed GC 计算找出回收收益较高的 Region 区域
全局并发标记主要是为 Mixed GC 计算找出回收收益较高的 Region 区域,具体分为 5 个阶段:
阶段 1:初始标记(Initial Mark )
暂停所有应用线程(STW),并发地进行标记从 GC Root 开始直接可达的对象(原生栈对象、全局对象、JNI 对象)。
当达到触发条件时,G1 并不会立即发起并发标记周期,而是等待下一次新生代收集,利用新生代收集的 STW 时间段,完成初始标记,这种方式称为借道(Piggybacking)。
阶段 2:根区域扫描(Root Region Scan)
在初始标记暂停结束后,新生代收集也完成的对象复制到 Survivor 的工作,应用线程开始活跃起来。
此时为了保证标记算法的正确性,所有新复制到 Survivor 分区的对象,需要找出哪些对象存在对老年代对象的引用,把这些对象标记成根(Root ) 。
这个过程称为根分区扫描(Root Region Scanning),同时扫描的 Suvivor 分区也被称为根分区(Root Region)。
根分区扫描必须在下一次新生代垃圾收集启动前完成(接下来并发标记的过程中,可能会被若干次新生代垃圾收集打断),因为每次 GC 会产生新的存活对象集合。
阶段 3:并发标记(Concurrent Marking)
标记线程与应用程序线程并行执行,标记各个堆中 Region 的存活对象信息,这个步骤可能被新的 Young GC 打断。
所有的标记任务必须在堆满前就完成扫描,如果并发标记耗时很长,那么有可能在并发标记过程中,又经历了几次新生代收集。
阶段 4:再次标记(Remark )
和 CMS 类似暂停所有应用线程(STW),以完成标记过程短暂地停止应用线程, 标记在并发标记阶段发生变化的对象,和所有未被标记的存活对象,同时完成存活数据计算。
阶段 5:清理(Cleanup )
为即将到来的转移阶段做准备, 此阶段也为下一次标记执行所有必需的整理计算工作:
从上述阶段的描述可以看出, G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望。
从Oracle官方透露出来的信息可获知,回收阶段(Evacuation)其实本也有想过设计成与用户程序一起并发执行,但这件事情做起来比较复杂,考虑到G1只是回收一部分Region,停顿时间是用户可控制的,所以并不迫切去实现,而选择把这个特性放到了G1之后出现的低延迟垃圾收集器(即ZGC)中。另外,还考虑到G1不是仅仅面向低延迟,停顿用户线程能够最大幅度提高垃圾收集效率,为了保证吞吐量所以才选择了完全暂停用户线程的实现方案。通过图3-13可以比较清楚地看到G1收集器的运作步骤中并发和需要停顿的阶段。
毫无疑问,可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。不过,这里设置的“期望值”必须是符合实际的,不能异想天开,毕竟G1是要冻结用户线程来复制对象的,这个停顿时间再怎么低也得有个限度。它默认的停顿目标为两百毫秒,一般来说,回收阶段占到几十到一百甚至接近两百毫秒都很正常,但如果我们把停顿时间调得非常低,譬如设置为二十毫秒,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间,但应用运行时间一长就不行了,最终占满堆引发Full GC反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。
从G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率(Allocation Rate),而不追求一次把整个Java堆全部清理干净。这样,应用在分配,同时收集器在收集,只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美。这种新的收集器设计思路从工程实现上看是从G1开始兴起的,所以说G1是收集器技术发展的一个里程碑。
G1收集器常会被拿来与CMS收集器互相比较,毕竟它们都非常关注停顿时间的控制,官方资料中将它们两个并称为“The Mostly Concurrent Collectors”。在未来,G1收集器最终还是要取代CMS的,而当下它们两者并存的时间里,分个高低优劣就无可避免。
CMS和G1对比
相比CMS,G1的优点有很多,暂且不论可以指定最大停顿时间、分Region的内存布局、按收益动态确定回收集这些创新性设计带来的红利,单从最传统的算法理论上看,G1也更有发展潜力。与CMS的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。
不过,G1相对于CMS仍然不是占全方位、压倒性优势的,从它出现几年仍不能在所有应用场景中代替CMS就可以得知这个结论。比起CMS,G1的弱项也可以列举出不少,如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。
就内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;相比起来CMS的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的。
在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同,譬如它们都使用到写屏障,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。
以上的优缺点对比仅仅是针对G1和CMS两款垃圾收集器单独某方面的实现细节的定性分析,通常我们说哪款收集器要更好、要好上多少,往往是针对具体场景才能做的定量比较。按照笔者的实践经验,目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,当然,以上这些也仅是经验之谈,不同应用需要量体裁衣地实际测试才能得出最合适的结论,随着HotSpot的开发者对G1的不断优化,也会让对比结果继续向G1倾斜。
G1收集器至少有(不限于)以下这些关键的细节问题需要妥善解决:
·譬如,将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?解决的思路我们已经知道(见3.3.1节和3.4.4节):使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上记忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。根据经验, G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。
·譬如,在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,该问题的解决办法笔者已经抽出独立小节来讲解过(见3.4.6节):CMS收集器采用增量更新算法实现,而G1收集器则是通过 原始快照(SATB)算法来实现的。此外,垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建, G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS中的“ConcurrentMode Failure”失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop TheWorld”。
·譬如,怎样建立起可靠的停顿预测模型?用户通过-XX:MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值,但G1收集器要怎么做才能满足用户的期望呢?G1收集器的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。这里强调的“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。换句话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。
G1日志设置
G1模式下总计有3中日志级别,分别被称为:fine,finer,finest。
fine:fine模式打开方式是-verbose:gc,等价于-XX:+PrintGC。
finer:推荐,finer模式的打开方式是-XX:+PrintGCDetails。
finest:finest模式打开方式是-XX:+UnlockExperimentalVMOptions -XX:G1LogLevel=finest。
GC日志有层级关系,笔者现在将其抽取成如下样式:
GC pause (G1 Evacuation Pause) (young)
├── Parallel Time
├── GC Worker Start
├── Ext Root Scanning
├── Update RS
├── Scan RS
├── Code Root Scanning
├── Object Copy
├── Code Root Fixup
├── Code Root Purge
├── Clear CT
├── Other
├── Choose CSet
├── Ref Proc
├── Ref Enq
├── Redirty Cards
├── Humongous Register
├── Humongous Reclaim
├── Free CSet
由这段GC日志我们可知,整个YGC由多个子任务以及嵌套子任务组成,且一些核心任务为:Root Scanning,Update/Scan RS,Object Copy,CleanCT,Choose CSet,Ref Proc,Humongous Reclaim,Free CSet。
G1 常用调优参数
·参数G1HeapRegionSize指定堆分区大小。分区大小可以指定,也可以不指定;不指定时,由内存管理器启发式推断分区大小。
·参数xms/xmx指定堆空间的最小值/最大值。一定要正确设置xms/xmx,否则将使用默认配置,将影响分区大小推断。
·在以前的内存管理器中(非G1),为了防止新生代因为内存不断地重新分配导致性能变低,通常设置Xmn或者NewRatio。但是 G1中不要设置MaxNewSize、NewSize、Xmn和NewRatio。原因有两个,第一G1对内存的管理不是连续的,所以即使重新分配一个堆分区代价也不高,第二也是最重要的,G1的目标满足垃圾收集停顿,这需要G1根据停顿时间动态调整收集的分区,如果设置了固定的分区数,即G1不能调整新生代的大小,那么G1可能不能满足停顿时间的要求。具体情况本书后续还会继续讨论。
·参数GCTimeRatio指的是GC与应用程序之间的时间占比,默认值为9[插图],表示GC与应用程序时间占比为10%。增大该值将减少GC占用的时间,带来的后果就是动态扩展内存更容易发生;在很多情况下10%已经很大,例如可以将该值设置为19,则表示GC时间不超过5%。
·根据业务请求变化的情况,设置合适的扩展G1ExpandByPercentOfAvailable速率,保持效率。
·JVM在对新生代内存分配管理时,还有一个参数就是保留内存G1ReservePercent(默认值是10),即在初始化,或者内存扩展/收缩的时候会计算更新有多少个分区是保留的,在新生代分区初始化的时候,在空闲列表中保留一定比例的分区不使用,那么在对象晋升的时候就可以使用了,所以能有效地减小晋升失败的概率。这个值最大不超过50,即最多保留50%的空间,但是保留过多会导致新生代可用空间少,过少可能会增加新生代晋升失败,那将会导致更为复杂的串行回收。
·G1NewSizePercent是一个实验参数,需要使用-XX:+UnlockExperimentalVMOptions才能改变选项。有实验表明G1在回收Eden分区的时候,大概每GB需要100ms,所以可以根据停顿时间,相应地调整。这个值在内存比较大的时候需要减少,例如32G可以设置-XX:G1NewSizePercent=3,这样Eden至少保留大约1GB的空间,从而保证收集效率。·参数MaxGCPauseMillis指期望停顿时间,可根据系统配置和业务动态调整。因为G1在垃圾收集的时候一定会收集新生代,所以需要配合新生代大小的设置来确定,如果该值太小,连新生代都不能收集完成,则没有任何意义,每次除了新生代之外只能多收集一个额外老生代分区。
·参数GCPauseIntervalMillisGC指GC间隔时间,默认值为0,GC启发式推断为MaxGCPauseMillis+1,设置该值必须要大于MaxGCPauseMillis。
·参数G1ConfidencePercent指GC预测置信度,该值越小说明基于过去历史数据的预测越准确,例如设置为0则表示收集的分区基本和过去的衰减均值相关,无波动,所以可以根据过去的衰减均值直接预测下一次预测的时间。反之该值越大,说明波动越大,越不准确,需要加上衰减方差来补偿。
·JVM中提供了一个对象对齐的值ObjectAlignmentInBytes,默认值为8,需要明白该值对内存使用的影响,这个影响不仅仅是在JVM对对象的分配上面,正如上面看到的它也会影响对象在分配时的标记情况。注意这个值最少要和操作系统支持的位数一致才能提高对象分配的效率。所以32位系统最少是4,64位最少是8。一般不用修改该值。
G1调优注意点
①Full GC 问题
G1 的正常处理流程中没有 Full GC,只有在垃圾回收处理不过来(或者主动触发 ) 时才会出现,G1 的 Full GC 就是单线程执行的 Serial old gc,会导致非常长的 STW,是调优的重点,需要尽量避免 Full GC。
常见原因如下:
类似 CMS,常见的解决是:
-
增大 -XX:ConcGCThreads=n 选项增加并发标记线程的数量,或者 STW 期间并行线程的数量:-XX:ParallelGCThreads=n。
-
减小 -XX:InitiatingHeapOccupancyPercent 提前启动标记周期。
-
增大预留内存 -XX:G1ReservePercent=n,默认值是 10,代表使用 10% 的堆内存为预留内存,当 Survivor 区域没有足够空间容纳新晋升对象时会尝试使用预留内存。
②巨型对象分配
巨型对象区中的每个 Region 中包含一个巨型对象,剩余空间不再利用,导致空间碎片化,当 G1 没有合适空间分配巨型对象时,G1 会启动串行 Full GC 来释放空间。
可以通过增加 -XX:G1HeapRegionSize 来增大 Region 大小,这样一来,相当一部分的巨型对象就不再是巨型对象了,而是采用普通的分配方式。
③不要设置 Young 区的大小
原因是为了尽量满足目标停顿时间,逻辑上的 Young 区会进行动态调整。如果设置了大小,则会覆盖掉并且会禁用掉对停顿时间的控制。
④平均响应时间设置
使用应用的平均响应时间作为参考来设置 MaxGCPauseMillis,JVM 会尽量去满足该条件,可能是 90% 的请求或者更多的响应时间在这之内, 但是并不代表是所有的请求都能满足,平均响应时间设置过小会导致频繁 GC。
-XX:GCTimeRatio=n
-XX:MaxGCPauseMillis=n 用于指定目标最大停顿时间。如果任何一次停顿超过这个设置值,G1就会尝试调整新生代和老年代的比例、调整堆大小、调整晋升年龄等,试图达到预设目标。对于性能调优来说,有事总是鱼和熊掌不可兼得,如果停顿时间缩短,对于新生代来说,这意味着很可能要增加新生代GC的次数。对于老年代来说,为了获得更短的停顿时间,在混合GC时,一次收集的区域数量也会表笑,这样无疑增加了进行Full GC的可能性。
-XX:ParallelGCThreads 设置并行回收时GC的工作线程数量。
-XX:InitiatingHeapOccupancyPercent指定当整个堆使用率达到多少时,触发并发标记周期的执行。默认是45,即当整个堆的占用率达到45%时,执行并发标记周期。InitiatingHeapOccupancyPercent一旦设置,始终都不会被G1修改,这意味着G1不会视图改变这个值来满足MaxGCPauseMillis的目标。如果InitiatingHeapOccupancePercent值设置得偏大,会导致并发周期迟迟得不到启动,那么引起Full GC的可能性也大大增加,反之,一个过小的InitiatingHeapOccupancyPercent值会使得并发标记周期执行的非常频繁,大量GC线程抢占CPU,导致应用程序的性能有所下降。
G1支持字符串去重
字符串去重涉及的参数有:
·参数UseStringDeduplication,默认值为false,打开参数表示允许字符串去重。
·参数StringDeduplicationAgeThreshold,默认值为3,控制字符串是否参与去重的阈值。
Shenandoah
Shenandoah作为第一款不由Oracle(包括以前的Sun)公司的虚拟机团队所领导开发的HotSpot垃圾收集器,不可避免地会受到一些来自“官方”的排挤。在笔者撰写这部分内容时,Oracle仍明确拒绝在OracleJDK 12中支持Shenandoah收集器,并执意在打包OracleJDK时通过条件编译完全排除掉了Shenandoah的代码,换句话说,Shenandoah是一款只有OpenJDK才会包含,而OracleJDK里反而不存在的收集器,“免费开源版”比“收费商业版”功能更多,这是相对罕见的状况。如果读者的项目要求用到Oracle商业支持的话,就不得不把Shenandoah排除在选择范围之外了。
最初Shenandoah是由RedHat公司独立发展的新型收集器项目,在2014年RedHat把Shenandoah贡献给了OpenJDK,并推动它成为OpenJDK 12的正式特性之一,也就是后来的JEP 189。这个项目的目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的垃圾收集器,该目标意味着相比CMS和G1,Shenandoah不仅要进行并发的垃圾标记,还要并发地进行对象清理后的整理动作。
从代码历史渊源上讲,比起稍后要介绍的有着Oracle正朔血统的ZGC,Shenandoah反而更像是G1的下一代继承者,它们两者有着相似的堆内存布局,在初始标记、并发标记等许多阶段的处理思路上都高度一致,甚至还直接共享了一部分实现代码,这使得部分对G1的打磨改进和Bug修改会同时反映在Shenandoah之上,而由于Shenandoah加入所带来的一些新特性,也有部分会出现在G1收集器中,譬如在并发失败后作为“逃生门”的Full GC,G1就是由于合并了Shenandoah的代码才获得多线程Full GC的支持。
那Shenandoah相比起G1又有什么改进呢?虽然Shenandoah也是使用基于Region的堆内存布局,同样有着用于存放大对象的Humongous Region,默认的回收策略也同样是优先处理回收价值最大的Region……但在管理堆内存方面,它与G1至少有三个明显的不同之处,最重要的当然是支持并发的整理算法,G1的回收阶段是可以多线程并行的,但却不能与用户线程并发,这点作为Shenandoah最核心的功能稍后笔者会着重讲解。其次,Shenandoah(目前)是默认不使用分代收集的,换言之,不会有专门的新生代Region或者老年代Region的存在,没有实现分代,并不是说分代对Shenandoah没有价值,这更多是出于性价比的权衡,基于工作量上的考虑而将其放到优先级较低的位置上。最后,Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题(见3.4.4节)的发生概率。连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记,如图3-15所示,如果Region 5中的对象Baz引用了Region 3的Foo,Foo又引用了Region 1的Bar,那连接矩阵中的5行3列、3行1列就应该被打上标记。在回收时通过这张表格就可以得出哪些Region之间产生了跨代引用。
Shenandoah收集器的工作过程大致可以划分为以下九个阶段(此处以Shenandoah在2016年发表的原始论文进行介绍。在最新版本的Shenandoah 2.0中,进一步强化了“部分收集”的特性,初始标记之前还有Initial Partial、Concurrent Partial和Final Partial阶段,它们可以不太严谨地理解为对应于以前分代收集中的Minor GC的工作):
· 初始标记
(Initial Marking):与G1一样,首先标记与GC Roots直接关联的对象,这个阶段仍是“Stop The World”的,但停顿时间与堆大小无关,只与GC Roots的数量相关。
· 并发标记
(Concurrent Marking):与G1一样,遍历对象图,标记出全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的结构复杂程度。
· 最终标记
(Final Marking):与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集(Collection Set)。最终标记阶段也会有一小段短暂的停顿。
· 并发清理
(Concurrent Cleanup):这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region(这类Region被称为Immediate Garbage Region)。
· 并发回收
(Concurrent Evacuation):并发回收阶段是Shenandoah与之前HotSpot中其他收集器的核心差异。在这个阶段,Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region之中。复制对象这件事情如果将用户线程冻结起来再做那是相当简单的,但如果两者必须要同时并发进行的话,就变得复杂起来了。其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。对于并发回收阶段遇到的这些困难,Shenandoah将会通过读屏障和被称为“Brooks Pointers”的转发指针来解决(讲解完Shenandoah整个工作过程之后笔者还要再回头介绍它)。并发回收阶段运行的时间长短取决于回收集的大小。
· 初始引用更新
(Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新的初始化阶段实际上并未做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的停顿。
· 并发引用更新
(Concurrent Update Reference):真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。
· 最终引用更新
(Final Update Reference):解决了堆中的引用更新后,还要修正存在于GC Roots中的引用。这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关。
· 并发清理
(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,这些Region都变成Immediate Garbage Regions了,最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。
以上对Shenandoah收集器这九个阶段的工作过程的描述可能拆分得略为琐碎,读者只要抓住其中三个最重要的并发阶段(并发标记、并发回收、并发引用更新),就能比较容易理清Shenandoah是如何运作的了。图3-16中黄色的区域代表的是被选入回收集的Region,绿色部分就代表还存活的对象,蓝色就是用户线程可以用来分配对象的内存Region了。图3-16中不仅展示了Shenandoah三个并发阶段的工作过程,还能形象地表示出并发标记阶段如何找出回收对象确定回收集,并发回收阶段如何移动回收集中的存活对象,并发引用更新阶段如何将指向回收集中存活对象的所有引用全部修正,此后回收集便不存在任何引用可达的存活对象了。
学习了Shenandoah收集器的工作过程,我们再来聊一下Shenandoah用以支持并行整理的核心概念——Brooks Pointer。“Brooks”是一个人的名字。1984年,Rodney A.Brooks在论文《Trading Data Space for Reduced Time and Code Space in Real-Time Garbage Collection on Stock Hardware》中提出了使用转发指针(Forwarding Pointer,也常被称为Indirection Pointer)来实现对象移动与用户程序并发的一种解决方案。此前,要做类似的并发操作,通常是在被移动对象原有的内存上设置保护陷阱(Memory Protection Trap),一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中段,进入预设好的异常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上。虽然确实能够实现对象移动与用户线程并发,但是如果没有操作系统层面的直接支持,这种方案将导致用户态频繁切换到核心态,代价是非常大的,不能频繁使用。
Brooks提出的新方案不需要用到内存保护陷阱,而是在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己,如图3-17所示。
从结构上来看,Brooks提出的转发指针与某些早期Java虚拟机使用过的句柄定位(关于对象定位详见第2章)有一些相似之处,两者都是一种间接性的对象访问方式,差别是句柄通常会统一存储在专门的句柄池中,而转发指针是分散存放在每一个对象头前面。
有了转发指针之后,有何收益暂且不论,所有间接对象访问技术的缺点都是相同的,也是非常显著的——每次对象访问会带来一次额外的转向开销,尽管这个开销已经被优化到只有一行汇编指令的程度,譬如以下所示:
mov r13,QWORD PTR [r12+r14*8-0x8]
不过,毕竟对象定位会被频繁使用到,这仍是一笔不可忽视的执行成本,只是它比起内存保护陷阱的方案已经好了很多。转发指针加入后带来的收益自然是当对象拥有了一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。这样只要旧对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码便仍然可用,都会被自动转发到新对象上继续工作,如图3-18所示。
需要注意,Brooks形式的转发指针在设计上决定了它是必然会出现多线程竞争问题的,如果收集器线程与用户线程发生的只是并发读取,那无论读到旧对象还是新对象上的字段,返回的结果都应该是一样的,这个场景还可以有一些“偷懒”的处理余地;但如果发生的是并发写入,就一定必须保证写操作只能发生在新复制的对象上,而不是写入旧对象的内存中。读者不妨设想以下三件事情并发进行时的场景:
1)收集器线程复制了新的对象副本;
2)用户线程更新对象的某个字段;
3)收集器线程更新转发指针的引用值为新副本地址。
如果不做任何保护措施,让事件2在事件1、事件3之间发生的话,将导致的结果就是用户线程对对象的变更发生在旧对象上,所以这里必须针对转发指针的访问操作采取同步措施,让收集器线程或者用户线程对转发指针的访问只有其中之一能够成功,另外一个必须等待,避免两者交替进行。实际上Shenandoah收集器是通过比较并交换(Compare And Swap,CAS)操作来保证并发时对象的访问正确性的。
转发指针另一点必须注意的是执行频率的问题,尽管通过对象头上的Brooks Pointer来保证并发时原对象与复制对象的访问一致性,这件事情只从原理上看是不复杂的,但是“对象访问”这四个字的分量是非常重的,对于一门面向对象的编程语言来说,对象的读取、写入,对象的比较,为对象哈希值计算,用对象加锁等,这些操作都属于对象访问的范畴,它们在代码中比比皆是,要覆盖全部对象访问操作,Shenandoah不得不同时设置读、写屏障去拦截。
之前介绍其他收集器时,或者是用于维护卡表,或者是用于实现并发标记,写屏障已被使用多次,累积了不少的处理任务了,这些写屏障有相当一部分在Shenandoah收集器中依然要被使用到。除此以外,为了实现Brooks Pointer,Shenandoah在读、写屏障中都加入了额外的转发处理,尤其是使用读屏障的代价,这是比写屏障更大的。代码里对象读取的出现频率要比对象写入的频率高出很多,读屏障数量自然也要比写屏障多得多,所以读屏障的使用必须更加谨慎,不允许任何的重量级操作。Shenandoah是本书中第一款使用到读屏障的收集器,它的开发者也意识到数量庞大的读屏障带来的性能开销会是Shenandoah被诟病的关键点之一,所以计划在JDK 13中将Shenandoah的内存屏障模型改进为基于引用访问屏障(Load Reference Barrier)的实现,所谓“引用访问屏障”是指内存屏障只拦截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等其他非引用字段的读写,这能够省去大量对原生类型、对象比较、对象加锁等场景中设置内存屏障所带来的消耗。
最后来谈谈Shenandoah在实际应用中的性能表现,Shenandoah的开发团队或者其他第三方测试者在网上都公布了一系列测试,结果各有差异。笔者在此选择展示了一份RedHat官方在2016年所发表的Shenandoah实现论文中给出的应用实测数据,测试内容是使用ElasticSearch对200GB的维基百科数据进行索引,如表3-2所示。从结果来看,应该说2016年做该测试时的Shenandoah并没有完全达成预定目标,停顿时间比其他几款收集器确实有了质的飞跃,但也并未实现最大停顿时间控制在十毫秒以内的目标,而吞吐量方面则出现了很明显的下降,其总运行时间是所有测试收集器中最长的。读者可以从这个官方的测试结果来对Shenandoah的弱项(高运行负担使得吞吐量下降)和强项(低延迟时间)建立量化的概念,并对比一下稍后介绍的ZGC的测试结果。
Shenandoah收集器作为第一款由非Oracle开发的垃圾收集器,一开始就预计到了缺乏Oracle公司那样富有经验的研发团队可能会遇到很多困难。所以Shenandoah采取了“小步快跑”的策略,将最终目标进行拆分,分别形成Shenandoah 1.0、2.0、3.0……这样的小版本计划,在每个版本中迭代改进,现在已经可以看到Shenandoah的性能在日益改善,逐步接近“Low-Pause”的目标。此外,RedHat也积极拓展Shenandoah的使用范围,将其Backport到JDK 11甚至是JDK 8之上,让更多不方便升级JDK版本的应用也能够享受到垃圾收集器技术发展的最前沿成果。
ZGC( JDK11 )
Java GC:干掉 CMS,ZGC 才是未来
新一代垃圾回收器ZGC的探索与实践
ZGC(The Z Garbage Collector)是 JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:
集器,是由Oracle公司研发的。2018年Oracle创建了JEP 333将ZGC提交给OpenJDK,推动其进入OpenJDK 11的发布清单之中。
ZGC和Shenandoah的目标是高度相似的,都希望在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。但是ZGC和Shenandoah的实现思路又是差异显著的,如果说RedHat公司开发的Shen-andoah像是Oracle的G1收集器的实际继承者的话,那Oracle公司开发的ZGC就更像是Azul System公司独步天下的PGC(Pauseless GC)和C4(Concurrent Continuously Compacting Collector)收集器的同胞兄弟。
早在2005年,运行在Azul VM上的PGC就已经实现了标记和整理阶段都全程与用户线程并发运行的垃圾收集,而运行在Zing VM上的C4收集器是PGC继续演进的产物,主要增加了分代收集支持,大幅提升了收集器能够承受的对象分配速度。无论从算法还是实现原理上来讲,PGC和C4肯定算是一脉相承的,而ZGC虽然并非Azul公司的产品,但也应视为这条脉络上的另一个节点,因为ZGC几乎所有的关键技术上,与PGC和C4都只存在术语称谓上的差别,实质内容几乎是一模一样的。相信到这里读者应该已经对Java虚拟机收集器常见的专业术语都有所了解了,如果不避讳专业术语的话,我们可以给ZGC下一个这样的定义来概括它的主要特征:ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。接下来,笔者将逐项来介绍ZGC的这些技术特点。
首先从ZGC的内存布局说起。与Shenandoah和G1一样,ZGC也采用基于Region的堆内存布局,但与它们不同的是,ZGC的Region(在一些官方资料中将它称为Page或者ZPage,本章为行文一致继续称为Region)具有动态性——动态创建和销毁,以及动态的区域容量大小。在x64硬件平台下,ZGC的Region可以具有如图3-19所示的大、中、小三类容量:
· 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
· 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
· 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,这也预示着虽然名字叫作“大型Region”,但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段,稍后会介绍到)的,因为复制一个大对象的代价非常高昂。
接下来是ZGC的核心问题——并发整理算法的实现。Shenandoah使用转发指针和读屏障来实现并发整理,ZGC虽然同样用到了读屏障,但用的却是一条与Shenandoah完全不同,更加复杂精巧的解题思路。
ZGC收集器有一个标志性的设计是它采用的染色指针技术(Colored Pointer,其他类似的技术中可能将它称为Tag Pointer或者Version Pointer)。从前,如果我们要在对象上存储一些额外的、只供收集器或者虚拟机本身使用的数据,通常会在对象头中增加额外的存储字段(详见2.3.2节的内容),如对象的哈希码、分代年龄、锁记录等就是这样存储的。这种记录方式在有对象访问的场景下是很自然流畅的,不会有什么额外负担。但如果对象存在被移动过的可能性,即不能保证对象访问能够成功呢?又或者有一些根本就不会去访问对象,但又希望得知该对象的某些信息的应用场景呢?能不能从指针或者与对象内存无关的地方得到这些信息,譬如是否能够看出来对象被移动过?这样的要求并非不合理的刁难,先不去说并发移动对象可能带来的可访问性问题,此前我们就遇到过这样的要求——追踪式收集算法的标记阶段就可能存在只跟指针打交道而不必涉及指针所引用的对象本身的场景。例如对象标记的过程中需要给对象打上三色标记(见3.4.6节),这些标记本质上就只和对象的引用有关,而与对象本身无关——某个对象只有它的引用关系能决定它存活与否,对象上其他所有的属性都不能够影响它的存活判定结果。HotSpot虚拟机的几种收集器有不同的标记实现方案,有的把标记直接记录在对象头上(如Serial收集器),有的把标记记录在与对象相互独立的数据结构上(如G1、Shenandoah使用了一种相当于堆内存的1/64大小的,称为BitMap的结构来记录标记信息),而ZGC的染色指针是最直接的、最纯粹的,它直接把标记信息记在引用对象的指针上,这时,与其说可达性分析是遍历对象图来标记对象,还不如说是遍历“引用图”来标记“引用”了。
染色指针是一种直接将少量额外的信息存储在指针上的技术,可是为什么指针本身也可以存储额外信息呢?在64位系统中,理论可以访问的内存高达16EB(2的64次幂)字节。实际上,基于需求(用不到那么多内存)、性能(地址越宽在做地址转换时需要的页表级数越多)和成本(消耗更多晶体管)的考虑,在AMD64架构中只支持到52位(4PB)的地址总线和48位(256TB)的虚拟地址空间,所以目前64位的硬件实际能够支持的最大内存只有256TB。此外,操作系统一侧也还会施加自己的约束,64位的Linux则分别支持47位(128TB)的进程虚拟地址空间和46位(64TB)的物理地址空间,64位的Windows系统甚至只支持44位(16TB)的物理地址空间。
尽管Linux下64位指针的高18位不能用来寻址,但剩余的46位指针所能支持的64TB内存在今天仍然能够充分满足大型服务器的需要。鉴于此,ZGC的染色指针技术继续盯上了这剩下的46位指针宽度,将其高4位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到,如图3-20所示。当然,由于这些标志位进一步压缩了原本就只有46位的地址空间,也直接导致ZGC能够管理的内存不可以超过4TB(2的42次幂)。
虽然染色指针有4TB的内存限制,不能支持32位平台,不能支持压缩指针(-XX:+UseCompressedOops)等诸多约束,但它带来的收益也是非常可观的,在JEP 333的描述页中,ZGC的设计者Per Liden在“描述”小节里花了全文过半的篇幅来陈述染色指针的三大优势:
· 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。这点相比起Shenandoah是一个颇大的优势,使得理论上只要还有一个空闲Region,ZGC就能完成收集,而Shenandoah需要等到引用更新阶段结束以后才能释放回收集中的Region,这意味着堆中几乎所有对象都存活的极端情况,需要1∶1复制对象到新Region的话,就必须要有一半的空闲Region来完成收集。至于为什么染色指针能够导致这样的结果,笔者将在后续解释其“自愈”特性的时候进行解释。
· 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作。实际上,到目前为止ZGC都并未使用任何写屏障,只使用了读屏障(一部分是染色指针的功劳,一部分是ZGC现在还不支持分代收集,天然就没有跨代引用的问题)。内存屏障对程序运行时性能的损耗在前面章节中已经讲解过,能够省去一部分的内存屏障,显然对程序运行效率是大有裨益的,所以ZGC对吞吐量的影响也相对较低。
· 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。现在Linux下的64位指针还有前18位并未使用,它们虽然不能用来寻址,却可以通过其他手段用于信息记录。如果开发了这18位,既可以腾出已用的4个标志位,将ZGC可支持的最大堆内存从4TB拓展到64TB,也可以利用其余位置再存储更多的标志,譬如存储一些追踪信息来让垃圾收集器在移动对象时能将低频次使用的对象移动到不常访问的内存区域。
不过,要顺利应用染色指针有一个必须解决的前置问题:Java虚拟机作为一个普普通通的进程,这样随意重新定义内存中某些指针的其中几位,操作系统是否支持?处理器是否支持?这是很现实的问题,无论中间过程如何,程序代码最终都要转换为机器指令流交付给处理器去执行,处理器可不会管指令流中的指针哪部分存的是标志位,哪部分才是真正的寻址地址,只会把整个指针都视作一个内存地址来对待。这个问题在Solaris/SPARC平台上比较容易解决,因为SPARC硬件层面本身就支持虚拟地址掩码,设置之后其机器指令直接就可以忽略掉染色指针中的标志位。但在x86-64平台上并没有提供类似的黑科技,ZGC设计者就只能采取其他的补救措施了,这里面的解决方案要涉及虚拟内存映射技术,让我们先来复习一下这个x86计算机体系中的经典设计。
在远古时代的x86计算机系统里面,所有进程都是共用同一块物理内存空间的,这样会导致不同进程之间的内存无法相互隔离,当一个进程污染了别的进程内存后,就只能对整个系统进行复位后才能得以恢复。为了解决这个问题,从Intel 80386处理器开始,提供了“保护模式”用于隔离进程。在保护模式下,386处理器的全部32条地址寻址线都有效,进程可访问最高也可达4GB的内存空间,但此时已不同于之前实模式下的物理内存寻址了,处理器会使用分页管理机制把线性地址空间和物理地址空间分别划分为大小相同的块,这样的内存块被称为“页”(Page)。通过在线性虚拟空间的页与物理地址空间的页之间建立的映射表,分页管理机制会进行线性地址到物理地址空间的映射,完成线性地址到物理地址的转换。如果读者对计算机结构体系了解不多的话,不妨设想这样一个场景来类比:假如你要去“中山一路3号”这个地址拜访一位朋友,根据你所处城市的不同,譬如在广州或者在上海,是能够通过这个“相同的地址”定位到两个完全独立的物理位置的,这时地址与物理位置是一对多关系映射。
不同层次的虚拟内存到物理内存的转换关系可以在硬件层面、操作系统层面或者软件进程层面实现,如何完成地址转换,是一对一、多对一还是一对多的映射,也可以根据实际需要来设计。Linux/x86-64平台上的ZGC使用了多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射,意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内存容量来得更大。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了,效果如图3-21所示。
在某些场景下,多重映射技术确实可能会带来一些诸如复制大对象时会更容易这样的额外好处,可从根源上讲,ZGC的多重映射只是它采用染色指针技术的伴生产物,并不是专门为了实现其他某种特性需求而去做的。
接下来,我们来学习ZGC收集器是如何工作的。ZGC的运作过程大致可划分为以下四个大的阶段。全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段,这些小阶段,譬如初始化GC Root直接关联对象的Mark Start,与之前G1和Shenandoah的Initial Mark阶段并没有什么差异,笔者就不再单独解释了。ZGC的运作过程具体如图3-22所示。
· 并发标记
(Concurrent Mark):与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于G1、Shenandoah的初始标记、最终标记(尽管ZGC中的名字不叫这些)的短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。与G1、Shenandoah不同的是,ZGC的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志位。
· 并发预备重分配
(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。重分配集与G1收集器的回收集(Collection Set)还是有区别的,ZGC划分Region的目的并非为了像G1那样做收益优先的增量回收。相反,ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。因此,ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面的Region会被释放,而并不能说回收行为就只是针对这个集合里面的Region进行,因为标记过程是针对全堆的。此外,在JDK 12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段中完成的。
· 并发重分配
(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。得益于染色指针的支持,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次,对比Shenandoah的Brooks转发指针,那是每次对象访问都必须付出的固定开销,简单地说就是每次都慢,因此ZGC对用户程序的运行时负载要比Shenandoah来得更低一些。还有另外一个直接的好处是由于染色指针的存在,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配(但是转发表还得留着不能释放掉),哪怕堆中还有很多指向这个对象的未更新指针也没有关系,这些旧指针一旦被使用,它们都是可以自愈的。
· 并发重映射
(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,这一点从目标角度看是与Shenandoah并发引用更新阶段一样的,但是ZGC的并发重映射并不是一个必须要“迫切”去完成的任务,因为前面说过,即使是旧引用,它也是可以自愈的,最多只是第一次使用时多一次转发和修正操作。重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束后可以释放转发表这样的附带收益),所以说这并不是很“迫切”。因此,ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。
ZGC的设计理念与Azul System公司的PGC和C4收集器一脉相承,是迄今垃圾收集器研究的最前沿成果,它与Shenandoah一样做到了几乎整个收集过程都全程可并发,短暂停顿也只与GC Roots大小相关而与堆内存大小无关,因而同样实现了任何堆上停顿都小于十毫秒的目标。
相比G1、Shenandoah等先进的垃圾收集器,ZGC在实现细节上做了一些不同的权衡选择,譬如G1需要通过写屏障来维护记忆集,才能处理跨代指针,得以实现Region的增量回收。记忆集要占用大量的内存空间,写屏障也对正常程序运行造成额外负担,这些都是权衡选择的代价。ZGC就完全没有使用记忆集,它甚至连分代都没有,连像CMS中那样只记录新生代和老年代间引用的卡表也不需要,因而完全没有用到写屏障,所以给用户线程带来的运行负担也要小得多。可是,必定要有优有劣才会称作权衡,ZGC的这种选择也限制了它能承受的对象分配速率不会太高,可以想象以下场景来理解ZGC的这个劣势:ZGC准备要对一个很大的堆做一次完整的并发收集,假设其全过程要持续十分钟以上(请读者切勿混淆并发时间与停顿时间,ZGC立的Flag是停顿时间不超过十毫秒),在这段时间里面,由于应用的对象分配速率很高,将创造大量的新对象,这些新对象很难进入当次收集的标记范围,通常就只能全部当作存活对象来看待——尽管其中绝大部分对象都是朝生夕灭的,这就产生了大量的浮动垃圾。如果这种高速分配持续维持的话,每一次完整的并发收集周期都会很长,回收到的内存空间持续小于期间并发产生的浮动垃圾所占的空间,堆中剩余可腾挪的空间就越来越小了。目前唯一的办法就是尽可能地增加堆容量大小,获得更多喘息的时间。但是若要从根本上提升ZGC能够应对的对象分配速率,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集。Azul的C4收集器实现了分代收集后,能够应对的对象分配速率就比不分代的PGC收集器提升了十倍之多。
ZGC还有一个常在技术资料上被提及的优点是支持“NUMA-Aware”的内存分配。NUMA(Non-Uniform Memory Access,非统一内存访问架构)是一种为多处理器或者多核处理器的计算机所设计的内存架构。由于摩尔定律逐渐失效,现代处理器因频率发展受限转而向多核方向发展,以前原本在北桥芯片中的内存控制器也被集成到了处理器内核中,这样每个处理器核心所在的裸晶(DIE)都有属于自己内存管理器所管理的内存,如果要访问被其他处理器核心管理的内存,就必须通过Inter-Connect通道来完成,这要比访问处理器的本地内存慢得多。在NUMA架构下,ZGC收集器会优先尝试在请求线程当前所处的处理器的本地内存上分配对象,以保证高效内存访问。在ZGC之前的收集器就只有针对吞吐量设计的Parallel Scavenge支持NUMA内存分配,如今ZGC也成为另外一个选择。
在性能方面,尽管目前还处于实验状态,还没有完成所有特性,稳定性打磨和性能调优也仍在进行,但即使是这种状态下的ZGC,其性能表现已经相当亮眼,从官方给出的测试结果来看,用“令人震惊的、革命性的ZGC”来形容都不为过。
图3-23和图3-24是ZGC与Parallel Scavenge、G1三款收集器通过SPECjbb 2015的测试结果。在ZGC的“弱项”吞吐量方面,以低延迟为首要目标的ZGC已经达到了以高吞吐量为目标Parallel Scavenge的99%,直接超越了G1。如果将吞吐量测试设定为面向SLA(Service Level Agreements)应用的“Critical Throughput”的话,ZGC的表现甚至还反超了Parallel Scavenge收集器。
而在ZGC的强项停顿时间测试上,它就毫不留情地与Parallel Scavenge、G1拉开了两个数量级的差距。不论是平均停顿,还是95%停顿、99%停顿、99.9%停顿,抑或是最大停顿时间,ZGC均能毫不费劲地控制在十毫秒之内,以至于把它和另外两款停顿数百近千毫秒的收集器放到一起对比,就几乎显示不了ZGC的柱状条(图3-24a),必须把结果的纵坐标从线性尺度调整成对数尺度(图3-24b,纵坐标轴的尺度是对数增长的)才能观察到ZGC的测试结果。
ZGC原本是Oracle作为一项商业特性(如同JFR、JMC这些功能)来设计和实现的,只不过在它横空出世的JDK 11时期,正好适逢Oracle调整许可证授权,把所有商业特性都开源给了OpenJDK(详情见第1章Java发展史),所以用户对其商业性并没有明显的感知。ZGC有着令所有开发人员趋之若鹜的优秀性能,让以前大多数人只是听说,但从未用过的“Azul式的垃圾收集器”一下子飞入寻常百姓家,笔者相信它完全成熟之后,将会成为服务端、大内存、低延迟应用的首选收集器的有力竞争者。
ZGC 垃圾回收周期如下图所示:
ZGC 只有三个 STW 阶段: 初始标记,再标记,初始转移 。其中,初始标记和初始转移分别都只需要扫描所有 GC Roots,其处理时间和 GC Roots 的数量成正比,一般情况耗时非常短;再标记阶段 STW 时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC 几乎所有暂停都只依赖于 GC Roots 集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与 ZGC 对比,G1 的转移阶段完全 STW 的,且停顿时间随存活对象的大小增加而增加。
Epsilon收集器
在G1、Shenandoah或者ZGC这些越来越复杂、越来越先进的垃圾收集器相继出现的同时,也有一个“反其道而行”的新垃圾收集器出现在JDK 11的特征清单中——Epsilon,这是一款以不能够进行垃圾收集为“卖点”的垃圾收集器,这种话听起来第一感觉就十分违反逻辑,这种“不干活”的收集器要它何用?
Epsilon收集器由RedHat公司在JEP 318中提出,在此提案里Epsilon被形容成一个无操作的收集器(A No-Op Garbage Collector),而事实上只要Java虚拟机能够工作,垃圾收集器便不可能是真正“无操作”的。原因是“垃圾收集器”这个名字并不能形容它全部的职责,更贴切的名字应该是本书为这一部分所取的标题——“自动内存管理子系统”。一个垃圾收集器除了垃圾收集这个本职工作之外,它还要负责堆的管理与布局、对象的分配、与解释器的协作、与编译器的协作、与监控子系统协作等职责,其中至少堆的管理和对象的分配这部分功能是Java虚拟机能够正常运作的必要支持,是一个最小化功能的垃圾收集器也必须实现的内容。从JDK 10开始,为了隔离垃圾收集器与Java虚拟机解释、编译、监控等子系统的关系,RedHat提出了垃圾收集器的统一接口,即JEP 304提案,Epsilon是这个接口的有效性验证和参考实现,同时也用于需要剥离垃圾收集器影响的性能测试和压力测试。
在实际生产环境中,不能进行垃圾收集的Epsilon也仍有用武之地。很长一段时间以来,Java技术体系的发展重心都在面向长时间、大规模的企业级应用和服务端应用,尽管也有移动平台(指Java ME而不是Android)和桌面平台的支持,但使用热度上与前者相比要逊色不少。可是近年来大型系统从传统单体应用向微服务化、无服务化方向发展的趋势已越发明显,Java在这方面比起Golang等后起之秀来确实有一些先天不足,使用率正渐渐下降。传统Java有着内存占用较大,在容器中启动时间长,即时编译需要缓慢优化等特点,这对大型应用来说并不是什么太大的问题,但对短时间、小规模的服务形式就有诸多不适。为了应对新的技术潮流,最近几个版本的JDK逐渐加入了提前编译、面向应用的类数据共享等支持。Epsilon也是有着类似的目标,如果读者的应用只要运行数分钟甚至数秒,只要Java虚拟机能正确分配内存,在堆耗尽之前就会退出,那显然运行负载极小、没有任何回收行为的Epsilon便是很恰当的选择。
显式垃圾回收
System.gc() 会使用传统的Full GC方式回收整个堆,而忽略参数中的UseG1GC和UseConcMarkSweepGC。 需要加参数-XX:+ExplicitGCInvokesConcurrent后,才会改变这个默认的行为。
Runtime.getRuntime().gc()
显式调用可恢复对象的finalize 方法。 不推荐使用。
System.runFinalization()
Runtime.getRuntime().funFinalization()
在什么情况下,GC会对程序产生影响?
不管YGC还是FGC,都会造成一定程度的程序卡顿(即Stop The World问题:GC线程开始工作,其他工作线程被挂起),即使采用ParNew、CMS或者G1这些更先进的垃圾回收算法,也只是在减少卡顿时间,而并不能完全消除卡顿。
那到底什么情况下,GC会对程序产生影响呢?根据严重程度从高到底,我认为包括以下4种情况:
-
FGC过于频繁 :FGC通常是比较慢的,少则几百毫秒,多则几秒,正常情况FGC每隔几个小时甚至几天才执行一次,对系统的影响还能接受。但是,一旦出现FGC频繁(比如几十分钟就会执行一次),这种肯定是存在问题的,它会导致工作线程 频繁被停止 ,让系统看起来一直有卡顿现象,也会使得程序的整体性能变差。
-
YGC耗时过长 :一般来说,YGC的总耗时在 几十或者上百毫秒是比较正常的,虽然会引起系统卡顿 几毫秒 或者 几十 毫秒,这种情况 几乎对用户无感知, 对程序的影响可以忽略不计。 但是如果YGC耗时达到了1秒甚至几秒(都快赶上FGC的耗时了),那卡顿时间就会增大,加上YGC本身比较频繁,就会导致比较多的服务超时问题。
-
FGC耗时过长 :FGC耗时增加,卡顿时间也会随之增加,尤其对于高并发服务,可能导致FGC期间比较多的超时问题,可用性降低,这种也需要关注。
-
YGC过于频繁 :即使YGC不会引起服务超时,但是YGC过于频繁也会降低服务的整体性能, 对于高并发服务也是需要关注的。
其中,「FGC过于频繁」和「YGC耗时过长」,这两种情况属于比较典型的GC问题,大概率会对程序的服务质量产生影响。剩余两种情况的严重程度低一些,但是对于高并发或者高可用的程序也需要关注。
垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency),三者共同构成了一个“不可能三角”。三者总体的表现会随技术进步而越来越好,但是要在这三个方面同时具有卓越表现的“完美”收集器是极其困难甚至是不可能的,一款优秀的收集器通常最多可以同时达成其中的两项。
在内存占用、吞吐量和延迟这三项指标里,延迟的重要性日益凸显,越发备受关注。其原因是随着计算机硬件的发展、性能的提升,我们越来越能容忍收集器多占用一点点内存;硬件性能增长,对软件系统的处理能力是有直接助益的,硬件的规格和性能越高,也有助于降低收集器运行时对应用程序的影响,换句话说,吞吐量会更高。但对延迟则不是这样,硬件规格提升,准确地说是内存的扩大,对延迟反而会带来负面的效果,这点也是很符合直观思维的:虚拟机要回收完整的1TB的堆内存,毫无疑问要比回收1GB的堆内存耗费更多时间。由此,我们就不难理解为何延迟会成为垃圾收集器最被重视的性能指标了。现在我们来观察一下现在已接触过的垃圾收集器的停顿状况,如图3-14所示。
图3-14中浅色阶段表示必须挂起用户线程,深色表示收集器线程与用户线程是并发工作的。由图3-14可见,在CMS和G1之前的全部收集器,其工作的所有步骤都会产生“Stop The World”式的停顿;CMS和G1分别使用增量更新和原始快照(见3.4.6节)技术,实现了标记阶段的并发,不会因管理的堆内存变大,要标记的对象变多而导致停顿时间随之增长。但是对于标记阶段之后的处理,仍未得到妥善解决。CMS使用标记-清除算法,虽然避免了整理阶段收集器带来的停顿,但是清除算法不论如何优化改进,在设计原理上避免不了空间碎片的产生,随着空间碎片不断淤积最终依然逃不过“Stop The World”的命运。G1虽然可以按更小的粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但毕竟也还是要暂停的。
读者肯定也从图3-14中注意到了,最后的两款收集器,Shenandoah和ZGC,几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系。实际上,它们都可以在任意可管理的(譬如现在ZGC只能管理4TB以内的堆)堆容量下,实现垃圾收集的停顿都不超过十毫秒这种以前听起来是天方夜谭、匪夷所思的目标。这两款目前仍处于实验状态的收集器,被官方命名为“低延迟垃圾收集器”(Low-Latency Garbage Collector或者Low-Pause-Time Garbage Collector)。
垃圾回收日志
阅读分析虚拟机和垃圾收集器的日志是处理Java虚拟机内存问题必备的基础技能,垃圾收集器日志是一系列人为设定的规则,多少有点随开发者编码时的心情而定,没有任何的“业界标准”可言,换句话说,每个收集器的日志格式都可能不一样。除此以外还有一个麻烦,在JDK 9以前,HotSpot并没有提供统一的日志处理框架,虚拟机各个功能模块的日志开关分布在不同的参数上,日志级别、循环日志大小、输出格式、重定向等设置在不同功能上都要单独解决。直到JDK 9,这种混乱不堪的局面才终于消失,HotSpot所有功能的日志都收归到了“-Xlog”参数上,这个参数的能力也相应被极大拓展了:
-Xlog[:[selector][:[output][:[decorators][:output-options]]]]
命令行中最关键的参数是选择器(Selector),它由标签(Tag)和日志级别(Level)共同组成。标签可理解为虚拟机中某个功能模块的名字,它告诉日志框架用户希望得到虚拟机哪些功能的日志输出。垃圾收集器的标签名称为“gc”,由此可见,垃圾收集器日志只是HotSpot众多功能日志的其中一项,全部支持的功能模块标签名如下所示:
add,age,alloc,annotation,aot,arguments,attach,barrier,biasedlocking,blocks,bot,breakpoint,bytecode,census,class,classhisto,cleanup,compaction,comparator,constraints,constantpool,coops,cpu,cset,data,defaultmethods,dump,ergo,event,exceptions,exit,fingerprint,freelist,gc,hashtables,heap,humongous,ihop,iklass,init,itables,jfr,jni,jvmti,liveness,load,loader,logging,mark,marking,metadata,metaspace,method,mmu,modules,monitorinflation,monitormismatch,nmethod,normalize,objecttagging,obsolete,oopmap,os,pagesize,parser,patch,path,phases,plab,preorder,promotion,protectiondomain,purge,redefine,ref,refine,region,remset,resolve,safepoint,scavenge,scrub,setting,stackmap,stacktrace,stackwalk,start,startuptime,state,stats,stringdedup,stringtable,subclass,survivor,sweep,system,task,thread,time,timer,tlab,unload,update,verification,verify,vmoperation,vtables,workgang
日志级别从低到高,共有Trace,Debug,Info,Warning,Error,Off六种级别,日志级别决定了输出信息的详细程度,默认级别为Info,HotSpot的日志规则与Log4j、SLF4j这类Java日志框架大体上是一致的。另外,还可以使用修饰器(Decorator)来要求每行日志输出都附加上额外的内容,支持附加在日志行上的信息包括:
· time:当前日期和时间。
· uptime:虚拟机启动到现在经过的时间,以秒为单位。
· timemillis:当前时间的毫秒数,相当于System.currentTimeMillis()的输出。
· uptimemillis:虚拟机启动到现在经过的毫秒数。
· timenanos:当前时间的纳秒数,相当于System.nanoTime()的输出。
· uptimenanos:虚拟机启动到现在经过的纳秒数。
· pid:进程ID。
· tid:线程ID。
· level:日志级别。
· tags:日志输出的标签集。
如果不指定,默认值是uptime、level、tags这三个,此时日志输出类似于以下形式:
[3.080s][info][gc,cpu] GC(5) User=0.03s Sys=0.00s Real=0.01s
下面笔者举几个例子,展示在JDK 9统一日志框架前、后是如何获得垃圾收集器过程的相关信息,以下均以JDK 9的G1收集器(JDK 9下默认收集器就是G1,所以命令行中没有指定收集器)为例。
1)查看GC基本信息,在JDK 9之前使用-XX:+PrintGC,JDK 9后使用-Xlog:gc:
bash-3.2$ java -Xlog:gc GCTest
[0.222s][info][gc] Using G1[2.825s][info][gc] GC(0) Pause
Young (G1 Evacuation Pause)
26M->5M(256M) 355.623ms[3.096s][info][gc] GC(1) Pause
Young (G1 Evacuation Pause)
14M->7M(256M) 50.030ms[3.385s][info][gc] GC(2) Pause
Young (G1 Evacuation Pause)
17M->10M(256M) 40.576ms
2)查看GC详细信息,在JDK 9之前使用-XX:+PrintGCDetails,在JDK 9之后使用-X-log:gc*,用通配符*将GC标签下所有细分过程都打印出来,如果把日志级别调整到Debug或者Trace(基于版面篇幅考虑,例子中并没有),还将获得更多细节信息:
bash-3.2$ java -Xlog:gc* GCTest
[0.233s][info][gc,heap] Heap re
gion size: 1M[0.383s][info][gc ] Using G1[0.383s][info][gc,heap,coops] H
eap address: 0xfffffffe50400000, size: 4064 M
B, Compressed Oops mode: Non-
zero based:0xfffffffe50000000, Oop shift a
mount: 3[3.064s][info][gc,start ] GC(0)
Pause Young (G1 Evacuation Pause)gc,task ] GC(0) Using 23 worker
s of 23 for evacuation[3.420s][info][gc,phases ] GC(0
) Pre Evacuate Collection Set: 0.2ms[3.421s][info][gc,phases ] GC(0
) Evacuate Collection Set: 348.0msgc,phases ] GC(0) Post Evacuate
Collection Set: 6.2ms[3.421s][info][gc,phases ] GC(0
) Other: 2.8msgc,heap ] GC(0) Eden regions: 2
4->0(9)[3.421s][info][gc,heap ] GC(0)
Survivor regions: 0->3(3)[3.421s][info][gc,heap ] GC(0)
Old regions: 0->2[3.421s][info][gc,heap ] GC(0)
Humongous regions: 2->1[3.421s][info][gc,metaspace ] G
C(0) Metaspace: 4719K->4719K(1056768K)[3.421s][info][gc ] GC(0) Pause
Young (G1 Evacuation Pause) 26M->5M(256M) 35
7.743ms[3.422s][info][gc,cpu ] GC(0) U
ser=0.70s Sys=5.13s Real=0.36s[3.648s][info][gc,start ] GC(1)
Pause Young (G1 Evacuation Pause)[3.648s][info][gc,task ] GC(1)
Using 23 workers of 23 for evacuation[3.699s][info][gc,phases ] GC(1
) Pre Evacuate Collection Set: 0.3msgc,phases ] GC(1) Evacuate Coll
ection Set: 45.6msgc,phases ] GC(1) Post Evacuate
Collection Set: 3.4msgc,phases ] GC(1) Other: 1.7msgc,heap ] GC(1) Eden regions: 9
->0(10)[3.699s][info][gc,heap ] GC(1)
Survivor regions: 3->2(2)[3.699s][info][gc,heap ] GC(1)
Old regions: 2->5[3.700s][info][gc,heap ] GC(1)
Humongous regions: 1->1[3.700s][info][gc,metaspace ] G
C(1) Metaspace: 4726K->4726K(1056768K)[3.700s][info][gc ] GC(1) Pause
Young (G1 Evacuation Pause) 14M->7M(256M) 51
.872ms[3.700s][info][gc,cpu ] GC(1) U
ser=0.56s Sys=0.46s Real=0.05s
3)查看GC前后的堆、方法区可用容量变化,在JDK 9之前使用-XX:+PrintHeapAtGC,JDK 9之后使用-Xlog:gc+heap=debug:
bash-3.2$ java -Xlog:gc+heap=debug GCTest
[0.113s][info][gc,heap] Heap region size:
1M[0.113s][debug][gc,heap] Minimum heap 838
8608 Initial heap 268435456 Maximum heap 4261
412864[2.529s][debug][gc,heap] GC(0) Heap befor
e GC invocations=0 (full 0):[2.529s][debug][gc,heap] GC(0) garbage-fi
rst heap total 262144K, used 26624K [0xffffff
fe50400000, 0xfffffffe50500800,0xffffffff4e400000)[2.529s][debug][gc,heap] GC(0) region siz
e 1024K, 24 young (24576K), 0 survivors (0K)[2.530s][debug][gc,heap] GC(0) Metaspace
used 4719K, capacity 4844K, committed 5120K,
reserved 1056768K[2.530s][debug][gc,heap] GC(0) class spac
e used 413K, capacity 464K, committed 512K, r
eserved 1048576K[2.892s][info ][gc,heap] GC(0) Eden regio
ns: 24->0(9)[2.892s][info ][gc,heap] GC(0) Survivor r
egions: 0->3(3)[2.892s][info ][gc,heap] GC(0) Old region
s: 0->2[2.892s][info ][gc,heap] GC(0) Humongous
regions: 2->1[2.893s][debug][gc,heap] GC(0) Heap after
GC invocations=1 (full 0):[2.893s][debug][gc,heap] GC(0) garbage-fi
rst heap total 262144K, used 5850K [0xfffffff
e50400000, 0xfffffffe50500800, 0xffffffff4e400000)[2.893s][debug][gc,heap] GC(0) region siz
e 1024K, 3 young (3072K), 3 survivors (3072K)
[2.893s][debug][gc,heap] GC(0) Metaspace
used 4719K, capacity 4844K, committed 5120K,
reserved 1056768K[2.893s][debug][gc,heap] GC(0) class spac
e used 413K, capacity 464K, committed 512K, r
eserved 1048576K
4)查看GC过程中用户线程并发时间以及停顿的时间,在JDK 9之前使用 -XX:+Print-GCApplicationConcurrentTime以及 -XX:+PrintGCApplicationStoppedTime,JDK 9之后使用-Xlog:safepoint:
bash-3.2$ java -Xlog:safepoint GCTest
[1.376s][info][safepoint] Application
time: 0.3091519 seconds[1.377s][info][safepoint] Total time
for which application threads were stopped: 0.0004600 seconds,
Stopping threads took:0.0002648 seconds[2.386s][info][safepoint] Application
time: 1.0091637 seconds[2.387s][info][safepoint] Total time
for which application threads were stopped: 0.0005217 seconds,
Stopping threads took:0.0002297 seconds
5)查看收集器Ergonomics机制(自动设置堆空间各分代区域大小、收集目标等内容,从Parallel收集器开始支持)自动调节的相关信息。在JDK 9之前使用-XX:+PrintAdaptive-SizePolicy,JDK 9之后使用-Xlog:gc+ergo*=trace:
bash-3.2$ java -Xlog:gc+ergo*=trace GCTest [0.122s][debug][gc,ergo,refine] Initial Refinement Zones: green: 23, yellow:69, red: 115, min yellow size: 46[0.142s][debug][gc,ergo,heap ] Expand the heap. requested expansion amount:268435456B expansion amount:268435456B[2.475s][trace][gc,ergo,cset ] GC(0) Start choosing CSet. pending cards: 0 predicted base time: 10.00ms remaining time:190.00ms target pause time: 200.00ms[2.476s][trace][gc,ergo,cset ] GC(0) Add young regions to CSet. eden: 24 regions, survivors: 0 regions, predicted youngregion time: 367.19ms, target pause time: 200.00ms[2.476s][debug][gc,ergo,cset ] GC(0) Finish choosing CSet. old: 0 regions, predicted old region time: 0.00ms, timeremaining: 0.00[2.826s][debug][gc,ergo ] GC(0) Running G1 Clear Card Table Task using 1 workers for 1 units of work for 24 regions.[2.827s][debug][gc,ergo ] GC(0) Running G1 Free Collection Set using 1 workers for collection set length 24[2.828s][trace][gc,ergo,refine] GC(0) Updating Refinement Zones: update_rs time: 0.004ms, update_rs buffers: 0, update_rsgoal time: 19.999ms
6)查看熬过收集后剩余对象的年龄分布信息,在JDK 9前使用-XX:+PrintTenuring-Distribution,JDK 9之后使用-Xlog:gc+age=trace:
bash-3.2$ java -Xlog:gc+age=trace GCTest
[2.406s][debug][gc,age] GC(0) Desired su
rvivor size 1572864 bytes, new threshold 15 (max threshold 15)[2.745s][trace][gc,age] GC(0) Age table
with threshold 15 (max threshold 15)[2.745s][trace][gc,age] GC(0) - age 1: 3
100640 bytes, 3100640 total[4.700s][debug][gc,age] GC(5) Desired su
rvivor size 2097152 bytes, new threshold 15 (max threshold 15)[4.810s][trace][gc,age] GC(5) Age table
with threshold 15 (max threshold 15)[4.810s][trace][gc,age] GC(5) - age 1: 2
658280 bytes, 2658280 total[4.810s][trace][gc,age] GC(5) - age 2: 1
527360 bytes, 4185640 total
囿于篇幅原因,不再一一列举,表3-3给出了全部在JDK 9中被废弃的日志相关参数及它们在JDK 9后使用-Xlog的代替配置形式。
垃圾收集器参数:
垃圾回收线程:
Hotspot 垃圾回收之ConcurrentMarkSweepThread 源码解析_孙大圣666的博客-CSDN博客_concurrentmarksweep
JVM中用于进行资源回收的线程,包括以下几种类型的线程:
"VM Periodic Task Thread"
该线程是JVM周期性任务调度的线程,它由WatcherThread创建,是一个单例对象。该线程在JVM内使用得比较频繁,比如:定期的内存监控、JVM运行状况监控。
"VM Periodic Task Thread" prio=10 tid=0x00007fbea82ae800 nid=0x5fa waiting on condition
可以使用jstat 命令查看GC的情况,比如查看某个进程没有存活必要的引用可以使用命令
jstat -gcutil 250 7 参数中pid是进程id,后面的250和7表示每250毫秒打印一次,总共打印7次。
这对于防止因为应用代码中直接使用native库或者第三方的一些监控工具的内存泄漏有非常大的帮助。
"GC task thread#0 (ParallelGC)"
垃圾回收线程,该线程会负责进行垃圾回收。通常JVM会启动多个线程来处理这个工作,线程名称中#后面的数字也会累加。
"GC task thread#0 (ParallelGC)" prio=5 tid=0x00007fc6b480d000 nid=0x2503 runnable
"GC task thread#1 (ParallelGC)" prio=5 tid=0x00007fc6b2812000 nid=0x2703 runnable
"GC task thread#2 (ParallelGC)" prio=5 tid=0x00007fc6b2812800 nid=0x2903 runnable
"GC task thread#3 (ParallelGC)" prio=5 tid=0x00007fc6b2813000 nid=0x2b03 runnable
如果在JVM中增加了 -XX:+UseConcMarkSweepGC 参数将会启用CMS (Concurrent Mark-Sweep)GC Thread方式,以下是该模式下的线程类型:
"Gang worker#0 (Parallel GC Threads)"
原来垃圾回收线程GC task thread#0 (ParallelGC) 被替换为 Gang worker#0 (Parallel GC Threads)。Gang worker 是JVM用于年轻代垃圾回收(minor gc)的线程。
"Gang worker#0 (Parallel GC Threads)" prio=10 tid=0x00007fbea801b800 nid=0x5e4 runnable
"Gang worker#1 (Parallel GC Threads)" prio=10 tid=0x00007fbea801d800 nid=0x5e7 runnable
"Concurrent Mark-Sweep GC Thread"
并发标记清除垃圾回收器(就是通常所说的CMS GC)线程, 该线程主要针对于年老代垃圾回收。
"Concurrent Mark-Sweep GC Thread" prio=10 tid=0x00007fbea8073800 nid=0x5e8 runnable
"Surrogate Locker Thread (Concurrent GC)"
此线程主要配合CMS垃圾回收器来使用,是一个守护线程,主要负责处理GC过程中Java层的Reference(指软引用、弱引用等等)与jvm 内部层面的对象状态同步。
"Surrogate Locker Thread (Concurrent GC)" daemon prio=10 tid=0x00007fbea8158800 nid=0x5ee waiting on condition [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
这里以 WeakHashMap 为例进行说明,首先是一个关键点:
-
WeakHashMap和HashMap一样,内部有一个Entry[]数组;
-
WeakHashMap的Entry比较特殊,它的继承体系结构为Entry->WeakReference->Reference;
-
Reference 里面有一个全局锁对象:Lock,它也被称为pending_lock,注意:它是静态对象;
-
Reference 里面有一个静态变量:pending;
-
Reference 里面有一个静态内部类:ReferenceHandler的线程,它在static块里面被初始化并且启动,启动完成后处于wait状态,它在一个Lock同步锁模块中等待;
-
WeakHashMap里面还实例化了一个ReferenceQueue列队
假设,WeakHashMap对象里面已经保存了很多对象的引用,JVM 在进行CMS GC的时候会创建一个ConcurrentMarkSweepThread(简称CMST)线程去进行GC。ConcurrentMarkSweepThread线程被创建的同时会创建一个SurrogateLockerThread(简称SLT)线程并且启动它,SLT启动之后,处于等待阶段。
CMST开始GC时,会发一个消息给SLT让它去获取Java层Reference对象的全局锁:Lock。直到CMS GC完毕之后,JVM 会将WeakHashMap中所有被回收的对象所属的WeakReference容器对象放入到Reference 的pending属性当中(每次GC完毕之后,pending属性基本上都不会为null了),然后通知SLT释放并且notify全局锁:Lock。此时激活了ReferenceHandler线程的run方法,使其脱离wait状态,开始工作了。
ReferenceHandler这个线程会将pending中的所有WeakReference对象都移动到它们各自的列队当中,比如当前这个WeakReference属于某个WeakHashMap对象,那么它就会被放入相应的ReferenceQueue列队里面(该列队是链表结构)。 当我们下次从WeakHashMap对象里面get、put数据或者调用size方法的时候,WeakHashMap就会将ReferenceQueue列队中的WeakReference依依poll出来去和Entry[]数据做比较,如果发现相同的,则说明这个Entry所保存的对象已经被GC掉了,那么将Entry[]内的Entry对象剔除掉。