小编:本文由 WebInfra 团队姚忠孝、杨文明、张地编写。意在通过深入剖析常用的内存分配器的关键实现,以理解虚拟机动态内存管理的设计哲学,并为实现虚拟机高效的内存管理提供指引。
在现代计算机体系结构中,内存是系统核心资源之一。虚拟机( VM )作为运行程序的抽象"计算机",内存管理是其不可或缺的能力,其中主要包括如内存分配、垃圾回收等,而其中内存分配器又是决定"计算机"内存模型,以及高效内存分配和回收的关键组件。
如上图所示,各种编程都提供了动态分配内存对象的能力,例如创建浏览器 Dom 对象,创建 Javascript 的内存数组对象( Array Buffer 等),以及面向系统编程的 C / C++ 中的动态分配的内存等。在应用开发者角度看,通过语言或者库提供的动态内存管理(分配,释放)的接口就是实现对象内存的分配和回收,而不需要关心底层的具体实现,例如,所分配对象的实际内存大小,对象在内存中的位置排布(对象地址),可以用于分配的内存区域,对象何时被回收,如何处理多线程情况下的内存分配等等;而对于虚拟机或者 Runtime 的开发者来说,高效的内存分配和回收管理是其核心任务之一,并且内存分配器的主要目标包括如下几方面:
1. 减少内存碎片,包括内部碎片和外部碎片
32
字节,分配了 40
字节,多余的 8
字节就是内部碎片。16
字节的内存,但是内存分配器中保存着部分 8
字节的内存,一直分配不出去。2. 提高性能
内存分配需要通过内核分配虚拟地址空间和物理内存,频繁的系统调用和多线程竞争显著的降低了内存分配的性能。内存分配器通过接管内存的分配和回收,无论在单线程还是多线程场景下都可以很好的起到提升性能的作用。
3. 提高安全性
随着系统和应用对安全性的关注度提升,默认的内存分配机制无论在内存数据布局,还是对硬件安全机制的使用上都无法最大化的满足应用诉求,因此自定义内存分配器可以针对特定的系统和应用充分的利用硬件安全机制( MTE )等,同时也能够在实际系统中引入内存安全性检测机制来进一步提升安全性。
因此,本文通过深入分析常用的内存分配器的关键实现( dlmalloc , jemalloc , scudo , partition-alloc ),来更好的理解背后的设计考量和设计哲学,以为我们实现高效,轻量的运行时和虚拟机内存分配器,内存管理模型提供指引。
实际的内存分配需要按照所申请的内存大小分别处理,如下所述:
小内存通过空闲链表管理空闲内存的使用状态;下图是某一时刻可能的内存快照情况,每个内存大小( size )都可作为空闲链表数组的下标访问对应大小的空闲链表。
基于空闲链表维护的空闲内存状态,采用如下的分配顺序和分配策略进行实际内存分配。
大内存不采用空闲链表数组进行管理(虚拟机地址空间方范围大,时间和空间效率均低),采用 trie-tree 作为大内存的状态维护结构,每次内存分配和释放在 trie-tree 上进行高效的搜索和插入。
对于超大内存,直接通过 mmap 从操作系统分配内存。
该内存管理器的主要目标是提升多线程内存使用的性能( efficiency 、 locality )和较少内存碎片(最大优势是强大的多线程分配能力)
上图展示了内存分配的主要流程及数据结构。jemalloc
通过 areans 进行内存统一内存分配和回收, 由于两个arena在地址空间上几乎不存在任何联系, 就可以在无锁的状态下完成分配。同样由于空间不连续, 落到同一个 cache-line 中的几率也很小, 保证了各自独立。理想情况下每个线程由一个 arena
负责其内存的分配和回收,由于 arena 的数量有限, 因此不能保证所有线程都能独占 arena , Android 系统中默认采用两个 arena ,因此会采用负载均衡算法( round-robin )实现线程到 arena 的均衡分配并通过内部的 lock 保持同步。
多个线程可能共享同一个 arena , jemalloc
为了进一步避免分配内存时出现多线程竞争锁,因此为每个线程创建一个 " tcache ",专门用于当前线程的内存申请。每次申请释放内存时, jemalloc
会使用对应的线程从 tcache 中进行内存分配(其实就是 Thread Local 空闲链表)。
每个线程内部的内存分配按如下3种不同大小的内存请求分别处理:
小对象( Small Object ) 根据 Small Object 对象大小,从 Run 中的指定 " region " 返回。
大对象( Large Object )
Large Object 大小比 chunk 小,比 page 大,会单独返回一个 " Run "(1或多个 page 组成)。
超大对象( Huge Object ) > 4 MB
huge object 的内存直接采用 mmap 从 system memory 中申请(独立" Chunk "),并由一棵与 arena 独立的红黑树进行管理。
Scudo 这个名字源自 Escudo ,在西班牙语和葡萄牙语中表示“盾牌”。为了安全性考虑,从 Android 11 版本开始,Scudo 会用于所有原生代码(低内存设备除外,其仍使用 jemalloc )。在运行时,所有可执行文件及其库依赖项的所有原生堆分配和取消分配操作均由 Scudo 完成;如果在堆中检测到损坏情况或可疑行为,该进程会中止。(以下内容以 Android-11 64位系统实现为分析目标)
Scudo 定义了 Primary 和 Secondary 两种类型分配器[4],当需求小于 max_class_size ( 64 k )时使用 Primary Allocator ,大于 max_class_size 时使用 Secondary Allocator 。
struct AndroidConfig {
using SizeClassMap = AndroidSizeClassMap;
#if SCUDO_CAN_USE_PRIMARY64
// 256MB regions
typedef SizeClassAllocator64
Primary;
#else
// 256KB regions
typedef SizeClassAllocator32 Primary;
#endif
// Cache blocks up to 2MB
typedef MapAllocator> Secondary;
template
using TSDRegistryT = TSDRegistrySharedT; // Shared, max 2 TSDs.
};
*Primary Allocator
Primary Allocator [5]首先会根据 AndroidSizeClassConfig [6]中的配置申请一个完整的虚拟内存空间,并将虚拟内存空间分成不同的区域( sizeClass ),每块区域只能分配出固定空间,区域1只能分配32 bytes (包含 header ),区域2只能分配48 bytes 。
Primary Allocator 从操作系统申请" PrimaryBase "指向的虚拟内存区域后,按照如下的配置为虚拟内存区域划分为 32 个 256 M 的内存区域( SizeClass )。
每个 SizeClass 提供特定大小的内存分配,其内存区域按照 RegionInfo 结构布局,其中" randon offset "是0~16页的随机值,以使随机化 ReginBeg 地址降低定向攻击的风险。
每个 SizeClass 区域初始分配内存为 MAP_NOACCESS ,当收到内存分配请求时, ClassSize 会分配出一块可用的内存区域( MappedUser ),并以 SizeClass 指定的 block 大小切分成可用使用的 Regions 。当进行 Regions 的分配的同时,每个可用的 Region 均通过 TranferBatch 进行管理( TBatch ), 这样 TBatch 的链表就可以作为 FreeList 供内存分配使用。
为了安全性的考虑,最终返回的内存按照上图" block “所示的内存布局组织。其中” chunk-header "[7]是为了保证每一个 chunk 区域的完整性, Checksum 用的是较为简单的CRC算法,此外,内存释放过程中 Scudo 增加了 header 检测机制,一方面可以检测内存踩踏和多次释放,另一方面也阻止了野指针的访问。
struct UnpackedHeader {
uptr ClassId : 8;
u8 State : 2;
u8 Origin : 2;
uptr SizeOrUnusedBytes : 20;
uptr Offset : 16;
uptr Checksum : 16;
};
struct AndroidSizeClassConfig {
static const uptr NumBits = 7;
static const uptr MinSizeLog = 4;
static const uptr MidSizeLog = 6;
static const uptr MaxSizeLog = 16;
static const u32 MaxNumCachedHint = 14;
static const uptr MaxBytesCachedLog = 13;
static constexpr u32 Classes[] = {
0x00020, 0x00030, 0x00040, 0x00050, 0x00060, 0x00070, 0x00090, 0x000b0,
0x000c0, 0x000e0, 0x00120, 0x00160, 0x001c0, 0x00250, 0x00320, 0x00450,
0x00670, 0x00830, 0x00a10, 0x00c30, 0x01010, 0x01210, 0x01bd0, 0x02210,
0x02d90, 0x03790, 0x04010, 0x04810, 0x05a10, 0x07310, 0x08210, 0x10010,
};
static const uptr SizeDelta = 16;
};
*Thread Local Cache
Scudo Primary Allocator 使用了 Thread Local Cache 机制来加速多线程下内存的分配,如下图所示。当一个线程需要分配内存时,它会通过各自对应的 TSD ( Thead Specific Data )来发起对象的申请,每个 TSD 对象通过各自的 Allocator::Cache 及其 TransferBatch 来寻找合适的空闲内存。但 TransferBatch 的大小是有限的,如果没有可用的内存会进一步利用 Primary Allocator 进行分配。
理想情况下每个线程由一个 TSD 负责其内存的分配和回收,由于 TSD 的数量有限, 因此不能保证所有线程都能独占 TSD , Android 系统中默认采用两个 TSD ,因此会采用负载均衡算法( round-robin )实现线程到 arean 的均衡分配并通过内部的 lock 保持同步。
*Secondary Allocator
Secondary Allocator [8]主要用于大内存的分配(> max_size_class ),直接采用 mmap 分配出一块满足要求的内存并按照 LargeBlock::Header 进行组织, 并统一在 InUseBlocks 链表中统一管理,如下图所示。
为了安全性考虑, LargeBlock 采用如下图 LargeBlock Layout 进行组织,其中:
此外,为了提高效率效率, LargeBlock 在释放的时候会通过 MapAllocatorCache 将可被复用的空闲内存进行缓存,在大内存分配中优先从 Cache 中进行分配。其中 CachedBlock 和 LargeBlock::Header 基本一致,都是对 LargeBlock 内存布局信息的记录和管理。
Scudo Android R 内存分配核心流程如下所示。
NOINLINE void *allocate(uptr Size, Chunk::Origin Origin,
uptr Alignment = MinAlignment,
bool ZeroContents = false) {
initThreadMaybe(); // 初始化TSD Array 和 Primary SizeClass地址空间
// skip trivials.......
// If the requested size happens to be 0 (more common than you might think),
// allocate MinAlignment bytes on top of the header. Then add the extra
// bytes required to fulfill the alignment requirements: we allocate enough
// to be sure that there will be an address in the block that will satisfy
// the alignment.
const uptr NeededSize =
roundUpTo(Size, MinAlignment) +
((Alignment > MinAlignment) ? Alignment : Chunk::getHeaderSize());
// skip trivials.......
void *Block = nullptr;
uptr ClassId = 0;
uptr SecondaryBlockEnd;
if (LIKELY(PrimaryT::canAllocate(NeededSize))) {
// 从 Primary Allocator分配small object
ClassId = SizeClassMap::getClassIdBySize(NeededSize);
DCHECK_NE(ClassId, 0U);
bool UnlockRequired;
auto *TSD = TSDRegistry.getTSDAndLock(&UnlockRequired);
Block = TSD->Cache.allocate(ClassId);
// If the allocation failed, the most likely reason with a 32-bit primary
// is the region being full. In that event, retry in each successively
// larger class until it fits. If it fails to fit in the largest class,
// fallback to the Secondary.
if (UNLIKELY(!Block)) {
while (ClassId < SizeClassMap::LargestClassId) {
Block = TSD->Cache.allocate(++ClassId);
if (LIKELY(Block)) {
break;
}
}
if (UNLIKELY(!Block)) {
ClassId = 0;
}
}
if (UnlockRequired)
TSD->unlock();
}
// 如果分配的是大内存,或者Primary 无法分配小内存,
// 则直接在Secondary Allocator进行分配
if (UNLIKELY(ClassId == 0))
Block = Secondary.allocate(NeededSize, Alignment, &SecondaryBlockEnd,
ZeroContents);
// skip trivials.......
const uptr BlockUptr = reinterpret_cast(Block);
const uptr UnalignedUserPtr = BlockUptr + Chunk::getHeaderSize();
const uptr UserPtr = roundUpTo(UnalignedUserPtr, Alignment);
void *Ptr = reinterpret_cast(UserPtr);
void *TaggedPtr = Ptr;
// skip trivials.......
// 根据返回内存地址,设置chunk-header对象数据
Chunk::UnpackedHeader Header = {};
if (UNLIKELY(UnalignedUserPtr != UserPtr)) {
const uptr Offset = UserPtr - UnalignedUserPtr;
DCHECK_GE(Offset, 2 * sizeof(u32));
// The BlockMarker has no security purpose, but is specifically meant for
// the chunk iteration function that can be used in debugging situations.
// It is the only situation where we have to locate the start of a chunk
// based on its block address.
reinterpret_cast(Block)[0] = BlockMarker;
reinterpret_cast(Block)[1] = static_cast(Offset);
Header.Offset = (Offset >> MinAlignmentLog) & Chunk::OffsetMask;
}
Header.ClassId = ClassId & Chunk::ClassIdMask;
Header.State = Chunk::State::Allocated;
Header.Origin = Origin & Chunk::OriginMask;
Header.SizeOrUnusedBytes =
(ClassId ? Size : SecondaryBlockEnd - (UserPtr + Size)) &
Chunk::SizeOrUnusedBytesMask;
// 设置chunk-header,CheckSum用于完整性校验
Chunk::storeHeader(Cookie, Ptr, &Header);
// skip trivials.......
return TaggedPtr;
}
以上代码包括的核心流程如下图所示:
Scudo
虽然它的分配策略更加简化,安全性上得到了很大的改进,但为了安全性引入 chunk header 等元数据管理,内存随机化和对齐引入内存碎片一定程度上降低了内存的使用效率;此外,基于 chunk header 的内存校验以及内存安全性保障也一定程度上降低了效率(性能)。
以上的分析主要参照了 Android R 中的实现,最初来源于 LLVM 中 scudo 的实现, LLVM 中有 scudo_allocator [9] 和 standalone [10] 2份实现,具体内容可以从[9][10]作为入口进行源码的分析。
PartitionAlloc 是 Chromium 的跨平台分配器,优化客户端而不是服务器工作负载的内存使用,并专注于有意义的最终用户活动,而不是在实际使用中并不重要的微基准[16]。
*Central Partition Allocator [16]
PartitionAlloc 通过分区( Partition )来隔离各内存区域。其中每个分区都使用基于 slab 的分配器来分配内存, PartitionAlloc 预先保留" Super Page “作为分区虚拟地址空间( Slab )。每个"Super Page”( Slab )按照实际可分配的最小内存单元大小( Slot )分成各自独立的" Slot Span ",每个 Slot Span 包含多个 Partition Page 并归属于特定的桶( Partiton Bucket )用于提供中小内存的分配。
PartitionAlloc 针对大内存分配(> kMaxBucketed )是通过直接内存映射( direct map )实现的(特殊的 bucket 管理),为了保证和小内存分配结构上的统一性,直接内存映射内存结构采用(伪装)和小内存分配类似的 Super Page 进行管理,并且保留了类似的内存布局布局(如下图所示)。
如上图所示, PartitionAlloc 中每个分区的内存通过一个 PartitionRoot 进行管理," PartitionRoot “中维护了一个 Bucket 数组;每个 Bucket 维护了” slot span “的集合;随着分配请求的到来, slot span 逐渐得到物理内存( Commit ),并按照所关联桶的可分配内存的大小,将物理内存切分为 slot 的连续内存区域。PartitionAlloc 除了支持多个独立的分区来提升安全性外;每个” Super Page “被分割成多个” Partition Page “,其中第一个和最后一个” Partition-Page "主要作为保护页使用( PROT_NONE )。( Super Page 选择 2 MB 是因为 PTE 在 ARM 、 ia 32 和 x 64上对应的虚拟地址是2 M )。
*Partition Layer
PartitionAlloc 虽然通过分区来隔离各区域,但在同一分区内多线程访问受单个分区锁的保护。为了缓解多线程的数据竞争和扩展性问题,采用如下三层架构来提高性能:
为了缓解多线程内存分配的数据竞争问题,每个线程的数据缓存区( TLS )保存了少量用于常用中小内存分配的 Slots 。因为这些 Slots 是按线程存储的,所以可以在没有锁的情况下分配它们,并且只需要更快的线程本地存储查找,从而提高进程中的缓存局部性。每个线程的缓存已经过定制以满足大多数请求,通过批量分配和释放第二层的内存,分期锁获取,并在不捕获过多内存的情况下进一步改善局部性。
Slot span free-lists 在每线程缓存未命中时调用。对于每个对应的 bucket size,PartitionAlloc 维护与该大小相关联的 Slot Span ,并从 Slot Span 的空闲列表中获取一个空闲 Slot 。这仍然是一条快速路径( fast path ),但比每线程缓存慢,因为它需要锁定。但是,此部分仅适用于每个线程缓存不支持的较大分配,或者作为填充每个线程缓存的批处理。
最后,如果桶中没有空闲的 slot ,那么 Slot span management 要么从 Super Page ( slab )中挖出空间用于新的slot span,要么从操作系统分配一个全新的Super Page ( slab )。这是一条慢速路径( slow path ),很慢但非常不经常操作。
以上简单介绍了 PartitionAlloc 中分区的关键结构, PartitionAlloc 在 Chromium 中主要维护了四个分区,针对每种分配器的用途采取不同的优化方案,并根据实际对象的类型在四个分区中任意一个上分配对象。
PartitionAlloc 在 Node 分区和 LayoutObject 分区上分配时不会获取锁,因为它保证 Node 和 LayoutObject 仅由主线程分配。PartitionAlloc 在 Buffer 分区和 FastMalloc 分区上分配时获取锁。PartitionAlloc 使用自旋锁以提高性能。
*安全性
安全因素的考虑也是PartitionAlloc最重要的目标之一,这里利用虚拟地址空间来达到安全加固的目的:不同的分区存在于隔离的地址空间;当某个分区内存页上所有对象都被释放掉之后,其物理内存归还于系统后,但其地址空间仍被此分区保留,这样就保证了此地址空间只能被此分区重用,从而避免了信息泄露;PartitionAlloc的内存布局,提供了以下安全属性:
传统内存分配器( jemalloc , etc.)调用 malloc 为对象分配内存时,无法指定分配器将在哪里存储什么样的数据,并且无法指定要存储它的位置。C++ 类对象可能紧挨着包含密钥的字符串,该密钥可能与函数指针的结构相邻。在所有这些数据之间是分配器用来管理堆的元数据(通常为双向链表和标志存储的指针),这为漏洞寻找要覆盖的数据目标时为他提供了更多选择。例如,当利用释放后使用漏洞时,我们希望将类型 B 的对象放置在先前分配类型 A 的对象的位置。然后我们可以触发类型 A 的过时指针的取消引用,该指针反过来使用类型 B 对象中的位。这通常是可能的,因为两个对象都分配在同一个堆中[43],而 PartitionAlloc 具有如下特性可以满足分配需求。
由于 PartitionAlloc 是面向 Chromium 特定应用场景的高效内存分配器,为特定内存使用场景的定制化能力能够提供高效的内存分配和回收(例如, layout 分区, node 分区),但面向通用的内存分配场景及高安全场景如果能够和 jemalloc, secudo 能力进行有效的融合( porting ),可能会是一个可行的路径和方向( MTE 等)。
从如上的分析可以看出,分配器的整体性能和空间效率取决于各种因素之间的权衡,例如缓存多少、内存分配/回收策略等,实际的时间和空间效率需要根据具体场景衡量并针对性的优化。如下从性能,空间效率,以及 benchmark 方面提供一些总结和参考。
内存分配器 | 说明 |
---|---|
dlmalloc | https://github.com/ARMmbed/dlmalloc · 非线程安全,多线程分配效率低 · 内存使用率较低(内存碎片多) · 分配效率低 · 安全性低 |
jemlloc | https://github.com/jemalloc/jemalloc/blob/dev/INSTALL.md **· **代码体积较大,元数据占据内存大 · 内存分配效率高(基于run(桶) + region) · 内存使用率高 · 安全性中 |
scudo | https://android.googlesource.com/platform/external/scudo/ · 更注重安全性,内存使用效率和性能上存在一定的损失 · 内存分配效率偏高(sizeclass + region, 安全性+checksum校验) · 内存使用率低(元数据,安全区,地址随机化内存占用多) · 安全性高( 安全性+checksum校验,地址随机化,保护区) |
partition-alloc | https://source.chromium.org/chromium/chromium/src/+/main:base/allocator/partition_allocator/PartitionAlloc.md · 代码体积小 · 内存使用率偏高(相比于jemalloc多保护区,基于range的分配) · 安全性偏高(相比sudo少安全性,完整性校验,地址随机化) · 内存分配效率高(基于bucket + slot,分区特定优化) · 支持"全"平台 PartitionAlloc Everywhere (BlinkOn 14) : https://www.youtube.com/watch?v=QfY-WMFjjKA |
NOTE:Benchmark alternate malloc implementations:
Allocator | QPS (higher is better) | Max RSS (lower is better) |
---|---|---|
tcmalloc (internal) | 410K | 357MB |
jemalloc | 356K | 1359MB |
dlmalloc (glibc) | 295K | 333MB |
mesh | 142K | 710MB |
portableumem | 24K | 393MB |
hardened_malloc* | 18K | 458MB |
guarder | FATALERROR** | |
freeguard | SIGSEGV*** | |
scudo (standalone) | 400K | 318MB |
[1]. A Tale of Two Mallocs: On Android libc Allocators – Part 1–dlmalloc https://blog.nsogroup.com/a-tale-of-two-mallocs-on-android-libc-allocators-part-1-dlmalloc/
[2]. jemalloc: https://github.com/jemalloc/jemalloc/blob/dev/INSTALL.md
[3]. A Tale of Two Mallocs: On Android libc Allocators – Part 2–jemalloc: https://blog.nsogroup.com/a-tale-of-two-mallocs-on-android-libc-allocators-part-2-jemalloc/
[4] AndroidConfig: https://android.googlesource.com/platform/external/scudo/+/refs/tags/android-11.0.0_r3/standalone/allocator_config.h#39
[5].SizeClassAllocator64: https://android.googlesource.com/platform/external/scudo/+/refs/tags/android-11.0.0_r3/standalone/primary64.h#46
[6]. AndroidSizeClassConfig:
https://android.googlesource.com/platform/external/scudo/+/refs/tags/android-11.0.0_r3/standalone/size_class_map.h#171
[7]. Chunk: https://android.googlesource.com/platform/external/scudo/+/refs/tags/android-11.0.0_r3/standalone/chunk.h#65
[8] MapAllocator : https://android.googlesource.com/platform/external/scudo/+/refs/tags/android-11.0.0_r3/standalone/secondary.h#225
[9] scudo_allocator :https://github.com/llvm/llvm-project/blob/main/compiler-rt/lib/scudo/scudo_allocator.h
[10] standalone: https://github.com/llvm/llvm-project/blob/main/compiler-rt/lib/scudo/standalone/combined.h
[11]. Scudo : https://source.android.com/devices/tech/debug/scudo
[12]. Scudo Hardened Allocator: https://llvm.org/docs/ScudoHardenedAllocator.html
[13]. System hardening in Android 11: https://android-developers.googleblog.com/2020/06/system-hardening-in-android-11.html
[14]. Android Native | Scudo内存分配器: https://juejin.cn/post/6914550038140026887
[15]. Scudo Allocator : https://zhuanlan.zhihu.com/p/235620563
[16]. Efficient And Safe Allocations Everywhere! : https://blog.chromium.org/2021/04/efficient-and-safe-allocations-everywhere.html
[17]. PartitionAlloc Design: https://chromium.googlesource.com/chromium/src/+/master/base/allocator/partition_allocator/PartitionAlloc.md
[18]. partition_alloc_constants: https://source.chromium.org/chromium/chromium/src/+/main:base/allocator/partition_allocator/partition_alloc_constants.h
[19]. Unified allocator shim: https://chromium.googlesource.com/chromium/src/base/+/refs/heads/main/allocator/README.md
[20]. Porting PartitionAlloc To PDFium: https://struct.github.io/pdfiu