news 2026/8/26 18:19:41

内存为什么越跑越高?从对象回收讲透内存泄漏的原因与预防

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
内存为什么越跑越高?从对象回收讲透内存泄漏的原因与预防

线上服务有一种问题很让人头疼:

刚启动时一切正常。

运行几个小时后,内存开始慢慢上涨。

再过一段时间,接口变慢、GC 变频繁,最后甚至直接 OOM。

很多时候,这背后就是一个很典型的问题:

内存泄漏。

不少人会把内存泄漏简单理解成:

“程序申请了内存,但是忘记释放。”

这个说法不能算错,但放在 Java 这类有垃圾回收机制的语言里,其实还不够准确。

更准确地说:

某些对象已经没有业务价值了,但程序中仍然存在引用指向它们,导致垃圾回收器认为这些对象还在使用,于是一直无法回收。

理解了这一点,很多内存泄漏问题其实就没那么神秘了。


一、先搞清楚:什么才算内存泄漏?

先看一个简单场景。

程序创建了一个对象:

User user = new User();

此时内存里大概是:

user ↓ User对象

当方法执行结束,并且再也没有地方引用这个对象时:

User对象

就变成了“没人认识的对象”。

垃圾回收器发现它不可达,就可以把这块内存回收掉。

正常流程就是:

创建对象 ↓ 使用对象 ↓ 失去引用 ↓ GC回收

但如果对象明明已经不用了,却仍然被某个地方引用着:

全局集合 ↓ User对象

垃圾回收器看到:

还有引用指向它,那它应该还有用。

于是不会回收。

如果这种对象越来越多:

对象1 对象2 对象3 对象4 …… 对象100000

内存就会一点点被吃掉。

这才是 Java 里最常见的内存泄漏。


二、垃圾回收器到底怎么判断一个对象能不能删?

很多人会以为:

没人用了就回收。

问题是,JVM 怎么知道“没人用了”?

核心思路叫:

可达性分析。

JVM 会从一批特殊的对象开始向下寻找。

这些对象叫:

GC Roots。

可以简单理解成:

GC Roots │ ├── 线程栈里的变量 ├── 静态变量 ├── JNI引用 └── JVM内部对象

然后顺着引用关系往下找:

GC Root ↓ 对象A ↓ 对象B ↓ 对象C

只要能够从 GC Root 一路找到某个对象,这个对象就被认为:

仍然存活。

反过来:

对象X → 对象Y

虽然 X 和 Y 互相引用,但如果从 GC Root 根本找不到它们:

GC Root 对象X ↔ 对象Y │ └──── 找不到

它们还是可以被 GC 回收。

所以 Java 内存泄漏真正的核心不是:

对象有没有引用。

而是:

对象是不是仍然可以从 GC Root 到达。


三、最常见的泄漏:集合只进不出

这大概是业务代码里最容易出现的一类问题。

比如为了做缓存,写了一个:

private static final Map<String, User> CACHE = new HashMap<>();

每次请求都放进去:

CACHE.put(userId, user);

但从来没有删除。

开始的时候:

CACHE ├── User1 ├── User2 └── User3

运行久了:

CACHE ├── User1 ├── User2 ├── User3 ├── User4 ├── User5 ├── ... └── User500000

关键在于:

static变量 ↓ CACHE ↓ User对象

静态变量本身可以长期存在。

所以这些 User 对象始终可以从 GC Root 找到。

GC 就算跑一百次,也不会回收它们。

这就是一个标准的内存泄漏。

这里特别容易出现一种误解:

“我都用了 GC 语言了,怎么还会泄漏?”

因为 GC 只能帮你回收:

真正不可达的对象。

它没办法理解你的业务逻辑。

它不知道:

这个用户的数据三小时前就没用了

它只知道:

还有引用,不能删。

四、缓存其实是内存泄漏的高发区

很多程序都有缓存:

数据库查询 ↓ 放入内存 ↓ 下次直接读取

本意是减少数据库压力。

但如果缓存只写:

cache.put(key, value);

从来不:

cache.remove(key);

也不设置:

最大容量 过期时间 淘汰策略

那缓存迟早会变成:

一个永远只进不出的仓库。

这就像你租了一个仓库。

每天往里面放 100 个箱子:

第一天:100 第二天:200 第三天:300 ……

但规定:

什么都不能扔。

那问题根本不是仓库够不够大。

而是:

迟早会满。

所以缓存系统一般都会有:

TTL LRU 最大容量 主动失效

这样的机制。

比如:

最多缓存10000条 ↓ 超过后淘汰最久没使用的数据

或者:

缓存30分钟 ↓ 超过30分钟自动删除

本质上都是为了:

主动切断无用对象的引用链。


