Unity内存分配和回收的底层原理

本文由 简悦 SimpRead 转码, 原文地址 zhuanlan.zhihu.com

前言

又到了高川老师的干货分享时间,视频连接如下:

「Unity 技术开放日」北京站视频演讲_哔哩哔哩_bilibili

作为小迷弟依旧做一个文字版的笔记,这次分享的内容是对 Unity 内存管理这一块的深入介绍,也就是帮我们开黑盒。其实早期高川老师就已经分享过一次这方面的内容了,笔记如下:

王江荣:Unity 的内存管理与性能优化

一开始以为这次的内容会和之前的差不多,实际上被打脸了,本次的分享会更加的深入讲解内存的分配与回收机制。此外老师还画了个大饼,提到了下面很多系统的底层,希望后续能够兑现这个饼,帮我们一一揭开这些黑盒。

其中 SRP 的黑盒已经由李中元老师帮我们揭晓了,可参考:

王江荣:【Unity】SRP 底层渲染流程及原理

由于自己也是个萌新,特别是对于很多底层的东西,本篇笔记里加入了一些自己的理解,如果不对,望大佬们指正。

下面进入正题。

Unity 由两部分内存来组成,原生内存(Native Memory)和托管内存(Managed Memory)。其中 Native Memory 大家接触的会比较少,而且可操控性也比较少,例如 AssetBundle,Texture,Audio 这些所占的内存,这一部分内存是由 Unity 自身来进行管理的。我们平时开发通常会接触到的是 Managed Memory,也就是我们自己定义的各种类,如果这部分内存爆了,就需要我们自己去进行优化。

Native Memory

我们先来介绍一下 Native Memory,看看 Unity 是如何分配和释放内存的。

new/malloc

由于 Unity 是一个 C++ 引擎,那么分配和释放内存无非有下面两种方法:

第一种 new 和 delete:
Obj *a = new Obj;
delete a;
第二种 malloc 和 free:
Obj a = (obj )malloc(sizeof(obj));
free(a);

那么它们的区别是什么呢?主要有如下几种:

首先 new 属于 c++ 的操作符(类似于 +、- 等),而 malloc 是 c 里面的函数,理论上来讲操作符永远快于函数。

其次 new 分配成功时,返回的是对象类型的指针,无须进行类型转换,而分配失败则会抛出 Exception。而 malloc 分配成功则是返回 void * ,需要通过强制类型转换变为我们需要的类型,分配失败只会返回一个空(NULL)。

然后还有非常重要的一点就是,malloc 基本上大家的实现是类似的,new 却各有各的不同。我们知道 new 会去调用构造函数(constructor),所以很多人会把 new 理解成 malloc+constructor,但是这种理解是错误的。因为在 c++ 实现的 standard 里,你会发现 new 会不会调用 malloc 完全由 new 库的实现来自行决定,也就是说 new 和 malloc 是两者独立的。

另外从严格的 c++ 和 c 的意义上来讲,他们分配内存的位置是不一样的,new 会分配在自由存储区 (Free Store),而 malloc 会分配在堆(heap)上。这两者的区别简单来说,你 new 分配的内存再 delete 后是否直接释放还给系统是由 new 自己来决定的,而 malloc 分配的内存再 free 后是一定会还回去的。

所以严格来讲 new 和 malloc 根本不是一件事,两者之间没有什么关系。

理解了原生的 new 和 malloc 之后就会发现,它们交给上层开发者去进行可控的单元其实并不多。也就是说当我去 new 的时候,具体 c++ 怎么去 new,实际上完全依赖于库实现。如果这个库实现的比较好,例如有些库的实现会包含内存池这样的机制,那么我们的 new 就不容易导致一些例如内存碎片化的问题。但是如果实现的并不好,就可能造成性能的瓶颈。

因此大部分的工业软件,在写软件的第一件事情就是去重载整个的 new 和 malloc,例如重载 operator new() 和 operator delete() 来重新定义 new 和 delete 操作符的功能。因此 Unity 也是这样的,即不会用原生的这套内存分配系统,而是自己去实现一套。

Unity 的内存管理

Unity 怎么做内存管理的呢?大致流程如下图:

简单来说 Unity 会自定义一整套的宏,例如图中的 UNITY_MALLOC,此外也会有自己的 new 等一系列的宏。当 Unity 代码里面通过这个宏去分配内存的时候,实际上并不会直接去调用库函数(malloc)或者说是我们对应的操作符(new),而是会交到一个叫 Memory Manager 的管理器里。如果大家去看一下我们的 Profiler,里面会列出忙忙多的 Manager(如下图),但是却找不到 Memory Manager,因为我们看见的那些 Manager 信息大部分都是由它提供的,所以它自己并没有被包含进去。

在日常的开发当中我们也会意识到 Unity 有很多的 Manager,比如说 MeshManager、SoundManager,但是很少会意识到 Unity 自己有一个 Manager,因为没把它写到 profiler 的数据里。

当 Unity 要去 malloc 一个东西的时候,我们会给这个内存一个标识符,也就是图中的 Memory Label。我们在 Profiler 的 Detailed 信息里,能够看见很多的分类,这些分类里面展示了各个项的内存占用情况,如下图:

那么 Unity 如何在运行时区分出这块内存到底是谁的呢,就是通过 Memory Label 来进行区分的。

同时 Memory Label 还会帮 Memory Manager 去做一个筛选,Unity 在底层会有一系列的内存分配策略,不同的策略会对应不同的分配器。图中列举了三个简单的分配器:栈分配器(Stack Allocator),批量分配器(Batch Allocator,在 SRP 和 URP 系统里经常会用到,此处又是一个饼),和比较常用的动态堆分配器(DynamicHeap Allocator),在读 Unity 源码时会经常碰见他们。

