【背景】
1.压测试算接口, 每分钟3600次请求
2.每1次试算需计算30个公式
3.通过监控,发现在上述条件下,30mins内老年代每分钟触发一次垃圾回收
(备注:老年代触发的 gc 是 full gc 会导致应用进程停顿 对性能的影响比较大)
【思路】
1.静态变量的生命周期会伴随jvm全程,我封装的工具类中,使用了很多静态方法,因对象对其有引用,是否导致堆内存的对象一直没有释放,一点点的内存泄漏,导致堆内存可用空间越来越小,最终内存溢出,触发full gc?
2.我的代码里是否有大对象?导致其存入堆内存后直接进入老年代?
看这内存配置也还行,给老年代预留的空间还是可以的:
-Xms2048m -Xmx2048m -Xmn512m -Xss256k -XX:PermSize=256m -XX:MaxPermSize=256m -XX:SurvivorRatio=8
【实践】
1.review 潜在问题的piece
结合我在第2篇aviator中的设计图,有如下几个点可能出问题的点:
1.每次试算从redis拉取一次公式集合"formulaMap",期间此Map要经过反射转换为ConcurrentHashMap且2个map我预设空间均为256。(ConcurrentHashMap对key和value要求同时都不能为null)
2.每次计算时,需要envMap做入参(obj转concurrentHashMap),且后续计算需要不断往进put新的计算中间值,初识大小128。
/**
* map转concurrentHashMap
*/
public static ConcurrentHashMap copyMaptoTsMap(Map paramsMap, ConcurrentHashMap resultMap) throws BusinessException{
isNull(paramsMap,"线程安全转换,formulaMap入参为空");
if (resultMap == null) {
resultMap = new ConcurrentHashMap(256);
}
Iterator it = paramsMap.entrySet().iterator();
while (it.hasNext()) {
Map.Entry entry = (Map.Entry) it.next();
Object key = entry.getKey();
resultMap.put(key, paramsMap.get(key) != null ? paramsMap.get(key) : "");
}
return resultMap;
}
/**
* obj转concurrentHashmap
*/
public static ConcurrentHashMap beanToTsMap(Object obj) throws BusinessException{
if(obj == null){
return null;
}
ConcurrentHashMap map = new ConcurrentHashMap(128);
try {
Field[] declaredFields = obj.getClass().getDeclaredFields();
for (Field field : declaredFields) {
field.setAccessible(true);
if(null == field || null == field.getName() || null == field.get(obj)){
continue;
}
map.put(field.getName(), field.get(obj));
}
if(map.containsKey("serialVersionUID")){
map.remove("serialVersionUID");
}
} catch (Exception e) {
throw new BusinessException("表达式引擎,bean转concurrentHashmap异常!");
}
return map;
}
3.每次试算无论何种资金来源,都要连续计算共30个公式,做30次表达式compile编译(官方推荐、性能更高)、execute执行操作。
4.期间封装多为静态方法,但是没有用到静态变量。
2.控制变量监测内存
针对上述4个点,分别避开做实验,比如从redis并发拉去公式,改为实例化map;每次3个资金来源的公式,我改为1种资金,并缩小envMap容量;静态工具改为注入,非静态方法等。然而依旧每分钟1次full gc发生。
之后思考是否aviator连续计算30个公式导致的,毕竟1分钟3600次调用,就要执行10800次表达式引擎的编译和执行,修改每次只计算贷款金额,发现并未出现老年代的垃圾回收。大致问题定位在aviator框架比较吃内存。
但之后,对照pom,aviator用的是最推荐的新版本,同时先compile后execute的方式也是官方最推荐的,那就奇了怪了,难道aviator的性能并没有官方推荐那么好吗?
3.jmap分析
此处推荐一篇文章:https://blog.csdn.net/kevin_luan/article/details/8447896 通过jmapdump分析dump文件,最好结合MemoryAnalyzer工具来查看可视化界面。
(在家用的笔记本,就不截图了)
通过jmap在测试服务器上进行分析,每经历1000次试算,发现2个现象:
1.永久带内存会增加40mb的占用,且不会释放。
2.堆内存占用整体容量并未明显增长,且这个过程中新生代垃圾回收很频繁。
此处更加说明了,我的业务代码不是导致频繁老年代full gc的原因。
4.aviator官方查询issue
带着上述的问题,在此查看aviator官方内容,发现前人提过类似的issue:
经过在官方github一通儿乱点,发现点儿门道:
“每个表达式都会生成一个匿名类(java lambda 对应的匿名类),因此如果你的表达式都是动态生成的,那么可能会生成大量的匿名类,占满 metaspace 就会触发 full gc。”, 原来坑在这里终于找到了。
备注:metaspece和老年代
在jdk1.8中,永久代已经不存在,存储的类信息、编译后的代码数据等已经移动到了元空间(MetaSpace)中,元空间并没有处于堆内存上,而是直接占用的本地内存(NativeMemory)。
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
【方案】
aviator通常采取对方式是直接执行表达式, 事实上 Aviator 背后都帮你做了编译并执行的工作。当然你可以自己先编译表达式, 返回一个编译的结果, 然后传入不同的env
来复用编译结果, 提高性能, 这是更推荐的使用方式。示例:
public class TestAviator {
public static void main(String[] args) {
String expression = "a-(b-c)>100";
// 编译表达式
Expression compiledExp = AviatorEvaluator.compile(expression);
Map env = new HashMap();
env.put("a", 100.3);
env.put("b", 45);
env.put("c", -199.100);
// 执行表达式
Boolean result = (Boolean) compiledExp.execute(env);
System.out.println(result); // false
}
}
由于每分钟有3600*30个编译结果需要程序自己存这些匿名类,所以官方说了如果频繁调用建议缓存编译结果:
public static Expression compile(String expression, boolean cached)
将cached
设置为true
即可, 那么下次编译同一个表达式的时候将直接返回上一次编译的结果。 我在第二篇中介绍了之前的设计方案,其中将编译后的表达式缓存到内存中,通过id来检索,问题在于分布式部署,修改公式时,nginx只能打到一台机器,官方的做法则是直接缓存表达式本身,不通过id来查找,当时怎么就没想到呢。。。。
That's all.
2019.04.05 (清明节)