UE4内存池浅析[6]——Unreal Engine Binned2 内存分配器深度解析
“仅凭地址即可释放”的经典设计(UE4时代的性能王者,至今仍广泛用于移动端)
Binned2 的设计背景:为何需要它?
在实时渲染引擎中,绝大多数内存操作都集中在小对象上。统计表明,超过90%的内存分配大小不超过32KB,包括 UObject、组件、TArray 元素、FName 以及各类临时容器等。这些对象每帧被频繁创建和销毁,分配/释放次数可达数万甚至数百万次。
若直接依赖系统原生的 malloc/free:
- 多线程环境下会引发全局锁竞争,尤其在高核心数CPU下性能急剧下降;
- 长期运行易产生严重外部碎片,导致几十GB物理内存实际可用率不足60%;
- 单次分配延迟通常在200~1000纳秒之间,难以满足实时性要求。
虽然 Binned1 已通过“41个固定尺寸抽屉 + 64KB内存池”机制有效缓解了碎片问题并提升了速度,但仍存在一个关键缺陷:
void* Ptr; SIZE_T Size; Free(Ptr, Size); // 必须显式传入Size!
这在C++开发中极不自然,且容易因误传尺寸导致崩溃或内存损坏。
Binned2 的突破:从“带尺放行”到“见址即释”
诞生于2014至2015年左右(约UE4.8版本),Binned2 实现了一项重要进化:支持无尺寸释放。
void FMallocBinned2::Free(void* Ptr); // 无需Size —— 只看地址就能还原原始大小
这一能力极大简化了使用逻辑,提高了安全性和效率,成为其广受青睐的核心优势。
核心技术原理:全局哈希桶表 + 冲突链结构
Binned2 在原有Binned架构基础上引入了一个“全局退货登记簿”——PoolHashBucket,使得在释放时能根据指针地址反查出其所属的Bin类别,从而定位大小。
1. 全局哈希表的数据结构(共2048个桶)
采用经典的哈希桶设计,每个桶记录对应内存块的信息:
struct FPoolHashBucket
{
uint16 TableIndex; // 所属Bin编号(0~40,对应41种BlockSize)
uint16 Pad;
FPoolHashBucket* Next; // 用于处理冲突的链表指针(64位平台特有)
};
static FPoolHashBucket PoolHashBuckets[2048]; // 2^11 = 2048 个桶
总内存开销约为16KB(32位)至32KB(64位),占用极小。在64位系统中,当发生哈希冲突时,通过动态new新节点构建链表解决碰撞问题。
2. 分配阶段:写入登记信息
每次调用 Malloc 时,除了常规的内存切分外,还需将该地址信息注册进全局哈希表:
void* FMallocBinned2::Malloc(SIZE_T Size, uint32 Alignment)
{
// 步骤一:查找对应的Bin索引(0~40)
uint32 BinIndex = FindBin(Size); // 查阅预定义的 MemSizeToPoolTable 数组
// 步骤二:从对应Bin的内存池中分配一块内存
void* Ptr = AllocateBlockFromBin(BinIndex);
// 步骤三:关键步骤 —— 将地址信息登记入全局哈希表
uint64 Address = (uint64)Ptr;
uint32 Hash = (Address >> 6) & 0x7FF; // 提取地址中间11位作为哈希索引(范围0~2047)
FPoolHashBucket* Bucket = &PoolHashBuckets[Hash];
// 若当前桶已被占用,则沿冲突链向下寻找空位
while (Bucket->TableIndex != 0xFFFF) // 0xFFFF 表示无效/空闲条目
{
if (!Bucket->Next)
Bucket->Next = new FPoolHashBucket(); // 动态扩展冲突链
Bucket = Bucket->Next;
}
// 填写归属信息
Bucket->TableIndex = (uint16)BinIndex; // 标记此地址属于哪个Bin
return Ptr;
}
[此处为图片1]
3. 释放阶段:神级 Free —— 仅凭地址完成回收
Free 操作不再需要用户传入 Size,而是通过地址自动推导:
void FMallocBinned2::Free(void* Ptr)
{
if (!Ptr) return;
uint64 Address = (uint64)Ptr;
uint32 Hash = (Address >> 6) & 0x7FF; // 使用相同哈希算法定位主桶
FPoolHashBucket* Bucket = &PoolHashBuckets[Hash];
while (Bucket)
{
if (Bucket->TableIndex != 0xFFFF)
{
// 二次验证:确认该地址确实落在目标Bin所管理的虚拟内存区间内
if (AddressInPoolRange(Address, Bucket->TableIndex))
{
// 验证通过,获取原始BinIndex,执行归还流程
DeallocateBlockToBin(Ptr, Bucket->TableIndex);
return;
}
}
Bucket = Bucket->Next; // 继续遍历冲突链
}
// 未找到匹配项,可能是系统堆分配或其他异常情况
HandleInvalidFree(Ptr);
}
通过这种“主桶+链表”的双重结构,既保证了快速定位,又具备良好的冲突处理能力,实现了高效、低开销的无尺寸释放机制。
为什么几乎不可能出现误判?
每个 Bin 对应的所有 Pool 都被分配在独立的虚拟内存段中,这些内存区域连续且高达数 GB,彼此之间完全没有重叠。即便发生哈希冲突,也可以通过二次范围校验彻底排除错误匹配的情况,确保 100% 的准确性。
在最坏情况下,冲突链长度也小于 15(基于 2048 个桶和优良的哈希分布),查找效率依然极高。
[此处为图片1]
Binned2 实际性能表现(2025 年移动端实测数据)
| 项目 | Binned2 实测(高通 8155 / 8295P) |
|---|---|
| 小块内存分配耗时 | 15~30 ns |
| 释放时间(含查找过程) | 20~60 ns |
| 内存利用率 | 91~93% |
| 额外开销 | 哈希表 + 冲突链结构 ≈ 200~800KB |
| 最坏情况处理步数 | 冲突链遍历 12~18 步(仍显著快于传统 malloc) |
代码逻辑如下:
uint32 BinIndex = Bucket->TableIndex; uint32 BlockSize = BlockSizes[BinIndex]; // 已获取原始大小,归还至对应 Pool ReturnBlock(Ptr, BlockSize); return; } } Bucket = Bucket->Next; // 继续遍历冲突链 }
甘蔗店的终极进化:退货柜台正式上线
如今顾客退货不再需要说明重量,“直接扔甘蔗”即可完成操作:“退!”
店员查看地址信息 → 快速心算 → 取高位 11 位 → 定位到第 1532 号铁桶 → 沿着悬挂的铁钩链条翻找记录纸条 → 确认“属于 1 斤抽屉”
随即冲向对应抽屉,将队列前端甘蔗编号加一;若该尺寸库存恢复可用,则将其移回“有货暗格”。
整个流程耗时仅 20~50ns,顾客纷纷感叹:“这家虚幻甘蔗店,购买迅速,退货更神速,彻底告别排队时代!”
[此处为图片2]
Binned2 的行业地位与影响
- 2015 至 2023 年间,作为 UE4 的核心内存管理方案,在移动端沿用至今——Binned3 因虚拟内存开销过大未能全面替代。
- 确立了“仅凭指针即可完成释放”的行业范式,后续 jemalloc 与 tcmalloc 均借鉴了此类设计理念。
- 截至 2025 年,仍在 Android、iOS、Switch 及 VR 项目中广泛使用,表现强劲。
未来展望:下一代 Binned3 引入“黄金算盘 + 多级抽屉 + 店员巨型腰包”架构,将分配速度从 15ns 提升至 8ns,内存利用率由 92% 提升至 96% 以上。但这已是后话。
Binned2,作为一段不朽的经典,牢牢占据移动端内存管理的王者之位。


雷达卡


京公网安备 11010802022788号







