NettyのFuture&Promise、Handler&Pipeline、ByteBuf

本篇介绍Netty的剩下三个组件Future&Promise、Handler&Pipeline、ByteBuf

**1、**Future&Promise

Future和Promise都是Netty实现异步的组件。

1.1、JDK中的future

在JDK中也有一个同名的Future,通常是配合多线程的Callable以及线程池的submit()方法使用,通过Future的get()方法在主线程处阻塞等待异步处理的结果:

复制代码
@Slf4j
public class TestJDKFuture {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService service = Executors.newFixedThreadPool(2);

        Future<Integer> future = service.submit(new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                log.debug("执行call方法");
                Thread.sleep(1000);
                return 30;
            }
        });

        log.debug("main 线程运行");
        //会阻塞
        log.debug("获取到的结果是:{}",future.get());
        log.debug("main 线程执行其他逻辑");
    }
}

在get()方法拿到线程池中任务的返回结果后,主线程结束阻塞才执行后续的逻辑:

1.2、Netty中的future

Netty中的Future,相比较JDK中的Future做出了增强,增强点在:

  • getNow()方法立刻得到异步处理结果,还未产生结果时返回 null。

  • addLinstener()添加回调,异步接收结果,主线程不会阻塞。

    @Slf4j
    public class TestNettyFuture {
    public static void main(String[] args) {
    EventLoopGroup group = new NioEventLoopGroup();

    复制代码
          EventLoop eventLoop = group.next();
    
          Future<Integer> future = eventLoop.submit(() -> {
              log.debug("执行call方法");
              Thread.sleep(1000);
              return 30;
          });
    
          //由执行call方法的线程异步获取结果,不会阻塞主线程
          future.addListener(future1 -> log.debug("获取到的结果:{}", future1.getNow()));
          log.debug("main 线程执行");
      }

    }

1.3、Netty中的promise

在上面的案例中,无论是JDK和Netty的Future,都是被动地接受多线程任务异步返回的结果。

而promise相当于一个容器,可以主动地设置多线程任务异步返回成功或失败的结果:

复制代码
@Slf4j
public class TestPromise {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        EventLoop eventLoop = new NioEventLoopGroup().next();

        DefaultPromise<Object> promise = new DefaultPromise<>(eventLoop);

        new Thread(() -> {
            try {
                log.debug("开始计算");
//                int i = 1 / 0;
                //设置成功结果
                promise.setSuccess(30);
            } catch (Exception e) {
                e.printStackTrace();
                //设置失败结果
                promise.setFailure(e);
            }
        }, "test").start();

        //会一直阻塞直到获取到最终结果
        log.debug("获取到的结果:{}",promise.get());
        log.debug("main 线程执行其他代码");
    }
}

没有发生异常,返回成功的结果:

将代码中的int i = 1 / 0 放开,发生异常,返回失败的结果:

2、Handler & Pipeline

Handler是Netty中的一个接口,用于处理特定类型的事件或数据,主要分为:

  • ChannelInboundHandler:处理入站事件和数据
  • ChannelOutboundHandler:处理出站数据和事件。

什么是入站和出站?

入站指的是数据从外部(例如客户端)进入到服务器(或其他网络组件)内部的过程。在Netty中,入站数据通常包括接收自网络的字节流、连接事件、解码后的消息等。(服务器接受客户端的消息数据,消息进入服务器)

出站指的是数据从内部(例如服务器)发送到外部(例如客户端)的过程。在Netty中,出站数据通常包括编码后的消息、写入网络的字节流等。(服务器向客户端发送数据消息,消息从服务器发出)

而Pipeline类似于流水线,由多个Handler组成,Handler可以理解成流水线上的各道工序。是Netty中的一个数据通道,负责管理和组织多个Handler的执行顺序。当一个Channel被创建时,Netty会自动创建一个与之关联的Pipeline。

相关案例:

复制代码
@Slf4j
public class PipelineDemo1 {
    public static void main(String[] args) {
        new ServerBootstrap()
                .group(new NioEventLoopGroup(),new NioEventLoopGroup(2))
                .channel(NioServerSocketChannel.class)
                .childHandler(new ChannelInitializer<NioSocketChannel>() {
                    /**
                     * head -> 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> tail
                     * @param ch
                     * @throws Exception
                     */
                    @Override
                    protected void initChannel(NioSocketChannel ch) throws Exception {
                        ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){
                            @Override
                            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                log.debug("1");
                                super.channelRead(ctx,msg);
                            }
                        });
                        ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){
                            @Override
                            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                log.debug("2");
                                super.channelRead(ctx,msg);
                            }
                        });
                        ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){
                            @Override
                            public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
                                log.debug("3");
                                super.channelRead(ctx,msg);
                                //向byteBuff写入字节
                                ch.writeAndFlush(ctx.alloc().buffer().writeBytes("abc".getBytes(StandardCharsets.UTF_8)));
                            }
                        });
                        ch.pipeline().addLast(new ChannelOutboundHandlerAdapter(){
                            @Override
                            public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
                                log.debug("4");
                                super.write(ctx, msg, promise);
                            }
                        });
                        ch.pipeline().addLast(new ChannelOutboundHandlerAdapter(){
                            @Override
                            public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
                                log.debug("5");
                                super.write(ctx, msg, promise);
                            }
                        });
                        ch.pipeline().addLast(new ChannelOutboundHandlerAdapter(){
                            @Override
                            public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
                                log.debug("6");
                                super.write(ctx, msg, promise);
                            }
                        });

                    }
                })
                .bind(8080);
    }
}

它的底层是一个双向链表:

我们看一下它的执行顺序:

会发现InBound事件是从前往后执行的,而outBound事件是从后往前执行的。

关于outBound事件的触发时机,为什么需要手动调用这段代码?

复制代码
ch.writeAndFlush(ctx.alloc().buffer().writeBytes("abc".getBytes(StandardCharsets.UTF_8)));

原因在于,入站事件是指从客户端到服务器的数据流动,这些事件是由Netty内部自动触发的,例如接收到数据、连接建立等。出站事件是由应用程序主动触发的操作,这些事件通常是由应用程序主动触发的操作,例如写入数据到Channel、刷新数据到网络。

案例中的这段代码,是利用了NioSocketChannel的.writeAndFlush方法,而ChannelHandlerContext也具有.writeAndFlush方法,两者之间有什么区别?

将案例中的代码进行替换:

复制代码
 ctx.writeAndFlush(ctx.alloc().buffer().writeBytes("abc".getBytes(StandardCharsets.UTF_8)));

结果是出站事件一个都没有触发,为什么?

原因在于它们的触发方式和传播机制:

  • ctx是上下文的实例,会从当前节点向前去找所有的outBound事件并且触发。
  • ch是整个连接通道的实例,会从尾部节点向前去找所有的outBound事件并且触发。

当前节点是在inBound3位置,它的前面没有任何outBound事件:

如果此时有一个outBound事件在inBound3之前,使用ctx去进行写入并刷新也是可以触发的:

将事件4放在事件3的前面:

在执行完所有inBound事件后回头执行了outBound事件:


在上面的案例中提到了,ctx是上下文的实例。调用链最初的ctx,可以从客户端接受消息,并且逐层传递给后一个调用链上的handle进行处理。那么它是如何通知后一个调用链进行处理的?

关键在于super.channelRead(ctx,msg); 它的内部调用了上下文实例的.fireChannelRead方法。这个方法大致的含义是唤醒下一个handle并且将ctx进行传递。

3、ByteBuf

ByteBuf可以理解成对于NIO中缓冲区ByteBuffer的增强,最大的区别在于:

  • ByteBuffer在创建时指定了最大容量后不可以扩容,而ByteBuf具有扩容机制。
  • ByteBuffer在写入/读取数据前需要进行读写模式切换,指针在读写模式下代表的含义也是不一样的,而ByteBuf内部维护了独立的读,写指针,互不干扰。
**3.1、**ByteBuf的初始容量

