一、背景
首先,发现线上某分析应用出现异常,连续好几天,一直没有分析数据产出。故登陆到线上查看error.log日志,发现:

二、排查历程
1、初步定位
jinfo
首先,我们使用jinfo pid查看当前jvm的堆相关参数:

jstat
接下来,我们使用命令jstat -gcutil pid 1s 5查看5秒内当前堆占用情况:

jmap
除了jstat命令外,我们也可以使用jmap -heap pid查看下当前JVM堆情况:

jmap -F -histo pid | head -n 13查看前13行打印,即查看TOP10的大对象(最好用head限制一下,否则列出的对象会铺满你的屏幕,另外:强制连接参数 -F对 jmap -histo:live是无效的): 
java.util.HashMap和 java.util.ArrayList非常显眼……先记下,后面继续分析先。
最后,我们使用命令jmap -F -dump:file=a.bin pid将堆dump出来,发现dump出来的文件有4.02G,很恐怖,故使用tar -czvf a.tar.gz a.bin打包压缩一下!
2、MAT深入分析
调整MAT最大堆内存
将打包好的a.tar.gz拉回到本地,并解压。但是由于a.bin过大,MAT打开肯定会内存溢出,故调整MAT软件的最大堆内存:
[ MAT根目录下的MemoryAnalyzer.ini ]
-startup
plugins/org.eclipse.equinox.launcher_1.5.0.v20180512-1130.jar
--launcher.library
plugins/org.eclipse.equinox.launcher.win32.win32.x86_64_1.1.700.v20180518-1200
-vmargs
-Xmx6g
-Xmx 改为6G!
MAT分析大对象

java.lang.Object[]的 Details,如下图: 
jmap -histo pid | head -n 13一个异常 ArrayList的情况,同时它内部也正是由 HashMap构成! 
代码走读排查
结合error.log日志报出的问题JAVA类、报错代码行数,再结合问题应该出在一个ArrayList上,很容易就定位到了相关问题代码块:
/**
* 按照指定起止时间分析数据
* @param beginTime 起始时间
* @param endTime 截至时间
*/
@Override public void exec(String beginTime, String endTime) {
List<Map<String, Object>> emlWithLoginList = new ArrayList<>();
List<Map<String, Object>> emlList = new ArrayList<>();
while (true) {
try {
//如果已分析到截至时间,则退出。
if (DateUtil.compareTime(beginTime, endTime) > 0) {
break;
}
//每次循环向前推10小时,YCYX_PERIOD=10小时
String tmpTime=DateUtil.addHours(beginTime, YCYX_PERIOD);
//1.构造请求
BoolQueryBuilder bqb = QueryBuilders.boolQuery();
bqb.must(QueryBuilders.rangeQuery(CREATE_TIME).gt(beginTime).lte(tmpTime));
bqb.must(QueryBuilders.termQuery(IS_DELETE, IS_FIELD_VALUE));
bqb.must(QueryBuilders.existsQuery(TOS));
bqb.must(QueryBuilders.existsQuery(FROMS));
bqb.must(QueryBuilders.existsQuery(SEND_TYPE));
bqb.must(QueryBuilders.existsQuery(SESSION_ID));
log.debug("emlAnalysis begin at " + beginTime + ", and end at " + lastTime);
SearchSourceBuilder requestBuilder = new SearchSourceBuilder().query(bqb).size(PAGE_SIZE).sort(CREATE_TIME, SortOrder.ASC).fetchSource(new String[] {"*"},
new String[] {EML_CONTENT});
//2.发起请求
EsHelper.getResponseScroll(EsCluster.DEFAULT, INF_EML_INDEX, "14m", requestBuilder.toString(), result -> {
//3.解析结果
EsSearchHit[] hits = result.getHits().getHits();
List<Map<String, Object>> loginList;
for (EsSearchHit hit : hits) {
//将邮件Map添加到列表中
Map<String, Object> emlMap = hit.getSource();
emlList.add(emlMap);
//将邮件+MailLogin的Map添加到另一个列表中
Map<String, Object> emlWithLoginMap = new HashMap(emlMap);
String sessionId = emlMap.get(SESSION_ID).toString();
MailLogin mailLogin = EsService.getMailLoginBySessionId(sessionId);
emlWithLoginMap.put("Login_Key", mailLogin );
emlWithLoginList.add(emlWithLoginMap);
}
return true;
});
//4.A类检测逻辑
checkDSYJEml(emlWithLoginList);
emlWithLoginList.clear();
//5.B类检测逻辑
checkYCYXEml(emlList);
emlList.clear();
beginTime = tmpTime;
} catch (Throwable e) {
log.error("", e);
}
}
}
由上面代码可见,问题List应该就是 emlWithLoginList 或者 emlList的其中一个了,而两个List存储的内容基本相同,除了emlWithLoginList内的Map元素额外存了一个叫做Login_Key的key!
而通过MAT查看了问题List内Map元素的所有Key,并没有找到相关叫做Login_Key的元素,故推测问题List应该是这个emlList!

