程序为什么会发生内存泄漏:从现象、原理到排查方法
程序刚启动时只占用几百兆内存,运行几天后却增长到数个GB;重启程序后内存恢复正常,但过一段时间又开始上涨——这通常会让人怀疑程序发生了内存泄漏。
不过,“内存占用增加”并不等于“内存泄漏”。缓存、内存池、垃圾回收策略和操作系统的统计方式,都可能造成内存持续占用的表象。
要判断程序是否真的存在内存泄漏,首先需要理解程序的内存究竟去了哪里。
一、什么是内存泄漏
内存泄漏是指程序申请了一块内存,使用完毕后却没有释放,并且以后也无法再有效利用这块内存。
如果这种情况不断发生,程序占用的内存就会逐渐增加,最终可能导致:
- 程序响应速度下降;
- 系统频繁进行内存回收;
- 开始大量使用交换空间;
- 进程被系统强制终止;
- 同一台机器上的其他程序受到影响;
- 服务出现崩溃或无法处理新请求。
一次泄漏几十字节看似无关紧要,但如果它位于每秒执行数千次的请求路径上,长时间运行后就可能消耗大量内存。
假设每个请求泄漏1KB,一秒处理1000个请求,那么程序每秒泄漏约1MB,一天就可能泄漏八十多GB。
因此,判断泄漏是否严重,不能只看单次泄漏量,还要结合发生频率和程序运行时间。
二、程序中的内存从哪里来
一个进程的内存通常可以分为多个区域。
1. 代码区
用于存放程序编译后的机器指令,通常是只读的,在程序运行期间基本不会发生明显变化。
2. 全局数据区
用于保存全局变量、静态变量以及部分常量。它们的生命周期通常与整个进程相同。
3. 栈
用于保存函数参数、局部变量和返回地址。函数调用时创建栈帧,函数返回后栈帧自动回收。
栈内存通常不需要程序员手动释放,但递归层次过深或局部变量过大可能造成栈溢出。
4. 堆
用于保存运行期间动态创建的对象。堆内存的生命周期不受单个函数调用限制,也是内存泄漏最常出现的地方。
C和C++通常需要程序员主动释放堆内存;Java、Go、Python等语言通常由垃圾回收器管理对象。
5. 内存映射区域
程序可以将文件、共享库或设备映射到虚拟地址空间。这部分内存如果使用不当,同样可能长期占用系统资源。
三、没有垃圾回收的语言为什么会泄漏
在C语言中,可以使用malloc申请内存:
void process_data(void) {
char *buffer = malloc(1024);
if (buffer == NULL) {
return;
}
/* 使用buffer处理数据 */
free(buffer);
}
如果忘记调用free,这块内存在函数结束后不会自动释放:
void process_data(void) {
char *buffer = malloc(1024);
if (buffer == NULL) {
return;
}
/* 函数结束前没有释放buffer */
}
局部变量buffer会随着函数返回而消失,但它指向的堆内存仍然存在。由于程序已经失去了这块内存的地址,之后也无法再释放它。
更隐蔽的情况是提前返回:
void process_data(void) {
char *buffer = malloc(1024);
if (buffer == NULL) {
return;
}
if (check_failed()) {
return;
}
free(buffer);
}
正常流程会执行free,异常分支却会直接返回,造成内存泄漏。
因此,手动管理内存时,不仅要检查正常路径,还要检查错误处理、异常退出和中断流程。
四、有垃圾回收为什么仍然会泄漏
Java、Python和Go等语言能够自动回收对象,但这并不代表它们不会发生内存泄漏。
垃圾回收器通常只能回收“程序已经无法访问的对象”。如果对象仍然被某个变量引用,即使程序以后不会再使用它,垃圾回收器也无法确定它是否无用。
例如,程序将每次请求的数据保存到一个全局集合中:
public class RequestStore {
private static final List<RequestData> DATA = new ArrayList<>();
public static void save(RequestData requestData) {
DATA.add(requestData);
}
}
只要程序不断调用save,集合就会持续增长。集合仍然持有所有对象的引用,所以垃圾回收器不能释放这些对象。
这类问题本质上不是“对象无法回收”,而是“程序错误地保留了对象”。
常见原因包括:
- 没有容量限制的缓存;
- 不断增长的静态集合;
- 未移除的事件监听器;
- 未取消的定时任务;
- 生命周期过长的回调函数;
- 线程局部变量没有清理;
- 类加载器被意外引用;
- 消息队列消费速度长期低于生产速度。
五、缓存增长不等于内存泄漏
为了提高性能,程序经常把计算结果或查询结果缓存在内存中。缓存占用内存是正常现象,关键在于它是否受到控制。
一个不限制大小的缓存,本质上可能演变成内存泄漏:
cache = {}
def calculate(key):
if key not in cache:
cache[key] = expensive_calculation(key)
return cache[key]
如果key的可能取值非常多,cache就会不断增长。
更加合理的缓存通常应当具备以下机制:
- 最大条目数量;
- 最大内存容量;
- 数据过期时间;
- 最近最少使用淘汰策略;
- 主动清理机制;
- 缓存命中率与容量监控。
缓存和内存泄漏的区别在于:缓存中的数据理论上仍然具有使用价值,并且可以按照明确策略被淘汰;泄漏的数据已经没有实际用途,却仍然占用内存。
如果一个缓存没有容量上限,也没有淘汰策略,那么从系统稳定性的角度看,它与内存泄漏并没有太大区别。
六、内存没有下降不一定是泄漏
程序释放对象后,任务管理器或top命令中显示的内存占用不一定立即下降。
这是因为内存释放可能发生在多个层次:
业务对象
↓
语言运行时
↓
内存分配器
↓
操作系统
对象被垃圾回收器释放,只代表这块空间能够被当前进程再次使用,并不代表它已经立刻归还给操作系统。
许多内存分配器会保留已经申请的内存,以便程序下次申请时直接复用,从而减少系统调用开销。
因此可能出现这种情况:
- 活跃对象已经明显减少;
- 堆内可用空间增加;
- 进程显示的常驻内存仍然很高。
这种现象不一定是泄漏。真正需要关注的是,在相同负载下,经过多轮完整垃圾回收后,仍然存活的对象数量和占用空间是否持续增长。
七、文件和连接也可能发生“泄漏”
程序泄漏的不一定是普通内存,也可能是操作系统资源,例如:
- 文件描述符;
- 数据库连接;
- 网络连接;
- 线程;
- 锁;
- 图形对象;
- 设备句柄。
例如,打开文件后没有关闭:
def read_data(filename):
file = open(filename, "r")
return file.read()
更安全的写法是使用上下文管理器:
def read_data(filename):
with open(filename, "r") as file:
return file.read()
数据库连接也需要可靠释放:
try (Connection connection = dataSource.getConnection()) {
// 执行数据库操作
}
资源泄漏不一定立刻体现为内存暴涨。它可能表现为“无法打开更多文件”“连接池耗尽”或“无法创建新线程”。
因此,排查长期运行服务时,不能只观察内存,还要关注文件描述符、连接数和线程数。
八、循环引用一定会导致泄漏吗
循环引用是指多个对象相互引用:
对象A引用对象B
对象B引用对象A
现代垃圾回收器通常能够判断这组对象是否仍能从根对象访问。如果整个循环已经无法从程序入口访问,那么即使对象相互引用,也可能被正常回收。
真正的问题是循环中的某个对象仍然被长生命周期对象引用:
全局对象
↓
对象A
↓
对象B
↓
对象A
此时整组对象仍然可达,垃圾回收器不能释放它们。
因此,“存在循环引用”和“发生内存泄漏”并不是同一件事。判断能否回收的核心通常是对象是否仍然可达。
九、如何确认程序是否存在内存泄漏
判断内存泄漏不能只观察某一个时间点,而应关注一段时间内的变化趋势。
1. 建立稳定的测试条件
首先尽量保持请求数量、数据规模和业务流程一致。如果负载本身不断增加,内存上涨可能只是正常结果。
2. 观察内存变化曲线
正常程序的内存可能呈现锯齿形:
申请内存 → 占用上升 → 垃圾回收 → 占用下降
发生泄漏时,每轮回收后的最低点可能持续升高。即使垃圾回收能够释放部分对象,长期存活的数据仍会越来越多。
3. 区分不同内存指标
常见指标包括:
- 虚拟内存:进程拥有的虚拟地址空间;
- 常驻内存:当前实际驻留在物理内存中的部分;
- 堆已使用空间:运行时堆中被对象占用的空间;
- 堆已提交空间:运行时已经向系统申请的堆空间;
- 堆外内存:不由普通对象堆管理的内存。
只观察虚拟内存往往没有意义,因为程序可能保留了很大的地址范围,却没有真正占用等量物理内存。
4. 比较多个时间点的内存快照
分别在程序启动、稳定运行和内存明显上涨时保存快照,再比较:
- 哪类对象数量增长最快;
- 哪类对象占用空间最大;
- 哪个对象持有了它们;
- 引用链最终连接到哪个长生命周期对象。
如果大量无用对象最终都被同一个静态集合、缓存或监听器持有,通常就找到了主要原因。
十、不同语言中的常用排查思路
C和C++
可以使用内存检测工具检查:
- 已申请但未释放的内存;
- 越界读写;
- 释放后继续访问;
- 重复释放;
- 未初始化内存访问。
编译阶段也可以启用地址检测功能:
gcc -fsanitize=address -g main.c -o main
运行程序后,检测器能够在许多错误发生的位置输出调用栈。
Java
Java程序可以重点分析:
- 堆使用情况;
- 完整垃圾回收后的存活对象;
- 对象数量与占用空间;
- 垃圾回收日志;
- 堆转储文件;
- 线程局部变量和静态集合。
如果堆空间不断上涨,最终抛出堆内存不足错误,通常需要通过堆转储查找占用最大的对象及其引用链。
Python
Python排查时可以关注:
- 对象数量是否持续增长;
- 哪些代码行分配了最多内存;
- 是否存在不断扩大的列表、字典或缓存;
- C扩展是否申请了未释放的内存;
- 循环引用中是否包含具有特殊析构行为的对象。
Python进程占用不下降也可能与解释器的内存池机制有关,因此同样需要区分“对象仍然存活”和“已释放空间尚未归还系统”。
十一、一个典型的泄漏案例
假设一个服务为了记录最近发生的错误,使用列表保存错误信息:
public class ErrorRecorder {
private static final List<String> ERRORS = new ArrayList<>();
public static void record(String message) {
ERRORS.add(message);
}
}
程序设计者原本只想方便排查问题,但这个列表没有容量限制。随着运行时间增加,所有历史错误都会一直保留。
可以将其改为有界队列:
public class ErrorRecorder {
private static final int MAX_SIZE = 1000;
private static final Deque<String> ERRORS = new ArrayDeque<>();
public static synchronized void record(String message) {
if (ERRORS.size() >= MAX_SIZE) {
ERRORS.removeFirst();
}
ERRORS.addLast(message);
}
}
修改后,程序最多保留1000条错误记录,内存占用不会随着运行时间无限增长。
不过在真实系统中,错误信息更适合写入日志系统,而不是长期保存在应用程序内存中。
十二、如何降低内存泄漏风险
1. 明确对象的生命周期
创建对象时就应该知道:
- 谁负责持有它;
- 谁负责释放或清理它;
- 它应该存活多长时间;
- 异常情况下是否仍能正确清理。
2. 优先使用自动资源管理
C++可以使用智能指针和资源获取即初始化机制;Java可以使用自动关闭语句;Python可以使用上下文管理器。
这些机制能够让资源释放与作用域绑定,减少遗漏清理操作的概率。
3. 为集合和缓存设置边界
任何长期存在的集合都应该考虑:
- 最大容量是多少;
- 什么时候删除数据;
- 删除失败时怎么办;
- 是否需要监控当前大小。
4. 及时注销监听器和回调
对象注册监听器后,事件源通常会一直引用该对象。如果对象销毁时没有注销监听器,它可能永远无法被回收。
5. 谨慎使用全局变量和静态变量
全局对象通常与进程生命周期相同。一旦它引用了其他对象,这些对象也可能被长期保留。
6. 监控趋势而不是瞬时数值
单次内存峰值并不可怕,持续增长且无法回落才更值得关注。生产环境应记录内存、对象、线程、连接和垃圾回收等指标的变化趋势。
7. 进行长时间压力测试
许多泄漏在短时间功能测试中不会出现。只有让程序持续执行同一批业务操作,才能观察每轮操作结束后是否残留了不应存在的数据。
结语
内存泄漏的本质,不只是“忘记释放内存”,而是某些已经没有业务价值的资源仍然被程序长期持有。
在需要手动管理内存的语言中,它通常表现为申请后没有释放;在具有垃圾回收机制的语言中,它更多表现为无用对象仍然可以从长生命周期对象访问。
排查内存问题时,应当依次确认:
- 内存是否在相同负载下持续增长;
- 垃圾回收后的最低占用是否不断升高;
- 增长来自堆内对象、堆外内存还是系统资源;
- 哪类对象或资源增长最快;
- 谁仍然持有这些对象;
- 对象的清理条件为什么没有发生。
只要沿着对象的生命周期和引用关系逐层分析,内存泄漏就不再是“程序运行久了越来越卡”这种模糊现象,而会变成一个能够定位、验证和修复的具体问题。
- 点赞
- 收藏
- 关注作者
评论(0)