网络编程基础

何为网络编程

网络编程的核心,是让运行在不同机器或同一机器的进程能够相互通信、交换数据。Netty 本质上就是一个高度封装、高性能的网络编程框架

从最朴素的定义看,网络编程就是利用网络应用编程接口开发通信应用程序,实现两个或多个设备之间的信息交互。这里的 接口,在绝大多数场景下指的就是 Socket(套接字)。

Socket 可以理解为操作系统提供给应用程序的通信端点。它像文件一样被打开、读取、写入和关闭。应用程序把数据写入 Socket,由操作系统内核负责后续的协议封装和网络发送接收方则从自己的 Socket 中读出内核递上来的数据。

网络协议栈(TCP/IP)的复杂处理由内核完成,应用层只面对 Socket 这个抽象,这正是网络编程得以相对简单地进行的根本原因。

一次网络通信涉及两个基本角色:客户端发起请求,服务端监听并响应。

为了定位通信的双方,需要 IP 地址(找到哪台机器)和端口号(找到机器上的哪个进程)。

了解 Socket 和 Socket 的基本操作

要想解释清楚 Socket,首先要知道 TCP,要想知道 TCP,那就得对 TCP/IP 的体系结构以及每一层的大概工作有所了解,那么我们就先来说说 TCP/IP 的分层。

OSI将网络分为七层,自下而上分别是物理层、数据链路层、网络层、传输层、会话层、表示层、应用层

而TCP/IP体系结构则将网络分为四层,自下而上分别是网络接口层、网络层、传输层、应用层。

img

发送端想要发送数据到接收端。首先应用层准备好要发送的数据,然后给了传输层,传输层的主要作用就是为发送端和接收端提供可靠的连接服务,传输层将数据处理完后就给了网络层。网络层的功能就是管理网络,其中一个核心的功能就是路径的选择也就是路由,从发送端到接收端有很多条路,网络层就负责管理下一步数据应该路由到哪个路由器。选择好了路径之后,数据就来到了数据链路层,这一层就是负责将数据从一个路由器送到另一个路由器。然后就是物理层了,可以简单的理解,物理层就是网线一类的最基础的设备。

Socket 是应用程序与网络协议栈之间的门。是网络中不同主机上的应用进程进行双向通信的端点抽象。它上联应用进程,下联网络协议栈,是应用程序使用网络协议进行通信的编程接口。

套接字是一个抽象层,应用程序可以通过它发送或接收数据,可对其进行像对文件一样的打开、读写和关闭等操作。套接字允许应用程序将 I/O 插入到网络中,并与网络中的其他应用程序进行通信。网络套接字是IP地址与端口的组合。

要定位一个 Socket,需要两个要素:IP 地址(找到哪台机器)和端口号(找到机器上的哪个进程)。因此 Socket 常被表示为 (IP地址:端口号) 的形式,例如 (210.37.145.1:23)。一对相互通信的进程,各自持有一个 Socket,共同唯一地确定一条传输层连接。

因为直接记忆 IP 地址非常困难,所以我们通常使用域名访问某个特定的服务。域名解析服务器 DNS 负责把域名翻译成对应的 IP,客户端再根据 IP 地址访问服务器。用nslookup可以查看域名对应的 IP 地址,有一个特殊的本机域名localhost,它对应的 IP 地址总是本机地址127.0.0.1

Socket 最初源于 Unix 系统“一切皆文件”的设计哲学,应用程序对 Socket 的操作,就像对文件进行 打开—读/写—关闭 一样。这一抽象至今仍是网络编程的基础。

我们再来看一下Java中提供的Socket 类中的核心功能,所以可以把 Socket 编程理解为对 TCP 协议的具体实现。

img

Socket 编程遵循客户端/服务端模型。服务端被动等待连接,客户端主动发起连接。两者的系统调用路径有明显差异,这是理解网络编程流程的关键。

  • 服务端的典型操作序列

    服务端先调用 socket() 创建一个套接字,然后用 bind() 将它绑定到一个已知的 IP 地址和端口上(例如 HTTP 服务绑定 80 端口)。

    接着调用 listen(),让这个套接字进入监听状态,准备接收外来的连接请求。

    最后调用 accept()。accept() 通常是阻塞的,如果没有新连接,它会一直等待。

    当客户端连接到达时,accept() 返回一个全新的套接字,专门用于与这个特定的客户端通信。原来的监听套接字继续留在原地,等待下一个连接。

    这一点对后续理解 Netty 的 Channel 生命周期很重要:监听通道和已连接通道是分开的。

  • 客户端的典型操作序列:

    客户端调用 socket() 创建套接字后,直接调用 connect(),指定服务端的 IP 和端口。connect() 会触发 TCP 三次握手。握手成功后,客户端就可以通过这个套接字读写数据了。客户端通常不需要显式调用 bind(),操作系统会自动分配一个临时端口。

    连接建立后,双方都通过 read()/write()来收发数据。对于 TCP,写入的数据先进入内核的发送缓冲区,所以write() 返回并不代表数据已经到达对方;读取时如果接收缓冲区为空,read() 默认会阻塞,直到有数据到来或连接关闭。

Socket 的一些常用命令

ping 与 telnet

ping,一种计算机网络工具,用来测试数据包能否透过 IP 协议到达特定主机。ping 会向目标主机发出一个 ICMP 的请求回显数据包,并等待接收回显响应数据包。

telnet 是 Internet 远程登录服务的标准协议和主要方式,可以让我们登录到另一台远程计算机上。

使用 telnet 登录远程计算机时,需要远程计算机上运行一个服务,它一直不停地等待那些希望和它进行连接的网络请求;当接收到一个客户端的网络连接时,它便唤醒正在监听网络连接请求的服务器进程,并为两者建立连接。连接会一直保持,直到某一方中止。

不过,需要注意的是,telnet 在格外重视安全的现代网络技术中并不受到重用。因为 telnet 是一个明文传输协议,用户的所有内容都没有经过加密,安全隐患非常大。

TCP/UDP 的分野

Socket 本身是一个通用的编程接口,它既可以用于 TCP,也可以用于 UDP。

TCP 与 UDP 的分野就是两种 Socket 类型。区别在于创建 Socket 时指定的类型,SOCK_STREAM 对应流套接字(TCP),SOCK_DGRAM 对应数据报套接字(UDP)。

流套接字(TCP):面向连接、可靠、字节流

  • TCP 的核心特征是面向连接和可靠。
  • 通信前必须先建立连接(connect/accept),通信中数据按序到达、无差错、无重复。TCP 适合对数据完整性要求高的场景,网页浏览、文件传输、邮件、聊天等。
  • 从编程角度看,TCP Socket 表现为一个双向字节流管道。你写入的字节会按顺序从另一端读出,但 Socket 本身不保留应用消息的边界。你分三次写入 100 字节,对方可能一次读到 300 字节,也可能分多次读到。这就是 粘包/半包 问题的根源,也是 Netty 编解码器要解决的核心问题之一。

数据报套接字(UDP):无连接、不保证可靠、保留消息边界

  • UDP 则完全不同。它不需要连接,没有 connect/accept 的握手过程,发送方直接调用 sendto() 指定目标地址,把数据报扔出去。

    因此 UDP 不保证送达,数据报可能在网络中丢失、重复,到达顺序也可能与发送顺序不一致。但 UDP 保留了消息边界,你发送一个数据报,接收方要么完整收到这一个数据报,要么什么都没收到,不会出现 半个数据报 的情况。

  • UDP 的代码模型也与 TCP 不同。服务端和客户端都只是创建套接字、绑定端口,然后直接收发。双方代码的差异很小,主要区别在于谁先发送数据。UDP 适合对实时性要求高、能容忍少量丢包的场景:音视频通话、DNS 查询、在线游戏的状态同步等。

阻塞与非阻塞,以及服务器如何处理并发连接

阻塞与非阻塞,描述的是发起一次 I/O 调用后,调用线程会不会被 卡住 来等待结果。这是 Netty 为什么采用事件驱动模型的关键一步

阻塞 I/O:线程被挂起等待

在阻塞模式下,当你对一个 Socket 调用 read() 时,如果接收缓冲区里没有数据,这个调用不会返回,线程会一直停在那里,直到有数据到达或连接关闭。accept() 也一样:没有新连接时,服务端线程就阻塞在 accept() 上,什么也做不了。

这种模型的代价很直接,一个线程同一时间只能服务一个连接。

假设你有 1000 个客户端连接,就需要大约 1000 个线程来分别阻塞等待。线程本身占用内存,操作系统在大量线程之间切换也会消耗 CPU。当并发连接数不高时,阻塞模型简单可靠;但连接数一多,线程数量就成了瓶颈,内核不得不在线程间频繁切换,真正用于处理业务的时间反而被压缩。

非阻塞 I/O:调用立即返回,但需要轮询

非阻塞模式改变了调用行为。

read() 调用后立即返回。如果此时没有数据可读,它不会等待,而是返回一个错误码(在 Linux 上通常是 EAGAIN 或 EWOULDBLOCK),告诉你“现在还没准备好”。如果有数据,它就把能读到的数据读出来,然后返回。

关键变化在于,线程没有被卡住,它可以继续做别的事。但问题也随之而来:你怎么知道数据什么时候到达?