我们可以通过下面的api去创建一个ByteBuf:

复制代码
ByteBuf buffer = ByteBufAllocator.DEFAULT.buffer();

DEFAULT 是ByteBufAllocator接口中的一个成员变量:

它的来源又是ByteBufUtil中的成员变量DEFAULT_ALLOCATOR

在静态代码块中,会在类加载时给DEFAULT_ALLOCATOR 赋值,其数据来源会根据是否开启池化进行分派:(这里暂且知道有池化这回事,后续会进行说明)

假设目前是开启了池化,最终DEFAULT_ALLOCATOR 的值是新创建的PooledByteBufAllocator实例。

PooledByteBufAllocator 的类关系图:

最后调用的buffer();方法还是PooledByteBufAllocator 的父类AbstractByteBufAllocator中的

有三个重载的方法,它们的共同点都是通过directByDefault 的布尔值判断返回何种类型的Buffer(直接内存或堆内存)

如果我们没有指定大小,走的是第一个无参的方法,初始容量是256,最大容量是integer的最大值:

**3.2、**ByteBuf的扩容机制

ByteBuf的扩容,主要体现在buffer.writeBytes()方法上,在上面的案例中,它的实际类型是

class io.netty.buffer.PooledUnsafeDirectByteBuf

又调用了另一个writeBytes方法,传递了参数数组以及参数数组的长度。

再次进入会发现内部调用了三个方法:

研究扩容机制,我们重点关注ensureWritable方法

3.2.1、ensureWritable(length)

ensureWritable(length)方法的内部又调用了两个方法(方法的参数minWritableBytes

是将要写入的byte数组的长度):

  • checkPositiveOrZero(minWritableBytes, "minWritableBytes"):这个方法的目的是为了检查将要写入的byte数组长度的合法性:
  • ensureWritable0(int minWritableBytes):该方法是扩容逻辑的体现:

这段代码的含义是,判断当前byte数组的长度如果小于或等于可写入大小,则无需扩容直接返回:

复制代码
if (minWritableBytes <= writableBytes()) {
     return;
}

writableBytes() 方法会用当前bytebuf的容量-写指针的索引得到可写入大小

假设没有设置ByteBuf的容量,那么它的默认值此时是256,还没有进行写入,写指针的索引就在0位置。writableBytes()方法返回的是256 - 0 = 256。并且假设需要写入的byte数组的大小是300,那么300 > 256**(300比当前的容量大,但是小于最大容量)**,就不满足该if代码块,会继续向下执行,如果没有进入该if块就说明需要扩容了。


在这里有必要顺带着说明一下ByteBuf的数据结构:

  • Capacity(容量):是缓冲区中可以存储的最大字节数。
  • Reader Index(读索引):表示当前读操作的位置。
  • Writer Index(写索引):表示当前写操作的位置。
  • Max Capacity(最大容量):integer的最大值或自己设置。

可扩容字节 = 最大容量 - 容量。


然后会将当前写指针的位置(0)赋值给一个局部变量。

复制代码
final int writerIndex = writerIndex();

这段代码的含义是检查边界**(默认情况下都是要检查的,都会进入该if条件块)**:

如果当前需要写入的byte数组的大小比最大容量还要大,就会抛出异常。

复制代码
     if (checkBounds) {
            if (minWritableBytes > maxCapacity - writerIndex) {
                throw new IndexOutOfBoundsException(String.format(
                        "writerIndex(%d) + minWritableBytes(%d) exceeds maxCapacity(%d): %s",
                        writerIndex, minWritableBytes, maxCapacity, this));
            }
        }

这段代码是将写指针的索引+byte数组的长度赋值给一个新的变量**(此时的值是300)**

复制代码
int minNewCapacity = writerIndex + minWritableBytes;

然后会在这段代码中执行扩容的逻辑,调用calculateNewCapacity方法,传入byte数组的长度和最大容量:

复制代码
int newCapacity = alloc().calculateNewCapacity(minNewCapacity, maxCapacity);

选择第一个实现:

同样去进行条件检查:

复制代码
checkPositiveOrZero(minNewCapacity, "minNewCapacity");
  if (minNewCapacity > maxCapacity) {
       throw new IllegalArgumentException(String.format(
            "minNewCapacity: %d (expected: not greater than maxCapacity(%d)",
             minNewCapacity, maxCapacity));
  }

然后会将一块4MiB大小的区域赋值给变量threshold,如果当前数组容量和它相等,就直接返回。

复制代码
        final int threshold = CALCULATE_THRESHOLD; // 4 MiB page

        if (minNewCapacity == threshold) {
            return threshold;
        }

否则进入该if块:

  • 首先用当前数组容量(300)/ threshold * threshold (当前数组容量/threshold的整数倍)赋值给newCapacity。

  • 如果newCapacity + threshold > 最大容量,就将newCapacity设置成最大容量。

  • 否则newCapacity加上一个threshold。

    复制代码
        if (minNewCapacity > threshold) {
              int newCapacity = minNewCapacity / threshold * threshold;
              if (newCapacity > maxCapacity - threshold) {
                  newCapacity = maxCapacity;
              } else {
                  newCapacity += threshold;
              }
              return newCapacity;
          }

没有进入上面的if块就说明当前数组的容量要小于threshold,就会执行下面的while循环:

  • 从64开始翻倍,直到newCapacity大于等于当前数组的容量

    复制代码
         // Not over threshold. Double up to 4 MiB, starting from 64.
          int newCapacity = 64;
          while (newCapacity < minNewCapacity) {
              newCapacity <<= 1;
          }

最后会返回扩容后的数组容量和最大容量的小值。

复制代码
 return Math.min(newCapacity, maxCapacity);

总结:对于小的容量需求,使用指数增长方式;对于大的容量需求,采用线性增长方式。

案例中的300容量,最终会走while (newCapacity < minNewCapacity)的逻辑,也就是最终扩容的结果是从最初的256->512。

3.3、ByteBuf堆内存和直接内存

ByteBuf和ByteBuffer一样,都支持创建基于堆的ByteBuf和基于直接内存的ByteBuf 。

  • 基于堆的ByteBuf受JVM控制,由JVM在合适的时机进行垃圾回收。正是因为此,使用堆内存会增加垃圾回收器的负担,尤其是在大量分配和释放内存的场景中,可能会导致 GC 开销较高。
  • 基于直接内存的ByteBuf使用 JVM 堆外内存,需要手动在合适的时机进行回收,Netty 使用引用计数(Reference Counting)机制来管理直接内存的生命周期。

学过JVM的都知道,垃圾回收有两种机制,可达性分析和引用计数。在JVM中实际上默认也是使用可达性分析 的,这样做的目的是为了解决循环引用的问题。为什么在Netty中使用了引用计数 ?

Netty 选择引用计数的原因

  1. 引用计数法在回收垃圾时是实时的,当引用计数归零时,会立刻释放内存,无需等待下一次垃圾回收的触发。
  2. Netty中的内存结构,通常不涉及复杂的对象图和循环引用,引用计数的缺点在这种情况下影响较小。
  3. Netty 结合了内存池化机制,进一步减少了频繁分配和释放内存的开销。

那什么是内存池化机制,基于直接内存的ByteBuf又为什么通常配合内存池化机制 一起使用?

池化是一种基于复用的思想,类似于线程池,数据库连接池,我们通常会使用线程池去管理线程,而不是手动地去创建线程,有新任务被分配时,线程池会使用空余的线程去进行处理,处理完毕后则归还线程,避免大量地去创建线程。数据库连接池也是一样的道理。

如果ByteBuf没有利用内存池化机制 ,则每次都要创建新的实例,对于内存的影响较大。特别是基于直接内存的ByteBuf,**直接内存创建和销毁的代价昂贵 ,**频繁地创建销毁很影响效率。并且在高并发时,池化功能更节约内存,减少内存溢出的可能。


在上文中提到,如果使用直接内存的ByteBuf,需要手动地去释放内存。那么释放内存的时机是?

