1. 概述

应用程序通常需要某种形式的连接管理来更好地利用资源。

本教程将探讨 Java 11 的 HttpClient 提供的连接管理支持。我们将介绍如何通过系统属性设置连接池大小和默认超时,并使用 WireMock 模拟不同主机。

2. HttpClient 的连接池

Java 11 的 HttpClient 拥有内置连接池。默认情况下,连接池大小无限制。

让我们通过构建一个用于发送请求的 HttpClient 来观察连接池的实际运行:

HttpClient client = HttpClient.newHttpClient();

3. 目标服务器

我们将使用 WireMock 服务器作为模拟主机。这让我们可以利用 Jetty 的调试日志来跟踪连接的创建情况。

首先,观察 HttpClient 如何创建并重用缓存连接。让我们在动态端口上启动 WireMock 服务器:

WireMockServer server = new WireMockServer(WireMockConfiguration
  .options()
  .dynamicPort());

在 setup() 方法中,启动服务器并配置它对所有请求返回 200 响应:

server.start();
server.stubFor(WireMock
  .get(WireMock.anyUrl())
  .willReturn(WireMock
    .aResponse()
    .withStatus(200)));

接下来,创建要发送的 HttpRequest,指向我们的 WireMock 接口:

HttpRequest getRequest = HttpRequest.newBuilder()
  .uri(URI.create("http://localhost:" + server.port() + "/first"))
  .build();

现在有了客户端和目标服务器,发送请求:

HttpResponse<String> response = client.send(getRequest, HttpResponse.BodyHandlers.ofString());

为简化示例,我们使用了 HttpResponse.BodyHandlers 的 ofString 工厂方法创建字符串响应处理器。

直接运行这段代码看不到明显效果,让我们开启调试功能来验证连接是否真的被创建和重用。

4. Jetty 调试日志配置

由于 JDK 11 的 ConnectionPool 类日志输出稀少,我们需要外部日志来观察连接的创建和重用时机。

因此,在类路径中创建 jetty-logging.properties 文件来启用 Jetty 调试日志:

org.eclipse.jetty.util.log.class=org.eclipse.jetty.util.log.StrErrLog
org.eclipse.jetty.LEVEL=DEBUG
jetty.logs=logs

这里将 Jetty 日志级别设为 DEBUG,并配置输出到错误流。

当新连接创建时,Jetty 会记录 "New HTTP Connection" 消息:

DBUG:oejs.HttpConnection:qtp2037764568-17-selector-ServerConnectorManager@34b9f960/0: New HTTP Connection HttpConnection@ba7665b{IDLE}

通过查找这些日志消息,可以确认测试运行时的连接创建行为。

5. 连接池的建立与重用

现在我们有了客户端和能记录新连接请求的服务器,可以开始测试了。

首先验证 HttpClient 是否真的使用了内部连接池。如果连接池生效,我们只会看到一条 "New HTTP Connection" 消息。

向同一服务器发送两个请求,观察日志中的新连接消息数量:

HttpResponse<String> firstResponse = client.send(getRequest, HttpResponse.BodyHandlers.ofString());
HttpResponse<String> secondResponse = client.send(getRequest, HttpResponse.BodyHandlers.ofString());

检查日志输出中的 "New HTTP Connection" 消息:

DBUG:oejs.HttpConnection:qtp2037764568-17-selector-ServerConnectorManager@34b9f960/0: New HTTP Connection HttpConnection@ba7665b{IDLE}

确实只记录了一条新连接请求。这表明第二次请求无需创建新连接。

客户端在首次调用时建立连接并存入连接池,使第二次调用能重用同一连接。

现在测试连接池是客户端独有还是跨客户端共享。创建第二个客户端验证:

HttpClient secondClient = HttpClient.newHttpClient();

从两个客户端分别向同一服务器发送请求:

HttpResponse<String> firstResponse = client.send(getRequest, HttpResponse.BodyHandlers.ofString());
HttpResponse<String> secondResponse = secondClient.send(getRequest, HttpResponse.BodyHandlers.ofString());

检查日志输出,发现创建了两个新连接:

DBUG:oejs.HttpConnection:qtp729218894-17-selector-ServerConnectorManager@51acdf2e/0: New HTTP Connection HttpConnection@3cc85dbb{IDLE}
DBUG:oejs.HttpConnection:qtp729218894-21-selector-ServerConnectorManager@51acdf2e/1: New HTTP Connection HttpConnection@6062141{IDLE}

第二个客户端导致向同一目标创建了新连接。由此推断:每个客户端拥有独立的连接池。

6. 控制连接池大小

观察了连接的创建和重用后,看看如何控制连接池大小。

JDK 11 的 ConnectionPool 初始化时会检查 jdk.httpclient.connectionPoolSize 系统属性,默认值为 0(无限制)。