五、监听器和回调,也特别容易偷偷泄漏

这种问题更加隐蔽。

假设有一个事件中心:

eventBus.register(listener);

某个页面或者对象创建时,把自己注册进去:

EventBus ↓ Listener ↓ 业务对象

后来这个业务对象已经不用了。

按理说应该被回收。

但是:

EventBus

还保存着它的 Listener。

于是引用链一直存在:

GC Root ↓ EventBus ↓ Listener ↓ 业务对象

结果:

整个对象都回收不了。

如果不断:

注册 注册 注册 注册

却从来没有:

unregister();

内存就会慢慢涨。

所以看到:

register subscribe addListener

这类代码时,最好顺便想一下:

对应的 unregister 在哪里? unsubscribe 在哪里? removeListener 在哪里?

有注册,就应该考虑解绑。


六、ThreadLocal 为什么也会造成内存泄漏?

ThreadLocal是 Java 中非常经典的一个坑。

比如:

ThreadLocal<UserContext> context = new ThreadLocal<>();

使用:

context.set(userContext);

正常情况下,使用完最好:

context.remove();

为什么?

因为在线程池里,线程不是请求结束就销毁。

可能是:

线程1 ↓ 处理请求A ↓ 处理请求B ↓ 处理请求C ↓ 一直活着

ThreadLocal 的数据实际上和线程存在关联。

如果请求结束以后没有清理:

Thread ↓ ThreadLocalMap ↓ Value

这个 Value 可能长期留在线程里。

普通线程如果很快结束,问题还不大。

但线程池里的线程可能:

活几小时 活几天 甚至跟服务一样久

这时候问题就来了。

所以使用 ThreadLocal 时,一个非常实用的写法是:

try { threadLocal.set(value); // 业务代码 } finally { threadLocal.remove(); }

重点就在:

finally

无论业务有没有异常,最后都清掉。


七、数据库连接、文件流不释放,算不算内存泄漏?

严格来说,这类问题更接近:

资源泄漏。

比如:

Connection connection = ... InputStream input = ... Socket socket = ...

如果用完以后不关闭:

数据库连接 文件句柄 Socket

就会一直占着系统资源。

最终可能出现:

连接池耗尽 Too many open files Socket资源不足

虽然它和普通 Java 堆内存泄漏不是完全一个概念,但在实际开发中经常一起讨论。

因为最终表现非常像:

资源一直占着不归还。

所以现在 Java 推荐:

try (InputStream input = ...) { // 使用 }

也就是:

try-with-resources。

它能保证资源在作用域结束后自动关闭。

比手动:

input.close();

更不容易漏。


八、为什么“对象很大”不一定是内存泄漏?

这个区别也很重要。

假设程序一次加载:

2GB文件

然后内存瞬间涨到:

3GB

这不一定是泄漏。

如果处理完以后:

引用消失 ↓ GC执行 ↓ 内存被回收

那只是:

正常的大内存使用。

真正的内存泄漏通常更像:

500MB ↓ 800MB ↓ 1.2GB ↓ 1.8GB ↓ 2.5GB ↓ OOM

而且即使经过 Full GC:

内存还是下不来

这时候才非常值得怀疑:

是不是有什么对象一直被引用着?


九、还有一种情况:不是泄漏,而是“对象生产太快”

假设程序每秒创建:

100万个对象

GC 虽然一直在回收:

创建 ↓ 回收 ↓ 创建 ↓ 回收

但如果:

对象产生速度 > GC回收速度

内存同样会不断上涨。

最终也可能 OOM。

这种情况不是典型泄漏,而是:

内存分配压力过大。

所以看到:

OutOfMemoryError

不能直接下结论:

一定有内存泄漏

还得继续看:

到底是对象不能回收 还是对象产生速度太快

十、线上怎么判断是不是内存泄漏?

一个很常见的判断思路是:

先看 GC 之后的内存。

比如:

第一次 Full GC 后:500MB 第二次 Full GC 后:700MB 第三次 Full GC 后:1GB 第四次 Full GC 后:1.5GB

如果每次垃圾回收以后:

存活对象还是越来越多。

那就比较可疑了。

因为这意味着:

GC明明努力回收了 ↓ 但仍然有大量对象不能删除

下一步一般就会分析:

Heap Dump

看看:

到底什么对象最多? 是谁引用了它? 为什么一直无法回收?

最后最重要的往往不是:

哪个对象占了2GB

而是:

谁把它拽住了?

也就是寻找引用链:

GC Root ↓ 对象A ↓ 对象B ↓ 巨大集合 ↓ 几十万个对象

找到这条链,泄漏原因通常就快出来了。


十一、内存泄漏怎么预防?

比起线上 OOM 后再排查,平时写代码时提前防范成本低得多。

首先是各种集合。

看到:

static Map static List static Set

最好问一句:

这里的数据什么时候删除?

如果答案是:

不知道

那就值得警惕。

对于缓存:

一定考虑容量限制 一定考虑过期时间 一定考虑淘汰机制

对于监听器:

注册 ↓ 记得注销

对于 ThreadLocal:

set ↓ try ↓ finally remove

对于连接和流:

申请资源 ↓ 使用 ↓ 及时关闭

说到底,预防内存泄漏有一个很简单的原则:

任何生命周期比较长的对象,只要它开始保存其他对象的引用,就要考虑这些引用什么时候解除。


十二、一个很实用的判断方法

以后看到一段代码:

container.add(obj);

不要只看:

add

最好顺手问三个问题:

谁持有 container? container 能活多久? obj 什么时候 remove?

如果答案是:

container是static ↓ 跟程序活得一样久 ↓ obj从不删除

那基本已经闻到内存泄漏的味道了。


写在最后

内存泄漏其实没有想象中那么玄学。

对于 Java 这类带 GC 的语言来说,它最核心的逻辑就是:

一个已经没用的对象,因为仍然存在从 GC Root 到它的引用链,所以垃圾回收器无法判断它已经“没用”。

整个问题可以压缩成:

业务上已经不用 ↓ 引用却还存在 ↓ 对象仍然可达 ↓ GC无法回收 ↓ 对象越来越多 ↓ 内存持续上涨 ↓ 最终OOM

所以真正需要关注的,从来不只是:

创建了多少对象

而是:

这些对象用完以后 还有谁在引用它们?

缓存没有淘汰、监听器没有解绑、ThreadLocal 没清理、静态集合无限增长,本质上其实都是同一件事:

对象该走的时候,没有人把那根引用线剪断。

想清楚“对象为什么还活着”,基本就抓住了内存泄漏问题的核心。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/26 18:17:13

frostmourne 部署(一)

本机用docker搭建elk环境并接入frostmourne&#xff0c;实现监控报警效果虽然logstash 和filebeat都具有日志收集功能&#xff0c;但是filebeat更轻量&#xff0c;占用资源更少&#xff0c;而不同的是logstash 具有filter功能&#xff0c;能过滤分析日志&#xff0c;所以一般都…

作者头像 李华
网站建设 2026/8/26 18:12:58

C 语言文件 IO 学习Day03:从基础函数到实战踩坑(BMP 读取 + 词典查询)

本文为 C 语言软件编程学习 Day03 笔记&#xff0c;系统梳理标准 IO 库函数、核心易混点对比&#xff0c;并结合「BMP 图片宽高读取」「英汉词典查询」两个实战项目&#xff0c;总结实操中高频踩坑点 文章目录 前言一、文件 IO 核心函数全解 1. fgetc&#xff1a;读取单个字符2…

作者头像 李华
网站建设 2026/8/26 18:09:57

Python进阶教程:Git版本控制与团队协作

目录Python进阶教程&#xff1a;Git版本控制与团队协作一、Git 是什么二、基本配置与初始化三、核心操作&#xff1a;add / commit四、回退与撤销五、分支管理六、远程仓库协作七、团队协作工作流7.1 常用工作流7.2 标准流程八、解决冲突九、.gitignore 与规范提交十、实战&…

作者头像 李华
网站建设 2026/8/26 18:08:22

配对样本t检验结果解读:前后测量差异的显著性判断

配对t检验结果解读一、分析方法概述配对样本t检验&#xff08;Paired-Samples t Test&#xff09;用于比较同一组受试者在两个不同时间点或两种不同条件下的均值差异&#xff0c;是重复测量设计中最常用的统计方法。该检验要求配对差值近似服从正态分布&#xff0c;通过计算每对…

作者头像 李华
网站建设 2026/8/26 17:56:50

java静态类使用@Value动态绑定环境变量

java不能在静态类&#xff08;即 static 字段&#xff09;上直接使用Value 绑定环境变量。直接在一个 static 字段上使用 Value 是不会生效的&#xff0c;该字段会始终为 null 或默认值。原因分析Spring 容器在启动时&#xff0c;会为每个由它管理的 Bean&#xff08;非静态类&…

作者头像 李华
网站建设 2026/8/26 17:55:30

从SEO到GEO:重塑内容流量逻辑

&#x1f50d; 1. 流量入口变天&#xff1a;从“点击蓝链”到“直达答案”在过去的互联网时代&#xff0c;内容创作者与数字营销人员早已习惯了一套固定的流量运作模式&#xff1a;寻找低竞争关键词、撰写长文、优化元标题与元描述、建立反向链接&#xff0c;然后静静等待搜索引…

作者头像 李华