解读Netty之接收缓冲区

来源:互联网 发布:json 对日期格式化 编辑:程序博客网 时间:2024/06/10 01:40

概述

用过netty,大家都知道在请求处理之前,会有一个缓冲区用于接受数据,不同场景对缓冲的大小都不太一样。
比如UDP协议的DatagramChannel,默认缓冲区大小只给了2048,而假如开发一个SyslogUdp的协议服务,大小其实就不止这么点。

因此,缓冲区怎么用,怎么设置就非常关键啦,很不小心就会踩坑,本文主要给大家讲解下netty4下接受缓冲区的原理及对源码进行解读…

结构分解

Netty的接收缓冲区,是由接口类RecvByteBufAllocator实现而来,该类有个子接口Handle,包含了三个方法:

    interface Handle {        /**         * 创建一个合适大小的接收缓冲区(大到足够读取所有inbound数据,小到不会存在数据浪费)         */        ByteBuf allocate(ByteBufAllocator alloc);        /**         * 猜测这个缓冲区的容量,之所有是猜测,是因为存在动态扩容的一种缓冲区         */        int guess();        /**         * 记录上一次读操作实际读取的字节数,以便接收缓冲区能动态调整一个合适的容量         *         * @param actualReadBytes the actual number of read bytes in the previous read operation         */        void record(int actualReadBytes);    }

了解完基类,应该可以知道设计者的意图,提供多种灵活方式来创建接收缓冲区,比如固定空间大小的,空间动态扩容缩的等…

这个有什么好处呢,对比起JDK原生的NIO类库使用的java.nio.ByteBuffer,实际是一个固定长度的byte数组,这也说明原生buffer无法动态扩容,相关代码如下:

public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer>{    // These fields are declared here rather than in Heap-X-Buffer in order to    // reduce the number of virtual method invocations needed to access these    // values, which is especially costly when coding small buffers.    //    final byte[] hb;                  // Non-null only for heap buffers    final int offset;    boolean isReadOnly;                 // Valid only for heap buffers

讲好处前先踩踩原生ByteBuffer固定空间的坏处,例如开发人员一开始很难预测到每条消息报文的长度,或者消息堆积空间所需大小,当然你可以说干脆直接分配一个比较大的ByteBuffer,这通常没问题,不过对于海量推送、高并发等场景,这会给服务器带来沉重的内存负担,也算是一种资源浪费啊。
举个详细点例子:例如海量推送服务,单条消息若最大上限是16K,消息平均大小是6K,为了满足消息支持16K的处理,我们需要把buffer设置成16K,这样子的话,因是海量链路推送,那么假如并发链接数为100w,每个链路都有独立的ByteBuffer接收缓冲区,那将会额外损耗的总内存为:100,0000 * (16K-6K) = 9764M。是不是吓一跳,竟然快要消耗一个G的内存了,大内存不仅增加硬件成本,而且会导致长时间的FGC,对系统的维护和稳定保障带来非常大的冲击。

那讲完坏处,相比好处就显而易见啦,能灵活调整内存空间大小,是一件多么棒的事情。实际上RecvByteBufAllocator提供了两种实现,分别是:AdaptiveRecvByteBufAllocator和FixedRecvByteBufAllocator

我们先来看看简单的FixedRecvByteBufAllocator,代码逻辑如下:

public class FixedRecvByteBufAllocator implements RecvByteBufAllocator {    private static final class HandleImpl implements Handle {        private final int bufferSize;        HandleImpl(int bufferSize) {            this.bufferSize = bufferSize;        }        @Override        public ByteBuf allocate(ByteBufAllocator alloc) {            return alloc.ioBuffer(bufferSize);        }        @Override        public int guess() {            return bufferSize;        }        @Override        public void record(int actualReadBytes) { }    }    private final Handle handle;    /**     * Creates a new predictor that always returns the same prediction of     * the specified buffer size.     */    public FixedRecvByteBufAllocator(int bufferSize) {        if (bufferSize <= 0) {            throw new IllegalArgumentException(                    "bufferSize must greater than 0: " + bufferSize);        }        handle = new HandleImpl(bufferSize);    }    @Override    public Handle newHandle() {        return handle;    }}

实现非常简单,FixedRecvByteBufAllocator提供了一个固定长度的接收缓冲区,就好比与JDK原生的ByteBuffer,跳过吧…
PS:这里担心大家理解有误,追加下说明,虽然其ByteBuf长度是固定,但当容量不足时,因Netty-ByteBuf本身的特性,还是会进行动态扩展的。

重点是另外一个,AdaptiveRecvByteBufAllocator,看官方说法,是一个能根据以往接受的消息进行计算,动态调整内存,利用CPU资源来换取内存资源,具体的实现策略如下:根据之前Channel接收到的数据包大小进行计算,如果连续填充满接收缓冲区的可写空间,则动态扩展容量。如果连续2次接收到的数据包都小于指定值,则收缩当前的容量,以节约内存。具体使用时,代码如下:

        Bootstrap serverBootstrap = new Bootstrap();        serverBootstrap.group(nioEventLoopGroup);        serverBootstrap.channel(NioDatagramChannel.class);        serverBootstrap.option(ChannelOption.RCVBUF_ALLOCATOR,                new AdaptiveRecvByteBufAllocator(DEFAULT_MIN_SIZE, DEFAULT_INITIAL_SIZE, DEFAULT_MAX_SIZE));        //或者使用如下的方式,用默认的分配器        //option(ChannelOption.RCVBUF_ALLOCATOR, AdaptiveRecvByteBufAllocator.DEFAULT);

值得注意的是,无论是接收缓冲区还是接收缓冲区,大小都建议设置为消息的平均大小,不要设置成消息的最大上限,通过如下方式可设置缓冲区的初始大小:

    /**     * Creates a new predictor with the specified parameters.     *     * @param minimum  the inclusive lower bound of the expected buffer size     * @param initial  the initial buffer size when no feed back was received     * @param maximum  the inclusive upper bound of the expected buffer size     */    public AdaptiveRecvByteBufAllocator(int minimum, int initial, int maximum) {

从如上AdaptiveRecvByteBufAllocator的构造方法,可得知其需要三个参数,当然该接收缓冲区也有默认的实现:

    static final int DEFAULT_MINIMUM = 64;    static final int DEFAULT_INITIAL = 1024;    static final int DEFAULT_MAXIMUM = 65536;    public static final AdaptiveRecvByteBufAllocator DEFAULT = new AdaptiveRecvByteBufAllocator();    /**     * Creates a new predictor with the default parameters.  With the default     * parameters, the expected buffer size starts from {@code 1024}, does not     * go down below {@code 64}, and does not go up above {@code 65536}.     */    private AdaptiveRecvByteBufAllocator() {        this(DEFAULT_MINIMUM, DEFAULT_INITIAL, DEFAULT_MAXIMUM);    }

那具体内部的实现逻辑是怎么做到的呢,让我们来一步步看下源码逻辑:

首先第一步,需要进行静态代码块初始化:

    private static final int INDEX_INCREMENT = 4;    private static final int INDEX_DECREMENT = 1;    private static final int[] SIZE_TABLE;    static {        List<Integer> sizeTable = new ArrayList<Integer>();        for (int i = 16; i < 512; i += 16) {            sizeTable.add(i);        }        for (int i = 512; i > 0; i <<= 1) {            sizeTable.add(i);        }        SIZE_TABLE = new int[sizeTable.size()];        for (int i = 0; i < SIZE_TABLE.length; i ++) {            SIZE_TABLE[i] = sizeTable.get(i);        }    }

分配了一个int类型的数组,并进行该数组的初始化处理,从实现来看,该数组的长度是53,前32位是16的倍数,value值是从16开始,到512;从第33位开始,值是前一位的两倍,即从1024、2048、到最大值1073741824。

那我们来看下,AdaptiveRecvByteBufAllocator的构造方法是怎样的:

    public AdaptiveRecvByteBufAllocator(int minimum, int initial, int maximum) {        if (minimum <= 0) {            throw new IllegalArgumentException("minimum: " + minimum);        }        if (initial < minimum) {            throw new IllegalArgumentException("initial: " + initial);        }        if (maximum < initial) {            throw new IllegalArgumentException("maximum: " + maximum);        }        int minIndex = getSizeTableIndex(minimum);        if (SIZE_TABLE[minIndex] < minimum) {            this.minIndex = minIndex + 1;        } else {            this.minIndex = minIndex;        }        int maxIndex = getSizeTableIndex(maximum);        if (SIZE_TABLE[maxIndex] > maximum) {            this.maxIndex = maxIndex - 1;        } else {            this.maxIndex = maxIndex;        }        this.initial = initial;    }

构造器需要三个参数,第一个是缓冲区预期的最小长度下限;第二个是初始化时的大小,最后一个是缓冲区期望的最大长度上限
从代码逻辑可见,minimum不能大于初始化长度initial,而初始化长度initial不能超过maximum

上面逻辑中,还关乎了一个关键方法,getSizeTableIndex,让我们进一步看看该方法主要干了啥:

    private static int getSizeTableIndex(final int size) {        for (int low = 0, high = SIZE_TABLE.length - 1;;) {            if (high < low) {                return low;            }            if (high == low) {                return high;            }            int mid = low + high >>> 1;            int a = SIZE_TABLE[mid];            int b = SIZE_TABLE[mid + 1];            if (size > b) {                low = mid + 1;            } else if (size < a) {                high = mid - 1;            } else if (size == a) {                return mid;            } else {                return mid + 1;            }        }    }

入参是一个大小,然后利用二分查找法对该数组进行size的定位,目标是为了找出该size值在数组中的下标位置,该构造方法,最终初始化了如下是三个参数:

    private final int minIndex;    private final int maxIndex;    //注意最后一个参数没有进行下标查找    private final int initial;

到目前为止,我们大致了解了,该AdaptiveRecvByteBufAllocator的内部的一些数据结构,那具体的处理过程是怎样的,详见如下的Hander:

    private static final class HandleImpl implements Handle {        private final int minIndex;        private final int maxIndex;        private int index;        private int nextReceiveBufferSize;        private boolean decreaseNow;        HandleImpl(int minIndex, int maxIndex, int initial) {            this.minIndex = minIndex;            this.maxIndex = maxIndex;            index = getSizeTableIndex(initial);            nextReceiveBufferSize = SIZE_TABLE[index];        }        @Override        public ByteBuf allocate(ByteBufAllocator alloc) {            return alloc.ioBuffer(nextReceiveBufferSize);        }        @Override        public int guess() {            return nextReceiveBufferSize;        }        ...    }

该实现类,有一个关键的参数nextReceiveBufferSize,在其构造器中可见,用initial获取一开始初始化缓冲的下标,在根据SIZE_TABLE查找应分配的BufferSize。
进一步往下看,在实际进行分配时,分配大小传递的是nextReceiveBufferSize这个参数,由此可见,该参数是每次调整容量的关键。

那具体是谁在更新这个参数呢,我们进一步看HanderImpl类的最后一个方法:

        @Override        public void record(int actualReadBytes) {            if (actualReadBytes <= SIZE_TABLE[Math.max(0, index - INDEX_DECREMENT - 1)]) {                if (decreaseNow) {                    index = Math.max(index - INDEX_DECREMENT, minIndex);                    nextReceiveBufferSize = SIZE_TABLE[index];                    decreaseNow = false;                } else {                    decreaseNow = true;                }            } else if (actualReadBytes >= nextReceiveBufferSize) {                index = Math.min(index + INDEX_INCREMENT, maxIndex);                nextReceiveBufferSize = SIZE_TABLE[index];                decreaseNow = false;            }        }

该方法的参数是一次读取操作中实际读取到的数据大小,将其与nextReceiveBufferSize 进行比较,如果实际字节数actualReadBytes大于等于该值,则立即更新nextReceiveBufferSize ,其更新后的值与INDEX_INCREMENT有关。INDEX_INCREMENT为默认常量,值为4。也就是说在扩容时会一次性增大多一些,以保证下次有足够空间可以接收数据。而相对扩容的策略,缩容策略则实际保守些,常量为INDEX_INCREMENT,值为1,同样也是进行对比, 但不同的是,若实际字节小于所用nextReceiveBufferSize,并不会立马进行大小调整,而是先把 decreaseNow 设置为true,如果下次仍然小于,则才会减少nextReceiveBufferSize的大小。

看到这里,我们大概了解了动态接收缓冲区的实现逻辑,本质是利用了预先分配好的SIZE_TABLE数组来进行空间大小的分配。

如何实战

讲到具体的使用,我们先从请求协议的角度来看待Netty4对于接收缓冲区是如何做默认处理的。

先来看看UDP协议,来一段bootstrap的最基本逻辑:

EventLoopGroup group = new NioEventLoopGroup();        try {            Bootstrap b = new Bootstrap();            b.group(group)             .channel(NioDatagramChannel.class)             .handler(new XXXXXXServerHandler());            b.bind(PORT).sync().channel().closeFuture().await();        } finally {            group.shutdownGracefully();        }

如上代码,就是一般最常见的创建基于UDP协议服务端的启动代码,进一步看看NioDatagramChannel,其是如何创建接收缓冲区的:
具体如何运行到该类的创建,有兴趣的同学,具体可以跟着代码逻辑从bind->doBind->initAndRegister->newChannel->newInstance。

    private static final RecvByteBufAllocator DEFAULT_RCVBUF_ALLOCATOR = new FixedRecvByteBufAllocator(2048);    /**     * Create a new instance which will use the Operation Systems default {@link InternetProtocolFamily}.     */    public NioDatagramChannel() {        this(newSocket(DEFAULT_SELECTOR_PROVIDER));    }    public NioDatagramChannel(DatagramChannel socket) {        super(null, socket, SelectionKey.OP_READ);        config = new NioDatagramChannelConfig(this, socket);    }

如上可见,创建UDP协议服务端时,其底层默认就是使用了FixedRecvByteBufAllocator,并且长度上只分配了2048的空间,用的时候要注意啦!

好,那TCP协议的流程又是怎样的,同样让我们看一段TCP协议的boostrap代码:

        EventLoopGroup bossGroup = new NioEventLoopGroup(1);        EventLoopGroup workerGroup = new NioEventLoopGroup();        try {            ServerBootstrap b = new ServerBootstrap();            b.group(bossGroup, workerGroup)             .channel(NioServerSocketChannel.class)             .handler(new LoggingHandler(LogLevel.INFO))             .childHandler(new SocksServerInitializer());            b.bind(PORT).sync().channel().closeFuture().sync();        } finally {            bossGroup.shutdownGracefully();            workerGroup.shutdownGracefully();        }

可见看到的是,其会创建两个group,对reactor模型有认识的,应该会知道两个group对应着两种Channel通道
bossGroup对应NioServerSocketChannel,而workerGroup对应NioSocketChannel,那NioSocketChannel通道对应的缓冲区又是怎样搞的呢?
那首先从workerGroup的线程run方法起,当然这里不展开,具体会运行到processSelectedKey,并进行请求的读取

        @Override        public final void read() {            final ChannelConfig config = config();            if (!config.isAutoRead() && !isReadPending()) {                // ChannelConfig.setAutoRead(false) was called in the meantime                removeReadOp();                return;            }            final ChannelPipeline pipeline = pipeline();            final ByteBufAllocator allocator = config.getAllocator();            final int maxMessagesPerRead = config.getMaxMessagesPerRead();            RecvByteBufAllocator.Handle allocHandle = this.allocHandle;            if (allocHandle == null) {                this.allocHandle = allocHandle = config.getRecvByteBufAllocator().newHandle();            }            ByteBuf byteBuf = null;            int messages = 0;            boolean close = false;            try {                int totalReadAmount = 0;                boolean readPendingReset = false;                do {                    byteBuf = allocHandle.allocate(allocator);

如上可得知,实际的接受ByteBuf分配器是在config方法中获取的,我们进入该方法:

    private static final RecvByteBufAllocator DEFAULT_RCVBUF_ALLOCATOR = AdaptiveRecvByteBufAllocator.DEFAULT;    private volatile RecvByteBufAllocator rcvBufAllocator = DEFAULT_RCVBUF_ALLOCATOR;    @Override    public RecvByteBufAllocator getRecvByteBufAllocator() {        return rcvBufAllocator;    }

实际中发现,该接收缓冲区的分配器,是采用了默认的AdaptiveRecvByteBufAllocator的默认策略。

大致了解了TCP和UDP的不同默认分配规则,若我们有场景需要自己创建,则可用如下代码来实现

    .option(ChannelOption.RCVBUF_ALLOCATOR, AdaptiveRecvByteBufAllocator.DEFAULT);    .option(ChannelOption.RCVBUF_ALLOCATOR, new FixedRecvByteBufAllocator(DEFAULT_SIZE));    .option(ChannelOption.RCVBUF_ALLOCATOR,                new AdaptiveRecvByteBufAllocator(DEFAULT_MIN_SIZE, DEFAULT_INITIAL_SIZE, DEFAULT_MAX_SIZE));
0 0
原创粉丝点击