Netty详解(一):ByteBuf介绍

每当需要传输数据时,就必须有一个buffer缓冲区。Java NIO API自带的ByteBuffer操作繁琐,没有自动回收机制。

Netty的ByteBuf等同于JDK的ByteBuffer,在进行channel数据传输时,ByteBuf缓冲区组件相比于ByteBuffer更加快捷更加高效地操作内存缓冲区。

ByteBuf的组成

ByteBuf是一个字节容器,内部是一个字节数组。从逻辑上分,分为四部分:

在这里插入图片描述

三个重要属性

  • readerIndex(读指针):指示读取的起始位置
  • writerIndex(写指针):指示写入的起始位置
  • maxCapacity(最大容量):表示ByteBuf可以扩容的最大容量

在这里插入图片描述

ByteBuf分配器

Netty通过ByteBufAllocator分配器来创建缓冲区对象和分配内存空间。Netty提供了两种形式的分配器实现:PoolByteBufAllocatorUnpooledByteBufAllocator

  1. PoolByteBufAllocator(池化的ByteBuf分配器)将ByteBuf实例放入池中,提高了性能,将内存碎片减少到最小;池化分配器采用了jemalloc高效内存分配的策略,该策略被好几种现代操作系统所采用。
  2. UnpooledByteBufAllocator(普通的未池化ByteBuf分配器)它没有把ByteBuf放入池中,每次被调用时,返回一个新的ByteBuf实例;使用完之后,通过Java的垃圾回收机制回收或者直接释放(对于直接内存而言)。

在Netty中,默认的分配器为ByteBufAllocator.DEFAULT,该默认的分配器可以通过系统参数(System Property)选项io.netty.allocator.type进行配置,配置时使用字符串值:unpooledpooled。不同的Netty版本,默认分配器是不一样的。在初始化代码在ByteBufUtil类中的静态代码中

public final class ByteBufUtil {
    static {
        MAX_BYTES_PER_CHAR_UTF8 = (int)CharsetUtil.encoder(CharsetUtil.UTF_8).maxBytesPerChar();
        String allocType = SystemPropertyUtil.get("io.netty.allocator.type", PlatformDependent.isAndroid() ? "unpooled" : "pooled");
        allocType = allocType.toLowerCase(Locale.US).trim();
        Object alloc;
        if ("unpooled".equals(allocType)) {
            alloc = UnpooledByteBufAllocator.DEFAULT;
            logger.debug("-Dio.netty.allocator.type: {}", allocType);
        } else if ("pooled".equals(allocType)) {
            alloc = PooledByteBufAllocator.DEFAULT;
            logger.debug("-Dio.netty.allocator.type: {}", allocType);
        } else {
            alloc = PooledByteBufAllocator.DEFAULT;
            logger.debug("-Dio.netty.allocator.type: pooled (unknown: {})", allocType);
        }

        ......
    }
}

现在PooledByteBufAllocator已经广泛使用了一段时间,并且有了增强的缓冲区泄漏追踪机制。因此,也可以在Netty程序中设置引导类Bootstrap装配的时候,将PooledByteBufAllocator设置为默认的分配器。

ServerBootstrap b = new ServerBootstrap()
//4 设置通道的参数,设置长连接
b.option(ChannelOption.SO_KEEPALIVE, true);
//设置父通道的缓冲区分配器
b.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT);
//设置子通道的缓冲区分配器
b.childOption(ChannelOption.ALLOCATOR,PooledByteBufAllocator.DEFAULT);

......

// 使用默认分配器分配
ByteBuf buffer = null;
//初始容量为 9,最大容量 100 的缓冲区
buffer = ByteBufAllocator.DEFAULT.buffer(9, 100);

//初始容量为 256,最大容量 Integer.MAX_VALUE 的缓冲区
buffer = ByteBufAllocator.DEFAULT.buffer();

ByteBuf类型

使用Netty时会遇到3种不同类型的ByteBuf,也就是堆缓冲区(Heap ByteBuf)、直接缓冲区(Direct ByteBuf),为了方便缓冲区进行组合,还提供了一种组合缓冲区(CompositeByteBuf)。

