Netty轻量级对象缓存池设计与实现
引言
在 Java 的世界中,对象的生命周期基本都是创建,使用,GC 回收。但是有些对象由于其创建的频度高,创建代价昂贵,且具备重复利用的可能性,因此往往希望将这类对象缓存起来,供后续的重复利用。这就出现了一种专门的设施结构:对象缓存池,专注于提供对象的复用,减少对象创建的开销。
在前文的很多分析中,特别是一些高频率使用的对象,比如PooledByteBuf等,我们都能见到一个类的身影,就是Recycle。这个类是Netty 设计的轻量级对象缓存池实现。因为像PooledByteBuf这种对象的创建是非常频繁的,每一次数据的读取,业务流程的处理都需要承载在这个对象上。但是一旦业务处理完毕就直接被丢弃了。如果反复的创建,对系统的GC压力来说也是很大的。使用对象缓存池,可以减少频繁重复创建的开销,提升一定的性能。
思路推导
像前文一样,我们先不分析代码,从设计的角度来推导对象池可能的实现方式。
对象池简单说就是将对象实例缓存起来供后续分配使用来避免瞬时大量小对象反复生成和消亡造成分配和GC压力。在设计时可以简单的做如下推导:
首先考虑只有单线程的情况,此时简单的构建一个 Queue 容器,对象回收时放入 Queue ,分配时从 Queue 取出,如果 Queue 中已经没有对象实例了,则创建一个新的实例。这种方式非常容易就构建了一个单线程的对象池实现。额外需要考虑的细节就是为 Queue 设置一个大小的上限,避免池化的对象实例过多而导致消耗太多的内存。
接着考虑多线程的情况,分配时与单线程情况相同,从当前线程的 Queue 容器中取出一个对象或者创建一个对象实例分配即可。但回收时却遇到了麻烦,对象实例被回收时的线程和分配时的线程可能一致,那处理方式与单线程相同。如果不一致,此时存在不同的策略选择,大致来说有三种:
- 策略一: 将对象回收至分配线程的 Queue 容器中。
- 策略二: 将对象回收至本线程的 Queue 容器,当成本线程的对象使用
- 策略三: 将对象暂存于本线程,在后续合适时机归还至分配线程。
每一种策略均各自的优缺点。
对于策略一,由于存在多个线程并发的归还对象实例到借出线程,因此对于借出线程而言,其存储对象实例的 Queue 容器必须是MPSC类型,多个归还线程,一个借出线程。但是采用 MPSC 队列的形式,伴随着对象的借出和归还,队列内部的节点也是在不断的生成和消亡,这样就变相的降低了对象缓存池的效果。
对于策略二,如果线程之间呈现明显的借出和回收分工,则会导致对象缓存池失去作用。因为借出线程总是无法回收到对象,因此只能不断的新建对象实例,;而回收线程因为很少分配对象,导致回收的对象超出上限被抛弃,从而无法有效的复用。
对于策略三,由于对象暂存于本线程,可以避开在回收时的并发竞争。并且因为在后续仍然会归还到借出线程,也避免了策略二可能导致的缓存失效情况。在 Netty 当中,采用的就是策略三模式。
算法设计
选定了实现思路之后我们就可以开始做算法上的设计。根据上面选定的策略三,首先需要构建一个数据结构用于存储当前线程分配和回收的对象实例。为了避免在出列和入列上结构本身的开销,我们采用数组的方式来存储对象。内部通过一个指针来实现类似堆栈的压栈和弹栈功能。我们将这个结构定义为 Stack ,每一个Stack实例都单属于一个特定的线程,Stack通过线程变量的方式存储,避免全局竞争。每一个从Stack被申请的对象实例都属于这个特定的Stack实例,最终必然要回收到该Stack实例之中。
通过Stack结构,对于借出线程而言,就能支撑其对象的申请和回收。而要支撑在其他线程暂存借出对象,需要另外的数据结构。在应用运行的过程中,对于特定的一个线程而言,可能会有多个属于不同Stack实例的对象要被暂存。显然这些对象应该按照其从属的Stack实例区分开来,为此设计一个Map<Stack,Queue>的结构。使用Stack作为映射键来进行区分,为每一个Stack都生成一个 Queue 结构来存储暂存于本线程的从属于其他线程Stack的对象。当需要暂存其他线程的对象实例时,通过该对象关联的Stack,找到或者创建属于其的 Queue 结构,将对象实例放入这个 Queue 结构中。这里的 Queue 结构,我们定义为 WeakOrderQueue,这个名称是因为其只保证元素有序,但是并不保证及时的可见性,只保证一个最终可见。同样的,这个 Map 结构一样也是通过线程变量的方式来实现并发安全。
通过Stack结构和Map结构,我们已经实现了当前线程回收和分配对象,其他线程暂存回收对象的效果。那么还差一个功能就是在合适的时机将回收对象归还至其从属的分配线程。对于这个功能,我们可以在Stack的内部增加一个WeakOrderQueue列表,每当其他线程生成一个与Stack关联的WeakOrderQueue,就将实例添加到Stack的WeakOrderQueue列表中。当Stack内部持有的对象数组没有元素供分配时,则遍历WeakOrderQueue列表,将WeakOrderQueue中的数据转移到数组中。此时数组中就有了元素,就可以执行对象的分配了。
经过上面的分析,我们将整个数据结构描述为下图所示

