ARTICLE DETAIL

资讯详情

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

内存泄漏排查实战:从JVM堆到浏览器DOM的全面指南

内存泄漏排查实战:从JVM堆到浏览器DOM的全面指南 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 MapString, 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 ListData 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重点看FGCFull GC次数和FGCTFull GC累计时间以及O老年代使用率。如果Full GC执行得很频繁但老年代使用率始终很高没有明显回退基本可以判定堆里存在大量无法回收的对象。4.2 第2步抓堆转储但抓的时机有讲究确认可疑后就需要把堆拍个X光片。常用命令# 方式一jmap手动触发 jmap -dump:live,formatb,fileheap.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:filecount5,filesize10m然后观察每次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确实不再出现但内存曲线依然整体向上只是速度变慢了。这说明修复不完整还残留了另一个泄漏点。回归对比相当于给修复结果做了量化体检。内存泄漏的修复很难做到一锤定音它更像是持续的过程。我个人的习惯是每解决一个线上泄漏就把这一轮的排查链路整理成文档沉淀进团队的故障案例库。这不是为了写报告而是为了让下一轮排查可以少走弯路——毕竟下次遇到类似问题时很可能换了人、换了代码、换了语言但排查思路是相通的。
返回列表