共计 12166 个字符,预计需要花费 31 分钟才能阅读完成。
先点赞再看,养成好习惯
背景
公司某个规定引擎零碎,次要用来做一些费用计算和业务逻辑核验的性能。不过因为一些 不可形容的历史起因 ,导致该零碎 没有文档也没有源码,就连配置形式都是靠口口相传。
尽管这个零碎比拟老,但毕竟是商业产品,性能上还是比较完善好用的。该零碎在接入业务零碎的算费和核验规定后,很快就上线了。
不过上线后,会偶发的呈现服务耗时过长的问题。失常状况下,该服务响应耗时也就 20ms 以下。但呈现问题时,服务的耗时会 减少到几十秒,每次呈现长耗时的工夫点也不固定。而且在长耗时期间,所有达到该服务的申请都会呈现长耗时,并不只是个别申请才会受影响。
实践上这种问题测试之类的环境应该也有;但因为测试环境重启很频繁,偶发的长耗时可能认为是在重启,就始终没人注意。
尽管啥材料都没有,但问题还是得解决啊,时不时的几十秒耗时谁能顶得住。对接这规定引擎的还是业务的外围零碎,超时一分钟得少挣多少保费……
抱着试试看的心态,先钻研一下。
排查通过
这种偶发长耗时的问题,排查起来是比拟麻烦的。无奈稳固复现,也没有法则,就算弄个其余环境模拟也不是很好办;再加上这个零碎没文档没源码,更没人懂它的构造和流程,查起来就更吃力了。
看监控
在查看了监控历史之后,发现每次呈现长耗时的时候,CPU & 磁盘 IO 的利用率会升高,但也没高的很离谱。CPU 高的时候不过也才 五六十,而磁盘就更低了,只是略微有一点稳定而已,这点稳定会带来几十秒的耗时,可有点说不过去。
看日志
CPU 那块只是略微有点高,但也不能阐明什么问题,还是得找到根本原因。于是我又翻了下那个零碎的日志,看看能不能找到什么线索。
卡工夫,看日志,折腾了半小时,终于在日志上看到一个要害线索:
2021-05-08 10:12:35.897 INFO [com. 马赛克.rules.res.execution] (default-threads - 65) 规定集 /ansNcreckonRuleApp/1.0/ansncreckonrule/61.0 已在 58 秒后解析结束。
58 秒……这行日志的工夫点,和咱们理论产生耗时的工夫点是能够匹配上的,在这个 58 秒范畴内,的确有大量的申请耗时,而且都是小于 58 秒或略大于 58 秒的。
除了这个耗时的打印之外,GC 日志也有点可疑。每次呈现这个耗时日志之前,都会有一次 GC 流动日志,不过暂停工夫并不长:
2021-06-07T17:36:44.755+0800: [GC2021-06-07T17:36:44.756+0800: [DefNew: 879616K->17034K(3531079K), 0.0472190 secs] 3549012K->2686430K(10467840K), 0.0473160 secs] [Times: user=0.05 sys=0.00, real=0.2 secs]
更奇怪的是……不是每次 GC 之后,都会有耗时日志
GC 问题
认真查看 gc 日志后发现,每次慢响应之前不久,都会有一次 GC,不过不是 FULL GC,而且每次暂停工夫也不长,远不迭申请的耗时工夫。而且 GC 过程中的 CPU 利用率并不高,从数据上看还是比拟失常的。
好当初有(一丁点)线索了:
- 长耗时期间,CPU 利用率有减少
- 长耗时期间有日志打印,在加载某个货色,时长是能够匹配的
-
每次长耗时之前,有 GC 流动
猜想可能的起因
毕竟没有源码,也没人懂,想手撕代码都没机会,只能靠猜了……
从以上几个线索来看,GC 流动之后呈现 CPU 利用率减少,而后打印了一行加载日志,工夫还能够和该零碎的长耗时申请对应上。
而且每次长耗时日志之前不久的中央,都会有一次 GC 流动,那么阐明这个资源加载的机会和 GC 有关系,GC 会影响资源加载……
想到这里,也大略猜到起因了。很可能是用弱援用 (Weak Reference) 来保护了这个资源缓存,当 GC 后弱援用的资源被回收,所以须要从新加载(弱援用的具体解释以及测试后果能够参考《Java 中的强援用 / 软援用 / 弱援用 / 虚援用以及 GC 策略》)。
那既然可能是这个起因,如果我找到这个弱援用保护资源缓存的中央,给他改成强援用就能解决问题了!尽管没有源码,但还是能够反编译啊,反编译之后改一下缓存那块的代码,问题不大。
定位资源加载点
终于找到了可能的起因,可是有一个问题……我怎么晓得这行耗时日志在哪打印的?在哪个类里?我连这个服务是用的什么 Web 容器都不晓得。
没方法,上 Arthas 吧,不过必定不能在生产环境间接测。于是我又新整了一套长期测试环境,用于排查这个问题。
幸好日志里有个 loggerName 前缀com. 马赛克.rules.res.execution
,通过 Arthas 的 trace
性能,能够用通配符的模式来 trace
这个包名下的所有类:
# trace 该包名下的所有类,所有办法,只显示耗时大于 1000ms 的办法
# 因为这个输入后果可能会比拟多,所以 > 输入到文件,并且后盾运行
trace com. 马赛克.rules.res.* * '#cost > 1000' > /app/slow_trace.log &
trace
命令执行了十几秒才返回,一共影响了 169 个类和 1617 个办法,可见通配符匹配多危险……要是生产这样玩我可能会被拉出去祭天。
联合后面形容的状况,GC 后会有这个耗时问题,那当初来手动触发一次 GC,
利用 Arthas 的 vmtool
能够间接 forceGC(利用 jmap 或者其余的伎俩也能够):
vmtool --action forceGc
forceGC 后,从新测试该零碎接口
和下面的状况一样,果然又呈现了长耗时,申请返回后打印了雷同的耗时日志,只是和生产环境的相比工夫更长了(因为我用了 arthas trace 加强)
2020-04-08 12:30:35.897 INFO [com. 马赛克.rules.res.execution] (default-threads - 65) 规定集 /ansNcreckonRuleApp/1.0/ansncreckonrule/61.0 已在 70 秒后解析结束。
同时 Arthas trace
日志写入的那个文件中,也有了内容(链路切实太长,没法贴代码,只能截图了):
这个 trace
日志一千多行,此处删减了局部内容。
Arthas 的 trace
命令不会进行全链路跟踪,所以这里的 一次 trace 后果 只是以后门路。但曾经不错了,问题范畴又再一次放大。
从上图能够看到 IlrRulesetProvider:getRuleset
这个办法是次要耗时点,那就先来看看这个办法里是个什么玩法。
为了简略,这里长期用 Arthas 的 jad
命令,间接反编译这个类,看看外面的逻辑:
jad ilog.rules.res.xu.ruleset.internal.IlrRulesetProvider
代码有点多,这里删减一些,只保留要害局部:
public final XURulesetImpl getRuleset(IlrXURulesetArchiveInformation archive, IlrXUContext xuCtx, boolean waitParsing, Listener listener) throws IlrRulesetArchiveInformationNotFoundException, IlrRulesetCreationException, IlrRulesetAlreadyParsingException, IlrRulesetCacheException, XUException {String canonicalPath = archive.getCanonicalPath().toString();
this.logger.finest("IlrRulesetProvider.getRuleset" + canonicalPath + " " + waitParsing);
if (!archive.getProperties().isShareable()) {return this.factory.createRuleset(archive, xuCtx);
} else {ClassLoader cl = archive.getXOMClassLoader();
XURulesetImpl ruleset;
while(true) {synchronized(this.parsingRulesets) {
// 字面意思是,从缓存中获取规定集,有的话间接 return 了
ruleset = (XURulesetImpl)this.getCache().getRuleset(canonicalPath, cl);
if (ruleset != null) {return ruleset;}
// 第一个加载的线程,将以后资源增加到 parsingRulesets 同时跳出 while
if (!this.parsingRulesets.contains(archive)) {this.parsingRulesets.add(archive);
break;
}
if (!waitParsing) {throw new IlrRulesetAlreadyParsingException("XU.ERROR.10406", (String[])null);
}
// 这里的 wait……应该是避免并发拜访,当其余线程也进入该代码块时 wait 期待第一个线程加载实现唤醒
try {this.parsingRulesets.wait();
} catch (InterruptedException var20) {throw new IlrRulesetCreationException("XU.ERROR.10009", (String[])null, var20);
}
}
}
if (!this.useWorkManager(archive)) {this.logger.finest("IlrRulesetProvider.getRuleset doesn't use the workmanager " + this.workManager, (Object[])null, xuCtx);
XURulesetImpl var9;
try {
// 创立资源
ruleset = this.factory.createRuleset(archive, xuCtx);
// 创立实现,增加到缓存
this.getCache().addRuleset(ruleset);
var9 = ruleset;
} finally {this.parsingStopped(archive);
}
return var9;
}
}
}
尽管删减了很多,但看代码还是有点不清晰,毕竟反编译的代码浏览烦扰有点大,这里简略解释下下面的代码逻辑:
- 先从缓存容器中获取资源
- 如果取不到就执行创立逻辑
- 创立实现,再次增加到缓存
- 在加载时若已有其余线程也执行加载,会被动 wait 期待第一个创立实现的线程唤醒
再联合咱们下面的猜想:
很可能是该零碎用弱援用 (Weak Reference) 来保护了这个资源缓存,当 GC 后该资源被回收,所以须要从新加载
那问题就在这个缓存容器上了!只有看看这个 cache 就能晓得,必定有弱援用的代码!
反编译找代码
jad
反编译在终端里看代码还是太折腾了,不如在 IDE 里直观,所以还是得把代码拉下来剖析,不然找个关联类都吃力。
这个零碎下有很多 Jar 包,得先找到这些相干的类在哪个 Jar 里,利用 Arthas 的 sc
命令,也非常简单:
sc 查看 JVM 已加载的类信息
sc -d ilog.rules.res.xu.ruleset.internal.IlrRulesetProvider
class-info ilog.rules.res.xu.ruleset.internal.IlrRulesetProvider
# 这里是咱们关怀的信息,该 class 所在的 jar
code-source /content/jrules-res-xu-JBOSS61EAP.rar/ra.jar
name ilog.rules.res.xu.ruleset.internal.IlrRulesetProvider
isInterface false
isEnum false
isAnonymousClass false
isArray false
isLocalClass false
isMemberClass false
isPrimitive false
isSynthetic false
simple-name IlrRulesetProvider
modifier final,public
annotation
interfaces com.ibm.rules.res.xu.ruleset.internal.RulesetParsingWork$Listener
super-class +-java.lang.Object
class-loader +-ModuleClassLoader for Module "deployment.jrules-res-xu-JBOSS61EAP.rar:main" from Service Module Loader
+-sun.misc.Launcher$AppClassLoader@2d74e4b3
+-sun.misc.Launcher$ExtClassLoader@5552bb15
classLoaderHash 2d022d73
Affect(row-cnt:1) cost in 79 ms.
找到这个 ra.jar 后,把这个 jar 拖到 IDE 里反编译,不过这个 class 还有些关联的 class 不在这个 ra.jar 中。还是同样的方法,找到关联的 class,而后 sc -d
找到所在的 jar 地位,复制到本地 ide 反编译
重复折腾了几次后,终于把有关联的 4 个 jar 包都弄回本地了,当初能够在 IDE 里开开心心的看代码了。
剖析缓存容器的机制
首先是下面那个 createCache
办法,通过剖析后得悉,cache 的实现类为 IlrRulesetCacheImpl
,这个类须要关怀的只有两个办法,getRuleset 和 addRuleset:
public void addRuleset(IlrXURuleset executableRuleset) {synchronized(this.syncObject) {
//...
this.entries.add(new IlrRulesetCacheEntry(executableRuleset, this.maxIdleTimeOutSupport));
//...
}
}
public IlrXURuleset getRuleset(String canonicalRulesetPath, ClassLoader xomClassLoader) {
// ...
List<IlrRulesetCacheEntry> cache = this.entries;
synchronized(this.syncObject) {Iterator iterator = cache.iterator();
while(iterator.hasNext()) {IlrRulesetCacheEntry entry = (IlrRulesetCacheEntry)iterator.next();
IlrXURuleset ruleset = (IlrXURuleset)entry.rulesetReference.get();
if (ruleset == null) {iterator.remove();
} else if (entry.canonicalRulesetPath.equals(canonicalRulesetPath) && (entry.xomClassLoader == xomClassLoader || entry.xomClassLoader != null && entry.xomClassLoader.getParent() == xomClassLoader)) {return ruleset;}
}
}
// ...
return null;
}
看完这两个办法之后,很显著了,entries
才是要害的数据存储汇合,看看它是怎么个玩法:
protected transient List<IlrRulesetCacheEntry> entries = new ArrayList();
居然只是个 ArrayList,持续看看 IlrRulesetCacheEntry
这个类:
public class IlrRulesetCacheEntry {
protected String canonicalRulesetPath = null;
protected ClassLoader xomClassLoader = null;
protected IlrReference<IlrXURuleset> rulesetReference = null;
public IlrRulesetCacheEntry(IlrXURuleset executableRuleset, boolean maxIdleTimeOutSupport) {this.canonicalRulesetPath = executableRuleset.getCanonicalRulesetPath();
this.xomClassLoader = executableRuleset.getXOMClassLoader();
long maxIdleTime = executableRuleset.getRulesetArchiveProperties().getMaxIdleTime();
// 留神这里是要害,依据 maxIdleTime 的值抉择强援用和弱援用
if (maxIdleTime != 0L && (!maxIdleTimeOutSupport || maxIdleTime == -1L)) {this.rulesetReference = new IlrWeakReference(executableRuleset);
} else {this.rulesetReference = new IlrStrongReference(executableRuleset);
}
}
}
代码曾经很直白了,依据 maxIdleTime 的不同应用不同的援用策略,不等于 0 就弱援用,等于 0 就强援用;不过还是得看下这俩 Reference 类的代码:
// 弱援用,继承 WeakReference
public class IlrWeakReference<T> extends WeakReference<T> implements IlrReference<T> {public IlrWeakReference(T t) {super(t);
}
}
// 强援用
public class IlrStrongReference<T> implements IlrReference<T> {
private T target;
IlrStrongReference(T target) {this.target = target;}
public T get() {return this.target;}
}
这俩类并没有什么特地的中央,和类名的意思雷同;IlrWeakReference 继承于 WeakReference,那就是弱援用,当产生 GC 时,援用的对象会被删除。
尽管找到了这个弱援用的中央,但还是须要验证一下,是不是真的应用了这个弱援用
验证是否应用了弱援用
这里应用 Arthas 的 vmtool
命令,来看看缓存中的实时对象:
watch – 办法执行数据观测
vmtool --action getInstances --className ilog.rules.res.xu.ruleset.cache.internal.IlrRulesetCacheImpl --express 'instances[0].entries.get(0)'
@IlrRulesetCacheEntry[canonicalRulesetPath=@String[/ansNcreckonRuleApp/1.0/ansncreckonrule/1.0],
xomClassLoader=@XOMClassLoader[com.ibm.rules.res.persistence.internal.XOMClassLoader@18794875],
# 这里能够看到,rulesetRef 的实例是 IlrWeakReference
rulesetReference=@IlrWeakReference[ilog.rules.res.xu.ruleset.cache.internal.IlrWeakReference@dbd2972],
]
从后果上看,石锤了就是弱援用。
但引起弱援用的毕竟是 maxIdleTime,还是须要找到 maxIdleTime 的源头……
寻找 maxIdleTime
在 IlrRulesetCacheEntry
的构造方法里能够看到,maxIdleTime 是从 IlrRulesetArchiveProperties
里获取的:
long maxIdleTime = executableRuleset.getRulesetArchiveProperties().getMaxIdleTime();
那就持续看看 IlrRulesetArchiveProperties
这个类:
public long getMaxIdleTime() {
// 从 properties 里获取 key 为 ruleset.maxIdleTime 的 value
String result = this.get("ruleset.maxIdleTime");
return result == null ? -1L : Long.valueOf(result);
}
public String get(Object key) {String result = (String)this.properties.get(key);
return result == null && this.defaults != null ? (String)this.defaults.get(key) : result;
}
getMaxIdleTime 返回的默认值是 -1,也就是说如果没配置这个 maxIdleTime 值,默认也会应用弱援用策略。
到目前为止,问题算是曾经准确的定位到了,弱援用的缓存策略导致被 GC 时资源缓存被清空,从新加载资源导致了长耗时。
可是这零碎没源码没文档,我上哪改这个 maxIdleTime 去……
不过去都来了,都曾经看到 IlrRulesetArchiveProperties 这个类了,不如先看看这个类里到底配置了哪些值,有没有 maxIdleTime
vmtool --action getInstances --className com.ibm.rules.res.xu.ruleset.internal.CRERulesetImpl --express 'instances[0].getRulesetArchiveProperties()'
@IlrRulesetArchivePropertiesImpl[@String[ruleset.engine]:@String[cre],
@String[ruleset.status]:@String[enabled],
@String[ruleset.bom.enabled]:@String[true],
@String[ruleset.managedxom.uris]:@String[resuri://ans-nc-xom.zip/54.0,resuri://ruleapp-core-model-1.5.2.jar/2.0],
]
从后果上能够看到,properties 里并没有 maxIdleTime 属性,和咱们下面的论断是能够匹配的。没有配置 maxIdleTime 属性,默认 -1,所以应用弱援用
寻找 maxIdleTime 的配置办法
反编译的代码尽管看不到正文,但从类名还是能够猜一下的,IlrRulesetArchiveProperties 这个类名应该是“规定集归档属性”的意思。
尽管我不懂这个零碎的业务规定,但有人懂啊!于是我找来了负责这个系统配置的老哥,找他给我解释了下这个零碎的各种概念。
老哥也很敌对,间接给我画了一张图
这个规定引擎零碎,有一个 App 的概念,就是 saas 平台里常说的那个利用的意思;每个 App 上面能够创立多个规定集,就是图上这个 RuleSet,每个 RuleSet 就是咱们的业务规定,比方费用计算公式或者逻辑核验规定,同时每个 RuleSet 还会记录多个每次变更的历史版本。
看到这张图,我也明确了个七七八八,下面定位的 IlrRulesetArchiveProperties 不就是规定集的属性?这个产品有个控制台,应该有这个属性配置的中央吧,不然弄这么个类干啥?规定集都是控制台创立的,那规定集属性应该也能够配置!
如我所料,控制台的规定集上果然有个属性的概念,而且还能够增加属性。
最重要的是,管制台上 ruleset.bom.enabled
这个属性,我下面用 arthas vmtool
看的时候也有,那就能够证实这里的配置,应该就是配置 IlrRulesetArchiveProperties 这个类的;只有是给这个规定集加上 maxIdleTime 是不是就能够应用强援用了?
而后我小心翼翼的点击了那个增加属性的按钮……
果然有 maxIdleTime 这个选项,抉择 maxIdleTime 之后,给它配置为 0。
验证后果
配置完了,还是重启验证一下是不是失效,谁晓得这个零碎支不反对热刷新呢,改变之后还是重启验证比拟稳当。
先验证该规定集的 properties:
vmtool --action getInstances --className com.ibm.rules.res.xu.ruleset.internal.CRERulesetImpl --express 'instances[0].getRulesetArchiveProperties()'
@IlrRulesetArchivePropertiesImpl[
# 新增配置
@String[ruleset.maxIdleTime]:@String[0],
@String[ruleset.engine]:@String[cre],
@String[ruleset.status]:@String[enabled],
@String[ruleset.bom.enabled]:@String[true],
@String[ruleset.managedxom.uris]:@String[resuri://ans-nc-xom.zip/54.0,resuri://ruleapp-core-model-1.5.2.jar/2.0],
]
从下面能够看到,咱们新增的配置,曾经失效了,规定集上曾经有了这个 maxIdleTime。
再来看看缓存里的援用,是不是曾经变成了强援用:
vmtool --action getInstances --className ilog.rules.res.xu.ruleset.cache.internal.IlrRulesetCacheImpl --express 'instances[0].entries.get(0).rulesetReference'
# 这里是强援用
@IlrStrongReference[target=@CRERulesetImpl[com.ibm.rules.res.xu.ruleset.internal.CRERulesetImpl@28160472],
]
在减少了 maxIdleTime 之后,规定集的缓存就变成了强援用,强援用下就不会再呈现因为 GC 被回收的状况了!
用 vmtool 来一遍 fullgc,试试看还会不会从新加载:
vmtool --action forceGc
执行了十几遍,也没出呈现长耗时问题,问题应该是解决了。
接着把这个配置同步到测试环境,跑了三天,我时不时还下来手动 forceGc 一下,没有再呈现过这个长耗时的问题了。
为什么不是每次 GC 后都会呈现耗时
本文结尾就提到,每次长耗时之前不久都会有一次 GC 流动,但并不是每次 GC 后都会有一次长耗时。
弱援用保护的对象,并不是说在 GC 时就会被清空;只是在 GC 时,如果弱援用的对象曾经没有其余援用了,才会被回收,比方上面这个例子里:
Map<String,Object> dataMap = new HashMap<>();
WeakReference ref = new WeakReference(dataMap);
System.gc();
System.out.println(ref.get());
dataMap = null;
System.gc();
System.out.println(ref.get());
//output
{}
null
第一次 gc 时,ref 里的数据不会被清空,而第二次 gc 前弱援用的数据,曾经没有其余任何援用了,此时会被清空。
联合这个零碎的问题来看,尽管规定集那里应用弱援用缓存,但如果在 GC 时,调用方还持有规定集对象没有开释,那么这个弱援用的规定集缓存也一样不会清空;所以才会呈现这个 不是每次 GC 都会导致从新加载,但每次从新加载却都是因为 GC 的问题
但我认为没有必要持续跟上来了,持有规定集对象的中央在哪,和这个耗时问题关系并不是很大;批改为强援用之后,就不会再有 GC 回收该对象的状况,那还在意谁持有干嘛呢(其实是我懒,没文档还没源码,找问题找的我头都要秃了)
写在前面
原本认为须要先反编译,而后批改代码从新打包能力搞定的问题,最初居然一行代码都没改就解决了,有点小惊喜……
不过这个规定引擎的设计思路还是挺好的,为了节俭内存,应用可配置的缓存策略,只是默认应用弱援用来保护规定集太激进了点。对于大多数服务端场景来说,不差它这点开释的内存,给多少用多少就行,开释了反而会引发更大的问题,从新加载导致的耗时才是最不能承受的。
原创不易,禁止未受权的转载。如果我的文章对您有帮忙,就请点赞 / 珍藏 / 关注激励反对一下吧❤❤❤❤❤❤