JDK JDK 学习手册8 → 27 · JDK 专用学习站
DEEPDIVE · NETWORK · JEP 321 (JDK 11)

HttpClient 内部机制:连接池、HTTP/2 与异步

JDK 11 · 深钻主题 01 / 03 · 预计 55 分钟
核心四步深钻JEP 321
一句话HttpClient 的公共 API 很小,内部却是一条完整的异步 I/O 管线:SelectorManager 负责非阻塞事件,ConnectionPool 负责复用,Exchange 负责一次请求/响应,HTTP/2 把一条连接拆成多个 stream。理解这条管线,才能正确设置超时、并发与生命周期。
本质:HttpClient 是一个长生命周期的线程安全门面;send 与 sendAsync 共用同一条 selector + 连接池 + protocol exchange 管线,差别只在调用方是否等待。

学完你要能自己回答

  1. 一次 send / sendAsync 从 API 到 socket 经过哪些层?
  2. 连接池按什么维度复用?HTTP/1.1 keep-alive 与 HTTP/2 多路复用差在哪里?
  3. CompletableFuture 在哪个线程完成?异常和超时怎么传播?
  4. JDK 11 的 HttpClient 有哪些生命周期与协议限制?
01 三个对象,一个长生命周期门面02 send 是 sendAsync 的等待版03 请求版本不等于响应版本

三个对象,一个长生命周期门面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 线程安全,应该复用;它不是一次请求的临时对象。
JDK 11 的 HttpClient 没有 close()。生命周期与 client 实例绑定;在长生命周期服务里,通常持有一个静态或注入的单例。

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);
维度sendsendAsync
调用方阻塞直到响应或异常立即返回 CompletableFuture
线程占用调用线程等待由客户端 executor 完成
异常直接抛 IOException / HttpTimeoutExceptionFuture 异常完成,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/2TLS ALPN 协商 h2连接上多路复用 stream
HTTP + HTTP/2h2c upgrade 或 prior knowledge服务端支持才升级
服务端只支持 HTTP/1.1回落response.version() = HTTP_1_1
请求显式 HTTP_1_1不尝试 h2response.version() = HTTP_1_1
本地 com.sun.net.httpserver.HttpServer 默认只支持 HTTP/1.1,所以它无法验证 HTTP/2 多路复用。要验证 HTTP/2,需要支持 ALPN 的 TLS 服务端或 h2c 服务端。
01 一次请求经过的五层02 连接池:按目标与配置分组03 CompletableFuture 的完成与异常

一次请求经过的五层01

从公共 API 到 socket,大致经过「门面 → 交换 → 协议 → I/O → 池」五层。内部类名不是公共 API,升级 JDK 时可能变化;这里用它们建立职责地图,而不是让你去 import。

层典型内部角色职责
公共门面HttpClientFacade / HttpClientImpl保存配置、暴露 send / sendAsync / WebSocket
请求交换MultiExchange / Exchange重定向、认证、一次请求-响应生命周期
协议Http1Exchange / Http2ConnectionHTTP/1.1 消息或 HTTP/2 帧与 stream
I/OSelectorManager非阻塞 socket 事件、连接建立、读写调度
连接池ConnectionPool按目标缓存空闲连接、复用与清理
  1. 调用 send / sendAsync,门面创建一次 exchange,确定目标地址、代理、TLS 配置。
  2. 连接池查找可复用连接;没有可用连接时,由 SelectorManager 发起非阻塞 connect。
  3. 协议层把请求编码为 HTTP/1.1 消息或 HTTP/2 HEADERS/DATA 帧;HTTP/2 在已有连接上开新 stream。
  4. 响应头到达后,BodyHandler 决定如何消费 body;body 可能分多个事件到达。
  5. 交换完成,连接归还池中;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.1TCP 连接每连接一个在途请求池可为并发请求开多条连接