类型说明优点不足
Heap ByteBuf内部数据为一个Java数组, 存储在JVM的堆空间中,可以通过hasArray方法来判断是不是堆缓冲区内部数据为一个Java数组, 存储在JVM的堆空间中,可以通过hasArray方法来判断是不是堆缓冲区写入底层传输通道之前,都会复制到直接缓冲区
Direct ByteBuf内部数据存储在操作系统的物理内存中能获取超过JVM堆限制大小的内存空间;写入传输通道比堆缓冲区更快释放和分配空间昂贵(使用了操作系统的方法);在Java中读取数据时,需要复 制一次到堆上
CompositeByteBuf多个缓冲区的组合表示方便一次操作多个缓冲区实例

上面三种缓冲区的类型,无论哪一种,都可以通过池化(Pooled)、非池化(Unpooled)两种分配器来创建和分配内存空间。

	@Test
    public void testHeapBuffer() {
        ByteBuf heapBuf = null;
        try {
            // 分配堆缓冲区
            heapBuf = ByteBufAllocator.DEFAULT.heapBuffer();
            heapBuf.writeBytes("堆缓冲区".getBytes(CharsetUtil.UTF_8));

            if (heapBuf.hasArray()) {
                byte[] bytes = heapBuf.array();
                int offset = heapBuf.arrayOffset() + heapBuf.readerIndex();
                int length = heapBuf.readableBytes();
                System.out.println(new String(bytes, offset, length, CharsetUtil.UTF_8)); // 堆缓冲区
            }
        } finally {
            Optional.ofNullable(heapBuf).ifPresent(buf -> buf.release());
        }
    }

    @Test
    public void testDirectBuffer() {
        ByteBuf directBuf = null;
        try {
            // 分配直接缓冲区
            directBuf = ByteBufAllocator.DEFAULT.directBuffer();
            directBuf.writeBytes("直接缓冲区".getBytes(CharsetUtil.UTF_8));

            if (!directBuf.hasArray()) {
                int length = directBuf.readableBytes();
                byte[] bytes = new byte[length];
                // 将直接缓冲区数据读到堆内存中
                directBuf.getBytes(directBuf.readerIndex(), bytes);

                System.out.println(new String(bytes, CharsetUtil.UTF_8)); // 直接缓冲区
            }
        } finally {
            Optional.ofNullable(directBuf).ifPresent(buf -> buf.release());
        }
    }

    @Test
    public void testCompositeBuffer() {
        CompositeByteBuf compositeBuf = null;
        ByteBuf heapBuf1 = null;
        ByteBuf heapBuf2 = null;
        try {
            // 分配两个堆缓冲区
            heapBuf1 = ByteBufAllocator.DEFAULT.heapBuffer();
            heapBuf2 = ByteBufAllocator.DEFAULT.heapBuffer();
            heapBuf1.writeBytes("堆缓冲区-1".getBytes(CharsetUtil.UTF_8));
            heapBuf2.writeBytes("堆缓冲区-2".getBytes(CharsetUtil.UTF_8));

            // 分配composite缓冲区
            compositeBuf = ByteBufAllocator.DEFAULT.compositeBuffer();
            compositeBuf.addComponents(heapBuf1, heapBuf2);

            StringBuffer sBuffer = new StringBuffer();
            for (ByteBuf buf : compositeBuf) {
                byte[] array = buf.array();
                int offset = buf.arrayOffset() + buf.readerIndex();
                int length = buf.readableBytes();
                sBuffer.append(new String(array, offset, length, CharsetUtil.UTF_8));
                sBuffer.append("\t");
            }
            System.out.println(sBuffer.toString()); // 堆缓冲区-1	堆缓冲区-2

        } finally {
            Optional.ofNullable(compositeBuf).ifPresent(buf -> buf.release());
        }
    }

ByteBuf引用计数

JVM中使用“计数器”(一种GC算法)来标记对象是否“不可达”进而收回(注:GC是Garbage Collection的缩写,即Java中的垃圾回收机制),Netty也使用了这种手段来对ByteBuf的引用进行计数,Netty的ByteBuf的内存回收工作是通过引用计数的方式管理的。
Netty之所以采用“计数器”来追踪ByteBuf的生命周期,一是能对Pooled ByteBuf的支持,二是能够尽快地“发现”那些可以回收的ByteBuf(非Pooled),以便提升ByteBuf的分配和销毁的效率。

什么是 Pooled(池化)的 ByteBuf 缓冲区呢?从 Netty4 版本开始,新增了 ByteBuf的池化机制。即创建一个缓冲区对象池,将没有被引用的 ByteBuf 对象,放入对象缓存池中;当需要时,则重新从对象缓存池中取出,而不需要重新创建。

