使用 Lookaside List 分配内存

1. 概述

windows 提供了一种基于 lookaside list 的快速内存分配方案,区别于一般的使用 ExAllocatePoolWithTag() 系列函数的内存分配方式。每次从 lookaside list 里分配 fixed size 的内存。 系统构建两个条 lookaside 链表:ExNPagedLookasideListHead 和 ExPagedLookasideListHead,分别用于 Non-paged 内存和 paged 内存。

lookaside list 链表头是 LIST_ENTRY 结构,因此:整个 lookaside list 链是双向链表。ExNPagedLookasideListHead 和 ExPagedLookasideListHead 是 kernel 的全局变量,由系统进行初始化。

下图是由 ExNPagedLookasideListHead 链头构造的 non-paged lookaside list 示意图:

使用 Lookaside List 分配内存_第1张图片

在使用 ExInitializeNPagedLookasideList() 函数进行初始化 NPAGED_LOOKASIDE_LIST 结构(或 PAGED_LOOKASIDE_LIST 结构)时,将节点的 LIST_ENTRY 域挂接在链表头里,形成一条双向链表。

2. lookaside list 节点结构

lookaside list 的节点是 NPAGED_LOOKASIDE_LIST 结构(用于 non-paged 链)或 PAGED_LOOKASIDE_LIST(用于 paged 链),它们的定义如下

typdef struct _NPAGED_LOOKASIDE_LIST
{
GENERAL_LOOKASIDE L;
KSPIN_LOCK Lock;
} NPAGED_LOOKASIDE_LIST, *PNPAGED_LOOKASIDE_LIST;
 
 
typdef struct _PAGED_LOOKASIDE_LIST
{
GENERAL_LOOKASIDE L;
FAST_MUTEX Lock;
} PAGED_LOOKASIDE_LIST, *PPAGED_LOOKASIDE_LIST;

内部的 GENERAL_LOOKASIDE 子结构维护着 lookaside 的整个工作,下面是 GENERAL_LOOKASIDE 结构的定义:

typedef struct _GENERAL_LOOKASIDE {
SLIST_HEADER ListHead;// 单向链表,用来挂接 pool block
USHORT Depth;  // lookaside 的 depth,缺省为 4
USHORT MaximumDepth;// maximum depth,缺省为 256
ULONG TotalAllocates; // 记录进行多少次分配
ULONG AllocateMisses; // 记录分配从 lookaside list 中分配失败次数
ULONG TotalFrees;// 记录进行多少次释放
ULONG FreeMisses; // 记录从 lookaside list 中释放 miss 次数
POOL_TYPE Type; // pool type 类型,与 ExAllocatePoolWithTag() 参数一致
ULONG Tag;  // 用于 ExAllocatePoolWithTag()
ULONG Size;  // 用于 ExAllocatePoolWithTag()
PALLOCATE_FUNCTION Allocate; // 自定义的分配函数,缺省使用 ExAllocatePoolWithTag()
PFREE_FUNCTION Free; // 自定义的释放函数,缺省使用 ExFreePool()
LIST_ENTRY ListEntry; // 双向 link list
ULONG LastTotalAllocates;// 记录上一次 Allocate 次数
ULONG LastAllocateMisses;// 记录上一次 Allocate  miss 次数
ULONG Future[2];// 保留
} GENERAL_LOOKASIDE, *PGENERAL_LOOKASIDE; 

每个节点有两条链表:

  • 单向链表:用于挂接 pool 块,这些内存块就是从系统 pool 里分配而来的(non-paged 内存或 paged 内存)。
  • 双向链表:用于挂接在整个 lookaside list 里,也是就前面所说的 ExNPagedLookasideListHead 链表头和 ExPagedLookasideListHead 链表头。

在前面描述图的基础上,形成像下面的结构:

使用 Lookaside List 分配内存_第2张图片

因此:每个节点都有一条 SLIST_ENTRY 链,挂接着内存块!,这些内存链是在释放 lookaside list 时挂接上去的。在初始化时,这些内存块链是不存在的!

