Calmer的文章

  • 首页
  • 文章归档
  • 关于页面

  • 搜索
体验游戏 笔记 推荐 工具链 工具使用 小游戏 插件 UI 软件 教程

UE5内存管理原理

发表于 2022-09-14 | 分类于 随笔 | 0 | 阅读次数 4697

前言

在使用UE的时候,最常听到的就是UE的GC,而说到GC又不得不说到UObject。因而引出的一个问题是:UE是如何对内存进行管理的呢?


一.基础概念

1.虚拟内存

20221102171006.png
创建进程的时候会给予其分配连续的虚拟内存,再映射到物理内存上,但是物理内存不一定连续

2.页表映射

20221102171203.png

3.内存分区

20221102171927.png

  • 代码区
    • 存放函数的二进制代码
    • 由操作系统进行管理
    • 共享、只读
  • 全局数据区
    • 存放全局变量、静态变量以及常量
    • 由操作系统进行管理
  • 栈区
    • 存放函数的参数,局部变量等
    • 由编译器自动分配释放
  • 堆区
    • 运行时动态分配内存
    • 通过VirtualAlloc,brk,mmap等系统接口进行管理

20221102172924.png

4. malloc和free

20221102173011.png

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分配的内存

20221102174107.png

3.placement new

  • 使用指定内存地址创建对象
    • 在已经分配好的内存上构造对象
    • 需要手动调用析构函数
    • 不能使用delete释放placement new创建的对象

20221102175350.png


三.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跟踪

20221103100829.png

20221103101233.png

UE5对operator new的处理

类型实现方式
ModuleFMemory::Malloc
RHIFMemory::Malloc
FParticleDynamicDataFMemory::Malloc
FSlateControlledConstructionFMemory::Malloc
TArray ElementTArray::AddUninitialized
FRawStatStackNodeGetRawStatStackNodeAllocator().Allocate
FMemStackBaseFMemStackBase::PushBytes
FDynamicEmitterDataBaseFastParticleSmallBlockAlloc
FUseSystemMallocForNewFMemory::SystemMalloc
UObjectStaticAllocateObject

FMemory::Malloc

20221103102328.png

GMalloc在系统中的位置

20221103102339.png

常见FMalloc类型和系统支持情况

平台AnsiTBBBinnedBinned2Binned3Stomp
Android支持支持默认支持(仅64位平台)
IOS支持默认
Windows支持默认支持支持支持(仅64位平台)支持
Linux支持支持默认支持
Mac支持默认支持支持支持

FMallocBinned2

20221103102430.png

3.为什么对象释放了,进程内存占用却回不来?(为什么UE5的内存变大以后就回不到原来的状态了呢?)

20221103102703.png

  • 内存碎片
    20221103102507.png
  • 内存池
    20221103102526.png
  • 进程的虚拟地址空间
    20221103102638.png

4.内存读写越界怎么办?

  • 内存池的引入使内存错误更难被发现,例如
uint8_t* const TestBytes = new uint8_t[64];
//1.内存溢出
TestBytes[128] = 1U;
//2.内存不足
TestBytes[-128] = 1U;
delete[] TestBytes;
//3.在释放后进行操作
TestBytes[0] = 1U;
  • FMallocStomp
    20221103102921.png

5.超短周期反复创建的对象

大量对象生命周期只有一帧

  • 渲染相关数据结构每帧创建
  • 性能统计数据每帧创建
  • 部分tick函数创建的临时对象

解决方案:FMemStackBase(栈式内存分配器)

  • 按顺序分配和释放
  • 分配内存直接返回当前位置即可
  • 释放内存直接移动CurrentOffset指针

20221103103257.png

6.内存泄漏问题(如何快速定位内存泄漏?)

方案一:MALLOC_LEAKDETECTION

  • 重新编译代码,定义MALLOC_LEAKDETECTION
  • mallocleak命令
    • mallocleak.start
    • mallocleak.stop
    • mallocleak.report
  • 原理是替换了GMalloc(使用了内存泄漏代理)
    20221103103721.png

方案二: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

image.png

  • StaticAllocateObject
    image.png

  • 调用构造函数

    • 只有接受FObjectInitializer参数的构造函数会执行
    • 通过placement new实现
      image.png
  • FObjectInitializer

    • 析构函数中会修改新创建的UObject对象
      image.png
    • FObjectInitializer::PostConstructInit
      20221103105430.png
    • FObjectInitializer::InitProperties
      20221103105502.png
  • 为什么配置文件会覆盖构造函数中的设置?

    • CDO(Class Default Object)对象创建时会从配置文件中读取设置
    • 对象实例创建时会从CDO对象获取配置文件的设置

image.png


四.UE垃圾回收

UObject对象不能直接删除

  • 大部分UObject派生类并没有定义析构函数
  • 直接delete会在UObjectBase析构函数中报错
  • UObject的销毁是由垃圾回收机制实现的
    20221103110114.png

1.UE垃圾回收算法:标记清扫法

2022-11-03-11-01-53

CollectGarbage

  • 标记可回收对象
  • 过程需要加锁
    • 避免其他线程创建对象
    • 避免其他线程找到一个正在被回收的对象

20221103110528.png
20221103110553.png

