当前位置:首页 > 技术 > 正文内容

HttpClient 多线程环境下的阻塞问题分析与解决

访客 技术 2026年10月7日 1

当在多线程环境中使用HttpClient组件频繁发起HTTP请求时,可能会遇到客户端CPU利用率下降且部分线程被阻塞的现象。这通常表明请求处理过程中出现了意外的长时间等待。

问题排查过程

最初的排查思路集中在多线程并发导致的死锁或JVM频繁GC。然而,代码审查并未发现明显的死锁迹象,GC日志也显示正常。在排查过程中,发现代码未设置请求的超时时间,即使添加了超时设置,问题依旧存在。最终通过线程堆栈信息(jstack)和内存堆栈分析(jmap),确认问题根源在于Socket连接在数据读取阶段被阻塞,导致线程长时间挂起。搜索"httpclient 超时"时发现,不同版本的HttpClient设置超时参数的方式差异很大,导致网上找到的方法未能生效。这暴露了对HttpClient组件理解不深入的问题。

问题复现

环境依赖

使用的HttpClient版本为 4.5.2:

<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient</artifactId>
    <version>4.5.2</version>
</dependency>

复现代码

以下Java代码用于模拟多线程高并发场景下的HttpClient请求:

import java.io.IOException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

import org.apache.http.HttpException;
import org.apache.http.HttpRequest;
import org.apache.http.HttpRequestInterceptor;
import org.apache.http.HttpResponse;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.ResponseHandler;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.apache.http.conn.HttpClientConnectionManager;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.config.SocketConfig;

public class HttpClientHangDemo {
    private final String targetUrl = "http://www.baidu.com/";
    private final AtomicInteger requestCounter = new AtomicInteger(0);

    public static void main(String[] args) {
        HttpClientHangDemo demo = new HttpClientHangDemo();
        demo.runTest();
    }

    public void runTest() {
        int totalRequests = 100000;
        int concurrentThreads = 50;
        CountDownLatch latch = new CountDownLatch(totalRequests);
        ExecutorService threadPool = Executors.newFixedThreadPool(concurrentThreads);

        System.out.println("Starting test with " + totalRequests + " requests and " + concurrentThreads + " threads.");

        for (int i = 0; i < totalRequests; i++) {
            threadPool.execute(() -> {
                try {
                    makeHttpRequest(targetUrl);
                    Thread.sleep(50); // Add a small delay to avoid overwhelming the system immediately
                } catch (IOException | InterruptedException e) {
                    Thread.currentThread().interrupt(); // Restore interrupted status
                    System.err.println("Request failed or interrupted: " + e.getMessage());
                } finally {
                    latch.countDown();
                }
            });
        }

        try {
            latch.await();
            System.out.println("All requests completed.");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.err.println("Test interrupted: " + e.getMessage());
        } finally {
            threadPool.shutdown();
            System.out.println("Thread pool shut down.");
        }
    }

