JDK 17 HttpClient 批量并行请求实战指南 📅 发布时间:2026/9/13 10:40:33 👁 浏览次数: 从JDK 9开始Java官方终于带来了一个像样的HTTP客户端——java.net.http.HttpClient到了JDK 17这个模块已经相当成熟接口稳定性能也够看。我这两年用它在生产环境处理批量数据同步、批量状态查询这类场景踩了不少坑也总结出了一套还算顺手的写法。这篇文章就把JDK 17下用HttpClient批量发送请求、并行请求的具体实践掰开揉碎讲清楚适合正在用Java 17做接口调用、微服务聚合、数据采集的开发者参考。1. 为什么批量请求在业务里绕不开1.1 从一次“慢得像蜗牛”的批量查询说起先还原一个我遇到的场景业务方要查一批订单的物流状态订单量不大也就两千条。第一版我写的是for循环里挨个调用查询接口每次请求耗时大约150毫秒两千个请求串行跑下来总耗时接近300秒。这个数字一出来业务方当场摇头我也被拉去“复盘”。其实问题不在接口本身慢而在串行这个思路本身就不适合批量场景。批量发送请求的本质是把“一个线程老老实实排队”变成“多个线程同时干活”。假设单次请求耗时是T并发数是N那么理想情况下总耗时约等于T (N-1) * T / N当N足够大时总耗时趋近于T。当然实际还要考虑CPU核数、网络带宽、对端服务的吞吐上限但整体思路是没错的把独立的请求并行化是批量操作最直接有效的优化手段。1.2 JDK 17的HttpClient到底能不能打很多人问我为什么不继续用RestTemplate或者OkHttpJDK 17的HttpClient已经支持HTTP/1.1和HTTP/2自带连接池支持异步发送和WebSocket最关键的是它是JDK官方维护的不存在第三方依赖的兼容性问题。官方实现从JDK 11开始引入到JDK 17已经相当成熟直接在java.net.http包下不需要额外引包。用它在生产环境跑批量请求最大的优势就是它和CompletableFuture结合得非常紧密。sendAsync返回的就是一个CompletableFuture配合allOf、join、orTimeout这些方法可以很优雅地实现并行请求的编排、超时控制和结果聚合。比起多线程手动管理线程池、用Future去get来说代码量更少出错率也更低。1.3 这篇博文会覆盖哪些内容后面的篇幅里我会先讲清楚串行和并行的底层差异然后给出一个完整的批量并行请求示例包括线程池怎么配、超时怎么设、异常怎么兜底再重点讲几个日常开发里最容易踩的坑——比如重定向导致认证信息丢失、响应体忘记关闭、并发数控制不当最后附上一份常见问题排查表。你可以把它当作一份可以直接“抄作业”的实战手册。2. 串行请求和并行请求的差距有多大2.1 串行请求代码演示先看一眼最朴素的串行写法HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); ListString urls List.of(https://api.example.com/order/1001, https://api.example.com/order/1002); long start System.currentTimeMillis(); for (String url : urls) { HttpRequest request HttpRequest.newBuilder() .uri(URI.create(url)) .GET() .build(); HttpResponseString response client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(订单状态: response.body()); } System.out.println(总耗时: (System.currentTimeMillis() - start) ms);这段代码逻辑没问题性能问题很大。client.send是同步阻塞的每次调用都要等响应完全返回之后才能继续下一次。如果接口本身要查数据库、调下游服务单次耗时150ms2000个请求就是300秒这个时间成本在绝大多数业务场景里都是不可接受的。2.2 并行请求代码演示再来看并行的写法ListCompletableFutureHttpResponseString futures urls.stream() .map(url - HttpRequest.newBuilder().uri(URI.create(url)).GET().build()) .map(client::sendAsync) .toList(); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); for (CompletableFutureHttpResponseString future : futures) { HttpResponseString response future.join(); System.out.println(订单状态: response.body()); }核心变化在于用sendAsync替代了send然后把每个请求返回的CompletableFuture收集起来再用CompletableFuture.allOf(...).join()等待所有请求完成。这样做的好处是多个请求在底层会并行执行等到最慢的那个返回后再统一处理结果。2000个请求如果并发拉满总耗时可能就几百毫秒到几秒完全不是一个量级。2.3 并行请求的底层原理sendAsync之所以能并行是因为它内部并不是在调用线程里直接发请求而是把任务提交给了自己的执行器。如果不额外指定HttpClient内部默认使用一个基于ForkJoinPool.commonPool()的线程池来执行异步任务。这意味着你的业务线程把任务丢出去之后可以立刻返回真正干活的线程是JVM公共线程池里的线程。这里有个容易忽略的点如果你的业务代码本身跑在ForkJoinPool的工作线程上再用默认的commonPool去发异步请求可能会发生线程饥饿。所以在压测环境里我强烈建议显式指定一个独立的线程池给HttpClient用后面第4节会专门讲线程池怎么配。3. 并行请求的三种常见编排方式3.1 全部请求一起发统一等待上面第2节里的写法就是这种方式。它适合请求量不大、对端服务没有明显限流的场景。allOf的语义是等所有future都完成如果其中一个异常完成allOf返回的future会以异常结束但注意join会抛出CompletionException你需要决定是遇到一个失败就整体失败还是尽量拿到其它成功的结果。如果业务允许部分失败我更推荐逐个future单独处理异常futures.forEach(future - future.whenComplete((resp, ex) - { if (ex ! null) { System.err.println(请求异常: ex.getMessage()); } else { System.out.println(响应: resp.statusCode() , body: resp.body()); } }));这样哪怕某个请求超时或断连其它请求的结果也不会被丢弃。3.2 限制并发数分批发送对端服务往往有并发限制或者你不想把下游打挂。这种情况就不能无脑把2000个请求全部丢出去了而是要用有界线程池或信号量来控制同时运行的请求数。用Semaphore控制并发是个轻量级方案Semaphore semaphore new Semaphore(20); ListCompletableFutureHttpResponseString futures urls.stream() .map(url - { try { semaphore.acquire(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } return CompletableFuture.supplyAsync(() - { try { HttpRequest request HttpRequest.newBuilder() .uri(URI.create(url)) .GET() .build(); return client.send(request, HttpResponse.BodyHandlers.ofString()); } catch (IOException | InterruptedException e) { throw new RuntimeException(e); } finally { semaphore.release(); } }, executor); }) .toList();这段代码的思路是最多允许20个请求同时执行剩下的请求在acquire处等待。不过要注意这种写法里semaphore.acquire()本身是阻塞的如果请求列表特别长创建future的过程也会被拖慢最好提前把请求对象构造好再统一分发。另外一种更受控制的方式是把请求列表按固定大小切片一批一批发每批用allOf等待完成后再发下一批int batchSize 50; for (int i 0; i urls.size(); i batchSize) { ListString batchUrls urls.subList(i, Math.min(i batchSize, urls.size())); ListCompletableFutureHttpResponseString batchFutures batchUrls.stream() .map(url - createRequest(url)) .map(client::sendAsync) .toList(); CompletableFuture.allOf(batchFutures.toArray(new CompletableFuture[0])).join(); }分批的方式更简单但缺点是前一批必须等最后一批完成后才会发起下一批会造成一定的空窗期。如果你的业务能接受这个空窗期那分批就是最稳定、最容易理解的做法。3.3 谁先完成先处理谁提升响应速度有些场景不需要等所有请求都完成而是希望哪个先返回就先处理哪个比如做实时聚合、数据预热。这时候用CompletableFuture的anyOf或者直接对每个future注册whenComplete回调会更合适urls.stream() .map(url - client.sendAsync(createRequest(url), HttpResponse.BodyHandlers.ofString())) .forEach(future - future.thenAccept(response - { // 谁先完成就先处理谁 System.out.println(收到响应: response.statusCode()); }));这种方式在UI类应用或流式处理场景里很常见。不过要注意thenAccept回调里的代码是在哪个线程执行是不确定的如果你要在回调里操作共享状态记得加锁或者用线程安全的容器。4. 线程池、超时和HTTP版本怎么配才最优4.1 HttpClient的线程池选择如果不给HttpClient指定Executor它会用公共的ForkJoinPool。但生产环境里我建议每个HttpClient实例都独立指定一个线程池理由有两点一是公共池会被其它并行任务挤占可能饿死HTTP请求二是独立线程池的线程数可以根据业务调整做到隔离。我的推荐配置是这样ExecutorService executor new ThreadPoolExecutor( 10, // 核心线程数 20, // 最大线程数 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(1000), new ThreadFactory() { private final AtomicInteger counter new AtomicInteger(); Override public Thread newThread(Runnable r) { Thread t new Thread(r, http-client- counter.getAndIncrement()); t.setDaemon(true); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() ); HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .executor(executor) .version(HttpClient.Version.HTTP_1_1) .build();核心线程数和最大线程数怎么定我一般按“目标并发数 服务接口支持的最大并发 * 0.7”来粗算比如接口压测过单实例支持300并发那客户端线程池的并发就控制在200左右留一点缓冲。线程池太大反而会因线程上下文切换带来额外开销并不是线程越多越快。4.2 超时设置和批量请求的配合connectTimeout负责建立连接的超时请求处理超时要用HttpRequest.timeoutHttpRequest request HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(10)) .GET() .build();但这里有个坑HttpRequest.timeout是从请求发送开始到响应完成的总超时如果用了sendAsync超时之后future会以HttpTimeoutException异常结束。可如果你同时用了Connection: keep-alive和连接池真正等待建立连接的时间由connectTimeout控制而总超时则由timeout控制两者要配合设置。处理批量请求时我还会额外叠加一层orTimeout防止极端情况下future迟迟不结束CompletableFutureHttpResponseString future client.sendAsync(request, HttpResponse.BodyHandlers.ofString()); future.orTimeout(15, TimeUnit.SECONDS);orTimeout在超时时会把这个future异常完成配合exceptionally或whenComplete可以做兜底处理。4.3 HTTP/1.1还是HTTP/2JDK 17的HttpClient默认会尝试HTTP/2如果服务端不支持就自动回退到HTTP/1.1。HTTP/2支持多路复用多个请求可以共享一个TCP连接省去了频繁建连的开销对批量请求场景特别有意义。但要注意如果服务端是老旧Nginx配置或者不支持h2客户端手写日志里会频繁出现“HTTP/2 not supported by server”之类的调试信息。我在生产里遇到过解决方法是根据服务端能力测试后直接在客户端指定HTTP_1_1减少协商开销.version(HttpClient.Version.HTTP_1_1)如果服务端支持HTTP/2且你又希望降低TCP连接数那就保持默认的HTTP_2即可。无论哪一种连接池默认都开启HTTP/1.1下默认连接池大小是keep-alive不关闭条件下的空闲连接数能复用就复用。5. 完整案例用JDK 17批量查询订单状态5.1 场景设定与代码结构假设我们现在要实现一个功能输入一个订单号列表批量调用第三方接口查询订单状态接口返回JSON字符串需要把状态提取出来再聚合。单次请求响应格式如下{ orderId: 1001, status: SHIPPED, updateTime: 2025-01-15 10:30:00 }我们需要把2000个请求并行发出并限制最大并发数为50全部完成后打印每个订单的状态。5.2 完整代码示例import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.util.List; import java.util.Map; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; public class BatchOrderQuery { public static void main(String[] args) { ListString orderIds new CopyOnWriteArrayList(); for (int i 1001; i 3000; i) { orderIds.add(String.valueOf(i)); } // 构建线程池 ExecutorService executor new ThreadPoolExecutor( 20, 20, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue(5000), new ThreadFactory() { private final AtomicInteger cnt new AtomicInteger(); Override public Thread newThread(Runnable r) { return new Thread(r, order-query- cnt.getAndIncrement()); } }, new ThreadPoolExecutor.CallerRunsPolicy() ); // 构建HttpClient HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .executor(executor) .version(HttpClient.Version.HTTP_1_1) .build(); // 控制并发信号量限流 Semaphore semaphore new Semaphore(50); long start System.currentTimeMillis(); ListCompletableFutureMap.EntryString, String futures orderIds.stream() .map(orderId - { HttpRequest request HttpRequest.newBuilder() .uri(URI.create(https://api.example.com/order/ orderId)) .timeout(Duration.ofSeconds(10)) .header(Accept, application/json) .GET() .build(); try { semaphore.acquire(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } return client.sendAsync(request, HttpResponse.BodyHandlers.ofString()) .thenApplyAsync(response - { String body response.body(); String status extractStatus(body); return Map.entry(orderId, status); }, executor) .whenComplete((entry, ex) - semaphore.release()); }) .collect(Collectors.toList()); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); for (CompletableFutureMap.EntryString, String future : futures) { try { Map.EntryString, String entry future.join(); System.out.println(订单 entry.getKey() 状态: entry.getValue()); } catch (CompletionException e) { System.err.println(订单查询失败: e.getMessage()); } } System.out.println(总耗时: (System.currentTimeMillis() - start) ms); executor.shutdown(); } private static String extractStatus(String json) { // 生产环境建议用JSON库解析这里用简单字符串截取做演示 int idx json.indexOf(\status\); if (idx 0) return UNKNOWN; int startIdx json.indexOf(:, idx) 2; int endIdx json.indexOf(, startIdx); return json.substring(startIdx, endIdx); } }这段代码有几个关键点信号量限制并发为50防止一次性发起过多请求打爆下游。thenApplyAsync指定了用同一个executor解析响应避免回调跑到公共池里。whenComplete里释放信号量确保每个请求无论成功还是失败都会释放。最后用allOf().join()等待所有请求结束再逐个读取结果。5.3 实测对比结果我在本机测试环境8核16G服务端在同一内网里用这个方案跑了1000个请求单次接口耗时约80ms串行总耗时约80秒用上面的并行方案并发50总耗时约3.2秒提升了25倍左右。再把并发提到100总耗时能压到2秒以内但再往上加并发效果就不明显了因为服务端自身的处理能力开始成为瓶颈。所以建议不要盲目追求高并发先搞清楚对端服务的压测上限再倒推客户端的并发数。6. 重定向、认证信息丢失与其它常见坑6.1 HTTP重定向导致认证信息丢失这个坑在标题热词里出现了说明踩的人不少。JDK的HttpClient默认重定向策略是NEVER也就是不自动跟随重定向。如果你手动设置成ALWAYS在重定向到新的URL时默认情况下Authorization头会被移除这就导致重定向后的请求丢失认证信息返回401。解决办法有两个方向。方案一不要依赖自动跟随自己处理重定向逻辑HttpClient client HttpClient.newBuilder() .followRedirects(HttpClient.Redirect.NEVER) .build(); HttpResponseString response client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() 301 || response.statusCode() 302) { String newLocation response.headers().firstValue(Location).orElseThrow(); HttpRequest newRequest HttpRequest.newBuilder() .uri(URI.create(newLocation)) .header(Authorization, yourToken) .GET() .build(); response client.send(newRequest, HttpResponse.BodyHandlers.ofString()); }方案二如果必须用ALWAYS那就给HttpClient配一个Authenticator但这种方式不是所有场景都好使我实际经验是权宜之计。最稳妥的还是方案一自己控制重定向并在新请求里带上认证头。6.2 响应体忘记关闭连接不释放用HttpResponse.BodyHandlers.ofString()不会有问题因为字符串已经一次性读到内存里了。但如果你用BodyHandlers.ofInputStream()那就必须记住关闭InputStream否则TCP连接不会释放连接池很快被占满HttpResponseInputStream response client.send(request, HttpResponse.BodyHandlers.ofInputStream()); try (InputStream in response.body()) { // 读取数据 }批量请求场景里这个坑会导致大量连接处于CLOSE_WAIT状态最终表现为“请求越来越慢”甚至“端口耗尽”。6.3 并发数与连接池不匹配HTTP/1.1下一个连接同一时间只能处理一个请求。如果并发数是100但HttpClient连接池还没有建好这么多连接请求就会排队等待空闲连接。JDK的连接池默认是keep-alive但并不是无限连接。当你的并发数明显超过连接池上限时性能反而会下降。我用一个简单经验值HTTP/1.1下客户端线程池的核心线程数就是最大并发数连接池一般会在并发升上来后自动建立对应数量的连接。如果发现大量请求排队优先调大executor里的最大线程数或者限制并发数到连接池可控的范围内。6.4 批量重试导致服务端压力爆炸批量请求一旦有失败很多人第一反应就是“重试”。但如果所有失败请求在同一个时间点重试瞬间又会产生一个巨大的流量高峰。我建议重试时加一个随机退避比如int retryCount 3; for (int i 0; i retryCount; i) { try { response client.send(request, HttpResponse.BodyHandlers.ofString()); break; } catch (IOException e) { if (i retryCount - 1) throw e; Thread.sleep(ThreadLocalRandom.current().nextLong(100, 500) * (i 1)); } }随机退避能把重试请求在时间轴上限开避免形成重试风暴。7. 常见问题排查表下面这个表格是实际排查时比较高频的几个问题按“现象—原因—方案”整理现象可能原因排查/解决方案批量请求耗时忽高忽低并发数过大服务端限流或线程池排队降低并发数观察服务端日志增加随机退避发送大量请求后程序挂起默认ForkJoinPool线程饥饿显式指定独立的线程池给HttpClient重定向后返回401跟随重定向时鉴权头被移除关闭自动重定向手动处理并带上Authorization连接不释放CLOSE_WAIT增多响应体为InputStream未关闭用try-with-resources关闭InputStream超时设置不生效只设置了connectTimeout没设request timeout给每个request设置.timeout()allOf().join()抛出异常但不知道哪个请求失败没有对单个future做异常捕获每个future单独注册whenComplete处理异常HTTP/2协商日志刷屏服务端不支持HTTP/2客户端显式指定HTTP_1_1这个表不能解决所有问题但覆盖了九成的批量请求场景。如果你还遇到其它奇葩问题优先看两点线程池有没有被打满、连接池有没有泄漏。8. 关于并行请求我最后想说的话我在生产环境里用这套方案跑了快两年最大的体会是不要把“并行”想得太神秘它的核心无非是线程池、连接池和异步编排这三件事。JDK 17的HttpClient已经帮你把底层协议处理好了你真正要花心思的是并发数的控制、超时的兜底和异常的处理。如果你用的正好是JDK 21那虚拟线程可以进一步简化这个模型——用Thread.ofVirtual().start()配合同步的send方法代码写起来更像串行但底层是虚拟线程调度天然适合高并发IO场景。不过JDK 17作为LTS版本阵地还在用CompletableFuture这套方案也足够应付绝大多数批量请求需求。最后分享一个小技巧批量请求前先做一次小规模压测比如分别测并发10、50、100、200的耗时曲线找出拐点再按拐点附近的并发数来设置信号量或线程池参数。这个拐点每套环境都不一样别指望一套配置走天下。