灵魂拷问-什么是内存
- 物理内存
- 虚拟内存
- 内存寻址方位
物理内存
下面是一张i7的处理器的芯片细节图,在整个板载面积上我们可以很明显的看到Shared L3 Cache占用了最大面积。为什么?因为硬件产商为了让我们忽略掉CPU访问内存是一个非常慢速的过程,所以CUP在访问内存的时候会有很多板载的Cahe,对于一个芯片来讲它的板上面积是非常珍贵的,但是从下面图中我们可以看到真正用到计算的板块区域大小是非常少的(6个Core区域)。剩下的大部分珍贵面积都被用在了IO和内存相关的工作上。
![](https://i-blog.csdnimg.cn/direct/1dcdee171dbe4e43b59a779b1108f03d.png)
Cache 是如何工作的
现代CPU的运算频率相较于服务器主内存的访问速度存在较大的差异,为了解决访问主存速度过慢的问题,现代CPU中一般会增加多个层次的cache,CPU cache使用SRAM制造且离CPU计算单元的距离更近,因此有着更快的访问速度。
有了CPU cache之后,CPU处理数据时首先尝试从cache中读取,如果找到就可以立即送入CPU处理,如果未找到,则会从下一层级的cache或者主存中读取,同时把读取的数据放入cache以便下一次使用. 现代CPU的一般使用三层cache(L1/L2/L3),其中L1又分为指令cache和数据cache,其架构大致如下图所示:
从L1 cach 到 L2 cache 到 L3 cache,容量越来越大,离CPU计算核心越来越远,而速度则越来越慢。
总结就是CPU访问内存是一个非常慢的过程,由于数据在不同层级cache以及主寸之间的转移是以cache line(64Bytes)为单位, 而从不同层级cache的速度差异可以看出,为了达到更快的数据访问速度,我们需要尽量发挥cache的局部性特征,将计算时序上相邻的数据在内存地址上安排在一起,目标是实现一次读取到高层次cache的数据即可供后续的多次运算使用。任何想达到最优计算性能的内存数据结构都需要遵循这个设计思想。
Unity ECS and DOTS
Unity DOTS设计的初衷就是将数据不连续的状态变为数据连续状态,这样CUP在读取内存的时候,下一次的Cache Miss就会降低,从而提高CPU有效的访问时间
台式设备与移动设备内存架构差异
- 没有独立显卡
- 没有独立显存
- CPU板上面积更小,缓存级数更少,大小更小(主流台式CPU L3Cache 在 8M到16M 而手机CPU L3Cache 2M,差了4-8倍)
虚拟内存
内存在计算机中的作用很大,电脑中所有运行的程序都需要经过内存来执行,如果执行的程序很大或很多,就会导致内存消耗殆尽。为了解决这个问题,Windows中运用了虚拟内存技术,即拿出一部分硬盘空间来充当内存使用,当内存占用完时,电脑就会自动调用硬盘来充当内存,以缓解内存的紧张。举一个例子来说,如果电脑只有128MB物理内存的话,当读取一个容量为200MB的文件时,就必须要用到比较大的虚拟内存,文件被内存读取之后就会先储存到虚拟内存,等待内存把文件全部储存到虚拟内存之后,跟着就会把虚拟内里储存的文件释放到原来的安装目录里了。
交换内存
当操作系统在使用内存不够的情况下,会尝试把一些不用的内存交换到硬盘上,从而节省出更多的物理内存,来提供给系统比较活跃的线程或者应用。
移动设备不支持内存交换
ios可以进行内存压缩
把不活跃的内存压缩到内存的特定空间里,节省出物理内存供活跃的App使用
Android没有内存压缩能力
内存寻址范围
如图1中 的 Memory Controller 的区域,通常来说64位CPU比32位CPU的内存寻址范围要大,当然不是一定的,因为这里的64位与32位讲的是运算位数,并不是内存范围。
Andriod 内存管理
内存基本单位-Page
andriod 是基于linux的操作系统,最基本的管理单位是一个Page,默认情况下
- 一个Page是4K
- 回收和分配以page为单位
- 用户态和内核态(内核态的内存用户态是不可以访问的)
内存杀手-low memory killer
当前台App内存使用量达到一定程度上,会导致后台app关闭了,或者当前app直接闪退了,更严重可能直接重启桌面或者手机重启了。这都是因为 memory killer干的,当系统内存不够后,killer会如上图中的 从最底下一层一层往上杀
Cached->Previous->Home->....->System 杀到System的时候手机就重启了
内存指标
- Resident Set Size
- Proportional Set Size
- Unique Set Size
RSS(Resident Set Size):是当前App所应用掉的所有内存,如下Rss图app调用了Google Play Services的4个page内存,在RSS统计的使用这部分内存会统计到App身上
PSS(Proportional Set Size):App 按比例统计,比如PSS图所示两个进程共享,那就负责一半page。如果三个进程共享,那就负责三分之一
USS(Unique Set Size):只有统计自己使用的内存,公共使用的内存不算进来
![]() |
![]() |
![]() |
总结:如果pss很高,uss很低,那说明app调用了一个非常大的公共库,内存公摊了。实际工作中能做到的是USS上的优化,以及避免在pss上照成更大的压力。
以上讲了一段内存相关的知识点,接下来我们回归到Unity上来讲:
Unity 是一个C++引擎
- 底层代码完全由C++写成
- 通过wrapper提供给用户API
- 用过代码会转换为cpp代码(il2cpp)
- vm任然存在(为了跨平台)
Unity内存按照分配方式分别为:
- Native Memory
- Managed Memory
- Editor & Runtime
一个Asset在runtime的使用不去load它的时候它是不会进内存的,如果是在Editor模式下,你打开它,它就进内存了,所以unity编辑器会在一开始加载它序列化它,第一次开项目会话很长的时间。这样的优点是在你开发的过程中更流畅。但是头一次要花费更长的时间。
Unity内存按照管理者分为:
- 引擎管理内存
- 用户管理内存
Unity 检测不到的内存
- 用户分配的native内存(c++插件)
- lua内存
Unity Native Memory管理
- Allocator与memory lable
- NewAsRoot
- GetRuntimeMemory
- 会及时返还给系统
unity重载了c++分配内存的操作符(allocator,new),在使用的时候要求多一个参数memory lable,指的是当前这块内存要分配到哪一个内存池里。
比如一个shader,当我们加载进内存的时候会生成一个shader的root(NewAsRoot),然后在shader底下会有很多的数据比如sub shader,pass等等,这些数据就会作为root的成员去依次分配。
什么会导致Native内存增长?
Scene
所有的实体都会反应到C++上,因为一个GameObject,在c++层上会存在一个或多个对象(gameobject身上的component)来存储信息。如果scene上有过多的GameObject,那么Native内存就会上升
Audio
DSP buffer 声音缓冲
当一个声音要播放的时候会发送一个cpu指令说要播放声音,当一个声音的数据量非常小,就会频繁的发送指令就会频繁的发声IO,DSP buffer 就是为了解决频繁发送的问题,它会等待填充慢了才会向cpu发送指令。当然也是有缺点的,如果你设置的 dsp buffer 过大会导致声音的延迟,如果设置过小,就会导致cpu负担上升。
Froce to mono
有的音频会存在双声道,但是有很多单声道的声音倍设置成了双声道,这就意味着一个 1M的声音,会变成2M的内存,这2M不仅在包体里,也会在内存里。所以单声道的声音就设置成单声道即可。
Format
不同平台会有不同的format支持,ios一般选 MP3,android并没有硬件支持都可以 wav,ogg
Compression Format
声音在内存里的格式:压缩的,流,解压的
Code Size
模板泛型的乱用,每个不同的泛型的组合会倍编译成不同的类,假设A类有三个泛型第一个泛型你使用了int,string,float 就会变编译成3个类,如果你每个泛型的组合都使用了就会被编译成3*3*3=27种可能,包体会增大,代码一样类型却不一样。
public class A<T,T1,T2>{...}
// 编译后
public class A1<int,int,int>{...}
public class A2<string,int,int>{...}
public class A3<float,int,int>{...}
......
AssetBundle
TypeTree
TypeTree 是Unity做版本升级使用的,unity迭代过程每一种类型都有可能做数据结构的改变,为了做数据结构改变的兼容,它会在生成这种数据类型的序列化的时候对应的生成TypeTree,当前版本所用到了哪些变量类型是什么,会通过TypeTree做反序列化。如果上一个版本类型在当前版本中没有就不做处理,如果当前版本出现了一个新的类型,那么就会使用默认。保证了在不同版本之间导致序列化出错。
在Build Bundle的时候如果关闭掉Type Tree生成,就会减少包体,当然要确保你当前AssetBundle使用的unity版本,和你构建时的unity版本是一致的就可以关闭掉。
关闭TypeTree有三个好处
- 包体变小
- runtime会变快 (因为会节省TypeTree的序列化)
- 内存减少
Lz4
压缩方式Lz4是主推的,Lz4的压缩速度是Lzma的10倍,但是它的压缩比率比Lzma差30%
Lzma
Lzma 压缩速度和读取速度都比Lz4慢,而且会占大量的内存,因为它需要一次全解压出来,不是一块一块的解压。
Size & Count
一个AssetBundle打多大合适?assetbundle不能过小,因为assetbundle会有自己的头数据,asset过小打了assetbundle可能会导致比不打还大,内存被消耗在无意义的头上了。所以不要太大也不要太小,官方给出合适的大小是2M,当然它们是考虑了网络带宽,现在是5G时代了可以更大点,当然要实际考虑用户带宽的承受能力,和对下载的敏感程度。
Resources
resources构建的时候会生成一个红黑树 R-B-Tree,用来检索asset在那个地方,而且是不可卸载的,所以在开始加载游戏的时候就会去解析RBTree(影响游戏启动时间),并且内存一直占用着,所以现在基本没人使用。
Texture
-
upload buffer (和上面讲的一致,填满多大向GPU push)
-
r/w (尽量不开,开了之后内存和显存个占用一份内存,否则是共用一份)
-
Mip Maps (UI就没必要开了)
Mesh
- r/w (尽量不开,开了之后内存和显存个占用一份内存,否则是共用一份)
- compression
Assets
托管部分内存
VM内存池
- Mono VM
- IL2CPP VM
VM会把内存返还给OS吗?
返还的条件是什么?
当一快内存连续6次GC没被访问到时,会返还给OS
GC机制
- Throughput(回收能力)
- Puase times(碎片化)
- Fragmentation(碎片化)
- Mutator overhead(额外消耗)
- Scalability(可扩展性)
- Portability(可移植性)
Unity 使用的是Boehm,Non-generational(非分代式)
分代是指:大块内存、小内存、超小内存是分在不同内存区域来进行管理的。还有长久内存,当有一个内存很久没动的时候会移到长久内存区域中,从而省出内存给更频繁分配的内存。
并且是 Non-compacting(非压缩式) ,如果是分代的话,当有内存被回收的时候,压缩内存会把下图空的地方重新排布。但 Unity 的 BOEHM 不会!它是非压缩式的。空着就空着,下次要用了再填进去。
历史原因:Unity 和 Mono 合作上,Mono 并不是一直开源免费的,因此 Unity 选择不升级 Mono,与实际 Mono 版本有差距。
新一代 GC(Incremental garbage collection - Unity 手册)
Incremental GC(渐进式 GC):
1.现在如果我们要进行一次 GC,主线程被迫要停下来,遍历所有 GC Memory ,来决定哪些 GC 可以回收。
2.Incremental GC 把暂停主线程的事分帧做了。一点一点分析,主线程不会有峰值。总体 GC 时间不变,但会改善 GC 对主线程的卡顿影响。
SGen 或者升级 Boehm?:
SGen 是分代的,能避免内存碎片化问题,调动策略,速度较快
IL2CPP:现在 IL2CPP 的 GC 机制是 Unity 自己重新写的,是升级版的 Boehm
Memory fragmentation 内存碎片化
为什么内存下降了,但总体内存池还是上升了?
- 因为内存太大了,内存池没地方放它,虽然有很多内存可用。(内存已被严重碎片化)
- 当开发者大量加载小内存,使用释放*N,例如配置表、巨大数组,GC 会涨一大截。
- 建议先操作大内存,再操作小内存,以保证内存以最大效率被重复利用。
Zombie Memory(僵尸内存)
内存泄露说法是不对的,内存只是没有任何人能够管理到,但实际上内存没有被泄露,一直在内存池中,被 zombie 掉了,这种叫 Zombie 内存。
- 无用内存:
1.Coding 时候或者团队配合的时候有问题,加载了一个东西进来,结果从头到尾只用了一次。
2.有些开发者写了队列调度策略,但是策略写的不好,导致一些他觉得会被释放的东西,没有被释放掉。
3.找是否有活跃度实际上并不高的内存。 - 没有被释放的内存
内存实践-Managed内存
用destory,别用Null。
Class VS Struct
Pool In Pool (池中池)
Closures and anonymous methods (闭包和匿名函数)
所有的匿名函数和闭包都会被生成一个为class,只是匿名的class,闭包或者函数单中的所有数据都会变成class的属性占用内存,所以需要慎用
Coroutines(协程)
协程在没有停止的时候,即使是局部变量所有的数据都会占用的内存,因为协程整个函数没有返还所以一直占用着,最好的方式就是用完就停止,因为协程实际上是Update轮询的。
Configuretion (配置表)
Singleton (慎用单例会一直存在整个app生命周期中)
UI 的 SetActive(会做很多的设置和初始化的工作以及递归子物体,如果SetAtive照成卡顿可以将UI移到屏幕外面就不会进行渲染)