本文分享Unity中的资源管理-对象池技术(2)
在上一篇文章中, 我们一起学习了普通类的对象池, 这种实现有一定的特点和适用范围:
- 只能存放相同类型的对象
- 所有对象都一视同仁, 在产生和回收后都需要额外的操作, 比如产生后需要初始化, 回收后需要重置信息
今天要介绍一种更加通用和灵活的实现方式: 这种对象池只负责存取对象, 而这些对象不拘泥类型且不需要额外的操作就能使用和回收.
简单说就是上一个对象池实现特点的反面.
对象池? 容器?
可能有些同学会有疑问, 如果只是需要一个存取对象的容器, 那么我用ArrayList
, List
, 或者Dictionary
不就行了么, 为什么还需要单独做一个对象池呢?
其实这些容器都可以看做是这种对象池的某种实现, 至于可以存放各种类型对象, 我们只需要让这些容器存放的是object
类型即可. 也就是说你可以把这些容器都称为对象池. 对象池的观点其实很宽泛, 只要能存放大量对象, 并提供对应的产生和回收接口即可.
今天要介绍的实现方式就是这样一种容器, 能够存放各种类型的对象, 而且可以及其快速的存取.
大家肯定第一个就能想到, 支持及其快速存取的就是数组, 存取都是O(1)
. 没错, 这种对象池就是基于数组, 是在一定程度上优化了数组的缺点的实现.
数组的缺点及其优化方式有:
- 容量大小固定
- 通过动态扩容和缩容解决
List
就是在数组的基础上进行这种优化的容器
- 只能存放同一个类型的对象
- 通过存放
object
类型的对象解决 ArrayList
就是在数组的基础上进行这种优化的容器- 但是
ArrayList
在删除元素时会移动删除位置之后的所有的元素
- 通过存放
- 只能通过整型索引对象
- 通过将其它类型的索引转换为整型解决
Dictionary
就是通过将其它类型的索引通过计算hash值转换为整型索引的容器
总之, 数组是一个非常基础的容器, 但是有各种各样的缺陷, 但是我们可以通过解决各种缺陷来产生新的容器, 这些容器继承了数组的快速存取而克服了部分缺陷. 我们日常中使用的大部分容器在底层都是通过数组实现的, 比如ArrayList, Stack, Queue, List, Dictionary, HashMap
等等.
今天要介绍的对象池和ArrayList
的实现有些类似, 但是克服了其需要在删除元素时移动元素的缺点. 其支持的特性如下:
- 支持极其快速存取对象, 且不用移动对象
- 支持存放任意类型的对象
- 支持动态扩容缩容
- 使用整型索引对象
这就是Xlua
中对象池的实现, Xlua
使用这种对象池管理所有lua
引用的C#
对象.
实现原理
首先其底层容器是一个数组, 因为要记录位置信息, 所以需要的是一个二级结构, 这个结构存放了下一个可用位置的信息, 并且持有了真正需要存取的对象.
const int LIST_END = -1;
const int ALLOCED = -2;
// 位置插槽, 用于描述位置信息, 用类也可以
struct Slot
{
// 如果是正值代表下一个可用位置, 如果为-2(ALLOCED)代表当前位置已被占用, -1代表这个位置是最后一个可用位置
public int next;
// 持有对象
public object obj;
}
// 底层容器
private Slot[] list = new Slot[512];
然后, 我们需要一个字段来记录当前使用的插槽数量: private int count = 0
.
再然后, 我们需要一个链表将所有可用位置串连, 当然, 我们不是使用真正的链表, 而是使用Slot
的next
字段结合一个表头索引来串连: private int freelist = LIST_END
. 一旦这个索引指向了一个确切的位置, 那么代表我们拥有了至少一个可用位置, 至于有没有更多可用的位置, 那么需要查看该位置的next
字段是否为正值. 这里纯粹使用语言来描述比较让人迷惑, 大家只需要有一个简单的印象就行了. 总之, 表头索引指向可用位置, 通过可用位置的next
串连下一个可用位置.
最后, 我们来描述详细的算法:
- 初始状态下,
freelist = LIST_END
, 代表现在没有可用位置. - 当添加一个对象时, 发现没有可用位置, 那么向对象池申请一个新的位置:
index = count++
, 也就是说可用位置为0, 并且将count++
- 使用该位置存储对象, 并将
next
字段置为已被占用的状态:list[index].obj = obj; list[index].next = ALLOCED
, 并返回索引. - 此时池中有一个对象(位置
index = 0; count = 1
), 可用位置依然为0(freelist = LIST_END
) - 当删除一个对象时, 通过对象的索引查找到位置并情况位置持有的对象:
list[index].obj = null
- 将当前位置的置为可用状态, 且按照头插法插入可用链表:
list[index].next = freelist; freelist = index;
- 最后返回已经删除的对象
- 此时池中没有对象, 有一个可用位置(位置
index = 0; count = 1; freelist = 0; list[0].next = -1
) - 再次添加一个对象, 发现有可用位置(
freelist = 0
), 直接使用该位置, 并在头部将位置从可用位置链表中移除:list[0].obj = obj; freelist = list[0].next;
, 最后将位置的状态设置为已占用:list[0].next = ALLOCED;
- 最后返回索引
以上就是整个算法的核心实现了. 这个算法十分巧妙, 但是也让人特别迷惑, 建议对链表不熟悉的同学先温习一下链表的基础知识, 看的头晕就不要吐槽作者了哟.
下面贴出添加和删除还有扩容部分的完整代码, 这里作者做了一些修改以便更容易理解.
// 申请一个新的大小为原始数组两倍大小的数组作为底层容器, 并被将原始数组的内容复制到新的数组
void extend_capacity()
{
Slot[] new_list = new Slot[list.Length * 2];
for (int i = 0; i < list.Length; i++)
{
new_list[i] = list[i];
}
list = new_list;
}
public int Add(object obj)
{
// 没有可用位置, 向对象池申请
if (freelist == LIST_END) {
if (count == list.Length)
{
// 对象池中已经没有更多位置, 需要扩容
extend_capacity();
}
// 申请一个新的位置
var newSlotIndex = count++;
ref var slot = ref list[newSlotIndex]; // 因为struct是值类型, 需要引用
// 使用该位置并置占用状态
slot.obj = obj;
slot.next = ALLOCED;
return newSlotIndex;
}
// 从头部取出第一个可用位置, 将该位置从可用链表中移除
var freeSlotIndex = freelist;
ref var freeSlot = ref list[freeSlotIndex];
freelist = freeSlot.next;
// 使用该位置并置占用状态
freeSlot.obj = obj;
freeSlot.next = ALLOCED;
return freeSlotIndex;
}
public object Remove(int index)
{
if (index < 0 || index >= count || list[index].next != ALLOCED)
return null;
// 找到位置, 取出对象并清空
ref var slot = ref list[index];
object o = slot.obj;
slot.obj = null;
// 使用头插法将位置插入可用链表
slot.next = freelist;
freelist = index;
return o;
}
注释已经写的比较清晰明了了, 相信只要熟悉链表知识的同学基本毫无压力.
这里总结一下, count
代表已经申请的位置数量, 但是不代表这些位置都被占用了, 只有freelist
指向表尾时才代表无可用位置, 需要再次申请. 如果需要知道对象池中已经存在的元素个数, 可以通过count - Length(freelist)
, 当然, 也可以添加字段来记录数量就行了.
最后再次感叹下这个设计的巧妙, 总之是学到了, 作者君又变强了(当然头发依然茂密)!
好啦, 今天的内容就是这样, 希望对大家有所帮助.