Unreal内存分配器深度解析:Binned2分桶与线程安全机制
2026/9/10 8:40:53 网站建设 项目流程

还记得那次线上版本刚发出去,玩家在论坛里集中反映某个区域一进战斗就掉帧,甚至有人直接客户端崩溃。我们查了一圈,DrawCall、贴图、GC、网络同步全都没问题,最后用stat memory一看,发现分配器在游戏运行时频繁触发全局锁,大量线程卡在等待内存释放上。从那一刻起,我才真正意识到:在Unreal里,内存分配器不是一个“换个引擎默认配置就完事”的基础组件,它直接决定你的多线程框架能不能撑住复杂场景。

这篇文章是“Unreal是如何驾驭内存的”系列第3章,专门聊Binned2分配器以及其他分配策略的来龙去脉。我会从“为什么Unreal不直接用系统malloc”开始,拆解Binned2的分桶、缓存、状态页机制,再对比Ansi、Mimalloc、TBB、Jemalloc这些分配器在Unreal里的定位,最后给出实际项目中怎么调参、怎么验证、怎么从崩溃日志反推分配器问题的完整思路。适合正在做性能优化、处理内存碎片、或是准备深入读引擎源码的开发者参考。

1. 为什么Unreal不肯用系统的malloc——一个线上卡顿现场说起

Unreal里所有内存分配最终都收敛到FMemory::Malloc,而FMemory::Malloc背后挂着一个全局分配器指针GMalloc。引擎启动时,根据平台、构建配置、启动参数来选择具体实现。但很多人没想过一个问题:为什么Unreal要这么大费周章地自己去管内存,直接用C的malloc不行吗?

1.1 系统malloc的两个致命短板

第一个短板是虚拟内存浪费。系统malloc为了保证任意大小、任意对齐的分配,往往会在每个分配块前后附加头部元数据,比如大小、前驱后继指针、校验字节等。小对象密集分配时,这部分开销占比非常高。假设你的项目里每秒有几十万个FNameTWeakObjectPtr、小TArray分配,纯头部开销就能让内存翻倍。

第二个短板更加致命:多线程下的锁竞争。默认的malloc实现为了线程安全,内部会有全局锁或局部锁。游戏引擎的主线程、渲染线程、Worker线程、网络线程都在高频分配和释放,当线程数超过8个之后,锁竞争会把分配率拖到惨不忍睹。现场表现就是:某个线程分配内存,其他线程全部卡住等锁,帧率出现周期性尖刺。

1.2 Unreal自己上手的解决思路

Unreal这套做法的核心思路就是池化 + 分级。所谓池化,就是启动时就向操作系统申请一大块连续内存,之后所有小对象分配都从这块内存里“切”出来用,释放时还回去,而不是还给操作系统。所谓分级,就是按大小把分配请求归到不同“桶”里,每个桶只处理固定范围的大小,避免malloc那样每次都要遍历空闲链表的开销。

这个思路和线程调度里的“多级队列”很像:不是所有任务都走一条主队列,而是按优先级、按类型分到不同的子队列,各自处理,互不干扰。Binned2就是这套思路在内存分配领域的完整实现。

提示:如果你想快速验证项目是否受默认分配器影响,在测试环境启动参数里加-ansimalloc,用系统malloc跑一遍同一场景,对比stat memory和帧耗时。很多“莫名其妙的多线程卡顿”在-ansimalloc下会暴露得更明显。

2. Binned2分配器的核心拆解:分桶、状态页与租约块

Binned2是UE 4.13之后默认使用的分配器,也是当前大多数项目实际运行的分配器。它不是全新的设计,而是对老版Binned分配器的重大改进。我理解它最有价值的几个机制分别是:固定大小分桶、状态页追踪、租约块分割、线程本地缓存。

2.1 固定大小分桶:把内存分配变成“最小公倍数”游戏

Binned2把分配请求按大小切成了多个“桶”,每个桶对应一个固定块大小。比如:

Size Class块大小(字节)对齐(字节)
01616
13216
24816
36416
48016
59616
......16
633276816

分配内存时,Binned2先看请求大小,按“向上取整到最近的桶块大小”找到对应Size Class,然后从这个桶的空闲链表里取出一个固定大小的块返回。

