ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

JVM垃圾回收器深度解析:从算法原理到实战调优

JVM垃圾回收器深度解析:从算法原理到实战调优

1. 项目概述:为什么我们需要深入理解垃圾回收器?

如果你是一名Java开发者,或者正在使用任何基于JVM的语言(比如Kotlin、Scala),那么“垃圾回收器”这个词对你来说一定不陌生。它就像是你程序背后那个默默无闻的清洁工,负责回收那些不再被使用的内存对象,防止内存泄漏,确保应用能够稳定、持续地运行。但很多时候,我们对它的认知可能仅仅停留在“它会自动回收内存”这个层面,至于它具体怎么工作、有哪些种类、各自有什么优缺点、又该如何为我们的应用选择合适的“清洁工”,很多人可能就一头雾水了。

尤其是在面对生产环境的性能调优时,比如应用出现频繁的Full GC导致服务卡顿,或者堆内存设置不合理导致资源浪费,如果你对GC的工作原理和不同回收器的特性没有深入的了解,排查问题就会像盲人摸象,非常被动。这篇文章的目的,就是带你彻底搞懂JVM的垃圾回收器。我不会只停留在概念介绍,而是会结合我这些年处理线上问题的实际经验,从底层原理、算法实现,到不同回收器的适用场景、核心参数调优,再到实战中遇到的典型问题和排查技巧,为你构建一个完整、立体的知识体系。无论你是刚入门的新手,还是有一定经验想深入优化的开发者,相信都能从中获得可以直接用于实践的“干货”。

2. 垃圾回收的核心思想与算法基础

在深入各个回收器之前,我们必须先打好地基,理解垃圾回收赖以生存的几个核心思想和基础算法。这是理解后续所有复杂回收器设计的前提。

2.1 如何判定对象“已死”?

垃圾回收的首要任务是识别哪些内存对象是“垃圾”,即已经不再被使用的对象。JVM主要依赖两种算法来进行判定:

引用计数算法:这是一种非常直观的想法,给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器就减1。任何时刻计数器为0的对象就是不可能再被使用的。这个方法实现简单,判定效率高,但它有一个致命的缺陷:无法解决对象之间循环引用的问题。比如对象A和对象B互相引用,除此之外再无其他引用,那么它们的引用计数都不为0,但实际上它们已经无法被外界访问,应该被回收。因此,主流的Java虚拟机都没有选用引用计数算法来管理内存。

可达性分析算法:这是当前主流JVM(包括HotSpot)所采用的算法。它的基本思路是通过一系列称为“GC Roots”的根对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为“引用链”。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,可以被回收。

