同事前几天找我,说他们有个Java服务上线后跑了两三周,占用内存一路走高,最后不得不每周定时重启一次,领导问起来也说不清楚到底是哪里漏了。其实这类问题我遇到太多次了,十次里有八次都不是代码里写了什么"泄漏代码",而是某个集合、缓存、线程或者框架内部悄悄把对象引用攥着不放。很多人一听到"内存泄漏"就只想到调大堆内存,或者干脆换一台更大内存的机器,这方向从一开始就偏了。
这篇我把Java内存管理从底层布局讲到线上排查,重点落在内存泄漏的几类典型模式上,每一条都会给出可复现的排查思路和修复方向。适合刚接触JVM调优的人,也适合那些被线上内存问题反复折磨、想系统梳理一遍的开发者。看完之后你起码能回答三个问题:对象到底活在哪个区域、为什么会被回收不掉、用哪些工具能在半小时内定位到罪魁祸首。
1. 运行时数据区的内部分工:对象存活的位置决定回收策略
很多人对Java内存的第一印象是"堆和栈",但真正写代码时,你的对象可能存在于堆、线程栈、元空间甚至直接内存,不同区域的回收机制完全不同。理解这些区域的分工,是判断"这里到底会不会泄漏"的前提。
1.1 堆内分代模型:新生代与老年代设计背后的逻辑
堆是最核心的区域,但堆并不是一整块大平地。JVM把堆分成新生代和老年代,新生代又按8比1比1分成Eden区和两个Survivor区。为什么要这么折腾?因为绝大多数对象的生命周期极短。一个典型的业务请求会产生几十个临时对象,请求结束之后它们就该被回收了,如果所有对象都堆在大区里做全量回收,每次都得扫描整个堆,Stop The World的停顿时间完全不可接受。
对象先进入Eden区,Minor GC触发时,Eden里还有引用的对象会被移动到Survivor区(也叫S0),并把年龄加1。存活对象在两个Survivor区之间来回拷贝,每经历一次Minor GC年龄加一,超过阈值(默认15)后被晋升到老年代。这套设计的精妙之处在于,新生代回收只花极短时间,就能清理掉大批短命对象;老年代里存的都是熬过多轮GC的长寿对象,Full GC频率明显更低。如果你用jstat观察到老年代持续增长、一直不减,那就是GC回收不了它,大概率存在泄漏或者对象规模涨上去了。
很多规范里把年轻人的堆配置为"-Xms2g -Xmx2g",让初始堆和最大堆相等,这是为了避免堆大小动态伸缩带来的抖动,但要注意,这只影响堆的物理内存分配计划,不会改变分代内部的回收逻辑。排查泄漏时,第一件事就是看老年代占用曲线,而不是一上来就抓堆转储。
1.2 栈与堆的边界:局部变量到底何时释放
方法内创建的局部对象,引用放在栈帧的局部变量表里,对象实体放在堆里。方法执行结束,栈帧弹出,局部变量表中的引用随之失效,堆里的对象失去强引用,变成可回收对象。很多人以为"方法结束了对象就释放了",严格说应该是"方法结束,引用失效,对象等GC来回收"。
有一个容易忽略的细节:局部变量的作用域其实比方法块更细。比如一个循环里反复创建集合对象,循环块结束,作用域内的引用理论上失效,但JVM不一定会立刻清空局部变量表里的引用,变量槽位可能依旧指向堆对象。这就是为什么你用一些保守式GC策略的JVM时,明明方法已经退出,对象却迟迟不被回收。现代HotSpot用的是精确式GC,能准确遍历活跃引用,但如果内存压力不大,GC不触发也是很正常的,这不算泄漏,顶多算回收延迟。
栈上还有个东西叫栈帧,每个线程都有独立的栈,深度用完就会抛StackOverflowError。这跟内存泄漏没直接关系,但你排查线程类泄漏时,栈数量增长往往伴随着栈内存持续占用,很多监控平台看到的内存上涨其实是线程数增加导致的,不是堆的问题。后面讲排查工具时我会专门提到这个坑。
1.3 元空间与直接内存:不受堆限制的"隐形内存"
JDK 8开始,永久代被移除,类元数据被放进了元空间(Metaspace)。元空间使用本地内存,默认上限是系统可用内存。如果你用CGLIB频繁生成代理类、反射加载大量Class或者用了某些热部署框架,元空间可能持续增长直到撑爆本地内存。我们用jmap或者jstat看堆内存正常,但进程总体占用居高不下时,优先怀疑元空间和直接内存。
直接内存(Direct Memory)是NIO用ByteBuffer.allocateDirect分配的内存块,绕过了堆,直接使用操作系统内存。它的回收依赖Cleaner机制,由GC的Reference线程触发,一旦ByteBuffer对象泄漏,Cleaner也可能不被触发,直接内存就会被无限消耗。Netty这类框架大量使用直接内存池,版本过低时存在内存池泄漏的经典问题,表现就是堆正常、CPU不高,但操作系统内存逐步升高,最后进程被OOM Killer干掉。判断技巧很简单:看RSS内存(实际常驻物理内存)远大于-Xmx设置的值,基本就能锁定堆外内存。
2. 从标记清除到并发回收:GC算法演进与"不该回收却回收不掉"的分水岭
理解GC算法,不要把它当成背诵考试知识点,它是定位泄漏的重要背景。你知道了不同垃圾收集器在发生什么,才能判断"老年代对象不减少"是正常还是异常。
2.1 三色标记与漏标问题:理论如何逼出增量更新和SATB
GC线程标记存活对象时,把对象分为白、灰、黑三色。白色表示未访问、黑色表示已被标记且引用已完成扫描、灰色表示自身存活但引用还没扫描完。理论干净利落,但现实是,并发标记过程中业务线程还在改引用关系,可能把一个白色对象挂在了一个已经扫描过的黑色对象上,这个白色对象就会被漏标,本来存活却被当成垃圾回收掉。这比泄漏更可怕,直接导致程序崩溃。
CMS用增量更新(Incremental Update)处理这个问题:黑色对象一旦被重新引用白色对象,就把黑色对象标记为灰色,重新扫描。G1则用SATB(Snapshot At The Beginning)方案,记录并发标记开始时点的引用快照,标记过程中引用发生变化时把旧引用记录下来,等标记结束再处理。这个差异在低延迟场景下很重要,SATB减少了重新扫描的开销,但引入了记忆集(Remembered Set)维护成本。你不需要背出每个算法的细节,但当你听到"G1老年代回收后RSet占用高,内存不降"这种描述时,至少知道它跟跨代引用追踪有关。
2.2 G1、ZGC与分代模型:为什么JDK 17默认换成G1后还要开ZGC
G1把堆划分成大小一致的Region块,逻辑上依然分代,但新生代和老年代是Region的动态集合。ZGC更进一步,追求几乎零停机的回收,它用染色指针、读屏障实现并发操作。JDK 17虽然默认GC还是G1,但低延迟服务选择ZGC的越来越多。
你的服务到底用哪个GC,取决于停顿目标。如果业务要求GC停顿不超过10毫秒,G1可能达不到,ZGC更合适;如果追求吞吐量,G1或者Parallel GC更稳。这里有个经验之谈:不要因为线上偶尔出现一次长停顿就仓促换GC,先看GC日志和堆情况,确认停顿的根因和频率再动配置。换GC不是调优,是换赛道,涉及全套参数重调。
2.3 GC日志里的关键信号:看懂晋升失败与并发模式失败
排查内存泄漏时,GC日志就是第一手证据。开启GC日志的方式各不相同版本,但HotSpot较新的通用做法是:
java -Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20m -jar app.jar重点看两类现象。第一类是"Promotion failed",晋升失败,新生代还有不少存活对象要晋升,但老年代剩余空间不够,触发一次Full GC。第二类是"Concurrent Mode Failure",CMS/G1并发回收还没完成,老年代空间就用完了,不得不退化为Serial Old式的全停顿回收。这两类情况说明老年代被占满了,至于占满的是泄漏对象还是合理的大对象,需要继续往下查。
看GC日志时还要注意回收趋势:如果每次Full GC后老年代只下降一点点,过一会儿又涨回去,这是典型的回收能力不足;如果Full GC后老年代大幅降下来,但后面继续稳步上升,这种形态更像持续有对象累积,指向某块长期缓存增长。
3. 内存泄漏的经典模式:代码里那些"合理写法"为何会变成泄漏源头
这一节是全文核心,我按高频程度给你梳理七类常见泄漏,并标注高危场景、问题本质和修复方向。
3.1 静态集合持有增长型数据:最常见也最容易被忽略
几乎每个项目都有类似这样的代码:
public class DataCache { private static final Map<String, SessionInfo> SESSION_MAP = new HashMap<>(); public static void putSession(String token, SessionInfo info) { SESSION_MAP.put(token, info); } }静态Map的生命周期和ClassLoader一致,JVM不卸载类,这个Map就会一直存在。如果不断put但缺少对应的remove,或者key一直变化,Map就会无限膨胀。Session状态、用户临时数据、消息回调记录放进了静态集合,是最常见的泄漏。谁写的都觉得自己"之后会清理",但清理逻辑往往只在理想路径上被执行,异常分支一旦跳过清理代码,数据就留在里面了。
解决思路不是"下次记得删除",而是从设计上限制:用带过期机制的缓存(后文会讲)、绑定请求生命周期、将状态存到分布式存储而不是本地静态集合。如果必须用静态集合,要给集合设上限,并且把put操作封装起来,强制走统一管理入口。
3.2 容器类里的监听器与回调:你删了行方,对象却被组件续命
向事件总线、消息队列或者一个全局管理器注册了监听器,这类代码隐藏很深:
messageBus.subscribe(userId, event -> handleUserLogout(userId));订阅后,bus内部持有订阅者引用的强引用。如果用户退出登录后没有调用unsubscribe,消息总线就永远引用着这个回调对象,回调又引用着用户上下文对象,整个对象图都无法回收。这不是你"忘记写代码"这么简单,而是框架文档里经常不强调的细节:很多Java框架的内存泄漏都发生在监听器和回调注册上,对象本身已经无用,却还挂在某个长生命周期组件上。
排查时可以这样想:什么对象的生命周期比你的业务对象更长?静态集合、Spring单例Bean、全局线程池、消息总线、ClassLoader都是。业务对象被它们间接引用,就等于被绑死。
3.3 缓存设计不当:没有过期策略的缓存是慢性毒药
缓存是不该背"泄漏"这个锅的,但设计不合理的缓存确实会造成无限增长的实占。比如用ConcurrentHashMap做缓存,只往里面加数据,没有淘汰策略,数据量随业务增长一直上涨,这从语义上叫"未及时清理",从内存形态上等价于泄漏。
推荐用支持过期和淘汰的现成组件:Caffeine是目前的推荐选项,底层做了W-TinyLFU淘汰,可以控制缓存大小:
Cache<String, Data> cache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .build();如果你还在用自研HashMap缓存,而且没有定时清理和上限控制,建议尽早替换。自研缓存的坑不止泄漏,还有并发问题,用一个成熟的库省心得多。
3.4 ThreadLocal使用不当:线程池场景下的隐性引用链
ThreadLocal是业务中常用工具,但它和线程池在一起时要格外小心。ThreadLocalMap是Thread对象的一个字段,Entry以ThreadLocal为key,value存在Entry内部。在线程池中,线程是复用且长时间存活的,如果线程执行完任务后没有调用ThreadLocal.remove(),那么value就会一直存在于线程的ThreadLocalMap中。
经典事故场景:一个请求处理线程里把用户上下文set进ThreadLocal,任务处理完没remove;线程被线程池复用跑下一个请求,上一个请求的用户对象还挂在当前线程上。这既可能导致数据串号,也是一条长期引用链。修复手段很死板:用完立即remove,放在finally里执行。自己写的代码好管,担心的是框架内部使用ThreadLocal,这时可以用告警和堆转储去发现。
3.5 类加载器泄漏:热部署场景里的"僵尸Class"
动态加载jar、频繁构建Agent、用了某些热部署方案,最容易出现的就是ClassLoader泄漏。每次创建新的ClassLoader加载类,类元数据进入元空间,如果老ClassLoader无法被回收,那它加载过的所有Class对象、静态变量、常量池信息全都会留在内存里。
判断方法:持续观察Metaspace占用,如果每次热更后Metaspace都上一个台阶,基本可以确定某个自定义ClassLoader被外部引用了。常见的引用来源包括:静态集合保存了该ClassLoader加载出来的对象、线程的ContextClassLoader没有切换回系统类加载器、JDBC驱动被初始化后线程持有引用。修复方向是规范热部署流程,确认ClassLoader之间的引用关系,用工具检查谁引用了这个ClassLoader对象。
3.6 资源未关闭:数据库连接、IO流与Socket的"半关闭"状态
这个比较基础,但我还是放在这里。JDBC连接、InputStream、Socket、Channel,凡是实现了java.io.Closeable的,用完就应该关闭。用不用try-with-resources,Java 7之后差不多等于送分题:
try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { // ... }资源不关闭和"泄漏"的关系很直接:连接池里的连接对象是池化复用的,用而不还,池子不断创建新连接,底层网络句柄和缓冲区持续累积,堆和堆外内存都会涨。这一问题最常见的原因是异常路径没处理,比如代码里try-catch之后忘记在finally里关闭。用try-with-resources从语法层面规避是最简单的方案。
3.7 字符串截取与包装对象:老JDK特殊场景下的"残留引用"
再补一个历史遗留问题。JDK 6及更早版本中,String.substring不会复制字符数组,子串与原字符串共享底层的char[],只要子串存活,整个原字符串数组就无法释放。如果你截取了一个超长字符串的一小段并长期持有,等于把超大数组留在内存里。JDK 7u6以后substring会复制内容,这个坑基本被填平了,但如果你还在维护非常老的版本,碰到内存占用异常时要想到它。
4. 线上定位内存泄漏的工具链:jmap、MAT与JFR的组合用法
知道原理,也要会实操。很多人用得最多的是jmap -dump一把梭,但dump文件动辄几个G,分析半天还找不到根因,主要问题是执行时机和方法都不对。这一节按实操顺序讲清楚。
4.1 先量化再转储:用jstat和jcmd观察趋势
不要一上来就dump,先看数据。jstat可以观察堆区使用情况:
jstat -gcutil <pid> 1000 10每秒输出一次,看Eden、Survivor、Old和Metaspace的占用比例,以及YGC、FGC次数和耗时。如果Old区占比持续上升,FGC触发后下降有限,基本可以判定老年代存在无法回收的对象。配合观察GC日志里的回收趋势曲线,基本能形成"疑似泄漏"的判断。这个阶段不要急着开dump,把证据收集齐了再动手。如果FGC之后Old区已经清理得差不多了,那问题可能是堆配置过小,先调大堆试试,这种场景一上来就dump纯属浪费时间。
JVM附带的新工具jcmd也很够用,jcmd <pid> GC.heap_info看各个区域的当前使用量和容量,jcmd <pid> VM.native_memory看堆外内存分布,需要开启Native Memory Tracking,对定位直接内存泄漏和元空间问题特别有用。
4.2 jmap堆转储的正确姿势:轻量级GC触发与导出顺序
确认需要dump后,先触发一次年轻代和Full GC,让可回收对象清干净,再打堆转储,这样能把"回收延迟"和"真正泄漏"区分开,不会拿一堆垃圾对象干扰分析:
# 开启GC日志后执行 jcmd <pid> GC.run # 等待日志显示Full GC完成 jmap -dump:live,format=b,file=/tmp/heap_dump.hprof <pid>live参数会先触发Full GC再来dump,导出的是存活对象。注意,生产环境执行这个操作会对服务造成停顿,建议在低峰期操作,或者使用jattach等工具的异步dump方案。这里有个经验细节:dump文件越大,分析难度越高,所以导出前尽量做一次GC,让dump里的有效信息密度更高。如果进程内存已经很高、GC运行极慢,用jmap的force参数:jmap -F -dump:format=b,file=heap.hprof <pid>,但这只适用于进程没有响应的情况。
4.3 MAT分析核心:Dominator Tree、Leak Suspects与GC Roots
拿到dump后打开Eclipse MAT。三个关键视图:Histogram(对象直方图)、Dominator Tree(支配树)、Leak Suspects Report(泄漏嫌疑报告)。Leak Suspects Report是最适合新手的入口,MAT会按照对象图和保留堆大小自动列出排名靠前的嫌疑点。
重点看两个指标的区别:Shallow Heap是对象自身占用,Retained Heap是对象被回收后可以释放的总内存。排序时用按保留堆大小排序的Dominator Tree,找到哪些大对象把一整套对象图踩在下面,然后右键"Path to GC Roots"排除弱引用和软引用,看它到底被谁强引用着。到这里,静态Map持有者、长生命周期组件持有者基本会浮出水面。
MAT还能查重复字符串和无用的对象数组,排查实际业务场景时,多利用这几个功能。需要注意的是,MAT本身的内存要分配足够,建议调整MemoryAnalyzer.ini的-Xmx,不然大dump文件打开就OOM了。
4.4 JFR与Arthas:低频头发掘和运行时对象分布
JDK 11起,JFR已经内置,性能开销通常控制在1%以内。录制一段时间内的内存分配、GC事件、线程活动,可以不用暂停服务就找到高频分配点。命令如下:
jcmd <pid> JFR.start name=leak profile=true settings=profile jcmd <pid> JFR.dump name=leak filename=/tmp/leak.jfr jcmd <pid> JFR.stop name=leak用JFR分析内存分配热点,搭配jmap的dump分析累积对象,是线上排查的标准打法。Arthas也有一类使用场景:dashboard观察内存区域,memory命令查看堆和非堆数据,还能用thread定位线程状态。但它更适合在线观察,不能替代JFR的全局录制能力。
4.5 常见排查误区的提醒:公式化方法见不到效果
有几次同事请我帮忙看泄漏,拿出的dump文件里明明有大量线程对象,但看堆内存并不高,整体占用却飙得很高。这类情况很可能不是堆泄漏,而是线程数量爆发。每个线程默认栈内存1MB,1000个线程就是约1GB的额外内存。排查时除了堆指标,还要盯线程数和RSS。同理,NIO直接内存、GC后仍未归还给操作系统的内存、JIT编译产物,都可能造成"堆正常但机器内存告警"的假象。
排查工具给到的结论都要结合业务代码验证。比如MAT报出某个大对象在静态Map里,你要去代码里看这个Map何时写入、何时删除、是否只有单向写入。工具负责缩小范围,最终确认还得靠读代码。
5. 一次模拟系统的泄漏排查复盘:从现象到根因的完整链路
前面理论讲了一堆,这里用一个真实感较强的案例把排查流程串起来。虽然场景里的系统是模拟项目,但排查链路和真实线上事件完全一致。
5.1 现象:老年代持续上涨、Full GC频率攀升到告警线
某模拟订单处理系统上线第四周,监控出现两条告警:老年代占用超过80%,Full GC每两分钟一次,单次耗时涨到3秒以上。服务响应时延跟着劣化,从平均60毫秒升到接近500毫秒。查看GC日志,每次Full GC后老年代从80%掉到30%,但半小时内又涨回80%,呈明显锯齿形上升,说明回收没能把留存对象清零,始终有对象在被不断"补货"进来。
5.2 先看GC日志排除配置问题,再确认进程线程数
先确认堆配置:-Xmx4g,新生代1.5g,理论上足够日常流量。线程数用jstack数了一下,稳定在200左右,排除线程泄漏的干扰。元空间占用也平稳,排除类加载器泄漏。把怀疑集中到堆内老年代,jmap触发Full GC后导出dump,事件发生在低峰期,服务受影响很小。
5.3 MAT展示真凶:一个静态Map持有所有历史订单快照
打开MAT,Leak Suspects Report直接提示一个suspect:一个静态ConcurrentHashMap很大,Retained Heap约2.8GB,占了整个堆的三分之二还多。点开这条suspect,路径是:
OrderSnapshotCache └── static Map<String, Object> └── OrderSnapshot对象Map的key是订单号,value是订单快照对象。快照对象内部维护了用户信息、商品快照、优惠明细、物流轨迹,整套对象图非常大。代码检查发现,这个Map在订单状态变更时写入快照,但只在订单"人工归档操作"时才会remove,而系统中大量订单会走自动完成流程,自动完成路径上压根没有归档逻辑,remove永远等不到执行。这正好解释了为什么Map持续增长:功能上看起来"数据多一点无所谓",内存上完全不可控。
5.4 修复方案:设计上不让它无限长,代码上补上清理时机
修复分两步走。第一步,给这个静态Map加上容量上限:用Caffeine替换原始ConcurrentHashMap,设置maximumSize为当前订单量的一个合理上限,并配置expireAfterWrite,比如72小时无访问自动过期,保证即使存在清理漏网,缓存也不会无限膨胀。第二步,梳理订单自动完成路径,在状态流转的出口统一触发快照移除逻辑,确保归档和数据清理不是靠某个分支自觉完成,而是从流程上强制覆盖。
修改上线后,观察一周内的老年代曲线:Full GC频率逐渐回落到一天两三次,老年代稳定在35%左右,响应时延回到正常范围。这里还有个细节:Caffeine的TinyLFU淘汰会不会导致偶尔缓存失效后重新计算结果?业务上快照是可以容忍短暂重建的,所以这个取舍合理。如果你做的是不可容忍丢失的会话数据,就不要用过期策略,而要考虑把数据挪到外部存储。
5.5 这类排查的通用套路总结
整个链路可以抽象成四步:先看GC日志和heap趋势判断"是不是回收不掉",再看堆内外分布排除"内存去哪了",然后dump加MAT定位"被谁引用着",最后对照代码逻辑确认"为什么没清理"。这套顺序不要反过来。先dump再分析的坏处是,dump文件巨大、干扰对象多,可能在Mat里翻了几个小时还找不到重点。先观察趋势和数据分布,能把问题域从"整个JVM"缩小到"某一个区域"甚至"某一类对象",效率完全不同。
写在最后:预防内存泄漏的经验清单
排查过太多次内存问题之后,我的一个明确感受是:内存泄漏问题的发现往往是以事故形式出现的,所以预防比排查更重要。这里写几条可以落地到日常开发流程里的经验,每一条都来自实际踩坑。
代码审查时,凡是长生命周期的对象,都要过一遍"有没有可能跟某个静态集合、单例或者全局组件关联上"这个思维模型。静态集合里禁止无上限增长,必须配套清理策略。使用线程池时,ThreadLocal一定要在finally里remove。自研缓存尽早替换为Caffeine这类成熟方案。资源类对象一律用try-with-resources写,不给异常路径留机会。上线前,压测阶段观察老年代趋势和Full GC频率,别等到上线后靠人工补丁续命。
另一个值得形成习惯的动作:给所有Java服务默认开启GC日志和JFR,配置合理的日志轮转策略。运行时信息爆发之前没人能预测,事后去补这些记录,成本要高昂得多。排查工具链里,jmap和MAT是解决问题的重武器,JFR和jstat是日常体检的常规项目,熟悉它们各自的适用场景,内存问题就不会是玄学。
最后再分享一个小技巧:对于线上"内存悄悄涨"的问题,不需要每次都等到报警才被动响应。你可以写一个小脚本,定期记录jstat的Old区占用和Full GC次数,哪怕只是每天的定时采集,连续观测几天就能画出趋势线。下次领导再问"这个服务为什么越来越慢",你甩出趋势图就能说明问题,而不是说"大家都觉得好像慢了"。内存问题的确定性远高于网络波动或偶发慢SQL,它是可以被量化、被追踪、被根除的。