程序为什么会发生内存泄漏:从现象、原理到排查方法

举报
yd_232225224 发表于 2026/09/11 23:52:37 2026/09/11
【摘要】 程序刚启动时只占用几百兆内存,运行几天后却增长到数个GB;重启程序后内存恢复正常,但过一段时间又开始上涨——这通常会让人怀疑程序发生了内存泄漏。不过,“内存占用增加”并不等于“内存泄漏”。缓存、内存池、垃圾回收策略和操作系统的统计方式,都可能造成内存持续占用的表象。要判断程序是否真的存在内存泄漏,首先需要理解程序的内存究竟去了哪里。 一、什么是内存泄漏内存泄漏是指程序申请了一块内存,使用完毕...

程序刚启动时只占用几百兆内存,运行几天后却增长到数个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. 进行长时间压力测试

许多泄漏在短时间功能测试中不会出现。只有让程序持续执行同一批业务操作,才能观察每轮操作结束后是否残留了不应存在的数据。

结语

内存泄漏的本质,不只是“忘记释放内存”,而是某些已经没有业务价值的资源仍然被程序长期持有。

在需要手动管理内存的语言中,它通常表现为申请后没有释放;在具有垃圾回收机制的语言中,它更多表现为无用对象仍然可以从长生命周期对象访问。

排查内存问题时,应当依次确认:

  1. 内存是否在相同负载下持续增长;
  2. 垃圾回收后的最低占用是否不断升高;
  3. 增长来自堆内对象、堆外内存还是系统资源;
  4. 哪类对象或资源增长最快;
  5. 谁仍然持有这些对象;
  6. 对象的清理条件为什么没有发生。

只要沿着对象的生命周期和引用关系逐层分析,内存泄漏就不再是“程序运行久了越来越卡”这种模糊现象,而会变成一个能够定位、验证和修复的具体问题。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。