在通信程序的数据传输过程中,Buffer缓冲区实例会被频繁创建、使用、释放,而频繁创建对象、内存分配、释放内存,这样导致系统的开销大、性能低,如何提升性能、提高Buffer实例的使用率呢?池化ByteBuf是一种非常有效的方式。
ByteBuf引用计数的大致规则如下:在默认情况下,当创建完一个ByteBuf时,它的引用为1;每次调用retain()方法,它的引用就加1;每次调用release()方法,就是将引用计数减1;如果引用为0,再次访问这个ByteBuf对象,将会抛出异常;如果引用为0,表示这个ByteBuf没有哪个进程引用它,它占用的内存需要回收。

	@Test
    public void testRef() {
        ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer();
        System.out.println("after create:" + buffer.refCnt());
        buffer.retain(); //增加一次引用计数
        System.out.println("after retain:" + buffer.refCnt());
        buffer.release(); //减少一次引用计数
        System.out.println("after release:" + buffer.refCnt());
        buffer.release(); //减少一次引用计数
        System.out.println("after release:" + buffer.refCnt());
        //错误:refCnt: 0,不能再 retain
        buffer.retain(); //增加一次引用计数
        System.out.println("after retain:" + buffer.refCnt());
    }

输出:
after create:1
after retain:2
after release:1
after release:0
io.netty.util.IllegalReferenceCountException: refCnt: 0, increment: 1
......

ByteBuf的自动创建与自动释放

首先来一个问题:在pipeline入站处理时,Netty是何时自动创建入站的ByteBuf缓冲区呢?

  1. ByteBuf的自动创建

    查看Netty源代码,我们可以看到,Netty的Reactor反应器线程会通过底层的Java NIO通道读数据,发生NIO读取的方法为AbstractNioByteChannel.NioByteUnsafe.read()方法

    在这里插入图片描述

    分配缓冲区的时候,为啥要进行大小的计算呢?从通道里读取时,是不知道具体的接收数据大小的,那么申请的缓冲区具体要多大呢?首先不能太大,太大了浪费,其次也不能太小你,太小了又不够,可能要涉及扩容,性能不好,所以申请的缓冲区大小需要推测,Netty设计了一个RecvByteBufAllocator大小推测接口和一系列的大小推测实现类,帮助进行缓冲区大小的计算和推测。默认的缓冲区大小推测实现类为AdaptiveRecvByteBufAllocator,其特点是能够根据上一次接收数据的大小,来自动调整下一次缓冲区建立时分配的空间大小,从而帮助避免内存的浪费。

再来一个问题:在入站处理完成时,入站的ByteBuf是如何自动释放的呢?

  1. 自动释放方式一:TailContext自动释放

    Netty默认会在ChannelPipline通道流水线的最后添加一个TailContext尾部上下文(也是一个入站处理器),它实现了默认的入站处理方法,在这些方法中会帮助完成ByteBuf内存释放的工作,具体如下图所示:

    在这里插入图片描述

  2. 自动释放方式二:SimpleChannelInboundHandler自动释放

    以入站读数据为例,Handler业务处理器可以继承自SimpleChannelInboundHandler基类,此时必须将业务处理代码,移动到重写的channelRead0(ctx, msg)方法中。SimpleChannelInboundHandle类的入站处理方法(如channelRead等),会在调用完实际的channelRead0(…) 方法后,帮忙释放ByteBuf实例。

    在这里插入图片描述

高级使用:ByteBuf的浅层复制

