内存问题探微原创
这篇文章是我在公司 TechDay 上分享的内容的文字实录版,本来不想写这么一篇冗长的文章,因为有不少的同学问是否能写一篇相关的文字版,本来没有的也就有了。
说起来这是我第二次在 TechDay 上做的分享,四年前第一届 TechDay 不知天高地厚,上去讲了一个《MySQL 最佳实践》,现在想起来那些最佳实践貌似不怎么佳了。不扯远了,接下来看看具体的内容。
这次分享的主题是《内存问题探微》,会分为下面几个方面来聊一聊。
- Linux 内存知识的底层原理
- malloc、free 的底层实现原理
- ptmalloc2 的实现原理
- Arena、Heap、Chunk、Bin 的内部结构
- java 开发相关的内存问题说明
为什么要分享这个主题
因为这是我被问的最频繁的问题,哎呀我的程序 OOM 了怎么办,我的程序内存超过配额被 k8s 杀掉了怎么办,我的程序看起来内存占用很高正常吗?
下面这个图就是我们之前做压测的时候,Nginx 内存占用过高,被操作系统杀掉的一个图。
当压测流量进来时,Nginx 内存蹭蹭蹭往上涨,达到 130G 左右时被系统 OOM-killer 杀掉,流量入口都有瓶颈,压测就没法继续进行下去了。那一个成熟的组件出现问题,要从哪些思路排查呢?
第二个原因就是内存管理的知识非常庞大,比如 linux 三驾马车,CPU、IO、内存,内存可以说是这里面最复杂的,与 CPU 和 IO 的性能有着千丝万缕的关系,搞懂了内存问题,才可以真正的搞清楚很多 Linux 性能相关的问题。
我们最近在做一个 8 点早读会,每天 8 点到 9 点分享一个小时,我花了将近 18 个小时讲内存相关的知识,但是还是有很多东西没能覆盖到。所以我想借这个机会再次把我们之前分享的一些东西拿出来再讲一讲,尽可能把我们开发过程中最常用的一些东西讲清楚。
理解内存可以帮助我们更深入的理解一些问题,比如:
- 为什么 golang 原生支持函数多返回值
- golang 逃逸分析是怎么做的
- Java 堆外内存泄露如何分析
- C++ 智能指针是如何实现的
第二部分:Linux 内存管理的原理
接下来我们来开始本次分享的主要内容:Linux 内存管理的原理,与人类的三个终极问题一样,内存也有三个类似的问题,内存是什么,内存从哪里申请来,释放以后去了哪里。
虚拟内存与物理内存
首先我们来先看看虚拟内存与物理内存,虚拟内存和物理内存的关系印证了一句名言,「操作系统中的任何问题都可以通过一个抽象的中间层来解决」,虚拟内存正是如此。
没有虚拟内存,进程直接就可能修改其它进程的内存数据,虚拟内存的出现对内存使用做好了隔离,每个进程拥有独立的、连续的、统一的虚拟地址空间(好一个错觉)。像极了一个恋爱中的男人,拥有了她,仿佛拥有了全世界。
应用程序看到的都是虚拟内存,通过 MMU 进行虚拟内存到物理内存的映射,我们知道 linux 内存是按 4k 对齐,4k = 2^12 ,虚拟地址中的低 12 位其实是一个偏移量。
现在我们把页表想象为一个一维数组,对于虚拟地址中的每一页,都分配数组的一个槽位,这个槽位指向物理地址中的真正地址。那么有这么一个虚拟内存地址 0x1234010,那 0x010 就是页内偏移量,0x1234 是虚拟页号,CPU 通过 MMU 找到 0x1234 映射的物理内存页地址,假定为 0x2b601000,然后加上页内偏移 0x010,就找到了真正的物理内存地址 0x2b601010。如下图所示。
Linux 四级页表
但是这种方式有一个很明显的问题,虚拟地址空间可能会非常大,就算拿 32 位的系统为例,虚拟地址空间为 4GB,用户空间内存大小为 3GB,每页大小为 4kB,数组的大小为 786432(1024 * 1024)。每个页表项用 4 个字节来存储,这样 4GB 的空间映射就需要 3MB 的内存来存储映射表。(备注:这里很多资料说的是 4M,也没有太大的问题,我这里的考虑是内核空间是共用的,不用太过于纠结。)
对于单个进程来说,占用 3M 看起来没有什么,但是页表是进程独占的,每个进程都需要自己的页表,如果有一百个进程,就会占用 300MB 的内存,这还仅仅是做地址映射所花的内存。如果考虑 64 位系统超大虚拟地址空间的情况,这种一维的数组实现的方式更加不切实际。
为了解决这个问题,人们使用了 level 的概念,页表的结构分为多级,页表项的大小只与虚拟内存空间中真正使用的多少有关。之前一维数组表示的方式页表项的多少与虚拟地址空间的大小成正比,这种多级结构的方式使得没有使用的内存不用分配页表项。
于是人们想出了多级页表的形式,这种方式非常适合,因为大部分区域的虚拟地址空间实际上是没有使用的,使用多级页表可以显著的减少页表本身的内存占用。在 64 位系统上,Linux 采用了四级页表,
- PGD:Page Global Directory,页全局目录,是顶级页表。
- PUD:Page Upper Directory,页上级目录,是第二级页表
- PMD:Page Middle Derectory,页中间目录,是第三级页表。
- PTE:Page Table Entry,页面表,最后一级页表,指向物理页面。
如下图所示。
应用程序看到的只有虚拟内存,是看不到物理地址的。当然是有办法可以通过一些手段通过虚拟地址拿到物理地址。比如这个例子,我们 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
在应用层来做这个事情没有办法,但是难不倒我们,我们来写一个内核扩展模块来实现这个功能。
写一个内核模块也非常简单,分为下面几个步骤:
- 定义好两个回调钩子,module_init, module_exit
- 通过传入的 pid 获取到这个进程的 task_struct通过 task_struct 中的 mm 变量和传入的虚拟内存地址 va,就可以拿到 pgd
- 通过 pgd 就可以拿到 pud,然后再拿到 pmd,最好获取到 pte,这个 pte 已经存储的是物理内存的页帧
- 通过低 12 位的页内偏移就可以得到最终的物理内存的地址。
精简以后的代码如下所示。
#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。
完整的代码见:https://github.com/arthur-zhang/virtualmem2physical
进程的内存布局
前面提到了虚拟内核和物理内存的关系,我们知道 linux 上的可执行文件的格式是 elf,elf 是一个静态文件,这个静态文件由不同的分节组成,我们这里叫它 section,在运行时,部分跟运行时相关的 Section 会被映射到进程的虚拟地址空间中,比如图中的代码段和数据段。除了这部分静态的区域,进程启动以后还有大量动态内存消耗区,比如栈、堆、mmap 区。
下面这个图是我们线上 java 服务使用 pmap 输出的内存布局的一部分,如下图所示。
那怎么来看这些部分呢?这就需要我们深入去理解 Linux 中进程的内存是如何被瓜分的。
libc 内存管理原理探究
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 内存分配器
Linux 的内存分配器有很多种,一开始是 Doug Lea 大神开发的 dlmalloc,这个分配器对多线程支持不友好,多线程下会竞争全局锁,随后有人基于 dmalloc 开发了 ptmalloc,增加了多线程的支持,除了 linux 官方的 ptmalloc,各个大厂有开发不同的 malloc 算法,比如 facebook 出品的 jemalloc,google 出品的 tcmalloc。
这些内存分配器致力于解决两个问题:多线程下锁的粒度问题,是全局锁,还是局部锁还是无锁。第二个问题是小内存回收和内存碎片问题,比如 jemalloc 在内存碎片上有显著的优势。
ptmalloc 的核心概念
接下来我们来看 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。
- fastbin 采用单向链表,每条链表的中空闲 chunk 大小是确定的,插入删除都在队尾进行。
- 普通 bin 根据回收的内存大小又分为了 small、large 和 unsorted 三种,采用双向链表存储,它们之间最大的区别就是它们存储的 chunk 块的大小范围不一样。
接下来我们看这两种 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 具有「不同」的大小
- 分为 6 组
- 每组的 bin 数量依次为 33、15、8、4、2、1,每条链表中的最大 chunk 大小公差依次为 64B、 512B、4096B、32768B、262144B 等
结构如下图所示。
unsorted bin
unsorted bin 只有一条双向链表,它的特点如下。
- 空闲 chunk 不排序
- 大于 128B 的内存 chunk 回收时先放到 unsorted bin
它的结构如下图所示。
下面是所有普通 bin 的概览图。
FastBin
说完了普通 bin,我们来详细看看 FastBin,FastBin 专门用来提高小内存的分配效率,它的结构如下。
它有下面这些特性。
- 小于 128B 的内存分配会先在 Fast Bin 中查找
- 单向链表,每条链表中的 chunk 大小相同,有 7 个 chunk 空闲链表,每个 bin 的 chunk 大小依次为 32B,48B,64B,80B,96B,112B,128B
- 因为是单向链表,fastbin 中的 bk 指针没有用到,第一个 chunk 的 fd 指针指向特殊的 0 地址
- P 标记始终为 1,一般情况下不合并
- FIFO,添加和删除都从队尾进行
Fast bins 可以看着是 small bins 的一小部分 cache。
内存的申请与释放
有了前面的知识,我们就可以来回答分享一开头的问题,内存从哪里来。大块内存申请没有特别多可以讲的,直接 mmap 系统调用申请一块,释放的时候也直接还给操作系统。
小块内存的申请就复杂很多了,原则就是先在 chunk 回收站中找,找到了是最好,就直接返回了,不用再去向内核申请。它是怎么做的呢?
首先会根据传入的大小计算真正 chunk 的大小,根据这个大小看看在不在 fastbin 的区间里,如果有的话,从 fastbin 直接返回,如果不在则尝试 smallbin,然后如果 smallbin 里没有则会触发一次合并,然后从 unsorted bin 里查找,还没有则会从 Large Bin 查找,如果没有再去切割 top 块,top 块也没有了,则会重新申请 heap 或者调整 heap 的大小,如下图所示。
接下来我们来回答最后一个问题,内存 free 以后去了哪里,根据不同的大小,有不同的处理策略。
- 符合 fastbin 的超小块内存直接放入 fastbin 单链表,快速释放,画外音就是这么点空间,值得我处理半天吗?
- 超大块内存,直接还给内核不进入 bin 的管理逻辑。画外音就是大客户要特殊处理,毕竟大客户是少数情况。
- 大部分是介于中间的,释放的时候首先会被放入 unsorted bin。根据情况合并、迁移空闲块,靠近 top 则更新 top chunk。这才是人生常态啊。
栈内存
前面我们介绍的大部分都是堆内存,其实还有一个非常重要的东西是栈内存,LInux 中默认的栈内存大小是 8M,然后外加 4K 的保护区,这 4k 的保护区不可读不可写不可执行,当真有栈越界时可以更早的发现,尽快 fast fail。
这个图就是一个典型的 linux 原生线程的栈内存布局,可以看到 8M 的栈空间和 4k 的 guard 区域的情况。
对于 Java 来说,它做了一些细微的调整,默认的栈大小空间为 1M,然后有 4k 的 RED 区域和 8K 的 yellow 区域,以便做更细粒度的栈溢出控制,这里的 yellow 区域和 red 区域到底有什么作用,之前我有写一篇文章线程与栈的文章专门介绍,这里就不展开了。
第三部分:开发相关的内存问题说明
接下来进入我们的最后一个部分,开发相关的内存问题。
Xmx 与内存消耗
首先要说的是一个问的比较多的问题,为什么我 Java 应用的内存消耗远大于 Xmx,这也是 Stack Overflow 上问的非常多的一个问题。
其实我们要搞清楚,一个进程除了堆消耗内存,还有大量的其他的开销,如下所示。
- Heap
- Code Cache
- GC 开销
- Metaspace
- Thread Stack
- Direct Buffers
- Mapped files
- C/C++ Native 内存消耗
- malloc 本身的开销
- 。。。
内存大户不是开玩笑的,根据多年实践 Xmx 设置为容器内存的 65% 左右比较合理
RES 占用
第二个问题是 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,效果还是非常明显的。
native 内存分析
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 可以在这里下载:https://github.com/arthur-zhang/presentations