Technology GC - chaolunner/CloudNotes GitHub Wiki

Garbage Collector(垃圾收集器)

GC有多种算法。比较常见的算法有Reference Counting,Mark Sweep,Copy Collection等等。目前主流的虚拟系统.net CLR,Java VM(Virtual Machine)都是采用的Mark Sweep算法。

Mark Sweep算法

  • Mark-Compact 标记压缩算法

    • Mark 标记清除阶段

      先假设堆中所有对象都可以被回收,然后找出不能被回收的对象,给这些对象打上标记,最后堆中没有打标记的对象都可以被回收。

    • Compact 压缩阶段

      对象回收之后的堆内存空间变的不连续,在堆中移动这些对象,使他们重新从堆基地址开始连续排列,类似于磁盘空间的碎片整理。

    • 指针修复阶段

      指针修复是因为Compact过程移动了堆对象,对象地址发生了变化,需要修复所有引用指针,包括堆栈(stack),寄存器(CPU register)中的指针以及堆(heap)中其他对象的引用指针。

      数据从内存里拿出来先放到寄存器,然后CPU再从寄存器里读取数据来进行处理,处理完后同样把数据通过寄存器存放到内存里,CPU不直接和内存打交道。

      一个CPU也可以有很多寄存器,不同型号的CPU拥有的寄存器的数量也不一样。寄存器其实就是一块一块小的存储空间,只不过其存储速度要比内存快得多。

  • Generational 分代算法

    分代算法的假设前提条件:

    • 大量新创建的对象生命周期都比较短,而较老的对象生命周期会更长。
    • 对部分内存进行回收比基于全部内存的回收操作要快。
    • 新创建的对象之间关联程度通常较强。堆分配的对象是连续的,关联度较强有利于提高CPU cache的命中率。

    堆分为3个代龄区域,相应的GC有3种方式:# Gen 0 collections,# Gen 1 collections,# Gen 2 collections。如果Gen 0 堆内存达到阀值,则触发0代GC,0代GC后Gen 0 堆内存中幸存的对象进入Gen 1 堆内存。如果Gen 1 堆内存达到阀值,则进行1代GC,1代GC将Gen 0 堆内存和Gen 1 堆内存一起进行回收,幸存的对象进入Gen2 堆内存。2代GC将Gen 0 堆内存、Gen 1 堆内存和Gen 2 堆内存一起回收。

    Gen 0和Gen 1比较小,这两个代龄加起来总是保持在16M左右,Gen2的大小由应用程序确定,可能达到几个G,因此0代和1代GC的成本非常低,2代GC称为fullGC,通常成本很高。粗略的计算0代和1代GC应当能在几毫秒到几十毫秒之间完成,Gen 2比较大时fullGC可能需要花费几秒时间。大致上来讲.NET应用运行期间2代、1代和0代GC的频率应当大致为1:10:100

  • Finalization Queue和Freachable Queue

    这两个队列和.net对象所提供的Finalize方法有关。这两个队列并不用于存储真正的对象,而是存储一组指向对象的指针。

    当程序中使用了new操作符在托管堆(Managed Heap)上分配空间时,GC会对其进行分析,如果该对象含有Finalize方法,即析构函数则在Finalization Queue中添加一个指向该对象的指针。

    在GC被启动以后,经过Mark 标记清除阶段分辨出哪些是垃圾,再在垃圾中搜索,如果发现垃圾中有被Finalization Queue中的指针所指向的对象,则将这个对象从垃圾中分离出来,并将指向它的指针移动到Freachable Queue中。

    这个过程被称为对象的复生(Resurrection),本来死去的对象就这样被救活了。为什么要救活它呢?因为这个对象的Finalize方法还没有被执行,所以不能让它死去。

    Freachable Queue平时不做什么事,但是一旦里面被添加了指针之后,它就会去触发所指对象的Finalize方法执行,之后将这个指针从队列中剔除,这时对象就可以安静的死去了。

    .net Framework的System.GC类提供了控制Finalize的两个方法,ReRegisterForFinalizeSuppressFinalize。前者是请求系统完成对象的Finalize方法,后者是请求系统不要完成对象的Finalize方法。ReRegisterForFinalize方法其实就是将指向对象的指针重新添加到Finalization Queue中。

    这就出现了一个很有趣的现象,因为在Finalization Queue中的对象可以复生,如果在对象的Finalize方法中调用ReRegisterForFinalize方法,这样就形成了一个在堆上永远不会死去的对象,像凤凰涅槃一样每次死的时候都可以复生。

.NET的GC机制有这样两个问题

  • GC并不是能释放所有的资源。它不能自动释放非托管资源

  • GC并不是实时性的,这将会造成系统性能上的瓶颈和不确定性。

GC并不是实时性的,这会造成系统性能上的瓶颈和不确定性。所以有了IDisposable接口,IDisposable接口定义了Dispose方法,这个方法用来供程序员显式调用以释放非托管资源。使用using 语句可以简化资源管理。

当你用Dispose方法释放未托管对象的时候,应该调用GC.SuppressFinalize。如果对象正在终结队列(finalization queue),GC.SuppressFinalize会阻止GC调用Finalize方法。因为Finalize方法的调用会牺牲部分性能。如果你的Dispose方法已经对委托管资源作了清理,就没必要让GC再调用对象的Finalize方法。