最朴素的做法是反复调用 read() 去问,这就是“轮询”。但轮询本身很浪费 CPU,大多数时候你问到的答案都是“没有数据”,线程空转,什么有效工作都没做。

所以,单纯把 Socket 设为非阻塞,只是 “阻塞等待 换成了 空转询问,并没有真正解决问题。真正让非阻塞变得有用的,是非阻塞 + I/O 多路复用。

非阻塞 + I/O 多路复用:少量线程管理大量连接

既然不能靠 反复问 来获知数据就绪,也不能挂起大量的线程来进行阻塞处理,就需要一种机制:让一个线程同时监视大量 Socket,当其中某个 Socket 有数据可读或可写时,再通知线程去处理。

这就是 I/O 多路复用,Linux 上常用的实现是 select、poll 和 epoll。

以 epoll 为例,它的工作方式大致是,你把关心的 Socket 注册给 epoll,然后调用 epoll_wait() 等待。epoll_wait() 本身会阻塞,但它不是阻塞在 某一个 Socket 的数据 上,而是阻塞在 一批 Socket 中任意一个发生事件 上。一旦某个 Socket 可读,epoll_wait() 就返回,告诉你哪些 Socket 准备好了,你可以再针对性地去读。

这样一来,一个线程就能管理成百上千个连接。线程不再为每个连接单独阻塞等待,而是集中等待事件,事件来了才工作。这正是 Netty 的 EventLoop 所依赖的基础:少数几个线程,通过多路复用器管理大量 Channel,有事件就分发处理,没有事件就安静等待,不浪费 CPU。

Netty 的 Reactor 模型本质上就是把上述机制工程化了。EventLoop 线程运行着一个多路复用器,底层是 epoll 或 select,负责监听所有注册的 Channel 上的事件。当某个连接有数据可读时,EventLoop 被唤醒,把读事件分发给对应的 ChannelPipeline 去处理。你的业务 Handler 被调用时,数据已经在缓冲区里了,不需要自己去阻塞等待或轮询。所以说,在 EventLoop 线程里不能执行耗时操作。

Netty 采用事件驱动模型,但做了分层设计来兼顾并发能力与编程便利。它的 Reactor 多线程模型将职责拆开:一组线程(Acceptor)专门负责接受新连接,另一组 I/O 线程(EventLoop)负责已建立连接的读写事件。一个 EventLoop 线程通过多路复用器管理多个 Channel,每个 Channel 只绑定到一个固定的 EventLoop,避免并发操作同一个连接。

TCP 基础模型和字节流

三次握手

TCP 是面向连接的协议,通信前必须让双方都确认“我能发,你也能收;你能发,我也能收”。三次握手就是完成这个确认的最小次数。

过程可以简化为:

  1. 客户端 → 服务端(SYN):客户端发送一个 SYN 报文,带上自己的初始序号 seq=x,进入 SYN_SENT 状态。这相当于说:“我想建立连接,我的起始序号是 x。”
  2. 服务端 → 客户端(SYN+ACK):服务端收到后,回复 SYN+ACK。ACK 确认号 ack=x+1,表示收到了客户端的 SYN,同时自己的 SYN 带上初始序号 seq=y,进入 SYN_RCVD 状态。这相当于说:“收到了你的请求x,我也准备好了,我的起始序号是 y。”
  3. 客户端 → 服务端(ACK):客户端回复 ACK,确认号 ack=y+1,双方进入 ESTABLISHED 状态。这相当于说:“收到你的确认y,连接建立。”

为什么不能是两次?

假设客户端的一个旧 SYN 在网络中滞留了很久,直到连接释放后才到达服务端。如果只有两次握手,服务端收到这个旧 SYN 后直接回复确认并建立连接,就会白白分配资源等待一个永远不会到来的客户端。三次握手让客户端有机会在第三步拒绝这个过期的连接请求,服务端收不到最终 ACK,就不会真正建立连接。

四次挥手

TCP 是全双工的,客户端到服务端、服务端到客户端这两个方向的数据通道是独立的。关闭一个方向,不意味着另一个方向也立刻关闭,所以需要四次交互。

过程如下:

  1. 主动关闭方 → 被动关闭方(FIN):主动方发送 FIN,表示“我没有数据要发了”,进入 FIN_WAIT_1。
  2. 被动关闭方 → 主动关闭方(ACK):被动方回复 ACK,确认收到 FIN。此时主动方进入 FIN_WAIT_2,被动方进入 CLOSE_WAIT。此时连接处于半关闭状态:主动方不再发送数据,但仍可以接收被动方发来的数据。
  3. 被动关闭方 → 主动关闭方(FIN):被动方处理完剩余数据后,发送自己的 FIN,表示“我也没有数据要发了”,进入 LAST_ACK。
  4. 主动关闭方 → 被动关闭方(ACK):主动方回复 ACK,确认收到对方的 FIN。主动方进入 TIME_WAIT,被动方收到后进入 CLOSED。

四次挥手的多出来的一次,就来自半关闭状态:被动方收到 FIN 后,可能还有数据要发送,所以它的 ACK 和 FIN 不能合并,必须分两次发。

TIME_WAIT 与 CLOSE_WAIT

  • TIME_WAIT 出现在主动关闭方,在发送最后一个 ACK 之后进入。它的持续时间通常是 2MSL,也就是最长报文段寿命。一是确保最后一个 ACK 能到达对方,如果 ACK 丢失,对方会重发 FIN,主动方还在 TIME_WAIT 状态就能重新回复 ACK;二是让本次连接中延迟的旧报文在网络中消散,避免它们干扰之后使用相同端口的新连接。
  • CLOSE_WAIT 出现在被动关闭方,在收到对方的 FIN 并回复 ACK 之后进入。此时应用层应该尽快调用 close() 来发送自己的 FIN。如果服务端出现大量 CLOSE_WAIT,几乎总是意味着代码里有 Socket 没有被正确关闭

Nagle 算法与 TCP_NODELAY

Nagle 算法要解决的是小包过多的问题。

TCP 报文头有 20 字节,IP 头也有 20 字节,如果应用层每次只发 1 个字节的有效数据,网络上传输的大部分都是头部开销。

Nagle 算法的规则是:如果当前还有已发送但未被确认的数据在途,就先不发送新的小数据,把它们攒起来,等到有足够数据凑成一个满包,或者等到之前的 ACK 回来再发。

这个算法默认是开启的,对于批量传输场景,如文件下载,很有价值。但它对交互式小消息不友好,比如你连续发送两个小请求,第二个可能会被 Nagle 算法扣住,直到第一个的 ACK 回来,这在某些场景下会引入可感知的延迟。

TCP_NODELAY 就是用来关闭 Nagle 算法的 Socket 选项。设置后,应用层写入的数据会尽可能立即发送,不再等待凑包。在 Netty 中,TCP_NODELAY 通常建议开启,因为网络框架处理的往往是请求/响应式的消息,延迟比包合并更重要。

粘包是字节流语义的结果

这是理解 Netty 编解码器设计的前提

TCP 的面向字节流意味着,TCP 只保证字节按序、可靠地送达,但不保留应用层写入操作的边界。

应用层调用 write() 发送了两条消息,内核可能把它们合并成一个 TCP 段发出,也可能拆成多个段;接收方调用 read() 时,可能一次读到两条消息的全部,也可能只读到其中一条的一半。所谓“粘包”和“半包”,描述的是接收方在一次 read 中拿到的东西不符合应用层对 一条消息 的预期,但字节流本身完全正确地传输了发送方写入的内容。

因为 TCP 不提供消息边界,应用层必须自己定义一套规则来切分字节流。常见的做法如下

  • 固定长度:每条消息长度固定,不够就填充
  • 分隔符:用特殊字符标记消息结束
  • 长度字段:在消息头中写明消息体长度

Socket 编程基础

用一个能看见界面的聊天室,把 Java Socket 的常用套路串起来:一条连接怎么建立、多个客户端怎么服务、消息怎么切分、界面和网络线程怎么配合

Socket 与 ServerSocket

Socket 编程始终围绕两个角色展开:服务端先监听一个端口,客户端主动连接这个端口。Java 把这两个角色分别封装成 ServerSocket 和 Socket。

类 / 方法 作用 是否阻塞
new ServerSocket(port) 在指定端口创建监听套接字 否
serverSocket.accept() 等待并接受一个客户端连接,返回新的 Socket 是,直到有连接
new Socket(host, port) 客户端连接服务端,完成三次握手 是,直到连接成功或超时
socket.getInputStream() 获取这条连接的输入流(读数据) 否
socket.getOutputStream() 获取输出流(写数据) 否
socket.close() 关闭这条连接 否

最小的服务端骨架只要三行:

1
2
3
ServerSocket server = new ServerSocket(8888); // 1. 监听 8888 端口
Socket socket = server.accept(); // 2. 阻塞等待客户端连接
InputStream in = socket.getInputStream(); // 3. 拿到这条连接的输入流

这里有一个容易忽略的点:accept() 返回的是一个全新的 Socket,专门代表服务端和这个客户端之间的这条连接;原来的 ServerSocket 仍然在监听,等待下一个客户端。

Netty 里的 父 Channel(监听) 和 子 Channel(已连接) 就是同一个概念。

