HttpClient 内部机制:连接池、HTTP/2 与异步
学完你要能自己回答
- 一次 send / sendAsync 从 API 到 socket 经过哪些层?
- 连接池按什么维度复用?HTTP/1.1 keep-alive 与 HTTP/2 多路复用差在哪里?
- CompletableFuture 在哪个线程完成?异常和超时怎么传播?
- JDK 11 的 HttpClient 有哪些生命周期与协议限制?
三个对象,一个长生命周期门面01
HttpClient 是配置与连接池的持有者;HttpRequest 描述一次请求;HttpResponse 由 BodyHandler 决定响应体形态。Client 和 Request 都是不可变对象,创建后可以安全地被多个线程复用。
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
public class ClientShape {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.followRedirects(HttpClient.Redirect.NORMAL)
.version(HttpClient.Version.HTTP_2)
.build();
HttpRequest request = HttpRequest.newBuilder(URI.create("https://example.com/api"))
.timeout(Duration.ofSeconds(5))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = client.send(
request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
}
}
HttpClient的配置在 build 时冻结;要改超时、代理、重定向策略,必须新建 client。HttpRequest也是不可变的;同一个 request 可以重复 send,BodyPublisher 是否可重复由实现决定。HttpResponse.BodyHandler决定 body 的类型与消费方式;handler 在响应头到达后参与处理 body。HttpClient线程安全,应该复用;它不是一次请求的临时对象。
send 是 sendAsync 的等待版02
send 和 sendAsync 不是两套实现。sendAsync 返回 CompletableFuture;send 内部发起同一套异步交换,然后阻塞等待结果。因此 send 不会为每个请求创建线程,也不会绕过连接池。
CompletableFuture<HttpResponse<String>> future =
client.sendAsync(request, HttpResponse.BodyHandlers.ofString());
String body = future
.thenApply(HttpResponse::body)
.get(2, TimeUnit.SECONDS);
| 维度 | send | sendAsync |
|---|---|---|
| 调用方 | 阻塞直到响应或异常 | 立即返回 CompletableFuture |
| 线程 | 占用调用线程等待 | 由客户端 executor 完成 |
| 异常 | 直接抛 IOException / HttpTimeoutException | Future 异常完成,get/join 包装 |
| 适用 | 简单脚本、已有线程池 | 高并发、需要组合多个请求 |
| 连接池 | 共用 | 共用 |
在 JDK 11 上没有虚拟线程,send 的阻塞成本是真实的平台线程。需要高并发时,优先用 sendAsync 组合,或把 send 放进有界线程池;不要为每个请求创建一个平台线程。
thenApply 默认在完成响应的线程上执行,不保证是调用线程。不要在 thenApply 里做长时间阻塞操作,否则会占住客户端 executor。请求版本不等于响应版本03
HttpClient.version() 是期望协议,默认 HTTP_2;HttpResponse.version() 是服务端实际协商结果。服务端不支持 HTTP/2 时,客户端会回落到 HTTP/1.1,请求本身不会失败。
HttpClient client = HttpClient.newHttpClient();
System.out.println("client.version=" + client.version()); // HTTP_2
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("sync=" + response.statusCode() + " "
+ response.body() + " " + response.version()); // 200 hello HTTP_1_1
client.version=HTTP_2 sync=200 hello HTTP_1_1
| 场景 | 协商方式 | 结果 |
|---|---|---|
| HTTPS + HTTP/2 | TLS ALPN 协商 h2 | 连接上多路复用 stream |
| HTTP + HTTP/2 | h2c upgrade 或 prior knowledge | 服务端支持才升级 |
| 服务端只支持 HTTP/1.1 | 回落 | response.version() = HTTP_1_1 |
| 请求显式 HTTP_1_1 | 不尝试 h2 | response.version() = HTTP_1_1 |
一次请求经过的五层01
从公共 API 到 socket,大致经过「门面 → 交换 → 协议 → I/O → 池」五层。内部类名不是公共 API,升级 JDK 时可能变化;这里用它们建立职责地图,而不是让你去 import。
| 层 | 典型内部角色 | 职责 |
|---|---|---|
| 公共门面 | HttpClientFacade / HttpClientImpl | 保存配置、暴露 send / sendAsync / WebSocket |
| 请求交换 | MultiExchange / Exchange | 重定向、认证、一次请求-响应生命周期 |
| 协议 | Http1Exchange / Http2Connection | HTTP/1.1 消息或 HTTP/2 帧与 stream |
| I/O | SelectorManager | 非阻塞 socket 事件、连接建立、读写调度 |
| 连接池 | ConnectionPool | 按目标缓存空闲连接、复用与清理 |
- 调用
send/sendAsync,门面创建一次 exchange,确定目标地址、代理、TLS 配置。 - 连接池查找可复用连接;没有可用连接时,由 SelectorManager 发起非阻塞 connect。
- 协议层把请求编码为 HTTP/1.1 消息或 HTTP/2 HEADERS/DATA 帧;HTTP/2 在已有连接上开新 stream。
- 响应头到达后,BodyHandler 决定如何消费 body;body 可能分多个事件到达。
- 交换完成,连接归还池中;HTTP/2 连接保持打开,HTTP/1.1 连接按 keep-alive 复用。
send 的「阻塞」发生在门面等待 Future 的阶段,底层仍然是 selector 驱动的非阻塞 I/O。它不会为每个请求创建一个 selector 线程。连接池:按目标与配置分组02
连接池属于 client 实例,按目标地址、代理、TLS 配置等维度区分。HTTP/1.1 连接同一时刻只处理一个请求,顺序请求可以复用;并发请求可能需要多条连接。HTTP/2 连接可以同时承载多个 stream。
// 同一个 client、同一个 origin
client.send(request, HttpResponse.BodyHandlers.ofString()); // 建立连接 A
int before = PORTS.size();
for (int i = 0; i < 5; i++) {
client.send(request, HttpResponse.BodyHandlers.ofString());
}
System.out.println(before + "->" + PORTS.size());
1->1
5 次顺序请求没有新增远端端口,说明连接被复用。这个实验只覆盖 HTTP/1.1 keep-alive;HTTP/2 的复用发生在 stream 层,服务端看到的仍然是一条 TCP 连接。
| 协议 | 复用单位 | 并发 | 注意 |
|---|---|---|---|
| HTTP/1.1 | TCP 连接 | 每连接一个在途请求 | 池可为并发请求开多条连接 |
| HTTP/2 | stream | 一条连接多个在途请求 | 受服务端 MAX_CONCURRENT_STREAMS 限制 |
| HTTP/2 明文 | 连接 + upgrade | 同上 | 服务端不支持则回落 HTTP/1.1 |
CompletableFuture 的完成与异常03
sendAsync 返回的 Future 在响应体被 BodyHandler 消费完后完成。thenApply 等依赖阶段默认在完成线程上执行;需要指定线程池时用 thenApplyAsync(fn, executor)。
CompletableFuture<String> body = client
.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.exceptionally(e -> "fallback: " + e.getClass().getSimpleName());
System.out.println(body.get(2, TimeUnit.SECONDS));
| 阶段 | 执行位置 | 注意 |
|---|---|---|
| 连接 / 读写 | SelectorManager | 非阻塞事件循环 |
| 响应体处理 | 客户端 executor | BodyHandler / BodySubscriber |
| thenApply | 完成响应的线程 | 不保证调用线程 |
| thenApplyAsync(fn, executor) | 指定 executor | 可控,但增加切换 |
| get / join | 调用线程 | get 抛 ExecutionException,join 抛 CompletionException |
send直接抛HttpTimeoutException;sendAsync的 Future 异常完成,get()的 cause 才是它。thenApply里抛出的异常会让后续阶段异常完成;用exceptionally/handle做降级。- 多个请求组合用
allOf时,任何一个失败都会让allOf失败;需要逐个 future 处理异常,避免丢失其他结果。 - 不要在没有限流的情况下对下游发起无限并发;连接池和信号量才是并发上限。
超时:连接与整个交换分开设01
connectTimeout 只覆盖建立连接;HttpRequest.timeout 覆盖从请求发出到响应体完成的整个交换。两者都不设时,客户端可能无限等待。
HttpRequest slow = HttpRequest.newBuilder(URI.create(base + "/slow"))
.timeout(Duration.ofMillis(100))
.GET()
.build();
try {
client.send(slow, HttpResponse.BodyHandlers.ofString());
} catch (HttpTimeoutException e) {
System.out.println("timeout=" + e.getClass().getSimpleName());
}
timeout=HttpTimeoutException
连接阶段超时通常是 HttpConnectTimeoutException(HttpTimeoutException 的子类),请求阶段超时是 HttpTimeoutException。异步调用要把 cause 取出来判断,不能只看最外层异常。
| 设置 | 覆盖范围 | 默认 | 建议 |
|---|---|---|---|
| connectTimeout | TCP / TLS 建连 | 无 | 必须设,避免黑洞地址 |
| request.timeout | 整个请求-响应 | 无 | 按下游 SLA 设 |
| 重定向 | 默认 NEVER | 不跟随 | 明确 NORMAL / ALWAYS |
| 连接池空闲 | 实现内部管理 | — | 不要依赖未公开属性 |
BodyHandler 与 BodyPublisher 的选择02
响应体用 BodyHandler 消费,请求体用 BodyPublisher 提供。选择时要考虑内存、流式和关闭责任。
| 需求 | Handler / Publisher | 注意 |
|---|---|---|
| 字符串 | BodyHandlers.ofString() | 优先使用响应头 charset,缺省 UTF-8 |
| 字节数组 | ofByteArray() | 整个 body 进内存 |
| 文件 | ofFile(path) | 适合大响应;注意覆盖策略 |
| 按行 | ofLines() | 返回 Stream,必须关闭 |
| 丢弃 | discarding() | 不读 body,适合只关心状态码 |
| 上传字符串 | BodyPublishers.ofString() | Content-Type 需要自己设 |
| 上传文件 | ofFile(path) | 长度已知 |
| 上传流 | ofInputStream(supplier) | 长度未知时 chunked |
HttpResponse<String> text = client.send(request,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
HttpResponse<Path> file = client.send(request,
HttpResponse.BodyHandlers.ofFile(Path.of("body.bin")));
try (var lines = client.send(request, HttpResponse.BodyHandlers.ofLines()).body()) {
lines.limit(10).forEach(System.out::println);
}
ofString() 会读取响应头 Content-Type 的 charset;没有时按 UTF-8。需要固定编码时显式传 StandardCharsets.UTF_8,不要依赖平台默认编码。
ofLines() 返回的 Stream 持有连接与解码器,必须关闭;不关闭会让连接无法归还池中。ofString() / ofByteArray() 会一次性读完,适合小响应。可运行:本地 HttpServer 全链路03
下面用 JDK 自带的 com.sun.net.httpserver.HttpServer 启动一个只支持 HTTP/1.1 的本地服务,验证同步、异步和连接复用。服务端按远端端口计数连接。
import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import java.util.concurrent.*;
public class HttpLocal {
static final Set<Integer> PORTS = ConcurrentHashMap.newKeySet();
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/hello", ex -> {
PORTS.add(ex.getRemoteAddress().getPort());
byte[] body = "hello".getBytes(StandardCharsets.UTF_8);
ex.sendResponseHeaders(200, body.length);
try (OutputStream os = ex.getResponseBody()) { os.write(body); }
});
server.start();
String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/hello";
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("client.version=" + client.version());
System.out.println("sync=" + response.statusCode() + " " + response.body() + " " + response.version());
String async = client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(r -> r.statusCode() + ":" + r.body())
.get(2, TimeUnit.SECONDS);
System.out.println("async=" + async);
int before = PORTS.size();
for (int i = 0; i < 5; i++) {
client.send(request, HttpResponse.BodyHandlers.ofString());
}
System.out.println("ports=" + before + "->" + PORTS.size());
server.stop(0);
}
}
client.version=HTTP_2 sync=200 hello HTTP_1_1 async=200:hello ports=1->1
输出说明三件事:客户端期望 HTTP/2;本地服务只支持 HTTP/1.1,响应实际是 HTTP_1_1;同步与异步都成功;连续 5 次请求复用了同一条连接。