使用PHP构建内存池
内存池的作用
- 提高内存分配效率,通过预先向系统申请一块大的内存用来分配,避免重复向系统申请和释放内存所带来的消耗,提高执行效率
- 实现动态内存分配和释放(malloc只支持小于128K的内存)
- 提高内存利用率,避免产生大量的内存碎片
实现
首选我们需要申请一块大的内存用来分配,我们将这块内存称为chunk内存
。假设这块内存的大小为size = 100Byte
,它返回的内存起始地址为*ptr = 0x0
。

我们使用一个内存池管理类HeapObject
进行管理。在只申请不释放的情况下,我们只需使用两个成员变量*ptr
和size
就可以管理内存池了。它的结构为:
HeapObject{
public ptr;//可分配内存起始地址
public size;//剩余可分配内存
public function __contruct($ptr, $size){
$this->ptr = $ptr;
$this->size = $size;
}
//分配内存
public function malloc($size){
if($this->size < $size){
return NULL;
}
$ptr = $this->ptr;
$this->size -= $size;
$this->ptr += $size;
return $ptr;
}
}
//初始化内存池
$HeapObject = new HeapObject(0x0,100);
当我们申请内存时,只需要偏移ptr指针和减少可分配内存就可以了。
$ptr = $HeapObject->malloc(20);//申请20Byte的内存
/**
* $HeapObject {
* $ptr => 0x14;
* $size => 80;
* }
* /

当$HeapObject->size
只剩下20Byte时,如果我们向内存池申请30Byte
大小的内存,会发现内存池中可分配的内存不够了,这时候我们需要再申请一块chunk
内存用来分配。将ptr
偏移到新chunk
的起始位置。分配之后的结构为
$ptr = $HeapObject->malloc(30);//申请20Byte的内存
/**
* $HeapObject {
* $ptr => 0x82;
* $size => 70;
* }
* /
但是这样旧内存的20Byte就无法被分配。
所以这时候只通过ptr
和size
这两个成员变量来管理内存池已经无法满足需求了。


我们需要再增加一个ChunkObject类来管理chunk
内存里的ptr
和size
。
HeapObject{
public $Chunks = [];//chunk数组
public $size;//内存池已分配内存
public function Chunk_init(){
$this->Chunks[] = new ChunkObject($this->Chunk_ini());
}
private function Chunk_ini($chunk = 1){
//向系统申请内存$chunk个CHUNK_SIZE的内存
...
return ptr;
}
public function malloc($size){
foreach($this->chunks as $chunk){
if($chunk->size < $size)continue;
$this->size += $size;
$ptr = $chunk->malloc($size);
if($ptr === NULL)continue;
return $ptr;
}
return NULL;
}
}
ChunkObject{
public ptr;//可分配内存起始地址
public size;//剩余可分配内存
public function __contruct($ptr, $size){
$this->ptr = $ptr;
$this->size = $size;
}
public function malloc($size){
if($this->size < $size){
return NULL;
}
$ptr = $this->ptr;
$this->size -= $size;
$this->ptr += $size;
return $ptr;
}
}
申请内存时首先遍历$HeapObject->chunks
数组,查找数组中的元素$ChunkOject
的可分配内存size
是否满足需求,满足则在该$ChunkObject
上进行内存分配,如果所有chunk
都不满足,则增加一个新的chunk
进行分配。
如果分配大于chunk
大小的内存时该怎么办呢?这时候我们需要申请n个chunk
用来分配内存,但是申请出来的chunk
无法再分配,再用HeapObject->Chunks
来管理显然不太合适,所以我们定义一个新的成员变量(HeapObject->huge_list
)来管理这些大于chunk
大小的内存。我们将大于等于chunk
大小的内存称为huge
内存,分配小于chunk大小的内存称为large
内存分配。HeapObject
修改为:
HeapObject{
public $Chunks = [];//Chunk数组
public $size;//内存池已分配内存
public $huge_list = [];
public function Chunk_init(){
ptr = $this->Chunk_ini();
h $this->Chunks[ptr] = new ChunkObject(ptr);
}
private function Chunk_ini($chunk = 1){
//向系统申请内存$chunk个CHUNK_SIZE的内存
...
return ptr;
}
public function malloc($size){
if($size >= ChunkObject::CHUNK_SIZE){
$this->Huge_malloc($size);
}else{
$this->large_malloc($size);
}
}
public function large_malloc($size){
foreach($this->chunks as $chunk){
if($chunk->size < $size)continue;
$this->size += $size;
$ptr = $chunk->malloc($size);
if($ptr === NULL)continue;
return $ptr;
}
return NULL;
}
public function huge_malloc($size){
$chunk_size = ceil($size / ChunkObject::CHUNK_SIZE);
$ptr = $this->chunk_ini($chunk_size);
$this->huge_list[] = [$ptr, $chunk_size*ChunkObject::CHUNK_SIZE];
}
}
在考虑内存回收的情况下,用这样的结构来管理内存池就不太合适了。只用ptr
和size
来管理内存池就无法使用已回收的内存。

在这种情况下我们需要保存所有可以分配的内存间隙。我们使用一个数组$ChunkObject->free_map[]
来管理可分配的内存间隙。
ChunkObject{
public ptr;//内存起始地址
public size;//剩余可分配内存
const CHUNK_SIZE = 100;//chunk的大小
public $free_map = [];//array(array(ptr,size),array(ptr,size)...array(ptr,size));
public function __contruct($ptr){
$this->ptr = $ptr;
$this->size = self::CHUNK_SIZE;
$this->free_map[] = [$ptr,self::CHUNK_SIZE];
}
public function malloc($size){
if($this->size < $size){
return NULL;
}
foreach($this->free_map as &$map){
if($map[1] < $size)continue;
$ptr = $map[0];
$map[0] += $size;
if($map[1] -= $size == 0)unset($map);
$this->size -= $size;
return $ptr
}
return NULL;
}
public function free($ptr,$size){
$this->free_map[] = [$ptr,$size];
$this->size += $size;
}
}
在内存不断的申请和释放之后,很容易产生大量的内存间隙,这种内存间隙就是所谓的内存碎片。如连续申请两块5Byte
大小的内存,将第一块内存释放,再在该位置申请一个4Byte
大小的内存,这样就会产生1Byte
大小的内存间隙。这样大小的内存很少被申请,不仅增加了内存申请时需要遍历的节点,还浪费了内存,降低了内存分配的效率。
我们可以将内存切割成一个个page
,分配的时候只要分配n个page
。这样虽然还是会造成内存的浪费,但是增加了内存间隙的大小,减少了内存分配时需要遍历的节点数量。提高了内存分配效率,这也是典型的空间换时间。
在上面的例子中,我们可以将内存分成4Byte
大小的内存块。需要分配5Byte
大小的内存时,我们分配2块内存块给程序,也就是8Byte
,虽然这样会有3Byte
大小的内存被浪费了,但是这样使内存间隙的最小为4Byte
,需要遍历的节点变少了(100 / 4 = 25个节点,最坏需要遍历25/2 = 12.5个节点)。
将100Byte
大小的内存以4Byte
进行切割,可以切割成25
块。我们将4Byte
大小的内存块称为page,chunk
只需要管理好这25个page就好了,page只有两种状态,分配和未分配。以0来标识未分配,1来标识已分配。ChunkObject
可以修改成:

ChunkObject{
public ptr;//内存起始地址
public size;//剩余可分配内存
const CHUNK_SIZE = 100;//chunk的大小
public $free_map = [];//array(0,0,0...0,0);
public function __contruct($ptr){
$this->ptr = $ptr;
$this->size = self::CHUNK_SIZE;
for($i = 0;$i < 25;$i++){
$this->free_map[] = 0;
}
}
public function malloc($size){
if($this->size < $size){
return NULL;
}
$page_num = ceil($size / 4);
$page_index = NULL;
$free_page_num = 0;
foreach($this->free_map as $index => $page){
if($page == 0){
if($page_index === NULL)$page_index = $index;
$free_page_num += 1;
continue;
}
if($page == 1){
if($page_index !== NULL && $free_page_num >= $page_num){
$this->size -= $page_num*4;
for($i = $page_index;$i < $page_num;$i++){
$this->free_map[$i] = 1;
}
return $this->ptr + $page_index*4;
}
$page_index = NULL;
$free_page_num = 0;
}
}
return NULL;
}
public function free($ptr,$size){
$page_num = ceil($size / 4);
$page_index = floor(($ptr - $this->ptr)/4);
for($i = $page_index;$i < $page_num;$i++){
$this->free_map[$i] = 0;
}
$this->size += $page_num * 4;
}
}
内存分配就是根据需要分配的page_num
查找连续0
的个数是否满足条件,满足则返回连续0
的起始位置ptr
.
为了使内存分配更加紧凑,产生的间隙更少,需要查找可以分配间隙的最优解。也就是使free_page_num - page_num的值最小
当我们需要申请1Byte
大小的内存时,我们分配一个page
,但是这样就会造成3Byte
的浪费。如果像chunk
那样来管理page
的话,显然没有必要。简单的办法是,我们可以用这个page
专门用来分配1Byte
大小的内存,这样一个page
就可以分配4
个1Byte
大小的内存。我们将这样的内存块称为slot。
小于page的内存大小有1Byte
、2Byte
、3Byte
。1Byte
和2Byte
都可以在一个page
上分配,3Byte
则无法占满一个page
,我们可以用3个page
来分配4个3Byte
大小的内存(3*4Byte = 4*3Byte)。
这些小于page
的内存称为small内存。
在HeapObject
上增加一个新的成员变量用来管理slot
。
HeapObject{
···
public free_slot = [];//array(size => array(ptr,ptr))
const SLOT_SIZE = [//[solt大小=>[需要的pape数量,可分配的slot数量]]
1=>[1,4],2=>[1,2],3=>[3,4]
],
public function small_malloc($size){
$ptr = array_shift($this->free_slot[$size]);
if($ptr == NULL){
$ptr = $this->large_malloc(self::SLOT_SIZE[$size][0]*4);
for($i = 1;$i< self::SLOT_SIZE[$size][1];$i++){
$this->free_slot[] = $slot_ptr + $i*$size;
}
}
return $ptr;
}
public function small_free($ptr,$size){
array_unshift($this->free_slot[$size],$ptr);
}
···
}
分配slot
大小的内存时,先在HeapObject->free_slot
上查找是否有可用的节点,有则直接返回,没有则分配对应的page
并初始化slot
内存。HeapObject->free_slot
上只需要保存ptr
的值就够了,size
都是固定的。

现在的结构已可以满足所有大小的内存的分配,但是在内存释放的时候还是要告诉内存池需要释放的内存大小,无法满足动态内存释放的要求。
如果释放的内存大小与申请的内存大小不一致就会产生内存泄漏的问题。我们需要增加一定的规则才能使我们知道ptr所对应的内存大小。
常用的方法是使用内存对齐:使ptr
向一定的规则对齐,如使chunk
的内存起始地址都为100
的整数倍,这样我们就能直接知道需要释放的ptr
所对应的chunk指针(chunk_ptr = (ptr / 100)*100)
,而ptr
所在的page_index = (ptr % 100)/4
,但这样还无法知道我们需要释放的内存大小。所以我们需要额外的结构来管理。
ChunkObject{
···
public $map = [];//[page_info,page_info]
/**
* page_info:type_num
* type = 1 为large内存分配,num为所分配的page数量
* type = 2 为small内存分配,num为对应的size
*
* /
···
}

如上图,需要释放的ptr
是205
。首先获得chunk
的起始地址为200
,查到对应的ChunkObject
。在计算得到page_index
= 1,则在ChunkObject->map
中对应的page_info
为2_1
。可以得到该ptr
分配的是slot
内存,大小为1
字节,释放的时候将ptr加到HeapObject->free_slot
对应数组的头部就可以了。
如果是page
内存,则将ChunkObject->free_map
中对应的数组元素置为0
。
以上就是使用PHP来实现的内存池。但是用hashTable
来管理节点,显然效率不会太高。接下来我们将一起来学习在C语言中,Zend内核是如何实现内存池。
Zend 内存池
首先我们先来回顾下上面所涉及到的概念
- Chunk:内存池中向系统申请和释放的最小单位,
chunk
的大小为2M
。 - Page:在
chunk
中将内存切割成512
个page
,每个page
的大小为4KB
。 - slot:
Zend
将小于page
的内存按大小分成了30种规格,最小为8Byte
,最大为3KB
。
内存分配策略
- Huge内存:大于
2M
的内存使用Huge
内存分配策略,分配n
个chunk
大小的内存,使用huge_list
链表管理。 - Large内存:大于
3KB
,小于4092KB
的内存使用large
内存分配策略,分配n
个连续的page
内存,使用zend_mm_chunk
结构体管理 - Small内存:小于等于3KB的内存使用
small
内存分配策略,分配对应大小的slot
内存。使用free_slot
链表管理。
struct _zend_mm_heap {
zend_mm_free_slot *free_slot[ZEND_MM_BINS]; /* free lists for small sizes */
zend_mm_huge_list *huge_list; /* list of huge allocated blocks */
zend_mm_chunk *main_chunk;
zend_mm_chunk *cached_chunks; /* list of unused chunks */
int chunks_count; /* number of allocated chunks */
int peak_chunks_count; /* peak number of allocated chunks for current request */
int cached_chunks_count; /* number of cached chunks */
double avg_chunks_count; /* average number of chunks allocated per request */
int last_chunks_delete_boundary; /* number of chunks after last deletion */
int last_chunks_delete_count; /* number of deletion over the last boundary */
};
zend_mm_heap
对应上文的HeapObject
。
*free_slot
为slot指针数组,ZEND_MM_BINS
常量为30
。数组保存的是各个规格slot
链表的头部,链表的每个节点只保存next
指针。
- 分配
small
内存的时候,先查找heap->free_slot[bin_num]
是否为null
,是则重新分配slot
内存,否则删除头部节点并返回ptr
。 - 释放
small
内存的时候,根据ptr
获取page_info
,获得slot
的下标,将节点添加到链表头部就可以。
/* num, size, count, pages */
#define ZEND_MM_BINS_INFO(_, x, y) \
_( 0, 8, 512, 1, x, y) \
_( 1, 16, 256, 1, x, y) \
_( 2, 24, 170, 1, x, y) \
_( 3, 32, 128, 1, x, y) \
_( 4, 40, 102, 1, x, y) \
_( 5, 48, 85, 1, x, y) \
_( 6, 56, 73, 1, x, y) \
_( 7, 64, 64, 1, x, y) \
_( 8, 80, 51, 1, x, y) \
_( 9, 96, 42, 1, x, y) \
_(10, 112, 36, 1, x, y) \
_(11, 128, 32, 1, x, y) \
_(12, 160, 25, 1, x, y) \
_(13, 192, 21, 1, x, y) \
_(14, 224, 18, 1, x, y) \
_(15, 256, 16, 1, x, y) \
_(16, 320, 64, 5, x, y) \
_(17, 384, 32, 3, x, y) \
_(18, 448, 9, 1, x, y) \
_(19, 512, 8, 1, x, y) \
_(20, 640, 32, 5, x, y) \
_(21, 768, 16, 3, x, y) \
_(22, 896, 9, 2, x, y) \
_(23, 1024, 8, 2, x, y) \
_(24, 1280, 16, 5, x, y) \
_(25, 1536, 8, 3, x, y) \
_(26, 1792, 16, 7, x, y) \
_(27, 2048, 8, 4, x, y) \
_(28, 2560, 8, 5, x, y) \
_(29, 3072, 4, 3, x, y)

