2、jvm虚拟机垃圾回收机制
一、首先了解一下堆栈内存
1、jvm内存结构
从上图可以看出,整个JVM内存是由栈内存、堆内存和永久代构成。
年轻代(New generation) = eden + s0 + s1
堆内存 = 年轻代 + 老年代(Old generation)
JDK1.8以前: JVM内存 = 栈内存 + 堆内存 + 永久代
JDK1.8以后: 由元空间取代了永久代,元空间并不在JVM中,而是使用本地内存。因此JVM内存 = 栈内存 + 堆内存
栈内存归属于单个线程,也就是每创建一个线程都会分配一块栈内存,而栈中存储的东西只有本线程可见,属于线程私有。 栈的生命周期与线程一致,一旦线程结束,栈内存也就被回收。 栈中存放的内容主要包括:8大基本类型 + 对象的引用 + 实例的方法
堆内存是由年轻代和老年代构成,JDK1.8以后,永久代被元空间取代,使用直接内存,不占用堆内存。堆内存是Jvm中空间最大的区域,所有线程共享堆,所有的数组以及内存对象的实例都在此区域分配。我们常说的垃圾回收就是作用于堆内存。
Eden区占大容量,Survivor两个区占小容量,默认比例是8:1:1
永久代这个区域是常驻内存的。用来存放JDK自身携带的Class对象。Interface元数据,存储的是Java运行时的一些环境。这个区域不存在垃圾回收!关闭虚拟机就会释放这个区域的内存。 当发现系统中元空间占用内存比较大时,排查方向是否加载了大量的第三方jar包,Tomcat部署了太多应用,大量动态生成的反射类等。
java -server -Xmx4g -Xms4g -Xmn2g –Xss128k
-Xmx4g:设置JVM最大可用内存为4g。
-Xms4g:设置JVM最小可用内存为4g。一般配置为与-Xmx相同,避免每次垃圾回收完成后JVM重新分配内存。
-Xmn2g:设置年轻代大小为2G。整个堆大小=年轻代大小 + 年老代大小,所以增大年轻代后,将会减小年老代大小。
-Xss128k:设置每个线程的堆栈大小。JDK5.0以后每个线程默认大小为1M,以前每个线程大小为256K。根据应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。
java -server -Xmx4g -Xms4g -Xmn2g –Xss128k -XX:NewRatio=4 -XX:SurvivorRatio=4 -XX:MaxMetaspaceSize=16m -XX:MaxTenuringThreshold=0
-XX:NewRatio=4: 设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。设置为4,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5
-XX:SurvivorRatio=4: 设置年轻代中Eden区与Survivor区的大小比值。设置为4,则两个Survivor区与一个Eden区的比值为2:4,一个Survivor区占整个年轻代的1/6
-XX:MaxMetaspaceSize=16m: 设置元空间最大可分配大小为16m。
-XX:MaxTenuringThreshold=0: 设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概率。
JVM给了三种选择:串行收集器、并行收集器、并发收集器,但是串行收集器只适用于小数据量的情况,所以这里的选择主要针对并行收集器和并发收集器。默认情况下,JDK5.0以前都是使用串行收集器,如果想使用其他收集器需要在启动时加入相应参数。JDK5.0以后,JVM会根据当前系统配置进行判断。
吞吐量优先的并行收集器
java -server -Xmx4g -Xms4g -Xmn2g –Xss128k -XX:+UseParallelGC -XX:ParallelGCThreads=20 -XX:+UseParallelOldGC -XX:+UseAdaptiveSizePolicy
-XX:+UseParallelGC:选择垃圾收集器为并行收集器。此配置仅对年轻代有效。即上述配置下,年轻代使用并发收集,而年老代仍旧使用串行收集。
-XX:ParallelGCThreads=20:配置并行收集器的线程数,即:同时多少个线程一起进行垃圾回收。此值最好配置与处理器数目相等。
-XX:+UseParallelOldGC:配置年老代垃圾收集方式为并行收集。JDK6.0支持对年老代并行收集。
-XX:+UseAdaptiveSizePolicy:设置此选项后,并行收集器会自动选择年轻代区大小和相应的Survivor区比例,以达到目标系统规定的最低相应时间或者收集频率等,此值建议使用并行收集器时,一直打开。响应时间优先的并发收集器
java -server -Xmx4g -Xms4g -Xmn2g –Xss128k -XX:ParallelGCThreads=20 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:CMSFullGCsBeforeCompaction=5 -XX:+UseCMSCompactAtFullCollection
-XX:+UseConcMarkSweepGC: 设置年老代为并发收集
-XX:+UseParNewGC: 设置年轻代为并行收集。可与CMS收集同时使用
-XX:CMSFullGCsBeforeCompaction:由于并发收集器不对内存空间进行压缩、整理,所以运行一段时间以后会产生“碎片”,使得运行效率降低。此值设置运行多少次GC以后对内存空间进行压缩、整理。
-XX:+UseCMSCompactAtFullCollection:打开对年老代的压缩。可能会影响性能,但是可以消除碎片其他辅助配置
GC日志打印
-XX:+PrintGC:输出形式:[GC 118250K->113543K(130112K), 0.0094143 secs] [Full GC 121376K->10414K(130112K), 0.0650971 secs]
-XX:+PrintGCDetails:输出形式:[GC [DefNew: 8614K->781K(9088K), 0.0123035 secs] 118250K->113543K(130112K), 0.0124633 secs] [GC [DefNew: 8614K->8614K(9088K), 0.0000665 secs][Tenured: 112761K->10414K(121024K), 0.0433488 secs] 121376K->10414K(130112K), 0.0436268 secs]OOM生成dump文件
-XX:+HeapDumpOnOutOfMemoryError 表示jvm发生oom异常时,自动生成dump文件
-XX:HeapDumpPath= 表示生成dump文件的存放目录
JVM性能调优牵扯到各方面的取舍与平衡,往往是牵一发而动全身,需要全盘考虑各方面的影响。在优化时候,切勿凭感觉或经验主义进行调整,而是需要通过系统运行的客观数据指标,不断找到最优解。同时,在进行性能调优前,您需要理解并掌握以下的相关基础理论知识:
1、JVM垃圾收集器和垃圾回收算法
2、JVM性能监控常用工具和命令
3、JVM运行时数据区域
4、能够读懂gc日志
5、内存分配与回收策略
垃圾回收的定义与重要性
垃圾回收(Garbage Collection,简称GC)是内存管理的核心组成部分,它负责自动回收不再使用的内存空间。在Java中,程序员不需要手动释放对象占用的内存,一旦对象不再被引用,垃圾回收器就会在适当的时机回收它们所占用的内存。这样可以避免内存泄漏和野指针,从而大大减轻了程序员的负担,也使得Java成为一个相对安全、易于开发的编程语言。
- 防止内存泄漏:手动管理内存容易导致内存泄漏,而GC可以自动回收不再使用的对象,防止内存泄漏的发生。
- 提高开发效率:程序员不再需要关心内存释放的问题,可以更加集中精力在业务逻辑的实现上。
- 系统性能和稳定性:通过有效的垃圾回收策略,可以保证系统的性能和稳定性。
垃圾回收的基本步骤分两步:
- 查找内存中不再使用的对象(GC判断策略)
- 释放这些对象占用的内存(GC收集算法)
2. 垃圾回收基础
2.1 对象的生命周期
在Java中,对象的生命周期包括以下几个阶段:
- 创建 (Creation): 当使用new关键字创建对象时,对象进入创建阶段。
- 使用 (Usage): 在对象被引用并使用的期间,对象处于使用阶段。
- 不可达 (Unreachable): 当对象不再被任何强引用指向时,它变成不可达状态,可能会被垃圾回收。
- 回收 (Collection): 垃圾回收器会在适当的时机回收不可达对象的内存空间。
- 终结 (Finalization): 如果对象有定义finalize方法,它会在被回收前被调用。
- 死亡 (Death): 对象的内存被回收,对象完成其生命周期。
public class ObjectLifecycle { public static void main(String[] args) { ObjectLifecycle obj = new ObjectLifecycle(); // 创建阶段 // 使用阶段 obj = null; // 不可达阶段 System.gc(); // 触发垃圾回收,进入回收阶段 // 对象进入死亡阶段 } @Override protected void finalize() throws Throwable { super.finalize(); System.out.println("对象终结阶段,finalize方法被调用"); } }
2.2 GC判断策略
1. 引用计数算法
给对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。正因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。
public class ReferenceCountingGC { public Object instance = null; public static void main(String[] args) { ReferenceCountingGC objectA = new ReferenceCountingGC(); ReferenceCountingGC objectB = new ReferenceCountingGC(); objectA.instance = objectB; objectB.instance = objectA; } }
2. 可达性分析算法
通过 GC Roots 作为起始点进行搜索,能够到达到的对象都是存活的,不可达的对象可被回收。
Java 虚拟机使用该算法来判断对象是否可被回收,在 Java 中 GC Roots 一般包含以下内容:
在虚拟机栈(栈帧中的本地变量表)中引用的对象:
Java public void method() { Object localVariable = new Object(); // localVariable是GC Roots }
在方法区中类静态属性引用的对象:
Java public class MyClass { private static Object staticObject = new Object(); // staticObject是GC Roots }
在方法区中常量引用的对象:
Java public class MyClass { private static final String CONSTANT_STRING = "constant"; // CONSTANT_STRING是GC Roots }
在本地方法栈中JNI(即通常所说的Native方法)引用的对象:
Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
所有被同步锁(synchronized关键字)持有的对象:
Javapublic synchronized void synchronizedMethod() { // 当前对象(this)在执行同步方法时是GC Roots }
所有被同步锁(synchronized关键字)持有的对象:
2.3 引用类型:
无论是通过引用计算算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。
Java中有四种类型的引用,它们对垃圾回收的影响不同:
- 强引用 (Strong Reference): 最常见的引用类型,只要对象有强引用指向,它就不会被垃圾回收。
- 软引用 (Soft Reference): 软引用可以帮助垃圾回收器回收内存,只有在内存不足时,软引用指向的对象才会被回收。
- 弱引用 (Weak Reference): 弱引用指向的对象在下一次垃圾回收时会被回收,不管内存是否足够。
- 虚引用 (Phantom Reference): 虚引用的主要用途是跟踪对象被垃圾回收的状态,虚引用指向的对象总是可以被垃圾回收。
javaCopy code import java.lang.ref.*; public class ReferenceTypes { public static void main(String[] args) { Object strongRef = new Object(); // 强引用 SoftReference<Object> softRef = new SoftReference<>(new Object()); // 软引用 WeakReference<Object> weakRef = new WeakReference<>(new Object()); // 弱引用 PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), new ReferenceQueue<>()); // 虚引用 System.gc(); // 触发垃圾回收 System.out.println("Strong Reference: " + strongRef); System.out.println("Soft Reference: " + softRef.get()); System.out.println("Weak Reference: " + weakRef.get()); System.out.println("Phantom Reference: " + phantomRef.get()); } }
这些基础知识为理解垃圾回收提供了必要的背景。接下来的部分将探讨不同的垃圾回收算法和Java中的垃圾回收器。
3. 垃圾回收算法
垃圾回收算法是垃圾回收器的核心,它决定了如何有效地回收不再使用的对象。以下是一些常见的垃圾回收算法:
3.1 标记-清除 (Mark-Sweep)
标记清除算法分为两个主要步骤:标记和清除。
- 标记阶段: 在标记阶段,垃圾回收器会从GC Roots开始,遍历所有可达的对象,并标记它们为活动对象。
- 清除阶段: 在清除阶段,垃圾回收器会遍历整个堆,回收所有未被标记的对象的内存。
该算法有两个问题:
- 效率问题:标记和清除过程的效率都不高;
- 空间问题:标记清除后会产生大量不连续的内存碎片, 空间碎片太多可能会导致在运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集。
3.2 复制 (Copying)
复制算法将堆内存分为两个相等的区域,只使用其中一个区域。当这个区域的内存用完时,垃圾回收器会将所有活动对象复制到另一个区域,并回收原区域的所有内存。
- 优点: 减少内存碎片,提高空间利用率。
- 缺点: 减半了可用的堆内存,可能增加垃圾回收的频率。
3.3 标记-整理 (Mark-Compact)
标记整理算法是标记清除算法的改进版本。它在标记和清除的基础上增加了整理阶段,将所有活动对象向一端移动,从而消除内存碎片。
- 优点: 解决了内存碎片化问题,提高了空间利用率。
- 缺点: 移动对象增加了额外的开销。
3.4 分代收集 (Generational Collection)
分代收集理论
当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)[1]的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
- 1) 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
- 2) 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
- 3) 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极 少数。
基于上述假说:
- 新生代: 使用复制算法,因为新生代中的对象生命周期较短。
- 老年代: 使用标记整理或标记清除算法,因为老年代中的对象生命周期较长,且数量较少。
新生代(Young Generation)的回收算法(以复制算法为主)
- 所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。
- 新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。一个Eden区,两个 Survivor区(一般而言)。大部分对象在Eden区中生成。回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空, 如此往复。
- 当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC(Major GC),也就是新生代、老年代都进行回收。
- 新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。
老年代(Tenured Generation)的回收算法(以标记-清除、标记-整理为主)
- 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到老年代中。因此,可以认为老年代中存放的都是一些生命周期较长的对象。
- 内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC,Major GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。
永久代(Permanet Generation)的回收算法
用于存放静态文件,如Java类、方法等。永久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的永久代空间来存放这些运行过程中新增的类。永久代也称方法区。方法区主要回收的内容有:废弃常量和无用的类。对于废弃常量也可通过根搜索算法来判断,但是对于无用的类则需要同时满足下面3个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
- 加载该类的ClassLoader已经被回收;
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
4. Java垃圾回收器
4.1 概述
Java虚拟机提供了多种垃圾回收器,每种回收器有其特定的用途和优势。以下是常见的垃圾回收器及其特点:
以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。
- 单线程与多线程: 单线程指的是垃圾收集器只使用一个线程进行收集,而多线程使用多个线程;
- 串行与并行: 串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并形指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。
4.2 内存分配与回收策略
JVM 在进行 GC 时,并非每次都对堆内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。
针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC)
- 部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
- 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
- 老年代收集(Major GC/Old GC):只是老年代的垃圾收集
- 目前,只有 CMS GC 会有单独收集老年代的行为
- 很多时候 Major GC 会和 Full GC 混合使用,需要具体分辨是老年代回收还是整堆回收
- 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
- 目前只有 G1 GC 会有这种行为
- 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾
4.3 常用垃圾回收器
- Serial 收集器
Serial 翻译为串行,也就是说它以串行的方式执行。
它是单线程的收集器,只会使用一个线程进行垃圾收集工作。
它的优点是简单高效,对于单个 CPU 环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率。
它是 Client 模式下的默认新生代收集器,因为在用户的桌面应用场景下,分配给虚拟机管理的内存一般来说不会很大。Serial 收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。
2. ParNew 收集器
它是 Serial 收集器的多线程版本。
是 Server 模式下的虚拟机首选新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。
默认开启的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。
3. Parallel Scavenge 收集器
与 ParNew 一样是多线程收集器。 其它收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户代码的时间占总时间的比值。 缩短停顿时间是以牺牲吞吐量和新生代空间来换取的: 新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。 可以通过一个开关参数打开 GC 自适应的调节策略(GC Ergonomics),就不需要手动指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
4. Serial Old 收集器
是 Serial 收集器的老年代版本,也是给 Client 模式下的虚拟机使用。如果用在 Server 模式下,它有两大用途:
- 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
- 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
5. Parallel Old 收集器
是 Parallel Scavenge 收集器的老年代版本。
在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。
6. CMS 收集器
CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。
分为以下四个流程:
- 初始标记: 仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
- 并发标记: 进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
- 重新标记: 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
- 并发清除: 不需要停顿。
在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。
具有以下缺点:
- 吞吐量低: 低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
- 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
- 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
7. G1 收集器
G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。
堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。
G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。
通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:
- ·初始标记(Initial M arking):
- 仅仅只是标记一下GC Roots能直接关联到的对象。
- ·并发标记(Concurrent Marking):
- 从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆 里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。
- ·最终标记(Final M arking):
- 对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留 下来的最后那少量的SATB记录。
- ·筛选回收(Live Data Counting and Evacuation):
- 负责更新Region的统计数据,对各个Region的回 收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行 完成的。
具备如下特点:
- 空间整合: 整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
- 可预测的停顿: 能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。
5. GC 对比
从G1收集器的设计,我们可以看到现代垃圾收集器的发展趋势是追求处理应用的内存分配速度(Allocation Rate),而不是一次性清理整个Java堆。这种设计允许应用程序在分配内存的同时,收集器也在进行垃圾收集。只要收集速度能跟上对象分配速度,系统就能高效运行。这种新设计思路自G1收集器开始流行,标志着垃圾收集器技术的一个里程碑。
相较于CMS,G1有许多优点。除了可以指定最大停顿时间、分Region的内存布局和按收益动态确定回收集等创新设计外,从传统算法理论看,G1具有更多发展潜力。G1是基于“标记-整理”算法实现的收集器,局部(两个Region之间)是基于“标记-复制”算法实现,这两种算法保证G1运作期间不会产生内存空间碎片,能提供规整的可用内存,利于程序长时间运行。
然而,G1并非全方位压倒CMS。
- 比如,G1的内存占用(Footprint)和程序运行时的额外执行负载(Overload)都较CMS高。
- G1和CMS都使用卡表处理跨代指针,但G1的卡表实现更复杂,每个Region都必须有一份卡表,可能占用堆容量的20%或更多。
执行负载方面,由于两收集器的实现细节不同,用户程序的运行负载也有不同。它们都使用写屏障,CMS用写后屏障更新维护卡表;G1除此之外,为实现原始快照搜索(SATB)算法,还需使用写前屏障跟踪并发时的指针变化。虽然原始快照搜索减少了并发标记和重新标记阶段的消耗,避免了CMS在最终标记阶段停顿时间过长的问题,但确实增加了用户程序的额外负担。
吞吐量比较
就吞吐量而言,JDK 8和JDK 11之间没有太大的差异,在并行方面,JDK 17比JDK 8高约15%。在G1方面,JDK 17比JDK 8高18%。ZGC在JDK 11中引入,与JDK 11相比,JDK 17提高了20%以上。
暂停时间比较
我们可以看到,JDK 17中的ZGC远低于目标:亚毫秒暂停时间。G1的目标是在延迟和吞吐量之间保持平衡,远低于其默认目标:200毫秒的暂停时间。ZGC的设计是确保暂停时间不随堆大小的变化而改变,我们可以看到当堆扩展到128GB时会发生什么。从暂停时间的角度来看,G1在处理较大堆方面比Parallel更好,因为它可以保证暂停时间达到特定目标。
资源使用情况
上图比较了三种不同收集器的峰值本机内存使用情况。由于Parallel和ZGC在这个角度上都比较稳定,我们应该看一下具体的数字。我们可以看到,G1在这个领域有所改进,主要是因为所有的功能和增强都提高了内存集管理的效率。
Java 8 :
总体来说,哪款收集器更好,往往需要针对具体场景进行定量比较。根据实践经验,小内存应用上CMS可能表现更好,而大内存应用上G1能发挥优势。这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,不过也需根据实际情况进行测试,以得出最合适的结论。
6. Full GC 的触发条件
对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:
- 调用 System.gc()
只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。- 老年代空间不足
老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。
为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。- 空间分配担保失败
使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。具体内容请参考上面的第五小节。- JDK 1.7 及以前的永久代空间不足(1.7之后元空间不足)
在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。
当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。
为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。- Concurrent Mode Failure
执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。