1 简介
在C语言中,通常直接使用malloc进行内存的分配,而频繁的分配、释放内存无疑会产生内存碎片,降低系统性能。PHP自己实现了一套内存池(ZendMM
:Zend Memery Manager
)用于替换glibc
的malloc
、free
,以解决内存频繁分配、释放的问题。
内存池技术主要作用:
- ①、减少内存分配及释放的次数
- ②、有效控制内存碎片的产生
PHP的内存池的实现参考了tcmalloc
的设计,tcmalloc
是Google
开源的一个非常优秀的内存分配器。
内存池是PHP内核中最底层的内存操作,他是非常独立的一个模块,可以移植到其他C语言应用中去。
内存池定义了三种粒度的内存块:
- chunk:每个chunk的大小为2M
- page:page的大小为4KB,每个chunk被切割为512个page(2048/4)
- slot:一个或若干个page被切割为多个slot
申请内存时按照不同的申请大小决定具体的分配策略:
- Huge(chunk):申请内存大于2MB,直接调用系统分配,分配若干个chunk.(>2MB)
- **Large(page)**:申请内
存大于3092B(即page大小的3/4, 4 * 1024 * 3/4 = 3072B),小于2044KB(即511个page的大小),分配若干个page。(3072B~2044KB) - Small(slot):申请内存小于等于3092B(即page大小的3/4),内存池提前定义好了30种同等大小的内存(8、16、24、32…3072),它们分配在不同的page上(不同大小的内存可能会分配在多个连续的page),申请内存时直接在对应的page上查找可用的slot。(<3092B)
内存池通过zend_mm_heap结构存储内存池的主要信息,比如大内存链表
、chunk链表
、slot各大小内存链表
等。
1 | // file:zend_alloc.c |
大内存分配的是若干个chunk,然后通过一个zend_mm_huge_list结构
进行管理,大内存之间构成一个单向链表。
1 | // file:zend_alloc.h |
chunk是内存池向系统申请、释放内存的最小粒度。chunk
之间构成双向链表,第一个chunk的地址保存于zend_mm_heap->main_chunk
。
每个chunk的大小为2MB,被切割为512个page,所以每个page的大小为4KB,其中第一个page的内存用于chunk自己的结构体成员,主要记录chunk的一些信息,比如前后chunk的指针,当前chunk上各个page的使用情况等。
chunk的定义结构如下:
1 | // file:zend_alloc.c |
slot内存是把若干个page按照固定大小分割好的内存块。
内存池定义了30中大小的slot内存:8、16、24、32…1792、2048、3072.这些slot的大小是有规律的。
- ①、最小的slot大小为8byte
- ②、前8个slot一次递增8byte(0~7递增 8byte)
- ③、后面每隔4个递增值乘以2(8
11递增16byte、1215递增32byte、1619递增64byte、2023递增128byte、2427递增256byte、2830递增512byte)
每种大小的slot占用的page数不相同的:
①、slot0~15各占1个page
②、slot16~29分别占5、3、1、1、5、3、2、2、5、3、7、4、5、3个page;
注:这个值实际也是有规律可循的,其算法为,目的是为了减少内存的碎片。1
page的个数 = 最小公倍数(slot对应的内存大小, page的大小即4096) / 4096
分配各个规格的slot时会按照各这个配置申请对应的数量的page,然后进行分割组成链表。<u>相同大小的slot之间构成单链表</u>。
1 | struct _zend_mm_free_slot { |
heap、huge、chunk、page、slot之间的关系如下图所示:
2 内存池的初始化
初始化过程主要是分配heap结构
,如果是多线程环境,则会为每一个线程分配一个内存池,线程之间互不影响。
注:zend_mm_heap
这个结构不是单独分配的,它嵌在chunk
结构体中(即heap_slot
成员)。也就是说内存池初始化时是分配了一个chunk结构,zend_mm_chunk->heap_slot
作为内存池的heap结构,这个chunk也是第一个chunk,即main_chunk,如下图所示:
问题:为什么内存时的heap结构要嵌在chunk中而不是单独分配呢?
因为每个chunk的第一个page始终是给chunk结构体自己使用的,剩下的511个page才会做内存分配,但是chunk结构体并不需要一个page那么大的内存。也就是说被占用的page会有剩余的空间,因此,为了尽可能利用空间,就将heap结构嵌在了chunk中。
具体的分配过成在zend_mm_init()中实现:
1 | // file:zend_alloc.c |
3 内存的分配
Huge大内存的分配过程比较简单、而Large与Small内存分配涉及到page的查找操作,过程稍显复杂。
使用emalloc
申请时,内存池会按照申请内存的大小自动选择那种格内存进行分配,如下图所示:
3.1 Huge分配
Huge是指超多2MB大小内存的分配,实际分配时将对齐到n个chunk,分配完还会分配一个zend_mm_huge_list结构,用于管理所有的Huge内存。
1 | static void *zend_mm_alloc_huge(zend_mm_heap *heap, size_t size ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC) |
除了Huge分配以外,分配chunk内存也是Large、Small内存分配的基础,它是ZendMM向系统申请内存的唯一粒度。
注:分配chunk时,会将内存地址对齐到chunk的大小2MB(ZEND_MM_THUNK_SIZE
)。也就是说,分配的chunk地址都是ZEND_MM_THUNK_SIZE
的整数倍。(实际上这个对齐并不是由系统简单的完成,而是需要内存池在申请内存后自己进行调整!)
ZendMM
具体处理对齐的方法是:
- ①、按实际要申请的内存大小申请一次
- 若系统分配的地址恰好是ZEND_MM_CHUNK_SIZE的整数倍,则不需要调整,直接返回。
- 若系统分配的地址不是ZEND_MM_CHUNK_SIZE的整数倍,则需要使用第②步进行调整。
- ②、调整方式:
- 首先ZendMM会将这块内存释放掉;
- 按照“
实际要申请的内存大小 + ZEND_MM_CHUNK_SIZE
”的大小重新申请一块内存,多申请的ZEND_MM_CHUNK大小的内存是用来调整的,ZendMM
会从系统分配的地址向后偏移到最近一个ZEND_MM_CHUNK_SIZE的整数倍
位置,调整完之后再把剩余的内存释放掉。
1 | static void *zend_mm_chunk_alloc_int(size_t size, size_t alignment) |
其中用到了ZEND_MM_ALIGNED_OFFSET宏
,这个宏的作用是计算按alignment对齐的内存地址距离上一个alignment整数倍内存地址的大小,也就是offset偏移量。
alignment必须是2的n次方,比如一段n*alignment
大小的内存,ptr为其中一个位置,那么就可以通过位运算计算得到ptr在所属alignment内存块中的offset,如下图所示:
1 | #define ZEND_MM_ALIGNED_OFFSET(size, alignment) \ |
这个位运算是因为alignment
为2^n
(用二进制表示即为第n个位上为1,其余位为0,当alignment-1相当于将除了第n位为0,其余低位全部为1),所以通过alignment取到最低的位置,也就是相对上一个整数倍的alignment的offset,非位运算算式如下,但效率没有位运算高。
1 | offset = ptr - (ptr/alignment取整 * alignment) |
3.2 Large分配
当申请的内存大于3072B、小于2044KB时,内存池会选择在chunk上查找对应数量的page返回。Large内存申请的粒度是page,也就是分配n页连续的page,所以Large分配的过程就转化为在chunk上查找n页连续可用的page的过程。
1 | static zend_always_inline void *zend_mm_alloc_large(zend_mm_heap *heap, size_t size ZEND_FILE_LINE_DC ZEND_FILE_LINE_ORIG_DC) |
chunk结构中有两个成员用于记录page分配信息:
free_map:类型为
zend_mm_page_map
,实际就是zend_ulong_free_map[16/8]
,这是一个bitmap,总大小为64byte,也就是512bit,用于记录用当前chunk上512个page是否分配,512个page对应512bit,1表示已分配,0表示未分配。map:这个是一个可容纳512个类型为uint32_t元素的数组,该数组用于记录各page的分配类型及分配的page页数,每个page对应一个数组成员。Large内存、Small内存都会占用page,正是通过这个数组标识改page属于哪个类型(最高两位用于标识page的分配类型):
- Large:01(0x40000000)
Small:10(0x80000000)
示例:申请12KB的内存,即3个page,内存池分配了page1,2,3,则map[1] = 0x400000000|3,如下图所示:
![page的分配类型及页数](https://note.youdao.com/yws/api/personal/file/474A05B6CB2D401785FA0FAB75FB3E97?method=download&shareKey=a1ce9cdd22022755fffe79243bd1db0e)
page分配时从第一个chunk开始遍历,依次查找各chunk是否有满足要求的page,如果当前chunk没有合适的,则进入下一chunk,如果直到最后都没有找到,则新分配一个chunk。
分配准则为:申请的page页数要尽可能地填满chunk的空隙,也就是说尽可能的与分配了的page连在一起,避免中间出现page空隙。减少后续分配时的茶之后按次数,提高内存利用率。
最优page的检索过程如下:
step1:
首先从第一个page分组(page0~63)开始检查,如果当前分组无空闲page(即free_map[x]=-1)则进入下一分组,知道当前分组有空闲page,然后进入step2.step2:
当前分组有可用page,首先检查当前page分组的bit位,找到第一个空闲page的位置,记做page_num,接着继续向下查找空闲page,知道遇到第一个已经分配的page位置,将最后一个空闲page位置记做end_page_num
。(注:查找end_page_num时并不局限在当前page分组内,会向下查找,直到最后一页。其查找做成主要依据free_map),page_num至end_page_num为找到的可用page,接着判断找到的page页数是否够用:- 不够的情况:将page_num至end_page_num这些page的bit位标为1,也就是已分配,然后回到step1继续检索其他page分组。
- 刚好是要申请的页数:直接使用,中断检索。
- page页数比申请的页数大,则表示可用,但不一定是最优的,将page_num暂存起来,接着回到step1继续向后找别的空闲page,最后比较选择best_len最小的,即能够最大程度填满page间隔示例:当前某个chunk的page分配情况如下图中的A所示,page:0,1,2,6,9,10已经分配占用,接下来要申请2页的page。
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/* find first 0 bit */
// tmp为当前page分组的bit位,i为当前分组第1个page的页码。
page_num = i + zend_mm_bitset_nts(tmp);
/* reset bits from 0 to "bit" */
tmp &= tmp + 1;
/* skip free blocks */
// 快速跳过剩余page全部可用的分组
while (tmp == 0) {
i += ZEND_MM_BITSET_LEN;
if (i >= free_tail || i == ZEND_MM_PAGES) {
len = ZEND_MM_PAGES - page_num;
if (len >= pages_count && len < best_len) {
chunk->free_tail = page_num + pages_count;
goto found;
} else {
/* set accurate value */
chunk->free_tail = page_num;
if (best > 0) {
page_num = best;
goto found;
} else {
goto not_found;
}
}
}
// 当前分组剩下的page都是可用的,直接跳到下一分组
tmp = *(bitset++);
}
/* find first 1 bit */
// 找到第一个已分配page
len = i + zend_mm_bitset_ntz(tmp) - page_num;
if (len >= pages_count) {
if (len == pages_count) {
goto found;
} else if (len < best_len) {
best_len = len;
best = page_num;
}
}
/* set bits from 0 to "bit" */
// 把找到的这些page标为已分配,注:此时tmp已经经过tmp &= tmp + 1处理
tmp |= tmp - 1;
step3:
最后没找到合适的page页后设置对应page的分配信息,即free_map、map,然后返回找到第一页page的地址1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18found:
if (steps > 2 && pages_count < 8) {
/* move chunk into the head of the linked-list */
chunk->prev->next = chunk->next;
chunk->next->prev = chunk->prev;
chunk->next = heap->main_chunk->next;
chunk->prev = heap->main_chunk;
chunk->prev->next = chunk;
chunk->next->prev = chunk;
}
/* mark run as allocated */
chunk->free_pages -= pages_count;
zend_mm_bitset_set_range(chunk->free_map, page_num, pages_count);
chunk->map[page_num] = ZEND_MM_LRUN(pages_count);
if (page_num == chunk->free_tail) {
chunk->free_tail = page_num + pages_count;
}
return ZEND_MM_PAGE_ADDR(chunk, page_num);
3.3 Small分配
Small内存在分配时,首先检查申请规格的内存是否已经分配,如果没有分配或者分配的已经用完了,则申请相应页数的page,page的分配过成与Larg分配完全一致,申请到page以后按固定大小将page切割为slot,slot之间构成单链表,链表头部保存至AG(mm_heap)->free_slot
;如果对应的slot已经分配,则直接返回AG(mm_heap)->free_slot
。
示例:16byte、3072byte大小的slot,将分别申请1个、3个page、然后切割为256个16byte的slot,以及4个3072byte的slot,如下图所示:
4 系统内存分配
内存池向系统申请内存的最小粒度是chunk,通过mmap()来申请。
5 内存释放
内存释放主要通过efree()
来完成,内存池会根据释放的内存地址自动判断属于哪种粒度的内存,从而执行不同的释放逻辑。
问:内存池是如何只根据一个地址就判断出改地址属于哪种内存类型的呢?
因为chunk分配时是按照ZEND_MM_CHUNK_SIZE(即2MB)对齐的,也就是chunk的起始内存地址一定是ZEND_MM_CHUNK_SIZE的整数倍,所以可以根据chunk上的任意位置知道chunk的起始位置与所在page。
5.1 Huge内存的释放
首先,根据释放地址ptr计算该机制相对chunk起始位置的内存偏移量,这个值通过宏ZEND_MM_ALIGNED_OFFSET()的到,通过位运算计算的到。
示例:ptr = 0x7ffff7c01000,计算的到offset = 4096
1 | offet = ptr & (alignment - 1) = 0x7ffff7c01000 & 0x1fffff = 0x1000 = 4096 |
Huge内存能够完全使用chunk,也就是Huge内存地址相对chunk的offset一定等于0,而Large、Small内存因为chunk的第1个page被占用了,所以这两种内存的offset不可能为0.
内存池根据offset值判断出释放的内存是否为Huge类型
,如果是则将占用的chunk释放,同时从AG(mm_heap)->huge_list链表中删除。
5.2 Large内存的释放
若计算得到的offset不等于0,则表示该地址是Large内存或者Small内存,然后根据offset值进一步计算出属于第几个page
计算方法:根据offset除page的大小取整,的到page_num
,的到page
页码后就可以从chunk->map
中获取该page
的分配类型,知道是何种粒度的内存了。
Large内存,并不会直接释放物理内存,只是将对应的page的分配信息重新设置为未分配。若释放page后,当前chunk下所有的page都是未分配的,则会释放chunk,释放时优先选择把chunk移到AG(mm_heap)->cached_chunks
缓存队列中,缓存数达到一定值后就不在继续缓存新加入的chunk,将内存归还系统,便面占用过多的资源。
(分配chunk时,如果发现cached_chunks中有缓存的chunk,就直接取出使用,不再向系统申请。)
5.3 Small内存的释放
若待释放的地址为Small内存,则会将释放的slot插入到该规格slot可用链表的头部,如下图所示: