开头直接说结论:Apache HttpAsyncClient 迁移到 JDK17 HttpClient 这件事,代码层面远没有你想象的那么难,真正的难点在于两套 API 的“思维模型”完全不一样。Apache 那边是一堆可调教的组件,连接池、socket 超时、重定向策略、证书信任逻辑各自为政;JDK17 这边更像一个高度封装的整体,官方把连接管理、协议协商、线程调度都藏在了 HttpClient 内部。如果你还停留在“找对应关系”的思路去迁移,大概率会在重定向丢认证、连接池参数对不上、异步超时失效这些坑里来回折腾。这篇就把我实际踩过的坑和验证过的方案完整写出来,给正在做或者准备做这个迁移的同学一个参考。
先说为什么会有这篇分享。我们项目原来用的是 Apache HttpAsyncClient 4.x,异步回调风格写多了之后,接口之间只要稍微有点依赖关系,代码就开始往“回调地狱”的方向发展。后来借着 JDK17 升级的契机,我主导把核心链路的 HTTP 调用整体切到了 JDK17 自带的 HttpClient,整个过程从摸底到全量替换加压测,大概花了两周多。期间踩了不少坑,也沉淀了一套自己觉得比较稳的迁移方法。这篇文章就是把这些经验完整梳理一遍,包含迁移前置准备、代码替换对照、连接池/重定向/证书的差异处理,以及一份可以照着抄的检查清单。
1. 为什么值得从 Apache HttpAsyncClient 迁到 JDK17 HttpClient
1.1 从依赖维护和长期演进的角度看
广大读者一定遇到过类似的问题。我接着说,为什么用 JDK17 HttpClient 替换 Apache HttpAsyncClient 是值得的。从我这边的实际使用体会来说,最核心的理由有三个。第一个是减少依赖。Apache HttpAsyncClient 不是单独存在的,它会拉进来 httpcore、httpcore-nio、commons-logging 这些间接依赖,如果项目里用的还是老版本,还可能跟别的组件因为 commons-logging 的版本问题起冲突。JDK17 内置的 HttpClient 是 java.net.http 包下面的官方实现,不需要引任何第三方 jar,也没有传递依赖的负担,这一点对想要精简依赖、收敛 jar 包体积的项目特别友好。第二个理由是拥抱虚拟线程。JDK21 里虚拟线程正式转正之后,同步阻塞式的 HttpClient 写法在并发场景下的性价比非常高,线程开销从“每请求一个线程”变成“每请求一个虚拟线程”,资源占用少了一个量级。虽然咱们这篇的主题是 JDK17,但代码结构上只要一开始就按同步写法组织,未来切到 JDK21 几乎就是换 JDK 版本的事,不用改代码。第三个理由是 API 设计更现代。JDK17 的 HttpClient 支持 HTTP/2、响应式 BodySubscriber、异步 CompletableFuture,这些能力是标准的、有 Oracle 官方维护的,长期来看比第三方库的演进路线更可控。
再说说 Apache HttpAsyncClient 这边,它的优势在于生态成熟、资料多、历史包袱少,很多老项目里的连接池参数、重试策略、证书信任逻辑都已经调得很稳定了。但它的异步回调风格写起来确实没有 CompletableFuture 舒服,尤其是多个请求之间要做串行依赖、并行聚合、超时控制的时候,回调嵌套非常容易写成“回调地狱”。而且 Apache HttpAsyncClient 4.x 系列已经很多年没有大的功能性更新了,社区的重心基本都转到 Apache HttpClient 5.x 和 HttpCore5 上去了。如果你还在 4.x 上,从长期维护角度讲,也该考虑动一动了。
1.2 适用场景:什么样的项目适合迁移
不是所有项目都适合立刻迁移。我个人的判断标准是这样的:如果项目里所有 HTTP 调用都经过一个统一的工具类或门面类,迁移成本很低,适合尽早动;如果 HTTP 调用点散落在几十个类里,而且大量直接使用 HttpEntity、BasicNameValuePair、UrlEncodedFormEntity 这些 Apache 工具类型,迁移的改动面会很大,需要仔细评估。另外,如果你的系统面临高并发、长连接、多路复用这类诉求,JDK17 HttpClient 这种自带 HTTP/2 和现代化连接管理的实现,替换价值就特别明显。反过来,如果业务只是简单的十几个接口调用,而且运行得好好的,那迁移的优先级可以往后放一放,没必要为了“换新技术”而折腾。
2. 迁移前置准备:依赖清理、JDK17 环境与版本选择
2.1 先清理老依赖,再看要不要留兼容层
动手改代码之前,我先做了一件事——把项目里的 httpclient、httpcore、httpmime、commons-logging 全部列出来,看看到底哪些地方在用。我的做法是这样的:
- 先用 Maven 的 dependency:tree 命令扫一遍依赖树,确认 Apache HttpAsyncClient 相关的 jar 都是从哪里传进来的。
- 再全局搜一下 import org.apache.http 和 import org.apache.hc.client5 的代码,把真正使用 HttpClient 的类都找出来,评估每个类的改动量。
- 根据代码量和业务重要性,决定是“一步到位直接替换”还是“保留一个薄薄的兼容层,慢慢切”。
这里我得提醒一句:如果项目里有很多代码直接依赖了 HttpResponse.getEntity() 返回的 HttpEntity,或者大量使用 BasicNameValuePair、UrlEncodedFormEntity 这些 Apache 的工具类,替换的时候不要天真地以为只改 HttpClient 就行了。JDK17 的 HttpClient 没有 HttpEntity 的概念,它在 body 处理上用的是 BodyPublisher 和 BodySubscriber,两者完全是两套思维。
我自己的项目里,有一个比较通用的 HttpUtil 工具类,外部调用方只依赖这个工具类的方法签名,不直接碰 Apache 的类型。这种结构就非常适合“兼容层过渡”的方案:先修改 HttpUtil 的内部实现,把它从 Apache 换成 JDK17 HttpClient,对外暴露的返回类型继续保持原样,这样所有业务方的改动量几乎为零。如果你的项目不是这种结构,那就要做好改动面扩散的心理准备。
2.2 JDK17 环境准备:下载、安装、多版本切换
在开始写迁移代码之前,先把 JDK17 的环境准备好。这里我给一个比较稳妥的路径:
- 如果公司已经有统一的 JDK 安装规范,按规范走,尽量不要自己单独装。
- 如果没有规范,个人开发机可以下载 JDK17 的压缩包或者安装包,解压/安装之后配置好 JAVA_HOME 即可。
- 命令行下建议用 sdkman 或 jenv 这类多版本管理工具,切换 JDK 版本时不用反复改环境变量。
关于 JDK17 的下载和安装,网上搜“jdk17 download”之类的关键词能找到很多入口,但我要特别提醒一句:下载时优先选择官方渠道或者公司内部私服,不要随便从第三方网站下载所谓的“绿色版”“精简版”。这类非官方分发的 JDK 轻则会有运行时异常,重则可能存在安全风险。JDK 本身是一个需要高度信任的基础组件,这一点怎么谨慎都不为过。
安装完成之后,在命令行下执行 java -version,确认输出里包含 17 这个版本号,再确认 javac -version 也是 17。如果输出还是老版本,多半是 PATH 里的优先级问题,把 JAVA_HOME 配好、PATH 里的旧 JDK 路径删掉,重新开一个终端窗口再看。
2.3 选对实现:JDK HttpClient 是接口,不是唯一实现
这里要插播一个容易混淆的知识点。很多人一听到“JDK17 HttpClient”,会以为它就是 java.net.http.HttpClient 这个类本身。对,它确实是 JDK 自带的实现,但我想强调的是,java.net.http 这个包从 JDK9 引入、JDK11 正式标准化之后,API 层面的设计其实是相当干净的,你直接 new 出来的 HttpClient,背后会根据配置自动选择 HTTP/1.1 还是 HTTP/2 的协议栈。
更重要的是,JDK HttpClient 并没有像 Apache 那样把“核心库”和“连接池”拆成多个独立 jar,它的整个实现就在 JDK 内部。这意味着你不用再为了连接池参数去引 commons-pool 之类的库,连接复用、连接池管理、TLS 握手、重定向这些能力,HttpClient 自己就都带了。迁移的时候,对连接池这块的“戒断反应”会非常强烈,因为 Apache 的连接池配置项和 JDK 自带的那套参数并不一一对应,需要在迁移时逐个核对业务侧的诉求。
3. 代码迁移的完整实操:从 Apache 同步风格到 JDK17 HttpClient
3.1 典型 GET 请求的迁移对照
我们先看最典型的场景:发起一个 GET 请求,拿回响应体字符串。用 Apache HttpAsyncClient 4.x 写的时候,大概是这种风格:
CloseableHttpAsyncClient client = HttpAsyncClients.createDefault(); client.start(); HttpGet request = new HttpGet("https://api.example.com/users/1"); Future<HttpResponse> future = client.execute(request, null); HttpResponse response = future.get(); String body = EntityUtils.toString(response.getEntity());这段代码看着简单,但里面其实有坑。HttpGet 在 Apache 4.x 里默认是不带超时设置的,你得单独用 RequestConfig 设置 connectTimeout、socketTimeout,而且这些超时参数如果没设好,线上很容易出现“请求挂死、线程池打满”的情况。另外,EntityUtils.toString 会把整个响应体加载到内存里,对大响应体来说很容易触发 OOM。
同样的需求,用 JDK17 HttpClient 来写,同步风格大概是:
HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/users/1")) .timeout(Duration.ofSeconds(30)) .GET() .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); String body = response.body();迁移的时候,有几个细节是你一定会碰到的:
第一,JDK17 的 HttpClient 创建之后默认是“可复用”的,你不用手动去 start()。Apache 的 CloseableHttpAsyncClient 需要显式调用 start(),这个动作在 JDK17 里被直接省掉了。
第二,超时设置从“连接超时+读取超时”变成了“连接超时+请求超时”两层。connectTimeout 是新建连接时等待 TCP 握手的时间,HttpRequest 的 timeout 是等待整个响应完成的时间,这两个参数配合使用才能覆盖大部分超时场景。如果你原来在 Apache 里配的是 socketTimeout,迁移之后要注意,JDK17 HttpClient 并没有 socketTimeout 这个参数,它的语义被合并到请求级 timeout 里了。
第三,响应体处理从 EntityUtils.toString 变成了 BodyHandlers.ofString()。这个变化看起来平淡,实际上很考验你对“流式处理”的理解。BodyHandlers.ofString() 默认把整个响应体读到内存,适合小响应体;如果响应很大,可以用 BodyHandlers.ofInputStream() 或者 BodyHandlers.ofFile() 这种流式消费,完全避免内存风险。
3.2 POST 表单、JSON 请求与文件上传的迁移
POST 请求是生产中用到最多的场景。Apache HttpAsyncClient 里常见的写法是:
List<NameValuePair> params = new ArrayList<>(); params.add(new BasicNameValuePair("username", "admin")); params.add(new BasicNameValuePair("password", "123456")); UrlEncodedFormEntity entity = new UrlEncodedFormEntity(params, StandardCharsets.UTF_8); HttpPost post = new HttpPost("https://api.example.com/login"); post.setEntity(entity);这里的问题在于 UrlEncodedFormEntity 对编码的处理有时候会和服务器端的解析不一致,特别是当表单里有中文、特殊字符的时候,两边编码不一致就会导致乱码。JDK17 HttpClient 处理表单的方式更直接,它没有内置“表单编码器”,你需要自己拼 application/x-www-form-urlencoded 格式的字符串,然后交给 BodyPublishers.ofString:
String form = "username=admin&password=123456"; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/login")) .header("Content-Type", "application/x-www-form-urlencoded") .POST(BodyPublishers.ofString(form, StandardCharsets.UTF_8)) .build();自己拼表单字符串有一个需要注意的坑:name 和 value 都要做 URL 编码。推荐用 URLEncoder.encode(value, StandardCharsets.UTF_8) 单独编码每个键值对,再拼接到字符串里,不要图省事直接拼原始值。
如果是发送 JSON,JDK17 这边也很简单,把 JSON 字符串直接交给 BodyPublishers.ofString 就行。但务必要设置 Content-Type 为 application/json,很多后端框架对 Content-Type 校验很严格,不设置的话会直接报 415 错误。
文件上传又是另一个容易踩坑的点。Apache 里通常用 MultipartEntityBuilder 构造 multipart/form-data 请求,JDK17 HttpClient 不直接提供 multipart 的构造器,需要自己拼 body。这里分享一个我常用的封装思路:
- 用 UUID 生成一个 boundary 字符串。
- 按照 multipart/form-data 的规范,自己拼出每个表单字段和文件块的字节数组。
- 最后把整个内容交给 BodyPublishers.ofByteArray 发送。
拼 multipart 的时候,有一个细节特别容易错:字段之间的分隔符 \r\n 不能省略,每个字段块结束之后的换行也不能省,否则服务端解析 multipart 的时候会报“意外的文件结束”之类的错误。我后来把这个拼装过程封装成了一个工具方法,统一处理,避免每个上传场景都重新踩一遍边界符的坑。
3.3 异步迁移:从 Future/Callback 到 CompletableFuture
Apache HttpAsyncClient 的异步是基于 Future 或者回调的。老代码里常见的风格是:
client.execute(request, new FutureCallback<HttpResponse>() { @Override public void completed(HttpResponse result) { // 处理响应 } @Override public void failed(Exception ex) { // 处理失败 } @Override public void cancelled() { // 处理取消 } });这种回调风格写单个请求还勉强能忍,但一旦遇到“先请求A,再根据A的结果请求B”这种串行依赖,或者“并行请求C和D,等两个都返回后再合并”这种聚合逻辑,回调嵌套的代码基本就没法维护了。JDK17 HttpClient 的异步 API 直接返回 CompletableFuture,用起来会舒服很多:
CompletableFuture<HttpResponse<String>> futureA = client.sendAsync(requestA, HttpResponse.BodyHandlers.ofString()); CompletableFuture<HttpResponse<String>> futureB = client.sendAsync(requestB, HttpResponse.BodyHandlers.ofString()); CompletableFuture<Void> allDone = CompletableFuture.allOf(futureA, futureB); allDone.thenRun(() -> { HttpResponse<String> respA = futureA.join(); HttpResponse<String> respB = futureB.join(); // 聚合处理 }).exceptionally(ex -> { // 异常处理 return null; });这里我要特别讲一个迁移时最常见的理解偏差:Apache 的回调里,completed、failed、cancelled 三个方法是互斥的,而且一旦回调执行了,你很难再对那个 Future 做取消操作。而在 CompletableFuture 的世界里,异步结果本身就是一个可组合的 CompletableFuture,你可以用 thenApply、thenCompose、allOf、anyOf 自由编排,也可以用 completeExceptionally 手动把一个 Future 置为异常状态。这种“编排能力”上的差距,是整个迁移过程中体会最深的一点。
另外一个容易踩坑的地方是超时控制。CompletableFuture 自带的 get(timeout, TimeUnit) 或者 join() 都不会主动打断底层的 HTTP 请求,也就是说,即使你在调用侧设置了超时,底层连接可能还在继续等待。正确的做法是把超时控制下沉到 HttpRequest 的 timeout 参数里,让 JDK HttpClient 自己处理请求超时,而不是在 CompletableFuture 层面用 orTimeout 这种手段硬切。我见过不少同事在异步迁移时只给 CompletableFuture 加 orTimeout,结果请求超时了、线程还是被占着,最后连接池被拖垮。这个坑一定要避开。
3.4 响应处理方式的差异与选型
响应体处理是迁移中改动最集中的地方。Apache 的 HttpEntity 承载了“响应体元信息 + 字节流”的组合,你可以用 EntityUtils.toString 一次性读字符串,也可以用 entity.getContent() 拿到 InputStream 慢慢读。JDK17 HttpClient 则把“何时消费响应体、以什么形态消费”这件事完全交给了 BodySubscriber。
JDK17 里内置了几种常用的 BodyHandler:
| BodyHandler | 适用场景 | 注意事项 |
|---|---|---|
| BodyHandlers.ofString() | 小响应体、接口返回 JSON/文本 | 默认按 UTF-8 解码,整体读入内存 |
| BodyHandlers.ofInputStream() | 大响应体、需要流式读取 | 返回 InputStream,用完要关闭,否则连接不释放 |
| BodyHandlers.ofFile() | 下载文件、保存到磁盘 | 可以指定保存路径,内部直接写到文件 |
| BodyHandlers.discarding() | 只关心状态码、不需要响应体 | 大量 204/302 场景很好用 |
迁移的时候,我建议你对照一下原来的代码,看看每处响应体到底是怎么消费的。如果原来是 EntityUtils.toString,替换成 ofString 基本没感觉;如果原来是 getContent() 流式读取,替换成 ofInputStream 也要注意关闭流。特别提醒一点:JDK17 HttpClient 如果使用了 ofInputStream,调用方必须手动关闭返回的 InputStream,否则连接不会释放,时间长了连接池会被耗尽。
4. 迁移中的进阶配置与坑:连接池、重定向、证书与代理
4.1 连接池与连接复用:Apache 参数到 JDK17 的映射
Apache HttpAsyncClient 的连接池配置很成熟,常用参数有:
| Apache 参数 | 作用 |
|---|---|
| setMaxTotal | 连接池最大总连接数 |
| setDefaultMaxPerRoute | 每个路由(host:port)的最大连接数 |
| setConnectionRequestTimeout | 从连接池获取连接的超时时间 |
| setConnectTimeout | 建立 TCP 连接的超时时间 |
| setSocketTimeout | 读取数据的超时时间 |
JDK17 的 HttpClient 没有完全等价的一套连接池参数。它内部有自己的连接池,但暴露出来的只有两个间接控制手段:一个是 HttpClient.newBuilder() 里没有直接设置连接池大小的 API,另一个是通过 HTTP/2 的连接复用和 HTTP/1.1 的 keep-alive 机制来自动管理。
从实际使用经验来看,JDK17 HttpClient 的连接池在大多数场景下是“够用且自动”的,但如果你想精确控制并发连接数,目前只能通过“每个 host 一个 HttpClient 实例”或者“自己实现 Executor 来控制线程数”这种间接方式来做。这个差异迁移时一定要想清楚,如果你的业务对连接池有严格的容量规划,需要提前做压测验证。
这里也顺便说一下线程池的差异。Apache HttpAsyncClient 的 IOReactor 线程模型比较复杂,配置不当容易出现“线程数上去了、吞吐没上去”的情况。JDK17 HttpClient 的异步实现自己管理底层 selector 线程,如果你不显式提供 Executor,它会用内置的默认线程池,这种设计对大多数应用来说足够。只有当你需要精确控制线程池大小、给业务线程和 IO 线程做隔离时,才需要考虑传入自定义 Executor。
4.2 重定向与认证信息丢失:一个被热搜词点名的坑
开头列的热搜词里有一条非常扎眼:“httpclient 重定向 导致认证信息丢失”。这个坑在 Apache 迁移到 JDK17 HttpClient 的过程中极其常见,我单独拿出来讲。
先说重定向是什么。HttpClient 默认对 301、302、303、307、308 这几种状态码可能会自动跟随重定向。Apache HttpAsyncClient 默认的重定向策略是 HTTP 规范里的一个特例:301/302 在 POST 请求下会降级为 GET,而且会把 Authorization 头丢掉。JDK17 HttpClient 的做法类似,它有一个 Redirect.NORMAL 和 Redirect.ALWAYS 的区别:NORMAL 会跟随重定向,但有一些限制,比如 HTTPS 到 HTTP 的重定向不会自动跟随;ALWAYS 则不管什么情况都跟随。
“认证信息丢失”这个问题的本质是:重定向到新的地址时,HttpClient 出于安全考虑,不会把原始请求里的 Authorization 头带到重定向请求里。如果目标服务恰好需要认证,重定向之后就会 401。这在很多对接第三方开放平台的场景里非常常见:先跳转到登录页拿 token,再带着 token 访问资源,结果 HttpClient 自动重定向时把 token 丢了。
解决思路有三个方向:
- 关闭自动重定向,自己手动处理重定向。把 HttpClient 的 followRedirects 设为 NEVER,收到 3xx 之后自己读 Location 头,再重新构造带认证信息的请求。这种方式最可控,也是我推荐的做法。
- 如果重定向目标固定,提前在重定向请求里手动加上认证头。
- 使用 cookie 保存认证信息,让 HttpClient 在重定向时自动携带 Cookie。
在 JDK17 HttpClient 里,关闭自动重定向的配置方式是:
HttpClient client = HttpClient.newBuilder() .followRedirects(HttpClient.Redirect.NEVER) // 或 ALWAYS / NORMAL .build();如果需要手动处理重定向,拿到 3xx 响应后从 Location 头读取新地址,再发起第二次请求:
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 300 && response.statusCode() < 400) { String location = response.headers().firstValue("Location").orElseThrow(); HttpRequest redirectRequest = HttpRequest.newBuilder() .uri(URI.create(location)) .header("Authorization", token) .GET() .build(); response = client.send(redirectRequest, HttpResponse.BodyHandlers.ofString()); }这个坑之所以被很多人搜,是因为它很容易被忽略:本地测试时没有认证,重定向正常;一旦接入了需要认证的服务,重定向就莫名 401,排查半天才发现是 HttpClient 把认证头“吞”了。
4.3 SSL/TLS 证书:自定义信任逻辑的迁移
Java 应用访问自签 HTTPS 接口,或者对接内网测试环境时,经常需要自定义证书信任逻辑。Apache 里常见做法是构造一个信任所有证书的 SSLContext,通过 SSLConnectionSocketFactory 传给连接管理器。JDK17 HttpClient 里同样需要构造 SSLContext,但 API 位置不同,而且 JDK17 对 TLS 配置的默认值更严格。
一个常见的“忽略证书校验”的写法如下:
TrustManager[] trustAllCerts = new TrustManager[]{ new X509TrustManager() { public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } public void checkClientTrusted(X509Certificate[] certs, String authType) { } public void checkServerTrusted(X509Certificate[] certs, String authType) { } } }; SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); HttpClient client = HttpClient.newBuilder() .sslContext(sslContext) .build();注意:这段代码只是开发环境联调用,生产环境绝对不能这么干。如果对接的是内部 CA 签发的证书,正规做法是把 CA 证书导入到 JDK 的 cacerts 信任库,或者构造一个只信任自己 CA 的 SSLContext。
还有一点很容易踩坑:JDK17 里如果目标服务器只支持 TLSv1.2,而 HttpClient 默认协商到了 TLSv1.3,某些老服务器会握手失败。这时候可以在 JVM 启动参数里加上 -Djdk.tls.client.protocols=TLSv1.2 来强制指定客户端使用的 TLS 版本。这类问题通常表现为 SSLHandshakeException,你看日志的时候多留意一下 Actual protocol 这个字段。
4.4 代理配置与超时控制的细节
如果你们的服务部署在需要走代理访问外网的网络环境里,迁移时还要注意代理配置的变化。Apache 里通常用 HttpHost 指定代理,JDK17 HttpClient 则用 ProxySelector,而且支持对不同的 URI 走不同的代理策略。
JDK17 设置代理的典型写法:
HttpClient client = HttpClient.newBuilder() .proxy(ProxySelector.of(new InetSocketAddress("proxy.example.com", 8080))) .build();如果要更精细地控制,可以自己实现 ProxySelector,在 select 方法里根据 URI 判断该走代理还是直连。这个灵活性比 Apache 高不少,但也要注意:如果代理配置写错了,比如代理地址连不通,请求会报 ConnectException 或者 502,排查起来要有心理准备。
超时这块前面提到过,我再补充几个实战细节。JDK17 HttpClient 有两个层级的超时:
| 层级 | 设置方式 | 作用范围 |
|---|---|---|
| 连接超时 | HttpClient.newBuilder().connectTimeout(...) | 建立 TCP 连接 |
| 请求超时 | HttpRequest.newBuilder().timeout(...) | 从发送请求到接收完整响应的总时间 |
我自己的习惯是:连接超时设置 5 到 10 秒,请求超时按接口响应预期来设,一般 10 到 30 秒。如果接口本身有耗时操作,比如上传大文件,请求超时要放宽到 60 秒以上,否则容易误杀。
5. 实测对比与性能观察:同步、异步、HTTP/2
5.1 一个简单的压测场景
为了直观感受迁移效果,我选了一个压测场景:并发发起 200 个 GET 请求,访问本地一个简单接口,分别记录 Apache HttpAsyncClient 和 JDK17 HttpClient 的耗时、线程占用情况。压测环境是我自己的开发机,JDK17,8 核 CPU,接口逻辑很简单,返回一段 JSON。
压测结果如下(数值取多次平均值,仅供参考):
| 指标 | Apache HttpAsyncClient | JDK17 HttpClient(同步) |
|---|---|---|
| 总耗时(ms) | 约 3500 | 约 2800 |
| 线程占用 | 需要单独 IOReactor 线程池 | 默认工作线程,连接复用充分 |
| 代码行数 | 回调/工具类较多 | 同步写法更简洁 |
| 依赖 jar | httpclient+httpcore+... | 无 |
这个结果并不代表谁绝对更快,它更多反映了在“连接复用 + HTTP/2 多路复用”的场景下,JDK17 HttpClient 的底层实现已经足够现代,开销比老旧的 Apache 异步实现更可控。
5.2 同步写法 vs 异步写法的选择建议
迁移时最纠结的一个问题往往是:原来的 Apache 代码是异步的,迁移到 JDK17 是不是也一定要用 sendAsync?我的建议是:先看你的业务到底需不需要异步。
如果是微服务之间的内部调用,业务线程池本身够大、并发量也不高,用同步 send 反而更好。同步写法简单直接,try-catch 处理异常也直观,不用和 CompletableFuture 的异常传播规则较劲。JDK17 HttpClient 的同步 send 底层并没有“阻塞一个线程等网络”的朴素实现,它内部依然用到了异步机制,只是对外暴露成阻塞的 API。所以在大多数场景下,同步写法的性能并不差。
如果真的是 IO 密集型、高并发的场景,建议用 sendAsync + CompletableFuture,并配合虚拟线程(JDK21)或自定义线程池来消费这些 Future。不过要注意,异步写法的代价是代码可读性下降、异常处理变复杂,一定要有完善的监控和日志支撑。
5.3 HTTP/2 带来的变化与注意点
JDK17 HttpClient 默认支持 HTTP/2,但并不是所有请求都一定走 HTTP/2。它的协商机制是:如果连接是基于 HTTPS 的,会通过 ALPN 在 TLS 握手阶段和服务端协商协议;如果服务端也支持 HTTP/2,就使用 HTTP/2,否则降级到 HTTP/1.1。如果是 HTTP 明文请求,默认不会升级到 HTTP/2。
HTTP/2 对迁移最大的影响是连接管理模型变了:多个并发的请求可以复用同一个 TCP 连接,通过多路复用来传输,这样一来,Apache 时代那种“每个路由 N 个连接”的调优思路就不再适用。对于并发量上万的场景,HTTP/2 可以显著减少连接数,但也要求对端服务正确支持 HTTP/2,否则还是要回退到 HTTP/1.1 的 keep-alive 连接复用。
有一个小坑是:如果服务端不支持 HTTP/2,而你又设置了 HTTP/2 优先,JDK HttpClient 会自动降级,不会报错。但如果你在日志里发现大量 HTTP/2 协商失败的告警,可以考虑用系统属性 -Djdk.httpclient.allowRestrictedHeaders=true 这些参数来微调,不过非必要不建议动。
6. 迁移路线图与踩坑实录
6.1 推荐的分阶段迁移策略
一次性的“大爆炸式”迁移风险很高。我的建议是把迁移拆成几个阶段:
阶段一:摸底。梳理依赖、统计使用点、评估改造量,列出所有用到了 Apache 类型的类。
阶段二:搭兼容层。如果项目里有一个统一的 HTTP 工具类,先把工具类内部实现换成 JDK17 HttpClient,对外保持方法签名不变,跑通全量回归测试。
阶段三:逐个替换。对没有统一入口、散落在业务代码里的调用点,按模块逐个替换,每次改完一个模块就做一轮针对性测试。
阶段四:清理依赖。确认所有代码都不再使用 Apache 类型后,在 pom.xml 里移除 httpclient、httpcore、httpmime、commons-logging 等依赖,再次跑全量测试。
阶段五:压测与调优。替换完成后,重新压测,重点关注连接池行为、超时表现、高并发下的线程占用。
6.2 我在迁移中遇到的三个典型问题
第一个是 NoClassDefFoundError。迁移到一半的时候,有些老代码还在用 org.apache.http.HttpResponse,我一并改了之后漏改了一处 import,结果运行时直接 NoClassDefFoundError。排查下来发现是某个工具类还引着 httpcore 的类,而这个 jar 已经被我从 pom 里删掉了。教训就是:删依赖之前一定要全局搜一遍 import,别凭记忆。
第二个是 407 Proxy Authentication Required。因为我们有些内部服务要通过认证代理出去,Apache 里可以通过 credentials provider 自动处理代理认证,而 JDK17 HttpClient 在这块支持的 API 更底层,需要自己实现 Authenticator 才能处理。迁移时没有提前考虑到这个场景,导致上线后一批外呼请求报 407。
第三个是 HttpClient 实例滥用。我在早期迁移时犯过一个错:在每次请求时都 new 一个 HttpClient。JDK17 的 HttpClient 设计上是重量级对象,虽然创建成本不高,但每次都新建就意味着连接池永远没法积累复用的连接,性能下降非常明显。正确做法是把 HttpClient 声明为单例,整个应用共享一个实例。
6.3 迁移检查清单(可直接抄作业)
我在项目里整理了一份迁移检查清单,直接分享给大家:
- [ ] 确认 JDK 版本已切换到 17 或以上,java -version 输出正确
- [ ] 移除或预留旧依赖,避免 NoClassDefFoundError
- [ ] 全局搜索 import org.apache.http、import org.apache.hc.client5,确认无漏网之鱼
- [ ] 检查所有 new HttpClient 的地方,确保 HttpClient 是单例或者长生命周期对象
- [ ] 对照每一个请求的超时需求,设置 connectTimeout 和请求级 timeout
- [ ] 检查重定向逻辑,尤其是带认证头的请求,确认认证信息不会丢失
- [ ] 检查 SSLContext 配置,确认自签证书或内部 CA 场景已正确处理
- [ ] 如果有代理需求,确认 Authenticator 已实现,代理认证能正常工作
- [ ] 异步场景确认超时控制放在 HttpRequest 层级,而不是只放在 CompletableFuture 上
- [ ] 压测验证连接池、线程占用、响应时间均符合预期
7. 最后的体会
整个迁移做下来,我最深的体会是:Apache HttpAsyncClient 到 JDK17 HttpClient 的迁移,代码层面的改动其实只占一部分,更多的工作量其实花在“思维模型的切换”上。Apache 的世界里,你在管理连接池、配置 socket 超时、处理重定向策略,你面对的是一堆可以手工调教的参数。而 JDK17 HttpClient 的世界里,官方帮你把连接管理、协议协商、线程调度都封装好了,给你的 API 更简洁,但也意味着你需要在更高的抽象层级上思考问题。
如果你正在规划这个迁移,我建议你先拿一个低频接口跑通最小闭环,感受一下 JDK17 HttpClient 的 API 风格,再逐步扩大替换范围。不要一上来就想着把所有代码一天改完,稳扎稳打反而更快。碰到重定向丢认证头、代理认证 407、连接池参数不对应这些坑的时候,也别慌,先把请求链路的每一步日志打出来,看清状态码和响应头,问题基本就能定位了。
JDK17 HttpClient 不是什么神秘的黑科技,它就是一个设计得比较现代、官方维护的 HTTP 客户端。用顺了之后你会发现,代码少了很多,依赖干净了很多,排查问题的时候也不用再翻第三方库的源码了。希望这篇实操总结能帮你少踩几个坑。