那么,哪些对象可以作为GC Roots呢?主要包括以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(即Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,常驻的异常对象(NullPointerException、OutOfMemoryError)等。
  • 所有被同步锁(synchronized关键字)持有的对象。

可达性分析算法有效地解决了循环引用的问题,因为循环引用的两个对象如果都无法到达GC Roots,那么它们就会被判定为可回收对象。

注意:即使在可达性分析中被判定为不可达的对象,也并非是“非死不可”的。要真正宣告一个对象死亡,至少要经历两次标记过程。第一次标记后,如果对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将直接进行回收。否则,这个对象会被放置在一个名为F-Queue的队列中,由一个低优先级的Finalizer线程去执行它的finalize()方法。这里对象可以获得“最后一次自救机会”,只要在finalize()中重新与引用链上的任何一个对象建立关联即可。但强烈不建议依赖这个方法来做资源释放,因为它的执行时机不确定,且性能开销大。

2.2 经典垃圾收集算法

知道了哪些是垃圾,接下来就要研究如何清理。主要有三种基础的收集算法,后续所有复杂的垃圾回收器都是这些算法的组合或优化。

标记-清除算法:这是最基础的收集算法,分为“标记”和“清除”两个阶段。首先标记出所有需要回收的对象,在标记完成后,统一回收所有被标记的对象。

  • 优点:实现简单,不需要移动对象。
  • 缺点:1.效率问题:标记和清除两个过程的效率都不高。2.空间问题:标记清除后会产生大量不连续的内存碎片。空间碎片太多可能导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集。

复制算法:为了解决效率问题,复制算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

  • 优点:每次都是对整个半区进行内存回收,实现简单,运行高效,而且不会产生内存碎片。
  • 缺点将可用内存缩小为了原来的一半,空间浪费太大。现代的商业虚拟机都采用这种收集算法来回收新生代。因为新生代中的对象有98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间(通常比例是8:1:1)。每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中存活的对象一次性复制到另一块Survivor上,最后清理掉Eden和用过的Survivor。这样只有10%的内存会被“浪费”。

标记-整理算法:复制算法在对象存活率较高时(比如老年代)就要进行较多的复制操作,效率会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况。所以老年代一般不能直接选用复制算法。 标记-整理算法的标记过程与“标记-清除”一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。

  • 优点:避免了内存碎片,也避免了复制算法空间减半的代价。
  • 缺点:移动存活对象并更新所有引用这些对象的地方(“指针调整”)是一种负重操作,而且这种操作必须暂停用户线程(Stop The World, STW)才能进行,对停顿时间敏感的应用不友好。

分代收集理论:当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”的理论。它建立在两个分代假说之上:1.弱分代假说:绝大多数对象都是朝生夕灭的。2.强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。基于这两个假说,收集器将Java堆划分出不同的区域(新生代、老年代),然后根据各个区域的特点采用不同的收集算法。新生代每次回收都有大量对象死去,存活少量,适合复制算法。老年代对象存活率高,没有额外空间担保,适合标记-清除或标记-整理算法。

3. 七种经典垃圾回收器深度解析

理解了基础算法,我们就可以来看具体的“清洁工”——垃圾回收器了。HotSpot JVM提供了多种选择,它们之间的关系并非替代,而是各有侧重,适用于不同的场景。下图展示了它们之间的配合关系(注:此处用文字描述替代图表):

  • 新生代收集器:Serial, ParNew, Parallel Scavenge
  • 老年代收集器:Serial Old, Parallel Old, CMS
  • 整堆收集器:G1, ZGC, Shenandoah (后两者为新一代低延迟收集器)

其中,有连线连接的表示它们可以搭配使用。例如,Serial可以搭配Serial Old,ParNew可以搭配CMS。

3.1 新生代收集器:关注吞吐量还是响应时间?

Serial收集器:这是一个单线程的收集器。它的“单线程”意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程(Stop The World),直到它收集结束。

  • 工作过程:采用复制算法。
  • 优点:简单而高效。对于限定单个CPU的环境来说,由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。它是Client模式下虚拟机默认的新生代收集器。
  • 缺点:STW时间可能较长。
  • 适用场景:桌面应用或客户端小程序,内存不大,停顿时间可以接受。在服务器环境,它主要用于虚拟机在后台管理、监控或日志输出的场景。

ParNew收集器:实质上是Serial收集器的多线程并行版本。除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样。

  • 工作过程:采用复制算法,多线程并行收集。
  • 优点:在有多核CPU的环境下,可以缩短垃圾收集的停顿时间。
  • 缺点:在单核CPU环境下,性能可能不如Serial,因为存在线程交互的开销。
  • 适用场景:Server模式下的虚拟机首选的新生代收集器,一个重要原因是,除了Serial,目前只有它能与CMS收集器配合工作。

Parallel Scavenge收集器:也是一个新生代收集器,使用复制算法,也是并行的多线程收集器。它的特点是它的关注点与其他收集器不同。CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

  • 优点:可以设置最大垃圾收集停顿时间(-XX:MaxGCPauseMillis)和直接设置吞吐量大小(-XX:GCTimeRatio)。还有一个-XX:+UseAdaptiveSizePolicy参数,这是一个开关参数,打开之后就不需要手动指定新生代大小、Eden与Survivor区的比例等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为GC自适应的调节策略
  • 缺点:与CMS不兼容。
  • 适用场景:适合在后台运算而不需要太多交互的任务,例如批量处理、订单处理、工资支付、科学计算等,主要目标是高效率地利用CPU时间,尽快完成程序的运算任务。

3.2 老年代收集器:应对高存活率对象

Serial Old收集器:是Serial收集器的老年代版本,同样是一个单线程收集器,使用“标记-整理”算法。

  • 用途:主要意义也是供Client模式下的虚拟机使用。在Server模式下,它主要有两大用途:一种用途是在JDK 1.5以及之前的版本中与Parallel Scavenge收集器搭配使用,另一种用途就是作为CMS收集器发生失败时的后备预案。

Parallel Old收集器:是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供的。

  • 优点:在注重吞吐量以及CPU资源敏感的场合,可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。在此之前,Parallel Scavenge只能搭配Serial Old,而Serial Old在服务端性能上的“拖累”使得Parallel Scavenge的吞吐量优势无法充分发挥。
  • 适用场景:与Parallel Scavenge搭配,组成“吞吐量优先”的黄金组合。

CMS收集器:这是一种以获取最短回收停顿时间为目标的收集器。它非常符合互联网站或者B/S系统的服务端应用,这类应用尤其重视服务的响应速度,希望系统停顿时间最短。

  • 工作过程:基于“标记-清除”算法,整个过程分为4个步骤:
    1. 初始标记:仅仅标记一下GC Roots能直接关联到的对象,速度很快,需要STW。
    2. 并发标记:进行GC Roots Tracing的过程,从初始标记的对象开始遍历整个对象图,这个过程耗时较长,但可以与用户线程并发执行。
    3. 重新标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。这个阶段的停顿时间通常会比初始标记阶段稍长,但远比并发标记时间短,需要STW。
    4. 并发清除:清理删除掉标记阶段判断的已经死亡的对象。由于不需要移动存活对象,这个阶段也可以与用户线程并发执行。
  • 优点:并发收集,低停顿。
  • 缺点
    1. 对CPU资源非常敏感:在并发阶段,它虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS默认启动的回收线程数是(CPU核心数+3)/4。
    2. 无法处理“浮动垃圾”:在并发清除阶段,用户线程还在运行,自然就会有新的垃圾产生。这部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留到下一次GC时再清理。这部分垃圾就称为“浮动垃圾”。因此,CMS不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。可以通过-XX:CMSInitiatingOccupancyFraction参数来设置触发百分比。如果预留空间无法满足程序需要,就会出现一次“并发失败”,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
    3. 会产生空间碎片:由于基于“标记-清除”算法,收集结束时会产生大量不连续的空间碎片。空间碎片过多时,会给大对象分配带来麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认开启),用于在Full GC时开启内存碎片的合并整理过程,但这个整理过程是无法并发的,空间碎片问题没有了,但停顿时间又会变长。