![](data:image/svg+xml;utf8,http://www.w3.org/2000/svg’ width=’298’ height=’429’>)

图中一整个大块叫做一个 Heap Block,当我们要分配一块内存的时候,实际上 Unity 帮我们分配的两块的内存:Header 和 User。

Header 里面记录了我们当前这一次分配的一些信息,一般有如下几种:

  • 当前这块是不是要被删掉,即图中的 Deleted 标记。
  • 下面 User 这块真正要给用户的区域要有多大,即一个 size。
  • 当前这一块它前面的那一块是谁,应该是个指针吧。

User 就是我们用户分配使用区域。也就是假如我们去分配一个 16byte 大小的内存,实际上消耗的可能是 32byte 的大小,因为会有个头。

这一整块内存在一开始的时候就已经预先分配好了,当我们进行栈分配的时候,实际上只是在不停的调整栈顶指针。例如示意图中我们分配了三块内存,那么每次分配时栈顶指针的位置如下图:

![](data:image/svg+xml;utf8,http://www.w3.org/2000/svg’ width=’402’ height=’116’>)

如果你见过这个东西,那么恭喜你中奖!今天的课对你就非常的有用了,你就能知道自己为什么碰见它了。

这个东西是干什么的呢?简单来说,刚刚我们说栈大小只有 128KB-1MB,如果爆了怎么办。Unity 整体的设计原则不会让大家的程序 Crash。例如你拿到的 Shader 不一定是你想要的 Shader,但是绝对会保证你不出错;以及你写的 C# 可能会抛异常,但是绝对不会让你的游戏崩溃。这是 unity 设计的一个理念,会做一个兜底行为(FallBack 机制)。在写 Shader 时会要求你去写一个 FallBack,不写就 FallBack 到一个 Error Shader,也就是我们常见的紫红色效果,如下图:

![](data:image/svg+xml;utf8,http://www.w3.org/2000/svg’ width=’394’ height=’96’>)

上面的曲线是我们正常使用的一个曲线,接下里来看一个不正常的曲线,如下:

不正常在哪呢?我们可以发现当我们在预留内存还明显足够的情况下,分配了一小块使用内存,此时却导致了预留内存的增加。导致这个现象的其中一个问题就是内存碎片化,例如我们内存池里还有 40byte 的内存,但是却是由 10 个 4byte 的碎片内存组成的,此时我们想要分配一个 6byte 的内存(连续的内存),却没有地方可以放得下,因此只能增加预留内存。

因此内存池最根本的一个目的就是要减少内存碎片化。但是实际开发中还是会经常出现这样的问题,例如我们频繁的分配小内存,把整个内存打的特别散,这样就会出现内存碎片化的问题。

官方文档:

undefined

Boehm GC

简单来看下 BDWGC(全称:Boehm-Demers-Weiser conservative garbage collector),也就是常说的 Boehm 回收器。其实它除了回收之外还做了很多分配的工作,甚至还可以用来检查内存泄漏。

而 Unity 用的是改良过的 BDWGC,它属于保守式内存回收。目前主流的回收器有如下三种:

  • 保守式回收(Conservative GC),以 Boehm 为代表。
  • 分代式回收(Generational GC),以 SGen(Simple Generational GC)为代表。
  • 引用(计数)式内存回收(Reference Counting GC),例如 Java 就是使用的这种,但是它是结合了保守式的引用式内存回收。

那么为什么 Unity 不用分代式的呢?分代式的是否更好呢?答案是,虽然分代式确实有很多的优点,但是它们都要付出额外的代价。例如分代式的 GC,它要进行内存块的移动,一块内存在频繁分配区长时间不动的话,会被移动到长时分配区,造成额外消耗。另外每次回收的时候还要进行一个评估,判断当前内存是否是一个活跃内存,这些东西都不是免费的,而是要消耗额外的 CPU 性能。当然 sGen 也有它的优势,例如它是可移动的,可以进行合并的(可以减少内存碎片)等等。但是在计算力本身就很紧张的移动平台上,再花费 CPU 去计算内存的搬迁和移动实际上是不合算的,引用计数也有类似的问题,所以 unity 还是使用相对比较保守的 Boehm 回收。

Boehm 回收有两个特点,一是不分代和不合并的,所以可能会导致内存碎片。二是所有保守式内存回收都是非精准式内存回收。

何为非精准?常规理解是我分配出去的内存你可能收不回来。实际上还有另一层意思:你没分配的内存你可能也用不了。也就是说,一是我已经分配出去的内存在没有人在引用它的情况下,不一定能收得回来。二是我没有分配使用的内存,当你想去分配使用的时候也不一定用的了。

我们来从 Boehm 内存管理的简单模型入手(如下图),来理解为什么会导致前面的问题。

如图,Boehm 在内存管理的时候实际上是两级的管理。第一级我们叫做类型(Kind),实际上就是一个三个元素的数组 GC_obj_kinds[3],如下图:

![](data:image/svg+xml;utf8,http://www.w3.org/2000/svg’ width=’273’ height=’345’>)

链表里面的每个元素代表的就是一小块内存,其内存大小就是 ok_freelist[i] 对应的大小。例如图中 Size(16) 下面挂着 Block0,Block1,Block2,说明每个 Block 的大小都是 16 字节,Size(32) 下面每个 Block 自然都是 32 字节。

所以总体来说,我们有一个 GC_obj_kinds[3] 数组,然后每个 GC_obj_kinds 元素下面会有一个 ok_freelist[MAXOBJGRANULES+1] 数组。而 ok_freelist[index] 里存的是一个链表指针,指向大小为 index*16 的内存块。

假如现在我们分配一个小于等于 16 字节大小的内存,那么就会把 Size(16) 链表里的空闲的内存块拿出来给用户来用。比如我就分配 8 字节的内存,但是会拿到一个 16 字节的内存块,那么多出来的 8 字节内存就会被浪费掉。但是如果在 Size(16) 的链表里找不到可用的内存块,那么就会去找 Size(32) 的链表。如果在 32 字节里找到一个可用的内存块,由于我们要的内存只有 8 字节,明显小于 16,那么 Unity 会把这 32 字节的内存块一刀切成两块,给用户 16 字节,剩下的 16 字节挂到 Size(16) 的链表底下。若此时所有 Size(n) 里面都找不到可用的内存块,那么则会调用分配函数,分配较大一块内存,然后将大内存分割为小内存链表存储在 ok_freelist 中,可以理解为一个内存池

假如我们先分配一个 16 字节的内存,使用掉 Size(16) 的 Block0,然后再分配一个 32 字节的内存,使用掉 Size(32) 的 Block0,此时这两块内存在物理上是连接的,虽然在逻辑上他们之前属于不同的链表。若此时这两块内存都不使用了,要回收它们,会怎么做呢?依旧分别插在对应的链表下么?并不是这样。当我们这两块内存同时被释放的时候,Unity 在释放第一个 Block0 的时候会去找后面的物理内存(也就是第二个 Block0)是否要被释放,发现这两块都要被释放的时候,那么就会把它们合并起来,让这个指针挂到更大的地方去,也就是 Size(48) 的链表下,从而去尽量减少整体内存碎片的问题。

也就是说 Unity 尽量会把空闲出来的内存合并成一个较大的内存块,同时以移动指针的方式(注意不是移动内存)把它挂到一个合适的链表下面,这就是整个内存分配的一个策略。

我们再来谈谈回收,以及为什么说他是非精准的原因。如下图,假如我们要回收 ObjectA。

在保守内存回收器来看,当我要去回收一个内存块的时候,我会尝试找到这个内存块下面所有的指针(图中的 0x011-0x013)指向的地址,并且标记为引用。例如图中 ObjectA 引用 l ObjectB,当 ObjectA 发现不能被回收的时候,同时会标记 ObjectB 也不能被回收。这样的算法我们称之为标记清除算法(Mark&Sweep),即标记阶段通过标记所有根节点可达的对象,未被标记的对象则表示无引用、可回收,所有从堆中分配的内存 Boehm 中均有记录。

看着没什么问题,但是因为在内存这个层次上已经没有了整个 class 的信息,那怎么知道这个东西是一个数还是一个指针的呢?因为我们知道 c++ 一份内存里东西它可以表示任何东西,它可以是个数也可以是个指针地址,它是什么都可以,你转是什么。比如 0x011 它可能是个地址指向了 ObjectB,也可能单纯的就是个数而已。

那么我怎么知道它们是不是指针呢?Boehm 用猜的,所以我们管它们叫潜在指针(potential pointer),并不确定是不是一个真的指针。Boehm 会以一个 pattern 的方式来检查当前这个数有没有可能是一个指针。比如说我先去检查 0x011 地址里面有没有东西,发现有 ObjectB,那么 ObjectB 就不会被回收。然后检查 0x012,发现有 ObjectC,那么 ObjectC 同样就不会被回收。但是实际上我们的 0x012 并不是一个指针,也就是说逻辑上来讲 ObjectA 和 ObjectC 没有引用关系,但是恰好分配在 0x012 内存上。但是对于 Boehm 来说发现 0x012 指的这块地方有东西,因此 ObjectC 就回收不了。最后检查 0x013,发现它指向一块没有被使用的内存,那么 Boehm 就会把这块内存加到黑名单里,然后当你下次要进行大内存分配的时候,碰巧踩到了这个地址,Boehm 会告诉你这块内存你不能用,得再去分配一块。这样就很好理解前面所说的非精准了,你要回收的内存可能收不回来,对于你没用的内存他也可能不让你用。

所以我们在做内存分配的时候要考虑先分配大内存,再分配小内存。因为当我们先分配大内存的时候,内存中对象较少,产生内存碎片和产生黑名单的概率都比较低。因为只有在分配大内存的时候,分配器才会去参考黑名单,看看这块内存是不是被黑掉了。如果我们先分配小内存,那么我们内存中的 Object 就会非常多,那么产生黑名单的概率就会变大,然后再分配大内存的时候就有可能你分配 1MB 内存,它给了你 50MB 内存用掉了,因为剩下那些内存全部被黑名单了(连续黑名单)。

另外一个原因是,当你先去分配大内存,比如 1024 字节的内存,用过大内存把它释放掉之后,那么这块内存就会被放到 Size(1024) 下。然后当我们需要扩展内存池的时候,会优先把已经分配出来的大内存切成一个个的小块,再去重复利用,并不会再去像系统申请新的物理内存。也就是说当我们先分配大内存再分配小内存的时候,之前表里的 block 会变多。

先分配小内存的话,此时内存已经被切散了,大的 block 都被变成一个个小的 block 了,当我们这些小内存被回收的时候,如果被回收的部分在物理上不是连续的,那么永远不会变回一个大的 block。这样当我们需要扩展内存池的时候,就再也分配不出大内存,只能找系统去要了。就出现了之前曲线图里面的异常情况,再也分配不出来了,或者分配的这块被黑名单了,所以不得不再去向系统要一块内存。

GC 相关的部分参考自:

Testplus:解读 MONO 内存管理和回收

SRP底层渲染流程及原理
lua中的字符串操作(模式匹配)