    private void makeHttpRequest(String url) throws IOException {
        // IMPORTANT: Create a new HttpClient instance for each request or manage a connection manager properly.
        // For demonstration of the issue, we create a new one per request.
        // In production, reusing HttpClient and its connection manager is recommended.

        // --- Demonstrating the issue: No explicit timeouts configured ---
        // CloseableHttpClient httpClient = HttpClients.createDefault();

        // --- Solution demonstration: Configure timeouts ---
        int timeoutMillis = 5000; // 5 seconds for all timeouts
        RequestConfig requestConfig = RequestConfig.custom()
                .setConnectionRequestTimeout(timeoutMillis) // Timeout for acquiring a connection from the connection manager
                .setConnectTimeout(timeoutMillis)          // Timeout for establishing the connection to the server
                .setSocketTimeout(timeoutMillis)           // Timeout for waiting for data from the server after the connection is established
                .build();

        // Use a custom HTTP client with configured connection manager and socket config
        PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
        connectionManager.setDefaultMaxPerRoute(20); // Limit connections per route
        connectionManager.setMaxTotal(100); // Limit total connections

        // Set socket configuration for the connection manager
        SocketConfig socketConfig = SocketConfig.custom()
                                             .setSoTimeout(timeoutMillis) // Socket read timeout
                                             .build();
        connectionManager.setDefaultSocketConfig(socketConfig);

        CloseableHttpClient httpClient = HttpClients.custom()
                .setConnectionManager(connectionManager)
                .setDefaultRequestConfig(requestConfig)
                .build();


        try {
            HttpGet httpGet = new HttpGet(url);
            // Setting the request-specific config
            httpGet.setConfig(requestConfig);

            ResponseHandler<String> responseHandler = response -> {
                int status = response.getStatusLine().getStatusCode();
                if (status >= 200 && status < 300) {
                    try {
                        return EntityUtils.toString(response.getEntity());
                    } catch (IOException e) {
                        System.err.println("Error reading response entity: " + e.getMessage());
                        return null;
                    }
                } else {
                    throw new ClientProtocolException("Unexpected response status: " + status);
                }
            };

            String responseBody = httpClient.execute(httpGet, responseHandler);
            if (responseBody != null) {
                System.out.println(String.format("Received response. Body length: %d, Request count: %d",
                        responseBody.length(), requestCounter.incrementAndGet()));
            }
        } finally {
            // In this specific demo, we close the client to release resources per request.
            // However, in a real application, managing the lifecycle of HttpClient and its connection manager is crucial.
            // Closing the client here is acceptable for demonstrating the timeout fix, but not ideal for performance.
            try {
                if (httpClient != null) {
                    httpClient.close();
                }
            } catch (IOException e) {
                System.err.println("Error closing HttpClient: " + e.getMessage());
            }
        }
    }
}

运行上述代码一段时间后,可以观察到类似如下的控制台输出,并且程序可能无法正常终止:

(此处应有原图1:HttpClient重现报错截图)

同时,使用netstat -anpt命令会发现大量TCP连接处于ESTABLISHED状态,表明连接已建立但可能未被有效管理或释放。

通过jstack -F -l 命令生成的线程堆栈信息,可以发现大量线程处于RUNNABLE状态,但被阻塞在Socket读取操作上,例如:

"pool-1-thread-45" #55 prio=5 os_prio=0 tid=0x00007f78702df000 nid=0x33d5 runnable [0x00007f7830c1d000]
   java.lang.Thread.State: RUNNABLE
	at java.net.SocketInputStream.socketRead0(Native Method)
	at java.net.SocketInputStream.socketRead(SocketInputStream.java:116)
	at java.net.SocketInputStream.read(SocketInputStream.java:171)
	at java.net.SocketInputStream.read(SocketInputStream.java:141)
	at org.apache.http.impl.io.SessionInputBufferImpl.streamRead(SessionInputBufferImpl.java:139)
	at org.apache.http.impl.io.SessionInputBufferImpl.fillBuffer(SessionInputBufferImpl.java:155)
	at org.apache.http.impl.io.SessionInputBufferImpl.readLine(SessionInputBufferImpl.java:284)
	at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:140)
	at org.apache.http.impl.conn.DefaultHttpResponseParser.parseHead(DefaultHttpResponseParser.java:57)
	at org.apache.http.impl.io.AbstractMessageParser.parse(AbstractMessageParser.java:261)
	at org.apache.http.impl.DefaultBHttpClientConnection.receiveResponseHeader(DefaultBHttpClientConnection.java:165)
	at org.apache.http.impl.conn.CPoolProxy.receiveResponseHeader(CPoolProxy.java:167)
	at org.apache.http.protocol.HttpRequestExecutor.doReceiveResponse(HttpRequestExecutor.java:272)
	at org.apache.http.protocol.HttpRequestExecutor.execute(HttpRequestExecutor.java:124)
	at org.apache.http.impl.execchain.MainClientExec.execute(MainClientExec.java:271)
	at org.apache.http.impl.execchain.ProtocolExec.execute(ProtocolExec.java:184)
	at org.apache.http.impl.execchain.RetryExec.execute(RetryExec.java:88)
	at org.apache.http.impl.execchain.RedirectExec.execute(RedirectExec.java:110)
	at org.apache.http.impl.client.InternalHttpClient.doExecute(InternalHttpClient.java:184)
	at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:71)
	at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:220)
	at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:164)
	at org.apache.http.impl.client.CloseableHttpClient.execute(CloseableHttpClient.java:139)
	at com.example.HttpClientHangDemo.makeHttpRequest(HttpClientHangDemo.java:106) <-- 线程在此处阻塞
	at com.example.HttpClientHangDemo.lambda$runTest$0(HttpClientHangDemo.java:72)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)