3.3 面向全堆的革新者:G1收集器

G1收集器是垃圾收集器技术发展历史上的一个里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。在JDK 9中,G1被设置为默认的垃圾收集器。

核心设计思想:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理。Region中还有一类特殊的Humongous区域,专门用来存储大对象(大小超过Region容量一半的对象)。

G1的运作过程:可以划分为四个阶段,虽然也保留了新生代和老年代的概念,但二者不再是物理隔离,而是一系列Region(不需要连续)的集合。

  1. 初始标记:仅仅标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短。
  2. 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象。这阶段耗时较长,但可与用户程序并发执行。
  3. 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的少量SATB(原始快照)记录。
  4. 筛选回收:首先对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划。然后,将决定回收的Region中的存活对象复制到空的Region中,再清理掉整个旧的Region。这里涉及到存活对象的移动,必须暂停用户线程,由多条收集线程并行完成。

G1的优势

  • 可预测的停顿时间模型:这是G1相对于CMS的一个巨大优势。G1跟踪各个Region里面的垃圾堆积的“价值”大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
  • 空间整合:从整体上看,G1是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上看又是基于“复制”算法实现的。这意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。
  • 更精细的调优:用户可以通过参数-XX:MaxGCPauseMillis指定一个期望的停顿时间目标(默认200ms)。G1会尽力达成这个目标,但并非绝对保证。

