java网关服务性能提升利器:CPU绑定对象池

SONA 是一个由比心语音技术团队开发,用于快速搭建语音房产品的全端解决方案,包含了房间管理、实时音视频、房间IM、长连接网关等能力,支撑了比心聊天室、直播、游戏房等业务。


前言

Sona 平台是一个搭建语音房产品的全端解决方案,包含了房间管理、实时音视频、房间IM、长连接网关等能力。其中最基础核心的就是长连接网关。

池化技术是把一些能够复用的东西放到池中,提前保存大量的资源,避免重复创建、销毁的开销,从而极大提高性能。在请求量比较大的时候能明显优化应用性能,降低系统频繁建连的资源开销。池化技术的应用非常广泛,我们工作中常见的比如数据库连接池、线程池、http连接池等。


一、对象池

还有一种可能在工作中不是经常使用的池化技术 - 对象池 ,它的核心思想就是空间换时间。

我们知道 Java 中频繁地创建和销毁对象的开销是很大的,通常情况下对象是分配在堆上的,因为堆是线程共享的,所以同一时间可能会有很多线程申请空间分配,在这种情况下要加锁处理,这样一来就会造成分配效率下降。虽然可以使用 TLAB 来分配(TLAB是每个线程独有的,它可以避免这种开销,直接分配内存),但还是存在着一些效率问题。

我们可以使用预先创建好的对象来减少频繁创建对象的性能开销,同时还可以对对象进行统一的管理,降低了对象的使用成本。这样不仅避免频繁地创建和销毁所带来的性能损耗,提高并发处理能力,而且能够降低 JVM GC 的压力。

像 Disruptor,它之所以高性能其实有一部分原因就是,RingBuffer 数组元素在初始化时一次性全部创建,提升缓存命中率,对象循环利用,避免频繁GC。

二、Netty Recycler

Recycler 是 Netty 提供的自定义实现的轻量级对象回收站,借助 Recycler 可以完成对象的获取和回收。

在 SONA网关中,每次业务请求最终都会被封装成 ChannelEventTask 丢到线程池里执行。为了尽可能地提升网关的性能,我就使用了 Recycler 来做池化。在压测过程中,内存占用以及CPU 消耗都有比较明显的改善。

使用方式其实非常简单,下面这个是 Netty 官方给出的样例:

通过 RECYCLER.get() 来获取对象,handle.recycle(this) 来回收对象 。

public class MyObject {

  private static final Recycler RECYCLER = new Recycler() {
    protected MyObject newObject(Recycler.Handle handle) {
      return new MyObject(handle);
    }
  }

  public static MyObject newInstance(int a, String b) {
    MyObject obj = RECYCLER.get();
    obj.myFieldA = a;
    obj.myFieldB = b;
    return obj;
  }
    
  private final Recycler.Handle handle;
  private int myFieldA;
  private String myFieldB;

  private MyObject(Handle handle) {
    this.handle = handle;
  }
  
  public boolean recycle() {
    myFieldA = 0;
    myFieldB = null;
    return handle.recycle(this);
  }
}

MyObject obj = MyObject.newInstance(42, "foo");
...
obj.recycle();

Recycler 原理

Recycler 里面一共包含四个核心组件:Stack、WeakOrderQueue、Link、DefaultHandle。

整个 Recycler 的内部结构中各个组件的关系,可以通过下面这幅图进行描述

java网关服务性能提升利器:CPU绑定对象池_第1张图片

Stack

private static final class Stack {

    final Recycler parent; // 所属的 Recycler

    final WeakReference threadRef; // 所属线程的弱引用

    final AtomicInteger availableSharedCapacity; // 线程回收对象时,其他线程能保存的被回收对象的最大个数

    final int maxDelayedQueues; // WeakOrderQueue最大个数

    private final int maxCapacity; // 对象池的最大大小,默认最大为 4095

    private final int ratioMask; // 控制对象的回收比率,默认只回收 1/8 的对象

    private DefaultHandle[] elements; // 存储缓存数据的数组

    private int size; // 缓存的 DefaultHandle 对象个数

    private int handleRecycleCount; 

    // WeakOrderQueue 链表的三个重要节点
    private WeakOrderQueue cursor, prev;

