侧边栏壁纸
  • 累计撰写 157 篇文章
  • 累计创建 21 个标签
  • 累计收到 3 条评论

目 录CONTENT

文章目录

Java中的一个 HTTP 请求,调用 send() 后,字节去了哪里?请求经历了什么?

YaFuX
2026-05-27 / 0 评论 / 0 点赞 / 2 阅读 / 0 字
温馨提示:
部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

前言

Java 程序发送 HTTP 请求时,代码可能只有几行:

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("http://example.com/users/1001"))
        .GET()
        .build();

HttpResponse<String> response =
        client.send(
                request,
                HttpResponse.BodyHandlers.ofString()
        );

我们在代码里只看到了:

构造请求
    ↓
调用 send()
    ↓
得到响应

Java 官方提供的 HttpClient.send() 是同步调用,会等待请求发送并接收响应;异步版本则是 sendAsync()。但无论同步还是异步,真正的网络通信最终都要进入操作系统协议栈。

这时很容易产生一连串问题:

URL 是怎样变成网络地址的?

HTTP 请求是谁拼成字节的?

Java 会自己完成三次握手吗?

Socket 和文件描述符是什么关系?

一个 HTTP 请求一定会新建一条 TCP 连接吗?

TCP 为什么看不懂 URL?

用户态中的字节怎样进入内核?

数据什么时候变成 TCP Segment 和 IP Datagram?

三次握手完成后,操作系统才会创建线程吗?

服务端收到的究竟是一条 HTTP 消息,还是一段 TCP 字节?

原资料已经从 HTTP、Socket、TCP 握手和网络分层开始串联这条链路,但其中“握手完成后才创建所有资源”“一个请求就是一个不可拆分过程”等说法需要进一步区分协议状态与应用线程模型。

整条链路可以先简化成:

Java 方法调用
    ↓
域名解析
    ↓
HTTP 消息
    ↓
Socket
    ↓
TCP 字节流
    ↓
IP 数据报
    ↓
查询路由

本文先讲到 IP 层。数据包怎样选择下一跳,留到第二篇。


一、先看分层

一次网络请求并不是由一个协议完成的。

可以把它理解为多层协作:

应用层
    HTTP、DNS、RPC

传输层
    TCP、UDP

网际层
    IPv4、IPv6、ICMP

链路层
    Ethernet、Wi-Fi

每层只负责自己的问题:

HTTP
    请求什么资源?

TCP
    字节怎样可靠、有序传输?

IP
    最终送到哪台主机?

链路层
    当前这一跳怎样真正发出去?

TCP 不理解:

GET /users/1001

IP 也不理解:

Host: example.com

Ethernet 更不知道 Java 中调用了哪个方法。

这就是分层的核心:

上层只使用下层提供的能力,不需要理解下层全部实现。

OSI 七层模型更适合描述职责边界;实际互联网协议栈通常以 TCP/IP 的四层或五层模型来理解。原资料也把 OSI 定义为参考模型,而不是某个操作系统必须严格实现的七个独立模块。


二、先解析域名

应用传入的地址通常是:

http://example.com/users/1001

其中包含:

scheme:http
host:example.com
port:默认 80
path:/users/1001

但 IP 层不能使用:

example.com

它需要的是 IPv4 或 IPv6 地址。

于是连接前通常需要完成:

example.com
    ↓
DNS 查询
    ↓
目标 IP

DNS 中的 A 记录可以把名称映射为 IPv4 地址;一台主机也可以拥有多个地址记录。解析结果可能来自本机缓存、系统解析器或 DNS 服务器,因此并不是每次请求都一定重新进行完整的网络查询。

假设最终得到:

93.184.216.34

接下来 Java 才有了可以交给网络层的远端地址。

但 DNS 只回答:

目标地址是什么?

它不负责回答:

数据应该经过哪些路由器?

三、构造 HTTP 消息

以 HTTP/1.1 为例,一个请求可以表示为:

GET /users/1001 HTTP/1.1
Host: example.com
Connection: keep-alive

在真实字节中,每行通常使用:

\r\n

结束。

完整格式是:

请求行
    ↓
零个或多个 Header
    ↓
空行
    ↓
可选 Body

HTTP/1.1 规范定义的消息结构正是:起始行、Header 区、表示 Header 结束的空行,以及可选消息体。

例如 POST 请求:

POST /users HTTP/1.1
Host: example.com
Content-Type: application/json
Content-Length: 16

{"name":"Tom"}

HTTP 负责规定:

使用 GET 还是 POST
访问哪个路径
Header 如何表达
Body 怎样定界
响应状态如何表示

它不负责:

如何重传丢失的数据
从哪张网卡发出
下一跳网关是谁

四、连接不一定新建

很多示意图会写成:

一次 HTTP 请求
    ↓
一次 TCP 三次握手
    ↓
一次 HTTP 响应
    ↓
四次挥手

这只是最容易理解的简化模型。

HTTP/1.1 默认支持持久连接,一条 TCP 连接可以承载多次 HTTP 请求与响应,除非一方明确使用 Connection: close,或者连接因超时、异常等原因关闭。

因此,实际情况可能是:

第一次请求
    ↓
DNS
    ↓
TCP 三次握手
    ↓
HTTP 请求与响应

第二次请求
    ↓
复用已有连接
    ↓
HTTP 请求与响应

是否复用取决于:

HTTP 版本
客户端连接池
服务端策略
代理服务器
空闲超时
目标地址
TLS 配置

所以:

HTTP 请求是应用层交换,TCP 连接是传输层通道,两者不是严格的一对一关系。


五、Socket 是接口

如果不用 HttpClient,也可以直接使用 Socket 发送 HTTP:

try (Socket socket = new Socket()) {
    socket.connect(
            new InetSocketAddress(
                    "example.com",
                    80
            ),
            3000
    );

    String request = """
            GET / HTTP/1.1\r
            Host: example.com\r
            Connection: close\r
            \r
            """;

    socket.getOutputStream().write(
            request.getBytes(
                    StandardCharsets.US_ASCII
            )
    );

    socket.getOutputStream().flush();

    byte[] response =
            socket.getInputStream().readAllBytes();

    System.out.println(
            new String(
                    response,
                    StandardCharsets.UTF_8
            )
    );
}

Java Socket.connect() 接收一个远端 SocketAddress,由底层 Socket 实现完成连接。

大致链路是:

Java Socket
    ↓
JDK 本地实现
    ↓
操作系统 Socket API
    ↓
内核 TCP 状态

Socket 是应用访问网络协议栈的入口。

它并不是 TCP 协议本身,也不是一个简单的 IP 地址。


六、fd 只在本地有效

在 Linux 等系统中,应用通常通过文件描述符引用内核 Socket。

例如:

Java 进程文件描述符表

fd 0 → 标准输入
fd 1 → 标准输出
fd 2 → 标准错误
fd 7 → TCP Socket

fd=7 只对当前进程有意义:

当前进程
    ↓
文件描述符表
    ↓
内核 Socket 对象

另一个进程也可以拥有 fd=7,但它可能指向:

普通文件
管道
另一个 Socket

文件描述符不会被发送给服务端。

它只是本地进程操作内核资源的句柄。


七、Socket 不等于四元组

经常有人说:

Socket 就是四元组

这个说法不够准确。

对于一条已经建立的 TCP 连接,我们通常用以下信息标识它:

本地 IP
本地端口
远端 IP
远端端口

例如:

192.168.1.10:51000
    ↔
93.184.216.34:80

TCP Header 携带源端口和目标端口,IP Header 携带源 IP 和目标 IP,这些字段共同构成常用的连接标识。

但一个内核 Socket 还包含:

TCP 状态
发送缓冲区
接收缓冲区
序号
确认号
窗口
定时器
错误状态

监听 Socket 甚至还没有固定的远端 IP 和远端端口。

更准确的说法是:

已建立 TCP 连接通常可以由两端 IP 和端口构成的四元组区分,但 Socket 是承载协议状态和缓冲区的内核通信对象。


八、TCP 建立连接

当前不存在可复用连接时,客户端会发起 TCP 三次握手:

客户端                                      服务端

CLOSED                                      LISTEN
   │                                           │
   │ SYN,seq=x                                │
   ├──────────────────────────────────────────→│
   │                                           │
SYN-SENT                                 SYN-RECEIVED
   │                                           │
   │ SYN+ACK,seq=y,ack=x+1                   │
   │←──────────────────────────────────────────┤
   │                                           │
   │ ACK,ack=y+1                              │
   ├──────────────────────────────────────────→│
   │                                           │
ESTABLISHED                              ESTABLISHED

TCP 为应用提供可靠、有序的字节流;应用写入的字节会被组织成一个或多个 TCP Segment,再通过 IP 数据报发送。

三次握手主要完成:

双方初始序号同步
连接状态建立
双向通信路径确认

第三个 ACK 可以带数据

如果客户端在收到 SYN+ACK 后已经准备好应用数据,那么第三个 ACK 所在的 Segment 可以同时携带 HTTP 字节:

ACK
+
HTTP Request

这并没有减少握手次数,只是确认信息和应用数据共用一次发送。

线程不是 TCP 创建的

原资料中提到:

三次握手完成后
才会创建线程、对象、描述符

这需要分层理解。

TCP 握手过程中,内核已经需要保存连接状态;而应用是否为连接创建线程,取决于服务端模型:

BIO
    可能一个连接一个平台线程

NIO
    一个 EventLoop 管理大量连接

虚拟线程
    可能一个连接一个虚拟线程

TCP 不会自动替 Java 创建业务线程。


九、TCP 只看字节

Java 可能调用:

output.write(requestBytes);

但 TCP 不会保留这次 write() 的边界。

应用写入:

HTTP Header:120 字节
Body:500 字节

TCP 可能组织成:

Segment 1:300 字节
Segment 2:320 字节

接收端也可能通过一次或多次 read() 得到这些字节。

TCP 规范明确把服务定义为可靠、有序的字节流,而不是业务消息服务。

所以:

一次 write()
≠
一个 TCP Segment
≠
一次 read()
≠
一条 HTTP 消息