CollectGrabageInternal

  • 调用PreGarbageCollectDelegate
  • 判断上次gc的对象清理是否完成
    • gc的对象清理是分帧进行的
    • 如果上次要清理的对象还没有处理完成就立即处理
  • 可达性分析
    • 收集根对象
    • 找到其他被引用的对象
  • 收集不可达对象
  • 标记可以开始清理对象(GObjectPurgelsRequired)

可达性分析

  • 遍历GUObjectArray所有元素
  • 清除Reachable标记
  • 如果带有RootSet标记则加入根对象数组
  • 如果带有KeepFlags中的标记则加入根对象数组
  • UClass或正在异步加载中的对象加入根对象数组
  • 其他对象标记为不可达Unreachable

20221103111125.png

  • 寻找对象依赖关系
    • 遍历根对象数组中的每一个元素
    • 生成每一个元素的TokenStream数据
      20221103110322.png
    • 根据TokenStream找到其他被引用的UObject并加入根对象数组
    • 清理掉新找到的对象的Unreachable

为什么UObject类型成员变量要加上UPROPERTY修饰?
20221103110353.png

  • 再次遍历UObjects
    • 把所有被标记为Unreachable的对象放入GUnreachableObjects数组

置空所有被引用的地方

  • 遍历的过程中如果发现自己引用的对象已经是PendingKill状态,那么就把该对象指针置为Null
    20221103111623.png

为什么UPROPERTY变量使用前要判空?
20221103111659.png

可达性分析后的效果

20221103111732.png

IncrementalPurgeGarbage

  • 增量清除对象
    • 分帧完成GUnreachableObjects对象清理
    • 默认最长执行时间为2ms
  • 对象清除过程
    • 调用ConditionBeginDestroy通知对象开始销毁
    • IsReadyForFinishDestroy判断是否已经完成销毁
    • ConditionFinishDestroy销毁大部分数据
    • 调用析构函数
    • 释放内存

2.非UObject对象引用的UObject如何GC?

  • AddToRoot
  • 继承FGCObject

20221103112114.png
20221103112235.png

3.簇(Cluster)

  • 对UObject分组管理
    • 对于复合性的逻辑物体,所有子对象状态同步化
    • 整体处理可以大幅减少gc需要遍历的对象数量
    • 大幅度提升gc性能

20221103112426.png

4.WeakObjectPtr

  • 如果没有标记UPROPERTY
    • UObject对象随时可能被GC
    • GC后对UObject指针的读写行为是不确定的
  • WeakObjectPtr可以感知GC过程
    • 不直接通过UObject指针来获取对象
    • 通过Index和SerialNumber来查找
      20221103112616.png
  • WeakObjectPtr原理
    image.png

5.UObject注意事项

  • 裸指针随时会被回收
    • 只能在短时间使用
    • 可以用TWeakObjectPtr封装
  • 如何不被回收
    • AddToRoot
    • 其他不被回收对象的UPROPERTY属性
    • 继承FGCObject
  • 主动被回收
    • UPROPERTY属性赋值为空(断开引用链)
    • 调用Actor的Destory
  • 垃圾回收的实际情况
    • 加载场景时
      • LoadMap卸载前一个场景后
      • 全量清理
    • 定时GC
      • gc.TimeBetweenPurgingPendingKillObjects
      • 增量清理
    • 手动GC
      • 调用ForceGarbageCollection

6. 如何查看对象引用关系

  • memreport
    20221103113144.png
  • Obj Refs
    20221103113210.png

五.了解Unlua与UE双重GC的问题

既然UE4有垃圾回收,为什么Unlua关联的UObject对象不会自动销毁呢?

1. Unlua与UE4混合GC

20221103113355.png

  • Unlua Release/Destory
    20221103113441.png

  • 为什么Unlua关联的对象不会自动销毁?

    • 因为lua代码无法确定对象的生命周期,所以会阻止gc回收对象
    • 通过Release可以明确通知Lua代码销毁对象
    • Unlua对Actor进行了特殊处理,不需要手动Release

2.如何监控特定模块内存的变化?

Low Level Memory Tracker (LLM)

image.png

  • 通过命令行参数开启LLM
    • LLM
    • LLMCSV
  • 显示汇总数据
    • stat LLM
    • stat LLMFULL
    • stat LLMPlatform
    • stat LLMOverhead
  • 可以扩展自定义的内存类型

入侵式内存跟踪代码

image.png

  • 使用Tag标记
    image.png

定位lua临时变量的"内存泄漏"

  • 创建LLM Tag标记Lua内存
    image.png
  • unlua分配内存时进行标记
    image.png

Lua调用C++函数时创建临时变量

  • Lua代码设置actor的位置
actor:K2_SetActorLocation(UE4.FVector(Location.X, Location.Y, 0));
  • Unlua代码中会创建一个lua的FVector对象
    image.png

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代码中尽量少使用临时变量
  • 本文作者: Calmer
  • 本文链接: https://mytechplayer.com/archives/ue5内存管理原理
  • 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!
# 笔记
UE4性能优化工具和经验
远程日志工具
  • 文章目录
  • 站点概览
Calmer

Calmer

88 日志
7 分类
10 标签
RSS
Creative Commons
0%
© 2020 — 2025 Calmer
由 Halo 强力驱动
蜀ICP备20010026号-1川公网安备51019002006543
Copyright © 2020-2025 Calmer的文章