Netty入门

NIO的概念

为什么需要 NIO?(打破"一连接一线程")

传统的 BIO (Blocking I/O) 采用的是同步阻塞模型。每当有一个客户端连接,服务端就必须开启一个专门的线程来处理。

当并发量达到万级甚至更高时,大量的线程会瞬间榨干服务器内存,且频繁的线程上下文切换会导致性能急剧下降。

线程在等待数据读取(read)或写入(write)时会被挂起,白白浪费 CPU 资源。

NIO 的三大核心组件

NIO 引入了"多路复用"的概念,主要由以下三个核心组成:

  1. Buffer(缓冲区):在 NIO 中,所有数据的读写不是直接操作流(Stream),而是通过 Buffer。它本质上是一块可以读写的内存块,既可以从中读数据,也可以向其中写数据。

  2. Channel(通道):Channel类似于 BIO 中的 Stream,但它是双向的。数据可以从 Channel 读到 Buffer 中,也可以从 Buffer 写入 Channel。TCP中分为ServerSocketChannel 和 SocketChannel。ServeSocketChannel可以理解为主干道,负责建立连接;而SocketChannel就是在主干道上的分干道,负责建立连接后的消息读写。主干道只有一个而副干道可以有多个。

  3. Selector(选择器);Selector 允许单线程处理多个 Channel。Channel 会向 Selector 注册自己感兴趣的事件(如:连接就绪、读就绪)。Selector 会不断轮询这些 Channel,只有当事件真正发生时,才会交给线程去处理。仅需极少的线程就能管理成千上万个连接,极大地提升了并发处理能力。

NIO 的编程逻辑流程

服务端

我们用代码来演示,先是服务端:

java 复制代码
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;

public class NioServer {
    public static void main(String[] args) throws IOException {
        // 1. 创建并配置 ServerSocketChannel
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.bind(new InetSocketAddress(8080));
        serverChannel.configureBlocking(false); // 必须是非阻塞才能注册到 Selector

        // 2. 创建 Selector
        Selector selector = Selector.open();

        // 3. 将服务端通道注册到 Selector,监听"连接请求"
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("NIO 服务端启动,端口:8080");

        while (true) {
            // 4. 等待事件发生(阻塞)
            selector.select();

            // 5. 获取所有就绪事件并迭代
            Iterator<SelectionKey> it = selector.selectedKeys().iterator();
            while (it.hasNext()) {
                SelectionKey key = it.next();
                it.remove(); // 必须手动移除,否则下次循环会处理废弃的 key

                // 6. 分发处理
                if (key.isAcceptable()) {
                    // 接受新客户端连接
                    SocketChannel clientChannel = serverChannel.accept();
                    clientChannel.configureBlocking(false);
                    // 注册读事件,等待客户端发消息
                    clientChannel.register(selector, SelectionKey.OP_READ);
                    System.out.println("新客户端接入:" + clientChannel.getRemoteAddress());
                } 
                else if (key.isReadable()) {
                    // 读取数据
                    handleRead(key);
                }
            }
        }
    }

    private static void handleRead(SelectionKey key) throws IOException {
        SocketChannel channel = (SocketChannel) key.channel();
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        try {
            int len = channel.read(buffer);
            if (len > 0) {
                System.out.println("收到消息: " + new String(buffer.array(), 0, len));
                // 回应客户端
                channel.write(ByteBuffer.wrap("Server Echo".getBytes()));
            } else if (len == -1) {
                System.out.println("客户端断开");
                channel.close();
            }
        } catch (IOException e) {
            key.cancel();
            channel.close();
        }
    }
}               

具体的流程如下:首先,我们需要注册一个ServeSocketChannel,并将其设置为非阻塞模式,接着我们创建一个选择器,连接到ServeSocketChannel上,监听OP_ACCEPT,也就是接受连接的事件。

我们设置一个死循环,调用 selector.select(),该方法会阻塞直到至少有一个事件发生。

如果有事件发生了,selector.selectedKeys() 会返回所有发生了事件的 Channel 句柄。紧接着,遍历 selectedKeys,根据事件类型(Accept, Read, Write)执行相应的业务逻辑。

客户端

客户端的操作相对简单,需要通过buffer接收和发放数据

java 复制代码
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.util.Scanner;