首先说明一下,浅层复制是一种非常重要的操作。可以很大程度地避免内存复制。这一点对于大规模消息通信来说是非常重要的。ByteBuf的浅层复制分为两种,有切片(slice)浅层复制和整体(duplicate)浅层复制。

  1. slice切片浅层复制

    ByteBuf的slice方法可以获取到一个ByteBuf的一个切片。一个ByteBuf可以进行多次的切片浅层复制;多次切片后的ByteBuf对象可以共享一个存储区域。

    slice方法有两个重载版本:

    • public ByteBuf slice()
    • public ByteBuf slice(int index, int length)

    在这里插入图片描述

    也就是说,第一个无参数slice方法的返回值是ByteBuf实例中可读部分的切片。而带参数的slice(int index, int length) 方法,可以通过灵活地设置不同起始位置和长度,来获取到ByteBuf不同区域的切片。

    	@Test
        public void sliceTest() {
            ByteBuf byteBuf = null;
            ByteBuf slice = null;
            try {
                byteBuf = Unpooled.buffer(9, 100);
                byteBuf.writeBytes(new byte[]{1, 2, 3, 4});
                System.out.println("init: " + byteBuf);
    
                byteBuf.retain();
                slice = byteBuf.slice();
                System.out.println("slice: " + slice);
    
                // 读取byte,会改变slice的readerIndex + 1
                byte readByte = slice.readByte();
                System.out.println("byteBuf after read: " + byteBuf);
                System.out.println("slice after read: " + slice);
    
            } finally {
                Optional.ofNullable(byteBuf).ifPresent(buf -> buf.release());
                Optional.ofNullable(slice).ifPresent(buf -> buf.release());
            }
        }
    
    
    输出:
    init: UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf(ridx: 0, widx: 4, cap: 9/100)
        
    slice: UnpooledSlicedByteBuf(ridx: 0, widx: 4, cap: 4/4, unwrapped: UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf(ridx: 0, widx: 4, cap: 9/100))
        
    byteBuf after read: UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf(ridx: 0, widx: 4, cap: 9/100)
        
    slice after read: UnpooledSlicedByteBuf(ridx: 1, widx: 4, cap: 4/4, unwrapped: UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf(ridx: 0, widx: 4, cap: 9/100))
    

    单元测试第二条输出结果可知,切片slice相当于一个独立的只可读(ridx: 0)不可写( widx: 4, cap: 4/4)的副本,副本的读操作导致readerIndex的改变不会影响源ByteBuf,所以对于源ByteBuf多次读,可以创建多个slice浅层复制。但是源ByteBuf引用计数为零,将导致slice不可用,后面介绍。另外,切片不会复制源ByteBuf的底层数据,底层数组和源ByteBuf的底层数组是同一个。

  2. duplicate整体浅层复制

    和slice切片不同,duplicate() 返回的是源ByteBuf的整个对象的一个浅层复制。duplicate() 和slice() 方法都是浅层复制。不同的是,slice()方法是切取一段的浅层复制,而duplicate( )是整体的浅层复制。

  3. 浅层复制的问题

    浅层复制方法不会实际去复制数据,也不会改变ByteBuf的引用计数,这就会导致一个问题:在源ByteBuf调用release() 之后,一旦引用计数为零,就变得不能访问了;在这种场景下,源ByteBuf的所有浅层复制实例也不能进行读写了;如果强行对浅层复制实例进行读写,则会报错。

    因此,在调用浅层复制实例时,可以通过调用一次retain() 方法来增加一次引用,表示它们对应的底层内存多了一次引用,此后引用计数为2。在浅层复制实例用完后,需要调用一次release()方法,将引用计数减1,这样就不影响Netty内部的ByteBuf的内存释放。

Netty的零拷贝

大部分的场景下,Netty的接收和发送ByteBuffer的过程中,一般来说会使用直接内存进行Socket通道读写,使用JVM的堆内存进行业务处理,会涉及到直接内存、堆内存之间的数据复制。但是,内存的数据复制,其实是效率非常低的,Netty提供了多种方法,帮助应用程序减少内存的复制。

Netty的零拷贝主要体现在五个方面:

  • Netty提供CompositeByteBuf组合缓冲区类, 可以将多个ByteBuf合并为一个逻辑上的ByteBuf, 避免了各个ByteBuf之间的拷贝。
  • Netty提供了ByteBuf的浅层复制操作(slice、duplicate),可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf, 避免内存的拷贝。
  • 在使用Netty进行文件传输时,可以调用FileRegion包装的transferTo方法,直接将文件缓冲区的数据发送到目标Channel,避免普通的循环读取文件数据和写入通道所导致的内存拷贝问题。
  • 在将一个byte数组转换为一个ByteBuf对象的场景,Netty提供了一系列的包装类,避免了转换过程中的内存拷贝。
  • 如果Channel接收和发送ByteBuf都使用direct直接内存进行Socket读写,不需要进
  • 行缓冲区的二次拷贝。但是,如果使用JVM的堆内存进行Socket读写,JVM会将堆内存Buffer拷贝一份到直接内存中,然后才写入Socket中,相比于使用直接内存,这种情况在发送过程中会多出一次缓冲区的内存拷贝。所以,在发送ByteBuffer到Socket时,尽量使用直接内存而不是JVM堆内存。