*huge_list
为huge
内存链表的头部,节点保存huge
内存的*ptr
、size
和*next
。
- 分配
huge
内存的时候,将节点插入头部。 - 释放
huge
内存的时候,根据*ptr
遍历链表,删除对应的节点并释放内存。
struct _zend_mm_huge_list {
void *ptr;
size_t size;
zend_mm_huge_list *next;
};

*main_chunk
为chunk链表的头部,节点为zend_mm_chunk
。zend
启动时会申请一块chunk
作为main_chunk
,main_chunk
保存了zend_mm_heap
结构,在进程结束之后才会被释放。chunk
链表为环状双向链表,新申请的chunk
都会添加到链表的尾部。需要申请page时遍历chunk链表查找是否有足够可分配的page。

*cached_chunks
为chunk缓存链表的头部,回收chunk时并不会立即向系统释放内存,而是会根据一定规则设置缓存chunk的上限,当缓存chunk的数量达到上限时才会向系统释放。
zend_mm_chunk
对应上文的ChunkObject
。
struct _zend_mm_chunk {
zend_mm_heap *heap;
zend_mm_chunk *next;
zend_mm_chunk *prev;
uint32_t free_pages; /* number of free pages */
uint32_t free_tail; /* number of free pages at the end of chunk */
uint32_t num;
char reserve[64 - (sizeof(void*) * 3 + sizeof(uint32_t) * 3)];
zend_mm_heap heap_slot; /* used only in main chunk */
zend_mm_page_map free_map; /* 512 bits or 64 bytes */
zend_mm_page_info map[ZEND_MM_PAGES]; /* 2 KB = 512 * 4 */
};
zend_mm_heap只会在main_chunk
中分配。
free_map
为8个64位无符号整数数组或者16个32位无符号整数数组(根据操作系统区分)
zend
使用bit
位来保存page
的分配情况,0
为未分配,1
为已分配。所以为了保存512
个page
的分配情况,需要512bit
的空间。数组元素的值为-1
时表示对应的page
都已被分配。
分配page
时就是在二进制数中查找连续0的个数。zend
使用GCC
内置函数__builtlin_czl
(二进制数从右起连续0的个数),具体操作如下:
假设需要操作的数为一个字节
bit = 00100101
page_index = __builtlin_ctrl(~bit)//取反码 11011010 ,连续0的个数为1
tmp &= tmp + 1 //将右起连续1置为0 00100100
page_index2 = __builtlin_ctrl(tmp)//连续0的个数为2
page_num = page_index2 - page_index//可以得到page的可分配间隙 page下标为1,可分配page数量为1。
//将已查找过的位数置为1
tmp |= tmp - 1//00100111
//重复以上的步骤,查找出最佳的page_index和page_num
map
为512个32位无符号整数数组
zend
使用32
位无符号整数的头两位来保存page
的类型。用16进制来标识为:
large
内存的page_info
,首两位为01
,16
进制数表示为0x40000000
。尾部用来保存使用的page
数量,如0x40000003
表示连续使用了3
个page
。slot
内存第一个page的page_info
,首两位为10
,16
进制数表示为0x80000000
。尾部用来保存使用的slot
规格,如0x80000001
表示该page
用来分配8Byte
的slot
内存。slot
内存剩下page的page_info
,首两位为11
,16
进制数表示为0xb0010000
。尾部用来保存使用的slot
规格,如0xb0010001
表示该page
用来分配8Byte
的slot
内存,中间部分(第16位开始)用来标识索引。

在申请chunk时,它的首个4KB的位置都会用来存放zend_mm_chunk结构,所以chunk只有511个page可供使用。
申请n*chunk
内存时,会将内存地址ptr
向2M
位置对齐。第一次申请,如果(off = ptr % 2M )== 0
则返回ptr
,
否则将内存释放,重新申请(n+1)*chunk
内存,将ptr
向2M
位置偏移,new_ptr = ptr + 2M - off
,掐头去尾释放(ptr,2M - off)
和(new_ptr+2M,off)
的内存。
释放内存时就可以根据ptr
来判断需要释放的是何种内存。
计算off = ptr % 2M:
off == 0
,因为chunk
的一个page
被占用了,所以off
为0
的只能是Huge
内存,直接在huge_list
中查找对应的节点。off > 0
,large
或者small
内存,需要查找对应的page_info
才能确定,page_index = off / 4KB
,在map
中查找对应的page_info
。type
为large
时,将对应的page
置为未分配状态;type
为small
时,将ptr
添加到对应的slot
链表。
源码分析