public class NioClient {
    public static void main(String[] args) throws IOException {
        // 1. 打开通道并连接服务端
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.configureBlocking(false); // 设置为非阻塞
        
        // 发起连接
        if (!socketChannel.connect(new InetSocketAddress("127.0.0.1", 8080))) {
            // 非阻塞模式下,connect 会立即返回,需要轮询是否连接完成
            while (!socketChannel.finishConnect()) {
                System.out.println("正在尝试连接...");
            }
        }
        System.out.println("连接成功!请输入要发送的消息:");

        // 2. 交互逻辑
        Scanner scanner = new Scanner(System.in);
        while (scanner.hasNextLine()) {
            String msg = scanner.nextLine();
            
            // 写入数据到 Buffer 并发送
            ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());
            socketChannel.write(buffer);

            // 读取服务端回传(简单处理)
            ByteBuffer readBuffer = ByteBuffer.allocate(1024);
            int len = socketChannel.read(readBuffer);
            if (len > 0) {
                System.out.println("服务端回应: " + new String(readBuffer.array(), 0, len));
            }
        }
    }
}

流程相对服务端更加简单:首先注册一个ServeSocket并设置为非阻塞模式,紧接着调用connect方法进行连接;并对连接结果进行判断,如果返回false,就一直轮询尝试建立连接。连接成功后,客户端就可以把数据放在buffer里面和服务端进行交互了。

Netty 对原生 NIO的优化

虽然原生 NIO 很强大,但直接使用时还是会有许多问题,而Netty针对这些做了优化:

  • 复杂度高: 需要手动处理半包、粘包问题。
  • 断连重连: 需要处理复杂的链路异常和网络闪断。
  • Epoll 空轮询 Bug 修复 :JDK 的 NIO 在 Linux 环境下可能会触发 epoll 的空轮询,导致 Selector 在没有事件发生时也被唤醒,从而使 CPU 占用率瞬间飙升至 100%。Netty 通过检测异常轮询次数并在必要时重建 Selector 彻底解决了这一痛点

Netty 的出现,正是为了解决这些痛点。 它在 NIO 之上构建了一套优雅的事件驱动架构(Reactor 模型),让我们能够专注于业务逻辑,而无需在网络底层细节中挣扎。

下面开始真正接触Netty。

Netty基本概念

相比NIO里面的Selector监听事件,Netty中引入了EventLoop的概念,这是一个事件循环,里面封装了NIO中的Selector。它会监听Channel上的事件,一个EventLoop对应着一个线程,它会不断检查是否有新的事件发生。

Netty将事件监听线程分为两组:

  1. Boss线程组:用于监听服务端的accept事件,负责客户端和服务端之间建立连接;它就像一个大管家,只负责接待来客,而细致的活就交给worker线程组。
  2. Worker线程组:用于监听客户端的write、read事件,负责读写数据;它就像小伙计,在Boss线程组确认建立连接后,具体的事物就交给它来处理。

结合前面的EventLoop,我们将这样的线程组分别称为:bossEventLoopGroup和workerEventLoop Group,而boss线程和worker线程称为bossEventLoop和workerEventLoop。

一般情况下我们只有一个服务端,所以bossEventLoopGroup里只有一个EventLoop监听accept事件;而客户端有多个,所以workerEventLoopGroup内有多个EventLoop来监听读写事件。

在Netty服务启动后,会先创建一个NioServeSocketChannel,它负责承载连接的消息。当建立连接成功后,boss线程组会为这个新链接创建一个NioSocketChannel,然后在worker线程组内挑选一个具体的EventLoop,以后这个NioSocketChannel上的任何读写操作都由这个EventLoop负责。一个 EventLoop 负责管理多个 Channel,但一个 Channel 只能绑定到一个固定的 EventLoop。

建立读写连接通道后,数据在服务端和客户端之间大概是这样的一个流程:

首先,客户端会先生成数据,经过一系列处理后将数据进行编码,再将编码好的数据发送给服务端;服务端将客户端传递过来的数据先解码,然后进行处理、最后将数据反馈给客户端,这一系列的处理都是在Channel上进行的。

而这一系列对消息的操作,我们统称为消息处理器,即Handler。相同类型的Handler之间相互进行传递;根据出站和入站的消息处理器类型,我们又把Handler 分为入站处理器:ChannelInboundHandler和出站处理器:ChannelOutBoundHandler。

我们将这些消息处理器维护在一个双向链表里,这个结构叫做PipeLine,它是一个消息处理流水线,当消息从Channel进行处理时,就会按照流水线上消息处理器的顺序对消息进行处理。当然每一个channel都拥有自己的pipeline。

Netty内部架构示意图:

上面提到的所有组件,在Netty中,我们使用bootStrap来进行组装。可以看到,Netty的组件还是很多的:ServeSocketChannel、SocketChannel、eventLoopGroup、Handler、Pipeline、BootStrap,现在请你回想一下,将这些概念串联起来,我们再继续。

构建Netty

现在我们来使用代码进行演示:

首先,我们引入依赖

