游戏开发自底向下:内存分配与管理

By KSkun, 2021/11
实习时接触了许多关于游戏开发相关的内存话题,在这里分享一些基于 Unity 游戏开发视角可能用得上的底层知识。这个系列将分成 4 期来探讨以下 4 个话题:
- 内存分配与管理
- 垃圾回收
- 内存占用、泄露的排查方法
- PC、Android 和 iOS 的平台差异
这些内容所参考的都是公开的信息,不会涉及需要保密的内容,请放心食用!
(只是挖了个坑,填不填二说,请不要期待下期 doge.webp)
目录
从物理内存到 C# 对象
物理内存

上图是一条由三星电子生产的 4GB 容量的 PC3-10600U (DDR3L-1333) 内存条,上面整齐排列着三星电子生产的、型号为 K4B2G0846D 的 SDRAM 存储器芯片。这样的一个芯片的规格是 256M x 8[2],也即 256×106 个宽度为 8 的存储单元(字节),故用 MB 来表示它的容量就是 256MB。你需要告诉芯片操作第几个数据单元,芯片则会根据操作读写对应单元。
这个存储器只能完成简单的存取功能,它并不关心你想存什么东西进去,数据和地址对它而言只是一些 0 或 1 的比特。CPU 中的内存管理单元会与它交互,来完成程序中的 IO 指令。只有操作系统才能和具体的内存设备打交道,用户程序并不用关心它的数据会保存在哪颗芯片的第几个单元,也即:物理内存对用户程序是透明的。
虚拟内存

对于一个 32 位 Linux 系统上运行的程序而言,它的每一个进程(process)都具有一段长 4GB 的内存空间,64 位系统甚至更大。即使你的电脑并不具备 4GB 物理内存也能运行这个程序,这是因为这个内存空间是「虚拟的」,不是每个地址对应的内存单元都被分配并存在于物理内存中。虚拟内存(virtual memory)的概念由此引入。

进程的内存以页(page)为单位管理,如果你需要操作某一内存单元,则操作系统先查询页表(page table)项,如果对应了物理内存中的单元则操作物理内存,否则需要将对应数据换入物理内存中,再操作物理内存。不存在物理内存中的页可能临时保存于外部存储器中,或未被分配。
综上所述,操作系统提供了 4GB 的虚拟内存空间,却并不是都能被用户程序所使用。能够使用的条件是:处于合理的内存区域内、被分配或被映射。操作系统通过虚拟内存、页式管理的机制彻底屏蔽了物理内存的特性,用户程序只关心自己的虚拟内存空间,而不再需要操心物理内存的种类、接口与大小。但用户程序在需要使用内存时必须注意地址是否合法,新分配内存也需与操作系统交互,不能任意使用内存。
进程内存模型
回到上面的图 2,这张图还包含了非常重要的东西:一个进程如何使用它的虚拟内存空间。这里我们以 C/C++ 程序的角度来理解。一个进程的虚拟内存空间中包含许多段,其中包括:
- 栈(stack):函数调用和返回信息、自动变量,先进后出
- 堆(heap):运行时分配的变量
- 数据段(data segment):静态变量、常量等编译时分配的变量
- 代码段(text segment):程序的指令
程序运行时可能进行多次函数调用,形成一条调用链,链末端的函数先退出,因此适用先进后出(FILO,first-in-last-out)数据结构,也即栈(stack)。而运行时发生的内存分配,如 new MyClass;
或 malloc(100);
,则在堆区域分配内存。编译时即完成解析的内存(代码、静态变量、常量等)则另分配固定的区域存储。
C# 的变量与对象
Unity 使用的脚本语言是 C#,这种语言的程序需要在运行时通过 JIT(Just-In-Time)编译器编译至本地指令再执行。此外,C# 运行时也提供了更高层的内存管理抽象与垃圾回收机制。因此,我们在 C# 中创建和使用对象并不是直接基于上面提到的机制,而是与 C# 运行时交互,运行时再与操作系统交互。
在了解 C# 如何管理内存之前,需要对 C# 的变量类型所有了解。C# 的变量类型可分为两类:
- 值类型(value type):在变量的内存空间存储值,包括 int、bool、char、double、enum 类型、struct 类型等
- 引用类型(reference type):在变量的内存空间中保存引用,通过引用访问堆中的对象,包括 class 类型、delegate、string、object、数组等
C# 的栈中保存的是值类型变量的值和引用类型变量的引用,堆中保存引用类型对象。C# 的栈工作原理与上面的进程内存模型类似,是对系统栈的一个封装;而堆则与系统堆的管理方式大相径庭,因为 C# 提供了自动内存管理和垃圾回收机制,并不需要手动分配和回收内存空间。
C# 内存管理机制
C# 的内存管理机制主要依赖 CLR(Common Language Runtime)中的垃圾回收(GC,Garbage Collect)组件完成,本章重点将放在堆内存的分配和管理上,有关垃圾回收的话题会在后续文章中介绍。
托管堆
为了与系统堆区分,C# 中引入了一个叫「托管堆(managed heap)」的概念,表示受 C# 运行时垃圾回收组件管理的堆空间。C# 的引用类型对象都在托管堆中分配。

