你好!我是miniluo,今天和你分享使用HttpClient过程中,未考虑释放连接和并发导致的坑。
HttpClient在项目中还是比较常见的,主要都是通过GET或POST请求第三方以获取响应结果。前段时间还了解到也有企业用它来做爬虫。下面我们就从两方面来一起学习HttpClient。
强占着不放
我们先来看一张因未释放连接导致的异常图。
我们再来看代码,代码很简单,用一个static修饰HttpClient的一个对象,也是用static的方式实例化(并发下,static实例化的对象是线程不安全的)。
1@Slf4j
2public class MyHttpClientTest{
3 private static HttpClient client;
4 static {
5 RequestConfig requestConfig = RequestConfig.custom()
6 .setConnectTimeout(5000)
7 .setConnectionRequestTimeout(3000)
8 .setSocketTimeout(5000).build();
9 client = HttpClientBuilder.create().setDefaultRequestConfig(requestConfig)
10 .build();
11 }
12
13 @Test
14 public void testHttpClient(){
15 for (int i = 0; i
16 String url = "http://127.0.0.1:9092/learn/product/get/12";
17 HttpGet httpGet = new HttpGet(url);
18 try {
19 HttpResponse res = client.execute(httpGet);
20 } catch (IOException e) {
21 log.error("异常: ",e);
22 return;
23 }
24 }
25 }
26}
我们看回异常图,图中有2个红框,我们先来看第一个红框的“[total kept alive: 0; route allocated: 2 of2; total allocated: 2 of 20]”,这个DEBUG级别日志描述的什么意思呢?也就是说这个route下共有2个连接,已用2个;pool共有20个,已用2个。再来看第二个红框“Timeout waiting for connection from pool”,从连接池获取连接等待超时。奇怪吧,这顺序执行也会出现?这其实是前两个连接响应结果回来后,并没有释放连接资源,导致后面的请求等待超时。
我们看看PoolingHttpClientConnectionManager类的构造函数,其给Pool初始化时给maxConnPerRoute和maxConnTotal设置了默认值。
1 public PoolingHttpClientConnectionManager(
2 final HttpClientConnectionOperator httpClientConnectionOperator,
3 final HttpConnectionFactory connFactory,
4 final long timeToLive, final TimeUnit tunit){
5 super();
6 this.configData = new ConfigData();
7 //defaultMaxPerRoute默认为2,maxTotal默认为20
8 this.pool = new CPool(new InternalConnectionFactory(
9 this.configData, connFactory), 2, 20, timeToLive, tunit);
10 this.pool.setValidateAfterInactivity(2000);
11 this.connectionOperator = Args.notNull(httpClientConnectionOperator, "HttpClientConnectionOperator");
12 this.isShutDown = new AtomicBoolean(false);
13 }
所以当请求连接被占用2个后,后面的请求并不会获取到连接,这么说,我们需要释放连接资源。这里我们需要介绍EntityUtils这个类,里面包含了两个释放连接资源的方法consumeQuietly(HttpEntity entity)和consume(HttpEntity entity),前者内部也是请求后者完成关闭InputStream。调整后的代码如下:
1@Test
2 public void testHttpClient(){
3 for (int i = 0; i
4 String url = "http://127.0.0.1:9092/learn/product/get/12";
5 HttpGet httpGet = new HttpGet(url);
6 HttpResponse res = null;
7 try {
8 res = client.execute(httpGet);
9 } catch (IOException e) {
10 log.error("异常: ", e);
11 return;
12 } finally {
13 if (null != res) {
14 EntityUtils.consumeQuietly(res.getEntity());
15 }
16 }
17 }
18 }
调整完代码后,我们执行后发现5个请求都能正确请求并得到响应结果。或许有同学会问到如果我调整maxConnPerRoute和maxConnTotal不也行吗?可以,但是你的连接还是没有释放,当超过你设置值后也会出现无法获得连接池的问题。
被CloseableHttpClient名字所坑
最近项目中就是使用到CloseableHttpClient实现请求第三方,踩完上面的坑后,我们在项目中写了一个工厂类内部用枚举方式实现单例。经过并发测试后,的确没有出现上述坑,看了CloseableHttpClient有一个execute方法,最后也有释放资源,所以没有在意。直到今天写文章对应的测试案例我才发现我被这个方法欺骗了。
CloseableHttpClient的execute()重载了多个方法,而实际调用能释放资源的execute必须是包含ResponseHandler extends T>这个属性,否则和原有HttpClient方式是一样的。为何,模拟高并发下并没有发生问题呢?这是因为项目中请求响应结果后是用下面的代码完成数据获取的。
1 HttpResponse response = client.execute(get);
2 String res = EntityUtils.toString(response.getEntity());
跟进EntityUtils.toString()方法的最底层调用,会执行InputStream关闭流,和上文说的consume方法一样。但是这里坑就坑在,如果请求第三方超时或其他异常,则直接跳过,并没有执行上面的toString()方法,那就不会释放连接资源。
知道坑所在,要解决也就好办。可以有下述两个方案:
1、外层调用增加finally释放连接,上文所述。
2、实例化一个ResponseHandler对象,调用实现了释放连接资源的方法。
总结
今天我们一起学习了HttpClient和CloseableHttpClient没有正确释放连接,以及并发受限的问题。实际项目中如果没有考虑到这两个情况,则会带来生产问题,后面的请求一直无法获取到连接资源,以及并发量起不来。很多项目并不会有这个问题是因为HttpClient和CloseableHttpClient并不是单例的,每次使用都会是新的实例。希望通过今天的学习对你日后使用HttpClient更加得心应手。由于篇幅问题,没有把CloseableHttpClient案例放上来,有兴趣的同学可以到GitHub上下载(https://github.com/littleluo/bj-share-java.git)。
思考和讨论
1、案例中,我们使用了static和枚举的方式解决单例问题,除了这两种方法外,还有哪些方式呢?他们各自的优缺点是什么?
2、说回前面关于分布式锁的文章中,我们用到了redis,也提到client.close()方式归还连接,为何是归还连接,而不是关闭连接呢?
欢迎留言与我分享和指正!也欢迎你把这篇文章分享给你的朋友或同事,一起交流。
感谢您的阅读,我们下节再见!
扫码关注我们,与君共进