1. 内存偷偷涨了三个月:一次真实的线上事故复盘
先说一件我实际碰到过的事情,它比任何教科书定义都直观。
去年下半年我接手了一个给内部业务团队用的Java服务,功能不复杂,就是定时拉取上游数据、做清洗、再写入数据库。部署在4C8G的容器上,JVM堆给了4GB。刚上线那阵子一切正常,接口响应、GC频率都在合理范围。结果运营了两周之后,监控图上出现了一条非常典型的曲线:内存使用量像个台阶一样,每过一段时间就往上跳一档,跳完不回落,慢慢地从1.5GB一路爬到3.8GB。到了某个晚上,GC时间突然从原来的几十毫秒飙到一次停顿好几秒,紧接着就OOM了,容器被自动重启,服务不可用。
最坑的是,你以为重启完就没事了,但系统自动恢复之后,内存又开始从头爬台阶。运维同事一开始判断是流量变大,于是加了配置,扩容到16GB,结果只是把OOM的时间从一周拉长到了一个月。后来我们才确认,这就是典型的内存泄漏——你写的代码在持续持有本应该被回收的对象,GC永远扫不掉它们,堆内存被一点一点吃干。
这类问题的隐蔽性在于:服务质量不会瞬间崩坏,业务功能通常也正常,只是系统像得了慢性病一样越来越慢。很多团队是在内存涨到临界值、频繁触发Full GC之后才意识到不对劲,但那时候已经晚了。
这篇文章我打算用实战的视角,把内存泄漏从原理到排查再到防范完整讲一遍。不管你是写Java、Go、C++还是JavaScript,只要你的程序长时间在服务器上运行,这些内容都值得收藏。适合的人群包括:正在排查"服务器内存占用过大"问题的同学、想在代码审查阶段提前发现隐患的团队,以及所有不想大半夜被OOM告警吵醒的后端开发。
2. 内存泄漏的本质:对象还在,但没人真正需要它
2.1 从GC的视角看"对象是怎么被回收的"
要理解内存泄漏,先得理解垃圾回收器眼中的世界。
以JVM为例,GC判定一个对象是否可以回收,核心依据是可达性分析。什么叫"可达"?就是从GC Roots出发,沿着对象引用链能走到的对象。GC Roots包括:当前正在执行的栈帧里的局部变量、静态变量引用的对象、JNI引用、活跃线程等。只要从这些根能找到某个对象,GC就认为它"活着",不会回收它;反之,就标记为垃圾,择机回收。
用生活化的类比来说,GC是个保洁员,GC Roots是"登记在册的住户"。保洁员只清理"查无此人"的东西——从住户那里问过去,没人认识、也没人指向的东西才会被扔进垃圾箱。任何一个物体,只要某个住户还能说出"这是我家的",保洁员就永远不会碰它。
所以内存泄漏的判定标准就出来了:如果一个对象已经永远不会再被业务代码使用,但GC Roots到它之间的引用链始终存在,那么它就是个泄漏对象。它占着内存,但没有任何业务逻辑会再来访问它,相当于住着空房却一直锁着门,保洁员永远进不去。
2.2 "泄漏"在不同语言里的三种形态
可能你会想:Java有GC、Go有GC,怎么还会泄漏?这不是GC没干活,而是GC根本没办法判断"你以后还用不用这个对象"。GC只看引用是否存在,不看引用是否"有意义"。所以在有GC的语言里,引用关系管理混乱是泄漏的唯一根源。
在C/C++这类手动管理内存的语言里,泄漏更直白——用malloc、new分配的内存,没有对应的free、delete。GC语言是"保洁员"帮你扫,手动管理是"没保洁员",垃圾只能自己倒,不倒就堆着。
还有一种形态在Go里容易遇到,叫goroutine泄漏。goroutine本身占用的栈内存通常不大,但如果泄漏的goroutine数量以万为单位增长,栈内存累积起来同样惊人。而且对于GC语言来说,如果goroutine阻塞在一个永远不会结束的等待里,它被GC判定为"可达"——因为它在运行栈里,栈上的对象就跟着一起无法回收。
2.3 一个反直觉的事实:内存泄漏不一定让内存无限增长
很多人在排查时有个误区:认为内存泄漏必然导致内存使用率一路冲到100%。实际上,很多泄漏是"涨到某个上限后维持稳定"。
原因是堆内存是被各种分区结构共同占用的。如果泄漏对象以固定速率进入一个无界增长的结构,堆会持续上升直到OOM;但如果你的代码里内置了一个"超出容量就淘汰旧数据"的缓存,那么泄漏可能表现为缓存命中率越来越低、GC频率越来越高,但内存曲线始终平稳。这种"假平稳"比直线上升更迷惑人,因为资源表面上稳定,性能却在悄悄劣化。
举个实际场景:某个定时任务每次执行都往一个List里追加一批数据,同时做了一次"把最早的1000条删除"的操作。内存看起来不涨,但删除操作本身是O(n)的,随着List里过期数据越来越多,单次任务耗时越来越长,CPU飙升但内存平稳——这也是泄漏,只是它先体现在CPU上。
3. 最容易写出泄漏的四处代码:定时器、缓存、监听器与连接
我在排查过的几十个泄漏案例里统计过,绝大多数问题出在下面这四类代码模式上。每个模式我都会给出一眼能看懂的示例和修复思路。
3.1 全局缓存与静态集合:往里放容易,往外删很难
典型代码:
public class UserCache { private static final Map<String, User> CACHE = new HashMap<>(); public static void cacheUser(String sessionId, User user) { CACHE.put(sessionId, user); } }这个例子极简,但线上代码里真实存在。每次用户登录,就往这个静态Map里塞一条数据,session过期时却忘记移除。只要系统在不断新增用户,这个Map就会无限膨胀。静态变量是GC Roots的入口之一,Map本身可达,Map里所有的User对象也都可达,于是这些本应在会话结束时废弃的对象一直存活。
修复方案很明确:给缓存设置容量上限和过期策略。Java里可以考虑ConcurrentHashMap配合remove、或者引入类似Caffeine的本地缓存库;简单场景也可以给Map加Landlord逻辑,比如"数量超过1万就清掉最旧的20%"。
但这里我想多说一句:不是所有缓存都应该被清理。有些缓存是合理的,比如配置信息、字典数据,生命周期和进程一样长。关键是要区分"业务上需要保留的数据"和"因为忘记清理而残留的数据"。问自己一个问题:如果现在进程重启,这个Map里的数据丢了,业务会不会出错?如果不会,它就不该长存。
3.2 事件监听器与回调:注册了十次,只注销了一次
典型代码(前端场景):
function bindUserEvents(userId) { const handler = () => { fetch('/api/user/' + userId).then(/* ... */); }; window.addEventListener('click', handler); }每当渲染一个用户卡片就调用一次bindUserEvents,每次调用都往window上挂一个新的handler,但组件卸载时没有移除监听器。于是用户界面每操作一次,就多一个永远不会触发的监听器挂在全局对象上,携带的外部引用(比如整个卡片对象)也跟着无法被回收。
后端的对应版本是用某些长生命周期的对象(比如Spring的ApplicationListener手动注册、Netty的ChannelPipeline里重复添加Handler),注册后没有按对称操作注销。
修复原则其实很简单:注册和注销必须成对出现。前端用useEffect的清理函数、或者removeEventListener;后端在必要的生命周期回调里做清理。写代码时就把"谁创建、谁负责销毁"想明白。
3.3 定时任务与异步线程:永远在跑,永远带着旧引用
这类泄漏特别隐蔽,因为定时器本身看起来一直在正常干活。
看这个Node.js场景:
function startCollectingMetrics() { setInterval(() => { const rawData = globalDataBuffer.popAll(); const processed = process(rawData); reportToServer(processed); }, 1000); }如果process或reportToServer是异步的,而globalDataBuffer的写入方一直往里面推数据,那么即使处理逻辑有问题导致popAll取不出来,定时器也会一直运行。这个定时器本身是活跃线程,活跃线程的栈和它引用的对象都属于GC Roots可达范围,永远不会被回收。
这个问题的可怕之处在于:定时器代码本身没写错,但它在持有某些旧对象的引用。比如setInterval的回调里不小心捕获了某个环境变量,或者某个回调数组里的元素被重复添加。
排查时要特别留意的特征:起一个任务、建一个协程/线程/定时器,但缺少统一的销毁机制。比如"每次收到消息创建一个goroutine去处理"这种代码,如果goroutine内部因为某个异常长期阻塞,goroutine数量就会持续增加。
3.4 IO连接与流资源:打开不会报错,关闭有时会报错
连接泄漏稍微特殊一点——它不一定表现为堆内存暴涨,更多表现为文件描述符耗尽、数据库连接池用满。
public List<Data> fetchFromFile(String path) throws IOException { FileInputStream in = new FileInputStream(path); byte[] buf = new byte[1024]; in.read(buf); // 忘了调用 in.close() return parse(buf); }Java 7之后可以用try-with-resources规避这个问题,但很多老代码、或者C/C++里仍然容易犯。C++的RAII(资源获取即初始化)机制能解决一部分,但如果你用了裸指针配合new和delete,漏掉一个分支的delete就有泄漏。
这类资源的内存在操作系统中通常是受控的,不会被杀掉整个进程。但套接字、文件句柄这些对象在JVM里也对应对象,如果数量上去了,同样会推高堆内存。
4. 完整排查链路:从监控告警到堆转储分析的每一环
排查内存泄漏的方法论比具体工具更重要,先捋清楚顺序,再按步骤操作。
4.1 第1步:先确认是不是"真泄漏"
收到内存告警后,不要急着dump,先回答三个问题:
- 内存是持续增长,还是涨到一个临界点后回落(像锯齿一样)?持续增长才更像泄漏。
- Full GC之后内存能不能显著回收?如果回收后内存仍然很高,说明有东西"囤"在堆里。
- 重启之后,经过同样时间是否恢复到同样高的水位?如果是,说明存在与业务请求量正相关的增长路径。
快速验证的手段之一是对比GC前后堆的使用量。在命令行下用JDK自带的jstat就能看:
jstat -gcutil <pid> 1000重点看FGC(Full GC次数)和FGCT(Full GC累计时间),以及O(老年代使用率)。如果Full GC执行得很频繁,但老年代使用率始终很高没有明显回退,基本可以判定堆里存在大量无法回收的对象。
4.2 第2步:抓堆转储,但抓的时机有讲究
确认可疑后,就需要把堆"拍个X光片"。常用命令:
# 方式一:jmap手动触发 jmap -dump:live,format=b,file=heap.bin <pid> # 方式二:通过VisualVM/Arthas导出 # 方式三:在启动参数里配置发生OOM时自动转储 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dumps说一个很多人踩过的坑:在内存还没涨到高位时dump,大概率什么都看不出来。泄漏是渐进的过程,如果内存刚起步、泄漏对象数量还少,堆转储里最大的对象往往是正常的业务对象。我一般是等到老年代使用率超过80%但仍然稳定运行(还没有开始大量OOM)的时候抓dump,这时候泄漏对象的比例最高,分析起来最有效。
另外,jmap -dump:live会先触发一次Full GC,不推荐在线上高峰期直接用,可能会造成较长停顿。我更倾向于在低峰期或者通过诊断接口触发。
4.3 第3步:用MAT找"支配树"里最肥的对象
拿到heap.bin之后,我用得最多的是Eclipse MAT。流程不复杂:
- 打开dump文件,MAT会自动解析,生成Leak Suspects报告。
- 看"Leak Suspects",重点是一个叫"Problem Suspect 1"的片段,通常是泄漏嫌疑最大的对象。
- 点进嫌疑对象,查看它的
Dominator Tree(支配树)——它能告诉你这个对象被谁引用、引用了谁。
一个典型的MAT分析结果会这样描述:
"The class java.util.HashMap is loaded by the system classloader, and it occupies 2.1GB of memory. The object is referenced by com.example.UserCache.CACHE."
看到这个基本就能锁定了。接着打开支配树,确认是HashMap里的哪些key在累积。比如看到全是SessionId,那就去代码里搜"谁在往CACHE里put",基本就是泄漏点。
4.4 第4步:反向验证
定位到可疑代码后,不要急着下结论,先做一轮"反向验证"。改代码之前的确认动作:
- 在本地或者测试环境复现同样的请求序列,对比修改前后的堆增长曲线。
- 写一段最小化复现程序,只执行嫌疑路径,看内存是否增长。
- 务检查数据库连接、外部调用等资源是否也出现对应上升,排除误判。
我见过不止一次"改对了方向但改错了地方"的情况——比如泄漏的根因是某个拦截器在每个请求上创建了线程池,但是排查者盯着代码里的全局缓存分析了好几天。所以反向验证的核心目的不是确认"怎么修",而是确认"泄漏的源头是不是这里"。
4.5 补充:线上不可用重命令时,先看GC日志
如果线上环境安全要求高,不允许直接执行jmap这类命令,还有一种"轻量级"的排查方式:把GC日志打开,分析日志里的规律。
启动参数里加上:
-Xlog:gc*:file=/var/log/gc.log:time,level,tags:filecount=5,filesize=10m然后观察每次Full GC前后的堆变化。一个健康的堆,Full GC之后老年代使用率应该回落到低位;泄漏情况下,Full GC之后的回落幅度会越来越小,最终基本不动。
GC日志还有一个好处:它不受进程级权限限制,很多容器环境里应用日志目录是可以读的,不需要额外授权。我习惯在每个Java服务里默认就打开GC日志,成本极低,出事时至少有个起点。
5. 前端 JavaScript 也会泄漏:浏览器的"堆肥"危机
聊完服务端,必须单独开一章说前端。很多人觉得JS是脚本语言、有浏览器管着,不可能有内存泄漏,这个认知大错特错。热搜词里"前端 内存泄漏怎么排查"能排到前列,说明这个问题普遍到已经成了前端面试常客。
5.1 浏览器的内存回收机制与Node.js的差异
浏览器里的JS引擎(V8等)也是基于可达性分析做垃圾回收的。理论上它和Java的GC思路类似,区别在于浏览器的页面生命周期更短,而且用户经常刷新页面。这带来两个后果:第一,单个页面内泄漏的问题往往要等到用户长时间停留在同一个页面才暴露;第二,单页应用(SPA)兴起后,页面不会因为跳转而整体刷新,组件挂载、卸载越来越频繁,对应的事件监听器、DOM引用、全局变量也跟着被频繁创建和销毁,泄漏问题变得比传统多页应用严重得多。
Node.js另外还有一层:服务端进程不退出,任何泄漏都是持续的,而且V8的堆、Libuv的句柄、底层Socket连接都可能成为泄漏载体。我在Node.js服务上排查过的泄漏,有三分之一不是JS变量引用导致的,而是底层socket没有关闭,句柄数持续增长。
5.2 前端最经典的三个泄漏场景
场景一:DOM引用残留在JS对象里
const elements = []; function createWidget() { const div = document.createElement('div'); div.innerHTML = '...'; const widget = { element: div, config: { /* ... */ } }; elements.push(div); document.body.appendChild(div); // 即使页面关闭了这个组件,elements数组里仍然持有div引用 }浏览器Engine的GC认为div是"可达"的(被elements数组引用),于是整个DOM树节点、关联的事件处理器都无法回收。这种问题在SPA里尤其常见——用户在页面之间切换,widget不断创建,DOM节点不断堆积。
场景二:全局变量污染
window.userList = window.userList || []; function loadUserData() { const data = fetch('/api/users').then(r => r.json()); window.userList = window.userList.concat(data); }每次请求都往全局数组里追加数据,没有任何上限。
场景三:闭包意外捕获了大对象
let cachedTable = null; function renderTable() { const dataSource = fetch('/api/big-table').then(r => r.json()); cachedTable = new Table(dataSource); // 这个闭包被加到了全局事件上 window.addEventListener('resize', () => { cachedTable.resize(); }); }每次调用renderTable都会创建新的数据源和Table实例,并给resize事件塞一个新的闭包。旧Table实例被新闭包覆盖,理论上应该被回收,但老的resize回调仍然引用着旧Table,导致旧实例永远无法释放。
5.3 用DevTools Memory工具定位前端泄漏
Chrome DevTools的Memory面板是排查前端泄漏的主要武器。三个步骤:
- 打开页面后,在Memory面板里选择"Allocation instrumentation on timeline",录制一段时间。
- 重复执行"创建组件→销毁组件"操作,比如打开弹窗再关闭、进入详情页再返回列表页。
- 点Profiles里的"Comparison"模式,对比录制前后哪些对象的数量没有减少。特别关注Detached DOM nodes——这个类别是DOM泄漏的直接证据。
操作细节上,录制时尽量保持操作模式一致,比如"打开弹窗→关闭弹窗"循环10次,然后看内存曲线是不是每完成一轮就上涨一截,而不是来回波动。如果曲线像台阶,基本就能判断存在泄漏,接着点进Detached DOM nodes看是哪个组件导致的。
5.4 Node.js服务端的排查要点
Node.js服务端排查时我首选用--inspect参数启动,然后在Chrome DevTools里做内存快照,流程和浏览器端类似。辅助用process.memoryUsage()打点日志,关注heapUsed的趋势。
有一个Node.js特有的坑:闭包泄漏在异步代码里特别容易发生。比如长时间运行的setInterval回调里引用了每次请求创建的参数对象,请求结束但对象被定时器持有。检查方法是用heapdump生成堆快照,在MAT或DevTools里搜索请求期间出现的业务对象,看它们是否还挂在定时器回调的闭包链上。
6. 数据库连接与外部资源占用:不只是"内存泄漏"的问题
热搜词里有两类热门问题:"k3 wise服务器为什么sql服务占用很多的内存"和"服务器数据库占用内存过大怎么办"。这两类现象不完全等于代码内存泄漏,但和它高度关联,值得单独拿出来说清楚。
6.1 数据库或外部组件占用内存过高的根源分析
MySQL、SQL Server这类数据库本身对内存的占用策略和普通应用不同。数据库通常倾向于把尽可能多的数据页缓存在内存里,以加快查询速度,所以"数据库服务占用了很多内存"在大多数情况下是正常的设计行为,不是泄漏。比如SQL Server的动态内存管理机制就会尽量多用可用内存做Buffer Pool,然后在操作系统的内存压力下来时再释放一部分。
但"正常"不等于"无需管理"。如果同一台机器上既跑数据库又跑应用服务,数据库的"贪婪缓存"就会挤压应用进程的可用内存,造成整机内存吃紧。这在云上小规格实例里特别常见——你买的是4G内存,数据库自己缓存了2.5G,应用只剩1.5G,当然会频繁OOM。
6.2 应用代码导致数据库内存上涨的隐蔽链路
数据库内存上涨还有一个经常被忽略的原因——应用端的连接泄漏和慢查询。当应用进程没有正确归还数据库连接时,连接池会被占满,数据库被迫为每个连接分配排序缓冲、临时表空间等资源;如果应用循环里写着大量重复查询,数据库的查询缓存和排序区也会不断膨胀。
排查思路已经从"看数据库进程内存"转变成"看应用侧的连接池使用曲线"。如果连接总数直线上升,先检查应用里所有的getConnection()是不是都有对应的close(),或者在Spring这类框架下有没有把事务边界声明清楚。不用框架手写JDBC的老代码,这是重灾区。
6.3 如何为数据库类进程设置合理的内存上限
对于可以直接配置内存上限的数据库(如MySQL的innodb_buffer_pool_size、SQL Server的max server memory),建议在部署时就按机器规格规划好:
- 8G内存实例,给数据库预留4-5G,应用预留2-3G,剩余留给操作系统。
- 宁可让数据库少缓存一点,也要保证应用不OOM。数据库慢一点通常可容忍,应用挂了就是事故。
- 定期查看数据库的"等待事件"或"性能计数器",确认Buffer Pool命中率等指标是否在可接受范围内。如果关小缓存后命中率急剧下降,说明查询本身可能需要优化,问题根源不在内存设置上。
这类问题本质上不是"内存泄漏",但最终表现是"服务器内存被吃掉"。排查时先分层:操作系统的内存是不是被某个进程集中占用?这个进程是应用还是数据库?如果是应用,走前面的堆转储流程;如果是数据库,先看配置,再看连接数,最后再看SQL。
7. 从流程上阻止内存泄漏进入生产环境
排查和修复都是亡羊补牢,真正高效的做法是在开发流程里加一道"安检",让大多数泄漏在上线前就暴露。这几年我把下面这几条固化成团队规范,效果非常明显——线上OOM告警率降了一个量级。
7.1 Code Review阶段的"内存泄漏检查清单"
代码评审时不用面面俱到,重点看几个"高危区":
- 所有静态/全局集合:往里面放的东西有没有清理时机和容量上限?
- 所有事件监听器:注册之后有没有对称的注销代码?
- 所有定时器/线程池:生命周期和业务对象一致吗?关闭时能完整终止吗?
- 所有IO资源:是否用了try-with-resources或RAII模式封装?
- 缓存相关代码:缓存有没有过期策略?如果进程重启数据丢了会有问题吗?
这个清单不复杂,但执行过一次就能让团队的代码质量提升不少。我在自己的PR模板里把这几条做成了必填的检查项,让提交者自己先确认一遍,评审者再重点过一遍。
7.2 用"重复操作+低峰期压测"暴露早期增长
内存泄漏的触发往往绑定在某个特定业务操作上。没有现成的压测工具时,可以用脚本不断重复触发可疑操作,观察内存曲线是否持续增长。
我常用的方法是写一个简单的压测脚本,循环调用目标接口几百上千次,监控进程的RSS和堆使用。比如:
for i in $(seq 1 5000); do curl -s -X POST http://service/api/create-session >/dev/null # 每100次打印一次内存情况 if (( i % 100 == 0 )); then jstat -gcutil <pid> 1 1 | awk '{print strftime("%H:%M:%S"), $0}' fi done观察输出里老年代使用率的变化趋势,如果呈稳定上升而没有"堆积到高点后不再增加"的平台期,就要重点排查。
需要注意"重复操作"的针对性。敲黑板:你要压测的是"会创建和销毁临时对象"的业务路径,而不是单纯的读操作。读操作通常不产生长期残留,写操作、状态操作、上传下载这类"每次请求都有独立上下文"的操作更值得关注。
7.3 线上监控的黄金指标与告警阈值
我没有把告警阈值设得很死,因为每个服务的基线不同。建议建立两层监控:
第一层是基础层,指标包括:老年代使用率、Full GC次数与耗时、Eden区晋升速率。阈值用"相对趋势"而不是"绝对数字"——比如老年代使用率在连续N次采样中单调递增且没有回退,就触发预警。
第二层是资源层,指标包括:进程RSS、容器内存使用量、文件描述符数量、goroutine/线程数。文件描述符数以万为单位增长是连接泄漏的强信号,线程数持续增长是线程池泄漏的强信号。
7.4 防止"修完没验证":回归测试的设计
修改完泄漏代码后,除了重新运行上面说的压测脚本,再加一道"回归对比":在同一个环境下,先后运行修复前和修复后的版本,各自压测10分钟,对比内存增长曲线。如果修复生效,后者的斜率应该显著低于前者,甚至完全持平。
这个方法比单纯看"有没有OOM"更敏感。我遇到过一种情况:修复代码后OOM确实不再出现,但内存曲线依然整体向上,只是速度变慢了。这说明修复不完整,还残留了另一个泄漏点。回归对比相当于给修复结果做了"量化体检"。
内存泄漏的修复很难做到"一锤定音",它更像是持续的过程。我个人的习惯是每解决一个线上泄漏,就把这一轮的排查链路整理成文档,沉淀进团队的故障案例库。这不是为了写报告,而是为了让下一轮排查可以少走弯路——毕竟下次遇到类似问题时,很可能换了人、换了代码、换了语言,但排查思路是相通的。