前言
在使用UE的时候,最常听到的就是UE的GC,而说到GC又不得不说到UObject。因而引出的一个问题是:UE是如何对内存进行管理的呢?
一.基础概念
1.虚拟内存
创建进程的时候会给予其分配连续的虚拟内存,再映射到物理内存上,但是物理内存不一定连续
2.页表映射
3.内存分区
- 代码区
- 存放函数的二进制代码
- 由操作系统进行管理
- 共享、只读
- 全局数据区
- 存放全局变量、静态变量以及常量
- 由操作系统进行管理
- 栈区
- 存放函数的参数,局部变量等
- 由编译器自动分配释放
- 堆区
- 运行时动态分配内存
- 通过VirtualAlloc,brk,mmap等系统接口进行管理
4. malloc和free
5.OOM(Out Of Memory)
- 物理内存是有限的
- 操作系统本身占用
- 前台挂起的其他进程
- 前台运行进程
- 内存不足时系统如何处理?
- 尝试释放内存
- 杀掉后台挂起进程
- 给前台进程发送内存告警
- 杀掉前台进程
二.C++方面内存管理
1.new/delete表达式
- new用于创建对象
- 调用operator new分配内存
- 在分配好的内存上执行构造函数
- delete用于删除对象
- 执行对象的析构函数
- 调用operator delete释放内存
2.operator new/delete
- operator new
- 类似malloc
- 只分配所要求的内存
- 不调用相关对象的构造函数
- operator delete
- 类似free
- 只负责operator new分配的内存
- 不调用对应的析构函数
- 不能用来释放malloc分配的内存
3.placement new
- 使用指定内存地址创建对象
- 在已经分配好的内存上构造对象
- 需要手动调用析构函数
- 不能使用delete释放placement new创建的对象
三.UE4方面内存管理(内存分配,垃圾回收)
1.常见内存问题出现的原因
- 为什么普通的new操作会调用UE5的内存分配函数?
- 为什么对象释放了,进程内存占用却回不来?
- 怎么检测内存读写越界错误?
- 为什么要用NewObject创建对象?
- 为什么构造函数中设置的属性会被覆盖?
- 为什么成员变量需要加UPROPERTY修饰?
- 为什么UPROPERTY变量使用前需要判空?
- 为什么Unlua返回的UObject对象要release?
- 为什么Unlua tick代码要尽量避免创建临时变量?
2.UE5是如何接管普通对象的new操作的呢?
UE5对operator new/delete的处理
- 重载每个class的operator new/delete
- 根据类型的不同提供不同的管理策略
- 重载全局的operator new/delete
- 保证所有内存分配都被UE5跟踪
UE5对operator new的处理
类型 | 实现方式 |
---|---|
Module | FMemory::Malloc |
RHI | FMemory::Malloc |
FParticleDynamicData | FMemory::Malloc |
FSlateControlledConstruction | FMemory::Malloc |
TArray Element | TArray::AddUninitialized |
FRawStatStackNode | GetRawStatStackNodeAllocator().Allocate |
FMemStackBase | FMemStackBase::PushBytes |
FDynamicEmitterDataBase | FastParticleSmallBlockAlloc |
FUseSystemMallocForNew | FMemory::SystemMalloc |
UObject | StaticAllocateObject |
FMemory::Malloc
GMalloc在系统中的位置
常见FMalloc类型和系统支持情况
平台 | Ansi | TBB | Binned | Binned2 | Binned3 | Stomp |
---|---|---|---|---|---|---|
Android | 支持 | 支持 | 默认 | 支持(仅64位平台) | ||
IOS | 支持 | 默认 | ||||
Windows | 支持 | 默认 | 支持 | 支持 | 支持(仅64位平台) | 支持 |
Linux | 支持 | 支持 | 默认 | 支持 | ||
Mac | 支持 | 默认 | 支持 | 支持 | 支持 |
FMallocBinned2
3.为什么对象释放了,进程内存占用却回不来?(为什么UE5的内存变大以后就回不到原来的状态了呢?)
- 内存碎片
- 内存池
- 进程的虚拟地址空间
4.内存读写越界怎么办?
- 内存池的引入使内存错误更难被发现,例如
uint8_t* const TestBytes = new uint8_t[64];
//1.内存溢出
TestBytes[128] = 1U;
//2.内存不足
TestBytes[-128] = 1U;
delete[] TestBytes;
//3.在释放后进行操作
TestBytes[0] = 1U;
- FMallocStomp
5.超短周期反复创建的对象
大量对象生命周期只有一帧
- 渲染相关数据结构每帧创建
- 性能统计数据每帧创建
- 部分tick函数创建的临时对象
解决方案:FMemStackBase(栈式内存分配器)
- 按顺序分配和释放
- 分配内存直接返回当前位置即可
- 释放内存直接移动CurrentOffset指针
6.内存泄漏问题(如何快速定位内存泄漏?)
方案一:MALLOC_LEAKDETECTION
- 重新编译代码,定义MALLOC_LEAKDETECTION
- mallocleak命令
- mallocleak.start
- mallocleak.stop
- mallocleak.report
- 原理是替换了GMalloc(使用了内存泄漏代理)
方案二:MallocProfiler2
- 修改模块的Target.cs设置
- bUseMallocProfiler = true
- bOmitFramePointers = false
- mprof命令
- mprof mark 生成快照
- mprof stop 停止记录
7.为什么要用NewObject创建UObject对象?
- NewObject帮我们规范UObject的创建过程
- 需要更新UObjectArray相关的全局变量
- 保证用模板或CDO默认值来设置新创建的对象
- SpawnActor是AActor对象创建的辅助函数
FUObjectArray和FUObjectAlloctor
- GUObjectArray
- 保存所有UObject对象的指针
- 最大数量由gc.MaxObjectsInGame或gc.MaxObjectsInEditor决定
- GC相关代码严重依赖GUObjectArray
- GUObjectAllocator
- 用于为UObject对象分配内存
- FObjectInitializer
- 用于初始化UObject对象
GUObjectAllocator.AllocatePermanentObjectPool(SizeOfPermanentObjectPool);
GUObjectArray.AllocateObjectPool(MaxUObjects, MaxObjectsNotConsideredByGC, bPreAllocateUObjectArray);
NewObject
-
StaticAllocateObject
-
调用构造函数
- 只有接受FObjectInitializer参数的构造函数会执行
- 通过placement new实现
-
FObjectInitializer
- 析构函数中会修改新创建的UObject对象
- FObjectInitializer::PostConstructInit
- FObjectInitializer::InitProperties
- 析构函数中会修改新创建的UObject对象
-
为什么配置文件会覆盖构造函数中的设置?
- CDO(Class Default Object)对象创建时会从配置文件中读取设置
- 对象实例创建时会从CDO对象获取配置文件的设置
四.UE垃圾回收
UObject对象不能直接删除
- 大部分UObject派生类并没有定义析构函数
- 直接delete会在UObjectBase析构函数中报错
- UObject的销毁是由垃圾回收机制实现的
1.UE垃圾回收算法:标记清扫法
CollectGarbage
- 标记可回收对象
- 过程需要加锁
- 避免其他线程创建对象
- 避免其他线程找到一个正在被回收的对象
CollectGrabageInternal
- 调用PreGarbageCollectDelegate
- 判断上次gc的对象清理是否完成
- gc的对象清理是分帧进行的
- 如果上次要清理的对象还没有处理完成就立即处理
- 可达性分析
- 收集根对象
- 找到其他被引用的对象
- 收集不可达对象
- 标记可以开始清理对象(GObjectPurgelsRequired)
可达性分析
- 遍历GUObjectArray所有元素
- 清除Reachable标记
- 如果带有RootSet标记则加入根对象数组
- 如果带有KeepFlags中的标记则加入根对象数组
- UClass或正在异步加载中的对象加入根对象数组
- 其他对象标记为不可达Unreachable
- 寻找对象依赖关系
- 遍历根对象数组中的每一个元素
- 生成每一个元素的TokenStream数据
- 根据TokenStream找到其他被引用的UObject并加入根对象数组
- 清理掉新找到的对象的Unreachable
为什么UObject类型成员变量要加上UPROPERTY修饰?
- 再次遍历UObjects
- 把所有被标记为Unreachable的对象放入GUnreachableObjects数组
置空所有被引用的地方
- 遍历的过程中如果发现自己引用的对象已经是PendingKill状态,那么就把该对象指针置为Null
为什么UPROPERTY变量使用前要判空?
可达性分析后的效果
IncrementalPurgeGarbage
- 增量清除对象
- 分帧完成GUnreachableObjects对象清理
- 默认最长执行时间为2ms
- 对象清除过程
- 调用ConditionBeginDestroy通知对象开始销毁
- IsReadyForFinishDestroy判断是否已经完成销毁
- ConditionFinishDestroy销毁大部分数据
- 调用析构函数
- 释放内存
2.非UObject对象引用的UObject如何GC?
- AddToRoot
- 继承FGCObject
3.簇(Cluster)
- 对UObject分组管理
- 对于复合性的逻辑物体,所有子对象状态同步化
- 整体处理可以大幅减少gc需要遍历的对象数量
- 大幅度提升gc性能
4.WeakObjectPtr
- 如果没有标记UPROPERTY
- UObject对象随时可能被GC
- GC后对UObject指针的读写行为是不确定的
- WeakObjectPtr可以感知GC过程
- 不直接通过UObject指针来获取对象
- 通过Index和SerialNumber来查找
- WeakObjectPtr原理
5.UObject注意事项
- 裸指针随时会被回收
- 只能在短时间使用
- 可以用TWeakObjectPtr封装
- 如何不被回收
- AddToRoot
- 其他不被回收对象的UPROPERTY属性
- 继承FGCObject
- 主动被回收
- UPROPERTY属性赋值为空(断开引用链)
- 调用Actor的Destory
- 垃圾回收的实际情况
- 加载场景时
- LoadMap卸载前一个场景后
- 全量清理
- 定时GC
- gc.TimeBetweenPurgingPendingKillObjects
- 增量清理
- 手动GC
- 调用ForceGarbageCollection
- 加载场景时
6. 如何查看对象引用关系
- memreport
- Obj Refs
五.了解Unlua与UE双重GC的问题
既然UE4有垃圾回收,为什么Unlua关联的UObject对象不会自动销毁呢?
1. Unlua与UE4混合GC
-
Unlua Release/Destory
-
为什么Unlua关联的对象不会自动销毁?
- 因为lua代码无法确定对象的生命周期,所以会阻止gc回收对象
- 通过Release可以明确通知Lua代码销毁对象
- Unlua对Actor进行了特殊处理,不需要手动Release
2.如何监控特定模块内存的变化?
Low Level Memory Tracker (LLM)
- 通过命令行参数开启LLM
- LLM
- LLMCSV
- 显示汇总数据
- stat LLM
- stat LLMFULL
- stat LLMPlatform
- stat LLMOverhead
- 可以扩展自定义的内存类型
入侵式内存跟踪代码
- 使用Tag标记
定位lua临时变量的"内存泄漏"
- 创建LLM Tag标记Lua内存
- unlua分配内存时进行标记
Lua调用C++函数时创建临时变量
- Lua代码设置actor的位置
actor:K2_SetActorLocation(UE4.FVector(Location.X, Location.Y, 0));
- Unlua代码中会创建一个lua的FVector对象
3. 为什么Unlua tick代码要尽量避免创建临时变量?
- 在tick函数中调用会生成大量临时变量
- 每个临时变量都是一个lua对象,实际内存远远大于对象数据
- sizeof(FVector) = 12
- NewTypedUserData创建48
- Lua对象需要等待gc时才能回收
- gc前内存持续增长
- 大量对象gc耗时长
解决方法
- 适当提高lua gc频率
- 尽量避免在tick函数中创建临时变量
- 调用c++代码时尽量使用原生数据类型
总结
- 基本概念
- 虚拟内存机制让各进程共享有限的物理内存
- 物理内存不足时会触发OOM杀掉低优先级进程
- C++内存管理
- 使用new和delete管理内存
- UE内存管理
- new操作被UE接管
- 内存池导致对象销毁后进程内存未减少
- FMemStack快速分配和释放内存
- 使用MallocLeakDetection快速检测内存泄漏
- UObject对象管理
- 使用NewObject创建对象
- 用配置文件修改类成员变量的默认值
- 垃圾回收
- 使用UPROPERTY避免对象被垃圾回收
- 被垃圾回收的UPROPERTY变量会变为NULL
- obj refs可以查看对象引用关系
- UnLua对象管理
- UnLua关联的UObject对象要手动Release
- LLM可以方便的给内存分类
- Lua代码中尽量少使用临时变量