《Java 网络编程基础》1.1 从一次 HTTP 请求看 Java 网络栈

一次 curl 请求打到 127.0.0.1:9090/healthz,中间经过内核监听队列、收发缓冲区、Selector 就绪事件、业务线程和响应写回。本节用 curl -v、lsof、netstat 与本机服务日志把这条路径逐层标出来,并说明 linkd 的 EventLoop、Handler 和 Connection 分别落在哪一层。

本节目标:能沿着一次 loopback 请求,指出内核缓冲区、Java Channel、Selector 和业务线程各自的位置。
适用版本:JDK 25(Temurin 25.0.5+8);curl 8.7.1;macOS 26.3 (25D125)。

承接的问题

linkd 的第一个可验证接口是健康检查:GET http://127.0.0.1:9090/healthz 返回 200 OK 和 ok。它足够小,小到可以把整条路径摊开看;又足够真实,真实到能暴露后面所有章节要处理的问题:连接从哪里来、字节什么时候可读、响应什么时候能写出去。

很多人的第一反应是「Socket.read() 就是一次系统调用」,这句话只对了最下面一层。一次请求在 Java 进程里至少经过五个位置:

  1. 内核的监听队列与已连接队列;
  2. 内核的接收缓冲区;
  3. Java 的 SocketChannel 与 ByteBuffer;
  4. Selector 的就绪事件与事件循环线程;
  5. 解析、路由和业务代码,最后通过发送缓冲区写回。

这一节用 loopback 把网卡和 DNS 排除掉,只留下软件栈。先看一次真实的 curl -v,再逐层解释每个缓冲区归谁所有,最后把 linkd 的 EventLoop、Handler 和 Connection 放到这条路径上。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。

一次 loopback 请求的七步

先启动一个最小服务。它只做三件事:监听 127.0.0.1:9090、等待 OP_ACCEPT、读到请求后回固定的 ok。完整代码在 1.2 会拆开讲,这里先看行为。

curl -v --max-time 3 http://127.0.0.1:9090/healthz

本机输出(省略进度表):

*   Trying 127.0.0.1:9090...
* Connected to 127.0.0.1 (127.0.0.1) port 9090
> GET /healthz HTTP/1.1
> Host: 127.0.0.1:9090
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Content-Length: 3
< Connection: close
<
ok

服务端日志同时打印了它看到的东西:

listening on 127.0.0.1:9090
accept /127.0.0.1:59644
read n=84 position=84
request-line: GET /healthz HTTP/1.1

把两边的时间线对齐,一次请求可以拆成七步:

  1. curl 解析 URL,发现主机是字面量 127.0.0.1,跳过 DNS,直接向内核发起 connect(127.0.0.1:9090)。
  2. 内核走 loopback 路由,完成 TCP 三次握手。服务端的监听套接字把新连接放进 accept 队列,此时 Java 还没有调用 accept()。
  3. Selector 报告 OP_ACCEPT,服务端调用 accept(),得到一个已连接的 SocketChannel,日志里的 accept /127.0.0.1:59644 就是这一步。
  4. curl 把 84 字节的请求写进内核发送缓冲区,loopback 把它送到服务端接收缓冲区;Selector 报告 OP_READ。
  5. Java 调用 channel.read(in),把 84 字节从内核缓冲区复制到 ByteBuffer;日志里的 read n=84 position=84 是复制完成后的位置。
  6. 应用层解析请求行、匹配 /healthz、生成响应,再把响应字节写入 SocketChannel。
  7. 服务端在响应头里写了 Connection: close,写完就关闭连接;curl 读到 ok 后退出。

第 7 步会在 netstat 里留下痕迹:

netstat -anv -p tcp | grep -E 'Local Address|9090'

本机输出(表头已简化):

Proto Recv-Q Send-Q  Local Address        Foreign Address      (state)      rxbytes txbytes rhiwat shiwat process:pid
tcp4       0      0  127.0.0.1.9090       *.*                  LISTEN             0       0 131072 131072 java:6718
tcp4       0      0  127.0.0.1.9090       127.0.0.1.59644      TIME_WAIT        272      86 408256 146988 java:6718

LISTEN 行说明监听套接字还在;TIME_WAIT 行说明先关闭的一方是服务端,因为响应头写了 Connection: close。如果服务端保持 keep-alive,这一行会先显示 ESTABLISHED,等某一方关闭后才进入 TIME_WAIT。这不是「连接没关干净」,而是 TCP 的正常状态。

同一时刻用 lsof 看进程持有的套接字:

lsof -nP -iTCP:9090 -sTCP:LISTEN

本机输出:

COMMAND  PID         USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
java    6718 bingrong.yan    5u  IPv6 0x9077098634c1ed59      0t0  TCP 127.0.0.1:9090 (LISTEN)

FD 5u 是文件描述符 5,u 表示读写打开;(LISTEN) 是套接字状态。lsof 擅长回答「哪个进程占了端口」,不擅长回答「连接走到了哪一步」;后者要用 netstat 或抓包。macOS 没有 ss,这一章后面会反复用到这两个替代命令。

内核缓冲区与 Java 缓冲区

请求字节不是从 curl 直接「掉进」ByteBuffer 的。中间至少有两层缓冲区:内核的接收缓冲区,以及 Java 的 ByteBuffer。

内核接收缓冲区由 SO_RCVBUF 控制,发送缓冲区由 SO_SNDBUF 控制。netstat -anv 里的 rhiwat、shiwat 是这两个缓冲区曾经达到的高水位,不是当前占用量;当前占用量看 Recv-Q 和 Send-Q。不要把 131072 当成「还有 128 KB 没读」,它只是高水位记录。

Java 侧的读取路径大致是:

SocketChannel.read(ByteBuffer)
  -> sun.nio.ch.IOUtil.read(...)
  -> read(2) / recv(2)
  -> 内核接收缓冲区 -> 用户缓冲区

这里有一个容易忽略的细节:如果传入的是堆上的 ByteBuffer.allocate(n),SocketChannelImpl 会借一个临时的直接缓冲区做系统调用,再把结果复制回堆;如果传入的是 ByteBuffer.allocateDirect(n),系统调用可以直接读写这块堆外内存,少一次复制。第 5 章会专门测量这个差别,本节只需要记住:「读进 ByteBuffer」不一定只有一次复制。

读方法的返回值有三个分支,必须分开处理:

ByteBuffer in = ByteBuffer.allocate(1024);
int n = channel.read(in);
if (n == -1) {
    // 对端关闭,读到 EOF
    channel.close();
} else if (n == 0) {
    // 非阻塞模式下暂时没有数据;不是 EOF
    return;
} else {
    in.flip();          // limit = position, position = 0
    // 只消费 in.remaining() 字节,不能假设一次读满
}

n == 0 只可能出现在非阻塞模式。阻塞模式下 read() 会一直等到有数据、EOF 或异常。n == -1 才是 EOF。把 0 当成 EOF,会在客户端短暂空闲时误关连接;把 -1 当成「再试一次」,会在对端关闭后空转。

flip() 也不是「反转缓冲区」,而是把 limit 设为当前 position,再把 position 归零,让后续读取从 0 开始、到刚才写到的位置为止。这个状态机在第 5 章会用图拆开;现在只要不在 flip() 之前调用 get() 就行。

Selector 就绪不等于数据到齐

Selector 解决的是「哪个连接现在可以读/写」,不是「这个连接的数据已经完整」。这两个问题经常被混为一谈。

Selector.select() 返回时,selectedKeys() 里是一批就绪的 key。OP_READ 的含义是「至少有一个字节可读,或者已经 EOF」,不是「一个完整的 HTTP 请求已经到了」。HTTP 请求行、头部和消息体可能分三个 TCP 段到达;第一次 OP_READ 时,你可能只读到 GET /hea。半包处理是第 7 章的主题,本节只需要知道:就绪事件是提示,不是消息边界。

一个最小的事件循环长这样:

Selector selector = Selector.open();
server.register(selector, SelectionKey.OP_ACCEPT);

while (running) {
    selector.select();
    Iterator<SelectionKey> it = selector.selectedKeys().iterator();
    while (it.hasNext()) {
        SelectionKey key = it.next();
        it.remove();                    // 必须移除,否则下一轮重复处理
        if (key.isAcceptable()) {
            SocketChannel ch = server.accept();
            ch.configureBlocking(false);
            ch.register(selector, SelectionKey.OP_READ);
        } else if (key.isReadable()) {
            // 读之前先确认 key 还有效
            if (key.isValid()) {
                readOnce((SocketChannel) key.channel());
            }
        }
    }
}

