做后端这些年,Java虚拟机垃圾收集这个话题,我见过不少同学有三种典型状态:一种是把垃圾收集当成“黑盒”,出了问题先用-Xmx翻倍再说;另一种是背了一堆参数却不知道背后的算法逻辑,调参全靠玄学;还有一种则是看过不少原理文章,但一到生产环境看GC日志,还是不知道什么算正常、什么算危险信号。我自己也在这条路上踩过不少坑,所以这篇内容想把这些年在垃圾收集上的一点积累整理出来。文章会按照这样的思路往下走:先讲清楚垃圾收集到底在回收什么,这是很多调优翻车的根源;再把几个基础回收算法和分代模型放到一起对比,解释为什么JVM要搞出这么复杂的体系;然后从历史序列把几款主流收集器的取舍翻一遍;最后落到实战,GC日志怎么开、怎么读、哪些参数组合在真实项目里更稳妥,以及最容易出问题的几个点,都会过一遍。无论你是刚开始想看懂GC,还是已经在生产环境里遇到过若干次停顿,应该都能找到对得上自己问题的地方。
1. 先弄明白:垃圾收集到底在回收“什么”
垃圾收集的第一步其实是“判定垃圾”。如果连什么是垃圾都定义不清楚,后面所有算法都是空中楼阁。目前判断对象是否存活的思路主要有两种:引用计数和可达性分析。这个选择题做错了,后面全乱。
1.1 判断对象存活:引用计数为什么在JVM里走不通
引用计数非常直观:每个对象维护一个引用计数器,每当新增一个引用指向它,计数器加一;引用失效时计数器减一;计数器归零,说明没有任何人引用它,可以被回收。早期不少语言实现和脚本引擎都采用过这种方案,但它有两个绕不开的问题。第一,每次引用赋值都要额外修改计数器,频率高了以后,这部分开销非常可观,对高频运算场景并不友好。第二点是最致命的:循环引用无法被识别。A对象持有B对象,B对象又持有A对象,即使整张引用图已经从程序逻辑上断开了,只要这两个对象的计数器都还大于零,它们就永远不会被当作垃圾。在一个复杂业务系统里,循环引用并不是罕见情况,最常见的就是父子节点双向关联的对象模型。
所以JVM主流实现没有走引用计数这条路,而是选择了可达性分析。这也是理解后面一切收集器行为的前提——GC判断的是“从根上能不能够到”,而不是“被引用了多少次”。
1.2 GC Roots到底包含哪些对象
可达性分析的做法是:选取一组根对象作为起始点,沿着引用链向下搜索,凡是能从根对象出发到达的对象,都标记为存活;无法到达的对象,才是可回收垃圾。具体到HotSpot虚拟机,可以作为GC Roots的对象大致有几类:虚拟机栈和本地方法栈中局部变量表引用的对象;方法区中类静态属性引用的对象;方法区中常量引用的对象;被synchronized持有的对象;JNI引用的对象;以及活跃线程和JVM内部使用的一些根。
GC Roots并不是某一个固定的内存区域,而是一个集合。这里有一个实际影响很多人没有注意:GC Roots里有一大部分是线程栈引用,如果线程数特别多、线程栈特别深,根扫描的时间也会明显变长。高并发场景下GC停顿更难压住,根数量本身就是一个重要因素。
1.3 finalize():能短暂“复活”对象,但千万别依赖
可达性分析还有一个容易被忽视的细节:当对象第一次被标记为不可达时,如果这个类重写了finalize()且还没有被执行过,虚拟机会先把对象放进一个叫F-Queue的队列里,等待Finalizer线程去执行。如果在这个过程中,对象又重新被某个存活对象引用到,它就能逃过这一轮回收。这就是“对象自救”的机制来源。
这个机制听起来很有趣,但在工程上基本是负资产。finalize()执行时机不固定,对象在队列里滞留的时间不确定;实现它的类每次实例化还会引入额外的引用队列管理成本。更重要的是,JVM并不保证finalize()一定会执行,所以任何资源释放逻辑都不应该放在里面。JDK 9开始已经把它标记为废弃。我在代码评审里见过有人用finalize()关socket连接,这种做法的风险极大,等于在项目里埋了一颗触发条件不确定的隐藏炸弹。
2. 三种基础回收算法背后的取舍逻辑
判定对象是否死亡之后,下一步就是怎么回收。这个世界不存在同时做到“不浪费空间、不产生停顿、不影响分配速度”的回收算法,所以JVM围绕“标记”“清除”“复制”“整理”组合出了几种基础方案,每一种都有明显的取舍。
2.1 标记-清除:思路最直但留下碎片的代价
标记-清除的思路最直接:先根据可达性分析标记出所有需要回收的对象,标记完成后做一次统一清理。实现简单,存活对象也不需要移动位置。但它的缺点非常致命:回收后会产生大量不连续的内存碎片。这就好比往磁盘里反复删除小文件,容量看起来还有,但想找一块足够大的连续空间分配大对象时,就很费劲。碎片多了以后,分配大对象可能被迫提前触发一次Full GC,把本来能撑很久的堆搞得很焦虑。基于标记-清除思想的CMS收集器,最终也是被碎片问题反复困扰。
2.2 标记-复制:用一块空闲区解决分配问题
标记-复制算法把可用内存划分为两块,每次只使用其中一块。回收时,把存活对象从正在使用的那块复制到备用块,然后直接清空原块。这样做的好处是,既不产生碎片,分配也只需要移动一个指针,整个回收过程简单高效。但代价是可用内存直接少了一半,空间利用率不高。
后来商业虚拟机做了个巧妙的妥协:不把内存对半切开,而是用一块Eden区和两块Survivor区来模拟“复制”逻辑,默认比例是8:1:1,这样只有约10%的空间被闲置,而不是50%。这个设计我在下一部分详细展开。
2.3 标记-整理:移动存活对象消除碎片
标记-整理算法在标记之后做的不是清理,而是把所有存活对象往内存一端移动,然后清理边界以外的所有空间。活对象搬完家,内存碎片自然消失,分配也变成指针移动。代价也很清楚:移动对象意味着要同时更新所有指向这些对象的引用,这一步如果全量做,停顿时间一般会比较长。它更适合老年代这种对象密度高、“搬家”频率低的场景。
2.4 三种算法怎么分工:一张表收回
| 对比维度 | 标记-清除 | 标记-复制 | 标记-整理 |
|---|---|---|---|
| 核心思路 | 标记垃圾后原地清理 | 存活对象复制到预留空间 | 存活对象向一端移动 |
| 碎片处理 | 会留下大量碎片 | 不会产生碎片 | 消除碎片 |
| 内存效率 | 不需要额外空间 | 需要一半或一块Survivor空间 | 不需要额外空间 |
| 实现复杂度 | 较低 | 中 | 较高,需要更新引用 |
| 典型适用区域 | 老年代(CMS时代) | 新生代 | 老年代(部分收集器) |
实际落地时,新生代对象“朝生夕死”比例高,适合复制算法;老年代对象存活率高,搬一次成本太大,适合标记-清除或标记-整理。这也是“分代收集”可以成立的根本原因。
3. 分代收集模型:新生代与老年代的博弈
有了基础算法,JVM又抛出一个更宏观的设计:分代。这个设计不是拍脑袋想出来的,而是建立在观察大量应用程序内存行为基础上的两个规律。
3.1 分代假说怎么推导出来的
通过分析大量运行中的程序,业界得出两个规律:绝大多数对象存活时间极短,创建后很快就不再被引用,这叫弱分代假说;而那些熬过了多次回收的对象,往往还会继续存活很长时间,这叫强分代假说。基于这两个观察,堆被划分成新生代与老年代:新生代处理那些“活不长”的对象,老年代存放那些“久经考验”的对象。这就是分代收集模型的理论基础。
一个很直观的类比是公司里的人:绝大多数新员工试用期没过就会离开,属于“新生代”;少数人扛过几次晋升周期,成为核心员工,就进入“老年代”。对不同阶段的人,管理方式自然不一样。
3.2 Eden和两个Survivor区到底怎么配合
新生代内部被进一步拆成三块:Eden、S0、S1,默认比例8:1:1。新对象通常在Eden区分配。Minor GC发生时,把Eden区和正在使用的Survivor区中的存活对象,复制到另一块空闲的Survivor区,然后整体清空Eden和原Survivor。S0和S1的角色每轮交换,一块叫From、另一块叫To。这样设计的目的,就是为了用约10%的空间浪费,替代标记-复制算法原本50%的空间牺牲。
这里有几个细节常被误解:第一,Survivor区只在Minor GC时充当“转存区”,平时大部分时候是空的;第二,如果某一轮存活对象超过了To区容量,多出来的对象会直接晋升到老年代,而不是等在Survivor区里;第三,Eden区分配对象使用指针碰撞,因为是连续空间,分配速度确实很快。
3.3 对象晋升:年龄阈值和动态年龄判断
对象每经过一次Minor GC并且存活下来,年龄就增加一岁。默认阈值是15,达到阈值就会晋升到老年代,可以通过-XX:MaxTenuringThreshold调整。但实际场景里不一定要等到15岁,JVM还有一个动态年龄判断机制:如果在Survivor空间中,相同年龄的所有对象大小总和大于Survivor空间的一半,那么年龄大于等于该年龄的对象,会被直接晋升到老年代。这个规则是为了避免Survivor被一堆“死不了但也不怎么活”的长寿对象占满,影响新生代正常的复制回收。
我见过不少团队把MaxTenuringThreshold调成0,目的很简单——既然对象早晚去老年代,干脆别在Survivor区折腾了。这个思路在某些高分配速率场景下有一定道理,但代价是大量短命对象过早进入老年代,老年代压力变大,Full GC频率可能升高。真要做这种调整,必须通过GC日志里的对象年龄分布来验证,不能拍脑袋。
3.4 大对象直接进老年代的利与弊
传统分代收集器里,还允许把超过一定大小的对象直接分配到老年代,相关参数是-XX:PretenureSizeThreshold。这么设计的原因是,大对象在新生代里每次Minor GC都要在Eden和Survivor之间复制,复制成本高且容易占用Survivor空间。
但“大对象直奔老年代”也不是没有代价。在G1收集器里,超过Region容量一半的对象会被视为“巨型对象”,会直接占用一个或多个连续Region。如果服务里频繁产生巨型数组、巨型Map,老年代很容易被这种对象撑出大片空洞,反而引发更多回收压力。所以大对象直接进老年代是个“两害相权”的设计,最终是否受益,还是要看对象的存活特征。
4. 从Serial到ZGC:收集器演进的几个关键转折
基础算法和分代模型拼出了框架,接下来要解决的是“谁来做回收”和“怎么做停顿控制”。收集器的演进史,本质上就是一部“如何把应用停顿时间压得更低”的历史。
4.1 单线程时代的Serial和Parallel
Serial收集器是最早的新生代收集器,单线程执行GC,回收时必须暂停所有工作线程,也就是Stop The World。在单核CPU年代,单线程意味着没有线程切换开销,实现简单、行为可靠,并不算劣势。现在Serial主要在小内存客户端程序或受限环境里还有存在感。
Parallel Scavenge则把目标从“停顿短”换成了“吞吐量大”。它同样采用复制算法,但支持多线程并行回收。后台计算、离线任务这类对单次停顿不敏感、对总吞吐量有要求的场景,Parallel至今仍然是不错的选择。它对应的老年代收集器Parallel Old负责标记-整理。
4.2 CMS:并发清理的先行者及其退场原因
CMS是“Concurrent Mark Sweep”的缩写,目标就是最短停顿时间。它把回收拆成了四个主要阶段:初始标记、并发标记、重新标记、并发清理。因为并发标记和并发清理都不需要暂停业务线程,大部分时间应用可以继续运行,停顿时间看起来非常优秀。CMS开启了很多并发GC的思路,对之后的G1和ZGC影响很大。
但CMS有两个绕不开的老问题。第一,并发标记和并发清理期间会产生浮动垃圾,这些垃圾要留到下一轮GC才处理,所以CMS必须预留一部分老年代空间。如果老年代空间在并发回收期间被塞满,就会触发“Concurrent Mode Failure”,退化成Serial Old的单线程Full GC,停顿瞬间爆表。第二,CMS基于标记-清除算法,内存碎片无法避免,碎片积累到一定程度,分配大对象又会触发Full GC。CMS在JDK 9之后逐渐被G1取代,JDK 14中被正式移除。它是我见过被吐槽最多的收集器,但不可否认它验证了一条路:GC的大部分工作是可以与业务并发执行的。
4.3 G1:按Region做局部回收的默认方案
G1把堆划分成多个大小相等的Region,天生站在“物理连续”之外思考问题。新生代和老年代不再是连续的物理区间,而是一个个Region的集合。G1仍然有分代概念,但每次回收不必覆盖整个堆,而是优先回收“垃圾最多”的那批Region。年轻人回收叫Young GC,包含旧年代的回收叫Mixed GC。
G1能高效工作离不开三个底层组件:记忆集记录“其他Region对当前Region的引用”,缩小扫描范围;SATB快照配合写屏障,在并发标记时记录引用变化,避免全堆重新扫描;停顿预测模型基于历史统计来调节回收节奏,让停顿时间尽量靠近-XX:MaxGCPauseMillis设定的目标。G1从JDK 9开始成为服务端默认收集器,稳定的表现让它在很长一段时间里都是通用首选。
4.4 ZGC:用颜色指针把暂停时间压下去
ZGC的目标更加极致:把暂停时间压到亚毫秒级别,而且与堆大小几乎没有关系。它引入染色指针和读屏障,把对象状态直接编码进指针的若干位里,这样回收过程中的标记、转移等阶段都能与业务线程并发执行,需要停顿的工作少之又少。
ZGC适合几十GB甚至更大堆的场景,延迟敏感型业务是它的主场。代价是CPU开销明显上升,而且对JDK版本有要求。如果你的服务CPU资源紧张但堆不大,选ZGC未必划算。
4.5 实际选型怎么选:一张表格对照
| 收集器组合 | 目标 | 优势 | 不适合场景 |
|---|---|---|---|
| Serial + Serial Old | 最小内存占用 | 单线程,简单可靠 | 多核高并发大堆 |
| Parallel + Parallel Old | 高吞吐量 | 并行回收适合后台计算 | 低延迟敏感业务 |
| CMS + Parallel Old | 低停顿 | 并发清理,停顿短 | 碎片问题、已被移除 |
| G1 | 可预测停顿和大堆 | Region局部回收,JDK9默认 | 超大堆且延迟极苛刻 |
| ZGC | 超低延迟、超大堆 | 停顿与堆大小几乎无关 | CPU开销高,版本要求高 |
选型不是越新越好。我曾见过一个几十MB堆的小工具服务用G1,GC日志里大部分时间在维护Region和记忆集,收益远低于损耗。要先把应用场景说清楚:是吞吐优先、延迟优先,还是内存优先,再谈选哪个收集器。
5. 打开并解读GC日志:调优前必须做的第一件事
不管是日常监控还是线上排障,第一步永远是打开GC日志。没有日志就调参数,基本等于闭着眼睛开车。GC日志记录了每一次回收的原因、前后内存变化、停顿耗时,是判断垃圾收集行为是否健康最直接的证据。
5.1 不同JDK版本的日志开关写法
JDK 8及更早版本,GC日志开关通常是这种写法:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:/logs/gc.logJDK 9开始引入了统一日志系统,写法完全变了,统一使用-Xlog。下面是JDK 11里比较常用的一组:
-Xlog:gc*:file=/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=50M-Xlog后面的冒号分段含义是:第一个是日志级别与标签选择,gc*表示所有与GC相关的事件;第二个是输出目标和文件名;第三个是输出格式,加上时间和运行时长;最后是滚动策略,这个配置保留10个文件,每个最大50MB。建议线上一定打开滚动,否则GC日志文件很快会把磁盘写满。
5.2 一行GC日志里关键的三个数字
以G1为例,真实日志里会出现类似这行的一串信息:
GC(12) Pause Young (Normal) (G1 Evacuation Pause) 2560M->1280M(4096M) 68.521ms拆开来看,Pause Young表示这是一次年轻代回收;2560M是回收前堆占用,1280M是回收后堆占用,4096M是堆总大小;68.521ms是这次回收的暂停时间。判断单次GC是否健康,不是只看回收了多少内存,更要看回收量与暂停时间的比例。如果一次回收只腾出几十MB却停顿大几十毫秒,说明内存里存活对象比例太高,回收效率很低。
传统分代器在JDK 8下的日志长这样:
[GC (Allocation Failure) [PSYoungGen: 102400K->25600K(307200K)] 409600K->307200K(1024000K), 0.0621230 secs] [Times: user=0.08 sys=0.01, real=0.06 secs]方括号里第一段是新生代回收前后容量,第二段是整堆回收前后容量和堆总容量,最后是耗时。Allocation Failure的意思是这次GC由堆中几乎无法为新对象分配触发,看到这个词不需要慌,它只是Minor GC的触发原因之一。
5.3 一眼看出异常的三类日志特征
长期看GC日志之后,我总结出最需要警惕的三个异常特征。
第一,CMS时代的“Concurrent Mode Failure”。这基本等于老年代在并发清理期间被打爆,被迫降级到Full GC,停顿一下拉高。出现这个关键词,说明预留空间不足或者并发速度跟不上去,通常是调参方向的报警。
第二,G1日志里频繁出现的“Humongous Allocation”。如果巨型对象分配过快,G1处理巨型对象的成本会很高,日志里挤满这类信息,基本等于代码里有大数组或者大集合在频繁创建。
第三,young GC频率极高但回收量极低。这代表分配速率非常高,或者Survivor区长期被打满。频繁GC会挤占业务线程时间,即使单次停顿不长,累计损失也会非常吓人。这种情况下先不要急着加堆,而是先看代码里是不是存在批量创建对象的循环或者不必要的集合复制。
6. 从现象到根因:老年代持续上涨的一次完整排查
理论堆了一大堆,不如完整走一遍排查链路。我们某个交易服务曾经出现过一次典型的老年代持续上涨问题,整个过程很适合用来理解GC排查的基本思路。
6.1 报警和初步表象
现象很清晰:服务上线运行一到两天后,老年代占用从20%慢慢爬到90%左右,Full GC次数开始增多,接口延迟出现明显尖刺。但奇怪的是,堆的总大小没有异常波动,dump下来的堆文件也不算大,不像常规意义上的内存泄漏。团队第一反应是加堆,把-Xmx从4G调到6G,结果是老年代照涨,Full GC间隔变长了一点,但并没有根治。
这时候大家意识到问题不在“不够大”,而在“对象生命周期太长”。于是把GC日志打开,让服务继续跑,同时记录每次Full GC前后的剩余空间。日志显示每次Full GC都能把老年代清掉一部分,但过一段时间又开始爬升,典型的“短期对象进入老年代”特征。
6.2 用jstat和heap信息缩小范围
公共工具里,jstat很适合看这种问题。命令很简单:
jstat -gcutil <pid> 1000 20它会每秒打印一次S0、S1、E、O、M等区域的占用比例,以及YGC次数、FGC次数和耗时。我们看到老年代O列持续上升,而S0、S1长期接近0,说明Survivor区几乎没起什么过滤作用,大量对象是直接从新生代冲到老年代的。
于是继续用jmap看堆直方图:
jmap -histo <pid>Top列表里出现了一个业务缓存的包装类,实例数和占用空间都很靠前。这就把方向带到了应用层的长生命周期对象上。
6.3 定位到应用层持有的长生命周期对象
顺着缓存对象找代码,发现是一个静态Map做缓存:把某张业务查询表的全量结果一次性加载到内存,Map被一个静态字段持有。这个Map本身不会消失,里面的量又随着测试数据增长,每上线一段时间就会把老年代塞满。对象不是“泄漏”,而是被业务长期持有,GC完全没有能力回收它。
过程链路其实不复杂:静态字段属于GC Roots的一部分,根持有Map,Map引用大量业务对象,这些对象再引用关联列表。对GC来说,它们全是活的,Roots扫到就会标记,绝不会回收。JVM怎么会把缓存对象清掉呢?根本不可能。
6.4 修复后的验证和沉淀的习惯
修复方案是改成分页加载,缓存只保留必要维度,并对缓存容量做上限控制。上线后老年代稳定在40%左右,Full GC几乎不再出现,接口尖刺消失。
这次排查让我沉淀下来两个习惯:第一,看到老年代持续上涨,不要第一时间调堆大小,先分清新对象是不是因为存活时间过长进入老年代,还是被某个静态根长期引用;第二,jmap直方图的Top类未必是“真正的元凶”,但它能快速暴露持有关系长得异常的业务对象,是缩小范围的好起点。排查GC问题,本质上是在排查“谁在引用什么”。
7. 实战参数调优:一组能抄的配置和三个大坑
参数调优是大家在GC话题里最感兴趣的部分,也是最容易翻车的部分。先给目标再给参数,是我一直坚持的原则。
7.1 先定目标再写参数
如果是延迟敏感业务,目标是减少单次停顿,让接口不被GC尖刺打断;如果是后台计算任务,目标是提高整体吞吐,允许偶尔一次较长停顿;如果是容器资源紧张的小服务,目标则是降低内存和CPU开销。目标不同,很可能连选哪个收集器都不同。同一个参数组,换一个业务场景可能就是灾难。我在生产环境里最常用的是G1组合,下面这组配置适合JDK 11、堆4G左右的在线服务:
-Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:G1NewSizePercent=5 -XX:G1MaxNewSizePercent=60 -XX:G1HeapRegionSize=8m -XX:MaxTenuringThreshold=6 -XX:+ParallelRefProcEnabled -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -Xlog:gc*:file=/logs/gc.log:time,uptime:filecount=5,filesize=20M7.2 熟悉参数含义的快速说明
逐个解释:-Xms和-Xmx设为一样,避免运行期堆扩容和收缩带来的额外开销与心理波动。MaxGCPauseMillis=100是给G1的停顿目标,注意它只是一个软性目标,不代表每次都能卡在100毫秒内。G1NewSizePercent和G1MaxNewSizePercent控制新生代Region在整堆中的初始和最大占比。G1HeapRegionSize=8m与堆大小匹配,Region太大太小都不合适,一般堆在几个GB到十来个GB时,2M到16M区间比较常见。MaxTenuringThreshold=6相比默认15,控制了对象在Survivor区里停留的年龄上限,这对高分配速率服务有帮助,但需要通过日志验证。ParallelRefProcEnabled让引用处理阶段支持多线程,对对象引用较多的服务有益。Metaspace参数限制类元数据空间,防止极端情况下无限增长。
代码不是死的,真正要抄的是这一行行参数背后的思路。
7.3 盲调翻车率最高的三个问题
第一个大坑:把MaxGCPauseMillis调得特别低,比如10毫秒。G1为了迎合这个目标,会加大每次回收的Region数量,或者提前更频繁地启动回收,结果GC次数变多,总停顿时间不降反升。目标参数不是越低越好,它只表达“倾向”,不会改变硬性物理约束。
第二个大坑:-Xms和-Xmx不相等。JVM运行时如果堆空间不足会先扩容,空闲后又收缩,对应的就是新生代尺寸震荡、老年代占比波动,GC日志里会出现一系列额外的回收事件。你以为是GC的锅,其实是堆容量在来回跳。
第三个大坑:只调堆大小,不关心分配速率和晋升速率。分配速率很高的时候,加大堆只是把问题往后推,对象该死还是死,不该死还是不死。更合理的做法是先压测量化分配速率,再看是否需要减少冗余对象创建,最后才调大小和收集器。参数是放大器,不是定海神针。
8. 容易翻车的几个GC关联细节
最后一节,我把这些年踩过的、或者说被别人反复问到的小问题集中说一下。它们不直接写在收集器原理里,但在生产环境经常冒出来,而且一冒出来就是事故。
8.1 System.gc()和它的隐式调用者
显式调用System.gc()在绝大多数场景下都不是好主意。它只是建议JVM执行Full GC,但具体做不做、什么时候做,由虚拟机决定。更麻烦的是,有些框架隐藏着System.gc()调用,比如RMI的分布式GC、NIO的堆外内存处理这类场景,某些配置下会周期性触发Full GC。如果线上发现莫名其妙的Full GC,而堆占用并不高,可以先考虑加参数禁用显式GC调用:
-XX:+DisableExplicitGC当然这是在确认没有合理调用者之后才做的事,别上来就禁用。
8.2 弱引用软引用到底怎么用
弱引用、软引用和虚引用是很多文章讲引用类型时必提的内容,但工程里容易被用歪。软引用适合实现缓存:内存够的时候不回收,内存紧张时还能给你留一线生机。弱引用适合做“临时关联”又不希望影响对象回收的场景。最常见的问题有两个:一个是把弱引用对象本身长时间保存在静态Map里,导致其引用的对象迟迟不清理;另一个是只知叠加引用类型,却不懂引用对象的清理动作本身也需要观察GC的引用处理阶段。引用类型不是银弹,它只是给GC多一个可选的“死亡判决书”而已。
8.3 显式引用清空和分区批量处理
总有同学在代码里手动把对象置为null,想“帮GC早点回收”。对于局部变量来说,这个方法基本没有意义,因为局部变量离开作用域后引用就会消失。真正需要手动清理的是长生命周期对象里持有的短生命周期引用,比如一个常驻Map持有了只在一段时间内使用的批量对象。这时候及时移除、分批处理,才是在帮GC的忙。千万别把置空当成仪式,GC不是靠仪式感工作的。
回到开头那个问题:为什么明明没有内存不足,服务还是会卡?答案往往就藏在这些“不常用但关键时刻捅一刀”的细节里。我现在的习惯是,不管服务规模大小,先把GC日志打开留存;线上碰到任何可疑停顿,第一件事不是猜,而是把日志拉出来对着现象看。垃圾收集不是玄学,背后每一环都有清晰的判断逻辑,你要做的事情,就是让你的代码和参数去匹配这套逻辑。希望这篇梳理能帮你少踩几个我踩过的坑。