内存溢出是当场崩溃,内存泄漏是慢性失血;溢出立即抛OutOfMemoryError并中断线程,泄漏则无报错但老年代内存持续缓慢上升直至最终溢出。

内存溢出(OutOfMemoryError)是“当场崩溃”,内存泄漏(Memory Leak)是“慢性失血”
内存溢出是你申请 10MB,JVM 告诉你:“堆只剩 2MB,不给,java.lang.OutOfMemoryError: Java heap space”。它立刻抛异常、中断线程、可能整服务挂掉。而内存泄漏根本不会报错——对象明明没用了,却还被 static Map、未注销的监听器、未关闭的 InputStream 死死拽着,GC 回收不了,内存占用一天天涨,直到某次 Full GC 后仍无法腾出空间,才触发溢出。
怎么一眼区分是泄漏还是溢出?看日志和增长趋势
OOM 错误日志里一定带明确的 OutOfMemoryError 和具体区域(如 Metaspace、Direct buffer memory),且往往发生在某个操作之后(比如导出大报表、批量上传)。而内存泄漏的典型信号是:jstat -gc 显示老年代(OU)使用率持续缓慢上升,每次 GC 后回收量越来越少;jmap -histo 发现某个类实例数暴涨(比如 com.example.UserCacheEntry 十万+);或者 VisualVM 中堆内存曲线呈阶梯式爬升,中间没有明显回落。
- 溢出常伴随单次大动作:如
new byte[200 * 1024 * 1024]直接申请 200MB - 泄漏常藏在“合理逻辑”里:比如 Spring Bean 中用
static ConcurrentHashMap缓存用户会话,但忘了加过期或清理机制 - 别信“重启后好了”——如果一周后又 OOM,八成是泄漏在悄悄堆积
定位泄漏最有效的三步实操
别一上来就啃 MAT(Memory Analyzer Tool)堆转储文件。先做轻量级排查:
- 用
jconsole或VisualVM连上运行中的进程,打开“Classes”页签,按实例数排序,重点关注你项目里的类(不是java.*或sun.*) - 执行一次手动 GC(VisualVM 的“Perform GC”按钮),再观察老年代(Old Gen)内存是否回落——如果不降,说明有对象被强引用卡住,大概率是泄漏点
- 抓一个堆转储:
jmap -dump:format=b,file=heap.hprof,用 MAT 打开后点 “Leak Suspects Report”,它会自动标出疑似泄漏链,重点看 “Path to GC Roots” 是否包含不该存在的静态引用或线程局部变量
常见坑:MAT 默认只显示“强引用”路径,但有时泄漏由 ThreadLocal 引起——得手动切换到 “Weak References” 或 “Soft References” 查看;还有些框架(如某些 RPC 客户端)会把回调注册进全局监听器池,不显式 remove 就永远不释放。
立即学习“Java免费学习笔记(深入)”;
JVM 参数和代码习惯才是防泄漏的真正防线
调大 -Xmx 只是把 OOM 推迟,治标不治本。真正管用的是从编码和配置双管齐下:
- 集合类优先用带过期策略的:
Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(10, TimeUnit.MINUTES),而不是裸写static Map - 所有实现了
AutoCloseable的资源(InputStream、Connection、Statement)必须用try-with-resources,哪怕只是临时读个配置文件 - 监听器注册后,务必在生命周期结束时反向注销,比如 Activity 的
onDestroy()、Spring Bean 的@PreDestroy方法里调用eventBus.unregister(this) - 避免在 ThreadLocal 中存大对象,用完必须
remove()——尤其 Web 容器中线程复用,不 remove 会导致前一个请求的数据污染下一个
最易被忽略的一点:第三方 SDK 的内存行为。比如旧版 FastJSON 在反序列化时若开启 Feature.SupportNonPublicField,可能因反射缓存导致元空间泄漏;又比如某些日志框架的 MDC(Mapped Diagnostic Context)在线程池中未清理,会随线程复用越积越多。上线前务必跑一轮压测 + 内存监控,别等凌晨报警才翻文档。










