这篇文章是我在公司 TechDay 上分享的内容的文字实录版,本来不想写这么一篇冗长的文章,因为有不少的同学问是否能写一篇相关的文字版,本来没有的也就有了。
说起来这是我第二次在 TechDay 上做的分享,四年前第一届 TechDay 不知天高地厚,上去讲了一个《MySQL 最佳实践》,现在想起来那些最佳实践貌似不怎么佳了。不扯远了,接下来看看具体的内容。
这次分享的主题是《内存问题探微》,会分为下面几个方面来聊一聊。
因为这是我被问的最频繁的问题,哎呀我的程序 OOM 了怎么办,我的程序内存超过配额被 k8s 杀掉了怎么办,我的程序看起来内存占用很高正常吗?
下面这个图就是我们之前做压测的时候,Nginx 内存占用过高,被操作系统杀掉的一个图。
当压测流量进来时,Nginx 内存蹭蹭蹭往上涨,达到 130G 左右时被系统 OOM-killer 杀掉,流量入口都有瓶颈,压测就没法继续进行下去了。那一个成熟的组件出现问题,要从哪些思路排查呢?
第二个原因就是内存管理的知识非常庞大,比如 linux 三驾马车,CPU、IO、内存,内存可以说是这里面最复杂的,与 CPU 和 IO 的性能有着千丝万缕的关系,搞懂了内存问题,才可以真正的搞清楚很多 Linux 性能相关的问题。
我们最近在做一个 8 点早读会,每天 8 点到 9 点分享一个小时,我花了将近 18 个小时讲内存相关的知识,但是还是有很多东西没能覆盖到。所以我想借这个机会再次把我们之前分享的一些东西拿出来再讲一讲,尽可能把我们开发过程中最常用的一些东西讲清楚。
理解内存可以帮助我们更深入的理解一些问题,比如:
接下来我们来开始本次分享的主要内容:Linux 内存管理的原理,与人类的三个终极问题一样,内存也有三个类似的问题,内存是什么,内存从哪里申请来,释放以后去了哪里。
首先我们来先看看虚拟内存与物理内存,虚拟内存和物理内存的关系印证了一句名言,「操作系统中的任何问题都可以通过一个抽象的中间层来解决」,虚拟内存正是如此。
没有虚拟内存,进程直接就可能修改其它进程的内存数据,虚拟内存的出现对内存使用做好了隔离,每个进程拥有独立的、连续的、统一的虚拟地址空间(好一个错觉)。像极了一个恋爱中的男人,拥有了她,仿佛拥有了全世界。
应用程序看到的都是虚拟内存,通过 MMU 进行虚拟内存到物理内存的映射,我们知道 linux 内存是按 4k 对齐,4k = 2^12 ,虚拟地址中的低 12 位其实是一个偏移量。
现在我们把页表想象为一个一维数组,对于虚拟地址中的每一页,都分配数组的一个槽位,这个槽位指向物理地址中的真正地址。那么有这么一个虚拟内存地址 0x1234010,那 0x010 就是页内偏移量,0x1234 是虚拟页号,CPU 通过 MMU 找到 0x1234 映射的物理内存页地址,假定为 0x2b601000,然后加上页内偏移 0x010,就找到了真正的物理内存地址 0x2b601010。如下图所示。
但是这种方式有一个很明显的问题,虚拟地址空间可能会非常大,就算拿 32 位的系统为例,虚拟地址空间为 4GB,用户空间内存大小为 3GB,每页大小为 4kB,数组的大小为 786432(1024 * 1024)。每个页表项用 4 个字节来存储,这样 4GB 的空间映射就需要 3MB 的内存来存储映射表。(备注:这里很多资料说的是 4M,也没有太大的问题,我这里的考虑是内核空间是共用的,不用太过于纠结。)
对于单个进程来说,占用 3M 看起来没有什么,但是页表是进程独占的,每个进程都需要自己的页表,如果有一百个进程,就会占用 300MB 的内存,这还仅仅是做地址映射所花的内存。如果考虑 64 位系统超大虚拟地址空间的情况,这种一维的数组实现的方式更加不切实际。
为了解决这个问题,人们使用了 level 的概念,页表的结构分为多级,页表项的大小只与虚拟内存空间中真正使用的多少有关。之前一维数组表示的方式页表项的多少与虚拟地址空间的大小成正比,这种多级结构的方式使得没有使用的内存不用分配页表项。
于是人们想出了多级页表的形式,这种方式非常适合,因为大部分区域的虚拟地址空间实际上是没有使用的,使用多级页表可以显著的减少页表本身的内存占用。在 64 位系统上,Linux 采用了四级页表,
如下图所示。
应用程序看到的只有虚拟内存,是看不到物理地址的。当然是有办法可以通过一些手段通过虚拟地址拿到物理地址。比如这个例子,我们 malloc 一个 1M 的空间,返回了一个虚拟地址 0x7ffff7eec010,怎么知道这个虚拟地址对应的物理内存地址呢?
#include <stdlib.h> #include <stdio.h> #include <unistd.h> int main() { char *p = NULL; p = malloc(1024 * 1024); *p = 0; printf("ptr: %p, pid: %d\n", p, getpid()); getchar(); return 0; } ptr: 0x7ffff7eec010, pid: 2621 复制代码
在应用层来做这个事情没有办法,但是难不倒我们,我们来写一个内核扩展模块来实现这个功能。
写一个内核模块也非常简单,分为下面几个步骤:
精简以后的代码如下所示。
#include <linux/module.h> ... int my_module_init(void) { unsigned long pa = 0; pgd_t *pgd = NULL; pud_t *pud = NULL; pmd_t *pmd = NULL; pte_t *pte = NULL; struct pid *p = find_vpid(pid); struct task_struct *task_struct = pid_task(p, PIDTYPE_PID); pgd = pgd_offset(task_struct->mm, va); // 获取第一级 pgd pud = pud_offset(pgd, va); // 获取第二级 pud pmd = pmd_offset(pud, va); // 获取第三级 pmd pte = pte_offset_kernel(pmd, va); // 获取第四级 pte unsigned long page_addr = pte_val(*pte) & PAGE_MASK; unsigned long page_addr &= 0x7fffffffffffffULL; page_offset = va & ~PAGE_MASK; pa = page_addr | page_offset; // 加上偏移量 printk("virtual address 0x%lx in RAM Page is 0x%lx\n", va, pa); return 0; } void my_module_exit(void) { printk("module exit!\n"); } module_init(my_module_init); // 注册回调钩子 module_exit(my_module_exit); // 注册回调钩子 MODULE_LICENSE("GPL"); MODULE_AUTHOR("Arthur.Zhang"); MODULE_DESCRIPTION("A simple virtual memory inspect"); 复制代码
我们编译这个内核模块会生成一个 .ko 文件,然后加载这个 .ko 文件,传入进程号、虚拟内存地址。
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules insmod my_mem.ko pid=2621 va=0x7ffff7eec010 复制代码
然后执行 dmesg -T 就可以看到真正的物理地址的值了。
[Sat Oct 10 05:11:12 2020] virtual address 0x7ffff7eec010 in RAM Page is 0x2358a4010 复制代码
可以看到在这个例子中,虚拟地址 0x7ffff7eec010 对应的物理地址是 0x2358a4010。
完整的代码见:github.com/arthur-zhan…
前面提到了虚拟内核和物理内存的关系,我们知道 linux 上的可执行文件的格式是 elf,elf 是一个静态文件,这个静态文件由不同的分节组成,我们这里叫它 section,在运行时,部分跟运行时相关的 Section 会被映射到进程的虚拟地址空间中,比如图中的代码段和数据段。除了这部分静态的区域,进程启动以后还有大量动态内存消耗区,比如栈、堆、mmap 区。
下面这个图是我们线上 java 服务使用 pmap 输出的内存布局的一部分,如下图所示。
那怎么来看这些部分呢?这就需要我们深入去理解 Linux 中进程的内存是如何被瓜分的。
Linux 内存管理有三个层面,第一层是我们的用户管理层,比如我们自己程序的内存池,mysql 的 bufferpool,第二层是 C 的运行时库,这部分代码是对内核的一个包装,方便上层应用更方便的开发,再下一层就是我们的内核层了。
我们今天要重点介绍的就是中间那一层,这一层是由一个 libc 的库来实现的,接下来详细看看看 libc 内存管理是如何做的。
Linux 内存管理的核心思想就是分层管理、批发零售、隐藏内部细节。我们还需要铭记在心的是 libc 中堆的管理是针对小内存分配释放来设计的,为了编程接口上的统一,大内存也是支持的。
我们先来看内存申请释放的两个函数,malloc 和 free,这两个函数的定义如下。
#include <stdlib.h> void *malloc(size_t size); void free(void *ptr); 复制代码
这两个函数压根不是系统调用,它们只是对 brk、mmap、munmap 系统调用的封装,那为什么有了这些系统调用,还需要 libc 再封装一层呢?
一个主要原因是因为系统调用很昂贵,而内存的申请释放又特别频繁,所以 libc 采取的的方式就是批量申请,然后作为内存的黄牛二道贩子,慢慢零售给后面的应用程序。
第二个原因是为了编程上的统一,比如有些时候用 brk,有些时候用 mmap,不太友好,brk 在多线程下还需要进行加锁,用一个 malloc 就很香。
Linux 的内存分配器有很多种,一开始是 Doug Lea 大神开发的 dlmalloc,这个分配器对多线程支持不友好,多线程下会竞争全局锁,随后有人基于 dmalloc 开发了 ptmalloc,增加了多线程的支持,除了 linux 官方的 ptmalloc,各个大厂有开发不同的 malloc 算法,比如 facebook 出品的 jemalloc,google 出品的 tcmalloc。
这些内存分配器致力于解决两个问题:多线程下锁的粒度问题,是全局锁,还是局部锁还是无锁。第二个问题是小内存回收和内存碎片问题,比如 jemalloc 在内存碎片上有显著的优势。
接下来我们来看 Linux 默认的内存分配器 ptmalloc,我总结了一下它有关的四个核心概念:Arena、Heap、Chunk、Bins。
Arena
先来看 Arena,Arena 的中文翻译的意思是主战场、舞台,对应在内存分配这里,指的是内存分配的主战场。
Arena 的出现首先用来解决多线程下全局锁的问题,它的思路是尽可能的让一个线程独占一个 Arena,同时一个线程会申请一个或多个堆,释放的内存又会进入回收站,Arena 就是用来管理这些堆和回收站的。
Arena 的数据结构长啥样?它是一个结构体,可以用下面的图来表示。
它是一个单向循环链表,使用 mutex 锁来处理多线程竞争,释放的小块内存会放在 bins 的结构中。
前面提到,Arena 会尽量让一个线程独占一个锁,那如果我有几千个线程,会生成几千个 Arena 吗?显然是不会的,所有跟线程有关的瓶颈问题,最后都会走到 CPU 核数的限制这里来,分配区的个数也是有上限的,64 位系统下,分配区的个数大小是 cpu 核数的八倍,多个 Arena 组成单向循环链表。
我们可以写个代码来打印 Arena 的信息。它的原理是对于一个确定的程序,main_arena 的地址是一个位于 glibc 库的确定的地址,我们在 gdb 调试工具中可以打印这个地址。也可以使用 ptype 命令来查看这个地址对应的结构信息,如下图所示。
有了这个基础,我们就可以写一个 do while 来遍历这个循环链表了。我们把 main_arena 的地址转为 malloc_state 的指针,然后 do while 遍历,直到遍历到链表头。
struct malloc_state { int mutex; int flags; void *fastbinsY[NFASTBINS]; struct malloc_chunk *top; struct malloc_chunk *last_remainder; struct malloc_chunk *bins[NBINS * 2 - 2]; unsigned int binmap[4]; struct malloc_state *next; struct malloc_state *next_free; size_t system_mem; size_t max_system_mem; }; void print_arenas(struct malloc_state *main_arena) { struct malloc_state *ar_ptr = main_arena; int i = 0; do { printf("arena[%02d] %p\n", i++, ar_ptr); ar_ptr = ar_ptr->next; } while (ar_ptr != main_arena); } #define MAIN_ARENA_ADDR 0x7ffff7bb8760 int main() { ... print_arenas((void*)MAIN_ARENA_ADDR); return 0; } 复制代码
输出结果如下。
那为什么还要区分一个主分配,一个非主分配区呢?
这有点像皇上和王爷的关系, 主分配区只有一个,它还有一个特权,可以使用靠近 DATA 段的 Heap 区,它通过调整 brk 指针来申请释放内存。
从某种意义上来讲,Heap 区不过是 DATA 段的扩展而已。
非主分配区呢?它更像是一个分封在外地,自主创业的王爷,它想要内存时就使用 mmap 批发大块内存(64M)作为子堆(Sub Heap),然后在慢慢零售给上层应用。
一个 64M 用完,再开辟一个新的,多个子堆之间也是使用链表相连,一个 Arena 可以有多个子堆。在接下的内容中,我们还会继续详细介绍。
Heap
接下来我们来看 ptmalloc2 的第二个核心概念 ,heap 用来表示大块连续的内存区域。
主分配区的 heap 没有什么好讲的,我们这里重点看「非主分配」的子堆(也称为模拟堆),前面提到过,非主分配批发大块内存进行切割零售的。
那如何理解切割零售这句话呢?它的实现也非常简单,先申请一块 64M 大小的不可读不可写不可执行(PROT_NONE)的内存区域,需要内存时使用 mprotect 把一块内存区域的权限改为可读可写(R+W)即可,这块内存区域就可以分配给上层应用了。
以我们前面 java 进程的内存布局为例。
这中间的两块内存区域是属于一个子堆,它们加起来的大小是 64M,然后其中有一块 1.3M 大小的内存区域就是使用 mprotrect 分配出去的,剩下的 63M 左右的区域,是不可读不可写不可执行的待分配区域。
知道这个有什么用呢?太有用了,你在 google 里所有 Java 堆外内存等问题,有很大可能性会搜到 Linux 神奇的 64M 内存问题。有了这里的知识,你就比较清楚到底这 64M 内存问题是什么了。
与前面的 Arena 一样,我们同样可以在代码中,遍历所有 Arena 的所有的 heap 列表,代码如下所示。
struct heap_info { struct malloc_state *ar_ptr; struct heap_info *prev; size_t size; size_t mprotect_size; char pad[0]; }; void dump_non_main_subheaps(struct malloc_state *main_arena) { struct malloc_state *ar_ptr = main_arena->next; int i = 0; while (ar_ptr != main_arena) { printf("arena[%d]\n", ++i); struct heap_info *heap = heap_for_ptr(ar_ptr->top); do { printf("arena:%p, heap: %p, size: %d\n", heap->ar_ptr, heap, heap->size); heap = heap->prev; } while (heap != NULL); ar_ptr = ar_ptr->next; } } #define MAIN_ARENA_ADDR 0x7ffff7bb8760 dump_non_main_subheaps((void*)MAIN_ARENA_ADDR); 复制代码
Chunk
接下来我们来看分配的基本单元 chunk,chunk 的字面意思是「厚块; 厚片」,chunk 是 glibc 中内存分配的基础单元。以一个简单的例子来开头。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> int main(void) { void *p; p = malloc(1024); printf("%p\n", p); p = malloc(1024); printf("%p\n", p); p = malloc(1024); printf("%p\n", p); getchar(); return (EXIT_SUCCESS); } 复制代码
这段代码逻辑是连续调用三次 malloc,每次分配 1k 内存,然后我们来观察它的内存地址。
./malloc_test 0x602010 0x602420 0x602830 复制代码
可以看到内存地址之间差了 0x410,1024 是等于 0x400,那多出来的 0x10 字节是什么?我们先按下不表。
再来回看 malloc 和 free,那我们不禁问自己一个问题,free 函数的参数只有一个指针,它是怎么知道要释放多少内存的呢?
#include <stdlib.h> void *malloc(size_t size); void free(void *ptr); 复制代码
香港作家张小娴说过,「凡事皆有代价,快乐的代价便是痛苦」。为了存储 1k 的数据,实际上还需要一些数据来记录这块内存的元数据。这块额外的数据被称为 chunk header,长度为 16 字节。这就是我们前面看到的多出来 0x10 字节。
这种通过在实际数据前面添加 head 方式使用的非常普遍,比如 java 中 new Integer(1024),实际存储的数据大小远不止 4 字节,它有一个巨大无比的对象头,里面存储了对象的 hashcode,经过了几次 GC,有没有被当做锁同步。
害,说 java 臃肿并不是没有道理。
在我们继续来看这个 16 字节的 header 里面到底存储了什么,它的结构示意图如下所示。
它分为两部分,前 8 字节表示前一个 chunk 块的大小,接下来的 8 字节表示当前 chunk 块的大小,因为 chunk 块要按 16 字节对齐,所以低 4 字节都是没用的,其中三个被用来当做标记位来使用,这三个分别是 AMP,其中 A 表示是否是主分配区,M 表示是否是 mmap 分配的大 chunk 块,P 表示前一个 chunk 是否在使用中。
以前面的例子为例,我们可以用 gdb 来查看这部分的内存。
可以看到对应 size 的 8 个字节是 0x0411,这个值是怎么来的呢?其实是按 size + 8 对齐到 16B 再加上低三位的 B001。
0x0400 + 0x10 + 0x01 = 0x0411 复制代码
因为当一个 chunk 正在被使用时,它的下一个 chunk 的 prev_size 是没有意义的,这 8 个字节可以被这个当前 chunk 使用。别奇怪,就是这么抠。接下来我们来看看 chunk 中 prev_size 的复用。测试的代码如下。
#include <stdlib.h> #include <string.h> void main() { char *p1, *p2; p1 = (char *)malloc(sizeof(char) * 18); // 0x602010 p2 = (char *)malloc(sizeof(char) * 1); // 0x602030 memcpy(p1, "111111111111111111", 18); } 复制代码
编译这个源文件,然后使用 gdb 调试单步运行,查看 p1、p2 的地址。
p/x p1 $2 = 0x602010 (gdb) p/x p2 $3 = 0x602030 复制代码
然后输出 p1、p2 附近的内存区域。
(gdb) x/64bx p1-0x10 0x602000: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x602008: 0x21 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x602010: 0x31 0x31 0x31 0x31 0x31 0x31 0x31 0x31 0x602018: 0x31 0x31 0x31 0x31 0x31 0x31 0x31 0x31 0x602020: 0x31 0x31 0x00 0x00 0x00 0x00 0x00 0x00 0x602028: 0x21 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x602030: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x602038: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 复制代码
布局如下图所示。
篇幅有限,我这里只展示了 malloc chunk 的结构,还有 Free chunk、Top chunk、Last Remainder chunk 没有展开,可以参考其它的资料。
Bins
我们接下来看最后一个概念,小块内存的回收站 Bins。
内存的回收站分为两大类,第一类是普通的 bin,一类是 fastbin。
接下来我们看这两种 bin 的细节。
普通 bin 采用双向链表存储,以数组形式定义,共 254 个元素,两个数组元素组成一个 bin,通过 fd、bk 组成双向循环链表,结构有点像九连环玩具。
所以普通 bin 的总个数是 254/2 = 127 个。其中 unsorted bin 只有 1 个,small 有 62 个,large bin 有 63 个,还有一个暂未使用,如下图所示。
smallbin
其中 smallbin 用于维护 <= 1024B 的 chunk 内存块。同一条 small bin 链中的 chunk 具有相同的大小,都为 index * 16,结构如下图所示。
largebin
largebin 中同一条链中的 chunk 具有「不同」的大小
结构如下图所示。
unsorted bin
unsorted bin 只有一条双向链表,它的特点如下。
它的结构如下图所示。
下面是所有普通 bin 的概览图。
FastBin
说完了普通 bin,我们来详细看看 FastBin,FastBin 专门用来提高小内存的分配效率,它的结构如下。
它有下面这些特性。
Fast bins 可以看着是 small bins 的一小部分 cache。
有了前面的知识,我们就可以来回答分享一开头的问题,内存从哪里来。大块内存申请没有特别多可以讲的,直接 mmap 系统调用申请一块,释放的时候也直接还给操作系统。
小块内存的申请就复杂很多了,原则就是先在 chunk 回收站中找,找到了是最好,就直接返回了,不用再去向内核申请。它是怎么做的呢?
首先会根据传入的大小计算真正 chunk 的大小,根据这个大小看看在不在 fastbin 的区间里,如果有的话,从 fastbin 直接返回,如果不在则尝试 smallbin,然后如果 smallbin 里没有则会触发一次合并,然后从 unsorted bin 里查找,还没有则会从 Large Bin 查找,如果没有再去切割 top 块,top 块也没有了,则会重新申请 heap 或者调整 heap 的大小,如下图所示。
接下来我们来回答最后一个问题,内存 free 以后去了哪里,根据不同的大小,有不同的处理策略。
前面我们介绍的大部分都是堆内存,其实还有一个非常重要的东西是栈内存,LInux 中默认的栈内存大小是 8M,然后外加 4K 的保护区,这 4k 的保护区不可读不可写不可执行,当真有栈越界时可以更早的发现,尽快 fast fail。
这个图就是一个典型的 linux 原生线程的栈内存布局,可以看到 8M 的栈空间和 4k 的 guard 区域的情况。
对于 Java 来说,它做了一些细微的调整,默认的栈大小空间为 1M,然后有 4k 的 RED 区域和 8K 的 yellow 区域,以便做更细粒度的栈溢出控制,这里的 yellow 区域和 red 区域到底有什么作用,之前我有写一篇文章线程与栈的文章专门介绍,这里就不展开了。
接下来进入我们的最后一个部分,开发相关的内存问题。
首先要说的是一个问的比较多的问题,为什么我 Java 应用的内存消耗远大于 Xmx,这也是 Stack Overflow 上问的非常多的一个问题。
其实我们要搞清楚,一个进程除了堆消耗内存,还有大量的其他的开销,如下所示。
内存大户不是开玩笑的,根据多年实践 Xmx 设置为容器内存的 65% 左右比较合理
第二个问题是 top 命令中 RES 占用很高,是不是代表程序真正有大量消耗呢?
其实不是的,我们以一个最简单的 java 程序为例,在使用 -Xms1G -Xmx1G 来运行程序时。
java -Xms1G -Xmx1G MyTest 复制代码
它的内存占用如下。
我们把启动命令稍作改动,加上 AlwaysPreTouch,如下所示。
java -XX:+AlwaysPreTouch -Xms1G -Xmx1G MyTest 复制代码
这个时候 RES 占用如下所示。
这里的 1G 业务程序其实没有使用,只是 JVM 把内存做了写入,以便后面真正使用时,不用发起缺页中断去真正申请物理内存。
内存占用不是越少越好,还要兼顾 GC 次数、GC 停顿时间。
默认的 Linux 内存分配器在性能和内存碎片方面表现不是很好,可以尝试替换默认的内存分配器为 jemalloc 或者 tcmalloc,只用新增一个 LD_PRELOAD 环境变量即可。
LD_PRELOAD=/usr/local/lib/libjemalloc.so 复制代码
在实际的服务中,有一个服务内存占用从 7G 变为了 3G,效果还是非常明显的。
Java 的堆内存分析非常容易,jmap 命令 dump 出内存,然后使用 jprofile、mat、perfma 等平台都可以很快的进行分析了。然而对于 native 的内存占用过大,还是比较麻烦的。这里可以使用 jemalloc 和 tcmalloc 强大的 profile 功能。以 jemalloc 为例,可以将内存的申请关系生成 svg。
export MALLOC_CONF=prof:true,lg_prof_sample:1,lg_prof_interval:30,prof_prefix:jeprof.out jeprof –svg /path/to/svg jeprof.out.* > out.svg 复制代码
生成的 svg 示意图如下所示。
这次介绍的只是内存问题的冰山一角,很多细节的东西没能在这次分享里详细展开,有问题可以来交流。
讲完这个 PPT,有人跟我说,场子有点冷。这个结局还不错,我以为中间要走一半。
完整的分享的 PPT 可以在这里下载:github.com/arthur-zhan…
原文链接:https://juejin.cn/post/6903363887496691719