下面先写一个最小的 Echo 服务端,只处理一个客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class EchoServer {
public static void main(String[] args) throws IOException {
// try-with-resources:退出时自动关闭 ServerSocket
try (ServerSocket server = new ServerSocket(8888)) {
System.out.println("Echo 服务已启动,监听 8888");
while (true) {
Socket socket = server.accept(); // 阻塞等待新连接

// 这条连接用完后自动关闭;关闭顺序是“后打开的先关闭”
try (socket;
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(
new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true)) {

String line;
while ((line = in.readLine()) != null) { // 阻塞等待客户端发来一行
out.println("echo: " + line); // 写回客户端
}
}
}
}
}
}

客户端代码:

1
2
3
4
5
6
7
8
9
try (Socket socket = new Socket("127.0.0.1", 8888);
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(
new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true)) {

out.println("hello"); // 发一行
System.out.println(in.readLine()); // 收一行:echo: hello
}

这段代码里有几个关键点:

  1. accept() 是阻塞的:没有客户端时,服务端主线程就停在这里。
  2. readLine() 是阻塞的:连接建立后,如果没有数据也没有关闭,读线程会一直等。
  3. readLine() 以换行作为消息边界:这是最简单的应用层协议,后面会专门讲。
  4. 必须关闭 Socket:用 try-with-resources 或 finally 都行;忘记关闭会泄漏文件描述符,服务端可能积累大量 CLOSE_WAIT。
  5. PrintWriter 的第二个参数 true 表示 autoFlush,写完一行立即发送。

但这段 Echo 服务端一次只能服务一个客户端,处理第一个客户端的 while 循环不结束,accept() 就不会执行,第二个客户端只能排队。要支持多人同时聊天,就需要把 等待连接 和 处理连接 拆开。

为多个客户端服务

聊天室服务端的思路是:主线程只负责接收连接,每接到一个客户端,就开一个线程专门服务它。

1
2
3
4
5
6
7
8
// 主线程:只做 accept,接到连接就交给新线程
while (true) {
Socket socket = serverSocket.accept(); // 阻塞等待新连接
ClientSession session = new ClientSession(socket);
Thread worker = new Thread(session, "client-" + session.id);
worker.setDaemon(true); // 主线程退出时不阻止 JVM 结束
worker.start(); // 每个连接单独一个线程
}

每个客户端线程在自己的 readLine() 上阻塞,互不影响。ClientSession 的核心逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Override
public void run() {
try {
// 协议约定:连接后的第一行是昵称
String firstLine = in.readLine();
if (firstLine == null) {
return; // 连上就断开
}
nickname = normalizeNickname(firstLine);
joined = true;
sessions.add(this);

// 通知所有人(包括自己):有人上线
broadcast("[系统] " + nickname + " 加入了聊天室(在线 " + sessions.size() + " 人)");

// 循环读取聊天内容,直到客户端关闭连接(readLine 返回 null)
String line;
while ((line = in.readLine()) != null) {
line = line.trim();
if (line.isEmpty()) {
continue; // 忽略空行
}
if (line.length() > 500) {
line = line.substring(0, 500) + "…"; // 防止超长消息
}
broadcast("[" + nickname + "] " + line);
}
} catch (IOException e) {
log("客户端连接异常:" + nickname + "," + e.getMessage());
} finally {
// 无论正常退出还是异常,都必须清理:否则在线列表和 socket 会泄漏
if (joined) {
sessions.remove(this);
broadcast("[系统] " + nickname + " 离开了聊天室(在线 " + sessions.size() + " 人)");
}
closeQuietly(socket);
}
}

广播方法要处理线程安全:多个客户端线程可能同时发消息。

1
2
3
4
5
6
7
/** 多个客户端线程会同时调用,synchronized 保证广播顺序不交错 */
private synchronized void broadcast(String message) {
log(message); // 服务端控制台也打印一份,便于观察
for (ClientSession session : sessions) {
session.send(message); // 每个客户端的 PrintWriter 写一行
}
}

这里用到了几个并发容器和约定:

  • sessions 使用 ConcurrentHashMap.newKeySet(),因为增删发生在不同线程;
  • broadcast() 加 synchronized,避免两个线程同时往同一批连接里写,导致消息交叉;
  • 客户端异常会让 readLine() 抛 IOException,finally 里统一清理;
  • 客户端正常关闭时 readLine() 返回 null,同样走 finally。

一个连接一个线程的写法非常直观,适合入门。但当连接数达到几千、几万时,线程数也会跟着涨;线程栈内存、上下文切换都会成为瓶颈。这就是下一节 I/O 模型要解决的问题,也是 Netty 用少量 EventLoop 线程管理大量连接的原因。

设计聊天协议

上面说了 TCP 是字节流,它只保证字节按序到达,不保留应用 一次发送 的边界。所以服务端必须自己定义一条消息到哪结束。聊天室采用最简单的一种:一行一条消息。

顺序 方向 内容 说明
1 客户端 → 服务端 昵称 连接后第一行
2+ 客户端 → 服务端 消息文本 一行一条;空行忽略
— 服务端 → 客户端 [系统] ... 或 [昵称] ... 客户端直接展示

对应的读写代码就是:

1
2
3
4
5
out.println(nickname);                            // 客户端:写一行,行尾自动加换行
String nickname = in.readLine(); // 服务端:读一行,自动去掉行尾换行

out.println("[" + nickname + "] " + text); // 服务端:广播一行
String message = in.readLine(); // 客户端:收一行

几个必须注意的细节:

  • BufferedReader.readLine() 用 \n 或 \r\n 识别一行,解决“怎么知道一条消息结束”的问题;
  • 两端都显式使用 UTF-8,避免中文在不同系统默认编码下乱码;
  • 输入要校验:昵称限制长度、空消息忽略、超长消息截断;
  • 这是最朴素的“应用层协议”。真实项目里更常用长度字段或 Protobuf 等结构化协议;Netty 的 LineBasedFrameDecoder 做的就是“按行切帧”这件事,后面第 03 篇会展开。

用 Java Socket 实现了一个 HTTP 服务器

HTTP 是应用层协议,底层仍然跑在 TCP 上。一个 HTTP 请求的报文结构非常固定:请求行、请求头、空行、请求体;服务器处理之后返回响应行、响应头、空行、响应体。理解了这一点,用 ServerSocket 就能写一个教学版 HTTP 服务器。

部分 示例 作用
请求行 GET /hello HTTP/1.1 方法、路径、协议版本
请求头 Host: 127.0.0.1 描述请求的元信息
空行 一个 CRLF 分隔请求头与请求体
请求体 表单或 JSON GET 请求通常没有

响应的结构与之对应:

部分 示例 作用
响应行 HTTP/1.1 200 OK 协议版本、状态码、原因短语
响应头 Content-Type: text/html 告诉浏览器如何解析
空行 一个 CRLF 分隔响应头与响应体
响应体 HTML、JSON、图片 真正返回的内容

下面这段代码只做四件事:接受连接、读请求行和请求头、拼一个固定的 HTML 响应、关闭连接。完整可运行版本在 examples/socket-chat/src/hbnu/project/socketchat/SimpleHttpServer.java。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
try (ServerSocket server = new ServerSocket(8081)) {
System.out.println("HTTP 示例服务已启动:http://127.0.0.1:8081");
while (true) {
try (Socket socket = server.accept();
BufferedReader in = new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
BufferedWriter out = new BufferedWriter(
new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8))) {

// 1. 请求行
String requestLine = in.readLine();
if (requestLine == null) {
continue;
}
System.out.println("收到请求:" + requestLine);

// 2. 请求头,读到空行为止
String headerLine;
while ((headerLine = in.readLine()) != null && !headerLine.isEmpty()) {
// 真实服务器会解析 Host、Cookie、Content-Length 等
}

// 3. 响应体
String body = "<html><body><h1>Hello HTTP</h1></body></html>";
byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8);

// 4. 响应行 + 响应头 + 空行 + 响应体
out.write("HTTP/1.1 200 OK\r\n");
out.write("Content-Type: text/html; charset=UTF-8\r\n");
out.write("Content-Length: " + bodyBytes.length + "\r\n");
out.write("Connection: close\r\n");
out.write("\r\n");
out.write(body);
out.flush();
}
}
}

几个关键点:

  1. HTTP 头部的换行是 \r\n,不是简单的 \n;请求头与请求体之间必须有一个空行。
  2. Content-Length 告诉浏览器响应体有多少字节,否则浏览器无法判断响应何时结束。
  3. 这里声明 Connection: close,表示一次连接只处理一个请求;真实的 HTTP 服务器会支持 keep-alive。
  4. 这是单线程版本,同一时刻只能处理一个请求;要支持并发,可以套用前面聊天室的一连接一线程,或者使用 Netty 的 HTTP 编解码器。
  5. 真实的 HTTP 服务器还要处理路径路由、查询参数、表单、Cookie、静态资源、压缩、TLS 等,所以不要在生产环境手写 HTTP 协议栈。

运行与验证:

1
2
3
4
cd examples\socket-chat
$src = (Get-ChildItem src -Recurse -Filter *.java).FullName
& "$env:JAVA_HOME\bin\javac.exe" -encoding UTF-8 -d out $src
& "$env:JAVA_HOME\bin\java.exe" -cp out hbnu.project.socketchat.SimpleHttpServer