HTTP/2stream一条连接多个在途请求受服务端 MAX_CONCURRENT_STREAMS 限制
HTTP/2 明文连接 + upgrade同上服务端不支持则回落 HTTP/1.1
连接池是 per-client 的。每个请求 new 一个 HttpClient,等于每次新建一个池,复用率为零;这比「少写一个单例」的代价大得多。

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非阻塞事件循环
响应体处理客户端 executorBodyHandler / BodySubscriber
thenApply完成响应的线程不保证调用线程
thenApplyAsync(fn, executor)指定 executor可控,但增加切换
get / join调用线程get 抛 ExecutionException,join 抛 CompletionException
  • send 直接抛 HttpTimeoutException;sendAsync 的 Future 异常完成,get() 的 cause 才是它。
  • thenApply 里抛出的异常会让后续阶段异常完成;用 exceptionally / handle 做降级。
  • 多个请求组合用 allOf 时,任何一个失败都会让 allOf 失败;需要逐个 future 处理异常,避免丢失其他结果。
  • 不要在没有限流的情况下对下游发起无限并发;连接池和信号量才是并发上限。
异步不是「没有线程」,而是「少量线程 + 事件驱动」。BodyHandler 和回调仍然消耗 executor 线程;阻塞式回调会把异步管线拖回平台线程模型。
01 超时:连接与整个交换分开设02 BodyHandler 与 BodyPublisher 的选择03 可运行:本地 HttpServer 全链路

超时:连接与整个交换分开设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 取出来判断,不能只看最外层异常。

设置覆盖范围默认建议
connectTimeoutTCP / TLS 建连无必须设,避免黑洞地址
request.timeout整个请求-响应无按下游 SLA 设
重定向默认 NEVER不跟随明确 NORMAL / ALWAYS
连接池空闲实现内部管理—不要依赖未公开属性
只设 connectTimeout 不够:连接建立后,服务端可能迟迟不返回响应。请求级 timeout 才是端到端的保护。

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 次请求复用了同一条连接。

这个示例没有验证 HTTP/2 多路复用。要验证它,需要一个支持 ALPN 的 HTTPS 服务端,或支持 h2c 的明文服务端;JDK 自带 HttpServer 不提供这些能力。
01 先猜再看:三个常见误解02 费曼自检:能否讲清管线

先猜再看:三个常见误解01

先猜再看每次 send 都会新建一条 TCP 连接吗?
不会。HttpClient 内部有连接池,顺序请求会复用 keep-alive 连接;HTTP/2 还会在一条连接上复用多个 stream。只有池中没有可用连接、或并发超过连接承载能力时才会新建。
先猜再看请求设置 HTTP_2,响应就一定是 HTTP/2 吗?
不一定。HTTP/2 需要服务端协商支持;不支持时会回落到 HTTP/1.1。判断实际协议要看 response.version(),不能看 request.version()。
先猜再看sendAsync 的回调一定在调用线程执行吗?
不是。Future 在客户端 executor 上完成,thenApply 默认在完成线程执行。需要指定线程池时用 *Async(fn, executor)。

费曼自检:能否讲清管线02

01. 一次 send 从 API 到 socket 经过哪些层?
门面创建 exchange,连接池查找/建立连接,协议层编码 HTTP/1.1 或 HTTP/2,SelectorManager 负责非阻塞读写,BodyHandler 消费响应体,完成后连接归还池中。
02. HTTP/1.1 和 HTTP/2 的复用单位有什么不同?
HTTP/1.1 复用 TCP 连接,但同一时刻一个连接只有一个在途请求;HTTP/2 复用连接上的 stream,一条连接可同时承载多个请求。
03. sendAsync 的异常为什么不能只看最外层?
sendAsync 的 Future 异常完成,get() 抛 ExecutionException,join() 抛 CompletionException;真正的 HttpTimeoutException 在 cause 里。需要沿 cause 链判断。
04. JDK 11 的 HttpClient 生命周期有什么限制?
它不是 AutoCloseable,没有 close(),不能 try-with-resources;连接池与实例绑定,只能依赖空闲回收或进程退出。这个限制在 JDK 21 才改变。
能把「send 只是等待,底层始终是 selector + 池 + exchange」讲清楚,并说清 HTTP/1.1 与 HTTP/2 复用单位的区别,这一节就过关了。
学习进度 记录保存在本机浏览器 · 不上传

JDK 学习手册 · JDK / Java 专用学习站 · 纯静态 · 双击 index.html 即可使用

本站专注 JDK 版本演进与 Java 教学,非 Oracle / OpenJDK 官方站点。版本数据来自 OpenJDK 官方 Features 表;课程示例已在对应本机 JDK 实测。构建日期 2026-11-12。