我们知道,pipeline是一种链式调用的结构,ByteBuf可能在多个handle之间传递。那么我们就应该在最后一个ByteBuf被使用,并且不再向下传递的handle中进行release操作。具体也可细分为以下几种情况:

  • 对原始 ByteBuf 不做处理,调用 ctx.fireChannelRead(msg) 向后传递,这时无须 release

  • 将原始 ByteBuf 转换为其它类型的 Java 对象,这时 ByteBuf 就没用了,必须 release

  • 如果不调用 ctx.fireChannelRead(msg) 向后传递,那么也必须 release

  • 注意各种异常,如果 ByteBuf 没有成功传递到下一个 ChannelHandler,必须 release

  • 假设消息一直向后传,那么 TailContext 会负责释放未处理消息(原始的 ByteBuf)

  • 出站消息最终都会转为 ByteBuf 输出,一直向前传,由 HeadContext flush 后 release。

这是尾节点的类,实现了入站处理器的接口:

其中的onUnhandledInboundMessage方法就是release的逻辑:

检查msg的类型是否属于ReferenceCounted及其子类。(ByteBuf实现了ReferenceCounted接口)

3.4、Slice&CompositeByteBuf

Slice和CompositeByteBuf也是ByteBuf中的两个方法,也是零拷贝的体现,下面通过案例的形式简单的介绍一下:

3.4.1、Slice

通过ByteBuf的Slice方法,将一个完整的byte数组切分成为了两个不同的部分,但是内存和完整的byte数组是同一个。

正因为如此,如果提前将完整数组手动release,则切片无法正常工作。

复制代码
/**
 * 切片
 */
public class SliceDemo1 {
    public static void main(String[] args) {
        ByteBuf origin = ByteBufAllocator.DEFAULT.buffer(10);
        origin.writeBytes(new byte[]{1, 2, 3, 4});
        MyByteBufUtil.log(origin);

        ByteBuf b1 = origin.slice(0, 2);
        ByteBuf b2 = origin.slice(2, 2);
        MyByteBufUtil.log(b1);
        MyByteBufUtil.log(b2);

        //slice 并没有切分内存
        System.out.println("**********************");
        b1.setByte(0,'a');
        MyByteBufUtil.log(origin);
        MyByteBufUtil.log(b1);


    }
}

可以看到对任何一个切片内容的修改,都会体现在完整的Byte上:

3.4.2、CompositeByteBuf

CompositeByteBuf是Slice的反向操作,将多个ByteBuf组合在一起,同样也不会开辟一份新的内存。

复制代码
public class CompositDemo {
    public static void main(String[] args) {
        ByteBuf b1 = ByteBufAllocator.DEFAULT.buffer();
        b1.writeBytes(new byte[]{'1','2'});


        ByteBuf b2 = ByteBufAllocator.DEFAULT.buffer();
        b2.writeBytes(new byte[]{'3','4'});


        //将多个ByteBuf组合在一起,同样也不会开辟一份新的内存
        //内部维护了一个 Component 数组,每个 Component 管理一个 ByteBuf
        //也就是说目前只存在b1和b2两块内存
        CompositeByteBuf compositeBuffer = ByteBufAllocator.DEFAULT.compositeBuffer();
        compositeBuffer.addComponents(true,b1,b2);
        MyByteBufUtil.log(compositeBuffer);
    }
}

4、双向通信综合案例

最后我们通过一个客户端,服务器互相发送消息的综合案例来总结一下上面的知识点:

服务器端,我们可以重点回顾一下这几个问题:

  1. ch.writeAndFlush 和 ctx.writeAndFlush 有什么区别?

  2. ChannelOutboundHandlerAdapter出站事件的触发时机以及顺序?

  3. ByteBuf手动release的时机?

    public class TestServer2 {
    public static void main(String[] args) {
    new ServerBootstrap()
    .group(new NioEventLoopGroup(),new NioEventLoopGroup())
    .channel(NioServerSocketChannel.class)
    .childHandler(new ChannelInitializer<NioSocketChannel>() {
    @Override
    protected void initChannel(NioSocketChannel ch) throws Exception {
    ChannelPipeline pipeline = ch.pipeline();
    pipeline.addLast(new StringDecoder(Charset.defaultCharset()));
    pipeline.addLast(new StringEncoder(Charset.defaultCharset()));
    pipeline.addLast(new ChannelInboundHandlerAdapter(){
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    log.debug("从客户端读取到的消息:{}",msg);
    //调用ch,触发出站事件 无视顺序
    ByteBuf buffer = ctx.alloc().buffer();
    ch.writeAndFlush(buffer.writeBytes("abc".getBytes(StandardCharsets.UTF_8)))
    .addListener(ChannelFutureListener.CLOSE_ON_FAILURE);
    //byteBuf没有继续向下传递,所以需要release?
    buffer.release();
    }
    });
    pipeline.addLast(new ChannelOutboundHandlerAdapter(){
    @Override
    public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
    log.debug("向客户端发送消息");
    super.write(ctx, msg, promise);
    log.debug("向客户端发送消息完成");
    }
    });
    }
    })
    .bind(8080);
    }
    }

客户端,我们可以重点回顾一下这几个问题:

  1. 建立连接是由主线程完成的吗?

  2. 建立连接后为什么要调用.sync方法,以及有无其他方式达成同样的效果?

  3. 关闭连接是否由主线程完成?如何才能正确地处理关闭连接后的逻辑?

    @Slf4j
    public class TestClient1 {
    public static void main(String[] args) throws InterruptedException {
    NioEventLoopGroup eventLoopGroup = new NioEventLoopGroup();
    Channel channel = new Bootstrap()
    .group(eventLoopGroup)
    .channel(NioSocketChannel.class)
    .handler(new ChannelInitializer<NioSocketChannel>() {
    @Override
    protected void initChannel(NioSocketChannel ch) throws Exception {
    //字符输出编码
    ChannelPipeline pipeline = ch.pipeline();
    pipeline.addLast(new StringDecoder(Charset.defaultCharset())); // 添加解码器
    pipeline.addLast(new StringEncoder(Charset.defaultCharset()));
    pipeline.addLast(new ChannelInboundHandlerAdapter() {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
    log.debug("从服务器端读取到的消息:{}", msg);
    super.channelRead(ctx, msg);
    }
    });
    }
    })
    .connect(new InetSocketAddress("localhost", 8080))
    .sync()//阻塞方法 连接建立成功后才会执行
    .channel();

    复制代码
         //新开启一个线程向服务端写入
         new Thread(()->{
             Scanner sc = new Scanner(System.in);
             while (true){
                 String str = sc.nextLine();
                 if (str.equals("q")){
                     //由nioEventLoopGroup线程异步关闭连接
                     channel.close();
                     break;
                 }
                 channel.writeAndFlush(str);
             }
         },"input").start();
    
         //主线程调用监听器发布通知,由nioEventLoopGroup在channel.close()后 处理连接关闭后释放资源
         channel.closeFuture().addListener(future -> eventLoopGroup.shutdownGracefully());
     }

    }

相关推荐
qq_4541757927 分钟前
gcc/g++常用参数
linux·运维·服务器
keke1027 分钟前
Java【14_2】接口(Comparable和Comparator)、内部类
java·开发语言
CN.LG38 分钟前
Java 乘号来重复字符串的功能
java·开发语言
萌新下岸多多关照42 分钟前
Java中synchronized 关键字
java·开发语言
中国lanwp44 分钟前
使用Maven部署WebLogic应用
java·maven
开开心心就好1 小时前
Word图片格式调整与转换工具
java·javascript·spring·eclipse·pdf·word·excel
sun0077001 小时前
windows 10 做服务器 其他电脑无法访问,怎么回事?
运维·服务器·网络
CGG921 小时前
【单例模式】
android·java·单例模式
苦学编程的谢1 小时前
多线程代码案例-1 单例模式
java·开发语言·单例模式
wb1891 小时前
Linux远程连接服务
linux·运维·服务器·笔记