1. 应用程序内存结构
应用程序内存空间通常划分为五个部分
1.1 静态/全局存储区
存放全局和静态变量,静态分配的,在程序执行的最开是分配,后面不会再增长
1.2 常量存储区
存储程序中的常量
1.3 代码段
存放程序执行代码的内存区域,在程序运行之前就已经确定了,通常是只读的,当多个进程运行同样的程序时,可以使用同一个代码段
1.4 栈
栈是一块连续的内存区域,栈的容量由系统规定,栈底地址在程序初始化时就确定了。栈通常用来存储局部变量、函数的参数和返回值
- 快速存取
栈最大的特点是快速存取,这是因为操作系统本身就支持栈这种数据结构,有专门的寄存器指向栈底,同时有专门的汇编指令进行入栈和出栈操作 - 内存分配特点
定义局部变量时进行分配,或函数参数/返回值自动分配和入栈。
在栈中分配内存是连续的,不会产生碎片,且栈的分配是从高地址往低地址方向增长 - 内存释放
当变量超出作用域时,系统自动释放
1.5 堆
堆主要用来存放动态分配的对象,堆的大小由系统内存/虚拟内存的上限决定
- 堆的分配特点
通常使用new
和malloc
来动态分配。堆的分配效率比较低。系统通常使用一个链表记录堆中所有空闲区域的首地址指针,当进行内存分配时,需要遍历链表,来选取一个大小足够容纳的区域进行分配,然后修改链表中的指针值;如果没有找到足够的空间,会调用系统接口来增加,因此堆的分配效率比较低,且容易生成内存碎片。另外,堆的分配是从低地址往高地址增长 - 内存释放
需要调用delete
或free
来主动释放
2. 内存管理方式
主流的内存管理方式包括三种:手动管理、引用计数和垃圾回收(Garbege Collect,GC)
2.1 手动管理
- 管理方式
手动调用malloc
或new
进行分配,手动调用free
或delete
进行释放回收 - 优点
速度快,无额外开销 - 缺点
较难管理,必须明确跟踪对象使用情况,容易产生分配后未回收导致内存泄漏、错误回收导致野指针、空指针等问题
2.2 引用计数
- 管理方式
对象使用时计数器 +1,使用完毕计数器 -1,当计数器为 0 时进行销毁 - 优点
半自动管理,切速度较快 - 缺点
存在循环引用的情况,会导致内存泄漏
2.3 垃圾回收(GC)
- 管理方式
自动进行垃圾回收 - 优点
整个过程是全自动的,用户几乎不需要参与内存的管理,并且不存在循环引用的问题 - 缺点
在进行垃圾回收时需要停止所有线程 Stop the World
3. .Net/Java 中 GC 的原理
3.1 堆内存划分区域
堆内存区域划分将堆内存划分为年轻代、老年代和永久代
- 年轻代
年轻代主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁。年轻代又被划分为 Eden 区、Survivor 区,Survivor 区分为 S1 和 S2 区,Eden 区和 S1、S2 区的大小比通常为 8:1:1,年轻代内存的代销和 Eden 区和 Survivor 区的大小比例都可以通过 JVM 参数来进行设置 - 老年代
老年代主要存放系统认为生命周期比较长的对象,区域大小相对会比较大,垃圾回收也相对没有那么频繁 - 永久代
持久代主要存放类定义、字节码和常量等很少会变更的信息
3.2 内存分配过程
当需要为一个对象分配内存时,过程大概是
- 一般对象往 Eden 区分配,查看 Eden 区中的空间是否足够容纳对象,如果足够则在 Eden 区进行分配
- Eden 区容纳不下该对象时,触发 MinorGC,结束后重新分配
- 大对象直接往老年代分配,若容纳不下将触发 FullGC,结束后重新分配
3.3 GC 触发时机
以下几种情况会触发 GC:
- 在 Eden 区分配时空间不足,触发 MinorGC
- 大对象分配,老年代空间不足,触发 FullGC
- 主动调用 GC.Collect 时,触发 FullGC
3.4 GC 过程和算法
3.4.1 年轻代基于复制的 GC 算法
执行在年轻代的 GC 也称之为 MinorGC,过程如下
- 遍历 Eden 区和 S0 区,计算每个对象是否存活,存活对象全部复制到 S1 区,然后清空 Eden 和 S0 区
- 此过程中若 S1 区空间不够存放对象,对象直接进入老年区
- Eden 区和 S0 区的对象,每复制一次年龄 + 1,年龄超过某个阈值(默认为15,可以通过 JVM 参数设置),进入老年代
- 清空 Eden 和 S0 区后,S0 区和 S1 区互换,下一次 Minor GC 触发时 S0 区用来接收 Eden 区和 S1 区的存活对象
3.4.2 老年代标记-清除的 GC 算法
执行于老年代的 GC,也成为 Major GC,其中一种算法是标记-清除算法,过程如下
- 第一趟遍历对象列表,标记所有未存活对象
- 第二趟遍历对象列表,清除所有未存活对象
标记-清除算法存在问题:
清除的对象很可能不在连续空间,容易产生内存碎片,随着时间推移,连续的内存区域越来越少,一次稍微大一点的分配就可能触发 GC,导致 GC 会越来越频繁
3.4.3 老年代标记-整理的 GC 算法
另外一种算法改进了标记-清除算法的问题,称为标记-整理算法,过程如下
- 第一趟遍历对象,标记所有未存活对象
- 第二趟遍历对象,进行整理,将所有存活对象复制到连续区域:使用 memmove 移动内存空间,同时修改引用该对象的指针值
标记-整理的问题是效率稍微低一些
3.4.3 GC时如何判断对象是否存活
- 可达性算法:如果对象 A 到对象 B 存在引用链路,说明 A 为 B 的可达对象
- 判断对象 A 是否存活:遍历程序的 根对象列表,若 A 为任何根对象的可达对象,则A为存活对象
3.4.4 哪些对象是根对象
可以作为根对象来进行可达性判定的对象包括:
- 栈中的局部变量
- 类静态变量
- 全局变量和常量
3.4.5 Minor GC,Major GC 和 FullGC
Minor GC 是发生在年轻代中的 GC,触发较为频繁,Major GC 是发生在老年代中的 GC,相对不频繁,触发 FullGC 时会先执行 Minor GC,然后执行 Major GC
4. Unity 中的 GC
4.1 Unity 中GC的特性
- Stop the World
Unity 不支持多线程 GC,要停止所有线程,GC才能继续执行。即便 Unity 2019 引入了增量式GC,将 GC 操作分散到不同帧当中,仍然是需要停止所有线程的 - 不分代
Unity 中的托管堆内存未分代,只要触发 GC,就是 FullGC - 不整理
Unity 中 GC 算法是基于标记-清除算法,不会和并对象空间,容易造成内存碎片,且 GC 频率会越来越高
4.2 Unity 中关于 GC 优化的建议
4.2.1 减少对象的大小
合理安排类或结构体的字段声明顺序,以优化其对象的内存布局进而减少对象大小,结构体可以使用 StructLayout 属性
关于结构体,结构体本身是值类型。若结构体中不包含引用类型时,针对结构体的 new 操作不会造成 GCAlloc,但若结构体中包含引用类型字段,如 string 或数组等,那么在对结构体执行 new 操作时会产生 GC Alloc
4.2.2 降低内存分配的频次
也就是尽量减少 GCAlloc
- 减少引用类临时对象的分配,传递结构类型的参数时,如果对象尺寸超过 IntPtr.Size 时,采用引用传递方式,参数加关键字
ref
,类类型的对象本身已经是引用传值了,不会生成临时对象 - 使用泛型优化装箱,例如
void Func(object o)
方法在传值类型参数时会进行装箱,使用 void Func<T>(T o) 则不会产生装箱,但泛型在 IL2cpp 时会生成多中类型对应的代码 - 可变参数的方法,先定义常用参数个数的方法,再定义可变参数方法,确保绝大多数调用是固定个数的方法。如
string.Format
方法是将1个、2个和3个参数的方法单独提出,另外再实现一个可变参数的方法 - 缓存某些 Get 类方法或属性的结果,例如不要使用
GameObject.name
和GameObject.tag
,但GameObject.CompareTag()
方法不会产生 GCAlloc - 尽量减少装箱和拆箱
- 不要在 Update 等频次较高的方法中分配堆内存
- 使用对象池
- 事先申请足量的容器尺寸,避免申请尺寸不足时添加元素造成的复制和重新申请操作
- 字符串本身是引用类型,字符串连接时会申请新的空间,所以尽量避免字符串拼接
- 协程
yield return 0
应该使用yield return null
来替代,避免装箱 - 协程
yield return new WaitForSeconds
应该先将new WaitForSeconds
缓存下来
5. 查看 Android 应用的内存情况
4.2.3 在适当的时机主动调用 GC.Collect
例如在场景切换显示加载界面时调用,用户无感知
4.2.4 关于调试日志的字符串参数
正式版本中使用 unityLogger.logEnabled = false
仅仅只是不打日志,但字符串已经被分配内存,GC Alloc 还是产生了。解决方法是使用 Conditional 特性来处理日志输出,在正式版本中不要生成打印相关的代码,也不会有字符串的生成,减少了 GCAlloc
网友评论