然后在浏览器打开 http://127.0.0.1:8081/,或者用命令行验证:

1
curl.exe -i http://127.0.0.1:8081/

看到 HTTP/1.1 200 OK 和一段 HTML,就说明这个手写 HTTP 服务器已经跑通。后续学习 Netty 的 HttpServerCodec 与 HttpObjectAggregator 时,可以回头对照这段代码,理解 Netty 到底替你处理了哪些协议细节。

I/O 模型

IO模型

IO 流基础不在这里说了,这里只说模型

从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。

为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间 和 内核控件,总所周知,IO 操作一定要依赖内核空间的能力,并且,用户空间的程序不能直接访问内核空间。所以说系统调用是必然的

开发过程中接触最多的就是 磁盘 IO(读写文件) 和 网络 IO(网络请求和响应)。

从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。

BIO

BIO 也就是 Blocking I/O,属于同步阻塞 IO 模型。

同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。

图源:《深入拆解Tomcat & Jetty》

在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。

NIO

同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。通过轮询操作,避免了一直阻塞。但是,应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。

图源:《深入拆解Tomcat & Jetty》

这个时候,I/O 多路复用模型 就上场了

图片预览

多路复用的核心思路是:不再让应用程序自己去挨个问,而是把一批文件描述符(fd)交给内核,让内核帮忙盯着,哪个就绪了就告诉我。只不过多路复用优化的是 等待数据就绪 这个阶段,数据拷贝阶段仍然是阻塞的

http://www.52im.net/data/attachment/forum/201811/04/221307gaaaj1zdzkv0dr60.jpg

Java 1.4 引入的 java.nio 包,把多路复用机制封装成了 Selector 这个类。

Java 中的 NIO 于 Java 1.4 中引入,对应 java.nio 包,提供了 Channel、Selector、Buffer 等抽象。

  • Buffer:用户段中一块内存的抽象,本质上就是用来进行数据读写的一个容器
  • Selector:NIO 实现非阻塞式 IO 的核心,其可以基于事件监听的方式,选择准备好的Channel,再去其中将数据读到Buffer中,然后去操作Buffer中的数据。
  • Channel:用来向Buffer中读写数据,是用户段和内核段 IO 区的连接的抽象,当然直接理解为内核段的 IO 区的抽象其实也可以。

NIO 中的 N 指的是 New,NIO 同时提供阻塞和非阻塞的通道;其中,可选择通道(SelectableChannel)可配置为非阻塞模式。它是支持面向缓冲的、基于通道的 I/O 操作方法。

Java 中的 NIO,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。

Buffer、Channel和Selector三者之间的关系

Selector 的工作流程大致是这样的

  • 创建 Selector 并注册通道,并选择你关心什么事件
  • 调用 select 等待事件
  • 处理就绪的通道

一个 Selector 线程在循环中反复执行“select 等待 → 遍历就绪 key → 处理事件 → 清理 key”,少量线程就能服务大量连接。

AIO

AIO 是 Asynchronous I/O,通常指 NIO.2 在 Java 7 中引入的异步通道 API。NIO.2 除了异步通道,还包括新的文件系统 API。

异步通道的操作会直接返回,调用者可以通过 Future 获取结果,也可以传入 CompletionHandler 在操作完成后执行回调。

图片预览

Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。

也就是说,AIO 是一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理。

了解 Netty

对于 Netty,官方的介绍是

Netty is an asynchronous event-driven network application framework for rapid development of maintainable high performance protocol servers & clients. Netty是 一个异步事件驱动的网络应用程序框架,用于快速开发可维护的高性能协议服务器和客户端。

从官网上介绍,Netty是一个网络应用程序框架,开发服务器和客户端。也就是用于网络编程的一个框架。

Netty 解决了什么问题

Netty 解决的问题,可以概括为一句话

把 Java NIO 这套 能力强但难用、易错、不完整 的底层 API,封装成一个可靠、高性能、可维护的网络应用开发框架。

说白了,Netty是封装 NIO 做出来的一个 Java 高性能通信框架。在 Java 领域,有高性能网络通信需求的时候,绝大多数都会选择 Netty 作为通信框架。

Java NIO 的使用鸿沟非常严重

  • Selector 的流程包括注册通道、select 等待、遍历就绪 key、处理事件、手动清理 key。写个 demo 还行,放到生产环境就暴露出大量细节问题,而且连接建立、读写、异常、关闭等事件需要一套清晰的分发机制。Netty 的 EventLoop 把这些封装了起来,你只需要关注连接本身即可

解决传输层 API 不统一的问题。

  • Java 的 OIO(阻塞 I/O)、NIO(非阻塞 I/O)、AIO(异步 I/O)在设计上互不兼容,写一套代码很难在不同传输模型之间切换。Netty 提供了统一的 Channel 抽象,底层可以是 NIO、OIO,甚至本地传输。切换传输实现通常只需要改几行构造代码,业务 Handler 不需要重写。这对需要根据场景选择传输方式的系统很有价值。

补齐了字节流处理的基础设施

  • ByteToMessageDecoder 让你可以从无序的字节流中切出完整消息,LengthFieldBasedFrameDecoder、DelimiterBasedFrameDecoder 等开箱即用的解码器覆盖了最常见的边界定义方式。如果没有这层,每个项目都要手写一套半包和粘包处理逻辑,重复且容易出错。

提供了可扩展的事件模型与 Pipeline

  • 通过 ChannelPipeline 串联一组 ChannelHandler。入站事件(读、连接建立)从头到尾经过 Handler 链,出站事件(写、连接关闭)反向经过。你可以把编解码、业务处理、异常处理、超时控制分别放在不同的 Handler 中,关注点清晰分离,符合工程设计

Netty 高性能的来源

Netty 的高性能不是某一项技术单独带来的,而是网络模型、线程模型、内存与数据、事件架构和工程细节共同作用的结果。衡量网络框架的指标也不只吞吐量,还包括延迟、连接数、资源占用、稳定性和可维护性。

Netty 高性能设计的五个层面
层次 主要手段 直接效果
传输层 I/O 多路复用、主从 Reactor 少量线程管理大量连接,减少线程与上下文切换
线程层 EventLoop 绑定、事件串行执行 同一连接无锁,多个连接并行
数据层 ByteBuf、内存池、零拷贝 减少分配、拷贝和 GC 压力
架构层 Pipeline、Handler 链、异步 Future 编解码与业务解耦,线程不阻塞
工程层 FastThreadLocal、Recycler、水位线、批量 flush、TCP 参数 降低固定开销,把突发流量变成可控节奏

Netty 的高性能设计

Netty的网络和IO模型

BIO 的根本问题是一个连接占住一个线程。服务端调用 accept 接到连接后,就在这个线程里阻塞读数据,读不到就一直等。客户端多了,线程数随连接数线性增长,线程栈、上下文切换和调度开销最终会把机器拖垮。这是 Netty 要解决的核心痛点之一。

I/O 复用的关键角色是 select,在 Java 中是 Selector。它的工作方式是:

  • 一个线程可以同时监听多个 I/O 操作(多个连接的读、写事件)。
  • 该线程阻塞在 select 调用上,直到至少有一个 I/O 操作就绪。
  • 就绪后,线程才真正去调用对应的 I/O 操作函数。

所以说 I/O 多路复用改变了等待方式。Netty 的非阻塞 I/O 的实现关键是基于 I/O 复用模型。

http://www.52im.net/data/attachment/forum/201811/04/221327oisbipbowoc1iolo.jpg

程序把一批文件描述符注册给 Selector,然后阻塞在 select 上;内核告诉它哪些描述符已经就绪,它再逐个处理。一个线程因此可以照看成百上千个连接。而多路复用优化的是等待数据就绪,数据从内核缓冲区拷贝到用户缓冲区这一趟仍然存在。减少拷贝要靠后面的零拷贝手段。

Netty 的 IO 线程 NioEventLoop 由于聚合了多路复用器 Selector,可以同时并发处理成百上千个客户端连接。

当线程从某客户端 Socket 通道进行读写数据时,若没有数据可用时,该线程可以进行其他任务。线程通常将非阻塞 IO 的空闲时间用于在其他通道上执行 IO 操作,所以单独的线程可以管理多个输入和输出通道。由于读写操作都是非阻塞的,这就可以充分提升 IO 线程的运行效率,避免由于频繁 I/O 阻塞导致的线程挂起。

传统 I/O 和 NIO 在数据组织方式上的差异也很明显

  • 传统 I/O 面向流:以流式方式顺序读取字节,读取指针不能随意改变位置,只能顺序往前。
  • NIO 引入 Channel 和 Buffer:数据只能从 Channel 读入 Buffer,或从 Buffer 写入 Channel。
  • Buffer 的优势:可以随意读取任意位置的数据,不像流式操作那样必须顺序进行

这就是基于 Buffer 的设计,Netty 使用 ByteBuf 作为数据载体,它比 JDK 的 ByteBuffer 更灵活。Netty 中ByteBufAllocator 是你申请 ByteBuf 的接口。池化实现是 PooledByteBufAllocator。

事件驱动模型和 Reactor 线程模型

数据报如何读取?读取之后的编解码在哪个线程进行,编解码后的消息如何派发,线程模型的不同,对性能的影响也非常大。

