1 前言
隔了好长一段时间都没写文章了,总有一些事情会耽搁导致挤不出时间来,心思不在这里,今天终于决定继续写文章。好了,废话不多说进入正题,Java后端服务当需要与外部第三方服务交互时,我们一般会通过http向对方发起请求,而用的最多的客户端工具是Apache的HttpClient,目前来说成熟度比较高,用起来方便,最主要的还提供了连接池功能,连接可以复用,大大提高了请求效率,这也是本文讲的最主要的内容,我们来看看它是如何做到的。
2 一个简单的例子
目前4.5.x的版本用户数量比较多,因此本文也是基于这个版本去分析,官方文档请参考Apache HttpComponents -- HttpClient Quick Start,首先引入maven依赖
java
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>4.5.14</version>
</dependency>
java
public static void main(String[] args) throws IOException, InterruptedException {
// 使用连接管理
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
// 最大连接数
cm.setMaxTotal(1);
// 每个路由最大连接数
cm.setDefaultMaxPerRoute(1);
// 具体的路由最大连接数,用的比较少
HttpHost localhost = new HttpHost("locahost", 8081);
cm.setMaxPerRoute(new HttpRoute(localhost), 1);
// 请求时间参数设置
RequestConfig.Builder requestBuilder = RequestConfig.custom();
// 建立连接超时时间,可理解为tcp三次握手建立连接的超时时间
requestBuilder.setConnectTimeout(10000);
// 读取数据超时时间,每次发起请求到收到响应的超时时间
requestBuilder.setSocketTimeout(10000);
// 从连接池获取连接的超时时间
requestBuilder.setConnectionRequestTimeout(10000);
RequestConfig requestConfig = requestBuilder.build();
// (1) -------------------------配置分割线----------------------------------------
CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(cm)
.setDefaultRequestConfig(requestConfig)
.build();
// 开启两个线程同时发起请求
new Thread(() -> {
try {
doExecute(httpClient);
} catch (Exception e) {
e.printStackTrace();
}
}).start();
new Thread(() -> {
try {
doExecute(httpClient);
} catch (Exception e) {
e.printStackTrace();
}
}).start();
Thread.sleep(10000000);
}
private static void doExecute(CloseableHttpClient httpClient) throws Exception {
HttpPost httpPost = new HttpPost("http://localhost:8081/hello");
List<NameValuePair> nvps = new ArrayList<NameValuePair>();
nvps.add(new BasicNameValuePair("msg", "hello"));
httpPost.setEntity(new UrlEncodedFormEntity(nvps));
CloseableHttpResponse response = null;
try {
response = httpClient.execute(httpPost);
HttpEntity entity = response.getEntity();
String s = EntityUtils.toString(entity, StandardCharsets.UTF_8);
System.out.println(System.currentTimeMillis() + " " + s);
} finally {
if (response != null) {
try {
response.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
上面例子中配置了一个连接池,连接池最大连接数设置成1,使用两个线程同时发起请求,
java
@RestController
public class HelloController {
@RequestMapping(value = "/hello", method = RequestMethod.POST)
public String hello(@RequestParam String msg) throws InterruptedException {
Thread.sleep(5000);
return "服务端返回: " + msg;
}
}
再使用springboot启动一个端口为8081的后端服务,暴露一个接口,接口延时5秒返回,我们来运行下程序 两个接口相差5秒返回,说明始终只用了一个连接,连接复用了,如果把连接数改成大于1,则两个线程请求几乎是同时返回的,有兴趣的可以自己试下。
3 连接复用分析
在没有认真研究HttpClient连接池之前,对连接复用的概念比较模糊,因为我们知道发起请求时url是会有可能不同的,既然不同,那怎么做到复用呢?原来它这里有个比较关键的概念,就是HttpRoute,这个就是判断连接是否复用的标识,它由主机名(ip或域名)+端口组成,每次获取之前的请求连接就会根据这个标识做对比,如果是一样的就会复用之前的连接,这么说比较抽象,一图胜千言,我们通过下面的图来大致了解原理。
从最上面的例子知道,创建连接池需要创建一个PoolingHttpClientConnectionManager对象,PoolingHttpClientConnectionManager对象持有ConnPool,ConnPool继承了AbstractConnPool,总的连接池ConnPool持有三个集合分别是可用的连接available、正在使用的leased、等待获取连接pending,为了能够更进一步控制相同HttpRoute的连接数量,对同一种HttpRoute又对应创建了RouteSpecificPool,同样含有三个集合available、leased、pending,当发起请求时,首先会在Map<T,RouteSpecificPool>根据HttpRoute去获取一个RouteSpecificPool,再从这个连接池获取连接,这就是它的大致原理。 操作连接相关的字段都放在AbstractConnPool了,我们继续来看下当发送请求时是如何从连接池获取连接和回收连接的。
3.1 获取连接
我们从例子中 httpClient.execute(httpPost) 去查看源码,httpClient.execute会调用doExecute方法,再这里会调用execChain.execute,execChain使用了责任链的模式,最终在MainClientExec.execute方法去获取连接 圈红框是关键代码(下同),connRequest.get方法里面包含一个Future,最终会调用Future的get方法,Future是执行connManager.requstConnection方法创建的匿名类,Future.get方法源码如下
我们可以看到继续会调用AbstractConnPool.getPoolEntryBlocking方法,从方法的名字我们就能猜出这是通过阻塞的方式去获取连接
这里有两个循环,内循环是从RoutToPool获取连接,如果获取不到就跳出内循环,pending加1,然后通过this.condition.await方法一直等待,直到被唤醒,唤醒后继续执行外循到内循环获取连接,重复之前的步骤,当成功获取后available可用连接会减1,leased正在使用的会加1,需要注意的是RoteToPool的available、leased、pending也会同步此操作,而且是在ConnPool变化之前执行。判断有没超过最大连接数也是首先根据RouteToPool来判断,满足后再判断ConnPool的最大连接数。
3.2 创建连接
当创建的连接数没有到达最大连接数并且没有可用的连接,此时当发起一个请求就会创建一个连接。回到刚刚getPoolEntryBlocking方法 这段代码会为创建连接做一些准备工作(开辟发送请求和接受响应缓存、编码处理),最终会调用PoolingHttpClientConnectionManager.create方法,准备工作做好之后调用MainClientExec的establishRoute方法(由MainClientExec.execute方法触发调用)建立连接,底层其实就是一个Socket,这个socket是会绑定发送请求的host的,中间源码太多了就只贴关键代码了,下面这个代码在DefaultHttpClientConnectionOperator.connect方法 创建一个Socket就会开辟一定的内存空间,如果频繁创建的话,系统开销就会增大,这也体现出连接池的必要性了。
3.3 释放连接
通过上面的分析我们知道了发送请求是如何从连接池获取连接的,分析释放连接就比较简单了,例子中finally里执行了response.close,最终会调用AbstractConnPool.release方法。 可以看到,正在使用的连接会从leased移除,重新放回available中,再通过this.condition.signalAll唤醒正在等待的pending,典型的生产消费模式。
4 总结
好了,到这里就大致分析完了,其实池化的原理大致都是这样,不管是线程池、数据库连接池、http连接池底层的思路基本都是一致的,只是发起http请求当有不同的url来获取连接时,它是怎么复用的,不看源码会觉这一点会让人觉得抽象,难以理解,通过上面的分析其实就明了了。最后补充一点让大家更好理解是什么叫创建连接、连接是怎么保持的,通俗的说创建连接就是我们让操作系统针对目标url开辟一段内存空间,这段内存用于与目标服务器读写交互,保持连接就是这段内存空间一直不回收,直到超过与服务器约定的超时时间。