自定义内存分配器实战:消除游戏主循环中的 GC 停顿
自定义内存分配器实战:消除游戏主循环中的 GC 停顿

在 C#、Java 或使用标准堆分配(malloc / new)的游戏逻辑开发中,内存管理的不当往往是主循环掉帧卡顿的头号元凶。
托管堆垃圾回收器(Garbage Collector, GC)在执行“标记-清除-整理”(Mark-Sweep-Compact)周期时,常常需要触发“世界暂停”(Stop-The-World, STW),导致正在运行的游戏逻辑瞬间冻结 10 到 50 毫秒。即便在 C++ 等非托管语言中,频繁调用操作系统级的底层分配器,也会因为堆碎片化(Fragmentation)和多线程内存锁竞争带来不可预测的耗时尖峰。
消除主循环停顿的根本之道,是根据游戏数据的生命周期模式,构建特化型的自定义内存分配器(Custom Memory Allocator)。
游戏数据的三大生命周期模型
游戏运行时的内存申请并非完全随机,90% 以上的数据分配都可以归结为以下三类:
- 单帧临时数据(Frame Allocations):例如当帧可见性剔除列表、碰撞检测临时射线结果、UI 顶点动态拼接。这些数据只在当前帧存活,帧末即可彻底废弃。
- 关卡阶段数据(Stage/Level Allocations):例如关卡加载时生成的导航网格、特定副本的剧情对话配置。生命周期与关卡场景严格绑定。
- 长期同构对象(Persistent Homogeneous Objects):例如上千发同类型子弹、上百个同构怪物实体。对象尺寸固定,频繁创建与销毁。
针对这些场景,通用堆分配器显得极其笨重。我们应当使用**线性分配器(Linear/Arena Allocator)和池分配器(Pool Allocator)**来替代通用的堆分配。
线性分配器(Arena Allocator)的极速原理
线性分配器预先向系统申请一大块连续内存(例如 16MB)。
- 分配时,仅需将内部的偏移指针(Offset Pointer)向前推进所请求的字节数,并做内存对齐(Alignment)。时间复杂度为绝对的 $O(1)$。
- 释放时,不单独回收某个对象,而是在整帧结束或阶段切换时,将指针一次性归零(Reset)。整个过程不产生任何内存碎片,也没有任何链表遍历开销。
C++ 工业级内存对齐 Arena Allocator 实现
以下是一个具备 64 位指针对齐保障的 C++ Arena 分配器实现:
#include <cstdint>
#include <cstddef>
#include <cstdlib>
#include <new>
#include <cassert>
class ArenaAllocator {
public:
explicit ArenaAllocator(size_t capacityBytes)
: m_capacity(capacityBytes), m_offset(0) {
// 分配大块底层连续物理内存
m_buffer = static_cast<uint8_t*>(std::malloc(m_capacity));
assert(m_buffer != nullptr && "Failed to allocate arena memory");
}
~ArenaAllocator() {
if (m_buffer) {
std::free(m_buffer);
m_buffer = nullptr;
}
}
// 禁止拷贝
ArenaAllocator(const ArenaAllocator&) = delete;
ArenaAllocator& operator=(const ArenaAllocator&) = delete;
/// <summary>
/// 分配指定字节并满足 alignment 字节对齐约束
/// </summary>
void* Allocate(size_t size, size_t alignment = alignof(std::max_align_t)) {
// 计算当前指针对齐所需的向上取整偏移
uintptr_t currentAddress = reinterpret_cast<uintptr_t>(m_buffer + m_offset);
size_t padding = 0;
size_t modulo = currentAddress % alignment;
if (modulo != 0) {
padding = alignment - modulo;
}
size_t totalBytes = size + padding;
if (m_offset + totalBytes > m_capacity) {
// 内存耗尽
return nullptr;
}
uint8_t* alignedAddress = m_buffer + m_offset + padding;
m_offset += totalBytes;
return alignedAddress;
}
/// <summary>
/// 模板泛型便捷分配
/// </summary>
template <typename T, typename... Args>
T* New(Args&&... args) {
void* mem = Allocate(sizeof(T), alignof(T));
if (!mem) return nullptr;
return new (mem) T(std::forward<Args>(args)...); // 定位 new 构造
}
/// <summary>
/// 极速重置:将整个缓冲区清空复用,耗时仅需几个时钟周期
/// </summary>
void Reset() {
m_offset = 0;
}
size_t GetUsedBytes() const { return m_offset; }
size_t GetCapacity() const { return m_capacity; }
private:
uint8_t* m_buffer;
size_t m_capacity;
size_t m_offset;
};
C# 中借助非托管内存实现零 GC 帧分配器
在 C# 中,我们可以利用 NativeMemory.Alloc 与 Span<T>,在托管堆之外构建属于自己的高性能 Frame Arena,彻底避开 GC 的追踪扫描:
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
public unsafe class FrameArena : IDisposable
{
private byte* _buffer;
private readonly int _capacity;
private int _offset;
public FrameArena(int capacityBytes = 1024 * 1024 * 8) // 默认 8MB
{
_capacity = capacityBytes;
_buffer = (byte*)NativeMemory.Alloc((nuint)_capacity);
_offset = 0;
}
/// <summary>
/// 获取一段连续的内存切片用于单帧临时计算,零托管堆分配
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Span<T> AllocateSpan<T>(int count) where T : unmanaged
{
int sizeInBytes = sizeof(T) * count;
int alignment = alignof<T>();
// 对齐计算
int padding = (alignment - (_offset % alignment)) % alignment;
int totalSize = sizeInBytes + padding;
if (_offset + totalSize > _capacity)
throw new OutOfMemoryException("FrameArena capacity exceeded!");
byte* start = _buffer + _offset + padding;
_offset += totalSize;
return new Span<T>(start, count);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Reset()
{
_offset = 0;
}
public void Dispose()
{
if (_buffer != null)
{
NativeMemory.Free(_buffer);
_buffer = null;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int alignof<T>() where T : unmanaged => Unsafe.SizeOf<T>() > 8 ? 8 : Unsafe.SizeOf<T>();
}
生产环境中的架构监控与防溢出
在使用自定义分配器时,需要建立两套安全防线:
- 高水位标记(High-Water Mark):在 Profiler 中持续监控每帧的内存峰值占用。当某帧的 Arena 占用达到警戒线(如容量的 80%)时触发 Warning,以便及时扩容,防止在复杂战斗场景下突发 OOM。
- 生命周期跨越防护:通过编译期注解或 Handle 封装,防止开发者将单帧 Arena 中分配的指针引用带入下一帧使用(Dangling Pointer)。任何需要跨帧存活的数据,必须显式拷贝到持久堆或持久 NativeArray 中。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)