通常,我们设计一个事件处理模型的程序有两种思路

  • 轮询方式:线程不断轮询事件源,看有没有事件发生,有就调用处理逻辑。
  • 事件驱动方式:事件发生时,主线程把事件放入事件队列,另一个线程循环消费队列中的事件,调用对应处理逻辑。这也叫消息通知方式,本质是观察者模式。

事件驱动模型包含 4 个基本组件:

  1. 事件队列(event queue):接收事件的入口,存储待处理事件。
  2. 分发器(event mediator):把不同事件分发到不同的业务逻辑单元。
  3. 事件通道(event channel):分发器与处理器之间的联系渠道。
  4. 事件处理器(event processor):实现业务逻辑,处理完会发出事件,触发下一步操作。

相比轮询,事件驱动的优点是:

  • 可扩展性好:事件处理器之间高度解耦,方便扩展处理逻辑。
  • 高性能:基于队列暂存事件,能方便地并行异步处理事件。

Reactor 是反应堆的意思,Reactor 模型是指通过一个或多个输入同时传递给服务处理器的服务请求的事件驱动处理模式。

Reactor 模型是事件驱动处理模式在网络服务端的具体应用,服务端处理传入的多路请求,并把它们同步分派给请求对应的处理线程。

Reactor 是在多路复用之上做事件分发。Reactor 线程只负责监听事件和派发,Handler 负责实际的非阻塞处理。

  • Reactor:在单独线程中运行,负责监听和分发事件,分发给适当的处理程序对 I/O 事件做出反应。用“公司电话接线员”来类比,它接听客户电话,把线路转给合适的人。
  • Handlers:处理程序,执行 I/O 事件要完成的实际事件,即公司里实际接待客户的官员。Reactor 调度合适的 Handler 来响应 I/O 事件,Handler 执行非阻塞操作。
新手入门:目前为止最透彻的Netty高性能原理和框架架构解析_5.jpg

根据 Reactor 数量 和 Handler 线程数量 的不同,Reactor 模型有三个变种:

  1. 单 Reactor 单线程;
  2. 单 Reactor 多线程;
  3. 主从 Reactor 多线程。

可以这样理解,Reactor 就是一个执行 while (true) { selector.select(); …} 循环的线程,会源源不断的产生新的事件,称作反应堆很贴切。

Netty 不发明新的 I/O 模型,主要基于主从 Reactor 多线程模型,它把 Reactor 模式落地成 EventLoopGroup 和 EventLoop 这两个具体组件,并允许你通过启动参数选择不同的 Reactor 变体。

  1. bossGroup 只负责 accept,通常一个线程就够。
  2. accept 出来的 NioSocketChannel 会注册到 workerGroup 中的某一个 EventLoop。
  3. workerGroup 负责已建立连接上的读写、编解码和 Handler 回调。
  4. 一个 Channel 从注册到关闭只绑定一个 EventLoop,不会在多个线程之间迁移。

主从 Reactor 也有边界。worker 线程不是越多越好,默认按 CPU 核数推算;线程太多会带来调度和缓存失效。单个 Channel 的吞吐上限受它绑定的那一个 EventLoop 限制,想提升只能靠业务分片、线程池或协议层批量处理。

Netty的线程模型与无锁串行化

EventLoop 是 Netty 线程模型的核心。一个 EventLoop 对应一个线程和一个 Selector,每一轮循环大致做三件事:

  1. select 等待就绪的 I/O 事件。
  2. 处理就绪的 I/O,把读到的数据送进 ChannelPipeline。
  3. 执行任务队列里的任务,包括定时任务、跨线程提交的任务和关闭钩子。

这里有一个非常重要的设计,同一个 Channel 上的读、解码、业务回调、编码、写回,全部由绑定它的那个 EventLoop 线程串行执行。因为状态只被一个线程修改,所以不需要为每个连接加锁,也就没有锁相关的额外开销。多个 EventLoop 之间又是并行的,形成局部串行、整体并行的效果。

如果业务线程要操作某个 Channel,例如推送系统在业务线程里根据用户找到 Channel 后调用 write,Netty 会判断当前线程是不是该 Channel 的 EventLoop 线程;不是的话,就把任务包装后放入任务队列,由 EventLoop 异步执行。这个机制让跨线程调用是安全的,但也意味着回调线程不固定。

无锁串行化有一个必须记住的代价,EventLoop 线程里不能有阻塞操作。数据库查询、同步 HTTP 调用、Thread.sleep、大文件读写,只要卡住一个 Handler,挂在这个 EventLoop 上的所有连接都会一起卡住。耗时业务必须交给独立的业务线程池,例如 DefaultEventExecutorGroup 或自定义线程池。

JDK 的 epoll 曾经有一个空轮询问题:没有事件时 select 也可能立刻返回,循环空转导致 CPU 100%。Netty 会统计连续空轮询次数,达到阈值后重建 Selector 并迁移注册关系,默认阈值是 512,可以用 io.netty.selectorAutoRebuildThreshold 调整。

Netty 4.1 及更早版本用 ioRatio 控制 I/O 处理与非 I/O 任务的时间比例,默认是 50;4.2 已经移除这套逻辑,setIoRatio 变成空操作,改为按固定时间片执行任务,默认时间片约 1000 毫秒。很多旧文章里关于 ioRatio 的调参结论在 4.2 上已经不成立。

另外,Netty 4.2 把 I/O 处理抽象成 IoHandler,NioEventLoop 和 NioEventLoopGroup 被标记为 deprecated;新项目更推荐 MultiThreadIoEventLoopGroup 配 NioIoHandler。

零拷贝

一次网络收发,数据从磁盘或内存到对端,中间通常要经过这些位置:

  • JVM 堆内内存(你 new byte[] 或普通 ByteBuf 所在的地方)
  • 直接内存 / 堆外内存(JVM 管理之外,但属于进程地址空间)
  • 内核缓冲区(socket 缓冲区、文件页缓存)
  • 网卡

传统路径里,数据每跨一道边界就要复制一次。所谓“零拷贝”,不是真的一次都不复制,而是砍掉用户态里那些多余、可以避免的搬运。

Netty 的零拷贝可以把它拆成三件事:

  1. 收发使用直接内存。Socket 读写如果使用堆内 ByteBuf,JVM 需要先把数据拷贝到直接内存,再交给内核;直接内存省掉堆到堆外这一趟。

    如果你的 ByteBuf 分配在 JVM 堆里,当你要通过 Socket 发送时,流程大致是

    1. 数据在堆内;
    2. JVM 要调用底层系统调用写 socket,但系统调用只能操作直接内存地址,不能直接拿堆内对象地址(因为 GC 会移动对象);
    3. 所以 JVM 先把堆内数据复制到一块直接内存;再从直接内存交给内核 socket 缓冲区;内核再发往网卡。

    堆内数据复制到一块直接内存这个堆到堆外的拷贝纯属 JVM 多余,如果 ByteBuf 一开始就分配在直接内存,那数据本来就在直接内存,直接交给内核即可,省掉了这部分搬运

  2. 组合与切片。多个小 Buffer 拼成一条逻辑消息时,用 CompositeByteBuf 把它们串成一个视图,而不是申请一个大数组逐个复制;

    实际协议里,一条完整消息常常由几段组成:比如 header 是一段、body 是另一段,可能来自不同的 ByteBuf。

    以前我们申请一个大小为 header.length + body.length 的新数组,把 header 拷进去,再把 body 拷进去。这就是一次批量复制,数据量越大越亏。

    CompositeByteBuf不申请新数组,而是把 header 和 body 这两个 ByteBuf 挂到一个逻辑视图上。对外看它是一个连续的 ByteBuf,内部其实是多个底层 buffer 的列表。读的时候按组件顺序依次读

  3. 切分消息时用 slice 或 retainedSlice 共享底层内存。

    有时你手里有一大块 ByteBuf,只想把其中一段当作独立消息处理。

    那么正常情况下,就是 new 一个数组然后拷出来,slice 不复制,返回一个共享同一块底层内存的新 ByteBuf,只是它的 readerIndex / writerIndex 被限定在那一段范围内。

  4. 文件传输。FileRegion 走 FileChannel.transferTo,让内核把文件页缓存直接送到目标 Channel,省掉用户态缓冲区这一趟。

    要把一个文件通过 socket 发出去,朴素做法是

    1. 内核把文件数据从磁盘/页缓存读到用户态缓冲区( byte[] 或 ByteBuf);
    2. 你的程序把这批数据写回内核的 socket 缓冲区;
    3. 内核再发往网卡。

    第 1、2 步,数据从内核出来到用户态,又从用户态回到内核。用户态缓冲区这一进一出在 Netty 眼中是多余的,因为不涉及到修改

    FileChannel.transferTo 是操作系统提供的系统调用,它让内核直接把文件页缓存的数据送到目标 Channel(socket),不经过用户态缓冲区。Netty 的 FileRegion 就是对这个能力的封装

Netty 零拷贝的三种典型路径

几个常用 API 可以这样理解:

1
2
3
4
5
6
7
8
9
// 组合多个 ByteBuf,不复制底层数据
CompositeByteBuf composite = Unpooled.compositeBuffer();
composite.addComponents(true, header, body);

// 切片:共享底层内存,读写位置独立
ByteBuf payload = buffer.slice(index, length);