    private volatile WeakOrderQueue head;

    // 省略其他代码
}

Stack 对象是从 Recycle 内部的 FastThreadLocal 对象中获得,因此每个线程拥有属于自己的 Stack 对象,创造了无锁的环境,并通过 weakOrderQueue 与其他线程建立沟通的桥梁。

每个 Stack 会维护一个 WeakOrderQueue 的链表,每个 WeakOrderQueue 节点会保存非当前线程的其他线程所释放的对象。例如图中 ThreadA 表示当前线程,WeakOrderQueue 的链表存储着 ThreadB、ThreadC 等其他线程释放的对象。

WeakOrderQueue

WeakOrderQueue 用于存储其他线程回收到当前线程所分配的对象,并且在合适的时机,Stack 会从异线程的 WeakOrderQueue 中收割对象。如上图所示,ThreadB 回收到 ThreadA 所分配的内存时,就会被放到 ThreadA 的 WeakOrderQueue 当中。

/**
 * 「WorkOrderQueue」 内部链表是由「Head」和「tail」节点组成的。
 * 队内数据并非立即对其他线程可见,采用最终一致性思想。
 * 不需要进行保证立即可见,只需要保证最终可见就好了
 */
private static final class WeakOrderQueue extends WeakReference {

    // Head节点管理「Link」对象的创建。内部next指向下一个「Link」节点,构成链表结构
    private final Head head;

    // 数据存储节点
    private Link tail;

    // 指向其他异线程的WorkOrderQueue链表
    private WeakOrderQueue next;

    // 唯一ID
    private final int id = ID_GENERATOR.getAndIncrement();

    // 可以理解为对回收动作限流。默认值: 8
    // 并非到阻塞时才限流,而是一开始就这样做
    private final int interval;

    // 已丢弃回收对象数量
    private int handleRecycleCount;
    
    // LINK 节点继承「AtomicInteger」,内部还有一个「readIndex」指针
    static final class Link extends AtomicInteger {
        final DefaultHandle[] elements = new DefaultHandle[LINK_CAPACITY];

        int readIndex;
        Link next;
    }
}

WeakOrderQueue 继承 WeakReference,当所属线程被回收时,相应的 WeakOrderQueue 也会被回收。内部通过 Link 对象构成链表结构,Link 内部维护一个 DefaultHandle[] 数组用来暂存异线程回收对象。添加时会判断是否会超出设置的阈值(默认值: 16),没有则添加成功,否则创建一个新的 Link 节点并添加回收对象,接着更新链表结构,让 tail 指针指向新建的 Link 对象。由于线程不止一个,所以对应的 WeakOrderQueue 也会有多个,WeakOrderQueue 之间则构成链表结构。变量 interval 作用是回收限流,它从一开始就限制回收速率,每经过8 个对象才会回收一个,其余则丢弃。

Link

每个 WeakOrderQueue 中都包含一个 Link 链表,回收对象都会被存在 Link 链表中的节点上,每个 Link 节点默认存储 16 个对象,当每个 Link 节点存储满了会创建新的 Link 节点放入链表尾部。

LINK_CAPACITY = safeFindNextPositivePowerOfTwo(
                max(SystemPropertyUtil.getInt("io.netty.recycler.linkCapacity", 16), 16));

static final class Link extends AtomicInteger {
    final DefaultHandle[] elements = new DefaultHandle[LINK_CAPACITY];

    int readIndex;
    Link next;
}

DefaultHandle

DefaultHandle 是 Recycler 对回收对象的包装类,它保存了实际回收的对象,Stack 和 WeakOrderQueue 都使用 DefaultHandle 存储回收的对象。在 Stack 中包含一个 elements 数组,该数组保存的是 DefaultHandle 实例。DefaultHandle 中每个 Link 节点所存储的 16 个对象也是使用 DefaultHandle 表示的。

它实现 Handle 接口,里面包含 recycle(Object obj) 回收方法。

private static final class DefaultHandle implements Handle {
    // 上次回收此Handle的RecycleId
    int lastRecycledId;
    
    // 创建此Handle的RecycleId。和 lastRecycledId 配合使用进行重复回收检测
    int recycleId;

    // 该对象是否被回收过
    boolean hasBeenRecycled;

