从一次GC导致的性能问题排查谈起:浅谈JVM内存模型与垃圾回收机制

从一次性能优化问题的排查说起

背景

最近在实习,我负责的是使得我们的产品兼容 Google Cloud 提供的对象存储服务。然而性能测试环节,程序出现延迟远高于预期的现象。今天和大家分享一下我们是怎么一步步排查定位到问题的。顺便聊聊关于 GC 的一些理论知识。

CPU 占用异常

为了方便定位问题,我拉起监控并配合 tsar ,观察到服务器的 CPU 占用率异常,比正常高了 20%。既然是 CPU 占用异常,那跑出 CPU 火焰图必不可少,async-profiler,启动!

在跑出火焰图之后,可以看到 GC 部分占的比重为 26%,这明显就是 CPU 占用过高罪魁祸首。这时候我的导师甩了一个命令给我:

grep 'Allocation Stall' gc.log | grep -v 'Critical:'

找到 GC 日志的 目录并运行上述命令,发现输出以下内容

 

scss

代码解读

复制代码

... [2024-08-06T03:48:12.858+0000][gc ] Allocation Stall (Thread-1) 387.394ms [2024-08-06T03:48:12.862+0000][gc ] Allocation Stall (Thread-2) 380.495ms [2024-08-06T03:48:12.865+0000][gc ] Allocation Stall (Thread-3) 56.993ms [2024-08-06T03:48:12.865+0000][gc ] Allocation Stall (Thread-4) 22.319ms [2024-08-06T03:48:12.901+0000][gc,start ] GC(529) Garbage Collection (Allocation Stall) [2024-08-06T03:48:14.387+0000][gc ] GC(529) Garbage Collection (Allocation Stall) 4996M(81%)->3064M(50%)

导师告诉我,在机器内存足够并且堆内存设置合理的情况下,出现大量的 'Allocation Stall' ,一般是由于程序频繁的申请/释放对象,或者内存泄露等导致的。于是他又甩给我一个大杀器 'heap dump'。为了方便定位问题,我为我写的部分代码单独写了一个测试函数,并在测试过程中使用 jcmd 生成了 JVM 内存的 dump 文件。在 IDEA 中打开 dump 文件,如图

果然,有大量的 16MB 的 byte[] 被频繁地申请,释放。最终导致了堆内存可用空间减少,CG 频率升高,CPU 占用率上升,程序延迟提升。

因此只要找到这么多 byte[] 是谁申请的,避免同时申请大量的 byte[] 就能解决性能下降的问题了。

至此,这个问题的排查便告一段落。在心中默默大喊 “我导牛逼” 的同时,不免又有很多疑惑:GC 日志怎么看?Allocation Stall 是啥?出现 Allocation Stall 又意味着什么?下面,我们便一起来探索 Java 的 GC。

GC 是什么?

在 《深入理解 Java 虚拟机》的第二章开头有这样一句话:“Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来”。这里提到的“垃圾收集技术”便是 GC(Garbage Collection)。对于 Java 程序员来说,我们只管 new 一个对象,从来不会想着去 delete/free 它。这当然给我们的开发带来的极大的便利,而且不容易出现内存泄露/溢出的问题。看似一切岁月静好,但实际上是 JVM 的 GC 在帮我们负重前行(你只管 new 就好了,GC 要考虑的问题可就多了)。

当然,一旦出现这方面的问题,如果不了解 JVM 的内存管理,那就会和我一样一脸懵了。因此我们有必要了解一下 JVM 的内存模型和 GC 的基本原理。

JVM 的内存模型

如上图所示,Java 虚拟机运行时的数据区可以分为方法区、堆、虚拟机栈、本地方法栈和程序计数器。