托管堆分为小对象堆(SOH,Small Object Heap)和大对象堆(LOH,Large Object Heap),每一部分都由许多内存段(segment)组成,每个段是进程内存中的一段连续内存空间。在运行时启动时,托管堆会预先从系统分配一些段作为初始内存空间。如果一个段被分配殆尽,运行时尝试对段运行 GC,如果还无法分配,则运行时向系统请求新的段来完成分配。
小于 85000B 的对象被分配在小对象堆,超过这一阈值的对象被分配在大对象堆。由于对象的大小差异,这两个区域有着不同的管理方式。

小对象堆
小对象堆保存的主要是体积较小的对象,在内存间复制这样的对象开销不大,因此采用了分代与压缩的管理策略。
小对象堆的分配总在末尾进行,运行时维护一个当前分配的末尾指针,新分配时直接增加此指针的值并将末尾未分配内存分配给对象即可。如果剩余未分配内存不够,则 GC 会尝试对段进行一次压缩。
GC 运行压缩的过程会将一个段分成若干代,从首部到尾部依次为 Gen 2、Gen 1 和 Gen 0。代数越高,表示存活的时间越久,新分配的对象都属于 Gen 0。当压缩进行时,GC 将被释放的对象移走,需要保留的对象搬运到一起,使段的未分配内存集中在末尾的连续一段,留给下一次分配使用。GC 和压缩结束后各对象的代数增加 1,增加到 2 则不再增加。这样一次操作使得 GC 释放出的未分配区域被移到末尾,用来支持只需移动指针的快速分配。
大对象堆
压缩的做法在大对象堆一般难以实现,因为这里的对象较大,搬运时需要较大开销,导致 GC 运行时间长。因此,在大对象堆不使用分代和压缩,只是使用最原始的管理方法:分配内存时,首先找前面的未分配内存碎片中有没有满足要求的,如果没有则在末端分配。
内存碎片

C# 关于 LOH 的管理方式很容易产生内存碎片,如果大对象的分配顺序不当,先分配较小的对象再分配较大的对象,此时可能因小对象回收出的间隙不足以容纳大对象而被浪费,原本小对象分配的内存区域中产生大量内存碎片。由于分代和压缩机制的存在,小对象堆则不需要担心这一问题。
一种解决内存碎片问题的方法是,预先统计出有哪些大对象需要分配,在使用前统一地预先分配这些对象。较大对象被回收后产生的空缺能够被较小对象利用,因此能够降低内存碎片的比例。
在 Unity 中进行内存性能分析
Memory Profiler
Unity 官方提供了一个很方便的内存分析工具——Memory Profiler,可以在 Package Manager 中找到这款插件,但由于是 Preview 版本,需要在 Project Settings 中勾选 Enable Preview Packages 设置项。


