进程地址空间相关的知识
要想理解内存泄漏,首先要理解进程的地址空间,下面用一张图来描述

进程内存的申请通常会调用系统函数mmap、brk
对于小块内存(小于 128K)分配,优先使用 brk,因为它在分配小内存时效率较高。对于大块内存分配,通常会采用 mmap,这样内存释放后能更好地返还给系统,减少堆的碎片问题。
brk系统调用用于调整进程的堆内存大小。它通过移动进程的堆顶指针来分配或释放内存。当进程需要更多内存时,brk会将堆顶指针向高地址方向移动,扩展堆的大小;当释放内存时,堆顶指针向低地址方向移动,收回未使用的内存。这个过程确保堆内存区域在内存中是连续的,但如果中间的内存块被释放,brk只能收回位于堆顶的内存,无法对中间的内存进行管理。所以会造成内存碎片,由于这些内存释放后并不会立刻归还系统,而是被缓存起来,这样同一进程再次申请内存时就可以重复使用,且此时已经有了虚拟地址到物理内存的映射,这会省掉一次CPU从用户态到内核态的转换过程,减少CPU消耗。mmap在上篇文章中已有描述,这里不多赘述。
上述两种方法针对的都是虚拟地址,应用程序都是跟虚拟地址打交道,不会直接跟物理地址打交道。而虚拟地址最终都要转换为物理地址,由于 Linux 都是使用 Page(页)来进行管理的,所以这个过程叫 Paging(分页)。只有往这些内存中写入数据后,才会真正地分配物理内存 。
Paging 的大致过程是,CPU 将要请求的虚拟地址传给 MMU(Memory Management Unit,内存管理单元),然后 MMU 先在高速缓存 TLB(Translation Lookaside Buffer,页表缓存)中查找转换关系,如果找到了相应的物理地址则直接访问;如果找不到则在地址转换表(Page Table)里查找计算。最终进程访问的虚拟地址就对应到了实际的物理地址。

观察进程的内存
我们可以使用 top 来观察系统所有进程的内存使用概况,打开 top 后,然后按 g 再输入 3,从而进入内存模式就可以了。在内存模式中,我们可以看到各个进程内存的 %MEM、VIRT、RES、CODE、DATA、SHR、nMaj、nDRT,这些信息通过 strace 来跟踪 top 进程,你会发现这些信息都是从 /proc/[pid]/statm 和 /proc/[pid]/stat 这个文件里面读取的

