
Channel 既是 Netty 对网络连接套接字的抽象,也是所有 IO 数据进出 Netty 的唯一通道。但 Channel 对外暴露的方法绝大多数只是异步编排入口,真正把数据写进操作系统、从操作系统读出数据的,是收口在 Unsafe 内部接口里的底层 IO 操作。一次 bind 如何让端口真正监听,一次 connect 如何在非阻塞模式下完成连接握手,一次 read 如何把字节读进 ByteBuf 并送入 Pipeline,一次 write/flush 又如何把 ChannelOutboundBuffer 里的数据批量写进 Socket,这些正是本文要回答的问题。
在 Netty 4.2 的 IoHandler/IoHandle 抽象下,这些操作统一收口到 handle(IoRegistration, IoEvent) 一个入口完成就绪事件分发,兴趣事件的增删由 NioIoOps 位图配合 IoRegistration.submit() 提交内核。围绕 Unsafe 读写操作从 Netty 到操作系统的完整流转路径,以下几个问题将贯穿本文的源码分析主线:
- 在 Netty 4.2 的 IoHandler/IoHandle 抽象下,
AbstractNioUnsafe如何通过handle(IoRegistration, IoEvent)这一个统一入口分发连接、写入、读取三类 IO 事件,NioIoOps兴趣位图又如何与 JDK 的SelectionKey兴趣集一一对应? AbstractUnsafe.connect()如何通过doConnect()提交 JDKSocketChannel.connect(),连接未就绪时如何用addAndSubmit(NioIoOps.CONNECT)注册连接关注,finishConnect()又如何在握手完成后触发fireChannelActive()?read()作为读操作统一入口,如何按 Channel 类型分流,服务端NioServerSocketChannel.doReadMessages()的 accept 与客户端NioSocketChannel.doReadBytes()的字节读取各自如何把数据送入 Pipeline?flush()如何借助ChannelOutboundBuffer触发doWrite()循环写入,字节型 Channel 的 gather write 与消息型 Channel 的逐条发送有何差异,写不动时又是如何通过addAndSubmit(NioIoOps.WRITE)让 EventLoop 稍后接管续写的?doBeginRead()如何通过readPending标志与addAndSubmit(readOps)建立读关注,addAndSubmit/removeAndSubmit又在registration().submit()这一步如何完成从兴趣集变更到内核生效的提交?
本文将沿着"总览 → 绑定 → 连接 → 读取 → 写入 → 关注管理 → 链路串联"的顺序,先剖析 I/O 操作内核的三层模型与 handle() 统一分发入口,再依次分析 bind、connect/finishConnect、read、write/flush 四种操作的落地实现,最后收口 doBeginRead 与 doClose 的兴趣管理与资源释放,串联起数据从 Netty 到操作系统的完整闭环。
一、I/O 操作内核总览:三层模型、NioIoOps 兴趣位图与 handle 统一分发
1.1 三层协作模型:Unsafe → JDK Channel → OS
理解 Netty 底层 IO 操作,首先要建立一张"三层协作"的全局认知图。所谓三层协作模型,是指一次 IO 操作从发起到真正生效,要依次穿越 Unsafe 策略层、JDK Channel 层与操作系统 Socket 三层:

最上层是 Unsafe,它是所有危险 IO 操作的收口点,Channel 对外暴露的安全 API 最终都收敛到这里;中间层是 JDK 的 SelectableChannel 及其子类(SocketChannel、ServerSocketChannel),Netty 并不直接操作内核,而是调用这些 JDK 封装的 NIO 通道;最底层才是操作系统 Socket 与内核协议栈,真正完成数据收发。三层之间各司其职,Unsafe 只关心"做什么",JDK Channel 只关心"怎么调用",内核只关心"数据往哪去"。
1.2 NioIoOps 兴趣位图:兴趣事件的位掩码表达
在分析具体操作之前,需要先认识一个贯穿全文的数据结构:NioIoOps。它位于 io.netty.channel.nio 包,是 Netty 4.2 为 NIO transport 引入的兴趣事件位掩码,与 JDK SelectionKey 的兴趣集常量一一对应:
java
io.netty.channel.nio.NioIoOps
public final class NioIoOps implements IoOps {
// 不关注任何 IO 事件
public static final NioIoOps NONE = new NioIoOps(0);
// 关注新连接接入(服务端)
public static final NioIoOps ACCEPT = new NioIoOps(SelectionKey.OP_ACCEPT);
// 关注连接建立完成
public static final NioIoOps CONNECT = new NioIoOps(SelectionKey.OP_CONNECT);
// 关注通道可写
public static final NioIoOps WRITE = new NioIoOps(SelectionKey.OP_WRITE);
// 关注通道可读
public static final NioIoOps READ = new NioIoOps(SelectionKey.OP_READ);
// 同时关注可读 + 新连接
public static final NioIoOps READ_AND_ACCEPT = new NioIoOps(SelectionKey.OP_READ | SelectionKey.OP_ACCEPT);
// 同时关注可读 + 可写
public static final NioIoOps READ_AND_WRITE = new NioIoOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
final int value;
private NioIoOps(int value) {
this.value = value;
}
// 判断当前位图是否已包含给定操作
public boolean contains(NioIoOps ops) {
return isIncludedIn(ops.value);
}
public boolean isIncludedIn(int ops) {
return (ops & value) != 0;
}
// 返回底层 int 位掩码值
public int value() {
return value;
}
// 在当前位置图上追加一个操作(位或)
public NioIoOps with(NioIoOps ops) {
if (contains(ops)) {
return this;
}
return valueOf(value | ops.value());
}
// 从当前位置图上移除一个操作(位清除)
public NioIoOps without(NioIoOps ops) {
if (!contains(ops)) {
return this;
}
return valueOf(value & ~ops.value());
}
}
NioIoOps 的本质是一个携带 int value 的不可变位图,value 直接复用 JDK 的 SelectionKey.OP_ACCEPT/OP_READ/OP_WRITE/OP_CONNECT 常量。它提供了三个关键运算:contains() 判断某个操作是否已存在于位图,with() 用位或追加一个操作,without() 用位清除移除一个操作。这三个运算构成了后续 addAndSubmit/removeAndSubmit 兴趣集变更的原子基础。
每个 Channel 在构造时都会确定自己的读关注类型,保存在 AbstractNioChannel 的 readOps(位图)与 readInterestOp(int)两个字段中:
java
io.netty.channel.nio.AbstractNioChannel
public abstract class AbstractNioChannel extends AbstractChannel {
private final SelectableChannel ch;
protected final int readInterestOp;
protected final NioIoOps readOps;
volatile IoRegistration registration;
boolean readPending;
protected AbstractNioChannel(Channel parent, SelectableChannel ch, NioIoOps readOps) {
super(parent);
this.ch = ch;
// 同时保存 int 形兴趣值(供 SelectionKey 场景使用)与位图形 readOps(供位运算使用)
this.readInterestOp = ObjectUtil.checkNotNull(readOps, "readOps").value;
this.readOps = readOps;
try {
// 统一设置为非阻塞模式,这是 Reactor 模式的前提
ch.configureBlocking(false);
} catch (IOException e) {
// 设置非阻塞失败则关闭底层通道并抛异常
try {
ch.close();
} catch (IOException e2) {
logger.warn("Failed to close a partially initialized socket.", e2);
}
throw new ChannelException("Failed to enter non-blocking mode.", e);
}
}
}
readOps 的取值由子类决定:字节型通道 AbstractNioByteChannel 构造函数传入 SelectionKey.OP_READ(即 READ),服务端 NioServerSocketChannel 传入 SelectionKey.OP_ACCEPT(即 ACCEPT)。这意味着"读关注"在客户端是读数据,在服务端是接新连接,二者共用 readOps 这一抽象。
1.3 handle 统一分发:连接、写入、读取的单一入口
在 IoHandler/IoHandle 抽象下,就绪事件被封装成 NioIoEvent,统一交给 AbstractNioUnsafe.handle() 一个入口分发:
java
io.netty.channel.nio.AbstractNioChannel.AbstractNioUnsafe#handle
@Override
public void handle(IoRegistration registration, IoEvent event) {
try {
NioIoEvent nioEvent = (NioIoEvent) event;
NioIoOps nioReadyOps = nioEvent.ops();
// 必须先处理 OP_CONNECT,否则 JDK 通道会抛 NotYetConnectedException
if (nioReadyOps.contains(NioIoOps.CONNECT)) {
// 移除 OP_CONNECT,否则 Selector.select() 会一直立即返回而无法阻塞
removeAndSubmit(NioIoOps.CONNECT);
unsafe().finishConnect();
}
// 优先处理 OP_WRITE,尽可能先把积压的缓冲区写出去,从而释放内存
if (nioReadyOps.contains(NioIoOps.WRITE)) {
// forceFlush 内部会负责在所有数据写完后清除 OP_WRITE
forceFlush();
}
// 最后处理读就绪;readOps 为 0 也被视为读就绪,用于规避 JDK 可能的空转 bug
if (nioReadyOps.contains(NioIoOps.READ_AND_ACCEPT) || nioReadyOps.equals(NioIoOps.NONE)) {
read();
}
} catch (CancelledKeyException ignored) {
// SelectionKey 已被取消,直接关闭通道
close(voidPromise());
}
}
handle() 的分发逻辑有严格的顺序考量:先连接、再写入、后读取 。之所以先处理 CONNECT,是因为连接未建立完成前,JDK 通道还不允许读或写,若跳过连接处理直接读写会抛 NotYetConnectedException;之所以在处理 CONNECT 时立即 removeAndSubmit(CONNECT),是因为连接关注一旦建立且未移除,Selector.select() 会一直返回就绪而不阻塞,导致空转。写入优先于读取,则是因为把积压数据尽早发出去可以尽快释放写缓冲区占用的内存。
配合 handle() 的是兴趣事件的增删方法 addAndSubmit/removeAndSubmit,它们完成了从位图变更到内核生效的提交:
java
io.netty.channel.nio.AbstractNioChannel#addAndSubmit / #removeAndSubmit
protected void addAndSubmit(NioIoOps addOps) {
int interestOps = selectionKey().interestOps();
// 幂等保护:已包含该操作则无需重复提交
if (!addOps.isIncludedIn(interestOps)) {
try {
// 位或合并新掩码后,交给 IoRegistration 提交到内核
registration().submit(NioIoOps.valueOf(interestOps).with(addOps));
} catch (Exception e) {
throw new ChannelException(e);
}
}
}
protected void removeAndSubmit(NioIoOps removeOps) {
int interestOps = selectionKey().interestOps();
if (removeOps.isIncludedIn(interestOps)) {
try {
// 位清除移出该操作后提交
registration().submit(NioIoOps.valueOf(interestOps).without(removeOps));
} catch (Exception e) {
throw new ChannelException(e);
}
}
}
这两个方法先是读取当前 selectionKey().interestOps() 得到现有兴趣集,再通过 NioIoOps.valueOf(interestOps).with(addOps)(位或)或 .without(removeOps)(位清除)计算出新的掩码,最后统一交给 registration().submit(...) 提交。值得注意的细节是:提交前会先做一次"是否已包含/未包含"的幂等判断,避免对内核发起无谓的重复提交,这是对 NIO 与 epoll 两类 transport 都适用的统一优化。这条"计算新掩码 → 提交内核"的链路,也是后文 doConnect、doBeginRead、doWrite 等所有兴趣变更操作的公共底座。
二、端口绑定:bind 的全链路落地
2.1 AbstractUnsafe.bind:入口校验与激活传播
bind 用于把本地端口与底层 Socket 绑定,是服务端"先监听、后接入"的第一步。它的入口同样收口在 AbstractUnsafe 中:
java
io.netty.channel.AbstractChannel.AbstractUnsafe#bind
@Override
public final void bind(final SocketAddress localAddress, final ChannelPromise promise) {
assertEventLoop();
// 断言在 EventLoop 线程内,且 promise 未被取消、通道仍打开
if (!promise.setUncancellable() || !ensureOpen(promise)) {
return;
}
// 记录绑定前的激活状态,用于后续判断是否需要触发 channelActive
boolean wasActive = isActive();
try {
doBind(localAddress);
} catch (Throwable t) {
// 绑定失败则使 promise 失败,并视情况关闭通道
safeSetFailure(promise, t);
closeIfClosed();
return;
}
// 由未激活变为激活时,异步传播 channelActive 事件
if (!wasActive && isActive()) {
invokeLater(new Runnable() {
@Override
public void run() {
pipeline.fireChannelActive();
}
});
}
safeSetSuccess(promise);
}
这段逻辑清晰地体现了 Netty 出站操作的两个共性:一是所有操作都要求运行在 EventLoop 线程内(assertEventLoop()),保证线程安全;二是通过 wasActive/isActive() 的前后对比,判断本次绑定是否让通道由"未激活"变为"激活",若是则异步触发 fireChannelActive() 传播激活事件。之所以用 invokeLater 延迟触发,是为了避免在 bind 调用栈内同步传播事件,给用户 handler 一个干净的调用栈。
bind 真正落地依赖的是 doBind(localAddress),这是定义在 AbstractChannel 上的 abstract 模板方法,由 transport 层子类实现。
2.2 服务端与客户端的 doBind 落地
服务端的 NioServerSocketChannel 与客户端的 NioSocketChannel 各自实现了 doBind,底层调用截然不同:
java
io.netty.channel.socket.nio.NioServerSocketChannel#doBind
@Override
protected void doBind(SocketAddress localAddress) throws Exception {
// 服务端绑定并设置 backlog,决定内核全连接队列长度
javaChannel().bind(localAddress, config.getBacklog());
}
java
io.netty.channel.socket.nio.NioSocketChannel#doBind / #doBind0
@Override
protected void doBind(SocketAddress localAddress) throws Exception {
doBind0(localAddress);
}
private void doBind0(SocketAddress localAddress) throws Exception {
// 客户端借助 SocketUtils 进行绑定(通常仅在显式指定本地地址时才走此路径)
SocketUtils.bind(javaChannel(), localAddress);
}
服务端的 doBind 直接调用 JDK ServerSocketChannel.bind(localAddress, backlog),多传了一个 backlog 参数,它来自 ServerSocketChannelConfig,决定内核维护的全连接队列最大长度;客户端则委托 SocketUtils.bind,一般情况下客户端发起连接时内核会自动分配本地端口,只有显式指定了 localAddress 时才需要真正绑定本地地址。
整个 bind 流程可以用下面的时序图概括:

上面的时序图清晰地展示了两层结构:AbstractUnsafe.bind 负责校验、状态对比与事件传播,doBind 负责真正调用 JDK 绑定。绑定完成后,服务端就可以继续注册 ACCEPT 关注去接新连接了。
三、连接建立:connect 与非阻塞握手 finishConnect
3.1 connect 入口:非阻塞提交与 OP_CONNECT 注册
客户端的连接建立是 Netty 中最能体现"非阻塞 + 事件驱动"的一段逻辑。连接的发起入口是 AbstractNioUnsafe.connect:
java
io.netty.channel.nio.AbstractNioChannel.AbstractNioUnsafe#connect
@Override
public final void connect(
final SocketAddress remoteAddress, final SocketAddress localAddress, final ChannelPromise promise) {
// 连接 promise 无需标记为不可取消:非阻塞连接本就允许被取消
if (promise.isDone() || !ensureOpen(promise)) {
return;
}
try {
if (connectPromise != null) {
// 同一时刻只允许一个进行中的连接
throw new ConnectionPendingException();
}
boolean wasActive = isActive();
if (doConnect(remoteAddress, localAddress)) {
// 立即连接成功(本地回环等场景)
fulfillConnectPromise(promise, wasActive);
} else {
// 连接未就绪,挂起 promise,等待 OP_CONNECT 就绪后由 finishConnect 完成
connectPromise = promise;
requestedRemoteAddress = remoteAddress;
final int connectTimeoutMillis = config().getConnectTimeoutMillis();
if (connectTimeoutMillis > 0) {
// 注册连接超时任务,超时未完成则失败 promise 并关闭通道
connectTimeoutFuture = eventLoop().schedule(new Runnable() {
@Override
public void run() {
ChannelPromise connectPromise = AbstractNioChannel.this.connectPromise;
if (connectPromise != null && !connectPromise.isDone()
&& connectPromise.tryFailure(new ConnectTimeoutException(
"connection timed out after " + connectTimeoutMillis + " ms: " +
remoteAddress))) {
close(voidPromise());
}
}
}, connectTimeoutMillis, TimeUnit.MILLISECONDS);
}
// 若用户取消连接 future,同步取消超时任务并关闭底层 socket
promise.addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) {
if (future.isCancelled()) {
if (connectTimeoutFuture != null) {
connectTimeoutFuture.cancel(false);
}
connectPromise = null;
close(voidPromise());
}
}
});
}
} catch (Throwable t) {
promise.tryFailure(annotateConnectException(t, remoteAddress));
closeIfClosed();
}
}
connect 的核心是通过 connectPromise 字段保证"同一时刻只有一个进行中的连接",若已有连接在途,后续连接请求直接抛 ConnectionPendingException。doConnect 的返回结果决定了后续走向:立即成功则 fulfillConnectPromise 直接完成;未就绪则保存 connectPromise、requestedRemoteAddress 并注册超时任务,等待事件驱动后续完成。
真正提交 JDK 连接的是 NioSocketChannel.doConnect:
java
io.netty.channel.socket.nio.NioSocketChannel#doConnect
@Override
protected boolean doConnect(SocketAddress remoteAddress, SocketAddress localAddress) throws Exception {
// 显式指定本地地址时先绑定
if (localAddress != null) {
doBind0(localAddress);
}
boolean success = false;
try {
// 非阻塞 connect:JDK 立即返回,未建立连接时返回 false
boolean connected = SocketUtils.connect(javaChannel(), remoteAddress);
if (!connected) {
// 连接未就绪,注册 OP_CONNECT 关注,等就绪后再 finishConnect
addAndSubmit(NioIoOps.CONNECT);
}
success = true;
return connected;
} finally {
if (!success) {
// 连接提交过程抛异常,关闭底层通道兜底
doClose();
}
}
}
这里的 SocketUtils.connect(javaChannel(), remoteAddress) 是非阻塞调用,它要么立即完成连接返回 true(典型如本地回环地址),要么返回 false 表示连接"已发出但尚未完成"。后一种情况下,doConnect 通过 addAndSubmit(NioIoOps.CONNECT) 注册 OP_CONNECT 关注,告诉 EventLoop"连接完成时叫我"。这正是非阻塞连接的精髓:发起连接只是提交,真正完成要交给事件循环异步推进。
3.2 finishConnect:握手完成与 fireChannelActive
当 TCP 三次握手完成、OP_CONNECT 就绪后,EventLoop 会回到第一节的 handle() 入口,先执行 removeAndSubmit(CONNECT) 移除连接关注,再调用 finishConnect() 完成连接:
java
io.netty.channel.nio.AbstractNioChannel.AbstractNioUnsafe#finishConnect
@Override
public final void finishConnect() {
// 仅由 EventLoop 在连接未取消、未超时时调用
assert eventLoop().inEventLoop();
try {
boolean wasActive = isActive();
// 完成 JDK 层的连接
doFinishConnect();
fulfillConnectPromise(connectPromise, wasActive);
} catch (Throwable t) {
fulfillConnectPromise(connectPromise, annotateConnectException(t, requestedRemoteAddress));
} finally {
// 无论成败都要清理连接状态
if (connectTimeoutFuture != null) {
connectTimeoutFuture.cancel(false);
}
connectPromise = null;
}
}
finishConnect 记录 wasActive 后调用 doFinishConnect() 完成握手,再走 fulfillConnectPromise 完成 promise 并传播激活事件。doFinishConnect 的落地实现很简单:
java
io.netty.channel.socket.nio.NioSocketChannel#doFinishConnect
@Override
protected void doFinishConnect() throws Exception {
// JDK 层完成连接;返回 false 表示连接未建立或失败
if (!javaChannel().finishConnect()) {
throw new UnsupportedOperationException("finishConnect is not supported for " + getClass().getName());
}
}
而连接完成后激活事件的传播,由 fulfillConnectPromise 统一负责:
java
io.netty.channel.nio.AbstractNioChannel.AbstractNioUnsafe#fulfillConnectPromise
private void fulfillConnectPromise(ChannelPromise promise, boolean wasActive) {
if (promise == null) {
// 已因取消而关闭,promise 已被通知,无需处理
return;
}
// 先取激活状态:trySuccess 可能触发 listener 关闭通道
boolean active = isActive();
boolean promiseSet = promise.trySuccess();
// 无论连接是否被用户取消,channelActive 事件都应触发(事实已经发生)
if (!wasActive && active) {
pipeline().fireChannelActive();
}
// 若用户取消了连接尝试,则关闭通道(伴随后续的 channelInactive)
if (!promiseSet) {
close(voidPromise());
}
}
fulfillConnectPromise 有一个巧妙的设计:它先取 promise.trySuccess() 的结果再决定是否 close。这是因为 trySuccess() 可能触发注册在 promise 上的 listener,而 listener 里用户可能已经关闭了通道或取消了连接,所以必须在 trySuccess() 之后重新评估状态,避免激活事件传播错误。无论连接最终是否被用户取消,只要确实由未激活变为激活,fireChannelActive() 都应触发。
连接链路的完整时序如下:

上面的时序图清晰地展示了非阻塞连接的两段式结构:第一段由用户主动发起,doConnect 提交 JDK 连接并注册 OP_CONNECT 关注后立即返回;第二段由事件驱动推进,OP_CONNECT 就绪后 handle 移除关注并 finishConnect 收尾。两段之间靠 connectPromise 字段串起状态。
四、读取内核:read 统一入口与 accept/readBytes 分流
4.1 服务端 NioMessageUnsafe:accept 构建子 Channel
read() 同样由 handle() 统一触发,但具体实现按 Channel 类型分叉:服务端走 NioMessageUnsafe,客户端走 NioByteUnsafe。先看服务端的 NioMessageUnsafe.read:
java
io.netty.channel.nio.AbstractNioMessageChannel.NioMessageUnsafe#read
private final List<Object> readBuf = new ArrayList<Object>();
@Override
public void read() {
assert eventLoop().inEventLoop();
final ChannelConfig config = config();
final ChannelPipeline pipeline = pipeline();
final RecvByteBufAllocator.Handle allocHandle = unsafe().recvBufAllocHandle();
allocHandle.reset(config);
boolean closed = false;
Throwable exception = null;
try {
try {
do {
// 一次 accept 一个连接,返回 0 表示本轮已无连接可接
int localRead = doReadMessages(readBuf);
if (localRead == 0) {
break;
}
if (localRead < 0) {
closed = true;
break;
}
allocHandle.incMessagesRead(localRead);
} while (continueReading(allocHandle));
} catch (Throwable t) {
exception = t;
}
// 把本轮 accept 到的所有子 Channel 逐个上抛给 Pipeline
int size = readBuf.size();
for (int i = 0; i < size; i ++) {
readPending = false;
pipeline.fireChannelRead(readBuf.get(i));
}
readBuf.clear();
allocHandle.readComplete();
pipeline.fireChannelReadComplete();
// 读取过程抛出异常时,据异常类型判断是否关闭通道并上抛给 Pipeline
if (exception != null) {
closed = closeOnReadError(exception);
pipeline.fireExceptionCaught(exception);
}
// 需要关闭(如 accept 出错)时,置 inputShutdown 并关闭通道
if (closed) {
inputShutdown = true;
if (isOpen()) {
close(voidPromise());
}
}
} finally {
// 未挂起读且关闭了 autoRead 时,回收读关注
if (!readPending && !config.isAutoRead()) {
removeReadOp();
}
}
}
服务端读取的核心是一个 do...while 循环:反复调用 doReadMessages(readBuf),把 accept 到的子 Channel 收集进 readBuf,直到 continueReading(allocHandle) 判定应该停止(防止单个服务端 Channel 通过大量 accept 饿死同一 EventLoop 上的其他 Channel)。循环结束后,再统一遍历 readBuf,把每个新接入的 NioSocketChannel 通过 fireChannelRead 交给 Pipeline。
doReadMessages 真正调用了 JDK 的 accept:
java
io.netty.channel.socket.nio.NioServerSocketChannel#doReadMessages
@Override
protected int doReadMessages(List<Object> buf) throws Exception {
SocketChannel ch = SocketUtils.accept(javaChannel());
try {
if (ch != null) {
// 为接入的 JDK 通道包装出 Netty 的 NioSocketChannel
buf.add(new NioSocketChannel(this, ch));
return 1;
}
} catch (Throwable t) {
logger.warn("Failed to create a new channel from an accepted socket.", t);
try {
ch.close();
} catch (Throwable t2) {
logger.warn("Failed to close a socket.", t2);
}
}
return 0;
}
SocketUtils.accept(javaChannel()) 返回的 SocketChannel 是 JDK 层的连接通道,doReadMessages 用 new NioSocketChannel(this, ch) 把它包装成 Netty 的子 Channel,其中 this 是父 Channel(服务端),ch 是接入的 JDK 通道。返回值 1 表示接入了一个连接,0 表示本轮无连接可接。
4.2 客户端 NioByteUnsafe:RecvByteBufAllocator 自适应读取
客户端的读是字节流读取,由 NioByteUnsafe.read 实现,并引入了 RecvByteBufAllocator.Handle 做自适应读:
java
io.netty.channel.nio.AbstractNioByteChannel.NioByteUnsafe#read
@Override
public final void read() {
final ChannelConfig config = config();
if (shouldBreakReadReady(config)) {
// autoRead 已关闭时不再继续读
clearReadPending();
return;
}
final ChannelPipeline pipeline = pipeline();
final ByteBufAllocator allocator = config.getAllocator();
final RecvByteBufAllocator.Handle allocHandle = recvBufAllocHandle();
allocHandle.reset(config);
ByteBuf byteBuf = null;
boolean close = false;
try {
do {
// 自适应分配器分配本次读入缓冲
byteBuf = allocHandle.allocate(allocator);
// 从 JDK 通道读字节进 ByteBuf,返回读入字节数
allocHandle.lastBytesRead(doReadBytes(byteBuf));
if (allocHandle.lastBytesRead() <= 0) {
// 未读到数据则释放缓冲;小于 0 表示对端已发送 EOF
byteBuf.release();
byteBuf = null;
close = allocHandle.lastBytesRead() < 0;
if (close) {
// 收到 EOF,无剩余可读,置读挂起为 false
readPending = false;
}
break;
}
allocHandle.incMessagesRead(1);
readPending = false;
// 每次读满一个 ByteBuf 即上抛一次 channelRead
pipeline.fireChannelRead(byteBuf);
byteBuf = null;
} while (allocHandle.continueReading());
allocHandle.readComplete();
pipeline.fireChannelReadComplete();
if (close) {
closeOnRead(pipeline);
}
} catch (Throwable t) {
handleReadException(pipeline, byteBuf, t, close, allocHandle);
} finally {
// 未挂起读且关闭了 autoRead 时回收读关注,避免读事件反复触发
if (!readPending && !config.isAutoRead()) {
removeReadOp();
}
}
}
客户端读取同样是 do...while 循环,但每轮先由 allocHandle.allocate(allocator) 分配一块 ByteBuf,再 doReadBytes(byteBuf) 把字节读进去,读满即 fireChannelRead 上抛一次。所谓自适应读 ,是指 RecvByteBufAllocator.Handle 会根据历史读到的字节数动态调整下一次分配的缓冲区大小,读得多就加大缓冲,读得少就减小缓冲,从而在吞吐与内存占用之间取得平衡。
字节真正从 JDK 通道进入 ByteBuf 的动作在 doReadBytes:
java
io.netty.channel.socket.nio.NioSocketChannel#doReadBytes
@Override
protected int doReadBytes(ByteBuf byteBuf) throws Exception {
final RecvByteBufAllocator.Handle allocHandle = unsafe().recvBufAllocHandle();
// 记录本次尝试读取的字节数,供 allocator 自适应调整
allocHandle.attemptedBytesRead(byteBuf.writableBytes());
// 直接把 JDK 通道读进 ByteBuf,返回实际读入字节数
return byteBuf.writeBytes(javaChannel(), allocHandle.attemptedBytesRead());
}
byteBuf.writeBytes(javaChannel(), attemptedBytesRead) 一步完成"从 JDK 通道读字节并写入 ByteBuf",它复用 JDK 的 ScatteringByteChannel.read 语义,避免了一次用户态的临时拷贝。读取到 ByteBuf 后,数据便沿着 fireChannelRead 进入用户业务 handler。
读操作的两路分流可以用下面的时序图概括:

上面的时序图清晰地展示了读操作按 Channel 类型分叉的两条路径:服务端把"读"实现在 accept 新连接上,客户端把"读"实现在字节流的接收上,两条路径最终都汇聚到 fireChannelRead/fireChannelReadComplete 这两个 Pipeline 事件上,保持了入站传播语义的一致性。
五、写入内核:flush 与 doWrite 循环写入
5.1 write 与 flush 的两阶段语义
Netty 的写出被有意拆成了 write 与 flush 两个阶段,这是理解写入内核的钥匙。write 只把消息放进写缓冲区,并不真正写 Socket:
java
io.netty.channel.AbstractChannel.AbstractUnsafe#write
@Override
public final void write(Object msg, ChannelPromise promise) {
assertEventLoop();
ChannelOutboundBuffer outboundBuffer = this.outboundBuffer;
if (outboundBuffer == null) {
// 通道已关闭(outboundBuffer 被置空),释放消息并失败 promise
try {
ReferenceCountUtil.release(msg);
} finally {
safeSetFailure(promise,
newClosedChannelException(initialCloseCause, "write(Object, ChannelPromise)"));
}
return;
}
int size;
try {
// 过滤与校验消息(如堆内 ByteBuf 转堆外)
msg = filterOutboundMessage(msg);
size = pipeline.estimatorHandle().size(msg);
if (size < 0) {
size = 0;
}
} catch (Throwable t) {
try {
ReferenceCountUtil.release(msg);
} finally {
safeSetFailure(promise, t);
}
return;
}
// 仅追加到写缓冲区,不真正写 Socket
outboundBuffer.addMessage(msg, size, promise);
}
write 做了三件事:先 filterOutboundMessage 过滤消息(字节型通道会把堆内 ByteBuf 转成堆外、校验消息类型),再 estimatorHandle().size(msg) 估算消息大小,最后 outboundBuffer.addMessage(msg, size, promise) 把消息挂到发送队列尾部。真正的写出要到 flush 才发生:
java
io.netty.channel.AbstractChannel.AbstractUnsafe#flush / #flush0
@Override
public final void flush() {
assertEventLoop();
ChannelOutboundBuffer outboundBuffer = this.outboundBuffer;
if (outboundBuffer == null) {
return;
}
// 把所有未刷新消息标记为已刷新,准备写出
outboundBuffer.addFlush();
flush0();
}
protected void flush0() {
if (inFlush0) {
// 防重入:避免 flush0 里再触发 flush 造成递归
return;
}
final ChannelOutboundBuffer outboundBuffer = this.outboundBuffer;
if (outboundBuffer == null || outboundBuffer.isEmpty()) {
return;
}
inFlush0 = true;
// 通道未激活时,先把待发消息标记为失败(如尚未连接就 write)
if (!isActive()) {
try {
if (!outboundBuffer.isEmpty()) {
if (isOpen()) {
// 通道已打开但未连接,用 NotYetConnectedException 失败
outboundBuffer.failFlushed(new NotYetConnectedException(), true);
} else {
// 通道已关闭,用 ClosedChannelException 失败(不触发可写性变化事件)
outboundBuffer.failFlushed(newClosedChannelException(initialCloseCause, "flush0()"), false);
}
}
} finally {
inFlush0 = false;
}
return;
}
try {
// 真正写 Socket
doWrite(outboundBuffer);
} catch (Throwable t) {
// 写入异常统一交给 handleWriteError 处理
handleWriteError(t);
} finally {
inFlush0 = false;
}
}
flush() 先 addFlush() 把 write 阶段累积的未刷新消息推进到"已刷新"状态,再 flush0() 触发真正写出。flush0() 里做了一系列防御:inFlush0 防重入、空缓冲区短路、未激活通道直接失败消息。这些判断通过之后,才进入 doWrite(outboundBuffer) 核心写出逻辑。
在此之上,AbstractNioUnsafe 覆写了 flush0,并新增了 forceFlush 与 isFlushPending:
java
io.netty.channel.nio.AbstractNioChannel.AbstractNioUnsafe#flush0 / #forceFlush / #isFlushPending
@Override
protected final void flush0() {
// 仅当当前没有挂起的 flush 时才立即写出
if (!isFlushPending()) {
super.flush0();
}
}
@Override
public final void forceFlush() {
// 无条件立即写出(供 EventLoop 在 OP_WRITE 就绪时调用来续写)
super.flush0();
}
private boolean isFlushPending() {
IoRegistration registration = registration();
return registration.isValid() && NioIoOps.WRITE.isIncludedIn(
((SelectionKey) registration.attachment()).interestOps());
}
这是写入续写的关键设计。当上次 doWrite 没写完时,OP_WRITE 已被注册到兴趣集,此时 isFlushPending() 返回 true,用户的 flush() 便只做 addFlush() 而不再立即写,真正的写出延迟到 OP_WRITE 就绪后由 EventLoop 调 handle() → forceFlush() 完成。也就是说,写不出去时把写出权交给事件循环,写得出时立即写 ,两者用 OP_WRITE 的注册状态作为切换开关。
5.2 字节型 Channel 的 gather write 与 OP_WRITE 管理
字节型通道(NioSocketChannel)的 doWrite 实现了 gather write,一次系统调用可以写出多个 ByteBuffer:
java
io.netty.channel.socket.nio.NioSocketChannel#doWrite
@Override
protected void doWrite(ChannelOutboundBuffer in) throws Exception {
SocketChannel ch = javaChannel();
int writeSpinCount = config().getWriteSpinCount();
do {
if (in.isEmpty()) {
// 全部写完,清除 OP_WRITE 关注
clearOpWrite();
// 直接返回,不再走 incompleteWrite
return;
}
// 把已刷新的 Entry 转成 JDK ByteBuffer 数组(最多 maxBytesPerGatheringWrite 字节)
int maxBytesPerGatheringWrite = ((NioSocketChannelConfig) config).getMaxBytesPerGatheringWrite();
ByteBuffer[] nioBuffers = in.nioBuffers(1024, maxBytesPerGatheringWrite);
int nioBufferCnt = in.nioBufferCount();
switch (nioBufferCnt) {
case 0:
// 存在非 ByteBuf 消息(如 FileRegion),走单条兜底写出
writeSpinCount -= doWrite0(in);
break;
case 1: {
// 单缓冲:直接单 buffer 写出
ByteBuffer buffer = nioBuffers[0];
int attemptedBytes = buffer.remaining();
final int localWrittenBytes = ch.write(buffer);
if (localWrittenBytes <= 0) {
// 写不动(内核发送缓冲区满)
incompleteWrite(true);
return;
}
// 根据本次写结果自适应调整聚集写上限
adjustMaxBytesPerGatheringWrite(attemptedBytes, localWrittenBytes, maxBytesPerGatheringWrite);
in.removeBytes(localWrittenBytes);
--writeSpinCount;
break;
}
default: {
// 多缓冲:gather write 一次写多个 ByteBuffer
long attemptedBytes = in.nioBufferSize();
final long localWrittenBytes = ch.write(nioBuffers, 0, nioBufferCnt);
if (localWrittenBytes <= 0) {
incompleteWrite(true);
return;
}
// 根据本次写结果自适应调整聚集写上限
adjustMaxBytesPerGatheringWrite((int) attemptedBytes, (int) localWrittenBytes,
maxBytesPerGatheringWrite);
in.removeBytes(localWrittenBytes);
--writeSpinCount;
break;
}
}
} while (writeSpinCount > 0);
// 写完了规定次数但仍有剩余,处理 OP_WRITE 关注
incompleteWrite(writeSpinCount < 0);
}
doWrite 以 writeSpinCount 为"写配额",在循环内反复:in.nioBuffers(...) 把已刷新的消息转成 JDK ByteBuffer[],再按缓冲数量分三路写出。其中单缓冲用 ch.write(buffer),多缓冲用 ch.write(nioBuffers, 0, cnt) 做 gather write,一次系统调用写入多个缓冲区,这正是 Netty 写出高性能的来源。每写出一部分就 in.removeBytes(localWrittenBytes) 更新发送进度。此外,NioSocketChannel 还会在每次写出后调用 adjustMaxBytesPerGatheringWrite(...),根据本次实际写入量与尝试写入量的比例动态放大或缩小 maxBytesPerGatheringWrite,让 gather write 的聚集上限随 OS 发送缓冲区的实际表现自适应调整。
当内核对缓冲区写满、write 返回 <= 0 时,incompleteWrite(true) 会注册 OP_WRITE 关注暂停写出:
java
io.netty.channel.nio.AbstractNioByteChannel#incompleteWrite / #setOpWrite / #clearOpWrite
protected final void incompleteWrite(boolean setOpWrite) {
if (setOpWrite) {
// 内核写不动了,注册 OP_WRITE,等可写就绪后再续写
setOpWrite();
} else {
// 写完了本次配额,先清除 OP_WRITE,再安排稍后重试,让出事件循环
clearOpWrite();
eventLoop().execute(flushTask);
}
}
protected final void setOpWrite() {
final IoRegistration registration = registration();
if (!registration.isValid()) {
return;
}
addAndSubmit(NioIoOps.WRITE);
}
protected final void clearOpWrite() {
final IoRegistration registration = registration();
if (!registration.isValid()) {
return;
}
removeAndSubmit(NioIoOps.WRITE);
}
setOpWrite/clearOpWrite 分别对应 addAndSubmit(NioIoOps.WRITE)/removeAndSubmit(NioIoOps.WRITE),把"可写"兴趣的增删又归结回第一节的公共提交链路。这套"写不动就注册 OP_WRITE、写得完就清除 OP_WRITE"的机制,配合 forceFlush 的续写入口,构成了完整的写流量控制闭环。
5.3 消息型 Channel 的逐条发送
消息型通道(AbstractNioMessageChannel)不处理字节,而是逐条发送消息对象,其 doWrite 逻辑与字节型截然不同:
java
io.netty.channel.nio.AbstractNioMessageChannel#doWrite
@Override
protected void doWrite(ChannelOutboundBuffer in) throws Exception {
int maxMessagesPerWrite = maxMessagesPerWrite();
while (maxMessagesPerWrite > 0) {
Object msg = in.current();
if (msg == null) {
break;
}
try {
boolean done = false;
// 对单条消息最多重试 writeSpinCount 次
for (int i = config().getWriteSpinCount() - 1; i >= 0; i--) {
if (doWriteMessage(msg, in)) {
done = true;
break;
}
}
if (done) {
// 发送成功,移除该消息并递减剩余配额
maxMessagesPerWrite--;
in.remove();
} else {
break;
}
} catch (Exception e) {
if (continueOnWriteError()) {
maxMessagesPerWrite--;
in.remove(e);
} else {
throw e;
}
}
}
if (in.isEmpty()) {
// 全部写完,清除 OP_WRITE 关注
removeAndSubmit(NioIoOps.WRITE);
} else {
// 没写完,注册 OP_WRITE 等待续写
addAndSubmit(NioIoOps.WRITE);
}
}
消息型 doWrite 以 maxMessagesPerWrite 为消息数配额(防止一次发太多消息饿死其他 Channel),循环内对每条消息用 doWriteMessage(msg, in) 尝试发送,配合 writeSpinCount 做单条重试。消息全部发完则 removeAndSubmit(WRITE) 清除可写关注,否则 addAndSubmit(WRITE) 注册关注等待续写。这里的 doWriteMessage 是 abstract 方法,由 UDP 等消息型 transport 实现,本文面向 NIO 的 TCP NioSocketChannel 走的是字节型路径,消息型路径的 OP_WRITE 管理逻辑同样收敛回 addAndSubmit/removeAndSubmit。
写操作的整体时序如下:

上面的时序图清晰地展示了写入的完整闭环:write 只入队、flush 才真正写,doWrite 循环里 gather write 批量写出,写不动时用 addAndSubmit(WRITE) 挂起并由 OP_WRITE 就绪后经 forceFlush 续写,写完后 removeAndSubmit(WRITE) 收尾。
六、兴趣事件管理与资源释放:doBeginRead 与 doClose
6.1 doBeginRead 与 readPending:读关注的建立与回收
读操作不是凭空发生的,它需要先建立"读关注",即告诉 EventLoop"这个 Channel 的读事件就绪时叫我"。建立读关注的入口是 doBeginRead:
java
io.netty.channel.nio.AbstractNioChannel#doBeginRead
@Override
protected void doBeginRead() throws Exception {
// Channel.read() 或 ChannelHandlerContext.read() 被调用
IoRegistration registration = this.registration;
if (registration == null || !registration.isValid()) {
return;
}
// 置读挂起标记,表示有读需求待处理
readPending = true;
// 注册 readOps(客户端 READ,服务端 ACCEPT)关注
addAndSubmit(readOps);
}
doBeginRead 做了两件事:置 readPending = true 标记"有读需求",并 addAndSubmit(readOps) 把 READ(客户端)或 ACCEPT(服务端)关注注册进兴趣集。readPending 是读流程的关键协调标志,doBeginRead 置 true,read() 读满一轮后置 false。
读关注的回收由 removeReadOp 与 clearReadPending 共同完成:
java
io.netty.channel.nio.AbstractNioChannel.AbstractNioUnsafe#removeReadOp / AbstractNioChannel#clearReadPending
protected final void removeReadOp() {
IoRegistration registration = registration();
if (!registration.isValid()) {
return;
}
// 取消读关注,避免读事件反复触发
removeAndSubmit(readOps);
}
protected final void clearReadPending() {
if (isRegistered()) {
EventLoop eventLoop = eventLoop();
if (eventLoop.inEventLoop()) {
clearReadPending0();
} else {
// 不在 EventLoop 线程则投递任务异步清理
eventLoop.execute(clearReadPendingRunnable);
}
} else {
// 尚未注册,仅置标志位,SelectionKey 尚不存在
readPending = false;
}
}
读关注的建与收,本质上是同一套 addAndSubmit/removeAndSubmit 机制在两个方向上的应用。用户或系统调用 read() 时建立关注,读到数据后 fireChannelRead;当用户关闭了 autoRead 且没有挂起的读需求时,read() 的 finally 块会调 removeReadOp() 回收关注,把读事件"关掉",直到下一次 read() 再次建立关注。
6.2 doClose:JDK Channel 关闭与连接状态清理
写入与读取内核之外,通道资源的释放收口在 doClose。在 Netty 4.2 中,doClose 被拆成多层职责,AbstractNioChannel.doClose 只负责连接状态的清理:
java
io.netty.channel.nio.AbstractNioChannel#doClose
@Override
protected void doClose() throws Exception {
ChannelPromise promise = connectPromise;
if (promise != null) {
// 关闭未完成的连接尝试:失败连接 promise
promise.tryFailure(new ClosedChannelException());
connectPromise = null;
}
Future<?> future = connectTimeoutFuture;
if (future != null) {
// 取消连接超时任务
future.cancel(false);
connectTimeoutFuture = null;
}
}
而真正关闭 JDK 通道的动作在子类里:
java
io.netty.channel.socket.nio.NioSocketChannel#doClose
@Override
protected void doClose() throws Exception {
super.doClose();
// 关闭底层 JDK SocketChannel
javaChannel().close();
}
java
io.netty.channel.socket.nio.NioServerSocketChannel#doClose
@Override
protected void doClose() throws Exception {
// 关闭底层 JDK ServerSocketChannel
javaChannel().close();
}
这里有一个需要澄清的易错点:doClose 只负责"关闭 JDK 通道 + 清理连接状态",它不取消 SelectionKey 。取消注册(IoRegistration.cancel())发生在 doDeregister 中,属于注销流程。二者职责分离:doClose 释放底层 Socket 资源,doDeregister 释放注册关系。把这两件事分开,Netty 才能在"半关闭""异常关闭"等复杂场景下分别精确控制。
至于兴趣集变更如何提交到内核,前文第一节介绍的 addAndSubmit / removeAndSubmit → registration().submit() 链路同样适用,此处不再赘述。
七、整体链路串联
把前面六节串联起来,一条完整的数据通路是这样的:服务端 bind 绑定端口后,doBeginRead 注册 ACCEPT 关注;handle 分发读就绪时走 accept 接入新连接、构建子 NioSocketChannel;客户端 connect 通过非阻塞 doConnect 发起连接,OP_CONNECT 就绪后 finishConnect 完成握手并触发 fireChannelActive;连接建立后双方 doBeginRead 注册 READ 关注,进入读写循环;入站方向由 read 统一分流(服务端接新连接、客户端读字节)后经 fireChannelRead 上抛,出站方向由 write 入队、flush/doWrite 批量写出。
四种操作与兴趣事件的对应关系可以总结如下:
| 操作 | 入站/出站 | JDK 落地方法 | 兴趣事件 | 结果去向 |
|---|---|---|---|---|
| bind | 出站 | ServerSocketChannel.bind / SocketUtils.bind | 无(主动调用) | fireChannelActive |
| connect | 出站 | SocketUtils.connect + finishConnect | CONNECT | fireChannelActive |
| read | 入站 | accept / channel.read 到 ByteBuf | READ(服务端 ACCEPT) | fireChannelRead |
| write/flush | 出站 | SocketChannel.write(gather write) | WRITE | ChannelOutboundBuffer → Socket |
回顾整条链路,Netty 底层读写内核的设计思想可以凝练为"统一分发 + 关注驱动 + 位图提交":handle() 一个入口把连接、写入、读取三类就绪事件统一分发,NioIoOps 位图与 readOps 字段表达兴趣事件,addAndSubmit/removeAndSubmit 通过 IoRegistration.submit() 完成位图到内核的幂等提交。这套机制把 EventLoop 的调度、兴趣事件的管理、JDK 通道的调用三者干净地剥离开,既保留了对 JDK NIO 的完整控制力,又为 epoll 等更底层的 transport 预留了统一的扩展点。
全文小结
本文聚焦 Netty Channel 的底层 I/O 操作内核,从出站写入与入站读取两个维度,深入分析了 Unsafe → JDK Channel → OS 的三层合作模型、handle() 统一分发机制,以及 bind/connect/read/write 四种操作从发起到底层落地的完整流转路径。
在架构总览层面,AbstractNioUnsafe 同时实现 NioUnsafe 与 NioIoHandle,在 Netty 4.2 下由 EventLoop 通过 handle(IoRegistration, IoEvent) 统一分发连接、写入、读取三类就绪事件,NioIoOps 位图与 JDK SelectionKey 兴趣集一一对应。在兴趣管理层面,addAndSubmit/removeAndSubmit 以 selectionKey().interestOps() 为基准做位运算,仅在结果变化时经 registration().submit() 幂等提交内核。在绑定与连接层面,服务端 doBind 直接 bind(localAddress, backlog),客户端 doConnect 以非阻塞方式提交 JDK connect() 并在未就绪时注册 CONNECT 关注,finishConnect() 完成握手后触发 fireChannelActive()。在读取层面,read() 统一入口按 Channel 类型分流,服务端 doReadMessages 经 accept 构建子 Channel,客户端 doReadBytes 经 byteBuf.writeBytes(javaChannel(), ...) 把字节读入 ByteBuf,二者最终都通过 fireChannelRead 上抛数据。在写入层面,write 只把消息放入 ChannelOutboundBuffer,flush 经 flush0 触发 doWrite 循环,字节型 Channel 走 gather write、消息型 Channel 逐条发送,写不动时通过 OP_WRITE 关注让 EventLoop 稍后经 forceFlush 续写。在资源释放层面,doClose 只处理 JDK Channel 关闭与连接状态清理,与 doDeregister 的 IoRegistration.cancel() 职责分离。
原创不易,如果本文对您有帮助,带来了些许灵感或启发,烦请动动小手点赞、关注、转发、收藏。这是作者持续更新的动力源泉,衷心感谢您的支持。我会尽量在工作之余,为大家带来更高品质的内容,努力保持周更。