xml 复制代码
<dependencies>
    <dependency>
        <groupId>io.netty</groupId>
        <artifactId>netty-all</artifactId>
        <version>4.1.100.Final</version> 
    </dependency>
</dependencies>

服务端

接着我们写服务端的bootStrap

Java 复制代码
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;

public class NettyServer {
    public static void main(String[] args) throws InterruptedException {
        // 1. 创建两个线程组
        // BossGroup: 负责监听 ServerSocketChannel 的 Accept 事件 (1个线程就够了)
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        // WorkerGroup: 负责处理所有 SocketChannel 的 Read/Write 事件 (默认 CPU*2)
        EventLoopGroup workerGroup = new NioEventLoopGroup();

        try {
            // 2. 服务端启动辅助对象
            ServerBootstrap b = new ServerBootstrap();
            
            b.group(bossGroup, workerGroup)
             // 3. 指定使用的 NIO 传输通道类型
             .channel(NioServerSocketChannel.class)
             
             // 4. 设置针对 ServerSocketChannel 的处理器 (比如日志记录)
             .handler(new LoggingHandler(LogLevel.INFO))
             
             // 5. 设置针对新产生的 SocketChannel 的初始化逻辑 (核心点)
             .childHandler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 protected void initChannel(SocketChannel ch) {
                     // 这里的 ch 是新接生的"孩子",每个孩子都有独立的 Pipeline
                     ChannelPipeline pipeline = ch.pipeline();
                     
                     // 依次添加 Handler (入站按顺序,出站按逆序)
                     pipeline.addLast(new StringDecoder()); // 入站:ByteBuf -> String
                     pipeline.addLast(new StringEncoder()); // 出站:String -> ByteBuf
                     
                     // 添加自定义的业务处理器
                     pipeline.addLast(new MyServerHandler());
                 }
             })
             
             // 6. TCP 参数配置
             .option(ChannelOption.SO_BACKLOG, 128)          // 服务端接受连接的队列长度
             .childOption(ChannelOption.SO_KEEPALIVE, true); // 保持长连接

            // 7. 绑定端口并同步等待成功 (启动服务器)
            System.out.println("服务器启动中...");
            ChannelFuture f = b.bind(8888).sync();

            // 8. 等待服务端监听端口关闭
            f.channel().closeFuture().sync();
            
        } finally {
            // 9. 优雅释放资源
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}

可以看到,流程是这样的:我们先定义两个NioEventLoopGroup,也就是线程组,分别为boss和worker,boss线程组来监听客户端的连接,然后将读写的监听交给worker线程。

接着,我们实例化一个bootStrap对象,指定它的boss线程组和worker线程组,同时我们也要指定其监听的Channel类型:NioServeSocketChannel,然后再配置服务端的Handler,我们在initHandler方法中,通过pipeline的addLast方法依次配置handler。

配置完Handler后,我们就需要将这个bootStrap绑定在端口上,来监听服务端。

客户端

下面是客户端的配置,客户端就更简单了,因为它只管发送消息

java 复制代码
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;

/**
 * Netty 客户端配置示例
 */
public class NettyClient {

    public static void main(String[] args) throws InterruptedException {
        // 1. 客户端只需要一个线程组 (相当于服务端的 WorkerGroup)
        // 用于处理网络 I/O 读写
        EventLoopGroup group = new NioEventLoopGroup();

        try {
            // 2. 客户端启动辅助对象 (注意是 Bootstrap,不是 ServerBootstrap)
            Bootstrap bootstrap = new Bootstrap();

            bootstrap.group(group)
                    // 3. 指定客户端使用的通道类型为 NioSocketChannel
                    .channel(NioSocketChannel.class)
                    // 4. 客户端直接使用 .handler(),因为它只有一个唯一的 SocketChannel
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        protected void initChannel(SocketChannel ch) {
                            ChannelPipeline pipeline = ch.pipeline();
                            
                            // 添加编解码器 (必须与服务端对应)
                            pipeline.addLast(new StringEncoder()); // 发送时:String -> ByteBuf
                            pipeline.addLast(new StringDecoder()); // 接收时:ByteBuf -> String
                            
                            // 添加客户端业务处理器
                            pipeline.addLast(new MyClientHandler());
                        }
                    });

            // 5. 连接服务器 (IP 和 端口)
            System.out.println("正在连接服务器...");
            ChannelFuture future = bootstrap.connect("127.0.0.1", 8888).sync();

            // 6. 连接成功后,获取 Channel 并发送一条消息
            future.channel().writeAndFlush("Hello, I am Yuanyifan!");

