cover_image

Groovy引发的内存爆满问题排查

玩物少年们
2020年06月25日 01:43

本期内容分为4个部分:
  • 背景

  • 问题定位

  • 验证和解决

  • 总结


背景

最近工作日的一个上午运维同学反馈用户中心部分服务器的memory已经接近80%,将要达到报警配置阀值。打开阿里云arms监控查看物理机情况,系统使用内存在逐渐走高,而系统空闲内存在不断降低。
图片

问题定位

随机找了台问题机器ssh上去,用top命令查看CPU情况,在10-20%正常范围内波动。用arthas的dashboard和thread命令查看JVM的内存和线程情况,也没有发现蛛丝马迹。同时发现其他机器的内存也在涨,从30%多涨到50%。

在线分析找不到原因,接下来就dump堆信息,分析哪些对象大量占着内存。这里用arthas的heapdump /tmp/dump.hprof命令,把文件dump到tmp目录,再下载到本地。

拿到dump文件后,怎么来分析呢?

用的比较多的内存分析工具有MAT和VisualVM。网上的介绍很多就不展开了。

如果使用MAT,要调整配置文件中的-Xmx大小,参数要大于dump文件大小,不然在加载到一半会报错。

  • 这里使用VisualVM来打开dump文件:
图片
  • 在dump profile表中观察到,org.codehaus.groovy.runtime.metaclass.MetaMethodIndex对象占用接近1G。
这里解释一下Groovy,它是个脚本语言,Wiki上的解释是:

Apache的Groovy是Java平台上设计的面向对象编程语言。

这门动态语言拥有类似Python、Ruby和Smalltalk中的一些特性,可以作为Java平台的脚本语言使用.

简单来说就是动态执行传入的表达式并返回执行结果,我们的项目中用它来做业务监控校验。http或dubbo接口的监控返回内容都不相同,使用groovy动态特性对返回值校验是最合适不过。


于是全局搜工程,首先用MetaMethodIndex关键字没有找到对应代码。换成groovy关键字,搜索到相关类和代码。
 private void dubboMonitor(InterfaceMonitorDTO monitorDTO) {      ......      Binding groovyBinding = new Binding();      groovyBinding.setVariable("object", result);      GroovyShell groovyShell = new GroovyShell(groovyBinding);      Boolean evaluate = (Boolean) groovyShell.evaluate(monitorDTO.getCheckScript());      if (!evaluate) {          sendError(monitorDTO, result);      }      ......  }

GroovyShell允许在Java类中(甚至Groovy类)求任意Groovy表达式的值。使用Binding对象输入参数给表达式,并最终通过GroovyShell返回Groovy表达式的计算结果。

代码中groovyShell.evaluate就是计算表达式结果。但是evaluate里到底隐藏有什么逻辑呢?我们来看groovy源码:

  public Class parseClass(final GroovyCodeSource codeSource, boolean shouldCacheSource){        String cacheKey = genSourceCacheKey(codeSource);        return ((ConcurrentCommonCache<String, Class>) sourceCache).getAndPut(                cacheKey,                new EvictableCache.ValueProvider<String, Class>() {                    @Override                    public Class provide(String key) {                        return doParseClass(codeSource);  //调用doParseClass                    }                },                shouldCacheSource        );    }  protected String generateScriptName() {      return "Script" + counter.incrementAndGet() + ".groovy";  }


evaluate传入的是脚本内容,然后调用generateScriptName来产生脚本name,也就是脚本类的名称。有了内容和名称,通过GroovyCodeSource对象传入到parseClass方法。
parseClass方法调用doParseClass对传入的codeSource做解析,根据脚本名称和脚本内容转成脚本类。
  private Class doParseClass(GroovyCodeSource codeSource) {        ......        SourceUnit su = null;        File file = codeSource.getFile();        if (file != null) {            su = unit.addSource(file);        } else {            URL url = codeSource.getURL();            if (url != null) {                su = unit.addSource(url);            } else {                su = unit.addSource(codeSource.getName(), codeSource.getScriptText());            }        }
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; String mainClass = su.getAST().getMainClassName(); for (Object o : collector.getLoadedClasses()) { Class clazz = (Class) o; String clazzName = clazz.getName(); definePackageInternal(clazzName); setClassCacheEntry(clazz); //缓存到GroovyClassLoader的clsCache里 if (clazzName.equals(mainClass)) answer = clazz; } return answer; }
  • 在调用createCollector(unit, su)方法中,每一次会new一个InnerLoader去加载脚本对象。
  • 在调用setClassCacheEntry(classz)方法中,会把脚本类对象缓存到clsCache中。以便运行时可以快速地调度它们。而MetaMethodIndex就是groovy在内存中存储脚本类和方法的映射。
解析和产生脚本类后,接下来就是实例化脚本。
通过InvokerHelper.createScript方法创建。

  //创建script    public Script parse(final GroovyCodeSource codeSource) throws CompilationFailedException {        return InvokerHelper.createScript(parseClass(codeSource), context);    }
  public static Script newScript(Class<?> scriptClass, Binding context) throws InstantiationException, IllegalAccessException, InvocationTargetException {        Script script;        try {            Constructor constructor = scriptClass.getConstructor(Binding.class);            script = (Script) constructor.newInstance(context);        } catch (NoSuchMethodException e) {            // Fallback for non-standard "Script" classes.            script = (Script) scriptClass.newInstance();            script.setBinding(context);        }        return script;    }

createScript中会调用newScript方法。它的入参是scriptClass和binding,在调用constructor.newInstance方法中,通过反射的方式产生Script对象。然后调用script.run()完成自定义脚本的执行和结果返回。

JVM中的Class只有满足以下三个条件,才能被GC回收:
  1. 该类所有的实例都已经被GC,也就是JVM中不存在该Class的任何实例。
  2. 加载该类的ClassLoader已经被GC。
  3. 该类的java.lang.Class 对象没有在任何地方被引用,如不能在任何地方通过反射访问该类的方法.
Groovy每执行一次脚本,都会生成一个脚本对应的class,这个脚本类运行时用反射生成一个实例,并调用它的MAIN函数执行。
Groovy在编译每个脚本时new一个InnerLoader去加载这个对象,所以InnerLoader也是独立的。
脚本类对象被方法setClassCacheEntry缓存到GroovyClassLoader:classCache,因为存在引用,所以无法在GC的时候被回收,运行一段时间后内存会被逐渐占满,然后频繁触发FullGC。

 protected void setClassCacheEntry(Class cls) {      classCache.put(cls.getName(), cls);  }

验证和解决

解决的方案有2种:

  • 使用clearCache清理脚本类对象。
  • 复用脚本类,相同脚本只加载一次,脚本类整体维持在一定数量。

模拟线上问题

编写测试用例模拟线上问题:


  @Test  public void test(){      do{          GroovyShell groovyShell = null;          Binding binding = null;          try{              binding = new Binding();              binding.setVariable("object", "\"result\": {\"test\":1}");               groovyShell = new GroovyShell(binding);              Boolean result = (Boolean) groovyShell.evaluate("return true;");          }catch (Exception e){              log.error("failed!",e);          }finally {              // groovyShell.getClassLoader().clearCache();          }      }while (true);  }


  • 运行一段时间后,用dashboard观察GC情况: 老年代已经100%,而且频繁FullGC:图片

方案一:clearCache

作为对比,每次执行完释放对象。GroovyClassLoader提供清除缓存的方法:shell.getClassLoader().clearCache(),在finally里直接调用。
修改代码重新运行,这次FullGC正常回收内存:图片

方案二:缓存脚本类

为了提升执行效率,对脚本类做缓存。使用MD5值作为Key,script类作为value缓存起来。下一个请求过来先从缓存中获取,如果没有再通过GroovyClassLoader加载。代码示例:

 String key = fingerKey(monitorDTO.getCheckScript());
//加载类信息,放入本地缓存中 Class<Script> script = classMaps.get(key); if (script == null) { synchronized (key.intern()) { // Double Check script = classMaps.get(key); if (script == null) { GroovyClassLoader classLoader = new GroovyClassLoader(); script = classLoader.parseClass(monitorDTO.getCheckScript()); classMaps.put(key, script); } } }
  • 运行一段时间后,老年代都被正常回收,问题得到了解决。
  • 考虑执行效率,线上使用方案二。代码更新到线上后,观察内存使用率也恢复正常。

总结

  • 产生的GroovyShell脚本类根据MD5值做缓存。如果不使用缓存方案,要使用clearCache清理。
  • 对于新技术的引入,要做好技术调研,熟悉后再使用,避免踩坑。
  • 从架构长期稳定考虑,等监控业务稳定后,从应用中抽离、单独部署,避免影响正常业务服务。

图片

继续滑动看下一个
玩物少年们
向上滑动看下一个