Netty轻量级缓存池实现解读
简述
Netty的缓存池是一个针对对象复用的轻量级缓存池。代码相对简单,属于JVM内对象缓存的范畴。整个对象池代码量约550左右,短小精悍,使用在Netty这种场景下也较为合适。
概要设计
思路推导
对象池简单说就是将对象实例缓存起来供后续分配使用来避免瞬时大量小对象反复生成和消亡造成分配和GC压力。在设计时可以简单的做如下推导:
首先考虑单线程的情况,此时简单的构建一个List容器,对象回收时放入list,分配时从list取出即可。这种方式非常容易就构建了一个单线程的对象池实现。
接着考虑多线程的情况,分配时与单线程情况相同,从当前线程的List容器中取出一个对象分配即可。回收时却遇到了麻烦,可能对象回收时的线程和分配时线程一致,那情况与单线程一致。如果不一致,此时存在不同的策略选择。
+ 策略一: 将对象回收至分配线程的List容器中
+ 策略二: 将对象回收至本线程的list容器,当成本线程的对象使用
+ 策略三: 将对象暂存于本线程,在后续合适时机归还至分配线程。
每一种策略均各有特点,
策略一,需要采用MPSC队列(或类似结构)来作为存储容器,因为会有多个线程并发回收对象的情况。如果采用MPSC队列,伴随着不停的分配和回收,MPSC队列内部的节点也是不停的生成和消亡,变相降低了内存池的效果。
策略二,如果线程呈现明显的分配和回收分工则会导致缓存池失去作用。因为分配线程总是无法回收到对象,进而分配时都是新生成对象;而回收线程因为很少分配对象,导致回收的对象超出上限被抛弃也很少得到使用。
策略三,由于对象暂存于本线程,可以避开在回收时的并发竞争。也不会出现策略2导致的失效问题。Netty采取的就是策略三。
算法设计
根据上文的策略三,首先需要构建一个数据结构用于存储当前线程分配和回收的对象。为了避免结构本身的开销,采用数组的方式进行对象存储。内部通过一个指针来实现类似堆栈的功能。将这个结构定义为Stack。
Stack通过线程变量的方式存储,避免全局竞争。Stack本身持有了对当前线程的引用(这里有个设计上的细节,为了避免互相强引用,这里对当前线程使用了弱引用)。
对象的分配通过Stack的方法完成。每一个可回收对象内部持有一个生成该对象的Stack的引用。
为了实现在其他线程暂存回收对象,设计一个Queue结构,定义为WeakOrderQueue。使用Map进行Stack和WeakOrderQueue的映射。将这个Map通过线程变量方式保存来实现无竞争。
通过Stack和WeakOrderQueue已经实现了当前线程回收和分配对象,其他线程暂存回收对象的效果。那差一个功能,在合适时机将回收对象归还至分配线程。在Stack内部存在WeakOrderQueue列表,每次其他线程有与Stack对应的WeakQueueOrder生成时,都会将该WeakOrderQueue添加到Stack的WeakOrderQueue列表中。其他线程对WeakOrderQueue只有写操作(暂存回收对象),分配线程对WeakOrderQueue只有读操作(从中取出回收对象放入自身的数组中)。当Stack中的数组没有数据时,就遍历WeakOrderQueue,获取其中的暂存对象到自身的数组,供后续分配。
在上面的描述后,我们可以得到Stack的数据结构,如下所示
+ 持有一个Element数组用于存储和分配回收对象
+ 持有WeakOrderQueue列表用于在element数组没有数据时从其他线程的WeakOrderQueue中转移数据到Element数组。
WeakOrderQueue为了完成SPSC操作(回收线程放入对象,原分配线程取出对象),其内部设计并非循环数组也不是SPSC队列,而是采用数组队列的方式。简单来说就是通过固定大小的片段数组存储对象,每一个片段数组通过Next指针链接。内部保留Head,Tail两个指针。Head指针供Stack使用,标识当前正在读取片段数组,Tail指针供WeakOrderQueue使用,标识最后一个数据可以写入的片段数组。
代码解读
DefaultHandler
Handler用于保存对象池相关的信息,回收操作也是通过该类的recycle方法进行
static final class DefaultHandle<T> implements Handle<T>
{
//用于检测重复回收的标识ID
private int lastRecycledId;
//用于检测重复回收的标识ID
private int recycleId;
boolean hasBeenRecycled;
//创建Handler对象的stack对象
private Stack<?> stack;
//真实需要复用的回收对象,具体在使用上,该对象也需要持有这个handler的实例
private Object value;
DefaultHandle(Stack<?> stack)
{
this.stack = stack;
}
@Override
public void recycle(Object object)
{
//handler只应该回收初始化时分配的对象
if (object != value)
{
throw new IllegalArgumentException("object does not belong to handle");
}
//执行真正的回收动作
stack.push(this);
}
}
Stack
首先来看下类属性
//通过弱引用持有Stack对应的线程对象
final WeakReference<Thread> threadRef;
//当前Stack可以在其他线程暂存的对象总量,使用原子变量方便进行并发访问。这意味着所有的其他线程可以为某一个Stack暂存的数量是有限的。
final AtomicInteger availableSharedCapacity;
//线程最多可以为多少个Stack暂存对象,也就是有效的WeakOrderQueue的个数。
final int maxDelayedQueues;
//Stack的最大容量
private final int maxCapacity;
//对于每个Stack,每次回收并不是一定回收对象,而是要经过N次不回收的决定后才真正回收一个对象。通过这样来避免Stack容量膨胀太快。N是一个2的次方数字。默认值为8.这里的ratioMask是为了采用位运算进行的优化。
private final int ratioMask;
private DefaultHandle<?>[] elements;
private int size;
//配合ratioMask进行回收决定的计数。初始值为-1,使得第一个回收请求可以确认,从而真正回收对象。
private int handleRecycleCount = -1;
private WeakOrderQueue cursor, prev;
//Stack内部的WeakOrderQueue实际上是依靠其内部的Next指针完整,而列表的头部Head指针则由Stack保存。为了保证可见性,该属性由volatile修饰。
private volatile WeakOrderQueue head;
来看pop
方法
DefaultHandle<T> pop()
{
int size = this.size;
if (size == 0)
{
//当数组消耗完毕后,就尝试从WeakOrderQueue中转移一些数据出来
if (!scavenge())
{
return null;
}
size = this.size;
}
//以下比较简单,就是从数组中获取数据,并且对size参数调整。这里的size是数组有效数据的容量。因此size-1才是下标最大的有效数据
size--;
DefaultHandle ret = elements[size];
elements[size] = null;
if (ret.lastRecycledId != ret.recycleId)
{
throw new IllegalStateException("recycled multiple times");
}
ret.recycleId = 0;
ret.lastRecycledId = 0;
this.size = size;
//返回handler,最终是为了返回handler中的value也就是真正的回收对象。
return ret;
}
相关的scavenge
方法如下
boolean scavenge()
{
//尝试从WeakOrderQueue中转移数据出来
if (scavengeSome())
{
return true;
}
prev = null;
cursor = head;
return false;
}
boolean scavengeSome()
{
//cursor属性保存了上一次对WeakorderQueueu列表的浏览位置,每一次都从上一次的位置继续,这是一种FIFO的处理策略
WeakOrderQueue prev;
WeakOrderQueue cursor = this.cursor;
if (cursor == null)
{
prev = null;
cursor = head;
if (cursor == null)
{
return false;
}
}
else
{
prev = this.prev;
}
boolean success = false;
do
{
//从WeakOrderQueue中转移数据到element数组中。该转移过程也会遵循每隔n次真正回收一次的原则,所以未必转移就一定获取到了数据。
if (cursor.transfer(this))
{
success = true;
break;
}
WeakOrderQueue next = cursor.next;
//如果当前处理的WeakOrderQueue所在的线程已经消亡,则尽可能的提取里面的数据,之后从列表中删除这个WeakOrderQueue,因为不会再有新的数据产生于其中。至于未能转移出来的数据则被丢弃,其申请的共享空间会在WeakOrderQueue消亡后返还。
if (cursor.owner.get() == null)
{
if (cursor.hasFinalData())
{
for (;;)
{
if (cursor.transfer(this))
{
success = true;
}
else
{
break;
}
}
}
if (prev != null)
{
prev.setNext(next);
}
}
else
{
prev = cursor;
}
cursor = next;
} while (cursor != null && !success);
this.prev = prev;
this.cursor = cursor;
return success;
}
接着看下push
方法
void push(DefaultHandle<?> item)
{
Thread currentThread = Thread.currentThread();
if (threadRef.get() == currentThread)
{
//当前线程内回收,实现很简单,就是单纯的将数据放入element数组
pushNow(item);
}
else
{
pushLater(item, currentThread);
}
}
private void pushLater(DefaultHandle<?> item, Thread thread)
{
Map<Stack<?>, WeakOrderQueue> delayedRecycled = DELAYED_RECYCLED.get();
//通过线程变量的方式得到当前线程中Stack映射的WeakOrderQueue
//如果没有对应的映射,或者其映射是一个特殊的dummy值,则放弃回收该对象
//否则初始化一个WeakOrderQueue后放入该对象
WeakOrderQueue queue = delayedRecycled.get(this);
if (queue == null)
{
if (delayedRecycled.size() >= maxDelayedQueues)
{
delayedRecycled.put(this, WeakOrderQueue.DUMMY);
return;
}
if ((queue = WeakOrderQueue.allocate(this, thread)) == null)
{
return;
}
delayedRecycled.put(this, queue);
}
else if (queue == WeakOrderQueue.DUMMY)
{
return;
}
queue.add(item);
}
以上就是Stack类的主要方法。可以看出其核心就是如何从WeakOrderQueue中提取数据了。有数据之后,本线程的分配和回收都是很简单的。
WeakOrderQueue
首先看下几个重要的类属性
private Link head, tail;
//Stack的WeakOrderQueue列表是依靠WeakOrderQueue内部的next指针完成
private WeakOrderQueue next;
private final WeakReference<Thread> owner;
private final int id = ID_GENERATOR.getAndIncrement();
//WeakOrderQueue不能存储Stack引用,因为Stack引用是作为WeakHashMap的Key存在的,所以这里存储的其内部的共享空间属性。
private final AtomicInteger availableSharedCapacity
首先是在Stack的回收中调用过的add
方法。
//简单说就是不断的构建Link使得内部形成一个Link链表。并且数据是写入到link中。只关心写入,就避免了与stack之间进行的交互
void add(DefaultHandle<?> handle) {
handle.lastRecycledId = id;
Link tail = this.tail;
int writeIndex;
if ((writeIndex = tail.get()) == LINK_CAPACITY) {
//每次新建Link都需要申请共享空间中的大小。
if (!reserveSpace(availableSharedCapacity, LINK_CAPACITY)) {
return;
}
this.tail = tail = tail.next = new Link();
writeIndex = tail.get();
}
tail.elements[writeIndex] = handle;
//Stack是作为Key存储在WeakHashMap中的。为了使得无效的stack尽可能快的被回收,因此非stack所在线程的其他地方都应该尽可能快的设置stack引用为null。下面这个null设置就是为了这个作用。
handle.stack = null;
//这里使用了延迟写入是为了提高一些性能。直接设置也是无妨的。
//WeakOrderQueue中很多地方并没有使用Volatile修饰,不影响正确性(比如link的next就没有,可能会导致暂存线程新建了link而stack线程无法发现)。最坏的情况无非就是Stack线程对一些数据不可见造成没有数据可以转移出去,但是在足够的时间下(CPU的Buffer是有限的,最终分配线程必然看到这些数据)
//tail中的volatile变量是为了保证在指针变化时,link数组中确实有了数据。避免由于重排序造成Stack线程看到指针却提取不到对象。
tail.lazySet(writeIndex + 1);
}
还有就是在Stack的pop
方法中则被调用过的transfer
方法,其内容较为简单,就是从WeakOrderQueue的Link中提取对象到Stack的数组中。唯一需要注意的是,在提取完一个Link的数据后,需要将对应的容量归还到共享空间中。
由于WeakOrderQueue对象可能会消亡(其所在的线程消亡,并且已经被Stack线程消费过一次),那么在消亡时需要归还尚未被使用的共享空间容量。代码表现为重写了finalize
方法
@Override
protected void finalize() throws Throwable {
try {
super.finalize();
} finally {
Link link = head;
while (link != null) {
reclaimSpace(LINK_CAPACITY);
link = link.next;
}
}
}
重复回收检测
为了避免在代码中错误的回收对象,框架设计了一种简单的重复回收检测规则。通过以下方式进行:
可回收对象(以下简称对象)设置2个数字属性recycleId,lastRecycleId。lastRecycleId
用来存储最后一次回收对象的线程ID(或Recycler的ID,该ID存在主要是为了避免每一个Stack都有单独ID,起到节省作用)。recycleId
在元素进入Stack的数组时设置值与lastRecycleId
相等。后续通过该相等关系是否存在判断是否重复回收。
涉及到该规则的操作主要有:
+ 从对象池中取出的对象时判断recycleId
和lastRecycleId
是否相等,否则抛出异常,相等则设置两个id为0.
+ 对象回收至本线程时判断是否2个ID为0,否的情况抛出异常。是则设置recycleId=lastRecycleId=本线程唯一ID
。
+ 对象回收至其他线程时设置lastRecycleId=回收线程ID
。
+ 对象从其他线程转移至stack
时如果recycleId
为0则设置recycleId=lastRecycleId
.如果recycleId
不为0,意味着在其他线程也执行了回收动作,抛出异常。
这种重复检测机制并无法覆盖所有的情况,仍然是需要在编码过程中避免出错的出现重复回收代码。举个例子:对象的Stack是A线程;B,C线程先后回收了对象;stack执行Pop操作时将B线程对象转移至自身数组中,并且成功弹出,清空recycleId
和lastRecycleId
。后续相同的Pop操作转移了线程C的对象。此种情况就会造成一个对象回收2次并且被弹出2次。