安装完成后,可以在菜单栏 Window – Analysis – Memory Profiler 打开插件窗口,如下图所示。

在编辑器内启动游戏后,点击上面界面菜单栏中的 Capture 抓取内存快照。此处演示已经抓取了一个,在界面左下角列表中可以看到。点击快照后插件展示内存的基本信息。




上面的截图反应了插件的主要功能,包括
- 总览:内存总的分类分析
- 块图:将对象按类型分类呈现内存占用的大小
- 对象列表:所有在内存中分配的对象
- 段分布:内存分布和占用的情况
其中我们在游戏内分配了一个超级大的 int 类型数组,在图 13 中可以找到它。根据上文的描述,从 0x000001c0f8831000 开始的一段内存应该是 C# 大对象堆的一个段。
脚本 API
有关内存分析的 C# 脚本 API 都在命名空间 UnityEngine.MemoryProfiler
中,你可以通过文档找到它们。
例如,你可以通过 MemorySnapshot.RequestNewSnapshot()
请求一个内存快照,拿到 PackedMemorySnapshot
对象,它包含的成员如下所示。

利用这些信息,你可以得到快照获取时内存中的所有对象和对象之间的引用关系,进行二次开发以分析内存。例如,一个第三方插件 Heap Explorer(https://github.com/pschraut/UnityHeapExplorer)就是对此 API 的二次开发。
结语
这期文章中自底向上地介绍了 C# 中的内存和对象与系统内存、物理内存的关系,并简单介绍了 C# 管理内存的方法,最后分享了一些在 Unity 中进行内存性能分析的方法。有关垃圾回收机制、内存泄露排查等话题将会在后续的文章中介绍。
所谓「工欲善其事,必先利其器」,进行 Unity 游戏开发的时候,对 Unity 本身的底层工作机制有所了解,有助于在进行 gameplay 开发时实现出性能优的方案。这一系列文章也是为 gameplay 开发者介绍 Unity 中内存相关知识而编写的,旨在为新人开发者降低了解底层知识的门槛。由于作者水平有限,文章难免存在错误或纰漏,欢迎读后提出建议和意见。
这里是 KSkun,一个不知道接下来打算研究啥而胡乱研究的新人开发者,不知道能不能下期再见(狗头)。
参考资料
- Samsung Ram DDR3 PC3 2RX8 4GB 1333 1600 MHz Desktop Memory 240pin sell 4GB/8GB DIMM 4G 8G 10600U 12800U 1333MHZ 1600MHZ. Yao Yue Store (AliExpress)
https://www.aliexpress.com/item/32873164329.html - 2Gb D-die DDR3L SDRAM K4B2G0846D Datasheet (pdf). Samsung Electronics Co., Ltd.
https://datasheet.ciiva.com/26786/k4b2g0846d-hck0-26786806.pdf - 虚拟内存[01] 用户内存空间的各个段分布. Gary Chan (知乎)
https://durant35.github.io/2017/10/29/VM1_UserSpaceSegments/ - CS142 Lecture 16 OS Virtual Memory. University of Northern Iowa
http://www.cs.uni.edu/~fienup/cs142f03/lectures/lec16_OS_virtual_memory.htm - C# Memory Management – Part 1. Sena Kılıçarslan (Medium)
https://medium.com/c-programming/c-memory-management-part-1-c03741c24e4b - Deep Dive Into .NET Garbage Collection. Nazar Kvartalnyi (DZone)
https://dzone.com/articles/deep-dive-into-net-garbage-collection - Fundamentals of garbage collection. Microsoft Docs
https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals - Managed memory. Unity Documentation
https://docs.unity3d.com/Manual/performance-managed-memory.html - Memory Management in C#. Adam Thorn (Rewind)
https://www.rewind.co/technical-articles/memory-management-c-sharp-lzfz3 - Scripting API. Unity Documentation
https://docs.unity3d.com/ScriptReference/