    // 创建「DefaultHandle」的Stack对象
    Stack stack;

    // 待回收对象
    Object value;

    DefaultHandle(Stack stack) {
        this.stack = stack;
    }
		
	/**
     * 回收此「Handle」所持有的对象「value」
     * 如果对象不相等,抛出异常
     */
    @Override
    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,剩下的交给Stack就好了
        stack.push(this);
    }
}

Recycler 获取对象

Recycler#get

public final T get() {

    if (maxCapacityPerThread == 0) {
        return newObject((Handle) NOOP_HANDLE);
    }

    Stack stack = threadLocal.get(); // 获取当前线程缓存的 Stack

    DefaultHandle handle = stack.pop(); // 从 Stack 中弹出一个 DefaultHandle 对象

    if (handle == null) {
        handle = stack.newHandle();
        handle.value = newObject(handle); // 创建对象并保存到 DefaultHandle
    }

    return (T) handle.value;
}

Recycler#get() 方法的逻辑非常清晰,首先通过 FastThreadLocal 获取当前线程的唯一栈缓存 Stack,然后尝试从栈顶弹出 DefaultHandle 对象实例,如果 Stack 中没有可用的 DefaultHandle 对象实例,那么会调用 newObject 生成一个新的对象,完成 handle 与用户对象和 Stack 的绑定。

stack.pop()

DefaultHandle pop() {

    int size = this.size;
    if (size == 0) {
        // 就尝试从其他线程回收的对象中转移一些到 elements 数组当中
        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;
}

如果 Stack 的 elements 数组中有可用的对象实例,直接将对象实例弹出;如果 elements 数组中没有可用的对象实例,会调用 scavenge 方法,scavenge 的作用是从其他线程回收的对象实例中转移一些到 elements 数组当中,也就是说,它会想办法从 WeakOrderQueue 链表中迁移部分对象实例。

boolean scavenge() {
    // 尝试从 WeakOrderQueue 中转移对象实例到 Stack 中
    if (scavengeSome()) {
        return true;
    }
    // 如果迁移失败,就会重置 cursor 指针到 head 节点
    prev = null;
    cursor = head;
    return false;
}

boolean scavengeSome() {
    WeakOrderQueue prev;
    WeakOrderQueue cursor = this.cursor; // cursor 指针指向当前 WeakorderQueueu 链表的读取位置
    // 如果 cursor 指针为 null, 则是第一次从 WeakorderQueueu 链表中获取对象
    if (cursor == null) {
        prev = null;
        cursor = head;
        if (cursor == null) {
            return false;
        }
    } else {
        prev = this.prev;
    }
    boolean success = false;
    // 不断循环从 WeakOrderQueue 链表中找到一个可用的对象实例
    do {
        // 尝试迁移 WeakOrderQueue 中部分对象实例到 Stack 中
        if (cursor.transfer(this)) {
            success = true;
            break;
        }
        WeakOrderQueue next = cursor.next;
        if (cursor.owner.get() == null) {
            // 如果已退出的线程还有数据
            if (cursor.hasFinalData()) {
                for (;;) {
                    if (cursor.transfer(this)) {
                        success = true;
                    } else {
                        break;
                    }
                }
            }
            // 将已退出的线程从 WeakOrderQueue 链表中移除
            if (prev != null) {
                prev.setNext(next);
            }
        } else {
            prev = cursor;
        }
        // 将 cursor 指针指向下一个 WeakOrderQueue
        cursor = next;
    } while (cursor != null && !success);
    this.prev = prev;
    this.cursor = cursor;
    return success;
}

scavenge 的源码中首先会从 cursor 指针指向的 WeakOrderQueue 节点回收部分对象到 Stack 的 elements 数组中,如果没有回收到数据就会将 cursor 指针移到下一个 WeakOrderQueue,重复执行以上过程直至回到到对象实例为止。

java网关服务性能提升利器:CPU绑定对象池_第2张图片

此外,每次移动 cursor 时,都会检查 WeakOrderQueue 对应的线程是否已经退出了,如果线程已经退出,那么线程中的对象实例都会被回收,然后将 WeakOrderQueue 节点从链表中移除。

每个 WeakOrderQueue 中都包含一个 Link 链表,Netty 每次会回收其中的一个 Link 节点所存储的对象。从图中可以看出,Link 内部会包含一个读指针 readIndex,每个 Link 节点默认存储 16 个对象,读指针到链表尾部就是可以用于回收的对象实例,每次回收对象时,readIndex 都会从上一次记录的位置开始回收。

在回收对象实例之前,Netty 会计算出可回收对象的数量,加上 Stack 中已有的对象数量后,如果超过 Stack 的当前容量且小于 Stack 的最大容量,会对 Stack 进行扩容。为了防止回收对象太多导致 Stack 的容量激增,在每次回收时 Netty 会调用 dropHandle 方法控制回收频率

boolean dropHandle(DefaultHandle handle) {
    if (!handle.hasBeenRecycled) {
        if ((++handleRecycleCount & ratioMask) != 0) {
            // Drop the object.
            return true;
        }
        handle.hasBeenRecycled = true;
    }
    return false;
}

dropHandle 方法中主要靠 hasBeenRecycled 和 handleRecycleCount 两个变量控制回收的频率,会从每 8 个未被收回的对象中选取一个进行回收,其他的都被丢弃掉。

Recycler 对象回收

DefaultHandle#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);
}

