Netty | 图文总结Netty高性能的秘密之对象池技术

前言

在计算机网络的浩瀚世界中,有一门开源的Java网络框架,它以卓越的性能和灵活的设计脱颖而出。这就是我们今天要探讨的主角------Netty。而在Netty的神奇背后,隐藏着一项关键的技术,它被誉为高性能的秘密武器之一:对象池技术。本文将深入研究Netty对象池的奥秘,揭示其背后的工作原理和设计哲学。让我们一同踏上这场深度解析之旅,揭开Netty高性能之谜。

阅读这篇文章后,你可以学到以下内容:

  1. Netty 中的对象池技术: 文章深入解析了 Netty 中如何利用对象池技术来管理对象的创建和回收,具体以 Recycler 类为例进行讲解。
  2. 对象池的实现原理: 通过分析 Recycler 类的关键结构,你可以了解对象池的内部实现原理,包括采用类似栈的数据结构存储对象、线程隔离等设计。
  3. Recycler 的核心结构: 文章详细介绍了 Recycler 类的三个主要组成部分,即 StackHandleWeakOrderQueue,以及它们在对象池中的作用。
  4. 线程隔离的队列设计: 通过 WeakOrderQueue 的设计,你可以学到在高并发环境下如何使用队列来进行线程隔离,以提高并行度,减少并发争用。
  5. 源码解读和分析: 通过对 RecyclerDefaultHandle 和相关方法的源码解读,你可以学到如何理解和分析复杂的框架源码,从而更好地理解和使用 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;
}

下面分别解释其中的关键步骤:

  1. 首先此处进行了一个判断,判断maxCapacityPerThread是否等于0, 如果等于0,那么就直接调用newObject方法创建一个新的对象,并且Handle对象使用的是一个NOOP_HANDLE对象,NOOP_HANDLE的recycle方法是一个空实现.
  2. 通过线程变量获取Stack对象, 这个Stack的对象我们待会再说, 在这里你只需要把它理解为对象池内部的数据结构就可以了,由此可见, 对象池的内部是通过类似于栈的结构存储对象的.
  3. 从stack中出栈一个对象
  4. 如果对象为空代表对象池内已经没有空闲的对象了, 此时要通过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的容器就可以了,但是你有没有想过以下几个问题:

  1. 当一个线程分配的对象被大量其他线程使用,那些线程使用完毕之后归还到线程池中,此时会存在并发非常高的问题,传统的线程安全容器会存在大量线程争用的情况,导致性能降低.
  2. 因此考虑一个适合高并发情况下的数据结构方案是非常有必要的,下面我们来看Netty中是如何实现的.

Recycler主要结构

如图所示, Recycler的主要结构由三部分组成:

  1. Stack
  2. Handle
  3. WeakOrderQueue

Stack的弹药库由两部分组成: elements和WeakOrderQueue组成的链表. elements是当前线程进行操作的地方,而WeakOrderQueue链表则是对应了每一个不拥有stack的线程所控制的,每个线程对应一个WeakOrderQueue.

下面通过对象池的借用和回收分别解释它们的作用.

首先我们知道对象池所具备的功能至少要有两个:

  1. 借出
  2. 回收

主要流程

我们以一个初始空着的对象池为例,解释在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中的很多设计都是可以借鉴和参考的.

相关推荐
winks32 分钟前
Spring Task的使用
java·后端·spring
Null箘4 分钟前
从零创建一个 Django 项目
后端·python·django
秋意钟13 分钟前
Spring新版本
java·后端·spring
椰椰椰耶15 分钟前
【文档搜索引擎】缓冲区优化和索引模块小结
java·spring·搜索引擎
mubeibeinv17 分钟前
项目搭建+图片(添加+图片)
java·服务器·前端
青莳吖18 分钟前
Java通过Map实现与SQL中的group by相同的逻辑
java·开发语言·sql
Buleall25 分钟前
期末考学C
java·开发语言
重生之绝世牛码27 分钟前
Java设计模式 —— 【结构型模式】外观模式详解
java·大数据·开发语言·设计模式·设计原则·外观模式
小蜗牛慢慢爬行33 分钟前
有关异步场景的 10 大 Spring Boot 面试问题
java·开发语言·网络·spring boot·后端·spring·面试
A小白59081 小时前
Docker部署实践:构建可扩展的AI图像/视频分析平台 (脱敏版)
后端