Java HttpClient 高并发调用实战踩坑,面向后端开发者,深度源码+线上故障复盘
Java 11 HttpClient 线上血泪复盘:高并发下连接泄露、超时卡死、内存暴涨完整排错与根治方案
摘要:项目升级JDK11之后,把旧的Apache HttpClient替换成JDK内置HttpClient,本地单元测试全部跑通,压测环境跑一段时间就出现线程堆积、连接泄露,服务内存持续上涨,接口大量卡死,没有直接报错日志。本文javascript: void 0结合真实线上故障,还原复现过程、错误代码、抓包排查、底层原理、多套修复方案以及生产级最佳实践。

前言
前两年我们团队逐步把项目从JDK8迁移到JDK17,为了减少第三方依赖,直接抛弃了Apache HttpClient 4.x,全面改用JDK内置的java.net.http.HttpClient。
本地开发环境,小并发场景一切完美,代码简洁,不用引入额外pom依赖,当时觉得这是一个非常完美的改造。结果上线压测直接给我们上了一课。
压测QPS跑到800左右,运行半小时后,服务TPS开始断崖式下跌,大量外部http调用接口超时,线程池线程全部被占满,GC日志频繁出现Full GC,但是业务异常日志很少,大部分请求没有抛出异常,就是hang住。
一开始怀疑是下游服务问题,切换回Apache HttpClient之后,同样压测条件,服务完全稳定,基本锁定问题出在JDK HttpClient的使用方式上。
网上很多教程只讲简单的get、post示例,很少讲高并发生产环境的坑,很多同学直接复制demo代码上生产,很容易踩一模一样的坑。
错误业务代码(线上出事版本)
很多网上示例就是这么写的,每次请求新建一个HttpClient实例。
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class BadHttpDemo {
// 每次调用接口都新建HttpClient
public static String callRemoteApi(String url) throws Exception{
// 重点错误:每次请求new HttpClient
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return resp.body();
}
}
这段代码本地跑完全没问题,单线程、少量并发看不出任何问题。
问题根源:HttpClient是重量级对象,内部封装了连接池、线程池、Selector管理器。
每new一次,就创建一套全新的线程池、连接池。高并发场景下,短时间创建大量HttpClient实例,旧实例无法及时回收,底层TCP连接不会主动关闭,发生连接泄露,同时创建大量内部线程,线程数量疯狂膨胀,内存持续上涨。
很多开发会混淆HttpClient和HttpRequest,以为HttpRequest每次新建,HttpClient也需要每次新建。HttpRequest是轻量,每次请求新建没问题;HttpClient必须全局单例复用。
现象复现
- netstat查看服务器TCP连接,大量CLOSE_WAIT连接堆积;
- jstack线程快照,大量
HttpClient-SelectorManager线程; - jmap堆快照,大量HttpClient对象无法释放;
- 接口没有抛出异常,请求直接阻塞,connectTimeout设置好像失效。
补充:connectTimeout只是建立TCP连接超时,send()同步阻塞模式下,如果连接池耗尽,会无限等待获取连接,不会触发超时,这是最容易被忽略的一点。
第一版修复:全局单例HttpClient
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class GoodHttpDemo {
// 全局单例,整个应用复用同一个HttpClient实例
private static final HttpClient HTTP_CLIENT;
static {
HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
// 设置连接池空闲连接超时,自动回收闲置TCP连接
.idleTimeout(Duration.ofSeconds(20))
.build();
}
public static String callRemoteApi(String url) throws Exception{
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse<String> resp = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
return resp.body();
}
}
改成单例之后,压测有改善,但新问题又来了:同步send高并发场景,HttpClient内部线程池队列打满,请求依然会hang。
HttpClient默认内部使用ForkJoinPool,ForkJoinPool的线程数量默认是CPU核心数。如果外部HTTP接口响应慢,同步send会占用工作线程,线程很快耗尽,后续请求排队阻塞。
进阶方案:异步+自定义线程池,生产可用完整版本
生产环境调用第三方接口,强烈推荐使用异步sendAsync,并且自定义执行器,不要直接使用默认ForkJoinPool。
完整可直接复制的生产工具类:
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.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class JdkHttpUtil {
// 自定义线程池,不要使用默认ForkJoinPool
private static final ExecutorService HTTP_EXECUTOR;
private static final HttpClient HTTP_CLIENT;
static {
HTTP_EXECUTOR = Executors.newFixedThreadPool(40);
HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.idleTimeout(Duration.ofSeconds(15))
.executor(HTTP_EXECUTOR)
.build();
}
/**
* get同步调用
*/
public static HttpResponse<String> get(String url) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(6))
.GET()
.build();
return HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
}
/**
* 异步调用,适合高并发业务
*/
public static java.util.concurrent.CompletableFuture<HttpResponse<String>> getAsync(String url){
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(6))
.GET()
.build();
return HTTP_CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString());
}
/**
* 应用关闭的时候手动释放资源
*/
public static void shutdown(){
HTTP_EXECUTOR.shutdown();
try {
HTTP_EXECUTOR.awaitTermination(10, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
额外踩坑清单,生产务必注意
- BodyHandlers.ofString() 默认使用UTF‑8,部分第三方接口返回GBK编码,会出现中文乱码,需要自定义BodyHandler处理编码;
- JDK11早期版本HttpClient存在不少bug,建议JDK升级到17.0.8以上LTS版本;
- HttpClient没有最大连接数的公开配置,底层依赖系统内核参数调优;
- 同步send模式下,连接池拿不到可用连接时,不会触发timeout,会阻塞等待,高并发尽量用异步;
- idleTimeout一定要配置,否则闲置TCP连接不会主动回收,大量CLOSE_WAIT堆积;
- 如果业务需要代理,不要在业务方法内每次构建代理,同样放在HttpClient构建阶段全局配置。
线上问题排查手段
- jstack 打印线程,观察HttpClient相关线程数量;
- netstat -anp | grep java 统计TCP连接状态;
- 开启JDK HttpClient日志,logging.level.java.net.http=DEBUG;
- 压测环境模拟下游慢接口,复现阻塞问题。
总结
JDK内置HttpClient省去第三方依赖,但是很多特性不像Apache HttpClient文档完善,网上大量示例只是玩具级demo,直接复制上生产很容易踩坑。
核心记住两点:
- HttpClient实例全局单例,绝对不要每次请求new;
- 高并发业务避免直接用send同步阻塞,优先sendAsync异步,自定义线程池,配置idleTimeout。
凡尘版权 © 2026
友情链接:凡尘博客、凡尘博客文章|凡尘博客文摘凡尘影院、凡尘乡音|凡尘街坊、凡尘博客|雨落凡尘博客|羽落凡尘博客凡尘博客|雨落凡尘博客|羽落凡尘博客

项目升级JDK11之后,把旧的Apache HttpClient替换成JDK内置HttpClient,本地单元测试全部跑通,压测环境跑一段时间就出现线程堆积、连接泄露,服务内存持续上涨,接口大量卡死,没有直接报错日志。本文结合真实线上故障,还原复现过程、错误代码、抓包排查、底层原理、多套修复方案以及生产级最佳实践。
浙公网安备 33010602011771号