[Unity] 托管堆与垃圾回收

有关Unity中的托管堆与垃圾回收。

托管堆与垃圾回收

托管堆:

“托管堆”是一段内存,由项目脚本运行时(Mono或IL2CPP)的内存管理器自动管理。托管代码中创建的所有对象必须在托管堆上分配(注意:严格地说,必须在托管堆上分配所有非空引用类型对象和所有盒装值类型对象)。

在上图中,白框表示分配给托管堆的内存量,其中的彩色框表示存储在托管堆内存空间中的数据值。当需要其他值时,将从托管堆中分配更多空间。

垃圾收集器定期运行(注意:确切的时间与平台有关)。这会扫描堆上的所有对象,标记删除任何不再引用的对象。然后删除未引用的对象,释放内存。

至关重要的是,Unity的垃圾收集 - 使用Boehm GC算法 - 是非代数和非压缩的。“非世代”意味着GC在执行收集传递时必须扫描整个堆,因此其性能因堆扩展而降低。“非压缩”意味着内存中的对象不会被重新定位以便关闭对象之间的间隙。

上图显示了内存碎片的示例。释放对象时,将释放其内存。但是,释放的空间也不会成为“空闲内存”一家独大池的一部分。释放对象两侧的对象可能仍在使用中。因此,释放的空间是存储器的其他部分之间的“间隙”(该间隙由图中的红色圆圈表示)。因此,新释放的空间仅可用于存储与释放的对象相同或更小的数据。

分配对象时,请记住该对象必须始终占用内存中的连续空间块。

这导致了内存碎片的核心问题:虽然堆中可用的总空间量可能很大,但是该空间中的一些或全部可能在分配的对象之间存在小的“间隙”。在这种情况下,即使可能有足够的总空间来容纳某个分配,托管堆也找不到足够大的连续内存块来适应分配。

但是,如果分配了大对象并且没有足够的连续可用空间来容纳对象,则如上所述,Unity内存管理器执行两个操作。

首先,如果还没有这样做,垃圾收集器就会运行。这会尝试释放足够的空间来完成分配请求。

如果在GC运行后,仍然没有足够的连续空间来满足请求的内存量,则堆必须扩展。堆扩展的具体数量取决于平台; 但是,大多数Unity平台的大小都是托管堆的两倍。

堆的关键问题

托管堆扩展的核心问题有两个:

  • Unity在扩展时不会经常释放分配给托管堆的内存页; 它乐观地保留了扩展堆,即使它的大部分是空的。这是为了防止在进一步发生大量分配时需要重新扩展堆。
  • 在大多数平台上,Unity最终将托管堆空部分使用的页面释放回操作系统。发生这种情况的间隔不能保证,不应该依赖。
  • 托管堆使用的地址空间永远不会返回给操作系统。
  • 对于32位程序,如果托管堆多次扩展和收缩,则可能导致地址空间耗尽。如果程序的可用内存地址空间已用尽,操作系统将终止该程序。
  • 对于64位程序,地址空间足够大,对于运行时间不超过人类平均寿命的程序来说,这种情况极不可能发生。

了解自动内存管理:

创建对象,字符串或数组时,存储它所需的内存是从称为的中央池分配的。当项目不再使用时,它曾经占用的内存可以被回收并用于其他内容。在过去,通常由程序员通过适当的函数调用显式地分配和释放这些堆内存块。如今,Unity的Mono引擎等运行时系统会自动为您管理内存。自动内存管理比显式分配/释放需要更少的编码工作,并且大大降低了内存泄漏的可能性(分配内存但从未随后释放的情况)。

分配和垃圾收集

内存管理器跟踪它知道未使用的堆中的区域。当请求新的存储器块时(例如,当实例化对象时),管理器选择一个未使用的区域,从该区域分配块,然后从已知的未使用空间中移除分配的存储器。后续请求以相同的方式处理,直到没有足够大的空闲区域来分配所需的块大小。此时极不可能从堆中分配的所有内存仍在使用中。只有存在可以找到它的引用变量时,才能访问堆上的引用项。如果对内存块的所有引用都消失了(即,引用变量已被重新分配,或者它们是现在超出范围的局部变量),则可以安全地重新分配它占用的内存。

为了确定哪些堆块不再使用,内存管理器搜索所有当前活动的引用变量,并将它们称为“实时”的块标记。在搜索结束时,内存管理器认为实时块之间的任何空格都是空的,并且可以用于后续分配。由于显而易见的原因,定位和释放未使用的内存的过程称为垃圾收集(或简称GC)。

优化

垃圾收集是自动的,对于程序员不可见,但在采集过程中实际需要的背后显著CPU时间的场景。如果使用正确,自动内存管理通常会等于或超过手动分配以获得整体性能。但是,程序员必须避免错误,这些错误会比必要时更频繁地触发收集器并在执行时引入暂停。

Unity中的垃圾回收:

堆分配期间会发生什么?

堆分配比堆栈分配复杂得多。这是因为堆可用于存储长期和短期数据,以及许多不同类型和大小的数据。分配和解除分配并不总是以可预测的顺序发生,并且可能需要非常不同大小的存储器块。

创建堆变量时,将执行以下步骤:

  • 首先,Unity必须检查堆中是否有足够的可用内存。如果堆中有足够的可用内存,则分配变量的内存。
  • 如果堆中没有足够的可用内存,Unity会尝试释放垃圾收集器,以释放未使用的堆内存。这可能是一个缓慢的操作。如果堆中现在有足够的可用内存,则会分配变量的内存。
  • 如果垃圾回收后堆中没有足够的可用内存,Unity会增加堆中的内存量。这可能是一个缓慢的操作。然后分配变量的内存。

堆分配可能很慢,特别是如果垃圾收集器必须运行并且必须扩展堆。

垃圾收集期间会发生什么?

当堆变量超出范围时,用于存储它的内存不会立即释放。只有在垃圾收集器运行时才会释放未使用的堆内存。

每次垃圾收集器运行时,都会发生以下步骤:

  • 垃圾收集器检查堆上的每个对象。
  • 垃圾收集器搜索所有当前对象引用以确定堆上的对象是否仍在范围内。
  • 任何不再在范围内的对象都被标记为删除。
  • 将删除标记的对象,并将分配给它们的内存返回到堆中。

垃圾收集可能是一项昂贵的操作。堆上的对象越多,它必须做的工作越多,代码中的对象引用越多,它必须做的工作就越多。

垃圾收集什么时候发生?

有三件事可能导致垃圾收集器运行:

  • 无论何时请求堆分配都无法使用堆中的可用内存来执行垃圾收集器。
  • 垃圾收集器会不时自动运行(尽管频率因平台而异)。
  • 垃圾收集器可以强制手动运行。

垃圾收集可能是一个频繁的操作。每当无法从可用堆内存中实现堆分配时,就会触发垃圾收集器,这意味着频繁的堆分配和解除分配会导致频繁的垃圾回收。


参考:

  1. Understanding the managed heap(理解托管堆)
  2. Understanding Automatic Memory Management(理解自动内存管理)
  3. Optimizing garbage collection in Unity games(在Unity游戏中优化垃圾回收)
  4. Boehm garbage collector