有些时候所有进程的 RES 相加起来要比系统总的物理内存大,这是因为 RES 中有一些内存是被一些进程给共享的。如果 RES 太高而 SHR 不高,那可能是堆内存泄漏;如果 SHR 很高,那可能是 tmpfs/shm 之类的数据在持续增长,如果 VIRT 很高而 RES 很小,那可能是进程不停地在申请内存,但是却没有对这些内存进行任何的读写操作,即虚拟地址空间存在内存泄漏。
内存泄漏
程序申请的内存只被使用了一次(memset)就再没被使用,但是在使用完后却没有把这段内存空间给释放掉,这就是典型的内存泄漏。如果进程不是长时间运行,那么即使存在内存泄漏(比如程序代码中只有 malloc 没有 free),它的危害也不大,因为进程退出时,内核会把进程申请的内存都给释放掉。可真实业务中又有多少重要进程是会即时退出的呢?
系统内存不足时会唤醒 OOM killer 来选择一个进程给杀掉。OOM killer 选择进程是有策略的,它未必一定会杀掉正在内存泄漏的进程,很有可能是一个无辜的进程被杀掉。OOM killer 在杀进程的时候,会把系统中可以被杀掉的进程扫描一遍,根据进程占用的内存以及配置的 oom_score_adj 来计算出进程最终的得分,然后把得分(oom_score)最大的进程给杀掉,如果得分最大的进程有多个,那就把先扫描到的那个给杀掉。如果你不想这个进程被首先杀掉,那你可以调整该进程的 oom_score_adj 改变这个 oom_score;如果你的进程无论如何都不能被杀掉,那你可以将 oom_score_adj 配置为 -1000。通常而言,我们都需要将一些很重要的系统服务的 oom_score_adj 配置为 -1000,比如 sshd,因为这些系统服务一旦被杀掉,我们就很难再登陆进系统了。但是,除了系统服务之外,不论你的业务程序有多重要,都尽量不要将它配置为 -1000。因为你的业务程序一旦发生了内存泄漏,而它又不能被杀掉,这就会导致随着它的内存开销变大,OOM killer 不停地被唤醒,从而把其他进程一个个给杀掉,导致进程误杀😣。
进程没有消耗内存,内存哪去了?
在遇到系统内存不足时,我们首先要做的是查看 /proc/meminfo 中哪些内存类型消耗较多,然后再去做针对性分析。这里我们分析 /proc/meminfo 下的shmem。shmem包括匿名共享内存和共享文件映射内存,二者都是通过mmap实现的。
匿名共享内存:不依赖于磁盘文件,常用于临时数据交换、缓存、内存池等,适合进程间高效的通信和数据共享。共享文件映射:通过将磁盘文件映射到内存中来实现进程间共享,通常用于文件访问、内存映射数据库等场景,具有数据持久性。
有时候各个进程的 RES 都不大,可看起来和 /proc/meminfo 中的 Shmem 完全对应不起来。这时就涉及到一种特殊的 Shmem:tmpfs。它是一种内存文件系统,只存在于内存中。tmpfs 中的文件不会体现在进程的内存占用上。所以我们 Shmem 占用内存多,可能就是因为 Shmem 中的 tmpfs 较大导致的。
通过我自己的观察得知,free下的shared显示大小和 /proc/meminfo 中的 Shmem大小一样。
内核内存泄漏
进程的虚拟地址空间(address space)既包括用户地址空间,也包括内核地址空间。这可以简单地理解为,进程运行在用户态申请的内存,对应的是用户地址空间,进程运行在内核态申请的内存,对应的是内核地址空间。应用程序可以通过 malloc() 和 free() 在用户态申请和释放内存,与之对应,可以通过 kmalloc()/kfree() 以及 vmalloc()/vfree() 在内核态申请和释放内存。kmalloc
是一种高效的内存分配方式,适用于小块内存的分配,要求物理内存连续。它的性能较好,但受到内存碎片的影响,不能用于分配非常大的内存。vmalloc
适用于大块内存分配,尤其是在物理内存碎片严重时,因为它不要求物理内存连续。但是,由于虚拟内存映射的开销,vmalloc
在速度上比 kmalloc
要慢。
$ cat /proc/meminfo
...
Slab: 2400284 kB
SReclaimable: 47248 kB
SUnreclaim: 2353036 kB
...
VmallocTotal: 34359738367 kB
VmallocUsed: 1065948 kB
...
vmalloc 申请的内存会体现在 VmallocUsed 这一项中,即已使用的 Vmalloc 区大小;而 kmalloc 申请的内存则是体现在 Slab 这一项中,它又分为两部分,其中 SReclaimable 是指在内存紧张的时候可以被回收的内存,而 SUnreclaim 则是不可以被回收只能主动释放的内存。内核空间的内存泄漏与用户空间的内存泄漏有什么不同呢?我们知道,用户空间内存的生命周期与用户进程是一致的,进程退出后这部分内存就会自动释放掉。但是,内核空间内存的生命周期是与内核一致的,却不是跟内核模块一致的,也就是说,在内核模块退出时,不会自动释放掉该内核模块申请的内存,只有在内核重启(即服务器重启)时才会释放掉这部分内存。所以内核空间的内存泄露是更加严重的。
如果 /proc/meminfo 中内核内存(比如 VmallocUsed 和 SUnreclaim)太大,那很有可能发生了内核内存泄漏;另外,你也可以周期性地观察 VmallocUsed 和 SUnreclaim 的变化,如果它们持续增长而不下降,也可能是发生了内核内存泄漏。我们可以在/proc/vmallocinfo文件中看到一些信息。