【编程基础】Unity的内存管理

前言

最近在看完unity技术开放日中高川老师对于内存的讲解,对unity的内存有了一些全新的认识。写一篇文章来做一次学习总结

正文

Unity的内存结构

在unity中内存其实分为两块:

  1. Native Memory 原生内存

  2. Managed Memory 托管内存

分为这两块主要是因为Unity的底层是用c++所写的所以有一块Native Memory,业务层使用Mono为了能够跨平台所以在mono中的内存是Managed Memory。所以我们在考虑Unity的内存的时候,要分别讨论这两块内存的管理机制(分配,回收)

Native Memory

介绍

Unity实际上是一个C++引擎,所有的实体最终都会反映到C++上,也就是反映在Native Memory上,当我们使用Unity的组件的时候,我们就可以很明显的看到Native Memory的上升。所以在使用Unity的组件的时候,我们需要好好考虑Native Memory的内存变化,来进行一个正确的使用方案。

分配方案

New/MALLOC

  1. New是操作符,MALLOC是函数

  2. New从自由存储区(free store)上分配,MALLOC从堆上分配。凡是通过New进行的内存申请,就是自由存储区,它可以是堆上的,也可以是今天存储区的,还能使用New的时候不给对象分配内存,只是返回一个指针实参;MALLOC分配的堆是操作系统中的术语,是操作系统所维护的一块特殊内存。

  3. New内存分配成功时是放回对象的类型指针,类型严格与对象匹配,符合类型安全性的操作符,它不会试图访问自己没被授权的内存区域;Malloc分配成功返回的类型是void*,需要强制类型转换到我们需要的类型

  4. New内存分配失败时候,会抛出bac_alloc异常,不会返回Null;Malloc分配失败会返回Null。

  5. new不需要指定内存大小,编译器会自动计算出来;而Malloc需要显式的指出内存的大小。

  6. new分配经历三个步骤:第一步:调用operator new 函数(对于数组是operator new[])分配一块足够大的,原始的,未命名的内存空间来储存制定类型对象;第二:编译器运行相应的构造函数一构造对象;第三:构造成功后,返回一个指向该对象的指针;当被回收的时候,调用delete的时候会经历两个步骤:第一:调用对象的析构函数;第二:编辑器调用operator delete(数组调用operator delete[])函数释放内存空间。Malloc不会调用构造函数。

  7. new对数组的是可以在初始化的时候调用构造函数去初始化每一个元素,释放的时候可以对每个对象调用析构函数。malloc 在分配内存的时候,不知道你再内存上要放的是什么。

  8. new可以在operator new/operator delete的实现调用malloc的;但是malloc的实现不可以去调用new。

  9. new的operator new/operator delete是可以被重载的。malloc是不可以的。

  10. malloc分配内存后,如果使用的时候内存不足,它是可以调用realloc函数去重新分配内存扩容。new就不能这样直管的去扩容。

  11. New抛出异常的时候会调用用户的指定错误处理函数;malloc在内存不足的时候只会返回一个null

Unity重写New/Malloc

unity会定义一些宏定义,然后当调用New/Malloc分配内存的时候,不会去调用库函数,而是使用unity自己实现的函数。

如图所示当Unity在分配内存的时候会定义这个分配内存的类型(Memory Label),然后根据这个Memory Label来判断在哪里分配内存给用户。其中有stack Allocator(栈分配器),Batch Allocator(批量分配器),DynamicHeapAllocator(动态堆分配器)

Stack Allocator

这一块其实不是我们一般认为的栈帧,而是unity自己实现的一个栈类型的内存分配方案。他其实是用来做一些临时数据的内存分配,要十分快的不被使用了。

特性

Fast(快),Small(小),TemPorary(临时)

结构

如上图所示,一块数据包含header,user这两块内容

  1. header

这块数据保存着这个内存块的一些信息,比如是否被删除,user的大小,上一块的位置。

  1. user

这块就是用户实际使用到的数据。

使用方案

  1. 这一整个栈分配器,在程序启动的时候就定好是多大了,一般在编辑器模式下,主线程16M,子线程256kb,在runtime(运行)模式下主线程128kb-1M,子线程 65kb。

  2. 这个是通过移动栈顶指针的方式来做分配和回收的;当用户要分配一块内存的时候,直接在栈顶指针之下分配一块内存,然后把栈顶指针给下移;当回收的时候,先在header上标记删除,然后看这块内存是不是栈顶指针指向的那块内存,如果是,就把栈顶指针上移,检测上一块内存是不被标记回收,标记了就回收,没有就停在这块。在这里就会有一个问题出现,如果我回收的是中间那块内存,就会只标记,不做栈顶指针的移动,下次使用的时候,还是去分配新的内存,而没有对这块内存重复利用了,只有等下一次栈顶指针移动到这里才会回收重复利用。所以这样就产生了内存碎片,内存浪费。

  3. 如果需要分配的内存已经超出了栈分配器的剩余可用的内存大小的时候(这里指的是栈顶指针之下的内存),会把内容分配到DynamicHeapAllocator(动态堆分配器)中抛出一个MemoryManager.FallbackAllocation这个异常。在栈分配器可能分配一段内存零点几毫秒但是在动态堆分配里,它可能需要几毫秒,这个时间消耗飙升。

