Apache HttpClient 内存泄露分析

639 阅读3分钟

问题

线上的一个应用在长时间运行后,内存使用率不断上升,堆内存无法被回收。

问题定位

使用 Arthas 导出堆转储文件,通过 Eclipes MAT 进行分析,发现  Finalizer 队列对象异常堆积。

image.png

进一步分析 Finalizer 队列的引用路径,发现 PoolingHttpClientConnectionManager 实例未被及时回收,导致大量 HTTP 连接对象残留。

image.png

通过分析PoolingHttpClientConnectionManager中的 conn 内容,定位到了使用了 Apache HttpClient4 代码行。

问题分析

在分析以上问题前,先通过出现问题的代码入手。

private static String doPostByJSON(String url, Map<String, String> headers, JSONObject data, String encoding) throws IOException {

    String resultJson = null;
    CloseableHttpClient client = HttpClients.createDefault();
    CloseableHttpResponse response = null;
    HttpPost httpPost = new HttpPost();


    RequestConfig requestConfig = RequestConfig.custom().setConnectTimeout(CONNECT_TIMEOUT).setSocketTimeout(SOCKET_TIMEOUT).build();
    httpPost.setConfig(requestConfig);

    try {
        httpPost.setURI(new URI(url));
        packageHeader(headers, httpPost);

        httpPost.setEntity(new StringEntity(JSON.toJSONString(data), encoding));

        response = client.execute(httpPost);
        int status = response.getStatusLine().getStatusCode();
        if (status != HttpStatus.SC_OK) {
            System.out.println("响应失败,状态码:" + status);
        }
        resultJson = EntityUtils.toString(response.getEntity(), encoding);
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        release(response, client);
    }
    return resultJson;
}

根据以上代码,初步判断出几个问题点:

  • HttpClients.createDefault() 每次调用都会创建一个新的 CloseableHttpClient 实例,没有显示关闭,增加内存消耗。
  • RequestConfig 是线程安全的,应该复用同一个实例,而不是在每次请求时重新创建,避免不必要的对象分配。
  • CloseableHttpResponse 没有显示关闭,可能导致 HTTP 连接无法及时释放。

接着,分析 HttpClients.createDefault() 方法内部实现,它会构建 PoolingHttpClientConnectionManager,既然 PoolingHttpClientConnectionManagerFinalizer 队列中,它肯定是实现了 finalize 方法,如下:

@Override
protected void finalize() throws Throwable {
    try {
        shutdown();
    } finally {
        super.finalize();
    }
}

这个 finalize() 方法的作用是在对象被 GC 回收时,主动关闭连接池,释放连接资源。然而,JVM 对 finalize() 的处理机制存在一定延迟和风险:

  • JVM 维护了一个 ReferenceQueue(引用队列),当 PoolingHttpClientConnectionManager 实例不再被引用时,JVM 不会立即回收,而是先将其放入 Finalizer 队列。
  • Finalizer 队列中的对象需要由 低优先级Finalizer 线程依次执行 finalize() 方法,而后才能真正释放资源。
  • 在 GC 频繁或系统负载较高时,Finalizer 线程可能无法及时处理队列中的对象,导致 PoolingHttpClientConnectionManager 长时间无法释放,进而造成连接池堆积、内存泄漏。

因此,为了避免 PoolingHttpClientConnectionManager 堆积,可以通过以下手段进行优化:

  1. 复用 CloseableHttpClient ,避免每次请求都创建新的 CloseableHttpClient,而是使用单例模式进行管理,确保 PoolingHttpClientConnectionManager 在整个应用生命周期内被复用。
  2. (可选)升级到 Apache HttpClient5,它移除了 finalize() 机制,不再依赖 Finalizer 线程回收资源,避免了 Finalizer 队列堆积导致的内存泄漏。即使升级到 HttpClient 5,仍然需要复用 CloseableHttpClient,否则仍然会导致连接池管理不当的问题。

问题验证

通过以下 demo 代码来单独验证 Finalizer 队列堆积的问题,通过 JProfile 来根据 Finalizer 观察。

@PostMapping("post")
public int post()throws Exception {
    HttpPost httpPost = new HttpPost("https://webhook.site/id");

    // 设置请求头
    httpPost.setHeader("Accept", "application/json");
    httpPost.setHeader("Content-Type", "application/json");

    // 设置 JSON 请求体
    String jsonBody = "{\"message\":\"Hello, Webhook!\"}";
    StringEntity entity = new StringEntity(jsonBody, StandardCharsets.UTF_8);
    httpPost.setEntity(entity);

    // 执行请求并获取响应
    CloseableHttpResponse response = HttpClients.createDefault().execute(httpPost);
    // 读取响应体
    String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
    log.info("Response Code: {}", response.getStatusLine().getStatusCode());
    log.info("Response Body: {}", responseBody);
    return response.getStatusLine().getStatusCode();
}

以上每次都是通过 HttpClients.createDefault() 来创建 CloseableHttpClient,通过观察得出 Finalizer 队列持续增长。

image.png

通过复用 CloseableHttpClient 来进行验证。

@Configuration
public class HttpClient4Config {

    @Bean
    public CloseableHttpClient httpClient(RequestConfig requestConfig) {
        return HttpClients.custom()
                .setDefaultRequestConfig(requestConfig)
                .build();
    }

    @Bean
    public RequestConfig requestConfig() {
        return RequestConfig.custom()
                .setConnectTimeout(5000) // 连接超时时间(毫秒)
                .setSocketTimeout(5000)  // Socket 超时时间(毫秒)
                .setConnectionRequestTimeout(5000) // 从连接池获取连接的超时时间(毫秒)
                .build();
    }
}

@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("http-client-4")
public class ApacheHttpClient4Controller {

    private final CloseableHttpClient httpClient;

    @PostMapping("post")
    public int post()throws Exception {
        HttpPost httpPost = new HttpPost("https://webhook.site/id");

        // 设置请求头
        httpPost.setHeader("Accept", "application/json");
        httpPost.setHeader("Content-Type", "application/json");

        // 设置 JSON 请求体
        String jsonBody = "{\"message\":\"Hello, Webhook!\"}";
        StringEntity entity = new StringEntity(jsonBody, StandardCharsets.UTF_8);
        httpPost.setEntity(entity);

        // 执行请求并获取响应
        CloseableHttpResponse response = httpClient.execute(httpPost);
        // 读取响应体
        String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
        log.info("Response Code: {}", response.getStatusLine().getStatusCode());
        log.info("Response Body: {}", responseBody);
        return response.getStatusLine().getStatusCode();
    }
}

观察 Finalizer 队列没有出现增长。

image.png

问题修复

  1. 复用 CloseableHttpClient 以及 RequestConfig
  2. 使用 try resource 来包装该方法 httpClient.execute,在执行完之后,释放 CloseableHttpResponse 持有的连接。