背景介绍
在编程语言中例如C语言中使用malloc/free来分配和回收内存,CPP中使用new/delete来分配和回收内存,本质上是malloc/free的包装,而Java虚拟机使用C/CPP,因此并无太大差异。
在不特殊指定的情况下,程序在编译时会链接到glibc默认的malloc实现,但是由于计算机硬件的发展,这个malloc已经不太能适合当前多核CPU的场景,因此诞生出了一些新的malloc实现例如FreeBSD使用的jemalloc和Golang使用的tcmalloc等(注意到netty的直接内存的分配也是直接参考了jemalloc),这里我们简单介绍下jemalloc的代码实现(主要基于4.0.3版本)。
注:jemalloc据说是有较高的内存分配并发,同时在单cpu时性能也能和glibc的malloc持平
简要介绍
xxmalloc这一类库函数,本质上是对系统调用(System Call)的包装,glibc的malloc会在申请小内存的时候使用brk增加堆的上界,在申请大内存的时候使用mmap从空闲堆分配,而jemalloc在默认情况下全部使用mmap分配内存。
环境搭建
通过下面的命令安装编译出jemalloc的库,并将代码链接到生成的库,之后就可以通过gdb对jemalloc进行debug,通过debug可以对整体代码进行较为全面的分析。
1 | # 环境 Centos 8,已预装gcc/g++ |
如果是动态链接,可以使用ldd查看动态链接来验证,dummy.c可以用下面的代码
1 |
|
基本概念
size class
首先,jemalloc将内存分为不同的size class,在申请内存的时候会对size向上取整,例如申请49Byte内存,会对应到64的size,实际去申请64Byte,这样是为了方便分配内存,减少外部碎片
1 | +---------+---------+--------------------------------------+ |
其中small是小于14KB的,Large在 14KB到1792KB之间,Huge为超过1792KB的内存申请。
chunk
jemalloc内存的分配以chunk为基本单位,对于 small和large size,chunk大小一般是2M,对于huge size,chunk大小是申请内存大小,同时是4M的倍数。
2M的chunk一共512个page,一个page 4KB,chunk用13个page存储了自己的信息,剩余499 page 用来使用,使用的时候一般是分配一个run出来,run的大小是page的整数倍,run再均匀且分成固定的大小来使用,chunk、run、region的对应关系大致如下:
上图中的region其实只是在分配 small 和 large 内存的时候使用
arena
上面说的chunk实际上是被arena管理的,chunk中有一个extent_node_t结构体字段指向了arena,同时chunk中的空闲run地址放在arena的runs_avail这个红黑树中。
考虑到快速分配内存的需要,为日常使用最多的内存大小范围small类型设置了大小从 8Byte 到 14KB 39个bin,bin里面有runcur,方便在需要的时候直接分配内存,arena和chunk、run、bin的关系如下:
每一个bin对应的一个bin_info里会有region size和run size,值得注意的是 region size不一样,run size可能一样,因为不同的run的region count是不一样的,run size一样其实可以方便内存申请和内存回收复用。
tcache和tsd
上述过程,如果要分配个small size的内存,要先从arena拿到bin,然后从其中拿到run,再去run里面拿空闲region,为了加快这个过程,jemalloc增加了tcache,里面按照size class存储了tbin,tbin里面有avail直接存储了可以使用的region,减少获取内存的时间。
另外,每个线程缓存(类似Java Thread Locale)分配了一个tsd用来存储线程的arena和tcache等数据,方便分配内存的时候直接获取,arena绑定到线程,这也是jemalloc和tcmalloc的区别,后者是维护arena的池子,线程没有绑定到固定的arena。
上述说到的内容的整体结构如下
值得注意的是,arena 等基础的内部结构体会走 base 分配,这里暂时不介绍
内存分配
从线程角度看,内存分配经历了图中的过程
上图从代码上看,malloc对应 jemalloc.c 的 je_malloc 函数,接着通过 tsd_fetch() 获取线程缓存的 tsd
1 | // tsd_get 要走 malloc_tsd_funcs,这个东西是个宏定义 |
线程缓存的tsd存储了arena和tcache等数据,tsd会在线程首次分配内存的时候创建并保存到线程缓存。
arena的数量,如果是单核就是一个arena,如果是多核arena数量是核数的4倍。
1 | if (ncpus > 1) |
线程首次创建的时候按照下面规则选择arena
- 如果arena数量没有达到上面指定的数量,直接创建
- 选择绑定线程数少的arena之后调用 arena_malloc 来分配内存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61/* Slow path, called only by arena_choose(). */
arena_t *
arena_choose_hard(tsd_t *tsd)
{
arena_t *ret;
if (narenas_auto > 1) {
unsigned i, choose, first_null;
choose = 0;
first_null = narenas_auto;
malloc_mutex_lock(&arenas_lock);
assert(a0get() != NULL);
for (i = 1; i < narenas_auto; i++) {
if (arenas[i] != NULL) {
/*
* Choose the first arena that has the lowest
* number of threads assigned to it.
*/
if (arenas[i]->nthreads <
arenas[choose]->nthreads)
choose = i;
} else if (first_null == narenas_auto) {
/*
* Record the index of the first uninitialized
* arena, in case all extant arenas are in use.
*
* NB: It is possible for there to be
* discontinuities in terms of initialized
* versus uninitialized arenas, due to the
* "thread.arena" mallctl.
*/
first_null = i;
}
}
if (arenas[choose]->nthreads == 0
|| first_null == narenas_auto) {
/*
* Use an unloaded arena, or the least loaded arena if
* all arenas are already initialized.
*/
ret = arenas[choose];
} else {
/* Initialize a new arena. */
choose = first_null;
ret = arena_init_locked(choose);
if (ret == NULL) {
malloc_mutex_unlock(&arenas_lock);
return (NULL);
}
}
arena_bind_locked(tsd, choose);
malloc_mutex_unlock(&arenas_lock);
} else {
ret = a0get();
arena_bind(tsd, 0);
}
return (ret);
}这里看到对于不同的size参数做了不同的处理,这里也可以映证small和large是有tcache的,而huge没有。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30JEMALLOC_ALWAYS_INLINE void *
arena_malloc(tsd_t *tsd, arena_t *arena, size_t size, bool zero,
tcache_t *tcache)
{
assert(size != 0);
arena = arena_choose(tsd, arena);
if (unlikely(arena == NULL))
return (NULL);
if (likely(size <= SMALL_MAXCLASS)) {
if (likely(tcache != NULL)) {
return (tcache_alloc_small(tsd, arena, tcache, size,
zero));
} else
return (arena_malloc_small(arena, size, zero));
} else if (likely(size <= large_maxclass)) {
/*
* Initialize tcache after checking size in order to avoid
* infinite recursion during tcache initialization.
*/
if (likely(tcache != NULL) && size <= tcache_maxclass) {
return (tcache_alloc_large(tsd, arena, tcache, size,
zero));
} else
return (arena_malloc_large(arena, size, zero));
} else
return (huge_malloc(tsd, arena, size, zero, tcache));
}
small size 分配
先调用 tcache_alloc_easy 从 tcache 中的去分配,从 tbin->avail 直接拿一个
1 | JEMALLOC_ALWAYS_INLINE void * |
如果 tcache 中没有就调用 tcache_alloc_small_hard 去调用 arena_bin_nonfull_run_get 先尝试从 bin->runs 中分配一个空闲run,如果没有空闲的会调用 arena_run_alloc_small 从chunk中分配一个(如果没有chunk会分配一个新的chunk),新分配的 run 会被设置到 bin->runcur 方便后续使用,这个过程会将分配出的 region 保存到 tcache_bin_t->avail 中
1 | void * |
这里注意到 arena_tcache_fill_small 中填充了下面这么多个region到tbin->avail中
1 | tcache_bin_info[binind].ncached_max >> tbin->lg_fill_div |
从run中分配region的函数如下
1 | JEMALLOC_INLINE_C void * |
large size 分配
如果有tcache且大小没超过 tcache 的最大范围,就会调用 tcache_alloc_large 从中直接分配,里面是直接调用 tcache_alloc_easy,这里就不赘述。
如果没有tcache或者大小超过了或者tcache中没货了,会调用 arena_run_alloc_large 直接从run中去分配,分配large run的流程和small也差不多
huge size 分配
先创建一个 extent_node_t 来存放后续分配chunk的信息,然后调用 arena_chunk_alloc_huge 来分配,里面会先调用 chunk_alloc_cache 来试着从回收的chunk中来分配,如果不行再调用 arena_chunk_alloc_huge_hard 来分配,这里注意,之前说的chunk的大小是2M,是针对small和large的,huge的chunk size是根据申请的内存大小来的。
huge chunk 被分配出来后会存储到 arena 的 huge 链表方便使用
1 | void * |
内存回收
内存回收的核心代码,和分配的代码没太大差异,先通过指针找到chunk,因为chunk的分配都是基于固定offset的,然后通过根据page大小定位到当前指针所在page,并通过arena_mapbits_get找到它的mapbits,mapbits里有它的binind,binind里面有很多信息,包括size class。
以small size为例,首先去把内存还回tbin->avail这个数组,如果这个数组里元素太多超过tbin_info->ncached_max,就会调用 tcache_bin_flush_small 来释放
1 | JEMALLOC_ALWAYS_INLINE void |
small size达到一定数量的时候通过下面的函数来释放内存,调用 tcache_bin_flush_small 函数来执行,其中 rem 为 tbin_info->ncached_max 的一半,里面调用 arena_dalloc_bin_junked_locked 函数来做实际的内存回收操作。这里面调用 arena_run_reg_dalloc 来先把region还给run,并把nfree加1,然后程序判断这个run是不是都free了,如果都free了就表示可以回收这个run了,会调用 arena_dissociate_bin_run 解除run和chunk的关系,然后调用 arena_dalloc_bin_run 进行回收
1 |
|
最终是调到了 arena_run_dalloc,先把run插入到 arena 的 runs_avail,如果发现 整个chunk都是空的了,会调用 arena_chunk_dalloc 回收chunk,最终是调用 chunk_dalloc_cache,最终都会打到 arena_maybe_purge ,最终都会调用到 chunk_dalloc_mmap ,调用munmap进行回收。
1 | static void |
pages_unmap
总结
- 一些tsd和arena的get set 方法是宏定的
- tsd的获取代码是宏定义的
- 代码存在namespace,代码没法直接跳过去,只能按照函数名全文搜索,搜索的时候带上参数类型就能快速搜索到了
- 一些函数编译后会加上je_前缀