前言
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 层接过数据后,才开始决定它下一步应该交给谁。
评论区