// Stack#push
void push(DefaultHandle item) {
    Thread currentThread = Thread.currentThread();
    if (threadRef.get() == currentThread) {
        pushNow(item);
    } else {
        pushLater(item, currentThread);
    }
}

从源码中可以看出,在回收对象时,会向 Stack 中 push 对象,push 会分为同线程回收和异线程回收两种情况,分别对应 pushNow 和 pushLater 两个方法,我们逐一进行分析。

同线程对象回收

private void pushNow(DefaultHandle item) {
    if ((item.recycleId | item.lastRecycledId) != 0) { // 防止被多次回收
        throw new IllegalStateException("recycled already");
    }
    item.recycleId = item.lastRecycledId = OWN_THREAD_ID;
    int size = this.size;
    // 1. 超出最大容量 2. 控制回收速率
    if (size >= maxCapacity || dropHandle(item)) {
        return;
    }
    if (size == elements.length) {
        elements = Arrays.copyOf(elements, min(size << 1, maxCapacity));
    }
    elements[size] = item;
    this.size = size + 1;
}

同线程回收对象的逻辑非常简单,就是直接向 Stack 的 elements 数组中添加数据,对象会被存放在栈顶指针指向的位置。如果超过了 Stack 的最大容量,那么对象会被直接丢弃,同样这里使用了 dropHandle 方法控制对象的回收速率,每 8 个对象会有一个被回收到 Stack 中。

异线程对象回收

private void pushLater(DefaultHandle item, Thread thread) {
    Map, WeakOrderQueue> delayedRecycled = DELAYED_RECYCLED.get(); // 当前线程帮助其他线程回收对象的缓存
    WeakOrderQueue queue = delayedRecycled.get(this); // 取出对象绑定的 Stack 对应的 WeakOrderQueue
    if (queue == null) {
        // 最多帮助 2*CPU 核数的线程回收线程
        if (delayedRecycled.size() >= maxDelayedQueues) {
            delayedRecycled.put(this, WeakOrderQueue.DUMMY); // WeakOrderQueue.DUMMY 表示当前线程无法再帮助该 Stack 回收对象
            return;
        }
        // 新建 WeakOrderQueue
        if ((queue = WeakOrderQueue.allocate(this, thread)) == null) {
            // drop object
            return;

        }
        delayedRecycled.put(this, queue);
    } else if (queue == WeakOrderQueue.DUMMY) {
        // drop object
        return;
    }
    queue.add(item); // 添加对象到 WeakOrderQueue 的 Link 链表中
}

通过 FastThreadLocal 取出当前对象的 DELAYED_RECYCLED 缓存,DELAYED_RECYCLED 存放着当前线程帮助其他线程回收对象的映射关系。假如 item 是 ThreadA 分配的对象,当前线程是 ThreadB,此时 ThreadB 帮助 ThreadA 回收 item,那么 DELAYED_RECYCLED 放入的 key 是 StackA。然后从 delayedRecycled 中取出 StackA 对应的 WeakOrderQueue,如果 WeakOrderQueue 不存在,那么为 StackA 新创建一个 WeakOrderQueue,并将其加入 DELAYED_RECYCLED 缓存。WeakOrderQueue.allocate() 会检查帮助 StackA 回收的对象总数是否超过 2K 个,如果没有超过 2K,会将 StackA 的 head 指针指向新创建的 WeakOrderQueue,否则不再为 StackA 回收对象。