G1的适用场景与调优:G1的设计目标是替代CMS,适用于服务端、大内存、多核处理器的应用。它尤其适合那些需要低延迟且堆内存较大的场景。调优G1时,核心是设定合理的停顿时间目标(-XX:MaxGCPauseMillis),并给予足够的堆内存。通常不需要像调优Parallel或CMS那样手动设置新生代、老年代的大小比例。

4. 新一代低延迟收集器:ZGC与Shenandoah

随着硬件发展和大内存应用(如数十GB甚至上百GB堆内存)的普及,用户对垃圾收集的停顿时间(延迟)要求越来越苛刻。G1虽然比CMS有了很大进步,但在超大堆下,其停顿时间(特别是Full GC)仍然可能达到秒级。为此,Oracle的ZGC和Red Hat的Shenandoah应运而生,它们的目标都是在超大堆内存(TB级别)下,将停顿时间控制在10毫秒以内。

4.1 ZGC:基于染色指针的并发奇迹

ZGC(Z Garbage Collector)是Oracle在JDK 11中引入的实验性功能,并在JDK 15中正式发布。它的设计目标是实现低延迟(亚毫秒到几毫秒)的同时,处理从几百MB到数TB的堆大小。

核心技术:染色指针:这是ZGC最核心的黑科技。它将少量额外的信息(称为“元数据”)存储在对象指针本身中,而不是像传统收集器那样存储在对象头或独立的数据结构中。在64位系统中,ZGC利用了虚拟地址空间的巨大空间,将指针的高位(目前是42-45位)用来存储标记信息(如标记、重映射、已移动等状态)。

  • 优点
    1. 大幅减少内存屏障:由于状态信息在指针上,访问对象时可以直接判断,减少了对内存屏障的依赖,提升了并发效率。
    2. 高效的并发转移:对象转移(即从旧地址复制到新地址)后,只需要修改指向它的指针中的地址部分,而所有持有该对象旧指针的线程,在后续访问时,可以通过指针中的“已移动”标记,触发一个“自愈”操作,自动将指针修正为新地址。这个过程是并发的,且非常快速。
    3. 区域划分灵活:ZGC将堆划分为许多小页面(类似于G1的Region,但大小不固定),并且没有分代概念(在JDK 21中引入了分代式ZGC,即ZGenerational)。

工作阶段:ZGC的收集周期也分为标记、转移(相当于清理/整理)等阶段,但所有这些阶段几乎都是并发执行的。其停顿时间几乎只与GC Roots的数量有关,而与堆中存活对象的总大小无关。因此,即使堆非常大,停顿时间也能保持在一个极低的水平。

使用与调优:启用ZGC非常简单,使用JVM参数-XX:+UseZGC即可。对于超大堆,通常还需要设置-Xmx,-Xms。ZGC的调优参数相对较少,核心是设置最大停顿时间目标(-XX:MaxGCPauseMillis,默认无限制)和并发GC线程数(-XX:ConcGCThreads)。对于大多数应用,使用默认参数就能获得很好的效果。

4.2 Shenandoah:低延迟的另一个选择

Shenandoah是由Red Hat领导开发的一款低停顿时间垃圾收集器,最初在OpenJDK 12中作为实验性功能引入。它的目标与ZGC类似:在超大堆上实现低延迟。

核心技术:Brooks指针:Shenandoah实现并发转移的核心技术是“Brooks指针”。它在每个对象前面增加了一个额外的引用字段(forwarding pointer)。当对象被移动时,会在原对象位置留下一个“转发指针”,指向对象的新地址。

  • 工作过程:当并发转移发生时,收集器将对象复制到新位置,并在旧位置设置转发指针。应用程序线程在访问对象时,会先检查这个转发指针。如果存在,则通过它“转发”到新地址进行访问。这个检查-转发操作通过读屏障实现。
  • 与ZGC的区别:虽然目标一致,但实现路径不同。ZGC使用染色指针,将元数据嵌入指针;Shenandoah使用Brooks指针,将元数据存储在对象旁边。Shenandoah的读屏障开销在早期版本中相对较高,但经过持续优化,其性能已经非常出色。