为啥要这么做?因为固定大小意味着回收后的内存可以放进同一桶的空闲链表,不会产生“小块碎成更小块”的问题。系统malloc的问题在于,它要严格满足你请求的具体字节数,释放后相邻块可能无法合并,碎片会越来越多。而Binned2把尺寸粒度化之后,碎片天然被约束在桶粒度的浪费范围内。

注意:如果你的项目大量分配大小超过32768字节的对象(比如超大纹理上传、大数据资产),这部分并不会走Binned2的分桶,而是直接转给系统malloc。所以Binned2调优对小对象密集的项目效果最明显,对大块内存为主的项目改善有限。

2.2 状态页:记录“这块内存是谁的、属于哪个桶”

有了分桶还不够,释放内存时必须知道“这块内存在哪个桶里”,否则没法把它归还到正确的空闲链表。Binned2用了个叫状态页(State Page)的结构,按页(通常64KB)维护元数据。每个分配块都有一个对应的状态记录,包括它属于哪个Size Class、当前标记为已分配还是空闲、所属的线程池标识等。

这个设计相当于给整块内存建了一张“户口登记表”。分配时查表找到可用块,释放时查表确认归属。虽然读状态页有一定的CPU开销,但换来的是释放操作O(1)级别的复杂度,不需要像系统malloc那样回溯合并相邻块。

状态页还有另一个重要职责:检测越界和重复释放。调试模式下Binned2会在块边界填充特定字节,释放时校验填充是否被改写。这能抓出一大批Buffer Overrun、写越界、Use-After-Free类崩溃。

2.3 租约块:64KB大的“共享工作台”

这是Binned2和老版Binned最明显的差异之一。老版Binned的每个线程会申请独立的内存块池,互不相通;而Binned2引入了租约块(Lease Block)概念,一个64KB大小的连续内存被拆成多个小块,同一个租约块可能同时给多个线程分配使用。

为什么这么改?因为独立线程池的内存利用率低。假设A线程分配了大量小对象、B线程少量小对象,独立池模式下A的内存块很快耗尽、又要申请新块,而B的内存块大量空置。租约块共享之后,Binned2可以在一个64KB块内,让不同线程分别分配自己需要的块,减少了整体内存的浪费。

租约块还有一个好处:跨线程释放效率更高。当线程T1分配了租约块L里的一个块,后来T2要释放这个块时,只需要把块归还到L所在的池记录里,不需要立刻把消息传递给T1。这种异步归还机制大大减少了线程间同步开销。

2.4 Binned2分配流程的“快路径”和“慢路径”

理解Binned2,核心是理解它把分配拆成两条路径:快路径和慢路径。

快路径(Fast Path)是每次分配默认走的:

// 伪代码:Binned2快路径 void* FMallocBinned2::Malloc(SIZE_T Size, uint32 Alignment) { // 1. 根据Size计算SizeClass uint32 SizeClass = SizeToSizeClass(Size); // 2. 从线程本地缓存取空闲块 FThreadLocalCache& TLS = GetThreadLocalCache(); if (TLS.FreeBlocks[SizeClass].Num() > 0) { return TLS.FreeBlocks[SizeClass].Pop(); } // 3. 线程本地缓存为空,走慢路径 return SlowPathAlloc(SizeClass); }

慢路径(Slow Path)则是线程本地缓存不足时,从全局池取一个空闲块,同时将池中额外的一些块搬到线程本地缓存,方便后续快速分配:

void* FMallocBinned2::SlowPathAlloc(uint32 SizeClass) { // 1. 从全局池的SizeClass空闲链表取一块 // 2. 顺便从同一链表中多拿几个块放入线程本地缓存 // 3. 如果没有空闲块,则向后台申请新的64KB租约块,并切割成多个SizeClass块 // 4. 返回其中一个块 }

这样设计的目的非常明确:让“常态分配”完全不碰锁,只有跨线程或池耗尽时才需要同步。如果你发现自己的项目CPU Profile里分配器相关耗时集中在FMallocBinned2::SlowPathAlloc,说明线程本地缓存命中率不高,要么是分配量太分散,要么是缓存容量配置不合理。

3. Binned2的线程安全设计:全局锁、后台线程与脏页回收

网上不少资料只说Binned2是“线程安全的”,但没告诉你它到底怎么做到线程安全。这里我给你拆成三个层次。

3.1 第一层:线程本地缓存(TLS)

每个线程维护自己的空闲块数组,分配和释放小对象时优先操作自己线程的缓存。这一层完全不需要锁,是分配器的性能高速公路。线程本地缓存有个容量上限(比如每个Size Class缓存N个块),超过上限后多余的块会被归还给全局池。

用生活场景类比:每个收银员自己有个小钱箱,找零时直接从自己钱箱拿,不用每次都去总金库取;只有自己钱箱快空了才去总金库补货,或者总金库要求清点时才把多余的钱上交。

3.2 第二层:全局池与互斥锁

当线程本地缓存不够用,就要进入全局池。全局池为每个Size Class维护一个空闲链表,用一把全局互斥锁保护。虽然这把锁只影响慢路径,但如果大量线程在同一帧同时触发慢路径,锁竞争就会变得明显。

Binned2在这里做了一点优化:当线程来全局池取块时,会一次性多取一批放入线程本地缓存,后续分配就不需要再抢锁了。这个批处理策略让慢路径的触发频率大幅降低。如果你的项目里出现高锁竞争,你可以打开stat memoryAlloc Lock相关的计数器,判断慢路径频率是否过高。

3.3 第三层:后台释放与整理

Binned2的释放路径也分两层。快路径释放是直接放回线程本地缓存;慢路径释放则是把块归还给全局池。分跨线程的释放场景中,Binned2会把释放请求先记录到对应的空闲列表,如果有其他线程正在使用这个池,就用原子操作处理,不需要立刻唤醒原分配线程。

真正的大块内存归还操作系统并不是实时发生的。Binned2会保留全局池里的空闲块,等后台整理线程发现整体空闲内存过多时,才把部分内存归还给系统。这样做的好处是避免频繁向操作系统申请内存的系统调用开销,坏处则是stat memory里看到的进程内存占用不会立刻下降。

我看过不少项目在“内存占用过高”的排查中,误以为Binned2有内存泄漏,其实只是缓存块没有及时释放。想验证这一点很简单:跑一段长时间的空闲场景,观察物理内存是否稳定在某个水位,如果稳定且不持续上升,就基本可以排除泄漏。

4. 其他分配策略盘点:Ansi、Mimalloc、TBB、Jemalloc到底适合谁

Binned2是默认值,但不是唯一选择。Unreal还内置了多种分配器,分别服务于不同的项目类型和调试阶段。

分配器启用方式特点适用场景
FMallocAnsi-ansimalloc直接调用系统malloc,最稳定但性能最差调试、验证内存问题是否与分配器相关
FMallocBinned-binnedmalloc老版池化分配器,线程独立池老项目迁移或对比测试
FMallocBinned2默认分桶+线程本地缓存+租约块大多数游戏项目
FMallocTBB-tbbsalloc基于Intel TBB分配器,多线程无锁队列高并发CPU密集计算项目
FMallocMimalloc-mimalloc微软开源分配器,小对象快、碎片少小对象密集、硬件兼容性好的PC项目
FMallocJemalloc需要第三方插件Facebook/FreeBSD的分配器,内存占用稳定服务器、长时间运行的架构

4.1 FMallocAnsi:调试时最好的“照妖镜”

Ansi分配器不做什么池化,直接转发给操作系统的malloc/free。它的优点是行为最简单、最贴近常规C++程序,出了内存问题容易复现。缺点是分配性能最差,多线程竞争明显,碎片也可能更严重。

但它有个好用途:当你怀疑Binned2的池化逻辑有Bug时,-ansimalloc一跑就能对照出差异。如果-ansimalloc下问题消失,那问题大概率出在Binned2的池管理上;如果-ansimalloc下仍然崩溃,那问题更可能在代码自身(比如越界写破坏堆结构)。

我自己常用的排查流程是:先崩→加-ansimalloc跑→还崩→拿内存调试器查越界;或者加-ansimalloc后不崩了→向官方或社区报分配器Bug。

4.2 FMallocMimalloc:小而快的现代派

Mimalloc是微软开源的分配器,主打“无锁线程缓存 + 碎片优化”。它在小对象分配、释放上表现非常亮眼,内存占用也趋于稳定。Unreal从4.26开始内置支持,只要启动参数加-mimalloc就可以启用,非常适合PC平台的单机或多人联机项目。

不过别急着换。Mimalloc在Unreal里属于“实验性”支持,某些DLC、第三方插件如果直接依赖系统malloc的行为,可能出现不兼容。我的建议是:先做A/B对比,再用长时间稳定性测试验证,最后才考虑上线。

4.3 FMallocTBB:面向并行计算的硬核选手

TBB(Threading Building Blocks)不仅仅是分配器,它是一套并行编程库,其中的tbb::scalable_allocator专为高并发场景优化。如果你的项目里有大量TaskGraph并行任务,每个任务都分配大量小对象,TBB可能比Binned2更快。

但TBB的问题在于:它和Unreal的内存扩容策略(Realloc)配合可能不如Binned2顺畅;而且TBB的分配器会在某些操作系统版本上表现不稳定。所以它更适合CPU密集型、对分配延迟极其敏感的工具类程序,而不一定是游戏客户端的最佳选择。

4.4 选型时的核心判断标准

选哪个分配器,核心看三件事:分配频率、对象大小分布、线程规模

  • 如果你的游戏以Actor、组件、UI元素等大量中等大小对象为主,Binned2是稳妥之选。
  • 如果你跑的是长期在线的服务器进程,更看重内存占用稳定,可以考虑Jemalloc。
  • 如果你的PC项目里小对象(小于64字节)多到爆,不妨跑一版-mimalloc对比帧率曲线。
  • 如果你做的是引擎工具、命令行烘焙工具,追求极致分配性能,TBB值得一试。

没有“最好”的分配器,只有“最适合当前负载特征”的分配器。换分配器前,一定要先用LLM或MemoryProfiler摸清楚项目的分配特征,不然就是盲调。

5. 用数据说话:LLM、MemoryProfiler与stat memory的使用思路

说再多理论,都不如真实数据来得直观。这一节我按“怎么看内存→怎么抓分配热点→怎么验证分配器表现”三步走,给你一套可落地的调优流程。

5.1 开滚LLM(Low Level Memory Tracker)抓大对象

LLM是Unreal内置的内存追踪系统,能够按Tag统计各类内存占用。启用方法很简单,在启动参数加-LLM,运行时控制台输入stat LLM即可看到按标签分类的内存使用量。

UnrealEditor.exe ProjectName.uproject -LLM

跑起来之后,重点看几个Tag:

  • EngineMisc:引擎杂项内存,通常包含大量分配器元数据和池化保留块。
  • AudioMeshesTextures:资源类内存,如果这些占大头,说明是资源加载策略问题,不是分配器问题。
  • AnimationPhysics:中间计算缓冲,如果这些反复增长,可能是物理或动画系统在分配临时对象。

LLM的核心价值是给你一张“内存地图”,让你知道该往哪个方向深挖,而不是一上来就去分析分配器内部。

5.2 MemoryProfiler:按调用栈抓到具体分配点

如果LLM告诉你某个Tag内存暴涨,下一步就要用MemoryProfiler定位具体是哪个系统、哪段代码分配出来的。Unreal的MemoryProfiler需要在启动参数加-memoryprofiler,它会记录每次分配的调用栈、大小、线程信息,并按模块聚合成报表。

启动参数示例:

UnrealEditor.exe ProjectName.uproject -memoryprofiler

运行一段时间后停止,生成的内存报表里可以按“模块”或“调用栈”排序,找到占比最高的分配点。这一步非常关键,因为你常常会发现“内存增长”其实是某个系统在做无谓的临时分配。

5.3 stat memory:最轻量的实时观测

如果只是日常查一下内存水位、怀疑有增长趋势,用stat memory就够了。它给出当前帧的物理内存、虚拟内存、可用内存、分配器统计等关键数字。

stat memory

输出中有一行和分配器相关性最高:PhysicalVirtualMemory Used。如果物理内存持续上涨而虚拟内存保持稳定,大概率是业务层的缓存增长;如果两者同步上涨,才需要怀疑是不是分配的块没有被正确释放。

5.4 分配器实测的对照组设计

在做分配器A/B时,不要只看一个场景的几分钟数据。我建议设计这样一组测试:

  1. 用同一关卡、同一角色、同样操作流程,跑10分钟;
  2. 分别记录-binned2alloc-ansimalloc-mimalloc下的帧生成时间P95值、平均内存占用、GC暂停次数;
  3. 各跑三轮取平均值,排除冷启动和缓存影响;
  4. 查看P95帧率和内存水位的综合表现,而不是单看平均帧率。

做这个测试时最好关掉其他无关后台进程,否则数据会很难看。别问我怎么知道的。

6. 实际调参与避坑:Binned2模式下的分配失败、碎片与日志分析

最后这一部分聊点实战中能直接套用的内容。Binned2虽然成熟,但用错场景、配错参数仍然会踩坑。

6.1 常见的Binned2内存问题:分配失败与OOM

Binned2本身不会“内存泄漏”,但可能出现“池化块耗尽而新块申请不到”的情况。这种情况往往不是分配器的问题,而是业务层确实把内存吃完了。崩溃日志里常见的关键字是:

Ran out of memory Out of Memory Fatal error: Out of Memory

出现这类崩溃,要做的不是调Binned2参数,而是去看LLM数据,找到哪个Tag占用了大量内存。绝大多数OOM的根因是资源加载没有释放、Actor持续生成、视频/音频缓冲叠加、或者某个第三方库悄悄缓存了大量数据。

不过,有一种情况真的和Binned2配置有关:当MaxMemoryBounds设置太小时,Binned2会在达到上限后拒绝分配。启动参数可以显式指定内存上限,例如:

-binned2MaxMemory=8GB

不设置时,Binned2基本沿用系统可用的物理内存;设置得过小就会提前OOM。如果你用的是自定义的构建或Launcher参数,建议检查一下是否有这个限制。

6.2 内存碎片的识别与缓解

Binned2解决了大部分小对象碎片问题,但对“大小跨度极大、频繁分配/释放大块内存”的场景,碎片依然可能存在。表现就是:物理内存不低,但分配器无法找到一个连续的大块内存来满足某个大分配请求,最终OOM。

识别方法:在崩溃或卡顿前,用控制台命令memreport -full导出内存报表,查看Free MemoryMaximum Free Memory的比值。如果最大空闲块远小于总空闲内存,说明碎片化严重。

缓解思路有几个:

  • 把大块分配改成池化复用,避免频繁申请释放;
  • 对大数组预先Reserve容量,减少TArray扩容时的Realloc;
  • 适当增加Binned2的块大小粒度,用空间换连续性;
  • 时间上错开大批量资源的加载和卸载,避免内存整块碎掉。

6.3 从崩溃日志反推分配器问题的排查链路

如果拿到一份崩溃日志,怀疑是分配器问题,可以按下面这个链路排查:

  1. 先看崩溃栈顶部是不是在FMallocBinned2::FreeFMallocBinned2::Malloc里;不是的话,大概率只是业务代码问题。
  2. 如果崩溃在Free里,检查崩溃对象附近的指针是否被反复释放,FMemory::Free传入的指针是否是合法块。Binned2在调试版会加校验,直接把无效释放拦下来。
  3. 如果崩在Malloc里,查看是不是在申请超大块、或者调用方的对齐参数异常。
  4. 如果都是正常分配、没有越界行为,再用-ansimalloc跑一遍,观察是否复现。
  5. 如果只有Binned2下复现,把日志连同内存dump一起反馈给引擎社区,并记录复现步骤和可用内存大小。

这个流程看着简单,但每一步都能筛掉一批伪问题。我自己在项目里遇到最多的,其实不是Binned2本身的Bug,而是第三方插件加载时向系统申请了超过平台限制的大块内存,给了Binned2一个无处安放的请求。


就说这么多,最后分享一个我个人的体会:内存分配器是那种“不出问题时你根本感觉不到它,出问题时你才发现它无处不在”的引擎模块。不要迷信换一个分配器就能救活所有性能问题,也不要默认Binned2就是最优解。先摸清自己项目的分配特征,再基于数据做选择,才是正确的做法。如果你正在读Unreal源码,建议先只看FMallocBinned2::MallocFree两条路径,能把这两条路径看明白,Binned2的大半机制就已经装进脑子里了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询