提醒

现在说完了栈分配器的一些特性,我们就可以对它的一些特性做一个解释了,为啥快呢?因为它只做指针的移动,而不会去做其他的操作,为啥需要临时呢?因为你不能永久的保持栈顶指针不动,而一直在回收中间部分的内存,这样就内存无法重复利用,引起内存碎片。造成很大的浪费,所以你需要快速的用完就丢掉。

未完待续

关于其他的内存分配器,等下回分享

Managed Memory

介绍

这个是托管内存,关于这块的内容网上到处都有,各种GC就是大部分说的就是这个托管内存的回收机制,它就是为了方便使用者不去做内存的管理,只需要使用,使用完了编辑器就会自动帮你回收内存,用的时候也是编辑器自动给你分配一块内存给你。
主流的GC算法:

  1. 保守内存回收(BoehmGC等)

  2. 分代式内存回收(sGen)

  3. 引用式内存回收(java)

关于GC的一些算法,我之后再详细写,现在先写一个mono中的BoehmGC,为啥mono里要用BoehmGC呢,而不用市场上现在很流行的分代GC算法呢?这个除了一些历史原因,还有一个原因是因为使用分代GC你会做一个内存块的移动,和对当前内存块的活跃度的判断;这些操作都会对cpc的计算力做一次消耗。

BoehmGC

这个GC算法是从c++中先使用的,所以带有很强烈的c++的一些特性,你可能在后面结合c++的一些特性能更好理解它有些东西为啥这样做的。

从上图我们可以看到它的内存管理属于一个二级管理,第一级代表的这个kind的类型,常见的有 PTRFREE(无指针类型),Normal(比较一般的类型),还有 Uncollectable(不可回收类型);一般Boehm自己用到的会放在Uncollectable中。然后再下面你看到它有一个size的二级,这里代表的是当前它下面所挂的内存的大小,它会把所有和自己所表示大小一样的内存块通过链表来链接在自己的下面。

分配

当用户要分配一个8byte大小的内存的时候,因为现在表面的最小是16,所以它就会把16下的内存块拿出一个给用户,剩下的8byte就直接浪费了。如果16byte的内存链表下面没有可用的内存块的时候,它就会从更大一级的内存块(32)中拿一块出来,然后一分为二16byte用户拿去用,剩下的16byte挂载16byte链表下。

回收

当用户要回收一块内存的会去找它物理相连的地址上的数据是不是也被回收了,如果也被回收了,那他们两个就合并在一起,然后挂在一个更大的链表下面等待使用(比如两个Block0),减少内存碎片。

标记回收

标记回收是怎么去找我哪块内存需要回收,在Boehm里它是属于非精准性回收。表示分配的内存没人用但是不一定内被标记到可以回收,还有一种是没人用的内存也没有办法被使用;说明这两种的内存被认为还在被引用。

我们看上图,对象的引用是因为在一个地方保存了他的地址,所以被认为是被引用了。在Boehm中他认为保存的这个数据是指针,它靠的是猜,怎么猜的呢?它会保存自己从HEAP分配内存的最低地址和最高地址,就好比现在我们看到的0x012它可能是一个数,但是它刚好在这个区间内,那不好意思了,你就是指针了,那通过你指向的位置的对象,也就被我引用了,那你就不能别回收了。还有一个0x013(也认为是一个数)如果把它也刚好在这个区间,但是它直接指向的那个地址其实是一个没人用到的(或者已经被回收的),Boehm就会把它标记到黑名单里,其他地方在使用内存的时候刚好踩到这块内存,Boehm会给你分配别的地方的内存,这块现在就是不能用了。
所以当你分配了一堆小内存的时候,那这个时候,你产生黑名单的概率和不能回收的概率都会大大提高的。

总结

在使用内存的时候,我们需要优先考虑分配大内存,然后再去分配小内存;减少内存碎片的,降低黑名单,以及不能回收的概率;也可以在大内存被回收之后可以被重复切成小块内存使用。但是当你有一堆小内存,他们的物理内存地址不是连续的时候,就算你能真实大小满足这一次要使用的内存大小的时候,但是因为你不是连续的,所以不能用,导致系统需要从新分配一块连续的内存给用户使用。

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注