有了上面这张图,我们可以将Stack描述如下
- 持有一个Element数组用于存储和分配回收对象。
- 持有WeakOrderQueue列表用于在element数组没有数据时从其他线程的WeakOrderQueue中转移数据到Element数组。
WeakOrderQueue是一个 SPSC 操作类型的结构,暂存线程放入对象,分配线程取出对象。在 Netty 的设计中,WeakOrderQueue的设计既不是循环数组也不是 SPSC 队列。而是通过一个固定大小的片段数组来存储对象,每一个片段数组通过指针进行连接。内部保留Head、Tail两个指针。Head指针供Stack使用,标识当前正在读取的片段数组,Tail指针供WeakOrderQueue使用,标识最后一个数据可以写入的片段数组。
WeakOrderQueue为了完成SPSC操作(回收线程放入对象,原分配线程取出对象),其内部设计并非循环数组也不是SPSC队列,而是采用数组队列的方式。简单来说就是通过固定大小的片段数组存储对象,每一个片段数组通过Next指针链接。内部保留Head,Tail两个指针。Head指针供Stack使用,标识当前正在读取片段数组,Tail指针供WeakOrderQueue使用,标识最后一个数据可以写入的片段数组。
代码分析
有了上面的算法设计之后,再来看代码的实现就容易的多。首先来看就是对象缓存池的整体入口,类io.netty.util.Recycler。该类提供了get方法用于从对象缓存池中获取对象实例。
Recycler
作为对象缓存池的入口,其本身并不承载任何数据结构,只是提供了一些基本的属性配置,如下
private final int maxCapacityPerThread; // Stack 结构内数组的最大长度,也就是一个线程内部最多可以缓存的对象实例个数 private final int maxSharedCapacityFactor; //Stack 在其他所有暂存线程中缓存的对象实例总数与 maxCapacityPerThread 的比的反值。 private final int ratioMask; // 在暂存线程暂存对象时,对于从未回收过的对象,Netty 设计按照一定比例来抛弃未回收过的对象,避免暂存线程中的实例数量增长太快。1-(1/ratioMask)即为抛弃的比例。 private final int maxDelayedQueuesPerThread; //一个线程最多为多少Stack暂存对象实例
这个类主要是提供了一个get方法用于获取对象实例,代码如下
public final T get()
{
if(maxCapacityPerThread == 0)
{
return newObject((Handle < T > ) NOOP_HANDLE);
}
Stack < T > stack = threadLocal.get();//代码①
DefaultHandle < T > handle = stack.pop(); //代码②
if(handle == null)
{
handle = stack.newHandle();//代码③
handle.value = newObject(handle);
}
return(T) handle.value;//代码④
}
首先是代码①。通过线程变量来获取当前线程对应的Stack实例,pop方法通过弹栈给出一个被缓存的对象实例。如果当前对象池内已经没有被缓存的实例时,则创建新的对象实例并且返回给调用者,也就是代码③和代码④。
这里首先来关注下接口Recycler.Handle,代码如下
public interface Handle < T >
{
void recycle(T object);
}
从代码③方法Recycler.Stack#newHandle和Handle的定义就可以看出,Handle和被缓存的对象实例是一个绑定关系,用于为缓存的对象实例提供在对象换存池中的元数据信息存储这样的作用。下面我们来详细分析下。
DefaultHandle
Handle接口在 Netty 中有唯一实现,就是Recycler.DefaultHandle,首先来看下它的属性,如下
private int lastRecycledId; //用于判断同一对象是否错误的回收 private int recycleId; // 用于判断同一对象是否错误的回收 boolean hasBeenRecycled; //该对象是否被回收过 private Stack <? > stack; //创建该对象的Stack,这也是该对象最终要被回收到的Stack。 private Object value; //可被缓存的对象实例
来看看接口方法recycle的实现,如下
public void recycle(Object object)
{
if(object != value)
{
throw new IllegalArgumentException("object does not belong to handle");
}
Stack <? > stack = this.stack;
if(lastRecycledId != recycleId || stack == null)
{
throw new IllegalStateException("recycled already");
}
stack.push(this);
}
这个方法在缓存对象需要回收的时候被调用。由此可见,对Recycler的使用,在缓存对象内部,也是需要持有handle实例的引用的。这也是方法Recycler#newObject的内容。该方法是一个抽象方法,供具体的对象缓存池实现,开发者在方法的实现中,应该将给定的入参Handle实例注入到需要缓存的对象中,以确保当对象需要被回收时,可以手动的来调用其recycle方式实现缓存对象的回收。
这个方法除了stack.push(this)都是在执行正确性校验。关于lastRecycledId和recycleId如何实现正确性校验,后面单独开小节说明,这边先略过。
Stack
无论是获取对象,还是归还对象,最终都是通过Stack来实现的。在分析功能之前,首先来看下Stack的一些重要属性,如下
final WeakReference < Thread > threadRef;//该Stack关联的线程实例。 final AtomicInteger availableSharedCapacity;//该Stack当前剩余其他线程可缓存对象实例个数 final int maxDelayedQueues; // 一个线程最多可以同时为几个Stack暂存对象实例。 private final int maxCapacity; //该Stack缓存对象数组的最大容量 private final int ratioMask; // 作用等同于Recycler.ratioMask private DefaultHandle <? > [] elements; //该Stack缓存对象数组 private int size; //数组中非空元素的个数 private int handleRecycleCount = -1; // Stack关联的线程处理的回收请求数量 private WeakOrderQueue cursor, prev; //指向当前处理的WeakOrderQueue和下一个将要处理的WeakOrderQueu private volatile WeakOrderQueue head; //指向Stack持有的WeakOrderQueue链表的头结点,使用volatile修饰是方便进行并发修改。
分配对象实例
我们首先来分析其对获取对象的实现。也就是方法Recycler.Stack#pop。该方法返回的是一个DefaultHandle对象,上个小节我们知道,每一个DefaultHandle对象内部都关联着,也即是返回了DefaultHandle对象,就相当于返回了一个被缓存的可复用对象实例了。来看具体方法,如下
DefaultHandle < T > pop()
{
int size = this.size;
if(size == 0)
{
if(!scavenge())
{
return null;
}
size = this.size;
}
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;
return ret;
}
首先让我们先忽略if代码块的内容,整个方法看起来就很简单明然。size属性是elements数组内有效元素的个数,也可以理解为使用数组作为栈,size-1的值就是栈顶元素的下标。在if代码块后面的部分就是一个很明了的弹栈操作了。
而如果当前数组有效元素为0,也就是size为0时,执行方法scavenge,从其他线程的为该Stack暂存的WeakOrderQueue中获取缓存对象填充到数组中,如果获取成功,则执行弹栈操作,否则就直接返回null。
从Recycler.get方法可知,如果无法从Stack中分配对象,则首先通过Recycler.Stack#newHandle获取一个DefaultHandle实例,并且通过方法Recycler#newObject将该实例赋值给被新建出来可缓存的用户对象。
接着我们分析下方法scavenge的具体实现,这个方法的目的是为了从其他线程的WeakOrderQueue中转移Handle实例到Stack的elements数组中。其代码如下
boolean scavenge()
{
if(scavengeSome())
{
return true;
}
prev = null;
cursor = head;
return false;
}
首先是通过方法从当前指向尝试转移实例到中。该方法中会重复逐个尝试,直到转移成功或者到达队列的末尾。如果到达队列末尾都没有成功找到实例并且转移,则将指针设置为队列的头
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
<p> 通过本专刊的学习,对网络开发所需掌握的基础理论知识会更加牢固,对网络应用涉及的线程模型,设计模式,高性能架构等更加明确。通过对Netty的源码深入讲解,使得读者对Netty达到“知其然更之所以然”的程度。在遇到一些线上的问题时,具备了扎实理论功底的情况,可以有的放矢而不会显得盲目。 本专刊购买后即可解锁所有章节,故不可以退换哦~ </p> <p> <br /> </p>
查看23道真题和解析