Unity中的内存种类

  • 程序代码

    程序代码包括了所有的Unity引擎,使用的库,以及你所写的所有代码。在编译后,得到的运行文件将会被加载到设备中执行,并占用一定内存。这部分内存实际上是没有办法去“管理”的,它们将在内存中从一开始到最后一直存在。一个空的Unity场景,什么代码都不放,在iOS设备上占用内存应该在17MB左右,而加上一些自己的代码很容易就飙到20MB左右。想要减少这部分内存的使用,能做的就是减少使用的库。

    当使用Unity开发时,默认的Mono包含库可以说大部分用不上,在Player Setting(Edit->Project Setting->Player)面板里,将最下方的Optimization栏目中Api Compatibility Level选为**.NET 2.0 Subset**,表示你只会使用到部分的**.NET 2.0 Subset**,不需要Unity将全部.NET的Api包含进去。接下来的Stripping Level表示从build的库中剥离的力度,每一个剥离选项都将从打包好的库中去掉一部分内容。你需要保证你的代码没有用到这部分被剥离的功能,选为Use micro mscorlib的话将使用最小的库(一般来说也没啥问题,不行的话可以试试之前的两个)。库剥离可以极大地降低打包后的程序的尺寸以及程序代码的内存占用,唯一的缺点是这个功能只支持Pro版的Unity。

  • 托管堆(Managed Heap)

    托管堆是被Mono使用的一部分内存。Mono项目一个开源的.Net框架的一种实现,对于Unity开发,其实充当了基本类库的角色。托管堆用来存放类的实例(比如用new生成的列表,实例中的各种声明的变量等)。“托管”的意思是Mono“应该”自动地改变堆的大小来适应你所需要的内存,并且定时地使用垃圾回收(Garbage Collect)来释放已经不需要的内存。关键在于,有时候你会忘记清除对已经不需要再使用的内存的引用,从而导致Mono认为这块内存一直有用,而无法回收。

  • 本机堆(Native Heap)

    本机堆是Unity引擎进行申请和操作的地方,比如贴图,音效,关卡数据等。Unity使用了自己的一套内存管理机制来使这块内存具有和托管堆类似的功能。基本理念是,如果在这个关卡里需要某个资源,那么在需要时就加载,之后在没有任何引用时进行卸载。听起来很美好也和托管堆一样,但是由于Unity有一套自动加载和卸载资源的机制,让两者变得差别很大。自动加载资源可以为开发者省不少事儿,但是同时也意味着开发者失去了手动管理所有加载资源的权力,这非常容易导致大量的内存占用(贴图什么的你懂的),也是Unity给人留下“吃内存”印象的罪魁祸首。

  • GC机制考量
    • Throughput(回收能力)
    • Pause times(暂停时长)
    • Fragmentation(碎片化)
    • Mutator overhead(额外消耗)
    • Soalability(可扩展性)
    • Portability(可移植性)
  • Boehm Unity当前所使用的。
    • Non-generational(非分代式)
    • Non-compacting(非压缩式)
  • 下一代GC
    • Incremental GC(渐进式GC) 已经实装,解决GC导致主线程卡顿问题。 在Player Setting(Edit->Project Setting->Player->OtherSettings) 启用 Use incremental GC
    • SGen 或者 升级Boehm?
    • IL2CPP
  • Memory fragmentation(内存碎片化)
  • Zombie Memory(僵尸内存)
    • 无用内存
    • 没有释放
    • 通过代码管理和性能分析工具

一些会造成 GC Alloc 的点

  • 序列化与反序列化(Json/XML/PB/...)
  • GetComponents、Input.Touchs、Mesh.vertices 因为返回的是内存的拷贝,或者缓存了一些内存的返回值
  • Debug.Log、Warning、Error
  • Lambda表达式、匿名函数、Linq 因为会生成一个非常长的语法树,带来的性能开销比较大
  • Camera.main 其实Unity内部实现是GameObject.FindObjectWithTag("MainCamera")
  • Physics.RaycastAll 可以考虑使用Physics.RaycastNonAlloc代替

名词解释

  • 托管资源

    Net中的所有类型都是(直接或间接)从System.Object类型派生的。

    CTS(公共类型系统)中的类型被分成两大类:引用类型(reference type,又叫托管类型managed type),分配在堆上 和 值类型(value type),分配在堆栈上。

  • 非托管资源

    ApplicationContext,Brush,Component,ComponentDesigner,Container,Context,Cursor,FileStream,Font,Icon,Image,Matrix,Object,OdbcDataReader,OleDBDataReader,Pen,Regex,Socket,StreamWriter,Timer,Tooltip ,文件句柄,GDI资源,数据库连接等等资源.

  • 公共类型系统

    一种确定公共语言运行库如何定义、使用和管理类型的规范。

    CLR通过CTS,实现严格的类型和代码验证,来增强代码鲁棒性。

    CTS 确保所有托管代码是自我描述的。

  • 公共语言运行库(Common Language Runtime)

    公共语言运行库 (common language runtime,CLR) 是托管代码执行核心中的引擎。

    运行库为托管代码提供各种服务,如跨语言集成、代码访问安全性、对象生存期管理、调试和分析支持。

    它是整个.NET框架的核心,它为.NET应用程序提供了一个托管的代码执行环境。

    它实际上是驻留在内存里的一段代理代码,负责应用程序在整个执行期间的代码管理工作。

⚠️ **GitHub.com Fallback** ⚠️