理解自动内存管理(Automatic Memory Management)

来源:互联网 发布:顺丰淘宝合作价格表 编辑:程序博客网 时间:2024/05/23 14:11

内存管理

当一个对象,字符串或者数组被创建时,和它所需大小相符的存储空间会从一个中央内存池中被分配出来,这个内存池我们一般称之为堆(heap)。

原理可以类比于C中的malloc()函数,只不过我们无需手动指定所需的内存大小。

当上述物体创建后不再被使用时,它所占用的内存会被回收以便重复利用。在早些时候,这个分配和释放堆中内存的功能都是由程序猿控制的,秘诀就是使用适宜的函数调用。而到了如今,很多运行时系统(例如Unity的Mono引擎)已经可以自动地帮你管理内存了。自动内存管理(即Automatic Memory Management)相对而言减少了代码量,还可以极大地避免内存泄露。

运行时系统(Runtime System), 指由编程语言自身实现的核心代码,它们包含了一些底层的处理策略(例如自动内存管理),虽然我们没有直观地看到,但是我们写的每个程序都包含了运行时系统。
内存泄露(Memory Leakage), 指在内存被分配后从未被销毁,在你编写的项目体量越来越大时内存泄露的弊端会更容易出现,自动内存管理简化了内存管理的工作,所以某种程度上避开了这个问题。

值类型和引用类型

当函数被调用时,它的所有参数值都会被复制到一块为该函数专门开辟的保留内存区(该过程被称为参数传递, parameter passing)。仅占用少量字节的数据类型可以被快速简单地完成复制。然而,对于那些对象,字符串和数组来说,它们占用的内存空间通常非常大,采用常规的方法进行拷贝往往效率低下。幸运的是,这种复制操作并不是必要的,大块数据的存储空间往往直接从堆中分配,它们出现的位置都会被一个占用字节数极少的指针所替代。指针存储的值用于记录大块数据的实际存放位置。采用这种方式,在参数传递时大块数据仅仅需要复制一个指向它们实际存放位置的指针即可。只要运行时系统可以成功定位到指针记录地址所在的数据,一个单独的数据副本就足矣应付所有必要的函数调用了。

在参数传递过程中可以直接存储和复制的类型被称为值类型(value types)。这种类型数据的代表有整数,浮点数,布尔值以及Unity中定义的结构体类型(比如Color和Vector3)。在堆中分配,并通过指针存取的数据类型则被统称为引用类型(Reference types)。这种叫法是源于存放在变量中的值仅仅用于“引用”真实的数据。引用类型的例子包括对象,字符串以及数组。

值类型和引用类型分别有一种类型时常被错误地归类。一个是Vector3,它是货真价实的结构体类型,所以尝试修改函数的Vector3参数的xyz值不会影响输入的数据。另一个就是字符串类型,它是“饱受误解”的引用类型,直接在堆中分配内存。

内存分配和垃圾回收

内存管理器可以实时追踪堆中未被使用的内存区域,你也可以认为管理器保存有一张未使用区域的列表。当有新的内存块被请求时(换句话说就是有对象被初始化),管理器会选择堆中未使用区域的内存进行分配并将该部分内存移出未使用区域的列表。随后的请求都会按照相同的套路处理,直到没有足够大的内存块可以被分配位置。实际上此时堆中被分配出去的内存并不是都处于使用状态。堆中一个引用类型的数据只有在仍有引用类型变量指向它时才可能被访问和修改。所以当一个内存块的所有引用都消失时(例如引用变量被赋予新的内存地址或者作为局部变量超出作用范围),这块被占用的内存空间就可以被安全地回收了。

为了确保堆中某块内存不再被使用,内存管理器会搜索当前所有活跃的引用变量并且将他们引用的数据块标记为“存活”状态。在搜索结束后,所有位于“存活”区块之间的内存区域都会被当做未使用区域处理并且可以在后续的内存分配过程中使用。事实上,这种定位和释放未使用内存区域的过程就是大名鼎鼎的垃圾回收(Garbage Collection, 简写为GC)。

性能优化

垃圾回收对于程序猿来说是自动化且不可见的,但这并不表明我们不需要关注它。事实上,垃圾回收的过程需要在后台占用大量的CPU时间。如果使用得当,自动内部分配通常在总体性能上可以媲美甚至打败手动内存分配。然而对于程序猿来说,如何避免由于GC频繁触发而导致的游戏卡顿是一个很重要的课题。

有一些声名狼藉的代码写法被称为”GC的噩梦”,尽管第一眼看上去它们看起来显得很清白。重复的字符串拼接就是一个典型的例子:

//C# script exampleusing UnityEngine;using System.Collections;public class ExampleScript : MonoBehaviour {    void ConcatExample(int[] intArray) {        string line = intArray[0].ToString();        for (i = 1; i < intArray.Length; i++) {            line += ", " + intArray[i].ToString();        }        return line;    }}