适用场景对比

  • ZGC:由Oracle官方大力支持,是未来JDK发展的重点。其染色指针设计非常精妙,在支持它的平台上(主要是Linux/AArch64和x86-64)性能表现卓越。如果追求最前沿的技术和官方长期支持,ZGC是首选。
  • Shenandoah:由Red Hat社区驱动,其优势在于更广泛的平台支持(包括一些ZGC尚未优化的平台)和更早的可用性。在一些特定工作负载下,其性能可能与ZGC互有胜负。

实操心得:如何选择新一代收集器?

  1. JDK版本:首先确认你的JDK版本。ZGC在JDK 15后正式生产可用,Shenandoah在JDK 12后作为实验性功能,在后续版本中逐步稳定。建议使用最新的LTS版本(如JDK 17, 21)以获得最佳支持和性能。
  2. 堆大小与延迟要求:如果你的应用堆内存小于8GB,对延迟不敏感(停顿几百毫秒可接受),那么G1甚至Parallel Scavenge/Old可能就足够了,它们更成熟稳定。如果堆内存大于16GB,且要求停顿时间在100毫秒甚至10毫秒以下,那么ZGC或Shenandoah是必然选择。
  3. 平台与供应商:检查你的运行环境。ZGC目前对Linux和某些BSD支持最好。Shenandoah的平台支持略广一些。如果你使用的是某个特定的JDK发行版(如Red Hat的OpenJDK),它可能对Shenandoah有更好的集成和优化。
  4. 测试!测试!测试!:这是最重要的原则。在生产环境全面切换前,务必使用与生产环境硬件、数据量、流量模式相似的测试环境进行充分的压力测试和基准测试。观察关键指标:应用吞吐量、P99/P999延迟、GC停顿时间、CPU使用率等。没有一种收集器是万能的,最适合的才是最好的。

5. 实战:GC日志分析与性能调优指南

理论懂了,收集器也认识了,最终还是要落到实战上。如何监控GC状态?如何从GC日志中发现问题?又该如何调优?这是每个Java开发者必须掌握的技能。

5.1 读懂GC日志:你的应用健康晴雨表

开启GC日志是分析问题的第一步。常用的JVM参数如下:

-XX:+PrintGCDetails // 打印详细的GC日志 -XX:+PrintGCDateStamps // 打印GC发生的时间戳 -XX:+PrintGCTimeStamps // 打印GC事件相对于JVM启动的时间戳 -Xloggc:/path/to/gc.log // 将GC日志输出到文件 // JDK 9+ 统一日志框架推荐用法 -Xlog:gc*,gc+age=trace,safepoint:file=gc.log:time,uptime,level,tags:filecount=10,filesize=10m

我们来看一段典型的Parallel Scavenge/Old收集器的GC日志:

2024-05-27T10:00:00.123+0800: 1.234: [GC (Allocation Failure) [PSYoungGen: 65536K->8192K(76288K)] 65536K->24576K(251392K), 0.0123456 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] 2024-05-27T10:00:10.456+0800: 11.567: [Full GC (Ergonomics) [PSYoungGen: 8192K->0K(76288K)] [ParOldGen: 163840K->150000K(175104K)] 172032K->150000K(251392K), [Metaspace: 3456K->3456K(1056768K)], 0.456789 secs] [Times: user=1.23 sys=0.01, real=0.46 secs]

