Unity WebGL内存详解:Unity堆
Unity WebGL内存详解一文对比了WebGL及其它平台内存的工作方式。我们给出的建议是Unity堆应越小越好,同时也强调了一个事实,即浏览器中还存在其他类型的内存开销。本文将深入探讨Unity堆,并根据实际数据来减少Unity堆的大小,而不再是通过不停地调试和试错来达到这一目的。下面就来看看Unity堆的定义、原理以及如何进行Unity堆内存分析。
Unity堆是什么
首先要明白,Unity堆和浏览器堆是不同的概念。Unity堆实际上只是浏览器堆中的一块内存。这方面的讲解可在之前的WebGL内存详解中查看。大体上说,所谓堆就是一块用于动态分配的内存,允许应用程序使用malloc/free或new/delete对内存进行操作。Unity有自己的内存分配系统,以便提高内存的利用率,同时更加方便地进行分析与调试。但在底层仍然使用malloc/free。
在Unity WebGL中,将含有所有运行时Unity引擎对象的这块内存称为Unity堆。Unity堆中的内存分配是通过dlmalloc完成的。在主机平台上,这个堆的大小由硬件规格和操作系统保留内存的大小所决定,因此应用程序应保证其申请的内存不会超过运行时可用内存。在WebGL平台上同理,我们需要预先定义Unity堆的大小(即在应用程序构造时)。这就是说一旦初始化,Unity堆的大小就不会再发生变化。
Unity堆中有什么
在Unity WebGL平台中,将Unity堆分配类型分类如下:
静态内存区域
栈
动态内存
未分配内存
被分配的第一块区域用于栈和存放所有静态对象。栈的大小通常是5MB,而静态区域的大小取决于编译的代码,即通常受Unity和工程版本所影响。上面这些区域分配好之后,剩余的所有内存即可供运行时动态内存分配使用。
当代码开始执行后,动态区域就会占据越来越多的Unity堆空间。如果这片区域占据的空间过多,最终就会导致没有内存可供Unity使用。
随着时间的推移,即便会有一些对象被释放或者其他对象的内存再分配,动态内存区的大小是不会减少的,因为没有对应的压缩机制。而且这类操作会使动态内存区产生碎片。
所以要知道,内存中是会有碎片产生的。
那托管内存哪去了?动态区域中有一个或多个运行时托管堆,程序创建的所有对象都在其中。因此,托管堆是Unity堆的一部分,而Unity堆又是浏览器JS VM堆的一部分。听起来可能有些复杂,如果看过《盗梦空间》或《黑客帝国》,那这里就好理解多了,一个堆在另一个堆中,另一个堆又在另外一个堆中,以此类推……
托管内存
所有脚本对象都存放于此。之所以叫它“托管”是因为每当一个对象不再被引用时,垃圾回收器(Boehm)就会自动回收这部分内存。首先需要了解的一个重点是:托管内存是从Unity堆中分配的 (或从其他平台上的操作系统分配)。其次,这部分内存不会再归还给操作系统,因此托管堆的大小只增不减。实际上,当一个对象被回收后,它原本占用的内存仍旧被保存在托管堆中以供将来使用。
就Unity WebGL而言,当我们说“内存不会被归还给操作系统”时,实际上说的是这部分内存不会再归还给Unity堆中的可用内存块池。还有一点需要强调的是,与Unity堆不同(Unity堆是单个一整块内存),Boehm垃圾回收器有分配多重缓存的能力。另外,每一块缓存都可以按需被分割为更小的块。不过当创建新的脚本对象时,需要一块足够容纳这个对象的毗邻内存空间,如果Boehm垃圾回收器托管的可用块不足以满足需求,则会创建一个新的内存块(从Unity堆中划取)。
更多关于托管内存的信息,请查阅Unity手册。
1
托管内存用尽后会如何?
如果Boehm垃圾回收器没能找到用于创建新对象的空闲内存,则从Unity堆请求分配失败,Unity WebGL将停止执行,同时抛出内存不足的错误并建议增加WebGLMemorySize的大小。
2
System.GC.Collect无法用于WebGL吗?
Unity WebGL平台上调用GC.Collect()是没有效果的。因为调用栈在不为空的时候是无法进行垃圾回收操作的。更多有关该限制的内容可查阅Unity手册。
这时,Unity WebGL会在每帧开始时尝试进行一些垃圾回收操作。之后在载入新场景时,系统会进行一次完全垃圾回收操作。
3
System.GC.GetTotalMemory具体做什么?
在Unity WebGL平台上,该函数的作用与在其他平台上比是相同的,同时也提供了垃圾回收异常机制:System.GC.GetTotalMemory() 返回当前使用的所有托管内存,正如Profiler.GetMonoUsedSize()一样。如需了解托管堆的总大小(已使用+空闲),可以使用Profiler.GetMonoHeapSize()。
4
如何在托管堆中保留一定数量的内存?
如果曾用过C++ std容器(例如string,vector等等),应该已经了解在向容器中追加或插入新元素时,它们的大小会发生变化。在需要将使用内存控制在一定范围内的游戏和一些其他应用程序中,这可能是个问题,不过可以使用预留内存方法(例如:std::string::reserve, std::vector::reserve)来解决这一问题。
与C++ std容器不同,Unity中没有为托管堆提供类似的内存保留API。不过此前也曾提到,可以另辟蹊径来达成这一点。假设已经预先知道程序内容的托管堆占用大小,就可以预先创建一个大小相同的数组,然后手动运行垃圾回收器。这样就“隐式”地为托管堆保留了这一块内存,从而托管堆也不再需要扩容了。
听着是个相当不错的思路,但正如我们之前提到的,调用GC.Collect()函数不会有任何效果,且完全垃圾回收机制仅在场景载入时被激活。当然,这个问题还是比较容易解决的,可以设置一个预加载场景,其中仅有一个游戏对象,将分配数组的脚本附加在对象上。
然后,将这个场景设置为工程的第一个场景。现在万事俱备只欠东风,我们需要知道预分配托管堆的大小:可供内容从头至尾运行,然后使用Profiler.GetMonoHeapSize()函数获取保留内存的总大小。最后记住一点,使用该方法的代价是,程序的托管内存永远都是最大值。
设置Unity堆大小
解释过Unity堆与内存管理脚本之后,回到最开始的问题:选择Unity堆大小的最佳策略是什么?
基本思路是要知道运行内容所需的最大内存占用量,然后将WebGLMemorySize设置为一个稍大的值(下一个16的倍数)。具体做法是完整测试WebGL程序的所有内容,记录所占内存的峰值大小。然后将最终大小再稍加扩大以防万一,将其调整为16MB的倍数。
好在Profiler API提供了获取总内存大小(Total Reserved Memory)的函数。Profiler.GetTotalReservedMemory(),对应Unity Profiler窗口中的ReservedTotal:
但是这种方法存在两个问题。第一个问题和内存峰值有关:如果临时内存的分配和释放发生在同一帧时,这部分开销就不会被计算在ReservedTotal中。第二个问题是Unity Profiler不会记录所有的内存分配。
未跟踪的内存分配
从Profiler中获取的信息可以发挥巨大的作用,然而还需要考虑到一些事情:显而易见,Profiler会告诉您它知道的一切,但它无法告诉您它不知道的东西!
Unity之所以能够追踪内存的使用情况,是因为内存的分配都是通过MemoryManager::Allocate()完成的,而该函数会存储有关内存分配名称和大小的额外信息。不过出于某些原因,还有一些其他的内存分配操作不会被追踪,因此如果想要确切地知道Unity堆里占用了多少内存,这一点就会成为问题。
这通常是因为某些内部子系统和第三方库在操作内存时使用的是malloc/free函数,而不是Unity自身的MemoryManager。除此之外,用户的插件也有可能产生这样的情况,例如C和C++代码中的mallc()函数,或JavaScript中的_malloc()函数(例如JS样例插件中的StringReturnValueFunction函数),或者是文件的写入操作(这也会导致内存的写入)。
为了能给出清晰的概念来表明未能追踪的内存究竟有多大,就拿Unity 5.5中的一个简易工程来说,这个数字大概是7MB。好消息是,我们正在着手解决这个问题,将来这个数字只会减少。
如何知道内存的准确用量
实际上还是有方法的。我们再次回顾最开始的图片,很容易就能发现总内存占用(Total Reserved Memory) = 静态内存 + 栈 + 动态内存。
幸运的是,我们能够实时地获取到这些内存区域的大小,使用emscripten生成的变量和常量即可。之后仅需存入jslib文件并存储在工程里,然后创建对应的C#代码即可。
请点击【阅读原文】查看代码。
使用这种方式还有一个好处,与Profiler API不同,这个插件可以在发布版本中使用。需要注意的是,上述的jslib代码依赖于emscripten生成的JS代码,因此在将来的Unity版本中,这个插件可能需要更新。不过既然已经发现了这个问题,我们可能会为其添加Unity WebGL专用API来避免这样的问题。
如何分析Unity堆的数据
首先可以使用Unity Memory Profiler内存分析器,该分析器可以提供内存数据的总览,以及所有内存分配类型的详细信息。如果需要排查内存泄露,可以参考CPU分析器中的GC Alloc一栏。这一栏可以清楚表明在某一帧分配了多少内存。
顺便一提,如果在使用分析器的过程中遇到问题,有可能是5.3中的bug。我们已经在5.3.6 Patch 8中修复了这个问题。如果想获得更底层的数据,可以尝试新的内存分析器(Unity 5.3中提供):
请点击【阅读原文】了解更多信息。
Memory Profiler是个非常实用的工具,但还是要注意一点:它只适用于il2cpp(当然这不是问题,因为我们只在Unity WebGL上使用),并且它还只是一个预览版,因此可能在使用时会产生各种各样的问题。
总结
这篇文章是否解决了您在内存方面的疑问?这是一个非常宽泛却又非常重要的课题,我们将来还会围绕这方面分享更多文章。重要的是,现在已经有了查看Unity WebGL内容运行所需内存大小的工具。如果想了解更多关于分析和优化的内容,请查阅Unite Europe 2016的主题演讲《优化移动应用程序》上发布的性能优化指南。
如果您对这里的内容有任何疑问,请点击【阅读原文】进入Unity官方中文社区留言。