先聊一个特别典型的场景:你用 JS 处理了一个比较大的列表,页面切换了十几次,浏览器标签页的内存占用一路飙升,最后整个页面开始掉帧、崩溃。或者一个 Node.js 服务跑了一个星期之后,内存从 200MB 涨到 2GB,不得不定时重启。
这时候很多人的第一反应是“代码写得有问题”,但往深了挖,问题往往出在 JS 垃圾回收机制上。JS 虽然是门自带垃圾回收(Garbage Collection,简称 GC)的高级语言,内存的分配和释放大部分时候不需要开发者手动管,但不代表你可以完全不管。理解了 GC 的运作方式,你才能解释“为什么内存会涨”“为什么会有卡顿”“哪些写法会造成内存泄漏”,也才能在线上出问题的时候,快速定位并解决。
这篇文章我想把 JS 的垃圾回收机制掰开揉碎讲清楚,从最基础的内存模型,到 V8 引擎真实使用的分代式回收,再落到 Chrome DevTools 里怎么排查内存泄漏,最后给出一份可以直接照着改的编码建议。内容按“由浅入深、从理论到实战”的顺序来,适合所有写 JavaScript 的开发者,尤其是经常被页面性能、内存问题折磨的前端同学。
1. 为什么每个 JS 开发者都要懂垃圾回收
1.1 内存管理:从“听语言的话”到“听引擎的话”
C 和 C++ 的开发者对内存是有绝对控制权的:malloc 一块内存,用完 free 掉,某个对象引用计数不对,程序直接给你段错误。这种“手动挡”的好处是极致可控,坏处是心智负担高,一个疏漏就崩溃。
JS 选择了一条完全不同的路——它是一门“自动挡”语言。你声明一个变量、创建一个对象,底层内存由引擎自动分配;你用完了,引擎会在某个时间点自动把这块内存收回去。这个“自动回收不再使用的内存”的过程,就是垃圾回收。
听着很完美对吧?但自动回收有一个隐藏代价:引擎不知道你的对象“什么时候算真正用完”,只能通过“还能不能访问到”来判断。它随时可能因为回收策略的问题,把你还想用的对象提前干掉,或者把早就该回收的对象一直留在内存里。前者是 Bug,后者就是内存泄漏。
所以懂 GC 的核心价值,其实是让你从“听语言的话”变成“听引擎的话”。你知道引擎是怎么判断一个对象是否存活的,就知道该怎么写代码,才能让它精准地回收掉那些没用的内存,而不是误判。
1.2 谁是“垃圾”:一切从可达性说起
要聊 GC,必须先搞清楚一个概念:什么样的对象是“垃圾”?在 JS 引擎看来,判定标准不是你手动调用了什么方法,而是这个对象是否“可达”(reachable)。
所谓可达,就是能从一组固定的根节点(roots)出发,通过引用链访问到。根节点包括但不限于:
- 全局对象(浏览器里的 window,Node.js 里的 global)
- 当前正在执行的函数调用栈中的局部变量和参数
- 所有被标记为 active 的 Promise、定时器、DOM 元素等(浏览器环境下)
只要一个对象能从根节点到达,引擎就会认为它“还有用”,即使你可能已经很久没碰它了。反过来,如果某个对象从根节点出发怎么都访问不到了,那它就是垃圾,会在下一次 GC 时被回收。
这个模型特别重要。很多内存泄漏的本质,就是“本来应该断开的引用链,因为某个疏忽一直没有断掉”,导致对象明明没人在用,却依然能从根节点访问到,GC 就永远不回收它。比如挂在全局变量上的数据、被闭包长期持有的 DOM、没有清理的定时器回调,都是这种类型。
理解了这个底层逻辑,再看后面那些算法和工具,就会顺很多。
2. 垃圾回收的核心算法:从引用计数到标记清除
2.1 引用计数:最直观的方案,为什么会被淘汰
很早期的 JS 引擎,比如第一代 Netscape 的 SpiderMonkey,用的是一种叫“引用计数”(Reference Counting)的算法。它思路特别简单:每个对象身上记一个数字,表示“现在有几个地方引用了我”。这个数字归零,说明没人再用它了,立刻回收。
let objA = { name: 'A' }; let objB = { name: 'B' }; // 此时 objA 和 objB 的引用计数都是 1 let objC = objA; // objC 也指向 objA,所以 objA 的引用计数变成 2这样确实很直观,回收也很及时,但有一个致命缺陷——循环引用。如果两个对象互相指着对方,那它们的引用计数永远不可能归零。
function createCircle() { const a = {}; const b = {}; a.ref = b; b.ref = a; return a; } // 即使外部不再使用返回的对象,a 和 b 互相引用, // 引用计数都不会降到 0,这块内存就永远回收不掉现实项目里,循环引用出现得非常隐蔽,尤其是 DOM 事件、闭包、复杂的数据结构组合起来之后,你根本防不住。所以引用计数后来被所有主流引擎放弃了,只在某些“辅助手段”里还能看到它的影子。现代 V8、SpiderMonkey、JavaScriptCore 的核心里,站 C 位的是标记清除。
2.2 标记清除(Mark-Sweep):现代引擎的地基
标记清除(Mark-Sweep)的思路和引用计数完全相反,它不关心“谁引用了谁”,只关心“从根出发能不能到达”。
整个算法分成两个阶段:
第一个阶段是标记(Mark)。从根节点出发,遍历所有能访问到的对象,给它们打上一个“存活”标记。这个遍历是深度优先的,一路沿着引用链往下走,把所有可达对象都标记上。
第二个阶段是清除(Sweep)。遍历整个堆内存,把没被打上标记的对象当成垃圾回收掉,并把回收的内存记到空闲列表里,供后续分配使用。
对比一下就知道,标记清除天生就能处理循环引用。因为标记阶段是从根开始“逆向”寻找的,不管 a 和 b 怎么互相指,只要根出发访问不到它们,它们就是垃圾。
但是标记清除也有问题:它会产生内存碎片。回收掉的对象在堆内存里分布得乱七八糟,后续分配大对象时可能找不到足够大的连续内存,只能被迫触发一次额外的 GC,或者浪费一些空间。这就是后面要说的“碎片化”问题。早年的 V8 老生代 GC 做完一次标记清除,内存布局就变得跟碎饼干一样,所以还需要一个补充手段。
2.3 标记整理(Mark-Compact):解决碎片化的下一步
既然清除会产生碎片,那就再加一步整理(Compact)。标记整理的前半程和标记清除一样,也是从根出发做标记,但在清除的时候,它不是简单地把垃圾对象的空间释放掉,而是会用一种特殊的方式把存活对象往内存的一头搬移,让它们紧凑排列,然后再一次性清掉边界之外的空间。
这个过程会把“碎片化的内存”整理成“连续的内存块”,后续分配大对象时成功率更高。但代价也很明显:搬移对象的成本很高,尤其是对象多且引用关系复杂的时候,整个堆的内存都要动一遍,停顿时间明显变长。
所以 V8 不会每次都走完整理,而是有一套自己的策略:大部分时候只做标记清除,当内存碎片化到影响分配效率时,才触发一次代价更高的标记整理。你可以理解成“平时按需打扫,隔一段时间做一次全屋归位”,既要控制单次 GC 的耗时,也要保证长期运行后内存还是够用的。
3. V8 引擎的垃圾回收结构解剖
3.1 分代式收集:为什么要区分年轻对象和老对象
如果是小规模脚本,用一套全局标记清除就够了,但真实世界的 JS 应用动辄成千上万个对象,GC 一跑就是几十毫秒,页面直接卡一下。于是 V8 引入了一个非常重要的优化思路——分代(Generational)收集。
这个思路的基础是一个统计学观察:大多数对象都是“朝生暮死”的。在函数里创建的对象、临时字符串、map 方法中间产生的数组,基本上用一次就变成垃圾了。而能活过几轮回收的对象,往往是长期存活的,比如模块级别的数据、全局配置对象、缓存等。
V8 把自己的堆(Heap)分成两大块:
- 新生代(New Space):放刚创建出来的新对象,空间不大,默认在 64 位系统里大概 16~32MB 的样子
- 老生代(Old Space):放经过了多轮回收仍然存活的对象,空间大得多,默认上限可以到 1.4GB~4GB 级别
新生代里的对象更新换代的频率极高,所以垃圾回收应该足够快、足够频繁;老生代里的对象生命周期长,回收不能太频繁,而一旦触发就应该做一次彻底的清理。两个区域采用完全不同的算法,这就是分代回收的核心。
3.2 新生代的 Scavenge 算法与对象晋升
新生代使用的算法叫 Scavenge,具体实现是“复制式回收”。它把新生代又分成两个“半空间”(Semi-space):一个叫 From 空间,一个叫 To 空间。新对象一律先放进 From 空间。
GC 发生时,V8 会扫描 From 空间里所有还存活的对象,把它们“复制”到 To 空间。复制完以后,From 空间整块清空,然后 From 和 To 交换角色:原来空的 To 变成新的 From,原来的 From 变成新的 To,继续给新对象分配空间。
这个思路的精妙之处在于,它避免了“逐块清除”产生的碎片问题,而且新生代里存活对象通常很少,复制成本很低,速度非常快。特别适合“绝大多数对象活不长”的场景。缺点是空间利用率只有一半,因为两个半空间同时只用一个,这也是新生代空间设计得比较小的原因。
对象在新生代里要是“命比较硬”,经历了好几轮 Scavenge 还活着,或者被复制时 To 空间已经快满了,它就会被“晋升”(Promotion)到老生代。这个逻辑也很好理解:既然你活过了好几轮 GC,说明你是个长期对象,放在朝生暮死的新生代里反而碍事,赶紧搬到老生代去。
对应到写代码上,你可以想想:如果你在一个高频执行的循环里创建了大量临时对象,而且每个对象都被人为挂到了全局变量上,那它们就会很容易晋升到老生代,让老生代提前膨胀,进而触发代价非常高的老年代 GC。这就是堆内存飞涨的一个典型前置因素。
3.3 老生代的标记清除、标记整理与触发时机
能进老生代的对象,都是“见多识广”的。在新生代,只要复制就能解决大部分问题,但在老生代,对象又多又大,再用复制的方式不现实——你总不能把几百 MB 的老对象全部搬到另一个空间吧?所以老生代的主算法是标记清除 + 标记整理的组合,这在第 2 章已经讲过。
老生代的 GC 不是轻易会触发的,它有自己的触发条件:
- 当老生代的内存占用达到一个阈值时
- 当新生代对象晋升到老生代,导致老生代空间不足时
- 当前一次 GC 之后内存使用率仍然很高,需要再次回收时
每次老生代 GC 都是一次对主线程的“入侵”,最直接的后果就是 JavaScript 执行被暂停,页面可能出现卡顿。所以 V8 才会绞尽脑汁优化 GC 的停顿时间,这也直接引出了现代 GC 的另一个重头戏——增量标记和并发技术。
另外补充一个和 Node.js 场景相关的信息:Node.js 里 V8 的堆内存上限是可以调的,常见的做法是加--max-old-space-size=4096让老生代上限变成 4GB。但注意,这只是“允许堆用这么大”,不是“一上来就占这么大”。堆越大,单次 GC 要扫的对象就越多,停顿时间可能越长。所以别盲目调大内存上限,真实需求是多少就调多少,否则是在用 GC 延迟换内存上限,不划算。
3.4 现代GC优化:增量标记、惰性清理、并发与并行
传统标记清除有一个“原罪”:标记阶段需要遍历整棵对象图,一个都不能漏。如果应用里有几百万个对象,这一步可能要花几十毫秒甚至上百毫秒。在这段时间里,JavaScript 是停的,页面就卡。
为了让 GC 尽量不打断主线程,V8 这些年做了一系列优化,核心思路是“把大任务拆小”和“把任务转移给其他线程”。
首先是增量标记(Incremental Marking)。不再一口气标记完所有对象,而是把标记过程分成一小段一小段,穿插在 JavaScript 的各个任务之间执行。这样每一小段只占非常短的时间,用户几乎体感不到卡顿。为了让“分小段标记”不出错,V8 采用了三色标记法:用白色表示“还没扫描到”,灰色表示“对象本身扫描了,但它的引用还没扫描完”,黑色表示“自己和引用都处理完了”。每个小段执行完,标记状态都会被保留在对象颜色里,下次增量接着做就是了。
然后是惰性清理(Lazy Sweeping)。以前清除阶段会立刻把垃圾对象的内存全部释放并整理进空闲列表,现在 V8 会“偷懒”,把清除挪到用时再做。只有在分配内存的时候发现空间不够了,才顺着空闲列表清掉一部分垃圾,释放出足够的内存给新分配。这避免了一次性清扫带来的长停顿,也把开销摊到了平时的分配路径上。
再往深了一步,V8 引入了并发标记(Concurrent Marking)和并行清理(Parallel Sweeping)。就是把标记、清理的任务交给辅助线程去跑,主线程继续执行 JavaScript。当然,不是所有步骤都能交给辅助线程,和主线程数据交互紧密的步骤,比如对象搬移、引用更新,还是得回到主线程做,但在 V8 的不断优化下,GC 造成的停顿已经从一个“一次性的长暂停”变成了“若干次极短的微暂停”。
理解了这一整套优化,你再看“页面 GC 卡顿”这个问题,就不会只想到“是不是对象太多了”,而是会去分析:是不是老生代频繁触发了代价高的 GC,是不是对象图太大导致增量标记也撑不住,是不是频繁分配大对象导致必须触发整理。
4. 内存泄漏排查与 GC 友好代码实战
4.1 最常见的六个内存泄漏场景
懂原理的最终目的是写出不容易泄漏的代码。我把自己排查线上问题时反复撞到的六个场景整理成了一张表,每一个都是真实项目里的“老熟人”。
| 场景 | 典型表现 | 根因 |
|---|---|---|
| 意外全局变量 | 局部赋值时忘了写let/const,或者隐式挂到 window 上,对象永远可达 | 根节点指向对象,GC 无法回收 |
| 定时器未清理 | setInterval回调引用了大对象,页面关闭了却没clearInterval | 回调中引用的对象被长期持有 |
| 事件监听器重复注册 | 每渲染一次就 addEventListener,组件卸载时没 remove | 监听器里的引用链断不掉 |
| 闭包滥用 | 一个长期存活的闭包引用了巨大的局部变量或 DOM | 闭包保住了外层的整条引用链 |
| DOM 引用残留 | 把 DOM 存进了数组或对象,DOM 从页面上移除了,但引用还在 | 已移除的 DOM 和关联数据一起滞留 |
| 无边界缓存 | Map 或普通对象里不断塞数据,从不清理过期项 | 缓存对象本身持续可达,且无限增长 |
最阴险的是“意外全局变量”。有一次排查一个页面内存泄漏,发现一个函数里写了data = res.data,明明是想声明局部变量,却因为少了let,直接把 data 挂到了全局对象上。后台返回的列表数据巨大,只要页面不刷新,那几 MB 就永远赖在内存里。解法很简单:开启'use strict',严格模式会直接禁止隐式创建全局变量,等于从语法层面拦掉了一类泄漏。
4.2 用 Chrome DevTools 定位泄漏源头
排查内存泄漏,强烈建议先用 Performance 面板录一段操作流程。具体做法是:
- 打开 Chrome DevTools,切到 Performance(性能)面板
- 勾选 Memory 选项,点录制
- 在页面上执行你想要测试的操作,比如刷新列表、打开弹窗、切换路由,重复几次
- 停止录制,观察内存曲线
如果内存曲线波浪式上涨后能回落,说明 GC 在正常工作;如果每次操作结束,内存的基线都往上抬一个台阶,就算你停止所有操作,内存也降不回去,那基本确定有泄漏。
找到方向后,再用 Memory(内存)面板做精确分析。切到 Heap Snapshot(堆快照),操作前拍一张,操作完成后再拍一张,然后对比两张快照的“Retained Size”(保留大小)。看哪个构造函数占用的内存增量最大,再点进去看是哪些对象被谁引用了。从引用链里,基本能顺藤摸瓜找到泄漏的源头——十有八九是一个闭包、一个定时器或者一个挂在全局的数组。
如果是持续性的内存增长,还可以用 Allocation Instrumentation on Timeline(分配时间线)录一段,它能把每毫秒的分配情况记录下来,形成按函数名分组的内存分配柱状图,一眼就能看出是哪个函数在疯狂分配大对象。在真实场景里,我的排查顺序基本都是:Performance 曲线确认“泄漏是否存在” → Heap Snapshot 对比确认“是哪些对象” → 看引用链确认“是谁在引用它” → 改代码闭环。这个过程不要靠猜,每一步都要有数据支撑。
4.3 弱引用:WeakMap、WeakSet 与 FinalizationRegistry
很多内存泄漏的尽头,是一个“不该存在却一直存在的强引用”。要想打破这种绑定,JS 提供了弱引用工具:WeakMap 和 WeakSet。它们的特点是:只要 key 对象没有其他强引用,即使你在这个 WeakMap 里存了值,GC 也可以把 key 回收掉。
最典型的应用场景是缓存。比如你写了一个处理大对象的缓存模块,用 Map 存对象会导致缓存无限膨胀。换成 WeakMap 后,key 对象如果不再被外部使用,它和对应的缓存数据就会一起被回收,不会拖垮内存。
// 使用 WeakMap 做对象级缓存,不会造成内存泄漏 const cache = new WeakMap(); function processData(obj) { if (cache.has(obj)) { return cache.get(obj); } const result = heavyCompute(obj); cache.set(obj, result); return result; } // obj 不再被外部引用后,cache 里的对应条目会被 GC 自动回收注意,WeakMap 不是万能的。它的 key 必须是对象,不能是原始类型;而且你没法遍历 WeakMap 的所有 key,因为在 GC 前后 key 集合是变化的。需要用“可枚举的缓存数据”时,还是老老实实用 Map,但记得自己做清理策略。
如果你需要更细粒度地监听对象的回收时机,可以试试FinalizationRegistry。它能注册一个回调,当注册的对象被 GC 回收时,回调会执行。比如你想给一个被回收的对象打日志、或者移除关联的缓存条目,都可以在回调里做。同理,WeakRef可以拿到一个对象的弱引用,不会阻止它被 GC 回收,但使用时要非常小心,因为deref()可能随时返回undefined。纯业务代码里我不建议大量使用这两个 API,它们更适合框架层、工具库这类需要精细控制资源释放的场景。
4.4 我总结的 GC 友好编码清单
聊完排查工具,最后再给一份可以直接落地执行的编码清单,算是我这几年替团队写前端代码时反复强调的“每日守则”。
第一,能局部就不全局。变量声明用let/const限定在尽可能小的作用域里,临时对象用完了就让它自然离开作用域,GC 才好判断它已经死了。很多人图省事,把本应该放在函数内部的数据挂到路由级状态或者组件实例上,等于给垃圾对象送了“免死金牌”。
第二,严格管理生命周期。配对使用定时器和清理器:组件里用了setInterval,unmount或beforeDestroy里一定要clearInterval;注册了事件监听,removeEventListener别忘;用了第三方库的订阅方法,退订方法同样要补上。这听起来像废话,但线上绝大多数泄漏都是这种“配对的另一半没写”造成的。
第三,谨慎使用闭包。闭包不是不能用,而是要控制它引用范围。我在实际项目里见过同事写了一个很长的闭包,内部引用了外层一个包含大量历史数据的 state,结果这个闭包被存进了全局数组,等于那整份历史数据全被保住了。解决办法是:闭包里只引用必要的数据,该解构的解构,该传参的传参,别让一个函数顺手牵住整个外层的“家产”。
第四,合理使用弱引用。对象映射、对象缓存、按对象维度存储元信息,优先考虑WeakMap/WeakSet。它们就是专门为“不想强持有对象”的场景设计的,用对了能让 GC 省很多事。
第五,避免频繁创建超大对象。比如渲染一个表格前,先用 filter/map 生成几个中间大数组,这本身没问题,但如果这些数组被人为挂到了组件属性上,或者闭包里被长期持有,就会不断推高老生代的内存水位线。该用流式处理的就别一次性全塞进来,该复用的对象池就复用,尤其是往 Canvas、WebGL、WebSocket 这类高频场景传数据时,对象复用的收益非常明显。
第六,给长时间运行的应用加上“观察哨”。前端可以用 Performance Observer、performance.memory监听内存变化,Node.js 里可以定期用process.memoryUsage()记录堆内存,超过某个阈值就报警。有监控你才能知道改动有没有生效,否则“内存泄漏”这种东西,往往等项目上线跑了两周才在某个深夜把服务拖崩。
5. 从面试题到实际项目:GC 知识应该怎么用
5.1 一道经典面试题背后的真实考点
网上关于 GC 的面试题不计其数,最常见的是“说说 JS 的垃圾回收机制有哪些”。很多人背了一堆概念,什么引用计数、标记清除、分代回收,都能说个大概。但到了实际项目里,能拿这套知识解决真实问题的人少之又少。
我判断一个人是不是真懂 GC,会问他一个延伸问题:“如果线上页面内存一直涨,你第一步做什么?”这时候如果他还停留在背概念,往往会回答“用标记清除把没用的对象清掉”——这就说明他没真正理解 GC 的边界。正确的做法是先判断内存趋势(用 Performance 看曲线),再定位到具体对象(用 Heap Snapshot),最后顺着引用链找到代码里的强引用,修改代码从而让 GC 有机会回收。GC 从来不会主动“修掉”你代码里的逻辑问题,它只负责把你已经“断开引用”的对象清走。
所以我的建议是:面试题里的概念一定要理解而不是死记,比如“标记清除为什么能解决循环引用”“Scavenge 为什么适合新生代”,这些本质都是在考你“这个设计解决了什么问题、代价是什么”。把这些底层逻辑想通了,面试时自然能结合场景展开,而不是给一份教科书式的背诵。
5.2 真实项目:一个内存泄漏排查的完整复盘
最后分享一个我印象极深的排查案例,它几乎包含了所有 GC 难点。
项目是一个数据大屏页面,每隔 5 秒请求一次后端接口,把数据渲染到页面上。运行大约 2 小时后,页面开始卡顿,Chrome 内存从开局的 300MB 涨到了 2GB 以上。
一开始我们以为是接口返回的数据量太大,让后端压缩数据,但优化后内存依然涨。于是开了 Performance 录制,发现每次数据刷新后,内存曲线都会“上一个台阶”,几乎不回落。再用 Heap Snapshot 对比,发现占内存最多的对象是一个被闭包引用的大数组。
顺着引用链一查,发现了真正的问题:代码在setInterval的回调里调用了一个updateChart()函数,而这个函数内部创建了一个Chart实例,并且在初始化时传入了这一次的完整数据。关键点在于,这个Chart实例被赋值给了模块级变量,导致每次调用都会把上一次的实例顶掉,但实例内部的事件监听器仍然持有老的数据数组,形成了一个“旧实例不被回收”的引用链。
问题根因不是数据量大,而是每次更新时没有销毁旧实例。修复方法也很简单:在更新前调用chart.destroy()或手动移除内部监听器,并把模块级变量重置为null。改完后内存曲线变得平稳,再跑一整天也没有明显增长。
这个案例对我最大的触动是:GC 不是靠某个一次性操作解决的,它是一个“让 GC 能正常工作”的工程问题——引用链不断干净,哪怕算法再牛也没有用。
5.3 这个知识后续还能怎么扩展
学会了 JS 的 GC,其实你已经在理解“自动内存管理”这件事上踏出了一大步。同样的分代思想,也应用在 Java 虚拟机、Go runtime、.NET CLR 里,只是各自叫法和细节不同。如果你以后要深入 Node.js 底层、写 WebAssembly 模块、或者做性能相关的工具链,这套“内存分配 → 对象存活判断 → 回收策略 → 停顿优化”的思路会反复出现。
还可以了解一下 V8 官方文档里的--trace-gc、--trace-gc-nvp日志输出,Node.js 下跑一段压测脚本,能看到每次 GC 的耗时、类型和内存回收量。把这些日志接入到监控系统里,你就能实时掌握服务的 GC 频率和压力水平,提前预判性能风险。
说实话,我刚入行时也觉得 GC 这话题太底层,前端写出界面就行了。直到我自己在大屏项目里被那个“2 小时内存暴涨”的问题折磨了整整三天、差点通宵上线的时候,才意识到:不理解垃圾回收,写出来的代码就像是一个不看油箱分表的司机,开着开着就抛锚了。这些年我再带团队,都会要求核心开发至少要能讲清楚“可达性”“分代回收”“引用链”这三个词,不为别的,就为哪天线上内存报警时,有人能冷静地说一句:“别急,我们先看引用链。”
最后分享一个实际工作中的小技巧:在排查内存问题时,别只看堆大小,也留意一下页面卡顿的时机。如果卡顿呈周期性,而且正好和 GC 触发节奏重合,那基本就是 GC 停顿在作祟。你可以用 Performance 面板里的“Experience”指标看看有没有 Long Task,再配合--trace-gc日志确认 GC 的暂停时间。这种问题通常不是内存泄漏,而是“对象图太庞大 + 频繁触发高成本 GC”,优化方向是减少长期存活对象的数量、压缩缓存、避免在热路径上创建大对象。两回事,别用一套排查方案硬套。