摘要:本文属于原创,欢迎转载,转载请保留出处:https://github.com/jasonGeng88/blogjava
git 地址:https://github.com/jasonGeng88/java-network-programmingreact
前不久,上线了一个新项目,这个项目是一个压测系统,能够简单的看作经过回放词表(http请求数据),不断地向服务发送请求,以达到压测服务的目的。在测试过程当中,一切还算顺利,修复了几个小bug后,就上线了。在上线后给到第一个业务方使用时,就发现来一个严重的问题,应用大概跑了10多分钟,就收到了大量的 Full GC 的告警。git
针对这一问题,咱们首先和业务方确认了压测的场景内容,回放的词表数量大概是10万条,回放的速率单机在 100qps 左右,按照咱们以前的预估,这远远低于单机能承受的极限。按道理是不会产生内存问题的。github
首先,咱们须要在服务器上进行排查。经过 JDK 自带的 jmap 工具,查看一下 JAVA 应用中具体存在了哪些对象,以及其实例数和所占大小。具体命令以下:shell
jmap -histo:live `pid of java` # 为了便于观察,仍是将输出写入文件 jmap -histo:live `pid of java` > /tmp/jmap00
通过观察,确实发现有对象被实例化了20多万,根据业务逻辑,实例化最多的也就是词表,那也就10多万,怎么会有20多万呢,咱们在代码中也没有找到对此有显示声明实例化的地方。至此,咱们须要对 dump 内存,在离线进行进一步分析,dump 命令以下:apache
jmap -dump:format=b,file=heap.dump `pid of java`
从服务器上下载了 dump 的 heap.dump 后,咱们须要经过工具进行深刻的分析。这里推荐的工具备 mat、visualVM。缓存
我我的比较喜欢使用 visualVM 进行分析,它除了能够分析离线的 dump 文件,还能够与 IDEA 进行集成,经过 IDEA 启动应用,进行实时的分析应用的CPU、内存以及GC状况(GC状况,须要在visualVM中安装visual GC 插件)。工具具体展现以下(这里仅仅为了展现效果,数据不是真的):服务器
固然,mat 也是很是好用的工具,它能帮咱们快速的定位到内存泄露的地方,便于咱们排查。
展现以下:异步
通过分析,最后咱们定位到是使用 httpasyncclient 产生的内存泄露问题。httpasyncclient 是 Apache 提供的一个 HTTP 的工具包,主要提供了 reactor 的 io 非阻塞模型,实现了异步发送 http 请求的功能。async
下面经过一个 Demo,来简单讲下具体内存泄露的缘由。
<dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpasyncclient</artifactId> <version>4.1.3</version> </dependency>
public class HttpAsyncClient { private CloseableHttpAsyncClient httpclient; public HttpAsyncClient() { httpclient = HttpAsyncClients.createDefault(); httpclient.start(); } public void execute(HttpUriRequest request, FutureCallback<HttpResponse> callback){ httpclient.execute(request, callback); } public void close() throws IOException { httpclient.close(); } }
Demo 的主要逻辑是这样的,首先建立一个缓存列表,用来保存须要发送的请求数据。而后,经过循环的方式从缓存列表中取出须要发送的请求,将其交由 httpasyncclient 客户端进行发送。
具体代码以下:
public class ReplayApplication { public static void main(String[] args) throws InterruptedException { //建立有内存泄露的回放客户端 ReplayWithProblem replay1 = new ReplayWithProblem(); //加载一万条请求数据放入缓存 List<HttpUriRequest> cache1 = replay1.loadMockRequest(10000); //开始循环回放 replay1.start(cache1); } }
这里以回放百度为例,建立10000条mock数据放入缓存列表。回放时,以 while 循环每100ms 发送一个请求出去。具体代码以下:
public class ReplayWithProblem { public List<HttpUriRequest> loadMockRequest(int n){ List<HttpUriRequest> cache = new ArrayList<HttpUriRequest>(n); for (int i = 0; i < n; i++) { HttpGet request = new HttpGet("http://www.baidu.com?a="+i); cache.add(request); } return cache; } public void start(List<HttpUriRequest> cache) throws InterruptedException { HttpAsyncClient httpClient = new HttpAsyncClient(); int i = 0; while (true){ final HttpUriRequest request = cache.get(i%cache.size()); httpClient.execute(request, new FutureCallback<HttpResponse>() { public void completed(final HttpResponse response) { System.out.println(request.getRequestLine() + "->" + response.getStatusLine()); } public void failed(final Exception ex) { System.out.println(request.getRequestLine() + "->" + ex); } public void cancelled() { System.out.println(request.getRequestLine() + " cancelled"); } }); i++; Thread.sleep(100); } } }
启动 ReplayApplication 应用(IDEA 中安装 VisualVM Launcher后,能够直接启动visualvm),经过 visualVM 进行观察。
说明:$0表明的是对象自己,$1表明的是该对象中的第一个内部类。因此ReplayWithProblem$1: 表明的是ReplayWithProblem类中FutureCallback的回调类。
从中,咱们能够发现 FutureCallback 类会被不断的建立。由于每次异步发送 http 请求,都是经过建立一个回调类来接收结果,逻辑上看上去也正常。不急,咱们接着往下看。
从图中看出,内存的 old 在不断的增加,这就不对了。内存中维持的应该只有缓存列表的http请求体,如今在不断的增加,就有说明了不断的有对象进入old区,结合上面内存对象的状况,说明了 FutureCallback 对象没有被及时的回收。
但是该回调匿名类在 http 回调结束后,引用关系就没了,在下一次 GC 理应被回收才对。咱们经过对 httpasyncclient 发送请求的源码进行跟踪了一下后发现,其内部实现是将回调类塞入到了http的请求类中,而请求类是放在在缓存队列中,因此致使回调类的引用关系没有解除,大量的回调类晋升到了old区,最终致使 Full GC 产生。
找到问题的缘由,咱们如今来优化代码,验证咱们的结论。由于List<HttpUriRequest> cache1
中会保存回调对象,因此咱们不能缓存请求类,只能缓存基本数据,在使用时进行动态的生成,来保证回调对象的及时回收。
代码以下:
public class ReplayApplication { public static void main(String[] args) throws InterruptedException { ReplayWithoutProblem replay2 = new ReplayWithoutProblem(); List<String> cache2 = replay2.loadMockRequest(10000); replay2.start(cache2); } }
public class ReplayWithoutProblem { public List<String> loadMockRequest(int n){ List<String> cache = new ArrayList<String>(n); for (int i = 0; i < n; i++) { cache.add("http://www.baidu.com?a="+i); } return cache; } public void start(List<String> cache) throws InterruptedException { HttpAsyncClient httpClient = new HttpAsyncClient(); int i = 0; while (true){ String url = cache.get(i%cache.size()); final HttpGet request = new HttpGet(url); httpClient.execute(request, new FutureCallback<HttpResponse>() { public void completed(final HttpResponse response) { System.out.println(request.getRequestLine() + "->" + response.getStatusLine()); } public void failed(final Exception ex) { System.out.println(request.getRequestLine() + "->" + ex); } public void cancelled() { System.out.println(request.getRequestLine() + " cancelled"); } }); i++; Thread.sleep(100); } } }
从图中,能够证实咱们得出的结论是正确的。回调类在 Eden 区就会被及时的回收掉。old 区也没有持续的增加状况了。这一次的内存泄露问题算是解决了。
关于内存泄露问题在第一次排查时,每每是有点不知所措的。咱们须要有正确的方法和手段,配上好用的工具,这样在解决问题时,才能游刃有余。固然对JAVA内存的基础知识也是必不可少的,这时你定位问题的关键,否则就算工具告诉你这块有错,你也不能定位缘由。
最后,关于 httpasyncclient 的使用,工具自己是没有问题的。只是咱们得了解它的使用场景,每每产生问题多的,都是使用的不当形成的。因此,在使用工具时,对于它的了解程度,每每决定了出现 bug 的机率。