因此我们先猜测,是否是因为当前时间段的ES数据过大导致?
代码里面可以看到,ES查询的数据,是通过CREATE_TIME(该常量值为@createtime)进行升序查询的,故先查的数据,应该位于这个问题List的开头,而后查的数据在问题List的结尾。
因此通过MAT找到问题List的第一个和最后一个元素,即得到本次查询的起、止时间:

图上说明,查询这一批数据,程序得到了31万条数据!
而我又到Kibana查询了一下这个时间段的数据量:

一下子就迷了,这个情况有点说不通,想了半天,于是,又仔细地看了下代码:

clear()方法清空List,为何 emlWithLoginList没有问题,而 emlList却内存溢出了?
那么就很显然了:
1、 假如在执行checkYCYXEml(emlList)时,出现了异常,就会直接被下面的catch给捕获;
2、 因而不会走下面的emlList.clear()代码,同时也不会走beginTime = timeTime;
3、 由于try catch位于while循环内部,因此抛出异常后会继续循环,且因为没有执行beginTime = timeTime,故查询的数据还是之前的这个时间段的数据;
4、 同时,也可以解释为什么emlWithLoginList没有问题了,因为在异常代码的前面,可以进行正常的clear()操作。
那么,如果是checkYCYXEml(emlList)时,出现了异常,error.log应该是有异常日志打印的,通过关键字checkYCYXEml搜了一下:

按照上面说的,如果是这个原因,这段代码会重复查询2019-11-01 16:00:00到2019-11-02 02:00:00的数据,且不断加入到emlList中,最后撑爆JVM!
那么MAT中的这个问题List应该会有多个相同元素存在(数据重复加入进去了嘛)。
如何验证这一点呢?
因为这些数据有一个emlkey字段,是一个唯一主键,对应这条记录在ES中的_id,因此可通过MAT,根据某一条数据的emlkey,去查找是否问题emlList中有多个元素均存在相同的emlkey,即可证明。
MAT对字符串进行分组


-XX:+UseStringDeduplication功能,来减轻重复字符串的问题。这将导致这几十万个String实例,但其底层的数组均指向同一个char数组。
图上即是对针对String进行的分组操作,这时,我们随便找一个元素的emlkey,查询一下:

merge shortest paths to gc roots功能,可查看这些对象到GC ROOT的最短路径,说白了,就是想通过这个功能,查看当前这个String是属于哪个对象下面的: 
854742740486326718e
emlkey属性,还有一个 document_id属性,这两个值是一样的,均是表示ES的 _id,如图: 
最后这个问题,基本就定位到了,修复也就简单了,把两个Clear()方法,都移到catch后面的finally中,即可保证100%会调用,另外就是对接中间件的同事,针对IceTimeOut问题的解决了。


