前言
在计算机网络的浩瀚世界中,有一门开源的Java网络框架,它以卓越的性能和灵活的设计脱颖而出。这就是我们今天要探讨的主角------Netty。而在Netty的神奇背后,隐藏着一项关键的技术,它被誉为高性能的秘密武器之一:对象池技术。本文将深入研究Netty对象池的奥秘,揭示其背后的工作原理和设计哲学。让我们一同踏上这场深度解析之旅,揭开Netty高性能之谜。
阅读这篇文章后,你可以学到以下内容:
- Netty 中的对象池技术: 文章深入解析了 Netty 中如何利用对象池技术来管理对象的创建和回收,具体以
Recycler
类为例进行讲解。 - 对象池的实现原理: 通过分析
Recycler
类的关键结构,你可以了解对象池的内部实现原理,包括采用类似栈的数据结构存储对象、线程隔离等设计。 - Recycler 的核心结构: 文章详细介绍了
Recycler
类的三个主要组成部分,即Stack
、Handle
和WeakOrderQueue
,以及它们在对象池中的作用。 - 线程隔离的队列设计: 通过
WeakOrderQueue
的设计,你可以学到在高并发环境下如何使用队列来进行线程隔离,以提高并行度,减少并发争用。 - 源码解读和分析: 通过对
Recycler
、DefaultHandle
和相关方法的源码解读,你可以学到如何理解和分析复杂的框架源码,从而更好地理解和使用 Netty。
Netty对象池的使用示例
在Netty的ChannelOutboundBuffer中, Entry使用了对象池技术
我们看Entry是如何创建的:
java
static Entry newInstance(Object msg, int size, long total, ChannelPromise promise) {
Entry entry = RECYCLER.get();
// ... 省略
return entry;
}
这是创建Entry实例的工厂方法, 可以看到在这里我们没有使用传统的new关键字创建新的对象,而是通过RECYCLER.get()方法获取, 这就是Netty使用的对象池技术, 它可以实现复用之前创建的对象减少因创建对象带来的负担.
java
static final class Entry {
private static final Recycler<Entry> RECYCLER = new Recycler<Entry>() {
@Override
protected Entry newObject(Handle<Entry> handle) {
return new Entry(handle);
}
};
// ... 省略
}
如上所示, Entry类内部有一个静态变量RECYCLER, 它是RECYCLER类型的匿名子类, 实现了newObject方法, newObject方法内创建了Entry对象, 注意作为newObject参数的Handle变量.
有获取就要有回收,我们在使用完毕Entry对象后,需要把它归还给对象池以便之后的复用,代码如下:
java
void recycle() {
// ... 省略了将field置为null的操作
handle.recycle(this);
}
Entry对象的recycle方法实际上在将其拥有的field清理完之后,调用了handle的recycle方法,即使不看源码也很容易可以理解这个方法的意思:
使用RECYCLE的句柄(Handle)回收借出的对象(此处的Entry).
通过以上方法的解析,我们可以理解RECYCLER实际上就是对象池的实例,它要能够被全局访问因此定义通常是静态的.
但是Handle的作用是什么呢?
我们可以看一下Handle类的定义:
java
public interface Handle<T> {
void recycle(T object);
}
可以看到非常的简单,只有一个recycle方法,接收借出对象作为参数,我们可以想见其作用就是进行后续的回收,但是为什么不直接使用Recycler进行回收呢?
可能这样做的原因现在解释起来可能你还不是很能理解,但我先在此将答案告诉你,你之后可以在阅读之后文章的适合慢慢去领会它的存在意义.Handle的最主要作用是: 提供借出对象和所属线程的绑定关系.
源码解析
Recycler.get()方法
上源码:
java
public final T get() {
if (maxCapacityPerThread == 0) { // @1
return newObject((Handle<T>) NOOP_HANDLE);
}
Stack<T> stack = threadLocal.get(); // @2
DefaultHandle<T> handle = stack.pop(); // @3
if (handle == null) { // @4
handle = stack.newHandle();
handle.value = newObject(handle);
}
return (T) handle.value;
}
下面分别解释其中的关键步骤:
- 首先此处进行了一个判断,判断maxCapacityPerThread是否等于0, 如果等于0,那么就直接调用newObject方法创建一个新的对象,并且Handle对象使用的是一个NOOP_HANDLE对象,NOOP_HANDLE的recycle方法是一个空实现.
- 通过线程变量获取Stack对象, 这个Stack的对象我们待会再说, 在这里你只需要把它理解为对象池内部的数据结构就可以了,由此可见, 对象池的内部是通过类似于栈的结构存储对象的.
- 从stack中出栈一个对象
- 如果对象为空代表对象池内已经没有空闲的对象了, 此时要通过newObject创建新的对象.
由Recycler.get()方法我们可以了解到对象池内部是由一个类似栈的结构存储的, 而且对于不同的线程来说对象池是隔离的.
DefaultHandle
DefaultHandle是Handle的默认实现, 让我们看一下它的结构:
java
private int lastRecycledId; // @1
private int recycleId; // @2
boolean hasBeenRecycled; // @3
private Stack<?> stack; // @4
private Object value; // @5
对于1、2我们暂且略过, 在我们说到回收的底层原理时会再次提到它们.hasBeenRecycled代表是否已经被回收,如果已经被回收,再次调用Handle的回收方法是会抛出异常的.
第四个field是stack,即这个Handle之前所属的Stack, 为什么要有这个变量呢?还记得我们之前说过,Handle的作用实际上是提供借出对象和所属线程的绑定关系. 即从什么线程上的stack分配,就要归还到哪个stack中,即使这个Handle被传到其他线程也一样.(当然在Handle所属的线程终止运行被回收的场合,在归还的适合它还是会被其他线程获得到)
第五个field就是真正的对象.
DefaultHandle实现了Handle接口, 只有一个方法recycle:
java
public void recycle(Object object) {
if (object != value) { // @1
throw new IllegalArgumentException("object does not belong to handle");
}
Stack<?> stack = this.stack;
if (lastRecycledId != recycleId || stack == null) { // @2
throw new IllegalStateException("recycled already");
}
stack.push(this); // @3
}
1、2:进行了一致性和是否被重复回收的校验.
3: 入栈,将Handle(包括其中的value对象)归还到线程本地的对象池中.
由此可见, 表面上的逻辑十分简单, 即通过线程本地变量stack进行对象的分配与归还,你可能以为stack的实现只要是任意一个线程安全的实现了stack的容器就可以了,但是你有没有想过以下几个问题:
- 当一个线程分配的对象被大量其他线程使用,那些线程使用完毕之后归还到线程池中,此时会存在并发非常高的问题,传统的线程安全容器会存在大量线程争用的情况,导致性能降低.
- 因此考虑一个适合高并发情况下的数据结构方案是非常有必要的,下面我们来看Netty中是如何实现的.
Recycler主要结构
如图所示, Recycler的主要结构由三部分组成:
- Stack
- Handle
- WeakOrderQueue
Stack的弹药库由两部分组成: elements和WeakOrderQueue组成的链表. elements是当前线程进行操作的地方,而WeakOrderQueue链表则是对应了每一个不拥有stack的线程所控制的,每个线程对应一个WeakOrderQueue.
下面通过对象池的借用和回收分别解释它们的作用.
首先我们知道对象池所具备的功能至少要有两个:
- 借出
- 回收
主要流程
我们以一个初始空着的对象池为例,解释在Netty的Recycler中,借出以及回收的流程. 1: start
上图中显示的是初始状态下Stack中初始的状态, elements为空, head指向的第一个队列也是空.
2: 假设我们借出一个对象Handle:
然后在相同的线程中调用handle的回收方法进行回收之后,Stack以及Handle的状态:
我们可以看到, 这个被回收的Handle放置在了Stack的elements里面, 同时lastRecycledId(int)和recycledId(int)都被设置成了ownThreadId, 你可以把ownThreadId理解为一个特殊值, 它们两相等且不同时为0代表它们已经被回收了, 而且等于ownThreadId的意思是它们是被stack所属的线程回收的.
3: 假设我们此时再借出一个对象,此时由于elements中有对象, 所以我们可以直接从stack中将对应的对象拿出去,然后stack就变为空的了,我们这里就不再画相应的示意图了.
4.假设我们此时借出一个对象,然后将其传入其它线程,我们叫做线程B,线程B使用完成之后调用Handle的recycle方法将其归还时,我们查看此时stack的情况:
从这张图我们可以看到, Stack指向的head发生了变化, head指向了一个WeakOrderQueue, 你可以把WeakOrderQueue暂时理解为一个由链表实现的队列, 队列的每一个元素叫做Link都是Handle的数组, 和Stack的elements一样(只不过容量比Stack的elements少), Handle被放置在队列Head中的索引为0的位置.
同时这个队列是放在线程B的线程变量中的, 这个线程变量的结构是一个Map, 每个Stack所属线程之外的线程进行原本属于该Stack的Handle回收时, 都会把Handle对象放在属于自己的队列中.
为什么在其他线程回收的对象要放到一个单独的队列中呢?
是因为这样做可以增加并行度, 减少回收时发生的并发争用, 假如我们直接把在线程B中回收的Handle放到Stack的elements中,那么势必要维持线程安全性, 为此要很小心的维护一个写指针, 这样做的代价是不仅代码实现困难, 而且性能也较差; 而使用这种线程隔离的方式可以有效提高并发度, 减少同步代码出错的概率, 可以说是很有技巧性的设计了.
5: 为了进一步演示多线程隔离的场景, 我们在4之上, 再加一个线程C回收的场景, 回收之后, Stack内部结构如下:
可以看到Stack中链表的变化, head被线程C的队列取代, 线程B的队列变成了线程C的next.
6: 现在我们来看当我们要开始使用对象池中的对象时, Stack的结构会发生何种变化. 现在我们使用reclcler.get方法, 此时会从Stack中pop出一个元素, 此时的Stack结构为:
可以看到此时优先取用的是elements的元素, 把它对应的位置设置为了null, 此时返回的Handle元素如下所示:
可以看到两个recycledId都被设置为了0, 代表它们并未被回收.
7: 假如这时我们再次获取对象池中的元素, 这时会发生什么呢? 请看下图:
这是获取之前的状况, 可见在Stack中的elements是没有"库存"的, 只有在其他线程的队列中有, 在线程C中有3个,线程B有1个,此时会从其他线程的"库存"中转移一部分到Stack的elements中.
具体做法是: 从队列链表中, 从head开始进行扫描, 如果从一个线程的队列中成功转移了元素(只要成功转移即可,即数量大于0)那么就停止扫描, 否则继续下一个线程的队列进行尝试.
听上去挺简单的, 但是要考虑到队列所属线程终止运行被回收的情况, 此时就要把这个队列中的元素进行转移, 并且把这个队列从链表中去除, 因此Stack中的cursor和prev就是为此而生的, 它们在扫描过程中记录当前扫描和当前扫描的前一个元素的指针,如果碰到类似情况, 就可以进行unlink操作, 由于不是主流程这种情况就不画图了, 感兴趣的读者可以阅读相关源码.
下面我们看转移之后Stack的结构:
可以看到线程C的队列已经空了, 所有Handle对象都转移到了Stack的elements中,我们也可以观察到, 在线程C的队列中, 它的wirteIndex和readIndex都为3, 可以看到即使把元素转移走, 一个Link元素使用的空间也不会从0开始, 所以它是一种append-only模型的. 有人会问这会不会造成空间浪费,答案是不会, 因为Link的elements数组默认大小仅为16.
WeakOrderQueue核心源码解读
java
static final class Link extends AtomicInteger {
private final DefaultHandle<?>[] elements = new DefaultHandle[LINK_CAPACITY]; // LINK_CAPACITY默认为16
private int readIndex;
Link next;
}
void add(DefaultHandle<?> handle) {
handle.lastRecycledId = id;
Link tail = this.tail;
int writeIndex;
if ((writeIndex = tail.get()) == LINK_CAPACITY) { // @1
if (!head.reserveSpace(LINK_CAPACITY)) { // @2
return;
}
// We allocate a Link so reserve the space
this.tail = tail = tail.next = new Link(); // @3
writeIndex = tail.get();
}
tail.elements[writeIndex] = handle; // @4
handle.stack = null;
// we lazy set to ensure that setting stack to null appears before we unnull it in the owning thread;
// this also means we guarantee visibility of an element in the queue if we see the index updated
tail.lazySet(writeIndex + 1); // @5
}
在解释以上步骤之前, 有必要说一下WeakOrderQueue中的元素------Link的定义, 可以看到, 它继承于AtomicInteger,同时它内部含有一个Handle数组, readIndex和next, Handle数组和next都很好理解, readIndex也比较容易理解,它的作用是记录在我们上面讲述Handle元素从队列转移到Stack中的elements时转移的位置, 下一次转移就从这个readIndex来.而Link本身就是一个AtomicInteger实际上代表它本身就记录了当前"写"的位置, 如果readIndex是读指针的话, 那么它就是写指针.
下面来解释每一步的作用:
1: 从Link链表的尾部开始写, 如果当前tail的数组已经写满了, 即达到LINK_CAPACITY, 那么就需要新建链表元素.
2: 在新建链表元素之前, 进行了一次是否超过当前队列允许存放的总元素大小的限制, 默认是Stack的elements大小的一半,即2048个, 如果超过了就直接丢弃,否则继续.
3: 这一步就是新建链表元素, 同时更新tail指针指向新建的Link元素.
4: 将回收的Handle写入到tail的对应位置上.
5: 这一步可能比较难于理解, 首先它将handle的stack设置为了true, 然后更新tail的写指针,而且是lazySet.
这一步的意义主要是确保将handle.stack=null这一步骤在transfer中设置handle的stack为当前stack之前完成, 因为重排序,可能会发生这样一种情况,
java
正常情况:
handle.stack = null;
tail.set(writeIndex + 1);
handle.stack = dst;
异常情况:
tail.set(writeIndex + 1);
handle.stack = dst;
handle.stack = null;
在异常情况中, 由于使用set的方式, 可能出现handle.stack=null的语句排序于tail.set更新writeIndex之后, 那么在 transfer方法中就可以看到handle.stack=null执行之前的handle对象,导致对stack更新为dst的操作排在更新为null之前,造成最终返回给用户的handle的stack为null,无法正确回收.
但是为什么使用lazySet就可以保证不发生重排序这一点还不是非常清楚,如果有知道的大佬可以赐教下.
我的猜测是使用lazySet方法更新可以做到不被其他线程立即读到当前值的变化,从而降低因为重排序而造成的问题(因为其他线程读到新值的时间可能大大延后了).
下面我们看transfer方法的实现:
java
boolean transfer(Stack<?> dst) {
Link head = this.head.link;
final int srcStart = head.readIndex; // @1
int srcEnd = head.get();
final int srcSize = srcEnd - srcStart;
final int dstSize = dst.size;
if (srcStart != srcEnd) {
final DefaultHandle[] srcElems = head.elements;
final DefaultHandle[] dstElems = dst.elements;
int newDstSize = dstSize;
for (int i = srcStart; i < srcEnd; i++) { // @2
srcElems[i] = null;
element.stack = dst;
dstElems[newDstSize ++] = element;
}
head.readIndex = srcEnd;
dst.size = newDstSize;
return true;
} else {
// The destination stack is full already.
return false;
}
}
}
我删去了一些参数校验和一些特殊情况的处理, 其核心实现实际上非常简单, 就是一个数组copy的过程:
1: 计算队列中要开始转移的起始位置srcStart和终止位置srcEnd, 是通过Link的readIndex和Link本身确定的.
2: 从队列中开始copy到Stack的elements, 直到srcEnd, 最终更新stack的size, 返回true.
Stack核心源码解读
push方法
java
void push(DefaultHandle<?> item) {
Thread currentThread = Thread.currentThread();
if (threadRef.get() == currentThread) {
pushNow(item);
} else {
pushLater(item, currentThread);
}
}
push方法是在回收元素时调用的, 当前线程为stack所属线程时, 进入pushNow分支, 当前线程不是stack所属线程时进入pushLater分支, 实际上是放到了线程对应的队列中去了.我们主要看下pushLater分支的代码(省略部分代码):
java
private void pushLater(DefaultHandle<?> item, Thread thread) {
Map<Stack<?>, WeakOrderQueue> delayedRecycled = DELAYED_RECYCLED.get(); // @1
WeakOrderQueue queue = delayedRecycled.get(this); // @2
if (queue == null) {
if ((queue = WeakOrderQueue.allocate(this, thread)) == null) { // @3
return;
}
delayedRecycled.put(this, queue); // @4
}
queue.add(item); // @5
}
1和2: 获取属于本线程的队列
3: 如果本线程的该Stack队列为空的话, 那么就新建一个队列,同时把该新建的队列链接到stack的队列链表上去.
4: 将新建的队列放到线程变量中
5: 将Handle元素添加到队列的Link元素中.
总结
Netty中的内部代码中用到对象池的地方数不胜数, 可以说对象池是Netty高性能的关键因素, 为此我们看了Netty对象池Recycler内部结构,分析了它在对象获取及回收时的行为,以及它为了提高性能所做的关键设计, 比如说线程隔离队列(或者称之为延迟队列)都是非常有趣的设计,接着我们在理解了其设计思路之后看了它的关键代码解析,可以说Netty中的很多设计都是可以借鉴和参考的.