其中虚拟机栈、本地方法栈和程序计数器是每个线程独有的,也就是说,每一个线程都有自己的 虚拟机栈、本地方法栈和程序计数器。他们的作用分别是:

  • 虚拟机栈: 每个线程有一个私有的栈,随着线程的创建而创建。栈里面存着的是一种叫"栈帧”的东西,每个方法会创建一个栈帧,栈帧中存放了局部变量表(基本数据类型和对象引用)、操作数栈方法出口等信息。栈的大小可以固定也可以动态扩展。
  • 本地方法栈: 与虚拟机栈类似,区别是虚拟机栈执行java方法,本地方法栈执行 native 方法。在虚拟机规范中对本地方法栈中方法使用的语言、使用方法与数据结构没有强制规定,因此虚拟机可以自由实现它。
  • 程序计数器: 程序计数器可以看成是当前线程所执行的字节码的行号指示器。在任何一个确定的时刻,一个处理器(对于多内核来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,我们称这类内存区域为“线程私有“内存。

而方法区、堆内存是 JVM 中所有线程共同享有的部分,他们的作用分别是:

  • 堆内存: 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯 一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。
  • 方法区: 用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

在上面的简单介绍中,我们可以看到,堆内存是用于存放几乎所有的对象实例的。因此,对于 GC 来说,最关心的也必然是堆内存。在 Java 中,我们使用 new 关键字实例化一个对象,会在堆内存中开辟一块内存空间用于存放对象的数据,当对象的生命周期结束之后,垃圾回收器便会帮我们从内存中回收这块内存空间,这个过程便成为 GC。

GC 的基本原理

对于 GC 来说,需要关注的事情大概有以下三件事情:

  1. 哪些内存需要被回收?
  2. 怎么回收这些内存?
  3. 什么时候回收?

在这篇文章中,我们会主要聚集于问题 1 2 ,了解一下常见的对象存活判定算法和垃圾收集算法。

哪些对象“死”了

在 GC 中,要判断一片内存空间是否可以被回收,本质上是判断一个对象还有没有被引用。要完成这件事情,自然而然的会想到“引用计数”。

引用计数

所谓引用计数,顾名思义。就是给对象的被引用次数进行统计,被引用一次 +1 ,取消引用了 -1,非常好理解。但是这是可行的吗?我们来看下面这段代码

 

java

代码解读

复制代码

public class ReferenceCountingGC { public Object instance = null; public static void testGC() { ReferenceCountingGC objA = new ReferenceCountingGC(); ReferenceCountingGC objB = new ReferenceCountingGC(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; // 假设在这行发生 GC,且使用单纯的引用计数,objA 和 objB 是否能被回收? System.gc(); } }

在上面的代码中 ,objA objB 互相引用了彼此,此时两个对象的引用次数都是 1,随后将其都置 null,理论上来说,这两个对象在内存中申请的空间都不可能再次被访问了。但他们的引用次数会因此 -1 吗?

在 objA 和 objB 被置为 null 之前,内存布局大致如下:

 

css

代码解读

复制代码

+---------+ +---------+ | objA | ---> | Object A | | | | instance | ---> | Object B | +---------+ +---------+ | instance | ---> | Object A | | objB | ---> | Object B | +---------+ +---------+ | | | instance | ---> | Object A | +---------+ +---------+ +---------+

在 objA 和 objB 被置为 null 之后,内存布局大致如下:

 

css

代码解读

复制代码

+---------+ +---------+ | objA | -X | Object A | | | | instance | ---> | Object B | +---------+ +---------+ | instance | ---> | Object A | | objB | -X | Object B | +---------+ +---------+ | | | instance | ---> | Object A | +---------+ +---------+ +---------+

可以看到尽管 objA 和 objB 被置为 null,它们在堆内存中的对象结构(包括互相引用的关系)仍然存在,此时如果使用单纯的引用计数,没做任何特殊处理,这两个对象将永远不可能回到 引用计数为 0 的情况,也就不可能被回收。但很明显,你在 Java 中写这样的代码一点问题都没有,不会出现内存泄露的情况。

既然如此,GC 是用什么办法来判断哪些对象可以被回收呢?

可达性分析算法

当前 Java 的内存管理系统,都是采用都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这 个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节 点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。 举个例子:

在上图中,从 GC Roots 出发,object 1 ~ object4 都是可达的,但是 object 5 ~ object 7 是不可达的,即使他们之间存在联系,因此 object 5 ~ object 7 被判定为“死”对象,可以被回收。

那么问题来了,选择什么作为 GC Root 呢?

在 Java 中,常见的 GC Roots 包括以下几类:

  1. Java 栈中的引用:这包括所有线程的栈帧中的局部变量和参数所引用的对象。
  2. 方法区中的类静态属性引用:这些包括类的静态变量所引用的对象。
  3. 方法区中的常量引用:这包括常量池中的引用。
  4. 本地方法栈中的引用:这包括 JNI(Java Native Interface)引用的对象。

具体来说,以下是一些常见的作为 GC Roots 的示例:

  1. 活跃线程:所有处于活动状态的线程都会作为 GC Roots。
  2. 静态变量:所有类的静态变量。
  3. JNI 引用:通过 JNI 保持的引用。

选择这些作为 GC Roots 的原因在于:

  • 活跃线程:任何活跃线程都可能在其栈中持有对对象的引用,这些对象不应该被回收。
  • 静态变量:静态变量是类级别的,并且它们的生命周期通常与应用程序相同,因此它们引用的对象也不应该被回收。
  • JNI 引用:这些引用是通过本地代码(通常是 C 或 C++)持有的,它们需要特别处理,因为 JVM 无法直接管理这些引用的生命周期。

在 GC 过程中,从这些 GC Roots 出发,垃圾收集器会遍历整个引用图,标记所有可达的对象,未被标记的对象则会被认为是不可达的,可以进行垃圾回收。

怎么回收这些对象?

“怎么回收对象” 实际上在讨论垃圾收集算法。当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实 际情况的经验法则,它建立在两个分代假说之上:

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

基于这个两个经验法则,很容易便可以想到,为了区别对待这两种对象(大多数生命周期很短的对象和小部分生命周期很长的对象),我们应该把堆内存划分为两个区域,也就是所谓的新生代(Young Generation)和老年代(Old Generation) 两个区域。在新生代中,每次都有大量的对象死去,被回收。而在一轮轮回收中幸存下来的对象,就会晋升到老年代。而针对新生代和老年代不同的特性我们就可以采取不同的垃圾回收算法。

说到这里,你可能会考虑到一个问题,如果一个老年代的对象引用了一个新生代的对象怎么办?为了找出该区域中的存活对象,我们将额外遍历整个老年代中所有对象来确保可达性分析结果的正确性。这个方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法 则:

  1. 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进 而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。

依据这条假说,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生新生代的 GC 的时候,只有包含了跨代引用的小块内存里的对象才会被加入到 GCRoots 进行扫描。

回收算法的具体行为

在了解了堆内存的分区以及分代假说之后,我们来看看一些垃圾收集算法。

标记-清除算法

从名字就可以看出来,这个算法非常的简单。分为“标记”和“清除”两个阶段:

  1. 找出要被回收的对象
  2. 统一回收掉所有被标记的对象

很明显,这里的步骤一就是我们上面提到的“哪些对象“死”了”,而清除就是直接将这块内存标记为可用就行了。但这个算法有些很明显的缺点:

  1. 执行效率不稳定,标记和清除这两个行为所需要的时间随着需要回收的对象的多少线性增长
  2. 内存碎片化:由于只是简单的将内存标记为可用,会产生大量的不连续的空间碎片,当有大的对象出现的时候,有可能会出现没有足够大的空间可以被分配的情况。

如下图,可以看到回收后,存在大量的不连续的空间碎片。

标记 - 复制算法

标记-复制算法(Mark-Compact Algorithm)是为了解决标记-清除算法的内存碎片化问题而引入的一种改进算法。它同样分为两个阶段:标记和复制。

  1. 标记阶段:与标记-清除算法一样,标记出需要回收的对象。
  2. 复制阶段:将所有存活的对象复制到内存的另一块连续区域中,然后清理掉整个旧区域的内存。

这个算法的缺点显而易见,必须准备两块相等大小的内存块。也就是说,可用内存直接变成了原来的一半。空间浪费也太多了。但是,对于新生代来说(还记得新生代吗?就是那个里面的对象都很“短命”的内存区域),里面有 90% 的对象都熬不过一轮回收。既然如此,我们还需要以 1:1 的比例来划分新生代的内存空间吗?当然不需要!于是, “Appel 式回收” 横空出世,

Appel式回收的具体做法是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor。发生垃圾回收时,将 Eden 和 Survivor 中仍然存活的对象次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor空间。

在 HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也即每次新生代中可用内存空间为整个新生代容量的 90% (Eden的 80%加上一个 Survivor 的 10%),所以只有 10%的新生代是会被“浪费”的。

不过,设想这样一种场景:GC 时,发现竟然有 20% 的对象是存活的,也就意味着,空闲的 Survivor 空间不足以容纳存活的所有对象。那这时候怎么办呢?因此,Appel式回收还有一个安全设计:如果 Survivor 空间不足以容纳存活的所有对象,这时候就需要其他内存区域“借”一点内存空间给它,在具体的场景中,一般是由老年代进行“借贷”。也就是说这些上一轮存活且在 Survivor 空间放不下的对象可以直接进入老年代。

标记 - 整理算法

标记-整理算法(Mark-Compact Algorithm)是为了解决老年代内存碎片化问题而设计的一种垃圾回收算法。它分为两个阶段:标记和整理。

  1. 标记阶段:与标记-清除算法一样,首先标记出所有存活的对象。
  2. 整理阶段:将所有存活的对象移动到内存的一端,整理出连续的可用空间,然后清理掉边界以外的内存。

标记-整理算法的优势在于减少了内存碎片,使得内存分配更高效。然而,由于需要移动存活对象,并更新所有引用这些对象的地方,这个过程会导致更长的暂停时间(这种暂停用户线程的操作被虚拟机的设计者称为“stop the world”),尤其是在老年代这种存活对象较多的情况下,停顿的时间会更久。但如果像上文提到的“标记 - 删除算法”一样,不移动对象呢?那你又必须解决空间碎片化的问题,必须依赖更加复杂的处理流程来进行内存分配,实际上也会影响吞吐量。

基于此,移动对象与否似乎是两瓶毒药。

  1. 不移动对象需要更复杂的内存分配处理,总体吞吐量会下降,但无需移动对象就不需要停顿用户线程,延迟大大降低。
  2. 移动对象会 “stop the world” ,使得延迟上升,但总体吞吐量相较于移动对象会更好。

因此,标记-整理算法常用于注重吞吐量的垃圾回收器中,例如 HotSpot 虚拟机中的 Parallel Scavenge 收集器。而关注延迟的 CMS 收集器则采用 “标记 - 清除算法”

作为一种折中方案,有时虚拟机会在多数情况下使用标记-清除算法,允许一定程度的内存碎片,直到碎片严重影响分配时,再切换到标记-整理算法来规整内存空间。前面提到的基于标记-清除算法的 CMS 收集器面临空间碎片过多时采用的就是这种处理办法。

绕了一大圈,我们终于了解了常见的对象存活判定算法和垃圾收集算法,至于具体的各种垃圾收集器的实现,在这里就不一一展开了(等我学完再给大家分享)。回到最开头的问题,所以 Allocation Stall 到底是什么?

Allocation Stall

Allocation Stall 直接翻译过来是“分配阻塞”。上面我们提到,在 Java 中内存的回收又 GC 帮我们完成。当 GC “干不过来” 的时候,当 JVM 在给对象分配内存时,就会遇到无法立即分配的情况,这就会导致线程的阻塞。这种情况通常发生在内存分配需要等待 GC 完成释放内存的时候。此时 JVM 申请内存要等待 GC 结束后再进行分配。这段时间的等待就是 Allocation Stall。

Allocation Stall 的原因

  1. 堆内存不足:当 JVM 堆内存中的可用空间不足以满足新的对象分配请求时,会引发 GC 操作。如果 GC 操作��能及时释放足够的空间,就会导致线程在内存分配时出现阻塞。
  2. 频繁的对象分配和释放:如果程序频繁地分配和释放对象,会导致堆内存中出现碎片,导致 Allocation Stall 的发生。尤其是在堆内存配置不合理的情况下,这种情况会更加明显。
  3. 内存泄漏:程序中存在内存泄漏也会导致 Allocation Stall,因为内存泄漏会导致内存中有大量无用的对象无法被回收,最终导致堆内存耗尽。

Allocation Stall 的影响

线程阻塞:在 Allocation Stall 期间,线程无法继续执行,必须等待 GC 完成内存回收后才能继续,这很明显会导致线程阻塞,

CPU 占用增加:当然,频繁的 Allocation Stall 就会导致频繁的 GC 操作(还是 Full GC,也就是在整个堆的范围内进行 GC),这会增加 CPU 的占用率,进一步影响系统性能。


至于如何排查并解决 Allocation Stall ,我想最开头的我的亲身经历已经能够给你带来一些启发,在这里就不过多赘述啦。如果有什么问题或文章中有什么错误,欢迎在评论区交流!

  • 7
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值