本节目标:在 macOS 上确认 linkd-v2 真的用了 kqueue,而不是「探测为 true、运行在 NIO」,并把 JDK 25 的 native access 警告处理干净。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-transport-classes-kqueue+osx-aarch_64classifier)。
承接的问题
12.1 的 NativeEventLoops.detect() 在本机返回 KQUEUE。但这是代码的意图,不是运行事实。启动路径里只要有一处没把 Transport 传下去,就可能出现「group 是 KQueue、channel 是 Nio」的组合,或者在改配置时退回 NIO 而没人发现。验证只能看运行时对象的类名,不能看配置。
macOS 还有两个容易误判的现象。第一,Epoll.isAvailable() 返回 false 是平台事实,不是安装损坏;第二,JDK 25 第一次加载 Netty 的 native 库时会打印一条 System.loadLibrary 的受限方法警告,很多人以为 kqueue 没生效,其实库已经加载成功。
本节只写 macOS 的 kqueue。Linux 的 epoll 放在 12.3,那里是部署说明,不会声称本机实测。
核心机制一:两段探针输出
第一段确认平台与可用性。代码就是 12.1 的 TransportProbe,运行它的 main 即可:
package com.plume.linkd.v2.server;
import io.netty.channel.epoll.Epoll;
import io.netty.channel.kqueue.KQueue;
public final class TransportProbe {
private TransportProbe() {
}
public static void main(String[] args) {
System.out.println("epoll=" + Epoll.isAvailable());
System.out.println("kqueue=" + KQueue.isAvailable());
System.out.println("os.name=" + System.getProperty("os.name"));
System.out.println("os.arch=" + System.getProperty("os.arch"));
}
}
本机输出,单次运行(Apple M1 Pro,10 核,32 GB,macOS 26.3;Temurin 25.0.5+8,Netty 4.2.19.Final):
epoll=false
kqueue=true
os.name=Mac OS X
os.arch=aarch64
四行分别说明四件事:
epoll=false:macOS 内核没有 epoll,Epoll的 native 库也不会为这个平台发布。这不是配置错误,12.1 的探测顺序会继续尝试 kqueue。kqueue=true:netty-transport-classes-kqueue和osx-aarch_64的 native 库都加载成功。只要有一项缺失,这里就是 false。os.name=Mac OS X:JVM 在 macOS 26 上仍然报告这个名字,不要用它解析系统版本;版本判断用sw_vers。os.arch=aarch64:当前 JDK 是 Apple Silicon 原生版本。如果是x86_64,说明跑的是 Intel JDK 或 Rosetta 下的 x86_64 JDK,classifier 要换成osx-x86_64。
第二段确认 server 实际用的 channel 和 event loop。下面是一个只绑定回环地址的探针,端口用 19094,避免占用 linkd-v2 的 9090:
package com.plume.linkd.v2.app;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.kqueue.KQueue;
import io.netty.channel.kqueue.KQueueEventLoopGroup;
import io.netty.channel.kqueue.KQueueServerSocketChannel;
public final class KQueueProbe {
private KQueueProbe() {
}
public static void main(String[] args) throws Exception {
if (!KQueue.isAvailable()) {
System.out.println("kqueue unavailable: " + KQueue.unavailabilityCause());
return;
}
EventLoopGroup boss = new KQueueEventLoopGroup(1);
EventLoopGroup worker = new KQueueEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap()
.group(boss, worker)
.channel(KQueueServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 128)
.childOption(ChannelOption.TCP_NODELAY, true)
.childHandler(new ChannelInitializer<Channel>() {
@Override
protected void initChannel(Channel ch) {
// 探针不装配业务 handler;真实服务用 LinkdServer 的 initializer。
}
});
Channel channel = bootstrap.bind("127.0.0.1", 19094).sync().channel();
System.out.println("bound=" + channel.localAddress());
System.out.println("serverChannel=" + channel.getClass().getName());
System.out.println("eventLoop=" + channel.eventLoop().getClass().getName());
channel.close().sync();
} finally {
boss.shutdownGracefully().sync();
worker.shutdownGracefully().sync();
}
}
}
本机输出,单次运行:
bound=/127.0.0.1:19094
serverChannel=io.netty.channel.kqueue.KQueueServerSocketChannel
eventLoop=io.netty.channel.kqueue.KQueueEventLoopGroup$KQueueEventLoop
三行的用途不同。bound 来自 channel.localAddress(),确认绑定的是回环地址和预期端口;serverChannel 来自 getClass().getName(),证明 bootstrap 选中的是 kqueue 的监听 channel;eventLoop 来自 channel.eventLoop().getClass().getName(),证明事件循环也是 kqueue 实现,而不是 NioEventLoop。第三行里的 $KQueueEventLoop 是 KQueueEventLoopGroup 的静态嵌套类,名字里的 $ 是 JVM 的正常表示法。
把探针输出和 12.1 的 LinkdServer.bind() 日志对照,两者应该只差端口:9090 对 19094,类名完全一致。如果 serverChannel 是 NioServerSocketChannel,说明 applyChannel 没生效;如果 eventLoop 是 NioEventLoop,说明 group 创建时用了 NIO。两者任何一个不符,都不要继续看性能,先把启动路径对齐。
核心机制二:启动代码的约束
KQueueProbe 很短,但有几个不能省的顺序:
- 先检查
KQueue.isAvailable(),false 时打印 cause 并返回。直接new KQueueEventLoopGroup()在库缺失时会抛UnsatisfiedLinkError,异常里没有「该换 NIO」这种提示。 - boss 用 1 个线程,worker 用默认构造函数。默认线程数是 Netty 根据核数算的,不要在这里写死 8 或 16;容量判断在 14.1。
.option()作用于监听 channel,.childOption()作用于 accept 出来的连接。SO_BACKLOG放错到childOption不会生效,TCP_NODELAY放错到option也不会。ServerBootstrap要求childHandler非空,探针用空ChannelInitializer占位。生产代码必须换成真正的 initializer,否则连接建立后没有 handler,客户端看到的是「连上了但永远没响应」。channel.close().sync()先解绑,再shutdownGracefully()两个组。先 close 再 shutdown 是固定顺序,避免把 channel 的关闭和事件循环的终止混在一起,日志里的关闭顺序也更清楚。
端口 19094 只用于探针。linkd-v2 的正式端口仍是 9090(HTTP)、9091(h2c)、9092(gRPC)、9093(自定义协议)。探针绑定回环地址,不暴露到局域网;验证完就关闭,不要把它留在启动脚本里。
SO_BACKLOG, 128 是探针值,不是容量建议。macOS 上 listen backlog 还受 kern.ipc.somaxconn 限制,设置超过该值的 backlog 不会扩大队列:
sysctl kern.ipc.somaxconn
12.1 里 LinkdServer 的 SO_BACKLOG, 1024 在 macOS 上未必全部生效,Linux 上同理受 net.core.somaxconn 限制。backlog 是「等待 accept 的队列长度」,不是「最大连接数」,两者不要混为一谈。
核心机制三:JDK 25 的受限方法警告
JDK 24 起,System.loadLibrary 被标记为受限方法。Netty 加载 native 库时走的就是这个入口。linkd-v2 的 NativeEventLoops.detect() 先调用 Epoll.isAvailable(),那一步就会触发一次加载尝试,所以警告通常出现在探测阶段,而不是 bind 时。未加 --enable-native-access=ALL-UNNAMED 时会打印四行警告。本机输出,单次运行(第二行省略了调用方类名和 jar 路径):
WARNING: A restricted method in java.lang.System has been called
WARNING: java.lang.System::loadLibrary has been called by ... in an unnamed module
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled
这条警告的含义要拆开看:
- 它不代表加载失败。紧接着
KQueue.isAvailable()仍然是 true,server 正常启动。 - 它通常只出现一次,因为 native 库在进程内只会加载一次;后续创建多少个
KQueueEventLoopGroup都不会为同一个库重复打印。 - 它针对的是「调用方所在的模块」。linkd-v2 没有
module-info.java,跑在 classpath 上,调用方属于 unnamed module,所以用ALL-UNNAMED。 - 最后一行是时间表:JDK 25 仍然允许并警告,未来版本会默认拒绝。现在不加参数能跑,不等于下一个 JDK 还能跑。
规避方式是在所有启动路径上加同一个参数:
java --enable-native-access=ALL-UNNAMED \
-cp "linkd-app/target/classes:linkd-server/target/classes:..." \
com.plume.linkd.v2.app.LinkdV2Main
测试和容器也要加。Surefire 的 fork JVM 不继承命令行参数,要在插件配置里写:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>--enable-native-access=ALL-UNNAMED</argLine>
</configuration>
</plugin>
只改 IDE 的运行配置是不够的:本机 IDE 没警告,CI 和容器里仍然有;反过来只改 Dockerfile,开发机每次启动都会刷四行。把参数放进启动脚本、Surefire 配置和镜像 ENTRYPOINT 三处,才算一致。如果将来给 linkd-v2 加了 module-info.java,ALL-UNNAMED 不再覆盖命名模块,要改成实际模块名。
核心机制四:和卷一的 Selector 对照
卷一的 Reactor 用 Selector.open()、SelectionKey 和 select() 自己写循环。Netty 的 NIO transport 就是把那个循环封装进 NioEventLoop;kqueue transport 则绕过 JDK selector,直接调 kevent。三者对照如下:
| 维度 | 卷一 Selector | NioEventLoopGroup | KQueueEventLoopGroup |
|---|---|---|---|
| 就绪查询 | select() | 内部 selector 循环 | kevent |
| 事件表示 | SelectionKey + readyOps | Channel + pipeline 事件 | Channel + pipeline 事件 |
| 注册与注销 | register / cancel | 封装在 channel 生命周期里 | 直接维护 kqueue 的变更列表 |
| 唤醒 | wakeup() 的内部管道 | 同左 | 由 native 层唤醒,不经过 JDK selector |
| 额外选项 | 标准 socket 选项 | 同左 | KQueueChannelOption,如 TCP_NOPUSH |
| 业务代码 | 自己写循环 | 不变 | 不变 |
kqueue 也有 JDK selector 不暴露的选项。KQueueChannelOption(netty-transport-classes-kqueue)里的 TCP_NOPUSH 对应 BSD 的「攒够再发」,效果类似 Linux 的 TCP_CORK:它会让首字节推迟,适合把一批小帧合并成一次发送,不适合要求低延迟的小响应。SO_SNDLOWAT 的语义和 Linux 的 SO_SNDBUF 不同,不要把一个平台的调优参数直接搬到另一个平台。12.1 的原则在这里同样成立:先确认场景,再开选项。
这张表最容易被误读的是第一行。macOS 上 JDK 的 selector 内部本来就用 kqueue,所以「换 kqueue transport」不是从无到有,而是从 JDK 的封装换成直接调用。差别在控制粒度和额外选项:Netty 可以按自己的节奏批量提交 kevent 变更,也可以设置 JDK 没暴露的选项;但事件通知本身仍然是内核的 kqueue。
因此不要期待数量级变化。卷一的手写 selector 和 Netty NIO 的差异主要来自线程模型、缓冲区和编解码,而不是 selector 本身;从 NIO 换到 kqueue 又只去掉了 JDK 封装这一层。连接数少、每连接请求稀疏时,这点差异很容易被业务处理盖住。要判断是否值得,按 14.2 的方法做同 pipeline 对比。
在 linkd-v2 里落地
LinkdServer.bind() 已经会打印 channel 与 event loop 的类名。macOS 上 9090 的启动日志应该是(示意输出,类名与上面的探针一致):
bound port=9090 channel=io.netty.channel.kqueue.KQueueServerSocketChannel eventLoop=io.netty.channel.kqueue.KQueueEventLoopGroup$KQueueEventLoop
再加一条启动断言,把「意外退回 NIO」变成显式警告,而不是静默接受:
if (transport != NativeEventLoops.Transport.KQUEUE
&& System.getProperty("os.name", "").startsWith("Mac")) {
LOG.log(System.Logger.Level.WARNING,
"expected kqueue on macOS, got {0}: {1}",
transport, NativeEventLoops.unavailabilityReport());
}
这里用 WARNING 而不是抛异常,是因为开发机可能故意没装 native 依赖,或者需要在 Rosetta 下跑 x86_64 JDK;直接失败会让本地调试无法进行。生产环境的强制策略放在 12.3,用配置项控制。
线程名也可以用来核对。默认线程工厂给 kqueue 组的线程名是 KQueueEventLoopGroup-<poolId>-<index>,注意首字母大写;NioEventLoopGroup 因为第二个字母是小写,会被转成 nioEventLoopGroup-<poolId>-<index>。两者的大小写规则不同,用 03-1 的 nioEventLoopGroup-3-1 去 grep kqueue 会什么都找不到。线上排查时:
jcmd <pid> Thread.print | grep KQueueEventLoopGroup
应该能看到 worker 线程;如果只有 nioEventLoopGroup,说明这个进程没有用上 kqueue。线程名不是契约,但作为第一眼的证据足够。反过来,看到 KQueueEventLoopGroup 也不要在这个线程里做阻塞调用,规则和 NIO 一样,见 3.3。
常见错误与边界
错误一:只手工拷贝 native jar,漏了 classes jar。 KQueue.isAvailable() 不会返回 false,而是在类加载阶段抛 NoClassDefFoundError: io/netty/channel/kqueue/KQueue。NoClassDefFoundError 是 Error,catch (Exception e) 接不住。用 Maven 依赖让 classes jar 传递进来,不要手工拼 -cp。
错误二:在 macOS 上装 linux-x86_64 的 epoll classifier,以为能让 epoll 可用。 epoll 的 native 库只对 Linux 有效,macOS 上无论装哪个 classifier,Epoll.isAvailable() 都是 false;这也不是 kqueue 坏了的证据。按 os.name 和 os.arch 选构件,不要按开发机型号猜。
错误三:把 native access 警告当成加载失败。 看到 WARNING: A restricted method... 就去删依赖或换回 NIO,是把警告当错误。先看 KQueue.isAvailable() 和 kqueue= 那一行;库加载成功就继续,然后补上 --enable-native-access。
错误四:探针端口没有释放就重复运行。
java.net.BindException: Address already in use
上一次进程没退出,或者 19094 被别的程序占用。用 lsof -nP -iTCP:19094 找占用者;探针的 finally 里已经有关闭逻辑,正常退出不会留下监听。
错误五:把探针的空 ChannelInitializer 复制到生产。 探针只需要绑定成功,所以 child pipeline 是空的;生产这样写,客户端连上后不会收到任何响应,日志里也没有异常。LinkdServer 必须装配 HttpServerCodec、HttpObjectAggregator、LinkdHttpHandler 或对应的自定义协议 initializer。
边界一:Rosetta 下的 os.arch 是 x86_64。 在 Apple Silicon 上如果 JDK 本身是 x86_64,os.arch 不会报 aarch64,需要 osx-x86_64 的 classifier。判断依据是 JVM 的属性,不是 uname -m。
边界二:os.name 不能用来判断 macOS 版本。 它在 macOS 26 上仍然是 Mac OS X。需要版本时用 sw_vers -productVersion,不要解析 JVM 属性。
边界三:isAvailable() 的结果是进程级缓存。 同一个 JVM 里第一次调用后不会重新加载;运行中替换 jar、改 java.library.path 都不会改变结果。排查加载问题时必须重启进程。
小结
- macOS 上
epoll=false、kqueue=true是正常组合;前者是平台事实,后者说明 classes jar 和osx-aarch_64的 native 库都在。 os.arch=aarch64对应osx-aarch_64;x86_64(包括 Rosetta 下的 JDK)对应osx-x86_64。- 验证 transport 要看运行时类名:
KQueueServerSocketChannel和KQueueEventLoopGroup$KQueueEventLoop,而不是看配置或isAvailable()。 ServerBootstrap的 option 与 childOption 分工、boss/worker 线程数、先 close 后 shutdown,这三条在 kqueue 上和 NIO 一样,不因为换了 transport 而放宽。- JDK 25 的
System.loadLibrary警告不影响加载,但要在启动脚本、Surefire 和容器入口三处加--enable-native-access=ALL-UNNAMED。 - 卷一的
Selector在 macOS 内部也用 kqueue;换 kqueue transport 去掉的是 JDK 封装层,收益要用同 pipeline 对比判断,不预设数量级。
macOS 的验证到此为止。下一节换到 Linux 部署侧:同一个 NativeEventLoops 怎么落到 EpollEventLoopGroup,SO_REUSEPORT 与 TCP_FASTOPEN 各需要什么前提,以及容器和 glibc 上 Epoll.isAvailable() 为 false 时怎么回退。
阅读导航:上一节:12.1 原生传输与平台差异 · 下一节:12.3 Epoll 在 Linux 的配置 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。