查找问题

先用jstack看看线程栈是否正常,确认正常后用jmap查看(因为线上用的OpenJDK,需要安装debuginfo包)堆中快照情况。jmap一些命令可能会造成JAVA进程挂起,特别是jmap -permstat会造成STW,程序无法响应。建议使用jmap命令应该与线上环境隔离才能用。

使用jmap -permstat发现大量dead状态的class对象,其中class为groovy/lang/GroovyClassLoader$InnerLoader。

class_loader    classes bytes   parent_loader   alive?  type

<bootstrap> 2801 17853536 null live <internal>
0x0000000781d20040 20 279464 0x000000077b328568 dead groovy/lang/GroovyClassLoader$InnerLoader@0x00000007e3f853a8
0x0000000793e8ad28 20 279464 0x000000077b328568 dead groovy/lang/GroovyClassLoader$InnerLoader@0x00000007e3f853a8
0x000000078e3106f8 20 279464 0x000000077b328568 dead groovy/lang/GroovyClassLoader$InnerLoader@0x00000007e3f853a8
0x000000077bf13df0 1 3072 0x0000000778325b10 dead sun/reflect/DelegatingClassLoader@0x00000007e005c4c8
0x000000079ed982d8 20 279464 0x000000077b328568 dead groovy/lang/GroovyClassLoader$InnerLoader@0x00000007e3f853a8
0x000000079d4954c0 20 279464 0x000000077b328568 dead groovy/lang/GroovyClassLoader$InnerLoader@0x00000007e3f853a8
0x000000077a3df5c8 1 3080 0x0000000778325b10 dead sun/reflect/DelegatingClassLoader@0x00000007e005c4c8
0x00000007ae218838 20 279464 0x000000077b328568 dead groovy/lang/GroovyClassLoader$InnerLoader@0x00000007e3f853a8
0x000000077a441f58 1 3072 0x0000000778325b10 dead sun/reflect/DelegatingClassLoader@0x00000007e005c4c8
0x000000078c6ea450 20 279464 0x000000077b328568 dead groovy/lang/GroovyClassLoader$InnerLoader@0x00000007e3f853a8
0x000000077a3f9718 1 1896 0x0000000778325b10 dead sun/reflect/DelegatingClassLoader@0x00000007e005c4c8
0x000000077a3f5a58 1 3072 0x0000000778325b10 dead sun/reflect/DelegatingClassLoader@0x00000007e005c4c8

...

total = 10414 180017 2395296248 N/A alive=1, dead=10413 N/A

初步怀疑Groovy脚本的使用出现了问题。在ideaJ中用全文搜索程序groovy信息,发现有2个类中用到了groovy校验。其中有个类是最近新加的,怀疑是这个类校验时出现问题。

@NotNull(when = "groovy:_this.seatCode == null")
@NotBlank
private String customerId;

@NotNull(when = "groovy:_this.customerId == null")
@NotBlank
private String seatCode;

定位问题

问题出在ValidatorAspect中的validator方法中。每次校验接口参数都会实例化一个net.sf.oval.Validator对象。这是没必要的。理由是:1.首先net.sf.oval.Validator是线程安全的,不用考虑线程安全问题;2.net.sf.oval.Validator对象比较重,每次实例化会浪费很多内存资源;3. net.sf.oval.Validator在执行groovy脚本校验时,threadScriptCache会缓存groovy脚本,如果每次重新生成该实例会导致缓存失效。

