《Netty 高性能网络编程》12.2 KQueue 在 macOS 的实测

isAvailable() 为 true 不等于 server 真的跑在 kqueue 上。本节用本机探针打印 serverChannel 与 eventLoop 的实际类名,给出 KQueueEventLoopGroup 启动代码、Apple Silicon classifier,并处理 JDK 25 对 System.loadLibrary 的受限方法警告。

本节目标:在 macOS 上确认 linkd-v2 真的用了 kqueue,而不是「探测为 true、运行在 NIO」,并把 JDK 25 的 native access 警告处理干净。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-transport-classes-kqueue + osx-aarch_64 classifier)。

承接的问题

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。三者对照如下:

维度卷一 SelectorNioEventLoopGroupKQueueEventLoopGroup
就绪查询select()内部 selector 循环kevent
事件表示SelectionKey + readyOpsChannel + 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 的配置 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发