UE4之FMemStack内存管理机制
示例代码:
{
FMemMark Mark(FMemStack::Get()); TArray<int32, SceneRenderingAllocator> Values; Values.Add(10); Values.Add(20); ... // 期间可能触发扩容 // 作用域结束,Mark 析构
}
大体流程如下:

偏移指针,即可从chunk上分配得到内存

完整的时序图如下:

时序图详解
假设这个数组在 Mark 作用域里经历了:
- 第一次
Add - 一次扩容
- 最后
Mark.Pop()
阶段 0:刚进入作用域
MemStack 当前 Chunk: | 之前已有临时数据 |.......................空闲.......................| ^ Top Mark 记录: - SavedChunk = 当前 Chunk - SavedTop = 当前 Top
阶段 1:第一次 Add,容量不够,分配首块数组内存
Values.Add(10) | 之前已有临时数据 | A0[capacity=1 或更大] |........空闲........| ^ Top A0: Data ---> [10] Num = 1 Max = 初始增长后的容量
阶段 2:继续 Add,暂时不扩容
Values.Add(20) | 之前已有临时数据 | A0[capacity=4 假设] |........空闲........| ^ Top A0: Data ---> [10][20][?][?] Num = 2 Max = 4
这时:
- 没有新申请
- 只是把新元素直接构造到
A0后面
阶段 3:再次 Add,A0 容量耗尽,触发扩容
Values.Add(30) // 假设这次触发 grow 扩容前: | 之前已有临时数据 | A0[10][20] |........空闲........| ^ Top 扩容后: | 之前已有临时数据 | A0旧块 | A1新块(更大容量) |....空闲....| ^ Top
注意这里最关键的点:
A1是重新从 MemStack 申请的新块- 然后把
A0里的旧数据Memcpy到A1 Values.Data改为指向A1- A0 旧块不会立即 free
所以扩容后的真实情况是:
A0: 旧数据块,已经废弃,但还占着 MemStack 空间
A1: 当前正在使用的新数据块
Values.Data ---> A1
阶段 4:如果又发生一次扩容
那会变成:
| 之前已有临时数据 | A0旧 | A1旧 | A2当前新块 |....空闲....|
^
Top
也就是说,SceneRenderingAllocator 的扩容路径不是:
- 原地扩
- 或释放旧块再换新块
而是:
- 一路往前“堆”新块
- 等
FMemMark统一回收
阶段 5:作用域结束,FMemMark::Pop()
Pop() 前: | 之前已有临时数据 | A0旧 | A1旧 | A2当前 |....空闲....| ^ Top Pop() 后: | 之前已有临时数据 |.......................空闲.......................| ^ Top(回到 SavedTop)
结果:
A0 / A1 / A2这几块全都一起失效Values.Data成为悬空指针- 所以这种数组只能在
Mark作用域内使用
对应到源码调用链
1. SceneRenderingAllocator 其实就是 TMemStackAllocator<>
/*UnrealEngine\Engine\Source\Runtime\RenderCore\Public\RendererInterface.h*/ // Shortcut for the allocator used by scene rendering. typedef TMemStackAllocator<> SceneRenderingAllocator;
2. Add() 实际走到 Emplace()
// UnrealEngine\Engine\Source\Runtime\Core\Public\Containers\Array.h FORCEINLINE SizeType Add(ElementType&& Item) { CheckAddress(&Item); return Emplace(MoveTempIfPossible(Item)); } /** * Adds a new item to the end of the array, possibly reallocating the whole array to fit. * * @param Item The item to add * @return Index to the new item * @see AddDefaulted, AddUnique, AddZeroed, Append, Insert */ FORCEINLINE SizeType Add(const ElementType& Item) { CheckAddress(&Item); return Emplace(Item); }
3. Emplace() 会先 AddUninitialized(1)
// UnrealEngine\Engine\Source\Runtime\Core\Public\Containers\Array.h template <typename... ArgsType> FORCEINLINE SizeType Emplace(ArgsType&&... Args) { const SizeType Index = AddUninitialized(1); new(GetData() + Index) ElementType(Forward<ArgsType>(Args)...); return Index; }
对 int32 来说,这一步可以理解成“在目标位置直接构造一个整数值”。
4. AddUninitialized() 里判断是否需要扩容
// UnrealEngine\Engine\Source\Runtime\Core\Public\Containers\Array.h FORCEINLINE SizeType AddUninitialized(SizeType Count = 1) { CheckInvariants(); checkSlow(Count >= 0); const SizeType OldNum = ArrayNum; if ((ArrayNum += Count) > ArrayMax) { ResizeGrow(OldNum); } return OldNum; }
5. 触发扩容时,TArray 调用 allocator 的 ResizeAllocation
// UnrealEngine\Engine\Source\Runtime\Core\Public\Containers\Array.h FORCENOINLINE void ResizeGrow(SizeType OldNum) { ArrayMax = AllocatorInstance.CalculateSlackGrow(ArrayNum, ArrayMax, sizeof(ElementType)); AllocatorInstance.ResizeAllocation(OldNum, ArrayMax, sizeof(ElementType)); } FORCENOINLINE void ResizeShrink() { const SizeType NewArrayMax = AllocatorInstance.CalculateSlackShrink(ArrayNum, ArrayMax, sizeof(ElementType)); if (NewArrayMax != ArrayMax) { ArrayMax = NewArrayMax; check(ArrayMax >= ArrayNum); AllocatorInstance.ResizeAllocation(ArrayNum, ArrayMax, sizeof(ElementType)); } } FORCENOINLINE void ResizeTo(SizeType NewMax) { if (NewMax) { NewMax = AllocatorInstance.CalculateSlackReserve(NewMax, sizeof(ElementType)); } if (NewMax != ArrayMax) { ArrayMax = NewMax; AllocatorInstance.ResizeAllocation(ArrayNum, ArrayMax, sizeof(ElementType)); } }
6. TMemStackAllocator 真正向 FMemStack 申请内存
// UnrealEngine\Engine\Source\Runtime\Core\Public\Misc\MemStack.h void ResizeAllocation(SizeType PreviousNumElements, SizeType NumElements,SIZE_T NumBytesPerElement) { void* OldData = Data; if( NumElements ) { // Allocate memory from the stack. Data = (ElementType*)FMemStack::Get().PushBytes( (int32)(NumElements * NumBytesPerElement), FMath::Max(Alignment,(uint32)alignof(ElementType)) ); // If the container previously held elements, copy them into the new allocation. if(OldData && PreviousNumElements) { const SizeType NumCopiedElements = FMath::Min(NumElements,PreviousNumElements); FMemory::Memcpy(Data,OldData,NumCopiedElements * NumBytesPerElement); } } }
这段代码直接说明:
- 新内存来自
FMemStack::Get() - 旧数据靠
Memcpy - 没有单独
free OldData
7. FMemStack 是当前线程的栈式分配器
// UnrealEngine\Engine\Source\Runtime\Core\Public\Misc\MemStack.h class CORE_API FMemStack : public TThreadSingleton<FMemStack>, public FMemStackBase { };
它不是“全局共享堆”,而是每个线程一个栈式分配器。
8. FMemStack 的分配就是“Top 指针前移”
// UnrealEngine\Engine\Source\Runtime\Core\Public\Misc\MemStack.h FORCEINLINE uint8* PushBytes(int32 AllocSize, int32 Alignment) { return (uint8*)Alloc(AllocSize, FMath::Max(AllocSize >= 16 ? (int32)16 : (int32)8, Alignment)); } FORCEINLINE void* Alloc(int32 AllocSize, int32 Alignment) { // Debug checks. checkSlow(AllocSize>=0); checkSlow((Alignment&(Alignment-1))==0); checkSlow(Top<=End); checkSlow(NumMarks >= MinMarksToAlloc); // Try to get memory from the current chunk. uint8* Result = Align( Top, Alignment ); uint8* NewTop = Result + AllocSize; // Make sure we didn't overflow. if ( NewTop <= End ) { Top = NewTop; } else { // We'd pass the end of the current chunk, so allocate a new one. AllocateNewChunk( AllocSize + Alignment ); Result = Align( Top, Alignment ); NewTop = Result + AllocSize; Top = NewTop; } return Result; }
当当前 chunk 放不下时,会新建 chunk:
// UnrealEngine\Engine\Source\Runtime\Core\Private\Misc\MemStack.cpp void FMemStackBase::AllocateNewChunk(int32 MinSize) { FTaggedMemory* Chunk=nullptr; // Create new chunk. int32 TotalSize = MinSize + (int32)sizeof(FTaggedMemory); uint32 AllocSize; if (TopChunk || TotalSize > FPageAllocator::SmallPageSize) { AllocSize = AlignArbitrary<int32>(TotalSize, FPageAllocator::PageSize); if (AllocSize == FPageAllocator::PageSize) { Chunk = (FTaggedMemory*)FPageAllocator::Get().Alloc(); } else { Chunk = (FTaggedMemory*)FMemory::Malloc(AllocSize); INC_MEMORY_STAT_BY(STAT_MemStackLargeBLock, AllocSize); } check(AllocSize != FPageAllocator::SmallPageSize); } else { AllocSize = FPageAllocator::SmallPageSize; Chunk = (FTaggedMemory*)FPageAllocator::Get().AllocSmall(); } Chunk->DataSize = AllocSize - sizeof(FTaggedMemory); Chunk->Next = TopChunk; TopChunk = Chunk; Top = Chunk->Data(); End = Top + Chunk->DataSize; }
所以可以把 MemStack 理解成:
- 不是一块无限连续内存
- 而是由多个 chunk 串起来
- 每个 chunk 内部按
Top -> End线性消费
9. FMemMark 负责批量回收
// UnrealEngine\Engine\Source\Runtime\Core\Public\Misc\MemStack.h class FMemMark { public: // Constructors. FMemMark(FMemStackBase& InMem) : Mem(InMem) , Top(InMem.Top) , SavedChunk(InMem.TopChunk) , bPopped(false) , NextTopmostMark(InMem.TopMark) { Mem.TopMark = this; // Track the number of outstanding marks on the stack. Mem.NumMarks++; } /** Destructor. */ ~FMemMark() { Pop(); } /** Free the memory allocated after the mark was created. */ void Pop() { if(!bPopped) { check(Mem.TopMark == this); bPopped = true; // Track the number of outstanding marks on the stack. --Mem.NumMarks; // Unlock any new chunks that were allocated. if( SavedChunk != Mem.TopChunk ) { Mem.FreeChunks( SavedChunk ); } // Restore the memory stack's state. Mem.Top = Top; Mem.TopMark = NextTopmostMark; // Ensure that the mark is only popped once by clearing the top pointer. Top = nullptr; } }
时序图总结
1. Add() 本身不神秘
就是:
- 增
ArrayNum - 不够就扩容
- 在尾部构造新元素
2. SceneRenderingAllocator 扩容不是 realloc
而是:
- 从
FMemStack再切一块新内存 Memcpy旧数据- 指针改到新块
3. 旧块不会立即释放
所以多次扩容会出现:
旧块1 + 旧块2 + 当前块
都同时挂在 MemStack 里。
4. 真正回收发生在 FMemMark::Pop()
一旦 Mark 弹栈:
- 当前数组内存失效
- 同一作用域里所有 memstack 临时分配一起失效
5. 归纳说明
SceneRenderingAllocator(TMemStackAllocator)是建立在 FMemStack(FMemStackBase)之上的TArray / TSet / TMap 等容器分配器策略;
它自己不管理内存池,只把容器的内存申请转发给当前线程的 FMemStack(FMemStackBase)
FMemStackBase作为底层实现者,负责:
- chunk 管理
Top/End指针推进- 不够时分配新 chunk
- 回退时释放多余 chunk
FMemStack是FMemStackBase的线程局部版本。
- 每个线程有自己的
FMemStack FMemStack::Get()取到的是当前线程的 memstack
生命周期上,谁负责释放?回收则依赖 FMemMark 批量完成。
代码里经常先 Reserve()
因为如果不先预估容量,后面每次扩容都会:
- 申请新块
Memcpy- 把旧块留在 memstack 里直到作用域结束
这在渲染阶段会额外浪费临时内存,还可能引入并行阶段的重分配风险。
你当前打开的 SceneVisibility.cpp 就有很典型的注释:
// The dirty list allocation must take into account the max possible size because when GILCUpdatePrimTaskEnabled is true, // the indirect lighting cache will be update on by threaded job, which can not do reallocs on the buffer (since it uses the SceneRenderingAllocator). View.DirtyIndirectLightingCacheBufferPrimitives.Reserve(Scene->Primitives.Num());
所以这类数组的最佳实践是:
- 先估最大数量
- 一次
Reserve到位 - 后面只做
Add/Push,尽量不触发扩容
优点
1. 分配非常快
本质是“指针前移”,开销远小于通用堆分配。
2. 几乎没有传统意义上的堆碎片
因为不是到处 malloc/free,而是线性推进。
3. 批量释放极高效
FMemMark::Pop() 一次恢复指针/回收 chunk。
4. 很适合“一帧临时数据”
比如:
- 可见性结果
- pass 构建中间数组
- 临时 draw command 列表
- transient primitive/light list
代价
1. 扩容成本偏高
因为每次扩容都是:
- 新申请
Memcpy- 旧块挂着不释放
2. Shrink() 不是真正回收
它只是给数组换一块更小的新内存; 旧块仍然占用到当前 mark 结束。
3. 不能把它当长期容器
如果对象活得太久,就会让 MemStack 内存长期滞留。
4. 跨线程 realloc 风险大
因为 FMemStack 是线程局部。 如果某个 TArray<..., SceneRenderingAllocator> 在别的线程触发重新分配, 会落到另一个线程的 memstack 上,生命周期/线程归属都会出问题。
其他示例代码
/** UnrealEngine\Engine\Source\Runtime\RHI\Public\RHICommandList.h */ // Stack allocate the transition FMemStack& MemStack = FMemStack::Get(); FMemMark Mark(MemStack); FRHITransition* Transition = new (MemStack.Alloc(FRHITransition::GetTotalAllocationSize(), FRHITransition::GetAlignment())) FRHITransition(Pipeline, Pipeline); GDynamicRHI->RHICreateTransition(Transition, Pipeline, Pipeline, ERHICreateTransitionFlags::NoSplit, Infos);
浙公网安备 33010602011771号