假如:Lookaside 指向其中一个 NPAGED_LOOKASIDE_LIST 节点 有三个 Depth 值是需要分清楚的:

  1. 节点当前的深度:Lookaside->L.ListHead.Depth 它指示节点当前有多少个内存块挂接着。
  2. 节点的最小深度:Lookaside->L.Depth 它指示着每个节点最少以挂接多少个内存块。在缺省的情况下,这个 Depth 值为 4,最少可以挂接 3 个内存块。
  3. 节点的最大深度:Lookaside->L.MaximumDepth 它指示节点最大可以挂接多少个内存块。这个值为 256,也就是最多可以挂接 255 个。

在缺省的时候,每个节点的 Depth(深度)的为 4,那么 kernel 根据使用情况来动态修改这个 Depth 值,最大值为 MaximumDepth。因此:Depth 相当于一个“额度”的概念!

3. 节点内存块 size

在一条 lookaside list 链里,每个节点可以有不同 size 的内存块,如下图所示:

使用 Lookaside List 分配内存_第3张图片

在图中的 lookaside 链里,其中 3 个节点分别挂接着 1K,2K 以及 4K 大小的内存块,当需要分配 1K 内存时,从第1个节点里分配。如下面代码所示:

PVOID p = ExAllocateFromNPagedLookasideList(lookaside1);// 从第 1 个节点分配 1K 内存块

在初始化每个节点时,可以为每个节点的内存块 size 设置不同的值,因此:整个 lookaside list 可以挂接着不同 size 的内存块,软件根据需要从不同 size 的节点里分配内存。

4. 初始化节点

在使用 lookaside list 之前,必须构建和初始化一个 NPAGED_LOOKASIDE_LIST 结构(或 PAGED_LOOKASIDE_LIST 结构用于 paged 内存),如下面代码所示:

NPAGED_LOOKASIDE_LIST Lookaisde; // 节点结构
 
//
// 下面初始化节点
//
 
ExInitializeNPagedLookasideList(
&Lookaside,  // 节点地址
NULL, // 使用缺省的 Allocate() 例程
NULL, // 使用缺省的 Free() 例程
0, // Flags 保留
1024, // 1K size
'tag1',  // Tag
0 // Depth 值,未使用
); 

在对节点初始化完成后,将节点挂接在以 ExNPagedLookasideListHead 为表头的双向链表里,组织成一个 lookaside list 体系。

下面的 ExInitializeNPagedLookasideList() 例程 C 代码是从 windows 2003 里逆出来的:

VOID ExInitializeNPagedLookasideList(
IN PNPAGED_LOOKASIDE_LIST Lookaside,
IN PALLOCATE_FUNCTION Allocate,
IN PFREE_FUNCTION  Free,
IN ULONG  Flags,
IN SIZE_T  Size,
IN ULONG  Tag,
IN USHORT  Depth
)
{
PGENERAL_LOOKASDIDE  L = &Lookaside->L;
 
//
// 初始化  SLIST_HEADER
//
L->ListHead.Next = NULL;
L->ListHead.Depth = 0;
L->ListHead.Spquence = 0;
 
L->Depth = ExMinimulLooasideDepth;// depth 最小值,为 4
L->Type = NonPagedPool | Flags;
L->Tag = Tag;
L->Size = Size;
L->MaximumDepth = 256;// depth 最大值, 256
L->TotalAllocates = 0;
L->AllocateMisses = 0;
L->TotalFrees = 0;
L->FreeMisses = 0;
 
if (Allocate == NULL)
{
L->Allocate = ExAllocatePoolWithTag;
}
else
{
L->Allocate = Allocate;
}
 
 
if (Free == NULL)
{
L->Free = ExFreePool;
}
else
{
L->Free = Free;
}
 
L->LastTotalAllocates = 0;
L->LastAllocateMisses = 0;
 
//
// 将 &L->ListEntry 挂接在 ExNPagedLookasideListHead 链表头里
//
ExfInterlockedInsertTailList(&ExNPagedLookasideListHead, &L->ListEntry, &ExNPagedLookasideLock);

从这个代码里,我们可以清楚地看到,节点的单向链表是空的,Depth 和 MaximumDepth 分别是 4 和 256,在对 non-paged 节点初始化时,Flags 值为 NoPagedPool 类型。

对 paged 节点的初始化例程与 non-paged 节点结构是一致的,只是使用了不同的 Flags 值和链表头,下面是 ExInitializePagedLookasideList() 的代码:

VOID ExInitializePagedLookasideList(
IN PPAGED_LOOKASIDE_LIST Lookaside,
IN PALLOCATE_FUNCTION Allocate,
IN PFREE_FUNCTION  Free,
IN ULONG  Flags,
IN SIZE_T  Size,
IN ULONG  Tag,
IN USHORT  Depth
)
{
....
 
L->Type = PagedPool | Flags;// PagedPool 类型
 
...
 
//
// 将 &L->ListEntry 挂接在 ExPagedLookasideListHead 链表头里
//
ExfInterlockedInsertTailList(&ExPagedLookasideListHead, &L->ListEntry, &ExPagedLookasideLock);

代码的最后,使用 ExfInterlockedInsertTailList() 例程将节点挂在双向链表里(non-paged 或 paged 类型)

下面代码是从 windows 2003 里逆出来的:

PLIST_ENTRY
ExfInterlockedInsertTailList(
PLIST_ENTRY ListHead,// 双向链表头
PLIST_ENTRY ListEntry,// 双向节点
PKSPIN_LOOK Look// 保留接口
)
{
DISABLEINTERRUPT();// 关中断
 
PLIST_ENTRY Blink = ListHead->Blink;
 
// 
// 下面将节点挂接在尾部
//
ListEntry->Flink = ListHead;
ListEntry->Blink = Blink;
ListHead->Blink = ListEntry;
Blink->Fink = ListEntry;

ENABLEINTERRUPT();// 开中断
 
//
// 空 list
//
if (Blink == ListHead)
{
return NULL;
}

return ListHead;

5. 分配内存

软件使用 ExAllocateFromNPagedLookasideList() 例程从 non-paged 链的节点里分配 non-paged 内存块,使用 ExAllocateFromPagedLookasideList() 例程从 paged 链的节点里分配 paged 内存块。

如下面代码所示:

PVOID p = ExAllocateFromNPagedLookasideList(lookaside);// 从 non-paged 链节点里分配内存,返回内存块到 p

在从节点里分配内存块时遇到两种情形:

  1. 当节点的内存块链为空时,直接调用节点的 Allocate 例程来在 pool 里分配内存,此时将记录一次 AllocateMisses 
  2. 当节点的内存块链不为空时,将重复使用节点内存块,无需直接分配内存。

当节点挂接着内存块时(不为空),将返回节点地址(SLIST_ENTRY 结构地址),节点为空时将返回内存块地址(如果分配成功的话)。

使用 Lookaside List 分配内存_第4张图片

如上图所示:有两个节点 A 和 B,从节点 A 分配内存将得到节点地址(作为内存块使用),从节点 B 分配将记录一次 miss(从 lookaside 中分配失败),直接调用 Allocate 例程分配内存。

节点挂着的内存块从哪里来?答案是:释放之前分配的内存块时挂接
      当使用 ExFreeToNPagedLookasideList() 例程释放内存块时,在 Depth 允许的情况下,kernel 将内存块挂接在节点内存块链里,以备以续的分配重复使用,而无需进行物理的分配内存操作。
      因此:当节点内存块链为空时,将产生 miss 情形。表示从节点内存块链里分配不到内存。

当返回节点地址时,它属于 SLIST_ENTRY 指针值。而实际上,这个指针值虽然指向 SLIST_ENTRY 结构,但是无需理会类型,它被当作一个空的内存块使用。这是因为:SLIST_ENTRY 结构里只有一个成员,就是 Next 指针(指向下一条 SLIST_ENTRY 结构)。

当返回这个 SLIST_ENTRY 结构指针值的,它里面的 Next 成员被作为“脏”数据而忽略这个数据(作为空的内存块)。

下面我们来看看 ExAllocateFromNPagedLookasideList() 例程的实现,下面代码是从 windows 2003 里逆出来的:

PVOID ExAllocateFromNPagedLookasideList(
IN PNPAGED_LOOKASIDE_LIST Lookaside
)
{
PSLIST_ENTRY ListEntry;

//
//  TotalAllocates  记录分配的次数,包括成功和失败
//
Lookaside->L.TotalAllocates++;

//
// 从节点里读取内存块链
//
ListEntry = ExInterlockedPopEntrySList(&Lookaside->L.ListHead);
 
//
// 分配内存有两个情形:
// 1. 返回 lookaside list 节点(SLIST_ENTRY) 
// 2. 返回内存,假如 lookaside list 为空
//
if (ListEntry == NULL)
{
//
//  AllocateMisses 记录从 lookaside list 中分配失败的次数
// 也就是从 pool 中分配的次数
//
Lookaside->L.AllocateMisses++;
 
//
// 调用 lookaside list 节点中的 Allocate() 例程
//
return Lookaside->L.Allocate(Lookaside->L.Type, 
Lookaside->L.Size, 
Lookaside->L.Tag);
}
else
return ListEntry;
}

代码使用 ExInterlockedPopEntrySList() 例程来读取节点内存块地址,如果为空时就分配物理内存块,记录一次 miss 事件!这个 miss 事件将被用作 kernel 定期维护和调整 lookaside 链的 Depth 值之用! 根据 miss 次数在分配操作数的频率而提升链的 Depth,达到更好的性能。

对于 paged 类型的内存分配,它看起来像这样:

#define ExAllocateFromPagedLookasideList(Lookaside) \
ExAllocateFromNPagedLookasideList((PNPAGED_LOOKASIDE_LIST)Lookaside) 

ExAllocateFromPagedLookasideList() 实际上也是调用 ExAllocateFromNPagedLookasideList() 例程,只不过传递的是 PAGED_LOOKASIDE_LIST 节点!

6. 释放内存

使用 ExFreeToNPagedLookasideList() 例程来释放 non-paged 链的内存块,而 ExFreeToPagedLookasideList() 释放 paged 链的内存块。当 lookaside 节点的 Depth 允许的情况下,释放的内存块被挂接在节点的 SLIST_ENTRY 链上。

下面的代码将分配 4 个内存块,然后分别释放它们:

NPAGED_LOOKAISDE_LIST Lookaside;
ExInitializeNPagedLookasideList(Lookaside, ...);  // 初始化 Lookaside 节点
 
PVOID p1 = ExAllocateFromNPagedLookasideList(Lookaside);// 从节点里第 1 次分配内存
PVOID p2 = ExAllocateFromNPagedLookasideList(Lookaside);// 第 2 次分配内存
PVOID p3 = ExAllocateFromNPagedLookasideList(Lookaside);// 第 3 次分配内存
PVOID p4 = ExAllocateFromNPagedLookasideList(Lookaside);// 第 4 次分配内存
 
... ...
 
ExFreeToNPpagedLookasideList(Lookaside, p1);  // 释放 p1
ExFreeToNPpagedLookasideList(Lookaside, p2);  // 释放 p2
ExFreeToNPpagedLookasideList(Lookaside, p3);  // 释放 p3
ExFreeToNPpagedLookasideList(Lookaside, p4);  // 释放 p4 
 
... ...
 
//
// 下面代码再次进行分配内存
//
p1 = ExAllocateFromNPagedLookasideList(Lookaside);  // 此时从节点 ListHead 链里重复使用上次释放过的内存块!(也就是第1个释放的内存块)

在前面的分配 4 次内存里,都调用节点的 Allocate() 例程来进行物理的分配内存操作(因为节点的 ListHead 链是空的),将产生 4 个 AllocateMisses 事件!

在后续代码对分配得到的内存块进行释放,这里也遇到两种情形:

  1. 在 Depth 值允许的情况下:将内存块挂接在节点的 ListHead 链里,以备后续代码重复分配使用,它不会进行物理的释放工作。
  2. 在节点的 Depth 超限的情况下:将调用节点的 Free() 例程进行释放内存块工作,此时会产生 FreeMisses 事件,指示释放到 lookaside 链中失败!
使用 Lookaside List 分配内存_第5张图片

前面的三次释放(p1,p2 以及 p3)会成功地挂接在节点的 ListHead 链表头里,而最后一次释放 p4 指向的内存块会导致进行物理地释放操作,回收到系统内存 pool 里(会记录在节点的 FreeMiesses 域里)。

Depth 超限
      当节点的 ListHead 域的 Depth 大于或等于节点的 Depth 值时,就属于 Depth 超限!
      也就是 Lookaside->L.ListHead.Depth >= Lookaside->L.Depth 时就超限了。

Lookaside->L.ListHead.Depth 代表着节点 SLIST_HEADER(也就是内存块链)的 Depth,而 Lookaside->L.Depth 代表示节点的 Depth,从前面的初始化例程里,我们知道节点的 Depth 最初的值为 4。因此:在初始化状态下节点的内存块链最多只能挂接 3 个内存块!超这个数量都将被释放掉,回收到系统 pool 池里。

在运行一段时间后(经过 N 次的分配与释放操作后),系统会定期检查分配的频率以及 miss 的频率,通过这些检查来动态调整节点的 Depth 值,提升节点的 Depth 额度来容纳更多的内存块。由此来达到更好的分配性能。

下面我们来看看 ExFreeToNPagedLookasideList() 例程的实现,在 windows 2003 里逆向出来下面的代码:

VOID ExFreeToNPagedLookasideList(
PNPAGED_LOOKASIDE_LIST Lookaside,
PVOID Entry
)
{
//
// TotalFrees 记录释放的次数,包括:物理释放和回收到 lookaside list 中
//
Lookaside->L.TotalFrees++;

 
//
// 释放的工作,主要有两个情形:
// 1.  当已经被分配的内存块超过 Lookaside list 所支持的深度时,就执行物理的释放工作(调用  Free() )
  // 2.  当小于 lookaside list 深度时,回收到 lookaside list 里,等待下次分配,不执行释放!(挂收在 list 里)
//
 
if (Lookaside->L.ListHead.Depth < Lookaside->L.Depth)
{
//
// 回收到 lookaside list 中
//
RtlpInterlockedPushEntrySList(Lookaside, (PSLIST_ENTRY)Entry);
}
else
{
//
// 记录回收到 lookaside list 失败次数,也就是:物理释放内存次数
//
Lookaside->L.FreeMisses++;
 
//
// 调用 lookaside list  节点中的 Free() 例程释放内存
//
Lookaside->L.Free(Entry);
}

我们清楚地看到两种情形:在回收到 lookaside list 时,代码调用 RtlpInterlockedPushEntrySList() 例程挂接在节点的 ListHead 链头里。在回到收系统内存 pool 时,就调用 Free() 来释放内存。

RtlpInterlockedPushEntrySList() 例程和 ExInterlocakPushEntrySList() 例程执行的操作是一样的,目的是:挂接在单向链表里。 在回收到 lookaside list 节点的 ListHead 链后,下次使用这个节点进行分配内存时,直接从 ListHead 链里取出内存块就行了(使用 ExInterlockedPopEntrySList() 例程)。

使用 Lookaside List 分配内存_第6张图片

内存块被释放后,内存块里的数据变为“脏”数据(无用的),ExInterlocakPushEntrySList() 例程将内存块的第 1 个 4 字节数据作为 SINGLE_LIST_ENTRY 结构来挂接在 ListHead 链表头上。

在接着的分配内存中,ExInterlockedPopEntrySList() 例程取出 ListHead 链头的下一个节点,因此:下次分配时是取出第1个释放的内存块(也就是 p1 内存块)。那么:内存块首 4 个字节的 SINGLE_LIST_ENTRY 结构数据会被作为无用数据,在正常使用内存块会被覆盖!这一点巧妙地解决了 PVOID 指针与 PSLIST_ENTRY 指针的兼容性!

7. 动态调整 lookaside list 节点深度

kernel 会定期地监视 lookaside list 的使用情况,根据分配的次数频率来动态调整节点的深度。在初始化节点时 Depth 被置为 4(只能挂接 3 个内存块)。

kernel 会考虑下面情况:

  1. 当节点被频繁地分配内存,这 3 个内存块是不够的!在不足够的情况下会调用节点的 Allocate() 例程进行物理地内存分配操作。kernel 发现这种情况后会提升节点的 Depth 值,使节点能容纳更多的内存块。 如果这种情况持续发生,结果会提升到 256 值(最大的 Depth 值)。
  2. 在提升节点的 Depth 后,发现后续的分配次数和频率被降下来时。kernel 也会降低节点的 Depth 值。如果这种情况持续发生,最后会降至 4 值(最小的 Depth 值)。

通过这些调整手段来达到最好地利用 lookaside list 目的。kernel 使用 ExAdjustLookasideDepth() 例程来监视来调整 lookaside list,它的实现是:(从 windows 2003 逆出来)

VOID ExAdjustLookasideDepth()
{
//
// 分别扫描 3 个 lookaside list。
// 每次扫描的 lookaside list 都是不同的
//
switch (ExpScanCount)
{
case 0:
ExpScanGeneralLookasideList(ExNPagedLookasideListHead, ExNPagedLookasideLock);
break;
case 1:
ExpScanGeneralLookasideList(ExPagedLookasideListHead, ExPagedLookasideLock);
break;
case 2:
ExpScanSystemLookasideList();
break;
}
 
//
// 只有 3 个 lookaside list 需要扫描
//
ExpScanCount++;
if (ExpScanCount == 3)
{
ExpScanCount = 0;
}

这个例程会扫描 non-paged 链,paged 链以及 system 链,每次扫描的对象是不同的。对于 non-paged 链和 paged 链使用 ExpScanGeneralLookasideList() 例程来监视和调整

下面我们看看它的实现(从 windows 2003 里逆出来):

//
// 这个 routine 用来根据分配的频率动态调整 lookaside list 的深度。
// 
VOID ExpScanGeneralLookasideList(
PLIST_ENTRY ListHead,
PKSPIN_LOOK Look
)
{
KIRQL OldIrql = KeRaiseIrqlToDpcLevel();
 
PLIST_ENTRY ListEntry;
PNPAGED_LOOKASIDE_LIST Lookaside;
 
 
//
// 从尾部开始遍历整个挂在 ExNPagedLookasideListHead 链表头的 所有 PNPAGED_LOOKASIDE_LIST 节点!
//
// 这个链由 ExNPagedLookasideListHead 作为链表头(LIST_ENTRY 结构)
// 它的节点是 PNPAGED_LOOKASIDE_LIST 结构的内的  ListEntry 成员!
//
 
for (ListEntry = ListHead->Flink; // 由尾部点节开始
ListEntry != ListHead; // 直到 head 部
ListEntry = ListEntry->Flink// 迭代
)
{
 
//
// 得到宿主的 NPAGED_LOOKASIDE_LIST 结构的地址
// 结果等于: Lookaside = (NPAGED_LOOKASIDE_LIST *)((ULONG)ListEntry - 0x30)
//
Lookaside = CONTAINING_RECORD(ListEntry, NPAGED_LOOKASIDE_LIST, ListEntry);
 
 
//
// 计算扫描时的分配情况
//
ULONG AllocateMisses = Lookaside->L.AllocateMisses - Lookaside->L.LastAllocateMisses;
ULONG TotalAllocates = Lookaside->L.TotalAllocates - Lookaside->L.LastTotalAllocates;

Lookaside->L.LastAllocateHits = Lookaside->L.AllocateHits;
Lookaside->L.LastTotalAllocates = Lookaside->L.TotalAllocates;

USHORT Depth = Lookaside->L.Depth;
USHORT MaximumDepth = Lookaside->L.MaximumDepth;
 
if (ExMinimumLookasideDepth != 0)
{
 
//
// 测试是否频繁使用 lookaside 进行分配
// 当分配次数(包括成功和失败) 达到 75 次就视为频繁
//
if (TotalAllocates >= 75)
{
ULONG MissPercent = AllocateMisses * 1000 / TotalAllocates;
 
//
// 假如 miss 次数低于  50% 则降低 depth ,最小的 depth 为 4
//
if (MissPercent < 5)
{
Depth--;  // 逐步降低节点 Depth 值
if (Depth < 4)
Depth = 4;
}
else
{
//
// 计算提高 depth 的幅度,
// 根据剩余的额度来调整:最多每次提升 30 ,最大的 depth 为 256
//
LONG RaiseDepth = ((MaximumDepth - Depth) * MissPercent) / 2000;
if (RaiseDepth > 30)
{
RaiseDepth = 30;
}

Depth = Depth + RaiseDepth;// 提升节点  Depth 值
 
if (Depth > MaximumDepth)
{
Depth = MaximumDepth;
}
}
}
else
{
// 
// 降低 lookaside list 节点的 depth
// 当使用不频繁时,下降的幅度为 10
//
Depth = Depth - 10;
if (Depth < 4)
Depth = 4;
}

 
Lookaside->L.Depth = Depth;// 调整节点 Depth 
}

kfLowerIrql(OldIrql);

Depth 调整的范围始终在 4 到 256 之间,这是 kernel 定义的最小值与最大值。

至此,lookaside list 的实现基本分析完毕!

你可能感兴趣的:(使用 Lookaside List 分配内存)