// 文件传输:内核直接把文件送到 Channel
channel.writeAndFlush(new DefaultFileRegion(fileChannel, 0, fileChannel.size()));

零拷贝不等于完全不拷贝:内核与用户态之间的数据传递、协议栈封装、TLS 加解密都会产生拷贝或计算。Netty 优化的是用户态里多余的搬运。

CompositeByteBuf 也有取舍。它用随机访问的相对高开销换掉了批量拷贝,如果业务频繁按下标随机读写,合并成一个连续数组反而更划算。

直接内存分配慢、回收依赖引用计数,用错可能更慢。FileRegion 不适合需要改写数据的场景,例如 TLS 加密后的数据必须落到用户态缓冲区,也不能和需要应用层处理的编码器混用。

ByteBuf 与内存池

JDK 的 ByteBuffer 只有一个 position,读和写共用这个指针,所以每次读写切换要 flip()和compact(),这个最大的问题就是同一个 buffer 不能同时读写。

Netty 的 ByteBuf 把读指针 readerIndex 和写指针 writerIndex 分开,读写可以独立推进,容量也能按需增长。这是 ByteBuf 最基本的价值,后面所有形态和池化都是在这个基础上做的工程优化。

ByteBuf 有三种常见形态

  • 堆内存 ByteBuf

    数据放在 JVM 堆里,就是一个普通数组。因此分配只需要动一下指针,非常快,而且受 GC 管理。

    缺点是写 Socket 时多一次拷贝,因为 JVM 堆内地址不能直接交给系统调用,内核需要的数据必须先在直接内存里有一份,所以堆内数据要先复制到堆外再交给内核。

  • 直接内存 ByteBuf

    数据放在 JVM 堆外,也就是直接内存。写 Socket 时不需要 堆 → 堆外 这一趟复制,适合网络收发。代价是分配要走系统调用,比堆内慢,而且不受 GC 管理,必须手动释放。

  • CompositeByteBuf

    它本身不存数据,而是把多个 ByteBuf 当作组件挂在一起,对外表现为一个连续的 buffer,用来拼接消息。

    它是一个挂载视图,只维护一个组件列表而且不涉及复制,代价是随机按下标访问时要先定位落在哪个组件,比连续数组慢。

网络框架里 ByteBuf 的分配非常频繁,每个连接、每次读写都可能要一个新的,如果每次都向操作系统申请直接内存,成本会很高。于是 Netty 做池化,预先向操作系统申请一大块内存,自己切着用,用完不还给系统,而是还回池子留给下次再用。池化的 ByteBufAllocator 内容如下:

  1. PooledByteBufAllocator 按 heap(堆内存)和 direct(直接内存) 维护多组 PoolArena。

    PoolArena 是真正干活的管理单元。多线程同时分配会争抢同一份元数据,一个 Arena 撑不住高并发。所以 Netty 维护多组 Arena,让不同线程尽量落到不同 Arena 上,减少锁竞争。

  2. PoolArena 管理一大块连续内存 PoolChunk。

  3. PoolChunk 切成更小的单位来分配,按 pageSize 切成 Page,Page 再切成更小的 Subpage 给小对象使用。

  4. 每个线程还有 PoolThreadCache,常见规格直接命中缓存,减少全局分配和锁竞争。

ByteBuf 内存池的层级

池化的内存用完要还回池子,否则池子被占满、后续分配只能重新向系统要,池化就失去意义。Netty 用引用计数决定 什么时候算用完:

  • retain():计数 +1;
  • release():计数 -1;
  • 计数减到 0,这块内存归还池子。

规则是 谁 retain 谁负责 release。

  • 忘记 release:计数永远不归零,内存永远不归还,泄漏。
  • 重复 release:计数被多减,抛异常或内存被错误复用。
  • 跨线程传递时责任不清:A 线程创建、交给 B 线程用,如果两边都以为对方会 release,就漏了;如果两边都 release,就重复了。跨线程必须明确约定谁持有所有权、谁负责最后一次 release。

Netty 提供 ResourceLeakDetector 帮你抓泄漏。默认级别 SIMPLE,开销小但只能抓一部分;调试时开到 PARANOID,每次分配都记录,抓得全但很慢。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 所有权转移后,原持有方不再释放
ByteBuf buffer = ctx.alloc().buffer(256);
try {
buffer.writeBytes("hello".getBytes(StandardCharsets.UTF_8));
ctx.writeAndFlush(buffer);
// writeAndFlush 把 buffer 交给 Netty 的写流程后,释放责任就转移给了写操作。
// 如果这里不置空,finally 里又 release() 一次,就是重复 release。
buffer = null; // 所有权交给写操作,不再由这里释放
} finally {
if (buffer != null) {
buffer.release();
}
}

池化不是无条件更快。分配次数少、对象存活时间长、消息大小极端不规则的场景,池化的内存占用和管理开销可能盖过收益。网上常见的池化快多少倍的结论都有特定压测条件。

Pipeline 与事件驱动

每个 Channel 被创建时,Netty 会自动给它配一个 ChannelPipeline,一个 Channel 有且只有一个 Pipeline,全程不变。ChannelPipeline 内部是由 ChannelHandlerContext 组成的双向链表,每个 Context 关联一个 ChannelHandler。

既然是双向链表,就涉及到头尾双指针,head 和 tail。addLast 加进去的 Handler 都夹在它们中间。

入站事件也就是从网络进来的事件,比如连接建立、读到数据、连接断开、发生异常。传播方向是 head → tail,也就是按addLast 的顺序依次经过 Handler。

出站事件:要发出去的操作,比如写数据、关闭连接、连接远端。传播方向是 tail → head,方向相反。

对应地,传播方法也分两组:

  • 入站 Handler 处理完,调用 ctx.fireChannelRead(msg),事件继续往 tail 方向传给下一个入站 Handler。
  • 出站 Handler 处理完,调用 ctx.write(msg),事件继续往 head 方向传给下一个出站 Handler。

Pipeline 的基本规则

  • 不调用传播方法,事件就停在这里,后面的 Handler 收不到。
  • 同一个 Handler 加入的顺序不同,处理顺序就不同。入站按 head→tail、出站按 tail→head

最典型的例子是编解码器和业务的相对位置:解码器必须放在业务 Handler 的前面,靠近 head 一侧,这样字节流先被切成完整消息,业务 Handler 收到的才是完整对象。出站方向同理,编码器要放在业务 Handler 的后面,靠近 tail 一侧,业务写出的对象才会先经过编码器变成 ByteBuf,再发出去。

编解码器就是 Handler

  • ByteToMessageDecoder:入站。负责把字节流切成完整消息。TCP 网络数据是流式的,一次 read 可能读到半个消息,也可能读到两个半消息,这个解码器就是处理粘包和半包的地方。它是很多帧解码器的基类。
  • MessageToMessageDecoder:入站。负责消息到消息的转换,输入输出都是消息对象,不直接碰字节流。比如把 ByteBuf 解码出来的字节消息转成业务 POJO。
  • 编码器:出站,方向相反。把业务对象转成 ByteBuf,写回网络。

一个 Channel 绑定一个 EventLoop,这个 Channel 上的所有入站出站事件,默认都在这个 EventLoop 的线程里执行。事件在 Pipeline 里从头走到尾,中间不切线程。这样既没有锁,也不允许阻塞。它的价值在于关注点分离,拆包、编解码、业务、异常、超时各占一层,改协议只需要换编解码器,业务代码不用动。

常用 Handler 可以分成几类:

类型 典型类 作用
帧解码 LineBasedFrameDecoder、LengthFieldBasedFrameDecoder 解决粘包和半包,切出完整消息
消息解码 StringDecoder、ProtobufDecoder 把 ByteBuf 转成业务对象
消息编码 StringEncoder、ProtobufEncoder 把业务对象转成 ByteBuf
业务处理 SimpleChannelInboundHandler 业务回调,自动释放消息
异常处理 exceptionCaught 记录异常并关闭连接
  • 注意帧解码和消息解码是两阶段:先切出完整帧,再把帧转成对象。很多人把这两步混在一起,结果粘包没处理好,或者对象转换和切帧耦合在一起。。。。。。没错就是我

Pipeline 也有成本。链越长,每个事件的遍历和虚调用开销越大,热点链路要精简;Handler 顺序错误会导致编解码不生效,排查成本很高。

异步机制:ChannelFuture 与 Promise

Netty 的 I/O 调用都不等结果。bind、connect、write、close 会立即返回一个 ChannelFuture,调用者要么轮询状态,要么注册监听器,等操作完成后再处理成功或失败。

ChannelFuture 的常用方法包括 isDone、isSuccess、isCancelled、getCause 和 addListener。如果注册监听器时操作已经完成,监听器会被立即通知。

1
2
3
4
5
6
7
8
ChannelFuture future = bootstrap.bind(9001);
future.addListener(f -> {
if (f.isSuccess()) {
log.info("端口绑定成功");
} else {
log.error("端口绑定失败", f.cause());
}
});

ChannelPromise 是可写的 Future,由 Netty 内部在操作完成时写入结果。业务代码一般只读取 ChannelFuture,只有需要自己控制流程时才使用 Promise。

