Java HttpClient 高并发调用实战踩坑,面向后端开发者,深度源码+线上故障复盘

Java 11 HttpClient 线上血泪复盘:高并发下连接泄露、超时卡死、内存暴涨完整排错与根治方案

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

QQ20260811-153648

前言
前两年我们团队逐步把项目从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连接不会主动关闭,发生连接泄露,同时创建大量内部线程,线程数量疯狂膨胀,内存持续上涨。

很多开发会混淆HttpClientHttpRequest,以为HttpRequest每次新建,HttpClient也需要每次新建。HttpRequest是轻量,每次请求新建没问题;HttpClient必须全局单例复用。

现象复现

  1. netstat查看服务器TCP连接,大量CLOSE_WAIT连接堆积;
  2. jstack线程快照,大量HttpClient-SelectorManager线程;
  3. jmap堆快照,大量HttpClient对象无法释放;
  4. 接口没有抛出异常,请求直接阻塞,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();
        }
    }
}

额外踩坑清单,生产务必注意

  1. BodyHandlers.ofString() 默认使用UTF‑8,部分第三方接口返回GBK编码,会出现中文乱码,需要自定义BodyHandler处理编码;
  2. JDK11早期版本HttpClient存在不少bug,建议JDK升级到17.0.8以上LTS版本;
  3. HttpClient没有最大连接数的公开配置,底层依赖系统内核参数调优;
  4. 同步send模式下,连接池拿不到可用连接时,不会触发timeout,会阻塞等待,高并发尽量用异步;
  5. idleTimeout一定要配置,否则闲置TCP连接不会主动回收,大量CLOSE_WAIT堆积;
  6. 如果业务需要代理,不要在业务方法内每次构建代理,同样放在HttpClient构建阶段全局配置。

线上问题排查手段

  1. jstack 打印线程,观察HttpClient相关线程数量;
  2. netstat -anp | grep java 统计TCP连接状态;
  3. 开启JDK HttpClient日志,logging.level.java.net.http=DEBUG;
  4. 压测环境模拟下游慢接口,复现阻塞问题。

总结
JDK内置HttpClient省去第三方依赖,但是很多特性不像Apache HttpClient文档完善,网上大量示例只是玩具级demo,直接复制上生产很容易踩坑。
核心记住两点:

  1. HttpClient实例全局单例,绝对不要每次请求new;
  2. 高并发业务避免直接用send同步阻塞,优先sendAsync异步,自定义线程池,配置idleTimeout。

凡尘版权 © 2026
友情链接:凡尘博客凡尘博客文章|凡尘博客文摘凡尘影院凡尘乡音|凡尘街坊凡尘博客|雨落凡尘博客|羽落凡尘博客凡尘博客|雨落凡尘博客|羽落凡尘博客

posted @ 2026-08-11 15:38  凡尘——雨落凡尘  阅读(8)  评论(0)    收藏  举报