堆栈信息明确指出,线程阻塞发生在httpClient.execute(request, responseHandler);这一行,具体是java.net.SocketInputStream.socketRead0(Native Method),表明线程在等待网络I/O操作完成。

问题根源与解决方案

根本原因在于HttpClient在执行请求时没有配置有效的超时时间。尽管连接可能已经建立(ESTABLISHED),但在数据读取阶段,如果服务器响应缓慢或网络存在问题,HttpClient会一直等待,直到Socket超时(如果设置了)或连接被服务器/网络设备主动断开。由于未设置超时,线程就会一直阻塞在socketRead0。

理解HttpClient的超时机制

HttpClient 4.x 版本中,超时配置主要涉及以下几个方面,这些配置通常通过RequestConfig对象进行设置:

  • ConnectionRequestTimeout: 获取可用连接的超时时间,即从连接池中获取一个可用连接的最大等待时间。
  • ConnectTimeout: 建立TCP连接的超时时间。
  • SocketTimeout: 等待服务器响应数据的超时时间,即建立连接后,从服务器读取数据的超时时间。这是导致上述阻塞问题的关键。

这些配置可以通过多种方式应用,包括直接在HttpClientBuilder或HttpClients.createDefault()创建的客户端上设置默认配置,或者为单个请求设置RequestConfig。

解决方案

为HttpClient配置适当的超时时间是解决此问题的关键。以下是几种常见的配置方式:

1. 通过 RequestConfig 对象设置

这是推荐的方式,可以在创建HttpClient实例时设置默认的RequestConfig,或为单个请求单独设置。

int requestTimeout = 5000; // 5秒
int connectTimeout = 5000; // 5秒
int socketTimeout = 5000;  // 5秒

RequestConfig requestConfig = RequestConfig.custom()
        .setConnectionRequestTimeout(requestTimeout)
        .setConnectTimeout(connectTimeout)
        .setSocketTimeout(socketTimeout)
        .build();

// 方法一:为默认HttpClient配置(全局生效)
// CloseableHttpClient httpClient = HttpClients.custom()
//         .setDefaultRequestConfig(requestConfig)
//         .build();

// 方法二:为单个请求配置
HttpGet httpGet = new HttpGet(url);
httpGet.setConfig(requestConfig);

// ... httpClient.execute(httpGet, responseHandler);

2. 通过 PoolingHttpClientConnectionManager 设置默认 Socket 配置

可以直接在连接管理器上设置默认的SocketConfig,这会影响所有通过该管理器创建的连接。

int socketTimeout = 5000; // 5秒

SocketConfig socketConfig = SocketConfig.custom()
        .setSoTimeout(socketTimeout) // 设置Socket读取超时
        .build();

PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
connectionManager.setDefaultSocketConfig(socketConfig);

CloseableHttpClient httpClient = HttpClients.custom()
        .setConnectionManager(connectionManager)
        .build();

3. (不推荐,已废弃或不常用)通过 HttpParams 设置

在较旧的HttpClient版本中,可以通过 HttpParams 设置 CoreConnectionPNames.SO_TIMEOUT 和 CoreConnectionPNames.CONNECTION_TIMEOUT。但这种方式在4.x后期版本中逐渐被 RequestConfig 取代。

// 仅为说明,不推荐在4.x+版本中使用
// HttpParams params = new BasicHttpParams();
// params.setParameter(CoreConnectionPNames.SO_TIMEOUT, 5000);
// params.setParameter(CoreConnectionPNames.CONNECTION_TIMEOUT, 5000);
// request.setParams(params);

在上述复现代码的修改版本中,我们采用了方法1和方法2的结合,即通过RequestConfig设置请求级别的超时,并通过PoolingHttpClientConnectionManager设置默认的SocketConfig。这样可以确保连接请求、建立和数据读取都有明确的超时限制。

