





















在很多项目的 HTTP 工具类里,你可能会看到这样的写法:
public static String doPost(Map<String, String> map, String url) throws HttpTransportException {
log_.info("连接超时时长为:" + timeout);
HttpPost method = new HttpPost();
method.setHeader("Content-Encoding", sendEncode);
method.setHeader("User-Agent", "Rich Powered/1.0");
method.setHeader("Content-Type", "application/x-www-form-urlencoded");
method.setConfig(requestConfig);
CloseableHttpClient httpClient = HttpClients.custom()
.setRetryHandler(new DefaultHttpRequestRetryHandler(retryConnTimes, false))
.build();
method.setURI(getUri(url));
// ... 执行请求并返回结果
}
这段代码看起来“能用”——每次调用都创建了一个 CloseableHttpClient,执行完请求后似乎也没关闭它。但正是这种“能用”的写法,埋下了严重的性能和资源隐患。
HttpClients.custom().build() 内部会创建一个 PoolingHttpClientConnectionManager,默认配置为:
问题:每次请求都新建一个连接池,意味着 TCP 连接无法复用。频繁的 connect() 和 disconnect() 不仅增加了延迟,还大量消耗服务器端的 TIME_WAIT 资源。在高并发场景下,系统很快会耗尽端口或达到文件描述符上限。
CloseableHttpClient 实现了 Closeable 接口,持有连接池、后台线程(如空闲连接清理线程)等资源。
如果不在使用完毕后调用 close(),这些资源永远不会被释放。而上述代码中,httpClient 是局部变量,方法结束后便失去引用,但连接池仍在后台运行,造成内存泄漏和线程泄漏。
每次调用都创建新的 HttpClient 实例,内部会创建数十个对象(连接管理器、拦截器链、处理器等)。在 QPS 较高的系统中,这会导致 Young GC 频繁触发,甚至晋升到 Old Gen 引发 Full GC。
每个 HttpClient 实例使用独立的连接池,无法全局配置最大连接数、超时时间、空闲回收策略等。当需要调优时,只能逐个修改所有调用处的代码,极易遗漏。
许多开发者将 HttpClient 视为“一次性的请求对象”,但实际上它是重量级的、线程安全的资源。官方文档明确指出:
HttpClient is thread-safe. It is recommended that the same instance of this class be reused for multiple request executions.
正确的做法是:整个应用生命周期内只创建一次 HttpClient,所有请求共用同一个实例。
public class HttpUtil {
private static final CloseableHttpClient HTTP_CLIENT;
static {
// 从配置文件读取超时等参数
int connectTimeout = 5000;
int socketTimeout = 5000;
int maxTotal = 200;
int maxPerRoute = 50;
int retryCount = 3;
RequestConfig config = RequestConfig.custom()
.setConnectTimeout(connectTimeout)
.setSocketTimeout(socketTimeout)
.setConnectionRequestTimeout(connectTimeout)
.build();
HTTP_CLIENT = HttpClients.custom()
.setDefaultRequestConfig(config)
.setRetryHandler(new DefaultHttpRequestRetryHandler(retryCount, false))
.setMaxConnTotal(maxTotal)
.setMaxConnPerRoute(maxPerRoute)
.setConnectionTimeToLive(60, TimeUnit.SECONDS)
.evictIdleConnections(30, TimeUnit.SECONDS)
.build();
// 注册 JVM 关闭钩子,优雅释放连接池
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
HTTP_CLIENT.close();
} catch (IOException e) {
log.error("关闭 HttpClient 失败", e);
}
}));
}
private HttpUtil() {}
public static String doPost(Map<String, String> map, String url) throws HttpTransportException {
HttpPost method = new HttpPost();
method.setHeader("Content-Encoding", sendEncode);
method.setHeader("User-Agent", "Rich Powered/1.0");
method.setHeader("Content-Type", "application/x-www-form-urlencoded");
method.setConfig(requestConfig);
method.setURI(getUri(url));
// 设置请求体
List<NameValuePair> params = new ArrayList<>();
for (Map.Entry<String, String> entry : map.entrySet()) {
params.add(new BasicNameValuePair(entry.getKey(), entry.getValue()));
}
try {
method.setEntity(new UrlEncodedFormEntity(params, "UTF-8"));
} catch (UnsupportedEncodingException e) {
throw new HttpTransportException("编码不支持", e);
}
try (CloseableHttpResponse response = HTTP_CLIENT.execute(method)) {
int statusCode = response.getStatusLine().getStatusCode();
if (statusCode >= 200 && statusCode < 300) {
String body = EntityUtils.toString(response.getEntity(), "UTF-8");
return body;
} else {
throw new HttpTransportException("HTTP 错误: " + statusCode + ", body: " + body);
}
} catch (IOException e) {
throw new HttpTransportException("请求失败", e);
}
}
}
| 改进项 | 说明 |
|---|---|
| 单例化 | HTTP_CLIENT 为 static final,整个进程只创建一次 |
| 连接池复用 | 所有请求共享同一个连接池,支持 Keep-Alive,减少 TCP 握手 |
| 资源清理 | JVM 关闭钩子确保应用退出时释放连接池 |
| 参数集中配置 | 连接数、超时、重试等均可统一调整 |
| try-with-resources | 自动关闭 CloseableHttpResponse,防止响应体泄漏 |
maxTotal:建议设置为 (目标服务并发数 × 2),一般 200~1000 之间。maxPerRoute:针对单一目标服务的最大并发连接数,建议 50~200。connectionTimeToLive:连接最大存活时间,建议 30~60 秒,避免长时间占用。evictIdleConnections:定期回收空闲连接,建议 10~30 秒执行一次。有读者可能会问:你说 HttpClient 应该全局单例,那为什么 doPost 方法里每次都要 new HttpPost?难道 HttpPost 不应该也复用吗?
答案是:不能复用,也不需要复用。两者的角色完全不同。
| 对象 | 角色 | 特点 |
|---|---|---|
HttpClient |
执行引擎 | 线程安全、重量级、包含连接池、重试策略、拦截器等基础设施 |
HttpPost |
请求消息 | 非线程安全、轻量级、只描述一次请求的方法、URL、头、实体 |
HttpClient 负责:管理连接、发送请求、处理重定向、重试、解码响应等。
HttpPost 负责:封装本次请求的目标地址、HTTP 方法、头部、请求体。
CloseableHttpClient 是线程安全的。官方文档明确说:“It is safe to share a single instance across threads.” 因此可以全局单例。HttpPost 不是线程安全的。它的 setHeader()、setEntity()、setConfig() 等方法修改内部状态,如果被多个线程同时使用,会出现数据竞争。所以每个请求必须新建一个 HttpPost 实例。HttpClient 创建时需要初始化连接池、注册 Socket Factory、构建拦截器链等,开销较大,适合复用。HttpPost 只是一个简单的 POJO,内部持有几个集合和配置对象,创建成本极低(微秒级),完全可以每次请求新建。| 现实类比 | HttpClient | HttpPost |
|---|---|---|
| 快递公司 | 顺丰总部(车辆、分拣中心、人员) | 一个包裹(收件人、地址、物品) |
| 餐厅 | 厨房(炉灶、厨师、食材储备) | 一张点菜单(菜名、口味要求) |
你不会每次点餐都重建一个厨房,但你会每次点餐都写一张新菜单。同样,你不会每次请求都重建一个 HttpClient,但你会每次请求都新建一个 HttpPost。
有些开发者会担心:既然 HttpClient 是复用的,那多个请求同时使用时,HttpPost 上的配置会不会互相污染?
答案是不会。因为 HttpClient.execute(HttpPost) 方法内部会深拷贝请求配置,不会修改传入的 HttpPost 对象。每个 HttpPost 实例是独立的,不会共享状态。
如果某些请求需要不同的超时时间,可以为 doPost 方法增加 RequestConfig 参数,但注意不要每次都新建 HttpClient,只需在 HttpPost 上设置独立的 config:
public static String doPost(Map<String, String> map, String url, int customTimeout) throws HttpTransportException {
HttpPost method = new HttpPost();
method.setConfig(RequestConfig.custom()
.setConnectTimeout(customTimeout)
.setSocketTimeout(customTimeout)
.build());
// ... 其余相同
}
CloseableHttpClient 的 execute() 方法会合并默认配置和请求级别配置,互不干扰。
| 错误做法 | 正确做法 |
|---|---|
每次请求都 new HttpClient() |
整个应用只创建一个 HttpClient 实例 |
| 不关心连接池参数 | 根据并发量合理配置连接池 |
不关闭 HttpClient |
注册关闭钩子或使用容器管理生命周期 |
将 HttpClient 当作轻量对象 |
视作重量级资源,线程安全可复用 |
误以为 HttpPost 也需要复用 |
每次请求新建 HttpPost,轻量且线程安全 |
一句话记住:HttpClient 是线程安全的重量级执行引擎,应当全局单例、复用连接池、统一配置、妥善关闭;而 HttpPost 只是轻量的请求消息,每次请求新建即可。
下次你在工具方法里看到 HttpClients.custom().build() 时,不妨停下来想一想——这个“方便”的写法,会不会成为线上事故的导火索?
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。