三个细节决定了这个循环是否正确:

  • it.remove():selectedKeys() 是复用集合,不移除就会在下一轮继续处理同一个 key。selector.selectedKeys().clear() 也可以,但边遍历边清空容易漏掉新加入的 key,初学阶段用迭代器移除更稳。
  • configureBlocking(false) 必须在 register 之前:把阻塞通道注册到 Selector 会抛 IllegalBlockingModeException。
  • key.isValid():连接可能在上一个分支里被关闭,key 随之失效;继续调用 channel.read() 会抛 CancelledKeyException。

macOS 上的 Selector 由 kqueue 实现,不是 epoll。写代码时不需要关心底层是 kqueue 还是 epoll,但排障时要记住:Linux 上的 epoll 经验不能原样搬过来,macOS 也没有 ss。这是本卷反复强调的平台事实。

业务线程在哪里

健康检查很便宜:读 84 字节、匹配一个字符串、写 3 字节,放在事件循环线程里没有问题。但 linkd 还有 GET /books/{id},它要访问 libraryd 的 8080 端口。如果这个访问是阻塞的,放在事件循环里会卡住所有连接。

这就是「网络栈」和「业务线程」的边界:

事件循环线程:accept -> read -> 解析 -> 路由
业务线程池:  阻塞调用 libraryd -> 生成响应 -> 回投事件循环
事件循环线程:write -> 关闭/继续读

linkd 的 EventLoop.execute(Runnable) 就是回投入口。业务线程不能直接操作 SocketChannel,因为多个线程同时写会让响应字节的先后顺序不可控;SocketChannel 的单次写有内部锁,但业务消息的顺序仍要由发送队列保证。正确做法是把响应交给 Connection.enqueue(ByteBuffer),由事件循环在 OP_WRITE 就绪时统一写出。

Connection.enqueue 至少要做三件事:

  1. 把 ByteBuffer 放进该连接自己的发送队列;
  2. 如果队列从空变非空,把 interestOps 加上 OP_WRITE;
  3. 如果当前线程不是事件循环线程,调用 selector.wakeup(),让阻塞在 select() 的线程立即返回。

这三步的顺序不能反。先加 OP_WRITE 再入队,事件循环可能在队列还空的时候就被唤醒,写出 0 字节;先 wakeup() 再入队,事件循环可能醒来后看到空队列,又睡回去,直到下一次事件才写出。

在 linkd 里落地

linkd 把这条路径拆成三个共享类型:EventLoop 负责等待就绪事件,Handler 负责处理单个连接的就绪回调,Connection 负责持有通道和发送队列。

一个最小的健康检查 Handler 可以写成这样(每个连接一个实例,不解析请求头,只演示路径):

package com.plume.linkd.app;

import com.plume.linkd.core.Connection;
import com.plume.linkd.core.Handler;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;

public final class HealthHandler implements Handler {

    private static final byte[] HEALTH_OK = ("HTTP/1.1 200 OK\r\n"
            + "Content-Type: text/plain\r\n"
            + "Content-Length: 3\r\n"
            + "Connection: close\r\n\r\nok\n")
            .getBytes(StandardCharsets.US_ASCII);

    private final ByteBuffer in = ByteBuffer.allocate(1024);

    @Override
    public void onReadable(Connection c) {
        try {
            in.clear();
            int n = c.channel().read(in);
            if (n == -1) {
                c.close();
                return;
            }
            if (n == 0) {
                return;
            }
            c.enqueue(ByteBuffer.wrap(HEALTH_OK));
        } catch (IOException e) {
            c.close();
        }
    }

    @Override
    public void onWritable(Connection c) {
        // 发送队列由 Connection 内部维护,这里不直接写通道
    }

    @Override
    public void onClosed(Connection c) {
        // 释放连接级资源;BufferPool 的归还也在这一层做
    }
}

这段代码故意省略了 HTTP 解析和路由,因为第 8 章才会实现 HttpParser 和 HttpRouter。它保留了两个关键动作:c.channel().read(in) 从内核缓冲区取字节,c.enqueue(...) 把响应交给事件循环写回。把这两个动作放在不同的线程上,就是 Reactor 模型;放在同一个线程上,就是单线程事件循环。两者都能工作,区别在于阻塞调用会不会拖住别人。

第二个落点是路由注册。HttpRouter.add 把方法和路径映射到一个函数,健康检查不经过业务线程池:

HttpRouter router = new HttpRouter();
router.add("GET", "/healthz", req -> new HttpResponse(
        200,
        Map.of("Content-Type", "text/plain"),
        "ok\n".getBytes(StandardCharsets.UTF_8)));

HttpResponse 是 linkd-http 的记录类型,status、headers、body 三个字段;响应编码、Content-Length 和 keep-alive 决策在 8.1 到 8.2 展开。本节只关心请求路径的终点:路由命中后生成响应,响应最终仍然要走 Connection.enqueue,而不是在路由函数里直接写 SocketChannel。

第三个落点是缓冲区。BufferPool.acquire(int minCapacity) 从池里拿一块 ByteBuffer,release(ByteBuffer) 归还。健康检查的 1024 字节缓冲区可以每次分配,但高并发下频繁 allocate 会把压力推给 GC;第 5 章和第 13 章会分别讨论堆内池和直接内存池。现在只需要知道:缓冲区有生命周期,读完之后要归还或复用,不能把 ByteBuffer 的引用泄漏到连接之外。

常见错误与边界

错误一:把 read() 返回 0 当成 EOF。 非阻塞模式下,对端还没发数据时 read() 返回 0;只有返回 -1 才是关闭。下面这种写法会在客户端刚连上、还没发请求时就把连接关掉:

int n = channel.read(in);
if (n <= 0) {
    channel.close();   // 反例:n == 0 只是暂时没数据
}

正确写法是 n == -1 关闭,n == 0 直接返回,等待下一次 OP_READ。

错误二:在事件循环里调用阻塞下游。 GET /books/{id} 如果直接在 Handler.onReadable 里调用阻塞的 HTTP 客户端,事件循环会停在 read() 上,其他连接的 OP_READ、OP_ACCEPT 都得不到处理。表现是「并发一高,所有请求一起变慢」,而不是某一个请求慢。把阻塞调用放进业务线程池,再用 EventLoop.execute 回投,是第 12 章的主线。

错误三:只用 lsof 判断连接状态。 lsof -nP -iTCP:9090 默认会把监听套接字也列出来;看到 (LISTEN) 不代表没有已建立的连接。要只看已建立连接,加 -sTCP:ESTABLISHED:

lsof -nP -iTCP:9090 -sTCP:ESTABLISHED

如果这条命令没有输出,而 curl 又能成功,说明连接已经关闭(比如 Connection: close),不是「连接没建立」。

错误四:在 macOS 上找 ss。 ss 是 Linux 的 iproute2 工具,macOS 没有。连接诊断用 netstat -anv -p tcp、lsof -nP -iTCP:9090,抓包用 sudo tcpdump -i lo0 -nn port 9090。本卷不会给出 ss 的「本机输出」,因为本机根本执行不了。

边界:loopback 没有网卡。 本节刻意用 127.0.0.1,是为了把网卡、DNS、NAT 排除掉。真实公网请求还会经过网卡队列、中断、协议栈校验和卸载等环节;这些不影响 Java 侧的 SocketChannel/Selector 语义,但会影响延迟和吞吐。第 15 章会用 tcpdump 在 loopback 上验证握手和关闭,不会伪造网卡层的数字。

小结

  • 一次 loopback 请求经过内核监听队列、接收缓冲区、SocketChannel、Selector 和业务代码,最后通过发送缓冲区写回。
  • read() 返回 -1 是 EOF,返回 0 在非阻塞模式下只是暂时没数据;flip() 设置的是 limit 和 position,不是「反转」。
  • OP_READ 只表示「至少一个字节可读或 EOF」,不表示一个完整 HTTP 请求已到齐;半包要在第 7 章处理。
  • macOS 的 Selector 基于 kqueue,不是 epoll;连接诊断用 netstat、lsof、tcpdump,没有 ss。
  • Connection.enqueue 是业务线程回到事件循环的唯一入口;入队、加 OP_WRITE、wakeup() 的顺序不能反。
  • 健康检查可以留在事件循环,访问 libraryd 的阻塞调用必须放进业务线程池,否则一个慢请求会拖住所有连接。

下一节把这条路径上的两种服务器写法并排跑起来:阻塞 Echo 每个连接一个线程,非阻塞 Echo 一个线程管多个连接,用真实日志对比 BIO、NIO、AIO 与 Reactor 的边界。

阅读导航:上一节:目录与学习路径 · 下一节:1.2 BIO、NIO、AIO 与 Reactor 模型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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