近期公司公网接口被频发攻击刷垃圾数据,某些不常用接口一晚上被刷了几十万次,此背景下接口项目频繁出现OOM的情况,主要表现如下图:
如图所示,每次fgc都无法回收内存,很明显项目代码中有内存泄漏的情况存在,只能重启项目临时救急。随后看近期代码变动记录也未发现明显问题,只得让运维协助导出内存dump来分析具体原因了。
分析问题前先明确下内存异常的概念:
内存溢出 out of memory,是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;
内存泄露 memory leak,是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。
memory leak最终会导致out of memory!
第一次排查(文中的jvisualvm用法,亲测可用)
第一次导出使用的命令是jmap -dump:live,format=b,file=/tmp/tmux-0/heap-dump.hprof 6829。没有选在内存占用最高的时候导出,所以文件只有1G多大小,这不会影响实例占用内存的比例情况因为对象占用是逐渐递增成比例的。不过这个命令有个问题就是导出的内存不是fullgc以后的内存情况,因为内存溢出的出现是fullgc后无法回收的那些对象有问题,当时心知这个命令会有问题但是当时文件已经导出不想太麻烦运维再导一次也就没有太在意,事实证明嫌麻烦的后果就是事情会变得更麻烦了。
-
拿到dump文件后使用jdk中自带的jvisualvm进行分析,单看内存占用情况如下:
-
内存中hashMap对象实例数最多且占用内存也是最多的,我们双击map对象查看详情,一般这种实例数较多的我们很容易就能找到问题对象,如下图所示,通过查看多个对象实例发现key是X509CertImpl的Map对象有很多,我们点击查看垃圾回收根结点查看
-
查看根结点后会把对象调用路径完整的展示出来,我们把根路径复制出来看看具体是哪些调用。如下图点击复制从根开始的路径
-
最后得到如下的一个完整路径,发现是阿里云 oss的引用,初步发现问题后就结合项目代码一起比对检查。
this - value: java.util.HashMap$Node #335002 <- [22] - class: java.util.HashMap$Node[], value: java.util.HashMap$Node #335002 <- table - class: java.util.HashMap, value: java.util.HashMap$Node[] #28817 <- map - class: java.util.HashSet, value: java.util.HashMap #32007 <- trustedCerts - class: sun.security.ssl.X509TrustManagerImpl, value: java.util.HashSet #12745 <- trustManager - class: org.apache.http.ssl.SSLContextBuilder$TrustManagerDelegate, value: sun.security.ssl.X509TrustManagerImpl #2346 <- tm - class: sun.security.ssl.AbstractTrustManagerWrapper, value: org.apache.http.ssl.SSLContextBuilder$TrustManagerDelegate #2357 <- trustManager - class: sun.security.ssl.SSLContextImpl$TLSContext, value: sun.security.ssl.AbstractTrustManagerWrapper #2358 <- context - class: sun.security.ssl.SSLSocketFactoryImpl, value: sun.security.ssl.SSLContextImpl$TLSContext #2343 <- socketfactory - class: org.apache.http.conn.ssl.SSLConnectionSocketFactory, value: sun.security.ssl.SSLSocketFactoryImpl #2363 <- val - class: java.util.concurrent.ConcurrentHashMap$Node, value: org.apache.http.conn.ssl.SSLConnectionSocketFactory #2345 <- [11] - class: java.util.concurrent.ConcurrentHashMap$Node[], value: java.util.concurrent.ConcurrentHashMap$Node #162146 <- table - class: java.util.concurrent.ConcurrentHashMap, value: java.util.concurrent.ConcurrentHashMap$Node[] #13097 <- map - class: org.apache.http.config.Registry, value: java.util.concurrent.ConcurrentHashMap #22642 <- socketFactoryRegistry - class: org.apache.http.impl.conn.DefaultHttpClientConnectionOperator, value: org.apache.http.config.Registry #7070 <- connectionOperator - class: org.apache.http.impl.conn.PoolingHttpClientConnectionManager, value: org.apache.http.impl.conn.DefaultHttpClientConnectionOperator #2345 <- [2073] - class: java.lang.Object[], value: org.apache.http.impl.conn.PoolingHttpClientConnectionManager #2342 <- elementData - class: java.util.ArrayList, value: java.lang.Object[] #156896 <- connectionManagers - class: com.aliyun.oss.common.comm.IdleConnectionReaper, value: java.util.ArrayList #74889 <- <class> (thread object) - class: com.aliyun.oss.common.comm.IdleConnectionReaper, value: com.aliyun.oss.common.comm.IdleConnectionReaper class IdleConnectionReaper
- 排查代码发现项目中上传头像的功能使用到了oss,代码如下
public static boolean fileUploadToOSS(InputStream uploadFilePath, String objectKey){ URL url = null; // 使用默认的OSS服务器地址创建OSSClient对象,不叫OSS_ENDPOINT代表使用杭州节点,青岛节点要加上不然包异常 OSSClient client = new OSSClient(OSSConfigure.getInstance().getOssEndPoint() , OSSConfigure.getInstance().getAccessId(), OSSConfigure.getInstance().getAccessKey()); try { ObjectMetadata objectMeta = new ObjectMetadata(); objectMeta.setCacheControl("no-cache"); objectMeta.setHeader("Pragma", "no-cache"); objectMeta.setContentEncoding("utf-8"); objectMeta.setContentType("image/png"); client.putObject(bucketName, Objectkey, file, objectMeta); return true; } catch (FileNotFoundException e) { log.error("文件图片OSS上传失败!", e); } return false; }
- 代码中发现每次上传头像都会new一个新的OssClient,而且图片上传完之后还没有手动关闭链接,并且图片流也没有进行关闭,另一个关键点是导出这个dump那段时间上传头像的接口正在被刷,OssClient的对象创建了很多并且没有关闭,种种迹象告诉我这肯定是问题的根源。
正确的使用方式参考:https://bbs.aliyun.com/simple/t257085.html,
其他人也有同样的问题:https://blog.csdn.net/ashur619/article/details/82835662,这个情况和我们极相似。
第二次排查
-
因为之前使用的jmap命令有问题这次加个histo:live参数,完整命令jmap -dump:live,format=b,file=/tmp/tmux-0/heap-dump.hprof 6829,这个命令会执行一次fgc,并导出gc后的内存情况,并且下载了mat来更清晰的解析dump文件。首次打开文件mat会生成自己的泄漏建议报告,我们可以打开看一下它提供的分析报告,选Leak Suspects Report点finish即可,分析后生成图表和问题报告,problem a几乎把1.6的内存快占满了,并且也准确指出了问题出现在哪
The memory is accumulated in one instance of com.alibaba.fastjson.util.IdentityHashMap$Entry[] -
点击details进去后查看具体情况,我们发现对象ParseConfig占用了93%的内存空间,而ParseConfig下引用了N多个fastjson自定义的IdentityHashMap
-
我们再看下内存空间中整体的对象实例情况,到overview页面点histogram查看实例直方图如下:
- ParserConfig只有一个实例 ,我们结合fastjson源码看下这个ParserConfig和IdentityHashMap的具体关系
-
ParseConfig是一个单例,IdentityHashMap是用来存放json对象反序列化器的,并且key是一个反射类Type类型,我们再看下为什么会存放这么多的反序列化器呢,因为原则上一种类型的对象(一种Type对象)只需要一个反序列化器就可以了。下面我们看下这个deserializers里都存了哪些对象,点击ParserConfig对象java Basics Open In Dominator Tree,查看依赖树
-
打开后如下,分析发现IdentityHashMap的key是一个gson的对象,而需要反序列化的类是我们项目中自定义的,我又打开了其他的IdentityHashMap发现里面的结构和反序列化对象都是一样的,同对象为什么会生成如此多个反序列化器呢,Map存了这么多对象说明map的key都是不一样的,因为map的key是不能重复的,IdentityHashMap同样也是,再查看了这些IdentityHashMap的key发现每个key的类型都是ParameterizedTypeImpl,但是内存地址值都是不一样的,说明key每次都是new的一个新ParameterizedTypeImpl对象。
-
再看了下IdentityHashMap的put方法,key是直接拿对象的hash值做运算并存放到buckets指定坐标上的,如此我们可以确定这跟key的生成是有关系的,我们结合上面查到的RespVipBaseDTO再看下key对象到底是怎么生成的,代码中JSON.parseObject()的时候会到ParserConfig中按key寻找对应的反序列化器,没有的话就会新创建一个新的。
RespBaseDTO<RecommendContentDTO> result = JSON.parseObject(resultString, new TypeToken<RespVipBaseDTO<RecommendContentDTO>>() {}.getType());
-
如上代码会用new TypeToken(){}.getType()当作key去查找合适的反序列化器,那我们看下这个key是怎么生成的。这里只贴出创建时的代码,如下代码,我们发现ParameterizedTypeImpl都是通过new创建的,说明每次使用TypeToken时都会创建一个新的,这也是为什么IdentityHashMap越来越多的根本原因。
if (type instanceof ParameterizedType) { ParameterizedType p = (ParameterizedType) type; return new ParameterizedTypeImpl(p.getOwnerType(), p.getRawType(), p.getActualTypeArguments()); }
-
其实原因还是开发人员在使用json时串用了不同厂商的json工具,TypeToken是谷歌gson的类,使用的JSONObject却是阿里fastjson的,正确的使用方法应该是使用TypeReference,TypeReference.getType()时同样会返回一个Type类型,但是这个Type类型是通过java反射获取到的类的基本信息,以此Type作为key是不会有这样的问题的:
BaseReqDTO<CommonReqDTO> baseReqDTO = parseRequest(request, new TypeReference<BaseReqDTO<CommonReqDTO>>() {}.getType());
-
可以简单写一些验证代码做个简单的验证,运行后发现使用TypeToken创建Type会造成程序运行缓慢并最终造成OOM异常,TypeToken换成TypeReference后没有此问题出现:
for(int i = 0;i < 1000000; i++){ JSON.parseObject("\"userId\":\"213\"", new TypeToken<RespVipBaseDTO<OqsChoicenessProducts>>() { }.getType()); MemoryMXBean memorymbean = ManagementFactory.getMemoryMXBean(); MemoryUsage usage = memorymbean.getHeapMemoryUsage(); if(i % 1000 == 0){ System.out.println("第:" + i + "次"); System.out.println("INIT HEAP: " + usage.getInit()); System.out.println("MAX HEAP: " + usage.getMax()); System.out.println("USE HEAP: " + usage.getUsed()); } if(i% 50000 == 0){ System.gc(); System.out.println("第:" + i + "次"); System.out.println("INIT HEAP: " + usage.getInit()); System.out.println("MAX HEAP: " + usage.getMax()); System.out.println("USE HEAP: " + usage.getUsed()); } }
总结:
-
发现问题可以先看代码最近是否有大的变动,有没有使用不合理的地方,结合系统最近情况作出判断,没发现明显问题就果断导dump文件开始做分析。
- 这次排查中看代码的时间要比看dump分析的时间多得多,要单从分析工具中找到具体问题是不可能的,或者是因为我还没掌握到更深的使用方法。
- 代码质量问题亟待解决