可通过 JVM 参数或编程方式设置系统属性。但由于该属性仅在初始化时读取,我们使用 JVM 参数确保首次连接前生效。

先运行测试:先调用服务器 A,再调用服务器 B,最后再次调用服务器 A:

HttpResponse<String> firstResponse = client.send(getRequest, HttpResponse.BodyHandlers.ofString());
HttpResponse<String> secondResponse = client.send(secondGet, HttpResponse.BodyHandlers.ofString());
HttpResponse<String> thirdResponse = client.send(getRequest, HttpResponse.BodyHandlers.ofString());

日志中只出现两条连接请求,因为首次请求的连接仍保留在池中:

DBUG:oejs.HttpConnection:qtp2037764568-17-selector-ServerConnectorManager@34b9f960/0: New HTTP Connection HttpConnection@1af88cae{IDLE}
DBUG:oejs.HttpConnection:qtp1932332324-26-selector-ServerConnectorManager@13d4992d/0: New HTTP Connection HttpConnection@71c7d4f{IDLE}

现在通过设置连接池大小为 1 来改变默认行为:

-Djdk.httpclient.connectionPoolSize=1

通常不会将池大小设为 1,但这样能更快达到池上限。

设置该属性后重新运行测试,看到创建了三个连接:

DBUG:oejs.HttpConnection:qtp2104973502-22-selector-ServerConnectorManager@48b67364/0: New HTTP Connection HttpConnection@3da6a47f{IDLE}
DBUG:oejs.HttpConnection:qtp351877391-26-selector-ServerConnectorManager@3b8f0a79/0: New HTTP Connection HttpConnection@20b59486{IDLE}
DBUG:oejs.HttpConnection:qtp2104973502-18-selector-ServerConnectorManager@48b67364/1: New HTTP Connection HttpConnection@599a00c1{IDLE}

属性设置生效!当连接池大小仅为 1 时,调用第二个服务器会导致第一个连接被清除。因此第三次调用第一个服务器时,池中已无可用连接,必须创建第三个新连接。

7. 连接保活超时

连接建立后会保留在池中供重用。如果连接空闲时间过长,将从连接池中清除。

JDK 11 的 ConnectionPool 初始化时会检查 jdk.httpclient.keepalive.timeout 系统属性,默认值为 1200 秒(20 分钟)。

⚠️ 注意:保活超时系统属性与 HttpClient 的 connectTimeout() 方法不同。连接超时决定建立新连接的等待时间,而保活超时决定连接建立后的保持时长

由于 20 分钟在现代架构中过长,JDK 20 build 26 已将默认值降至 30 秒。

通过 JVM 参数设置保活超时为 2 秒来测试该配置:

-Djdk.httpclient.keepalive.timeout=2

运行测试,在两次调用间休眠足够时间使连接断开:

HttpResponse<String> firstResponse = client.send(getRequest, HttpResponse.BodyHandlers.ofString());
Thread.sleep(3000);
HttpResponse<String> secondResponse = client.send(getRequest, HttpResponse.BodyHandlers.ofString());

如预期所见,两次请求都创建了新连接,因为第一个连接在 2 秒后被断开:

DBUG:oejs.HttpConnection:qtp1889057031-18-selector-ServerConnectorManager@928763c/0: New HTTP Connection HttpConnection@7d1c0d89{IDLE}
DBUG:oejs.HttpConnection:qtp1889057031-20-selector-ServerConnectorManager@928763c/1: New HTTP Connection HttpConnection@62a8bb1d{IDLE}

8. HttpClient 的增强功能

自 JDK 11 以来 HttpClient 持续演进,例如引入了网络日志改进。在 Java 19 中运行测试时,可直接使用 HttpClient 内部日志监控网络活动,无需依赖 WireMock 的 Jetty 日志。还有实用的客户端使用指南

由于 HttpClient 支持 HTTP/2 (H2) 连接(使用连接多路复用),应用程序可能不需要那么多连接。因此在 JDK 20 build 25 中,新增了专门针对 H2 池的系统属性:

  • jdk.httpclient.keepalivetimeout.h2 – 控制 H2 连接的保活超时
  • jdk.httpclient.maxstreams – 控制每个 HTTP 连接允许的最大 H2 流数量(默认 100)

9. 总结

本教程展示了 Java HttpClient 如何重用内部连接池中的连接。我们使用 WireMock 和 Jetty 日志观察新连接请求的创建时机。接着学习了如何控制连接池大小及其达到上限时的效果,还配置了空闲连接的清除时间。

最后探讨了较新 Java 版本中的网络改进。

对于 Java 11 之前的版本或需要不同功能的情况,可参考我们的 Apache HttpClient4 教程作为替代方案。

一如既往,示例源代码可在 GitHub 获取。


原始标题:Java HttpClient Connection Management

» 下一篇: Quarkus Funqy指南