Netty 中的零拷贝和操作系统层面上的零拷贝是有区别的,不能混淆,我们所说的Netty 零拷贝完全是基于(Java 层面)或者说用户空间的,它的更多的是偏向于应用中的数据操作优化,而不是系统层面的操作优化。

  1. 通过CompositeByteBuf实现零拷贝

    CompositeByteBuf可以把需要合并的多个ByteBuf组合起来,对外提供统一的readIndex和writerIndex。CompositeByteBuf 只是逻辑上是一个整体,在CompositeByteBuf内部, 合并的多个ByteBuf都是单独存在的。CompositeByteBuf里面有个Component数组,聚合的ByteBuf都放在Component数组里面,最小容量为16。

    在很多通信编程场景下,需要多个ByteBuf组成一个完整的消息:例如HTTP协议传输时消息总是由Header(消息头)和Body(消息体)组成的。如果传输的内容很长,就会分成多个消息包进行发送,消息中的Header就需要重用,而不是每次发送都创建新的Header缓冲区。这时候可以使用CompositeByteBuf缓冲区进行ByteBuf组合,避免内存拷贝。

    假设有一份协议数据,它由头部和消息体组成,而头部和消息体是分别存放在两个ByteBuf中的, 为了方便后续处理,要将两个ByteBuf进行合并

    在这里插入图片描述

  2. 通过wrap操作实现零拷贝

    Unpooled了提供了一系列的wrap包装方法,帮助大家方便快速包装出CompositeByteBuf实例或者ByteBuf实例,而不用进行内存的拷贝。

    Unpooled包装CompositeByteBuf的操作,使用起来更加方便。例如,上一小节的header与body的组合,可以使用 Unpooled.wrappedBuffer方法。

    Unpooled类提供了很多重载的wrappedBuffer方法,将多个ByteBuf包装为CompositeByteBuf实例,从而实现零拷贝,这些重载方法大致如下:

    public static ByteBuf wrappedBuffer(ByteBuffer buffer)
    public static ByteBuf wrappedBuffer(ByteBuf buffer)
    public static ByteBuf wrappedBuffer(ByteBuf... buffers)
    public static ByteBuf wrappedBuffer(ByteBuffer... buffers)
    

    除了通过Unpooled包装CompositeByteBuf之外,还可以将byte数组包装成ByteBuf。如果将一个byte数组转换为一个ByteBuf对象,大致的代码如下

    byte[] bytes = ...
    ByteBuf byteBuf = Unpooled.wrappedBuffer(bytes);
    

    通过Unpooled.wrappedBuffer方法将bytes包装为一个UnpooledHeapByteBuf对象, 而在包装的过程中, 不会有拷贝操作的,所得到的ByteBuf对象是和bytes数组共用了同一个存储空间,对bytes的修改也就是对ByteBuf对象的修改。

    	@Test
        public void wrappedByteBufTest() {
            byte[] bytes = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9};
    
            // 将数组包装成ByteBuf
            ByteBuf byteBuf = Unpooled.wrappedBuffer(bytes);
            System.out.println(byteBuf);
    
            byte[] copy = new byte[9];
            byteBuf.getBytes(0, copy);
            for (byte b : copy) {
                System.out.print(b);
            }
            System.out.println();
    
            // 修改数组,ByteBuf内容跟着变
            bytes[1] = 5;
            System.out.println(byteBuf);
            byteBuf.getBytes(0, copy);
            for (byte b : copy) {
                System.out.print(b);
            }
            System.out.println();
    
            // 修改ByteBuf,数组跟着变。。。所以包装的ByteBuf底层只是拷贝了数组地址(浅拷贝),而不是拷贝内容
            byteBuf.setByte(0, 9);
            for (byte b : bytes) {
                System.out.print(b);
            }
            System.out.println();
    
            byteBuf.release();
        }
    
    输出:
    UnpooledHeapByteBuf(ridx: 0, widx: 9, cap: 9/9)
    123456789
    UnpooledHeapByteBuf(ridx: 0, widx: 9, cap: 9/9)
    153456789
    953456789
    
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

EzrealYi

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值