异步的好处是线程不阻塞,一个线程可以继续处理其他连接;代价是回调线程不确定,容易写出线程安全问题,而且失败分支很容易被忽略。在 EventLoop 线程里调用 sync 或 await 会抛 BlockingOperationException,甚至造成死锁,这是常见事故。正确做法是尽量使用监听器,或者在非 EventLoop 线程里才做同步等待。

性能基础设施与调优

除了模型和数据,Netty 还准备了一组基础设施,它们单个看起来不起眼,叠起来就是性能差距。

设施 作用 关键点
FastThreadLocal 替代 ThreadLocal 的哈希表查找 用常量下标访问数组,配合 FastThreadLocalThread 收益更明显
Recycler 轻量对象池 线程本地栈复用短命对象,默认每线程上限 4096
HashedWheelTimer 时间轮定时器 适合海量定时任务,默认 tick 100 毫秒、512 个槽
水位线 写缓冲背压 默认低水位 32 KiB、高水位 64 KiB,触发 isWritable 变化
批量 flush 减少系统调用和小包 write 入队,flush 才真正写 Socket;可用 FlushConsolidationHandler
TCP 参数 调整内核行为 SO_BACKLOG、TCP_NODELAY、SO_RCVBUF、SO_SNDBUF 等
序列化 决定码流大小和解析成本 长度字段定界;protobuf 通常比 Java 原生序列化更紧凑

调优时不要一上来就堆参数。先定位瓶颈,再单变量对比,记录基线,最后回归。

常见瓶颈包括 EventLoop 被阻塞、日志和字符串拼接过多、频繁创建对象、无界队列积压、写缓冲没有背压、线程数不合理以及 TCP 参数与业务不匹配。

Netty 的工作原理

Netty 的架构设计和核心组件

这是 Netty 的架构分层示意图:

Netty 架构分层示意图

Netty 的架构可以分成三层:

  1. Core 核心层:事件模型、通信 API、ByteBuf、可扩展的事件模型和统一的通信 API。
  2. Protocol Support 协议支持层:HTTP、WebSocket、SSL/TLS、压缩、编解码等协议能力。
  3. Transport Services 传输服务层:Socket、Datagram、In-VM Pipe 等底层通道实现。

传输层负责字节如何进出,协议层负责字节如何解释,核心层负责把两者组织成可扩展的事件驱动模型。业务代码通常只和核心层、协议层打交道,不需要关心底层是 NIO、epoll 还是其他传输实现。

把常用组件放在一起看:

组件 作用 关键点
Bootstrap / ServerBootstrap 启动配置器 客户端用 Bootstrap,服务端用 ServerBootstrap;后者有 boss/worker 两个线程组
EventLoopGroup / EventLoop 事件循环与线程 一个 EventLoop 绑定一个线程;一个 Channel 只绑定一个 EventLoop
Channel 连接抽象 监听 Channel 与已连接 Channel 分开;所有 I/O 操作都是异步的
ChannelPipeline Handler 链 入站从头到尾,出站从尾到头;支持动态增删
ChannelHandler / Context 业务与协议处理 Handler 负责逻辑,Context 控制传播起点
ByteBuf / ByteBufAllocator 数据载体与分配 读写指针分离,支持池化和直接内存
Codec 编解码器 本质是 Handler,负责字节与消息的转换
ChannelFuture / Promise 异步结果 I/O 立即返回,通过监听器处理结果
ChannelOption / AttributeKey 配置与通道属性 连接参数、水位线、自定义会话状态

理解这些组件之后,再看一次完整的运行时结构会更清楚。下面这张图把 ServerBootstrap、bossGroup、workerGroup、EventLoop 和 Pipeline 放在了一起:

Netty 服务端运行时结构

ServerBootstrap → EventLoopGroup → NioServerSocketChannel → ChannelPipeline → Handler → ByteBuf,恰好串起了 Netty 从启动、接受连接、处理事件到数据承载的完整生命周期。

Netty 的全局链路和工作原理

一次服务端启动,最终会形成这样的运行时结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ServerBootstrap(配置器)
│
├── bossGroup(EventLoopGroup,含若干 EventLoop,每个 EventLoop 绑一个线程)
│ └── 负责 accept 新连接
│
├── workerGroup(EventLoopGroup,含若干 EventLoop,每个 EventLoop 绑一个线程)
│ └── 负责已连接 Channel 的读写事件
│
└── NioServerSocketChannel(监听端口的 Channel)
└── 注册在 bossGroup 的某个 EventLoop 上
└── 每 accept 一个客户端,创建一个 NioSocketChannel
└── 注册到 workerGroup 的某个 EventLoop 上
└── 每个 NioSocketChannel 自带一个 ChannelPipeline
└── Pipeline 里按顺序挂着若干 ChannelHandler
└── Handler 读写的对象是 ByteBuf

把启动过程拆成步骤,大致是:

  1. 创建 bossGroup 和 workerGroup。
  2. 创建 ServerBootstrap,设置 channel 为 NioServerSocketChannel。
  3. 设置 TCP 参数和 ChannelInitializer。
  4. 调用 bind 绑定端口,Netty 创建监听 Channel 并注册到 bossGroup。
  5. 注册完成后开始监听,客户端连接进入 accept 流程。
  6. accept 得到 NioSocketChannel,交给 workerGroup 的某个 EventLoop。
  7. 子 Channel 初始化 Pipeline,把编解码 Handler、业务 Handler 等按顺序加入它的 Pipeline。
  8. 客户端发来数据后,EventLoop 从 Channel 读到ByteBuf,沿 Pipeline 传播
  9. 前面的解码器 Handler 把 ByteBuf 切成完整消息,转成业务对象继续向后传。
  10. 最终进入业务 Handler。之后就是处理和写回了。

详细的讲解就是如下

  • 服务端启动的第一步是配置 ServerBootstrap。它是 Netty 服务端的引导类,负责组装线程模型、通道类型和处理器链,最终通过 bind() 绑定端口并启动服务。

    ServerBootstrap 需要配置两个 EventLoopGroup,这对应了 Netty 的主从 Reactor 线程模型

    • bossGroup:只需要处理 accept 事件。负责接收客户端的 TCP 连接请求。它通常只需要少量线程,因为建立连接的动作本身不频繁。
    • workerGroup:负责处理已连接 Channel 上的 I/O 读写事件。当 bossGroup 接受一个连接后,会将生成的 NioSocketChannel 注册到 workerGroup 中的某个 EventLoop 上。

    ServerBootstrap 本身不处理任何网络事件,它只做三件事:

    • 接收你的配置:用哪个 EventLoopGroup 做 accept(boss)、哪个做读写(worker)、用哪种 Channel 实现(NioServerSocketChannel)、给新连接挂什么初始化器(ChannelInitializer)。
    • 调用 bind(port) 时,创建监听用的 NioServerSocketChannel,把它注册到 bossGroup,并执行端口绑定。
    • 每个新连接被 accept 后,按你配置的 ChannelInitializer 给新 Channel 装配 Pipeline。

    EventLoopGroup 内部包含多个 EventLoop,每个 EventLoop 绑定一个专属线程。一个 Channel 在生命周期内只会注册到一个 EventLoop 上,这意味着该 Channel 的所有 I/O 事件都由同一个线程处理,天然避免了并发同步问题,这就是上面说的无锁串行化

    EventLoop 是一个单线程事件循环:内部持有一个线程、一个 Selector、一个任务队列。它的 run() 循环不断做三件事:

    1. 调用 selector.select() 等待 I/O 就绪;
    2. 处理就绪的 I/O 事件(accept / read / write);
    3. 执行提交到这个 EventLoop 的普通任务和定时任务(execute() / schedule())。
  • Channel 是 Netty 对网络操作,读、写、连接、绑定,的抽象,对应传统 BIO 中的 Socket 概念。NioServerSocketChannel 是服务端监听通道的具体实现,基于 Java NIO 的 ServerSocketChannel 封装而成。

    当 ServerBootstrap 启动时,会创建 NioServerSocketChannel 并注册到 bossGroup 的某个 EventLoop 上,开始监听端口。一旦有客户端接入,Netty 会创建对应的 NioSocketChannel 代表这条新连接,并将其交给 workerGroup 管理。

  • ChannelPipeline 是 Netty 处理逻辑的核心容器,本质上是一个责任链。每个 Channel 创建时都会自动关联一个独立的 ChannelPipeline。

    Handler 的添加通常通过 ChannelInitializer 在通道初始化时,将自定义的 ChannelHandler 按顺序 addLast 到 pipeline 中。然后事件处理就是上面讲的事件传播

  • ByteBuf 是 Netty 中所有网络数据的实际承载者,它替代了 JDK NIO 的 ByteBuffer。上面读写索引分离啊,动态扩容啊,零拷贝啊,池化啊都是上面讲的内容了

    在 Pipeline 中流动的“消息”,本质上就是经过编解码器处理后封装或解析的 ByteBuf 对象。网络中流动的原始数据是 ByteBuf,Handler 之间的入站和出站消息都围绕它进行

服务端处理一次请求时,事件大致经过这些环节:

1
2
3
4
5
6
7
8
客户端连接
→ boss EventLoop accept,创建 NioSocketChannel
→ 注册到 worker EventLoop
→ Pipeline.fireChannelRead
→ 帧解码器切出完整消息
→ 业务 Handler 处理
→ 编码器把响应编码成 ByteBuf
→ 写回客户端

