JDK原生NIO与Netty原生io_uring机制对比剖析
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
JDK NIO Poller与Netty原生io_uring Transport机制剖析
Netty netty-transport-native-io_uring 与 JDK 原生 NIO (io_uring) 深度对比分析
在 Linux 异步 I/O 演进史中,io_uring 彻底重塑了用户态与内核态的交互方式。Netty 社区推出的 netty-transport-native-io_uring 模块与 OpenJDK(主要是 Java 21+ 结合 Project Loom 的内部 sun.nio.ch.IOUring 实验性/原生引擎)对 io_uring 的支持代表了两种完全不同的架构范式。
一、 架构设计与 JNI / Native 桥接层对比
1. Netty:基于 mmap 的无 JNI 调用 SQE 填充架构
Netty 的设计核心是 绕过标准 JNI 开销,将内核 SQ/CQ 环内存直接映射到 JVM 堆外。
[ Netty EventLoop (Java) ]
│
├── 1. 获取 Unsafe / DirectBuffer 映射地址
├── 2. 直接写入 SQE 内存 (无 JNI 方法调用开销)
│ * sqesAddress + (tail & ringMask) * SQE_SIZE
├── 3. 内存屏障更新 tail (Unsafe.storeFence)
│
└── 4. 仅在需要唤醒内核时触发 JNI -> io_uring_enter()
Netty JNI/Native 映射机制源码分析
Netty 并不通过 JNI 传递每一个 read/write 参数。在 IOUringSubmissionQueue.java 与 C 端 netty_io_uring_native.c 中,Netty 在初始化时调用 io_uring_queue_init_params 并通过 mmap 将 SQ (Submission Queue) 和 CQ (Completion Queue) 映射到进程空间,随后将这些内存指针(long 地址)直接暴露给 Java 层:
// io.netty.channel.uring.IOUringSubmissionQueue
final class IOUringSubmissionQueue {
private final long kheadAddress;
private final long ktailAddress;
private final long flagsAddress;
private final long ringMaskAddress;
private final long ringEntriesAddress;
private final long sqesAddress;
// 关键:在 Java 端利用 Direct Memory 地址直接进行指针偏移与写入
boolean enqueueSqe(byte opcode, byte flags, short ioPrio, int fd, long off, long addr, int len, long userData) {
int tail = PlatformDependent.getIntVolatile(ktailAddress);
int head = PlatformDependent.getIntVolatile(kheadAddress);
if (tail - head >= ringEntries) {
return false; // SQ 队列已满
}
// 计算 SQE 数组的物理偏移 (每条 SQE 占 64 字节)
long sqeAddr = sqesAddress + ((tail & ringMask) * SQE_SIZE);
// 纯内存写入:无任何 JNI 边界穿越
PlatformDependent.putByte(sqeAddr + SQE_OPCODE_FIELD, opcode);
PlatformDependent.putByte(sqeAddr + SQE_FLAGS_FIELD, flags);
PlatformDependent.putShort(sqeAddr + SQE_IOPRIO_FIELD, ioPrio);
PlatformDependent.putInt(sqeAddr + SQE_FD_FIELD, fd);
PlatformDependent.putLong(sqeAddr + SQE_OFF_FIELD, off);
PlatformDependent.putLong(sqeAddr + SQE_ADDR_FIELD, addr);
PlatformDependent.putInt(sqeAddr + SQE_LEN_FIELD, len);
PlatformDependent.putLong(sqeAddr + SQE_USER_DATA_FIELD, userData);
// 更新 tail 指针,使用内存屏障确保内核/其他线程可见
PlatformDependent.putIntOrdered(ktailAddress, tail + 1);
return true;
}
}
架构优势:
- 零 JNI 门槛损耗(Zero JNI Crossing Overhead): 填充 1,000 个 SQE 请求只需要 1,000 次堆外内存写入(相当于本地 CPU 寄存器到 L1 Cache 的写操作),零 JNI 函数查找与栈帧转换开销。
- 单次系统调用批处理: 只有在 SQE 填充完毕且需要内核通知时,才调用一次 C 层的 JNI 方法
io_uring_enter。
2. JDK 原生 NIO:基于传统 JNI/C 适配与 sun.nio.ch 的抽象封装
JDK 的 io_uring 集成需要兼顾 java.nio.channels.spi.SelectorProvider 规范以及虚拟线程(Project Loom)的非阻塞切换需求。JDK 无法像 Netty 那样完全破坏抽象层,因此采用了 JNI 桥接层 + 内核 Poller 线程 的设计。
[ JDK Virtual Thread / Channel ]
│
├── 1. 发起 Socket/File Channel I/O
├── 2. 调用 sun.nio.ch.IOUring 原生 JNI 方法
│ └── IOUring.submitRead(fd, address, len, ...) [触发 JNI 边界穿越]
├── 3. 内核 C 适配层写入 SQE
└── 4. IOUringPoller 线程在内核 CQE 返回时通过 Continuation.unpark 恢复 VT
JDK sun.nio.ch.IOUring 源码与 JNI 分析
在 OpenJDK 源码(以 JDK 21+ 内部 sun.nio.ch 路径为例)中,IOUring.java 封装了 C 语言的 libnio 动态库,其 SQE 提交依赖 JNI 导出函数:
// sun.nio.ch.IOUring.java (OpenJDK 内部实现)
class IOUring {
private static native int submitRead0(long ringAddress, int fd, long address, int len, long offset, long fileIndex);
private static native int submitWrite0(long ringAddress, int fd, long address, int len, long offset, long fileIndex);
private static native int enter0(long ringAddress, int toSubmit, int minComplete, int flags);
// 每次 I/O 操作均触发 JNI 调用
int submitRead(int fd, long address, int len, long offset) {
// 通过 JNI 将参数打包传给 C 语言的 io_uring_get_sqe / io_uring_prep_read
return submitRead0(this.address, fd, address, len, offset, -1);
}
}
对应的 C 语言 Native 端代码 (IOUring.c):
// src/java.base/linux/native/libnio/ch/IOUring.c
JNIEXPORT jint JNICALL
Java_sun_nio_ch_IOUring_submitRead0(JNIEnv *env, jclass clazz,
jlong ring_addr, jint fd,
jlong address, jint len,
jlong offset, jlong file_index) {
struct io_uring *ring = (struct io_uring *)(uintptr_t)ring_addr;
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
if (sqe == NULL) {
return -1; // SQ 队列满了
}
io_uring_prep_read(sqe, fd, (void *)(uintptr_t)address, len, offset);
if (file_index >= 0) {
sqe->flags |= IOSQE_FIXED_FILE;
sqe->fd = (int32_t)file_index;
}
return 0;
}
架构劣势:
- 频繁穿越 JNI 边界: 虚拟线程发起每一次
read或write操作,均需要调用一次 JNI 方法,导致 CPU 需要保存 JVM 寄存器状态、转换指针类型、执行 JNI 安全检查。 - 分层抽象开销: 为了兼容 POSIX 语义和 NIO 接口,JDK 必须将
io_uring的异步 Proactor 完成事件,在 Poller 层再封装一层就绪/完成状态,增加了对象分配和指针查找转换的开销。
二、 事件循环(Event Loop)与线程调度对比
1. Netty:Thread-per-Core 绝对无锁环架构
Netty 的 IOUringEventLoop 遵循无共享(Share-Nothing)原则,将一个 io_uring 实例绑定到一个固定的 EventLoop 线程。
+----------------------------------------------------+
| Netty IOUringEventLoop |
| |
| 1. 执行用户 Task ──> 产生 I/O |
| 2. 填充 SQE (无锁,单线程独占 SQ) |
| 3. sys_io_uring_enter() (批量提交) |
| 4. 消费 CQE ──> 直接触发 ChannelPipeline |
+----------------------------------------------------+
│
单线程独占,无需任何 CAS / 锁
事件循环源码逻辑 (IOUringEventLoop.java)
// io.netty.channel.uring.IOUringEventLoop
@Override
protected void run() {
int template = IORING_ENTER_GETEVENTS;
for (;;) {
try {
// 1. 处理 Netty 内存队列中的普通任务 (Non-I/O Tasks)
runAllTasks();
// 2. 批量提交 SQE 到内核
submissionQueue.submit();
// 3. 处理完成队列 CQE (无锁直寻)
processReady(completionQueue);
} catch (Throwable t) {
handleLoopException(t);
}
}
}
private void processReady(IOUringCompletionQueue cq) {
while (cq.hasCompletions()) {
long userData = cq.getUserData();
int res = cq.getRes();
cq.advance(); // 推进 CQ head 指针
// 从 userData 直接解码出 Channel 指针或 AbstractUringChannel 引用
AbstractUringChannel channel = lookupChannel(userData);
channel.finishIO(userData, res); // 直接在当前 EventLoop 线程回调 Pipeline
}
}
核心优势:
- 无锁 Single-Producer Single-Consumer (SPSC): SQ 的
tail更新与 CQ 的head推进完全在单线程内运行,消除了所有线程间竞争。 - 极佳的 CPU Cache 局部性: 数据读写、SQE 填充、CQE 解析、Pipeline 处理全过程在同一个 CPU 核心上的 L1/L2 Cache 内完成。
2. JDK:M:N 虚拟线程调度与 IOUringPoller 集中式响应
JDK 必须支持 任意数量的虚拟线程(M)在少量 Carrier 线程(N)上运行 的场景,这导致 io_uring 提交端和完成端解耦。
[ Carrier Thread 1 ] ──(VT A 发起 I/O) ──┐
├──> [ 共享/并发 SQ 锁/CAS ] ──> [ Linux Kernel ]
[ Carrier Thread 2 ] ──(VT B 发起 I/O) ──┘ │
│
[ JDK IOUringPoller 线程 ] <────────────── 消费 CQE 恢复 Continuation ─────────┘
JDK IOUringPoller 源码分析
在 OpenJDK 中,IOUringPoller 担当了全局/分组事件监听器的角色:
// sun.nio.ch.IOUringPoller.java
class IOUringPoller extends Poller {
private final IOUring ring;
private final Map<Long, CompletableFuture<Integer>> pendingOperations = new ConcurrentHashMap<>();
@Override
void run() {
for (;;) {
// 1. 调用 enter0 等待 CQE 事件到来 (可能阻塞 Poller 线程)
int completed = ring.enter(0, 1, IORING_ENTER_GETEVENTS);
// 2. 轮询 CQ 环
while (ring.pollCQE(cqeConsumer)) {
long userData = cqeConsumer.getUserData();
int result = cqeConsumer.getResult();
// 3. 寻找挂起的虚拟线程 Continuation 引用
VirtualThread vt = lookupVirtualThread(userData);
// 4. 将虚拟线程重新标记为 RUNNABLE,提交给 ForkJoinPool 调度
vt.unpark();
}
}
}
}
核心差异与痛点:
- SQ 提交锁竞争(MPSC 瓶颈): 多个 Carrier 线程(运行不同的虚拟线程)可能同时调用
ring.submitRead(),因此 JDK 在写入 SQE 时必须施加 Synchronized 锁或 CAS 锁,导致多核扩展性受限。 - 线程上下文切换二次开销: CQE 完成后,
IOUringPoller线程解析出userData,但它不能直接执行 Java 业务逻辑,而是必须将虚拟线程的Continuation重新放入ForkJoinPool的任务队列中,等待某个 Carrier 线程来调度执行。这引入了一次额外的线程切换与队列排队开销。
三、 内存管理与 Buffer Ring 特性对比
io_uring 是异步 DMA 操作,要求操作系统在执行 I/O 期间,内存地址必须绝对稳定(不能发生 GC 移动、解绑或提前释放)。
1. Netty:Kernel Buffer Ring (IORING_REGISTER_PBUF_RING) 与 Pooled Direct ByteBuf
Netty 深度集成了 Linux 5.19+ 引入的 Provided Buffer Ring (io_uring_buf_ring) 特性,实现了内核级零拷贝与精确按需内存分配。
[ Netty EventLoop Initialization ]
│
├── 1. 预分配一块连续的 Direct Memory
├── 2. 格式化为 struct io_uring_buf_ring 结构
├── 3. 调用 io_uring_register_buf_ring() 注册给内核 (Group ID = 1)
│
[ Read I/O 触发 ]
│
├── 1. Netty 提交 SQE: IORING_OP_RECV_MULTISHOT,设置 IOSQE_BUFFER_SELECT
├── 2. 内核接收到网络数据包,自动从 io_uring_buf_ring 中切分一个 Buffer
└── 3. CQE 返回:包含填充了数据的 buffer_id,Netty 零拷贝包装为 ByteBuf 交付应用
Netty Buffer Ring 源码实现逻辑
// io.netty.channel.uring.IOUringBufferRing
final class IOUringBufferRing {
private final long ioUringBufRingAddr; // 指向 struct io_uring_buf_ring
private final int ringEntries;
// 初始化时将预分配的 PooledByteBuf 物理地址填入内核 Buffer 环
void addBuffer(short bufId, long address, int len) {
int tail = getTail();
long entryAddr = ioUringBufRingAddr + (tail & mask) * BUF_ENTRY_SIZE;
PlatformDependent.putLong(entryAddr + BUF_ADDR_OFFSET, address);
PlatformDependent.putInt(entryAddr + BUF_LEN_OFFSET, len);
PlatformDependent.putShort(entryAddr + BUF_BID_OFFSET, bufId);
// 告知内核 Buffer 已就绪
setTail(tail + 1);
}
}
内存优势:
- 完全消除预分配读缓冲区内存浪费: 在传统的 Selector 或未开启
PBUF_RING的io_uring中,针对 100 万个连接,应用必须预先为每个连接分配 64KB 读缓冲区(占用 64GB 内存)。而在 Netty 中,使用PBUF_RING,100 万个连接共享一个容量仅为 8,192 的全局内核 Buffer 环,内核仅在真正有数据包到达时才从环中扣减 Buffer,内存占用降低 90% 以上。
2. JDK:DirectByteBuffer、GC Safety 机制与安全屏障
JDK 受限于 Java 内存安全模型(Safety and Security Guarantees),无法向用户暴露裸指针,且必须防止在异步 DMA 读写过程中发生 Buffer 提前回收或指针失效。
[ JDK SocketChannel.read(DirectByteBuffer) ]
│
├── 1. 提取 Buffer 的 nativeAddress (Unsafe)
├── 2. 构建 PendingBuffer 对象,挂载到 Poller 的 ConcurrentHashMap 中 (防止 GC)
├── 3. 提交 SQE 到内核执行 DMA
│ └── 插入 Reference.reachabilityFence(buffer) 确保 Java 引用存活
│
[ CQE 到达 ]
│
└── 从 PendingBuffer Map 中移除引用,允许 GC/Cleaner 并在必要时解冻虚拟线程
JDK 内存安全控制源码分析
// sun.nio.ch.IOUringRawChannel
public int read(ByteBuffer dst) {
if (!(dst instanceof DirectBuffer)) {
// 若为 HeapByteBuffer,必须先拷贝到临时的 DirectByteBuffer!
dst = Util.getTemporaryDirectBuffer(dst.remaining());
}
long address = ((DirectBuffer)dst).address() + dst.position();
int len = dst.remaining();
try {
// 1. 注册映射,防止 Cleaner 线程在 DMA 期间释放该 Native 内存
long identity = poller.registerBuffer(dst);
// 2. 提交到 io_uring
int res = poller.submitRead(fd, address, len, identity);
// 3. 关键:内存保护屏障,防止 JVM 编译器优化导致 dst 在 read 执行完前被 GC
Reference.reachabilityFence(dst);
return res;
} finally {
// ...
}
}
内存劣势:
- 高昂的 Map 记录与 Reachability 维护: 为了确保堆外内存不被
Cleaner线程回收,JDK 必须在每次异步 I/O 提交时,在 Java 堆上创建一个 Key/Value 对象并插入全局 ConcurrentHashMap,在 CQE 返回时再移除。这种频繁的 Hash 映射操作在高 IOPS 场景下带来了严重的 GC 压力与 CPU 消耗。 - 不支持复杂的内核 Buffer 环: 由于 JDK 接口必须遵循 POSIX 语义(即用户传入指定的
ByteBuffer容器,内核将数据填充进该指定的 Buffer),难以无缝适配io_uring的PBUF_RING(内核随机分配 Buffer 再通知应用)范式。
四、 核心 C / Java 源码对比汇总表
针对系统工程关注的关键操作路径,两者的源码实现对比分析如下:
+------------------+-----------------------------------------------+-----------------------------------------------+
| 系统级操作维度 | Netty (netty-transport-native-io_uring) | JDK 原生 NIO (sun.nio.ch.IOUring) |
+------------------+-----------------------------------------------+-----------------------------------------------+
| SQE 压栈实现 | 纯 Java 代码通过 Unsafe 直接写 mmap 物理地址 | 跨越 JNI 边界调用 C 函数 io_uring_get_sqe |
| 内存屏障更新 | PlatformDependent.putIntOrdered (Unsafe写) | JNI 内部锁 / io_uring_submit 隐式屏障 |
| 线程隔离度 | Single-Threaded SPSC (完全无锁) | Multi-Producer Single-Consumer (依赖互斥锁) |
| user_data 语义 | 直接编码为 Channel 对象的 Native 指针/ID | 编码为 Poller 操作 Token / Continuation 引用 |
| 内存 Pinning 机制 | 依赖 ByteBuf 引用计数,绝对脱离 GC 干扰 | 依赖 ConcurrentHashMap 硬引用 + Reachability |
| 多包接收优化 | 原生支持 IORING_OP_RECV_MULTISHOT | 仅支持单次 IORING_OP_READ/RECV |
| 缓冲区分配 | 支持 IORING_REGISTER_PBUF_RING (内核 Buffer 环)| 必须由应用层提前分配具体的 DirectByteBuffer |
+------------------+-----------------------------------------------+-----------------------------------------------+
五、 系统视角的性能与设计结论
- Netty
netty-transport-native-io_uring:
- 设计取向: 极致性能优先(Performance First)。
- Netty 将
io_uring视作一种全新的硬件接口,通过mmap零 JNI 提交、内核 Buffer Ring、无锁 Thread-per-Core 架构,把 CPU 系统调用与内存拷贝开销压缩到了物理极限。它是构建高吞吐网关、RPC 框架以及百万级长连接系统的首选。
- JDK 原生 NIO (
io_uring):
- 设计取向: 通用性、内存安全与开发体验优先(Safety & Transparency First)。
- JDK 将
io_uring视作解决虚拟线程在文件/网络 I/O 阻塞 Carrier 线程的底层补丁。为了保持java.nioAPI 兼容性与 JVM 内存绝对安全,JDK 牺牲了部分性能(引入了 JNI 穿越、并发 Lock 竞争与 Pending Map 维护)。但它为广大 Java 开发者提供了用标准同步代码编写超高并发系统的能力。

380

被折叠的 条评论
为什么被折叠?