验证结果

配置了超时参数后,即使服务器响应超时,请求也会被中断并抛出异常(例如SocketTimeoutException),而不是无限期地阻塞线程。这使得程序能够正常结束,CPU利用率也恢复正常。

(此处应有原图3:有报错但是不会出现线程hang住截图)

(此处应有原图4:CPU使用率基本稳定截图)

总结与经验

  1. 熟悉组件: 对使用的开源组件(如HttpClient)不熟悉是导致问题的主要原因。深入理解其工作原理、配置项和版本差异至关重要。
  2. 版本兼容性: HttpClient不同版本之间的API变化较大,尤其是超时设置方式。在遇到配置不生效时,应优先查阅当前使用版本的官方文档或源码。
  3. 监控与日志: 程序日志、GC日志、线程堆栈(jstack)和内存堆栈(jmap)是排查问题的关键工具。
  4. 源码是真相: 当搜索引擎和常规排查方法无法解决问题时,直接阅读组件的源代码是定位和理解问题的最佳途径。
  5. 替代方案: 对于HTTP客户端,除了HttpClient,还可以考虑JDK原生的java.net.URL,或Spring框架提供的RestTemplate(其底层可能使用Netty等更现代的HTTP客户端)。
  6. 实践方法论: 解决复杂问题的通用方法论包括:细致分析日志,代码审查排除逻辑错误,利用诊断工具(jstack, jmap),以及在必要时深入源码。
标签: HttpClient

相关文章

Linux crontab 详解

1) crontab 是什么cron 是 Linux 的定时任务守护进程;crontab 是用来编辑/查看“按时间周期执行命令”的表(cron table)。常见两类:用户 crontab:每个用户一份(crontab -e 编辑)系统级 crontab / cron.d:可指定执行用户(/etc/crontab、/etc/cron.d/*)2) crontab 时间...

富文本里可以允许的 HTML 属性

一、所有标签默认允许的安全属性(极少)class        (可选)id           (通常建议禁用)title️ 注意:id 容易被滥用做锚点注入,很多系统直接禁用class 允许的话最好只允许固定前缀(如 editor-*)二、a 标签允许属性<a href="" t...

Mac 安装 Node.js 指南

方法一:通过官网安装包(最简单,适合初学者)如果你只是想快速安装并开始使用,这是最直接的方法。访问 Node.js 官网。页面会显示两个版本:LTS (Recommended For Most Users):长期支持版,最稳定。建议选这个。Current:最新特性版,包含最新功能但可能不够稳定。下载 .pkg 安装包并运行。按照安装向导点击“下一步”即可完成。方法二:使用 Homebrew 安装(...

Dom\HTML_NO_DEFAULT_NS 的副作用:自动加闭合标签

在使用Dom\HTMLDocument时,Dom\HTML_NO_DEFAULT_NS 将禁止在解析过程中设置元素的命名空间, 此设置是为了与DOMDocument向后兼容而存在的。当使用它时,已知的一个副作用就是:自动加闭合标签例如 </img> 为什么会这样?当你使用:Dom\HTML_NO_DEFAULT_NS文档会变成 无命名空间模式,此时内部更接近 XML...

Laravel 事件和监听器创建

在 Laravel 中,使用 Artisan 命令创建 Events(事件) 和 Listeners(监听器) 是非常高效的。你可以通过以下几种方式来实现:1. 手动创建单个 Event如果你只想创建一个事件类,可以使用 make:event 命令:Bashphp artisan make:event UserRegistered执行后,文件将生成在 app/Even...

自定义域名解析神器 dnsmasq

什么是 dnsmasq?dnsmasq 是一个轻量级、功能强大的网络服务工具,专为小型和中等规模网络设计。它是一个综合的网络基础设施解决方案[1]。dnsmasq 能做什么?功能说明应用场景DNS 转发与缓存将 DNS 查询转发到上游服务器(ISP、Google DNS 等),并在本地缓存结果加快 DNS 查询速度,减少外部 DNS 流量本地 DNS解析本地网络设备的主机名,无需编辑&n...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。