2.8.3 尽可能地使用对象池
说到类实例,我们应该明白,内存分配和内存消耗会对我们的程序产生影响,这也是提高程序效率的关键所在,我们不但要减少内存分配次数和内存碎片,还要避免内存卸载带来的性能损耗。Unity3D使用的是C#语言,因此它使用垃圾回收机制回收内存,即使Unity3D在发布后将C#转换为C++,也依然会使用垃圾回收机制来执行分配和销毁内存。作为高级程序员,我们应该能感受到,在创建类实例时内存分配时的性能损耗以及垃圾回收时的艰难。
垃圾回收有多难呢?下面进行解释。我们在C#中可随意地新建类实例,由于不用管它们的死活,所以可丢弃或空置引用变量。类实例不断地被引用和间接引用,又不断地被抛弃,垃圾回收器就要负责仔仔细细地收拾我们的烂摊子。内存不可能永远被分配而不回收,于是垃圾回收只能在内存不够用的时候到处询问和检查(即遍历所有已分配的内存块),看看哪个类实例完全被遗弃就捡回来(意思是完全没有人引用了),并将内存回收。因此,当业务逻辑越大、数据量越多时,垃圾回收需要检查的内容也越多,如果回收后依然内存不足,就得向系统请求分配更多内存。
垃圾回收过程如此艰难,它每次回收时都会占用大量CPU算力,因此,我们应该尽可能地使用对象池来重复利用已经创建的对象,这有助于减少内存分配时的消耗,也减少了堆内存的内存块数量,最终减少了垃圾回收时带来的CPU损耗。
除了通过new操作创建某个类内存导致GC单元耗时增加外,以我的经验来看,很容易被忽略的还有new List这种类型的使用,我们在平时编程时会大量使用动态数组,并且随时将它抛弃。类似的Dictionary<int,List>也是众多被忽略的内存分配消耗之一,被装进Dictionary字典中的List常被随意地丢弃,且我们不会注意它是否能被再次利用。
C#中一个简单的通用对象池就能解决这些问题,但我们常常嫌弃它,觉得麻烦。以我的编程经验来看,图方便、好用往往要付出性能损耗的代价性能高的代码通常都有点反人性,我们应该尽量找到一个平衡点,既有高的代码可读性,又尽量不要被人性所驱使而去做一些图方便的事情,这在任何时候都是很有价值的,对象池源码如下:
internal class ObjectPool<T>where T : new() { private readonly Stack<T>m_Stack = new Stack<T>(); private readonly UnityAction<T>m_ActionOnGet; private readonly UnityAction<T>m_ActionOnRelease; public int countAll { get; private set; } public int countActive { get { return countAll - countInactive; } } public int countInactive { get { return m_Stack.Count; } } public ObjectPool(UnityAction<T>actionOnGet, UnityAction<T>actionOnRelease) { m_ActionOnGet = actionOnGet; m_ActionOnRelease = actionOnRelease; } public T Get() { T element; if (m_Stack.Count == 0) { element = new T(); countAll++; } else { element = m_Stack.Pop(); } if (m_ActionOnGet != null) m_ActionOnGet(element); return element; } public void Release(T element) { if (m_Stack.Count>0 && ReferenceEquals(m_Stack.Peek(), element)) Debug.LogError("Internal error. Trying to destroy object that is already released to pool."); if (m_ActionOnRelease != null) m_ActionOnRelease(element); m_Stack.Push(element); } } internal static class ListPool<T> { // 避免分配对象池 private static readonly ObjectPool<List<T>>s_ListPool = new ObjectPool<List<T>>(null, l =>l.Clear()); public static List<T>Get() { return s_ListPool.Get(); } public static void Release(List<T>toRelease) { s_ListPool.Release(toRelease); } }
这两个对象池的类都是从Unity的UI库中提取出来的,都是非常实用的对象池工具,我们应该尽可能地使用它们。上述对象池使用栈队列将废弃的对象存储起来,并在需要时从栈队列中推出实例交给使用者。对象池并不复杂,麻烦的是使用,程序中所有创建对象实例、销毁对象实例、移除对象实例的部分都需要用对象池去调用。
我们来举几个使用ObjectPool和ListPool对象池的例子,源码如下:
public class A { public int a; public float b; } public void Main() { Dictionary<int,A>dic2 = new Dictionary<int, A>(16); for(int i = 0 ; i<1000 ; i++) { A a = ObjectPool<A>.Get(); // 从对象池中获取对象 a.a = i; a.b = 3.5f; A item = null; if(dic.TryGetValue(a.a, out item)) { ObjectPool<A>.Release(item); // 值会被覆盖,所以覆盖前收回对象 } dic[a.a] = a; int removeKey = Random.RangeInt(0,10); if(dic.TryGetValue(removeKey, out item)) { ObjectPool<A>.Release(item); // 移除时收回对象 dic.Remove(removeKey); } } Dictionary<int,List<A>>dic2 = new Dictionary<int, List<A>>(1000); for(int i = 0 ; i<1000 ; i++) { List<A>arrayA = ListPool<A>.Get(); // 从对象池中分配List内存空间 dic2.Add(i,arrayA); List<A>item = null; int removeKey = Random.RangeInt(0,1000); if(dic.TryGetValue(removeKey, out item)) { ListPool<A>.Release(item); // 移除时收回对象 dic.Remove(removeKey); } } }
上述代码中,A类和List需要创建1000次,每次创建都使用对象池,并在字典Dictionary移除时会将对象送回对象池。这样我们就可以不断利用被回收的对象池,自然也就不用总是创建新的对象了,所有被遗弃的对象都会被存储起来,并不会被垃圾回收程序回收,内存不断被重复利用,减少了内存分配和释放所带来的消耗。
减少内存分配除了使用对象池外,还可以在对象池上使用预加载来优化,在程序运行前让对象池中的对象分配得多一些,这样在我们需要实例对象时就不再需要临时分配内存了。此方法可以扩展到资源内存,类实例对象有对象池,资源也可以有对象池,在核心程序运行前,如果能提前知道后面要加载的内容,那么提前将资源内容加载到内存中可以让内存分配次数减少,甚至完全避免临时的加载和分配,因此很多优化技巧会围绕如何预测后面内容需要的实例对象和资源内容展开,例如,统计每个角色需要的资源和实例对象在下一个场景中的数量并提前加载,或者接近某个出口或入口时就开始预测即将进入的场景的资源内容等。