            // 7. 等待客户端通道关闭 (比如在 Handler 中调用了 ctx.close())
            future.channel().closeFuture().sync();

        } finally {
            // 8. 优雅退出,释放 NIO 线程组
            group.shutdownGracefully();
            System.out.println("客户端已关闭");
        }
    }

}

流程是一样的:首先,我们定义一个NioEventLoopGroup,这个线程组是workerEventLoopGroup,接着实例化一个bootStrap,指定其线程组和通道类型,紧接着配置客户端的handler,我们在initHandler方法中,实例化一个pipeline,然后通过其addLast方法配置Handler,最后绑定端口,给服务端发消息。

Handler顺序

有一个细节问题,我们在对handler的编排顺序是有一定要求的,我们要求"进站顺序,出站逆序"的顺序来进行编排。

ChannelPipeLine本质上是一个双向链表,它会基于当先事件的类型选择方向:

  1. 如果是入站类型,就会按照从头到位的方向(head->tail)
  2. 如果是出站类型,就会按照从尾到头的方向(tail->head)

假设我们在pipeline中设置了五个handler

java 复制代码
pipeline.addLast("In1", new InboundHandlerA());   // 1
pipeline.addLast("Out1", new OutboundHandlerB()); // 2
pipeline.addLast("Out2", new OutboundHandlerC()); // 3
pipeline.addLast("In2", new InboundHandlerD());   // 4
pipeline.addLast("In3", new InboundHandlerE());   // 5

根据事件的不同,它的顺序如下:

入站流程(读取数据时)

数据从 Head 节点开始,只寻找 Inbound 类型的处理器,跳过所有 Outbound。

执行顺序:Head -> In1 -> In2 -> In3 -> Tail (注意:尽管 Out1 和 Out2 插在中间,但在入站时它们会被直接无视)

出站流程(写出数据时)

当你在代码里调用 ctx.writeAndFlush() 时,消息是会从当前 Handler 开始向从往 Head 方向寻找出站处理器。

Handler对Channel进行监听

我们可以通过事件回调机制让Handler对Channle的各种状态变化进行感知,主要有两种实现方法,一种是在handler中使用内部匿名类进行监听

java 复制代码
pipeline.addLast(new ChannelInboundHandlerAdapter() {
    @Override
    public void channelActive(ChannelHandlerContext ctx) throws Exception {
        // 监听:连接激活
        System.out.println("检测到新连接: " + ctx.channel().id());
        // 必须向后传递,否则后面的 Handler 听不到这个事件
        super.channelActive(ctx); 
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
        // 监听:连接断开
        System.out.println("连接已关闭,清理用户 Session...");
        super.channelInactive(ctx);
    }
});

或者是通过继承ChannelInboundHandlerAdapter来重写各种监听方法

java 复制代码
public class MyChannelListener extends ChannelInboundHandlerAdapter {

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        // 监听到新成员加入会议(连接激活)
        System.out.println("新连接已建立: " + ctx.channel().remoteAddress());
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
        // 监听到成员退出或网络掉线
        System.out.println("连接已断开,正在清理资源...");
    }

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        // 特殊监听:配合 IdleStateHandler 监听心跳超时
        if (evt instanceof IdleStateEvent) {
            // 发现客户端很久没说话了,主动踢掉
            ctx.close();
        }
    }
}

一下是常见的监听状态的方法:

方法名 监听的事件 触发时机
channelRegistered 注册成功 Channel 注册到 EventLoop 时。
channelActive 连接建立/激活 TCP 三次握手完成,Channel 准备好读写时(最常用,常用于心跳开始、鉴权)。
channelInactive 连接断开 TCP 连接关闭或掉线。常用于清理资源、统计在线人数。
channelRead 数据到达 有新数据从对端发来,进入 Pipeline。
exceptionCaught 异常发生 读写过程中出现错误(如连接重置)。

Bytebuf

解决 NIO 的 Epoll Bug

相关推荐
2401_873544922 小时前
持续集成/持续部署(CI/CD) for Python
jvm·数据库·python
第二只羽毛2 小时前
C++ 高并发内存池2
大数据·开发语言·jvm·c++·c#
2301_814590252 小时前
使用Python进行图像识别:CNN卷积神经网络实战
jvm·数据库·python
第一程序员2 小时前
GitHub Actions:Python项目的CI/CD实践
python·ci/cd·github
我真会写代码2 小时前
Java程序员常用设计模式详解(实战版)
java·开发语言·设计模式
2401_878530212 小时前
C++与FPGA协同设计
开发语言·c++·算法
2301_814590252 小时前
C++中的装饰器模式实战
开发语言·c++·算法
夫礼者2 小时前
【极简监控】不骗篇幅!7个零运维成本的排障“微操”,让线上问题彻底左移
java·运维·监控