连接建立阶段:boss EventLoop 的线程执行 accept,拿到 NioSocketChannel,把它注册到 workerGroup 的某个 EventLoop 上。注册完成后,这个 Channel 就归属那个 worker EventLoop 了,之后它的所有事件都由那个线程处理。

数据处理阶段:worker EventLoop 检测到读就绪,把字节读进 ByteBuf,生成入站 channelRead 事件,从 Pipeline 的 head 开始向后传播,依次经过帧解码器、业务 Handler,业务要回复时产生出站事件,反向经过编码器,最终写回。

Handler 是理解这条链路的关键。最常用的两种写法是 ChannelInboundHandlerAdapter 和 SimpleChannelInboundHandler:

写法 是否自动释放消息 适用场景
ChannelInboundHandlerAdapter 否 需要自己控制 ByteBuf 生命周期,或者要把消息继续往后传
SimpleChannelInboundHandler 是 业务处理完就结束,不关心释放细节,适合字符串或对象消息

常见回调的时机如下,这些回调是 ChannelInboundHandler 的生命周期钩子,按发生顺序排列如下

回调 触发时机 常见用途
channelRegistered Channel 注册到 EventLoop 初始化会话状态
channelActive 连接建立并可用 发送欢迎消息,记录在线状态
channelRead 读到一条消息 业务处理
channelReadComplete 一批读事件处理完 批量 flush 或统计
channelInactive 连接断开 清理的可靠位置,广播下线
exceptionCaught 处理过程中出现异常 记录日志并关闭连接
  • channelRead 和 channelReadComplete 不是一一对应的。 一次读循环可能读到多条消息,channelRead 被调多次,然后 channelReadComplete 调一次。所以“一条消息处理完”不能靠 channelReadComplete 判断,它表示,这一批读事件处理完了。
  • channelRegistered 早于 channelActive。 注册只表示 Channel 归属了某个 EventLoop,连接可能还没建立好;channelActive 才是连接可用、可以读写的时刻。初始化会话状态可以放 channelRegistered,但涉及网络读写的动作要等 channelActive。

ChannelInitializer 是连接建立时给 Pipeline 装配 Handler 的地方。它只会在 Channel 注册时执行一次,每个新连接都会触发一次 initChannel,所以你在 initChannel 里 new 出来的 Handler,是这个连接专属的实例,因此可以在里面 new 出有状态的 Handler,不会出现多个连接共享同一个实例的问题,成员变量也不会在多个连接之间共享,所以在 Handler 里放会话状态是安全的。

入站事件按注册顺序从 head 传到 tail,出站事件按相反方向传播。ctx.write从当前 Handler 的位置开始向前找出站 Handler,channel.write 从 Pipeline 的尾部开始,二者在复杂 Pipeline 中结果不同。

ctx.write(msg):从当前 Handler 的位置开始,向 head 方向找出站 Handler。也就是说,它只经过当前 Handler 前面(靠近 head 一侧)的那些出站 Handler,当前 Handler 后面的出站 Handler 不会被经过。

channel.write(msg):从 Pipeline 的尾部(tail) 开始,向 head 方向传播。它会经过整条链上所有的出站 Handler。

写一个 Netty 基础工程了解 Netty

工程概况

1
2
3
4
5
Nettydemo/src/main/java/hbnu/project/nettydemo/demo/echo/
├── EchoServer.java 服务端入口:Bootstrap、线程组、Pipeline
├── EchoServerHandler.java 服务端业务:打印并回显
├── EchoClient.java 客户端入口:连接、控制台输入
└── EchoClientHandler.java 客户端业务:打印服务端回显
1
2
3
Nettydemo/src/test/java/hbnu/project/nettydemo/demo/echo/
├── EchoServerHandlerTest.java EmbeddedChannel 单元测试
└── EchoServerIntegrationTest.java 随机端口真实 socket 集成测试

服务端代码

先看服务端入口。它把前面讲的启动流程完整走了一遍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public final class EchoServer {
private static final int DEFAULT_PORT = 9001;
private static final int MAX_FRAME_LENGTH = 1024;

public static void main(String[] args) throws InterruptedException {
int port = args.length > 0 ? Integer.parseInt(args[0]) : DEFAULT_PORT;

// 1. boss 只负责 accept,worker 负责读写
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
// 2. 组装启动配置
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childOption(ChannelOption.TCP_NODELAY, true)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline()
// 入站:先按行拆帧,再解码成字符串
.addLast(new LineBasedFrameDecoder(MAX_FRAME_LENGTH))
.addLast(new StringDecoder(StandardCharsets.UTF_8))
// 出站:把字符串编码成 UTF-8 字节
.addLast(new StringEncoder(StandardCharsets.UTF_8))
// 业务处理
.addLast(new EchoServerHandler());
}
});

// 3. 绑定本机回环端口,等待成功
Channel channel = bootstrap.bind("127.0.0.1", port).sync().channel();
log.info("Echo 服务已启动,端口 {}", port);

// 4. 阻塞到 Channel 关闭
channel.closeFuture().sync();
} finally {
// 5. 优雅关闭两个线程组
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
bossGroup.terminationFuture().sync();
workerGroup.terminationFuture().sync();
}
}
}

这里有五个关键点:

  1. group 传入两个线程组,正好对应主从 Reactor 模型。
  2. channel 指定 NioServerSocketChannel,底层使用 JDK NIO 的 Selector。
  3. option 作用于监听 Channel,childOption 作用于 accept 出来的子 Channel。
  4. childHandler 里的 ChannelInitializer 是每条新连接都会执行一次的装配入口。
  5. bind 是异步操作,sync 只是等待绑定结果;真正的读写由 EventLoop 在后台自动进行。

Pipeline 里三个 Handler 的顺序很关键。入站字节先经过 LineBasedFrameDecoder 拆成完整行,再经过 StringDecoder 变成字符串,最后交给业务 Handler;出站时字符串先经过 StringEncoder 变成 ByteBuf,再写回客户端。如果把编码器放到业务 Handler 前面,出站方向就会先经过其他 Handler,顺序会乱。

服务端 Handler 只做三件事:连接日志、回显、异常处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class EchoServerHandler extends SimpleChannelInboundHandler<String> {
public static final String ECHO_PREFIX = "echo: ";

@Override
public void channelActive(ChannelHandlerContext ctx) {
log.info("客户端已连接:{}", ctx.channel().remoteAddress());
ctx.fireChannelActive();
}

@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) {
log.info("收到消息:{}", msg);
// 回显必须带上换行符,对端才能按行拆帧
ctx.writeAndFlush(ECHO_PREFIX + msg + System.lineSeparator());
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
log.warn("连接异常,关闭连接", cause);
ctx.close();
}
}

SimpleChannelInboundHandler 会自动释放消息持有的 ByteBuf,所以业务代码不需要手写 release。也正因为如此,这类 Handler 不应该加 @Sharable 在不同连接之间共享,每次建立连接都应该创建独立实例。

客户端代码

客户端用 Bootstrap,结构比服务端简单,只需要一个 EventLoopGroup:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
EventLoopGroup group = new NioEventLoopGroup();
try {
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group)
.channel(NioSocketChannel.class)
.option(ChannelOption.TCP_NODELAY, true)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast(new LineBasedFrameDecoder(1024))
.addLast(new StringDecoder(StandardCharsets.UTF_8))
.addLast(new StringEncoder(StandardCharsets.UTF_8))
.addLast(new EchoClientHandler());
}
});

Channel channel = bootstrap.connect("127.0.0.1", 9001).sync().channel();

// 从控制台读一行发一行
BufferedReader console = new BufferedReader(
new InputStreamReader(System.in, StandardCharsets.UTF_8));
String line;
while ((line = console.readLine()) != null) {
if ("quit".equalsIgnoreCase(line.trim())) {
break;
}
channel.writeAndFlush(line + System.lineSeparator());
}

channel.close().sync();
} finally {
group.shutdownGracefully().sync();
}

客户端 Handler 更简单,只打印服务端回显:

1
2
3
4
5
6
7
8
9
10
11
12
public class EchoClientHandler extends SimpleChannelInboundHandler<String> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, String msg) {
log.info("服务端回显:{}", msg);
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
log.warn("客户端异常,关闭连接", cause);
ctx.close();
}
}

验证

先运行 EchoServer#main,控制台出现服务端已启动。

image-20261024180833001

再运行 EchoClient#main,在控制台输入内容并回车。

image-20261024180921134

客户端会打印服务端回显,服务端控制台也会打印收到的消息。

image-20261024180912699

客户端输入 quit 退出。

这就是最简单的网络通信。

关于 Netty 4.2 的启动写法

上面的示例使用了 NioEventLoopGroup。Netty 4.2 已经把它标记为 deprecated,新的写法是:

1
2
3
4
EventLoopGroup bossGroup = new MultiThreadIoEventLoopGroup(
1, NioIoHandler.newFactory());
EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(
NioIoHandler.newFactory());

两种写法在当前版本都能运行,行为也一致。学习阶段先理解 boss/worker 的职责和 EventLoop 的绑定关系,比记住具体类名更重要;写新项目时再按 4.2 的推荐 API 调整即可。