当然 ThreadB 不会只帮助 ThreadA 回收对象,它可以帮助其他多个线程回收,所以 DELAYED_RECYCLED 使用的 Map 结构,为了防止 DELAYED_RECYCLED 内存膨胀,Netty 也采取了保护措施,从 delayedRecycled.size() >= maxDelayedQueues 可以看出,每个线程最多帮助 2 倍 CPU 核数的线程回收线程,如果超过了该阈值,假设当前对象绑定的为 StackX,那么将在 Map 中为 StackX 放入一种特殊的 WeakOrderQueue.DUMMY,表示当前线程无法帮助 StackX 回收对象。

queue.add(item)

void add(DefaultHandle handle) {
    handle.lastRecycledId = id;
    Link tail = this.tail;
    int writeIndex;
    // 如果链表尾部的 Link 已经写满,那么再新建一个 Link 追加到链表尾部
    if ((writeIndex = tail.get()) == LINK_CAPACITY) {
        // 检查是否超过对应 Stack 可以存放的其他线程帮助回收的最大对象数
        if (!head.reserveSpace(LINK_CAPACITY)) {
            // Drop it.
            return;
        }
        this.tail = tail = tail.next = new Link();
        writeIndex = tail.get();
    }
    tail.elements[writeIndex] = handle; // 添加对象到 Link 尾部
    handle.stack = null; // handle 的 stack 属性赋值为 null
    tail.lazySet(writeIndex + 1);
}

在向 WeakOrderQueue 写入对象之前,会先判断 Link 链表的 tail 节点是否还有空间存放对象。如果还有空间,直接向 tail Link 尾部写入数据,否则直接丢弃对象。如果 tail Link 已经没有空间,会新建一个 Link 之后再存放对象,新建 Link 之前会检查异线程帮助回收的对象总数超过了 Stack 设置的阈值,如果超过了阈值,那么对象也会被丢弃掉。

对象被添加到 Link 之后,handle 的 stack 属性被赋值为 null,而在取出对象的时候,handle 的 stack 属性又再次被赋值回来,为什么这么做呢,岂不是很麻烦?如果 Stack 不再使用,期望被 GC 回收,发现 handle 中还持有 Stack 的引用,那么就无法被 GC 回收,从而造成内存泄漏。

到此为止,Recycler 如何回收对象的实现原理就全部分析完了,在多线程的场景下,Netty 考虑的还是非常细致的,Recycler 回收对象时向 WeakOrderQueue 中存放对象,从 Recycler 获取对象时,WeakOrderQueue 中的对象会作为 Stack 的储备,而且有效地解决了跨线程回收的问题。

Netty 里面有很多使用了对象池的地方,大部分是和内存分配ByteBuf相关的,它是通过统一封装了一个 ObjectPool 来使用的。

java网关服务性能提升利器:CPU绑定对象池_第3张图片


总结

本文详细介绍了SONA长连接网关中是如何使用 Netty 中的Recycler 来实现对象池化,提升系统性能 ,在后续的系列文章中会对网关中的其他技术细节进行详细的介绍。

目前sona已经在比心的github仓库上开源,仓库地址:

GitHub - BixinTech/sona: Sona 平台是一个搭建语音房产品的全端解决方案,包含了房间管理、实时音视频、房间IM、长连接网关等能力。Sona 平台是一个搭建语音房产品的全端解决方案,包含了房间管理、实时音视频、房间IM、长连接网关等能力。 - GitHub - BixinTech/sona: Sona 平台是一个搭建语音房产品的全端解决方案,包含了房间管理、实时音视频、房间IM、长连接网关等能力。https://github.com/BixinTech/sona

欢迎你访问我们的项目,有任何想交流的想法可以留言联系我们。

你可能感兴趣的:(SONA聊天室,后端,java,websocket,实时音视频)