Class<? extends ValidatorAdapter> vda = p.adapter();
//如未指定适配器,则默认使用oval验证对象
if (vda.getName().equals(ValidatorAdapter.class.getName())) {
if(o != null) { //当验证对象不为null,使用oval验证框架验证
net.sf.oval.Validator validator = new net.sf.oval.Validator();
List<ConstraintViolation> ret = validator.validate(o);

...

groovy脚本生成class入口代码。由于每次校验的时候都会新生成net.sf.oval.Validator实例,造成缓存scriptCache每次都重新生成,这里的缓存失效,每次都会重新解析groovy脚本。而静态变量GROOVY_SHELL每次解析groovy脚本的时候,都会新生成class加载到Perm区,导致OOM的问题发生。

public class ExpressionLanguageGroovyImpl implements ExpressionLanguage
{
private static final Log LOG = Log.getLog(ExpressionLanguageGroovyImpl.class);

private static final GroovyShell GROOVY_SHELL = new GroovyShell();

private final ThreadLocalObjectCache<String, Script> threadScriptCache = new ThreadLocalObjectCache<String, Script>();

public Object evaluate(final String expression, final Map<String, ? > values) throws ExpressionEvaluationException
{
try
{
final ObjectCache<String, Script> scriptCache = threadScriptCache.get();
Script script = scriptCache.get(expression);
if (script == null)
{
script = GROOVY_SHELL.parse(expression);
scriptCache.put(expression, script);
}

final Binding binding = new Binding();
for (final Entry<String, ? > entry : values.entrySet())
{
binding.setVariable(entry.getKey(), entry.getValue());
}
LOG.debug("Evaluating Groovy expression: {1}", expression);
script.setBinding(binding);
return script.run();
}
catch (final Exception ex)
{
throw new ExpressionEvaluationException("Evaluating script with Groovy failed.", ex);
}
}

...

为什么没有groory脚本生成的class没有被GC回收?
因为GROOVY_SHELL静态的,这个肯定是不能GC回收的。GROOVY_SHELL每次执行parse的时候会缓存class信息

private static final GroovyShell GROOVY_SHELL = new GroovyShell();

GroovyClassLoader在parseClass时会缓存在sourceCache中,而缓存的key为Groovy脚本的名字,这个名字每次生成都不一样。所以class每次都会重新生成,这样做是为了动态执行Groovy的class。潜在的问题是class会被无限加载到虚拟机的Perm区中。

public class GroovyClassLoader extends URLClassLoader {
public Class parseClass(GroovyCodeSource codeSource, boolean shouldCacheSource) throws CompilationFailedException {
synchronized (sourceCache) {
Class answer = (Class) sourceCache.get(codeSource.getName());
if (answer != null) return answer;

// Was neither already loaded nor compiling, so compile and add to
// cache.
CompilationUnit unit = createCompilationUnit(config, codeSource.getCodeSource());
SourceUnit su = null;
if (codeSource.getFile() == null) {
su = unit.addSource(codeSource.getName(), codeSource.getInputStream());
} else {
su = unit.addSource(codeSource.getFile());
}

ClassCollector collector = createCollector(unit, su);
unit.setClassgenCallback(collector);
int goalPhase = Phases.CLASS_GENERATION;
if (config != null && config.getTargetDirectory() != null) goalPhase = Phases.OUTPUT;
unit.compile(goalPhase);

answer = collector.generatedClass;
for (Iterator iter = collector.getLoadedClasses().iterator(); iter.hasNext();) {
Class clazz = (Class) iter.next();
setClassCacheEntry(clazz);
}
if (shouldCacheSource) sourceCache.put(codeSource.getName(), answer);
return answer;
}
}

上面的codeSource.getName()得到的是脚本的名字。脚本名字在GROOVY_SHELL生成,每次生成名字都不一样。

protected synchronized String generateScriptName() {
return "Script" + (++counter) + ".groovy";
}

解决问题

静态实例化net.sf.oval.Validator

private static final net.sf.oval.Validator validator = new net.sf.oval.Validator();

思考问题

现在架构大都是SOA或者微服务架构,服务通过RPC调用大都是无状态的,一般情况下出现OOM情况是比较少的。大部分OOM原因不合理使用引入的第三方中间件或者第三方jar包。随着后续业务量增大,需要更多关注和研究引入的第三方中间件或者第三方jar包。