HTTP Server 仍然必须按照 HTTP 协议解析消息边界。


十、字节进入内核

Java 程序运行在用户空间。

可以把发送过程简化为:

用户态

Java byte[]
    ↓
HttpClient / Socket
    ↓
系统调用边界

内核态

Socket 发送缓冲区
    ↓
TCP
    ↓
IP
    ↓
网卡驱动

这里需要避免两个极端说法。

错误说法一:

Java 每写一个字节
网卡立刻发送一个字节

错误说法二:

只要写入用户态 Buffer
数据就已经到达对端

真实过程中可能包含:

用户态缓冲
JDK 缓冲
Socket 发送缓冲
TCP 分段
网卡队列
网络传输

调用返回只代表某一层完成了自己的工作,不等于远端业务已经执行。


十一、逐层封装

HTTP 字节进入 TCP 后,会被逐层封装。

HTTP Request
    ↓
TCP Segment
    ↓
IP Datagram
    ↓
链路层 Frame

可以画成:

┌──────────────────────────────────────┐
│ 链路层 Header                        │
├──────────────────────────────────────┤
│ IP Header                            │
│   源 IP                              │
│   目标 IP                            │
├──────────────────────────────────────┤
│ TCP Header                           │
│   源端口                             │
│   目标端口                           │
│   序号与确认号                       │
├──────────────────────────────────────┤
│ HTTP Request                         │
└──────────────────────────────────────┘

不同层关注不同字段:

HTTP Server
    关注方法、路径、Header

TCP
    关注端口、序号、状态

IP
    关注源地址与目标地址

链路层
    关注当前链路上的发送

本文到这里停止。

IP 层拿到目标地址后,接下来必须查询:

数据从哪块网卡发出?
当前下一跳是谁?

这正是第二篇的主线。


十二、服务端怎样收到?

数据到达服务端时,链路反向展开:

服务端网卡
    ↓
链路层
    ↓
IP
    ↓
TCP
    ↓
Socket 接收缓冲区
    ↓
Java / Netty / Servlet
    ↓
HTTP 解析
    ↓
业务 Handler

服务端监听:

0.0.0.0:80

表示监听本地适用地址上的 80 端口。

连接建立后,内核维护一条具体连接:

服务端 93.184.216.34:80
    ↔
客户端 192.168.1.10:51000

监听 Socket 与已建立连接对应的 Socket 是不同对象。

应用最终通过:

InputStream.read()
SocketChannel.read()
Netty Channel
Servlet 容器

读取 TCP 字节,再把它们解析成 HTTP 消息。


十三、动手观察

可以用三个终端观察一次请求。

终端一

查看 TCP 连接:

ss -antp

终端二

抓取 HTTP 与 TCP 数据:

sudo tcpdump -ni any \
    'tcp port 80'

终端三

发起请求:

curl -v http://example.com/

可能观察到:

SYN
    ↓
SYN+ACK
    ↓
ACK
    ↓
HTTP Request
    ↓
HTTP Response
    ↓
连接关闭或继续复用

再次执行请求时,不一定重新出现握手。

是否复用连接,要看客户端及服务器是否保留了连接。


十四、常见误区

1. HTTP 负责建立连接

错误。

HTTP 定义应用消息,TCP 或其他传输机制负责连接和数据交付。

2. 每个 HTTP 请求都对应一次握手

错误。

HTTP/1.1 支持持久连接,一条连接可以承载多个交换。

3. Socket 就是四元组

不准确。

四元组用于区分已建立连接,Socket 还包含状态、缓冲区和定时器。

4. fd 是网络连接编号

不准确。

fd 是当前进程引用本地内核对象的句柄。

5. 三次握手后才分配所有资源

错误。

内核在握手过程中已经需要维护状态;应用线程由编程模型决定。

6. TCP 一次 write 对应一次 read

错误。

TCP 提供字节流,不保留应用写入边界。

7. write 返回代表服务端处理成功

错误。

它最多表示数据已经被当前调用链的某个发送阶段接受。

8. TCP 可靠代表永远不会失败

错误。

TCP 会重传和检测错误,但网络长期不可达时仍然会超时或终止连接。


总结

Java 发起 HTTP 请求时,首先从 URL 中得到:

协议
域名
端口
路径

域名通常需要通过 DNS 转换为 IP 地址:

example.com
    ↓
DNS
    ↓
93.184.216.34

HTTP 层构造:

请求行
Header
Body

Socket 将字节交给操作系统:

Java Socket
    ↓
文件描述符
    ↓
内核 Socket

如果没有可复用连接,TCP 进行三次握手:

SYN
    ↓
SYN+ACK
    ↓
ACK

随后,HTTP 字节被 TCP 当作连续字节流处理:

HTTP Request
    ↓
TCP Segment
    ↓
IP Datagram

TCP 不理解 URL,IP 不理解 HTTP。

每一层只负责自己的问题。

最后,可以用一句话概括本文:

Java 负责发起调用,HTTP 负责表达请求,Socket 负责连接应用与内核,TCP 负责可靠传输字节,而 IP 层接过数据后,才开始决定它下一步应该交给谁。

0

评论区