日志解析

  • 2024-05-27T10:00:00.123+0800:GC发生的绝对时间。
  • 1.234:GC事件距离JVM启动的时间(秒)。
  • [GC[Full GC:GC的类型,Full GC意味着整个堆(包括新生代、老年代、元空间等)都被回收,通常停顿时间很长。
  • (Allocation Failure):触发GC的原因。这里是“分配失败”,即新生代Eden区空间不足,无法为新对象分配内存。
  • [PSYoungGen: 65536K->8192K(76288K)]:新生代(Parallel Scavenge)的回收情况。回收前使用了65536K,回收后剩余8192K,该区域总容量为76288K。
  • 65536K->24576K(251392K):整个堆的回收情况。回收前堆使用量65536K,回收后24576K,当前堆总容量251392K。
  • 0.0123456 secs:本次GC所花费的时间(秒)。
  • [Times: user=0.03 sys=0.00, real=0.01 secs]:CPU时间与墙钟时间。user是垃圾收集器线程消耗的所有CPU时间,sys是系统调用消耗的时间,real是操作从开始到结束经历的墙钟时间。如果是多核并行收集,user时间可能远大于real时间。

需要警惕的GC日志信号

  1. 频繁的Full GC:这是最危险的信号。如果日志中频繁出现[Full GC,且每次耗时都很长(>1秒),说明应用可能存在内存泄漏、堆大小设置不合理、或者对象晋升过快等问题。
  2. GC后内存回收效果差:看箭头前后的数字。例如老年代在Full GC后175104K->170000K(175104K),意味着回收前用了170G,回收后还有169.9G,几乎没回收掉什么对象。这强烈暗示存在内存泄漏,大量对象被长期持有。
  3. 晋升失败(Promotion Failure):在Parallel Scavenge日志中可能出现。当新生代存活对象需要晋升到老年代,但老年代空间不足时发生,会触发一次Full GC。这通常说明老年代空间设置太小,或者存在“朝生夕死”的大对象直接进入了老年代。
  4. 并发模式失败(Concurrent Mode Failure):在CMS收集器日志中常见。这意味着CMS在并发清理过程中,老年代空间被快速填满,不得不退化为Serial Old进行Full GC,导致长时间停顿。需要调整-XX:CMSInitiatingOccupancyFraction参数,让CMS更早启动。

5.2 性能调优实战:从问题现象到解决方案

调优没有银弹,必须结合监控指标和日志分析。下面是一个典型的调优思路流程:

场景一:应用响应时间偶尔出现尖刺(毛刺)

  • 现象:应用P99延迟正常,但P999延迟(或最大响应时间)偶尔会飙升至数秒。通过系统监控发现,响应时间尖刺与CPU使用率高峰吻合。
  • 分析:查看对应时间点的GC日志。很可能发现了一次长时间的Full GC停顿。例如,日志显示一次Full GC耗时2.5秒。
  • 排查
    1. 检查堆内存大小:使用jstat -gcutil <pid> 1000命令实时观察各区域使用率。如果老年代使用率经常接近100%,说明堆可能太小。适当增加-Xmx-Xms(建议设置成相等,避免堆动态调整带来的开销)。
    2. 检查对象晋升:观察jstat输出中YGC(Young GC次数)和FGC(Full GC次数)的比值。如果YGC很频繁但FGC也不少,说明很多对象“过早晋升”到了老年代。可以尝试调整新生代大小(-Xmn),或者调整晋升年龄阈值(-XX:MaxTenuringThreshold,默认15)。
    3. 检查大对象:如果应用有大量大对象(如大数组、大字符串),它们会直接进入老年代(或G1的Humongous区)。检查代码逻辑,看是否能优化大对象的使用,比如使用流式处理代替全量加载。
    4. 切换低延迟收集器:如果堆内存较大(>16GB)且Full GC无法避免,考虑从Parallel或CMS切换到G1,甚至ZGC/Shenandoah。对于CMS,可以尝试优化-XX:CMSInitiatingOccupancyFraction(如从68%调到60%),并确保有足够的CPU资源。

场景二:应用吞吐量不达标

  • 现象:CPU使用率很高,但系统处理事务的TPS(每秒事务数)低于预期。
  • 分析:GC本身也是CPU密集型操作。如果GC线程占用了过多CPU时间,留给业务线程的时间就少了。使用jstat -gcutil <pid> 1000观察GCT(GC总时间)占YGC/FGC周期的比例。也可以使用top -Hp <pid>查看GC线程的CPU消耗。
  • 排查
    1. 减少GC频率:如果YGC非常频繁(每秒几次),说明新生代太小,对象很快占满Eden区。适当增大新生代(-Xmn),但注意不要太大,否则单次YGC时间会变长。目标是找到一个平衡点。
    2. 调整GC线程数:对于Parallel收集器,可以通过-XX:ParallelGCThreads设置并行GC线程数。默认值通常与CPU核心数相关。如果GC线程数过多,会加剧CPU竞争;过少则拉长GC时间。需要根据实际CPU核心数和应用负载调整。
    3. 关注对象分配速率:使用jstat -gc <pid> 1000观察EU(Eden区使用量)的增长速度。如果分配速率极高,可能是代码中存在大量不必要的临时对象创建(如在循环中创建对象、大量使用String拼接等)。这时需要优化代码,减少对象分配。

场景三:使用G1时,停顿时间超过预期

  • 现象:为G1设置了-XX:MaxGCPauseMillis=200,但监控显示偶尔停顿超过500ms。
  • 分析:G1的停顿时间目标只是一个软目标。超过目标可能原因有:1. 混合收集(Mixed GC)需要处理的Region太多;2. 大对象(Humongous对象)分配或回收的影响;3. 并发标记周期耗时过长。
  • 排查
    1. 分析GC日志:启用更详细的G1日志-XX:+PrintAdaptiveSizePolicy-Xlog:gc+ergo*=trace。查看是哪个阶段(如Evacuation Pause, Concurrent Mark)耗时过长。
    2. 调整Region大小:G1的Region大小由堆大小自动决定(1M-32M)。如果存在大量中等大小的对象,可以尝试通过-XX:G1HeapRegionSize手动设置Region大小,使其更匹配对象大小分布。
    3. 关注Humongous对象:使用jcmd <pid> GC.humongous_info查看大对象信息。如果大对象过多且生命周期短,会对G1性能造成压力。考虑优化代码,避免分配短命的大对象。
    4. 调整并发线程数:通过-XX:ConcGCThreads控制并发标记阶段的线程数。增加此值可以加快并发标记,但会占用更多应用线程的CPU。

避坑技巧:一份GC参数设置清单

  • 基本参数
    • -Xms-Xmx务必设置成相同值,避免堆动态扩容收缩带来的性能损耗。
    • -XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath:内存溢出时自动转储堆快照,是事后分析的救命稻草。
  • 日志参数(JDK 8及之前)
    • -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log
  • 日志参数(JDK 9+)
    • -Xlog:gc*,gc+age=trace,safepoint:file=gc.log:time,uptime,level,tags:filecount=10,filesize=10m
  • Parallel Scavenge/Old调优
    • -XX:MaxGCPauseMillis=100:设置期望的最大GC停顿时间(毫秒)。收集器会尽力达成,但不保证。
    • -XX:GCTimeRatio=99:设置吞吐量目标。公式:1 / (1 + GCTimeRatio)。这里是99,表示GC时间不超过总时间的1%。
    • -XX:+UseAdaptiveSizePolicy:启用自适应策略(默认开启),让JVM自动调整各区大小。
  • G1调优
    • -XX:MaxGCPauseMillis=200:核心目标参数。
    • -XX:InitiatingHeapOccupancyPercent=45:触发并发标记周期的堆占用率阈值。如果老年代增长快,可以调低(如35)。
    • -XX:G1ReservePercent=10:堆内存的保留比例,用于晋升失败时的回退空间。如果频繁发生晋升失败,可以适当增加。
  • 通用建议
    • 不要过度调优:JVM的默认参数和自适应机制对于大多数应用已经足够好。在遇到明确的性能问题之前,不要盲目添加大量GC参数。
    • 一次只改变一个参数:调优时,记录基准性能,然后一次只调整一个参数并观察效果,这样才能准确定位每个参数的影响。
    • 监控与日志是关键:没有监控和日志,调优就是盲人摸象。务必建立完善的APM(应用性能监控)和日志收集体系。
返回列表