这段代码的关键细节就在于字符串组不能直接采用累加的方式直接拼在一起。在每次循环中实际进行的操作其实是:之前的引用变量指向的字符串“当场去世”,一个崭新的字符串会被创建并包含旧字符串以及新的字符串。由于字符串会随着for循环变得越来越长,被消耗的堆内存空间也会越来越多。所以每次该函数被调用时轻轻松松就会消耗掉数以百计字节的空闲内存空间。如果你需要把很多字符串连接在一起,更好的选择是使用Mono库中的System.Text.StringBuilder类。

然而,即使是重复的字符串拼接,在非频繁调用的情况下也不会带来太大的麻烦。一个错误的示范就是在Update函数中使用:

//C# script exampleusing UnityEngine;using System.Collections;public class ExampleScript : MonoBehaviour {    public GUIText scoreBoard;    public int score;    void Update() {        string scoreText = "Score: " + score.ToString();        scoreBoard.text = scoreText;    }}

由于Update()每一帧都会被调用,所以上述写法会在每一帧产生固定大小的垃圾。大多数类似情况都可以通过条件语句判断score是否改变,并仅在改变时修改text来节约内存:

//C# script exampleusing UnityEngine;using System.Collections;public class ExampleScript : MonoBehaviour {    public GUIText scoreBoard;    public string scoreText;    public int score;    public int oldScore;    void Update() {        if (score != oldScore) {            scoreText = "Score: " + score.ToString();            scoreBoard.text = scoreText;            oldScore = score;        }    }}

另一个潜在的问题会在函数返回一个数组类型的值时产生:

//C# script exampleusing UnityEngine;using System.Collections;public class ExampleScript : MonoBehaviour {    float[] RandomList(int numElements) {        var result = new float[numElements];        for (int i = 0; i < numElements; i++) {            result[i] = Random.value;        }        return result;    }}

这种类型的函数在创建带有指定数值的数组时非常高效优雅。然而如果它被频繁地调用,每次都会分配一个崭新的内存区域。由于数组可以变得很大,空闲的堆内存空间就会因此被迅速消耗,从而导致频繁的GC。一种避免该问题的方案是利用数组类型也属于引用类型的事实。由于引用类型可以被作为参数传递,在函数中被修改后即使离开作用范围仍不会立刻回收,所以上述代码经常可以被替换为:

//C# script exampleusing UnityEngine;using System.Collections;public class ExampleScript : MonoBehaviour {    void RandomList(float[] arrayToFill) {        for (int i = 0; i < arrayToFill.Length; i++) {            arrayToFill[i] = Random.value;        }    }}

上述方法只是简单地使用全新的内容替换数组中已有的内容。虽然在使用之前需要进行额外的初始化操作(看起来一点也不清真),但是这个函数被调用时不再会产生新的垃圾了。

请求垃圾回收

像之前提到过的,最好尽可能地避免垃圾回收。但是鉴于GC并不能被完全地消除,一般都有两种主要的策略可以用于最小化GC对游戏过程产生的冲击。

小型堆内存,快速频繁GC

该策略常常适用于拥有较长游戏周期的游戏。在该类游戏中平稳的帧率往往是主要的考量。在这类游戏中小块的内存会被频繁地分配,但仅仅只会使用很短的时间。在iOS上使用该策略时,典型的堆大小大约为200KB。在iPone 3G上GC大概会花费5ms。如果堆大小增加到1MB,GC花费的时间就会增长到7ms。这时以固定的间隔请求GC有时会变得比较有用。尽管这么做通常会让垃圾回收比常规情况更加频繁,但是每次GC都可以处理的很快,对游戏的影响可以降到很低:

if (Time.frameCount % 30 == 0){   System.GC.Collect();}

大型堆内存,缓慢低频GC

该策略适用于内存分配相对频率较低并且可以在暂停时(例如加载页面)进行内存处理的游戏。该情况下堆内存尽可以大会比较好,但要保证堆内存不会过大而导致应用被操作系统干掉。不幸的是,Mono的运行时环境会尽可能避免堆内存扩张。但是你仍可以通过在游戏启动时预分配一些占位空间来强制扩张堆内存:

//C# script exampleusing UnityEngine;using System.Collections;public class ExampleScript : MonoBehaviour {    void Start() {        var tmp = new System.Object[1024];        // make allocations in smaller blocks to avoid them to be treated in a special way, which is designed for large blocks        for (int i = 0; i < 1024; i++)            tmp[i] = new byte[1024];        // release reference        tmp = null;    }}

一个足够大的堆内存在两次游戏暂停的间隙不太可能被填满。而在游戏暂停的过程中你完全可以通过显式调用GC进行内存回收。

System.GC.Collect();

再次提醒,为了达到理想的效果,你应该分析性能统计数据来调整堆内存的大小,而不是想当然的认为可以满足预期。

可重用的对象池(Object Pools)

很多情况下你可以简单地通过减少创建和销毁的对象数量来避免产生垃圾。在游戏里有很多种类的对象会被频繁地使用但仅有一小部分会同时显示。在这种情况下,通常选择复用对象要比直接销毁+创建高效的多。

结语

该文章参考了Unity Manual中的Understanding Automatic Memory Management,并根据个人理解进行了整理,有疑问的欢迎留言探讨。

阅读全文
0 0
原创粉丝点击