一.概述
GC需要完成的三件事:1.哪些内存需要回收? 2.什么时候回收? 3.如何回收
Java内存运行时区域的各个部分:
a. 其中程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作,每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(在运行期会由JIT编译器进行一些优化,本章默认是编译器可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。
b. Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存。
二.堆内存的回收
1.判断对象是否还有引用
在堆里面存放着 Java 世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还"存活"着,哪些已经"死去"(即不可能再被任何途径使用的对象)。
1.1 引用计数算法
a. 给对象中添加一个引用计数,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
b. 引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,也有一些比较著名的应用案例,例如微软公司的COM计数,FlashPlayer,Python语言和在游戏脚本领域被广泛应用的Squirrel中都使用了引用计数算法进行内存管理。
c. 但是主流的Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题,例如两个对象A和B都有字段instance,赋值令A.instance = B和B.instance = A,除此之外,这两个对象再无任何引用,实际上这两个已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为0,于是引用计数法无法通知GC收集器回收它们。
1.2 可达性分析算法
a.在主流的商用程序语言(Java、C#)的主流实现中,都是通过可达性分析来判定对象是否是存活的。
b.这个算法的基本思路是通过一系列的称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连,则证明此对象是不可用的。
在Java语言中,可作为GC Roots的对象包括下面几种:
1. Thread-存活的线程。
2. 虚拟机栈(栈帧中的本地变量表)中引用的对象。
3. 方法区中的类静态属性引用的对象。 (一般指被 static 修饰的对象,加载类的时候就加载到内存中。)
4. 方法区中的常量引用的对象。
5. 本地方法栈中的 JNI(native 方法)引用的对象。
6. Monitor Used-用于同步监控的对象。
2.java中的引用类型
1. 无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与"引用"有关。
2. 在 JDK 1.2 以前,Java中的引用的定义很传统,如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用,这种定义很纯粹,,是太过狭隘,我们希望能描述这样一类对象,当内存空间还足够时,则能保留在内存之中,如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象,很多系统的缓存功能都符合这样的应用场景。
3. 在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为强引用,软引用,弱引用,虚引用 4 种,这4种引用强度依次逐渐减弱。
四种引用类型:
1. 强引用:在程序代码之中普遍存在的,类似"Object obj = new Object()"这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
2. 软引用:用来描述一些还有用但并非必须的对象,对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常,在JDK 1.2 之后,提供了SoftReference 类来实现软引用。
3. 弱引用:也是用来描述非必须对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前,当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象,在JDK 1.2之后,提供了WeakReference 类来实现弱引用(ThreadLocal中也采用了弱引用)。
4. 虚引用:也称为幻影引用,它是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被垃圾收集器回收时收到一个系统通知,在JDK1.2之后,提供了PhantomReference 类来实现虚引用。
3.回收前的标记
即使在可达性分析算法中不可达的对象,也并非是"非死不可",这时候它们暂时处于"缓刑"阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
1. 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。
2. 当对象没有覆盖finalize()方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为"没有必要执行finalize()"。
3. 如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一个由虚拟机自动建立的低优先级的 Finalizer 线程去执行它,虚拟机会触发这个方法,但并不会承诺等待它运行结束,这样做的原因是,如果一个对象在 finalize() 方法中执行缓慢,或者发生了死循环,将很可能导致 F-Queue 队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。
4. finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后GC将对 F-Queue 中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,那在第二次标记时它将被移除出"即将回收"的集合,如果这时候还没有逃脱,那基本上他就真的被回收了。
5. 最好不要用 finalize() 方法拯救对象,它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,finalize() 能做的工作使用try-finally或者其他方式可以做的更好。
三.回收方法区
很多人认为方法区(或者HotSpot虚拟机中的永久代)是没有垃圾收集的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区中进行垃圾收集的"性价比"一般比较低:在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而永久代的垃圾收集效率远低于此。
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类:
1. 回收废弃常量与回收Java堆中的对象非常类似,以常量池中字面量的回收为例,例如一个字符串"abc"已经进入了常量池中,但是当前系统没有任何一个String对象是叫做 "abc" 的,换句话说,就是没有任何String对象引用常量池中的"abc"常量,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个"abc"常量就会被系统清理出常量池,常量池中的其他类(接口)、方法、字段的符号引用也与此类似。
2. 无用的类:
a.该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
b.加载该类的ClassLoader已经被回收。
c.该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是"可以",而并不是和对象一样不使用了就必然会回收,在大量使用反射、动态代理、CGLib等ByteCode框架,动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。
注:本章主要以java1.7举例,这里有必要记录下方法方法区的演变:
方法区的实现的演变:
jdk1.7之前:hotspot虚拟机对方法区的实现为永久代 。
jdk1.8及之后:hotspot移除了永久代用元空间代替。
运行时常量池存和字符串常量池的变化:
jdk1.7之前:运行时常量池(包含字符串常量池)存放在方法区,此时 hotspot 虚拟机对方法区的实现为永久代。
jdk1.7:字符串常量池从方法区移动到了堆中。运行时常量池剩下的东西还在方法区,也就是hotspot中的永久代。
jdk1.8:hotspot移除了永久代,用元空间(Metaspace) 取代。字符串常量池还在堆中,运行时常量池还在方法区, 只不过方法区的实现从永久代变成元空间(Metaspace)。
四.垃圾收集算法
1.基础算法:标记-清除算法
1. 该算法分为"标记"和"清除"两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,它是最基础的收集算法,后续的收集算法都是基于这种思路并对其布局进行改进而得到的。
2. 它主要有两个不足:
a.效率问题:标记和清除两个过程的效率都不高。
b.空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2.新生代算法:复制算法
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清除掉,这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效,只是这种算法的代价是将内存缩小为了原来的一半,代价很高。
1. 现代商业虚拟机都采用这种收集算法来回收新生代,新生代中的对象98%是"朝生夕死"的,所以不需要按照 1:1 的比例来划分内存空间而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块Survivor,当回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间,HotSpot虚拟机默认 Eden 和 Survivor 的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被浪费,当 Survivor 空间不够时,需要依赖其他内存(这里指老年代)进行分配担保。
2. 内存的分配担保:如果另外一块 Survivor 空间没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。
3.老年代算法:标记-整理算法
1. 复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低,更重要的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
2. 根据老年代的特点,产生了一种"标记-整理"算法,标记过程仍然与 "标记-清除" 算法一样,但后续步骤不是直接对可回收对象进行整理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
4.总结:分代收集算法
1. 当前商业虚拟机的垃圾收集都采用"分代收集"算法,这种算法根据对象存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样根据各个年代的特点采用最适当的收集算法。
2. 新生代:在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
3. 老年代:在老年代中因为对象存活率高,没有额外空间对它进行分配担保,就必须使用"标记-清理"或者"标记-整理"算法来进行回收。
六.垃圾收集器
1.串行GC(Serial GC / Serial Old GC)
配置串行 GC:
-XX:+UseSerialGC
1. 串行 GC 对年轻代使用 mark-copy(标记-复制)算法,对老年代使用 mark-sweep-compact(标记-清除-整理)算法。
2. 单线程收集器,只会使用一个 CPU 或一条收集线程去完成垃圾收集工作(多条垃圾收集并行工作,但用户线程仍处于等待状态),而且在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束,这种停顿就是典型的"stop the world"。
3. 虽然如此,但它依然是虚拟机运行在 Client 模式下的默认新生代收集器,它有着优于其他收集器的地方:简单而高效,没有线程交互的开销,可以获得最高的单线程收集效率。
4. 该选项只适合几百 MB 堆内存的 JVM,而且是单核 CPU 时比较有用。
2.PerNew 收集器
改进版本的 Serial GC,可以配合 CMS 使用:
-XX:+USeParNewGC
1. ParNew 收集器是 Serial 收集器的多线程版本,是一个新生代垃圾收集器, 除了使用多条线程进行垃圾收集之外,其余行为包括 Serial 可用的所有控制参数,ParNew 收集器除了多线程收集之外,与其他 Serial 收集器相比并没有太多创新之处,但它却是许多运行在 Server 模式下的虚拟机中首选的新生代收集器,其中一个与性能无关但很重要的原因是:除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。
2. ParNew 随着使用的 CPU 的数量的增加,它对于 GC 时系统资源的有效利用还是很有好处的。
3.并行 GC(Parallel GC / Parallel Old GC)
-XX:+UseParallelGC
-XX:+UseParallelOldGC
-XX:+UseParallelGC -XX:+UseParallelOldGC
1. 年轻代和老年代的垃圾回收都会触发 STW 事件。
2. 在年轻代使用 标记-复制(mark-copy)算法,在老年代使用 标记-清除-整理(mark-sweepcompact)算法。
3. -XX:ParallelGCThreads=N 来指定 GC 线程数, 其默认值为 CPU 核心数。
4. 并行垃圾收集器适用于多核服务器,主要目标是增加吞吐量。因为对系统资源的有效使用,能达到更高的吞吐量:
• 在 GC 期间,所有 CPU 内核都在并行清理垃圾,所以总暂停时间更短;
• 在两次 GC 周期的间隔期,没有 GC 线程在运行,不会消耗任何系统资源。
4.CMS GC(Mostly Concurrent Mark and Sweep Garbage Collector)
启用 cms 垃圾收集器
-XX:+UseConcMarkSweepGC
CMS垃圾收集器本身是老年代垃圾收集器, 主要使用并发 mark-sweep (标记-清除)算法。它可以搭配 PerNew 的年轻代垃圾收集器使用, 其对年轻代采用并行 STW 方式的 mark-copy (标记-复制)算法。
CMS GC 的设计目标是避免在老年代垃圾收集时出现长时间的卡顿,主要通过两种手段来达成此目标:
1. 不对老年代进行整理,而是使用空闲列表(free-lists)来管理内存空间的回收。
2. 在 mark-and-sweep (标记-清除) 阶段的大部分工作和应用线程一起并发执行。
也就是说,在这些阶段并没有明显的应用线程暂停。但值得注意的是,它仍然和应用线程争抢CPU。
• 默认情况下,CMS 使用的并发线程数等于 CPU 核心数的 1/4。
• 如果服务器是多核 CPU,并且主要调优目标是降低 GC 停顿导致的系统延迟,那么使用 CMS 是个很明智的选择。
• 进行老年代的并发回收时,可能会伴随着多次年轻代的 minor GC。
CMS GC的六个阶段
阶段1 : Initial Mark(初始标记)
这个阶段伴随着 STW 暂停。初始标记的目标是标记所有的根对象,包括根对象直接引用的对象,以及被年轻代中所有存活对象所引用的对象(老年代单独回收)。
阶段2 : Concurrent Mark(并发标记)
在此阶段,CMS GC 遍历老年代,标记所有的存活对象,从前一阶段 “Initial Mark” 找到的根对象开始算起。 “并发标记”阶段,就是与应用程序同时运行,不用暂停的阶段。
阶段3 : Concurrent Preclean(并发预清理)
此阶段同样是与应用线程并发执行的,不需要停止应用线程。因为前一阶段【并发标记】与程序并发运行,可能有一些引用关系已经发生了改变。如果在并发标记过程中引用关系发生了变化,JVM 会通过“Card(卡片)”的方式将发生了改变的区域标记为“脏”区,这就是所谓的 卡片标记(Card Marking)。
阶段4 : Final Remark(最终标记)
• 最终标记阶段是此次 GC 事件中的第二次(也是最后一次)STW 停顿。本阶段的目标是完成老年代中所有存活对象的标记。因为之前的预清理阶段是并发执行的,有可能 GC 线程跟不上应用程序的修改速度。所以需要一次 STW 暂停来处理各种复杂的情况。
• 通常 CMS 会尝试在年轻代尽可能空的情况下执行 Final Remark 阶段,以免连续触发多次 STW 事件。
阶段5 : Concurrent Sweep(并发清除)
此阶段与应用程序并发执行,不需要 STW 停顿。JVM 在此阶段删除不再使用的对象,并回收他们占用的内存空间。
阶段 6: Concurrent Reset(并发重置)
此阶段与应用程序并发执行,重置 CMS 算法相关的内部数据,为下一次 GC 循环做准备。
总结:
CMS 垃圾收集器在减少停顿时间上做了很多复杂而有用的工作,用于垃圾回收的并发线程执行的同时,并不需要暂停应用线程。 当然,CMS 也有一些缺点,其中最大的问题就是老年代内存碎片问题(因为不压缩),在某些情况下 GC 会造成不可预测的暂停时间,特别是堆内存较大的情况下。
5.G1 GC
启用G1垃圾收集器, 并每次执行 GC 操作的暂停时间为 50 ms:
-XX:+UseG1GC -XX:MaxGCPauseMillis=50
• G1 的全称是 Garbage-First,意为垃圾优先,哪一块的垃圾最多就优先清理它。
• G1 GC 最主要的设计目标是:将 STW 停顿的时间和分布,变成可预期且可配置的。
• 事实上,G1 GC 是一款软实时垃圾收集器,可以为其设置某项特定的性能指标。为了达成可预期停顿时间的指标,G1 GC 有一些独特的实现。
• 首先,堆不再分成年轻代和老年代,而是划分为多个(通常是2048个)可以存放对象的小块堆区域(smaller heap regions)。
• 每个小块,可能一会被定义成 Eden 区,一会被指定为 Survivor区或者Old 区。在逻辑上,所有的 Eden 区和 Survivor 区合起来就是年轻代,所有的 Old 区拼在一起那就是老年代。
• 这样划分之后,使得 G1 不必每次都去收集整个堆空间,而是以增量的方式来进行处理:
• 每次只处理一部分内存块,称为此次 GC 的回收集(collection set)。每次 GC 暂停都会收集所有年轻代的内存块,但一般只包含部分老年代的内存块。
• G1 的另一项创新是,在并发阶段估算每个小堆块存活对象的总数。构建回收集的原则是:
• 垃圾最多的小块会被优先收集。这也是 G1 名称的由来。
G1 GC--配置参数
参数 | 说明 |
---|---|
-XX:+UseG1GC | 启用 G1 GC; |
-XX:G1NewSizePercent | 初始年轻代占整个 Java Heap 的大小,默认值为 5%; |
-XX:G1MaxNewSizePercent | 最大年轻代占整个 Java Heap 的大小,默认值为 60%; |
-XX:G1HeapRegionSize | 设置每个 Region 的大小,单位 MB,需要为 1、2、4、8、16、32 中的某个值,默认是堆内存的1/2000。如果这个值设置比较大,那么大对象就可以进入 Region 了; |
-XX:ConcGCThreads | 与 Java 应用一起执行的 GC 线程数量,默认是 Java 线程的 1/4,减少这个参数的数值可能会提升并行回收的效率,提高系统内部吞吐量。如果这个数值过低,参与回收垃圾的线程不足,也会导致并行回收机制耗时加长; |
-XX:+InitiatingHeapOccupancyPercent | (简称 IHOP)G1 内部并行回收循环启动的阈值,默认为 Java Heap的 45%。这个可以理解为老年代使用大于等于 45% 的时候,JVM 会启动垃圾回收。这个值非常重要,它决定了在什么时间启动老年代的并行回收; |
-XX:G1HeapWastePercent | G1停止回收的最小内存大小,默认是堆大小的 5%。GC 会收集所有的 Region 中的对象,但是如果下降到了 5%,就会停下来不再收集了。就是说,不必每次回收就把所有的垃圾都处理完,可以遗留少量的下次处理,这样也降低了单次消耗的时间; |
-XX:G1MixedGCCountTarget | 设置并行循环之后需要有多少个混合 GC 启动,默认值是 8 个。老年代 Regions的回收时间通常比年轻代的收集时间要长一些。所以如果混合收集器比较多,可以允许 G1 延长老年代的收集时间。 |
-XX:+G1PrintRegionLivenessInfo | 这个参数需要和 -XX:+UnlockDiagnosticVMOptions 配合启动,打印 JVM 的调试信息,每个Region 里的对象存活信息。 |
-XX:G1ReservePercent | G1 为了保留一些空间用于年代之间的提升,默认值是堆空间的 10%。因为大量执行回收的地方在年轻代(存活时间较短),所以如果你的应用里面有比较大的堆内存空间、比较多的大对象存活,这里需要保留一些内存。 |
-XX:+G1SummarizeRSetStats | 这也是一个 VM 的调试信息。如果启用,会在 VM 退出的时候打印出 Rsets 的详细总结信息。如果启用 -XX:G1SummaryRSetStatsPeriod 参数,就会阶段性地打印 Rsets 信息。 |
-XX:+G1TraceConcRefinement | 这个也是一个 VM 的调试信息,如果启用,并行回收阶段的日志就会被详细打印出来。 |
-XX:+GCTimeRatio | 这个参数就是计算花在 Java 应用线程上和花在 GC 线程上的时间比率,默认是 9,跟新生代内存的分配比例一致。这个参数主要的目的是让用户可以控制花在应用上的时间,G1 的计算公式是 100/(1+GCTimeRatio)。这样如果参数设置为9,则最多 10% 的时间会花在 GC 工作上面。Parallel GC 的默认值是 99,表示 1% 的时间被用在 GC 上面,这是因为 Parallel GC 贯穿整个 GC,而 G1 则根据 Region 来进行划分,不需要全局性扫描整个内存堆。 |
-XX:+UseStringDeduplication | 手动开启 Java String 对象的去重工作,这个是 JDK8u20 版本之后新增的参数,主要用于相同String 避免重复申请内存,节约 Region 的使用。 |
-XX:MaxGCPauseMills | 预期 G1 每次执行 GC 操作的暂停时间,单位是毫秒,默认值是 200 毫秒,G1 会尽量保证控制在这个范围内。 |
G1 GC的处理步骤
1.年轻代模式转移暂停(Evacuation Pause)
• G1 GC 会通过前面一段时间的运行情况来不断的调整自己的回收策略和行为,以此来比较稳定地控制暂停时间。在应用程序刚启动时,G1 还没有采集到什么足够的信息,这时候就处于初始的 fully-young 模式。当年轻代空间用满后,应用线程会被暂停,年轻代内存块中的存活对象被拷贝到存活区。如果还没有存活区,则任意选择一部分空闲的内存块作为存活区。
• 拷贝的过程称为转移(Evacuation),这和前面介绍的其他年轻代收集器是一样的工作原理。
2.并发标记(Concurrent Marking)
• G1 GC 的很多概念建立在 CMS 的基础上,所以下面的内容需要对 CMS 有一定的理解。
• G1 并发标记的过程与 CMS 基本上是一样的。G1 的并发标记通过 Snapshot-At-The-Beginning(起始快照)的方式,在标记阶段开始时记下所有的存活对象。即使在标记的同时又有一些变成了垃圾。通过对象的存活信息,可以构建出每个小堆块的存活状态,以便回收集能高效地进行选择。
• 这些信息在接下来的阶段会用来执行老年代区域的垃圾收集。
有两种情况是可以完全并发执行的:
a.如果在标记阶段确定某个小堆块中没有存活对象,只包含垃圾;
b.在 STW 转移暂停期间,同时包含垃圾和存活对象的老年代小堆块。
当堆内存的总体使用比例达到一定数值,就会触发并发标记。这个默认比例是 45%,但也可以通过 JVM 参数InitiatingHeapOccupancyPercent 来设置。和 CMS 一样,G1 的并发标记也是由多个阶段组成,其中一些阶段是完全并发的,还有一些阶段则会暂停应用线程。
阶段 1: Initial Mark(初始标记)
此阶段标记所有从 GC 根对象直接可达的对象。
阶段 2: Root Region Scan(Root区扫描)
此阶段标记所有从 "根区域" 可达的存活对象。根区域包括:非空的区域,以及在标记过程中不得不收集的区域。
阶段 3: Concurrent Mark(并发标记)
此阶段和 CMS 的并发标记阶段非常类似:只遍历对象图,并在一个特殊的位图中标记能访问到的对象。
阶段 4: Remark(再次标记)
和 CMS 类似,这是一次 STW 停顿(因为不是并发的阶段),以完成标记过程。 G1 收集器会短暂地停止应用线程,停止并发更新信息的写入,处理其中的少量信息,并标记所有在并发标记开始时未被标记的存活对象。
阶段 5: Cleanup(清理)
最后这个清理阶段为即将到来的转移阶段做准备,统计小堆块中所有存活的对象,并将小堆块进行排序,以提升GC的效率,维护并发标记的内部状态。 所有不包含存活对象的小堆块在此阶段都被回收了。有一部分任务是并发的:例如空堆区的回收,还有大部分的存活率计算。此阶段也需要一个短暂的 STW 暂停。
3.转移暂停: 混合模式(Evacuation Pause (mixed))
• 并发标记完成之后,G1将执行一次混合收集(mixed collection),就是不只清理年轻代,还将一部分老年代区域也加入到回收集中。混合模式的转移暂停不一定紧跟并发标记阶段。有很多规则和历史数据会影响混合模式的启动时机。比如,假若在老年代中可以并发地腾出很多的小堆块,就没有必要启动混合模式。
• 因此,在并发标记与混合转移暂停之间,很可能会存在多次 young 模式的转移暂停。
• 具体添加到回收集的老年代小堆块的大小及顺序,也是基于许多规则来判定的。其中包括指定的软实时性能指标,存活性,以及在并发标记期间收集的 GC 效率等数据,外加一些可配置的 JVM 选项。混合收集的过程,很大程度上和前面的 fully-young gc 是一样的。
G1 GC 的注意事项
特别需要注意的是,某些情况下 G1 触发了 Full GC,这时 G1 会退化使用 Serial 收集器来完成垃圾的清理工作,它仅仅使用单线程来完成 GC 工作,GC 暂停时间将达到秒级别的。
1.并发模式失败
G1 启动标记周期,但在 Mix GC 之前,老年代就被填满,这时候 G1 会放弃标记周期。
解决办法:
增加堆大小,或者调整周期(例如增加线程数-XX:ConcGCThreads 等)。
2.晋升失败
没有足够的内存供存活对象或晋升对象使用,由此触发了 Full GC(to-space exhausted/to-space overflow)。
解决办法:
a. 增加 –XX:G1ReservePercent 选项的值(并相应增加总的堆大小)增加预留内存量。
b. 通过减少 –XX:InitiatingHeapOccupancyPercent 提前启动标记周期。
c. 也可以通过增加 –XX:ConcGCThreads 选项的值来增加并行标记线程的数目。
3.巨型对象分配失败
当巨型对象找不到合适的空间进行分配时,就会启动 Full GC,来释放空间。
解决办法:
增加内存或者增大 -XX:G1HeapRegionSize
七.各个 GC 对比
收集器 | 串行、并行or并发 | 新生代、老年代 | 算法 | 目标 | 适用场景 |
---|---|---|---|---|---|
Serial | 串行 | 新生代 | 复制算法 | 响应速度优先 | 单CPU环境下的Client模式 |
Serial Old | 串行 | 老年代 | 标记-整理 | 响应速度优先 | 单CPU环境下的Client模式、CMS的后备预案 |
ParNew | 并行 | 新生代 | 复制算法 | 响应速度优先 | 多CPU环境时在Server模式下与CMS配合 |
Parallel Scavenge | 并行 | 新生代 | 复制算法 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
Parallel Old | 并行 | 老年代 | 标记-整理 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
CMS | 并发 | 老年代 | 标记-清除 | 响应速度优先 | 集中在互联网站或B/S系统服务端上的Java应用 |
G1 | 并发 | both | 标记-整理+复制算法 | 响应速度优先 | 面向服务端应用,将来替换 CMS |
八.常用的 GC 组合
常用的组合为:
1. Serial + Serial Old 实现单线程的低延迟垃圾回收机制;
2. ParNew+CMS,实现多线程的低延迟垃圾回收机制;
3. Parallel Scavenge和Parallel Scavenge Old,实现多线程的高吞吐量垃圾回收机制。
九.如何选择GC
选择正确的 GC 算法,唯一可行的方式就是去尝试,一般性的指导原则:
1. 如果系统考虑吞吐优先,CPU 资源都用来最大程度处理业务,用 Parallel GC
2. 如果系统考虑低延迟有限,每次 GC 时间尽量短,用 CMS GC
3. 如果系统内存堆较大,同时希望整体来看平均 GC 时间可控,使用 G1 GC。
对于内存大小的考量:
1. 一般 4G 以上,算是比较大,用 G1 的性价比较高。
2. 一般超过 8G,比如 16G-64G 内存,非常推荐使用 G1 GC。
十.其他GC
1. ZGC
ZGC(Z Garbage Collector) 是一款性能比 G1 更加优秀的垃圾收集器。
ZGC 第一次出现是在 JDK 11 中以实验性的特性引入,这也是 JDK 11 中最大的亮点。在 JDK 15 中 ZGC 不再是实验功能,可以正式投入生产使用了,使用 –XX:+UseZGC 可以启用 ZGC。
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC -Xmx16g
ZGC 最主要的特点包括:
1. GC 最大停顿时间不超过 10ms
2. 堆内存支持范围广,小至几百 MB 的堆空间,大至 4TB 的超大堆内存(JDK13 升至 16TB)
3. 与 G1 相比,应用吞吐量下降不超过 15%
4. 当前只支持 Linux/x64 位平台,JDK15 后支持 MacOS 和 Windows 系统
2. ShennandoahGC
-XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC -Xmx16g
• Shenandoah GC 立项比 ZGC 更早,设计为 GC 线程与应用线程并发执行的方式,通过实现垃圾回收过程的并发处理,改善停顿时间,使得 GC 执行线程能够在业务处理线程运行过程中进行堆压缩、标记和整理,从而消除了绝大部分的暂停时间。
• Shenandoah 团队对外宣称 Shenandoah GC 的暂停时间与堆大小无关,无论是 200 MB 还是 200 GB的堆内存,都可以保障具有很低的暂停时间(注意:并不像 ZGC 那样保证暂停时间在 10ms 以内)。
十一. GC 总结
1. 到目前为止,我们一共了解了 Java 目前支持的所有 GC 算法,一共有 7 类:
1. 串行 GC(Serial GC): 单线程执行,应用需要暂停;
2. 并行 GC(ParNew、Parallel Scavenge、Parallel Old): 多线程并行地执行垃圾回收,关注于高吞吐;
3. CMS(Concurrent Mark-Sweep): 多线程并发标记和清除,关注与降低延迟;
4. G1(G First): 通过划分多个内存区域做增量整理和回收,进一步降低延迟;
5. ZGC(Z Garbage Collector): 通过着色指针和读屏障,实现几乎全部的并发执行,几毫秒级别的延迟,线性可扩展;
6. Epsilon: 实验性的 GC,供性能分析使用;
7. Shenandoah: G1 的改进版本,跟 ZGC 类似。
2. GC 算法和实现的演进路线:
1. 串行 -> 并行: 重复利用多核 CPU 的优势,大幅降低 GC 暂停时间,提升吞吐量。
2. 并行 -> 并发: 不只开多个 GC 线程并行回收,还将 GC 操作拆分为多个步骤,让很多繁重的任务和应用线程一起并发执行,减少了单次 GC 暂停持续的时间,这能有效降低业务系统的延迟。
3. CMS -> G1: G1 可以说是在 CMS 基础上进行迭代和优化开发出来的,划分为多个小堆块进行增量回收,这样就更进一步地降低了单次 GC 暂停的时间
4. G1 -> ZGC::ZGC 号称无停顿垃圾收集器,这又是一次极大的改进。ZGC 和 G1 有一些相似的地方,但是底层的算法 和思想又有了全新的突破。
3. 根据场景选择相应的垃圾收集器
• 目前绝大部分 Java 应用系统,堆内存并不大比如 2G-4G 以内,而且对 10ms 这种低延迟的 GC 暂停不敏感,也就是说处理一个业务步骤,大概几百毫秒都是可以接受的,GC 暂停 100ms 还是 10ms 没多大区别。
• 另一方面,系统的吞吐量反而往往是我们追求的重点,这时候就需要考虑采用并行 GC。
• 如果堆内存再大一些,可以考虑 G1 GC。如果内存非常大(比如超过 16G,甚至是 64G、128G),或者是对延迟非常敏感(比如高频量化交易系统),就需要考虑使用本节提到的新 GC(ZGC/Shenandoah)。