暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

一次内存泄漏排查与优化

阿东编程之路 2022-07-17
861


背景:阿东做了一个根据设置的规则进行匹配的功能,最终考虑使用 Groovy 脚本实现动态规则匹配。由于系统并发较高(日峰值 QPS 读 + 写 5W 左右)上线前需要进行一波压力测试,测试环境实例数 1 个,容器配置较低(4核4G)。


一. 压测

使用 jmeter 进行一波压力测试:

1. 设置线程组和 HTTP 接口信息

2. 设置结果树和聚合报告

3. 设置 2000 线程执行 300 秒,间隔 1 秒


执行没一会就收到 CPU 占用 100% 的告警,心里初步想法是 GC 线程导致。


二. 内存泄漏排查

赶紧登录实例容器一看究竟!

找到 Java 应用的进程 id:
    jps

    使用 top 命令动态展示该 Java 应用下的 CPU 占用高的子进程:
      top -Hp 1


      发现进程号为 355 和 356 的进程 CPU 已经飙到 70% 多!

      因为 jstack 打印堆栈信息的线程 id 是 16 进制,所以需要先将线程 id 转换为 16 进制格式数据:
        printf "%x" 355

        得到结果为 163

        使用 jstack 命令打印线程堆栈信息:
          jstack 1 | grep 163


          果然是 GC 线程导致 CPU 飙高,可以得出是空闲内存不足(Java 堆区没有空间分配新对象)导致频繁 GC。

          再通过 jstat 命令查看 GC 情况:
            jstat -gcutil 1 2000


                (内存占用曲线)

            看到发生 Full GC 一百五十多次,并且内存曲线一直呈上升趋势无降低,基本可以判断发生了内存泄漏

            但是内存泄漏分为 堆内内存泄漏 和 堆外内存泄漏,需要再次判断下( -heap 参数打印出 Java 堆详细信息


              jmap -heap 1


              看到 CMS 垃圾收集的区域(也就是老年代)占用达到 99.999%,庆幸发生的是堆内内存泄漏,可控的!

              所以我们需要将堆快照 dump 下来进行内存分析看下到底是哪个地方导致的内存泄漏。

              使用 jmap -dump 生成 Java 堆转储快照(live 参数代表 dump 存活对象):
                jmap -dump:live,format=b,file=OOMDump.bin 1

                提示“Heap dump file created”代表生成完成,将堆 dump 文件 scp 到本地。

                我们使用 Java 自带内存分析工具 jvisualvm 来分析下内存泄漏,本地终端执行启动 jvisualvm:
                  jvisualvm

                  将 OOMDump.bin 文件装入存分析工具进行分析:


                  点进占用内存大小最多的类,查看 GC 引用链:


                  发现是 GroovyClassLoader 类下的 classCahe 变量造成的内存泄漏。

                  Show Me The Code!来看下业务代码怎么写的:
                    /**
                    * 执行脚本
                    * @param script 脚本,例如:"return '1'.equals(a)"
                    * @param params 脚本变量,例如:"'a':'1'"
                    * @return 执行结果
                    */
                    public static Boolean executeGroovy(String script, Map<String, String> params) {
                    // 使用 groovyClassLoader 将脚本加载成 Groovy 对象
                    Class groovyClass = groovyClassLoader.parseClass(script);
                    if (groovyClass == null) {
                    return false;
                    }
                    Binding binding = new Binding();
                    // 绑定变量
                    params.entrySet().stream().forEach(e -> {
                    binding.setVariable(e.getKey(), e.getValue());
                    });
                    // 创建 Groovy 脚本对象
                    Script scriptObj = InvokerHelper.createScript(groovyClass, binding);
                    try {
                    // 执行脚本
                    return (Boolean) scriptObj.run();
                    } catch (Exception e) {
                    log.error("脚本执行出错,script:{}, error:{}", script, e.getMessage(), e);
                    return false;
                    }
                    }

                    在业务代码里只有在每次调用执行脚本方法 executeGroovy() 开始加载 Groovy 对象时用到了 GroovyClassLoader 类加载器,所以我们来看下源码 groovyClassLoader.parseClass() 方法做了些什么:
                      public Class parseClass(String text) throws CompilationFailedException {
                      // 默认以时间戳+脚本的hash值作为groovy的名称
                      return parseClass(text, "script" + System.currentTimeMillis() +
                      Math.abs(text.hashCode()) + ".groovy");
                      }


                      public Class parseClass(final String text, final String fileName) throws CompilationFailedException {
                      GroovyCodeSource gcs = AccessController.doPrivileged(new PrivilegedAction<GroovyCodeSource>() {
                      public GroovyCodeSource run() {
                      return new GroovyCodeSource(text, fileName, "/groovy/script");
                      }
                      });
                      gcs.setCachable(false);
                      // 加载
                      return parseClass(gcs);
                      }

                      如果不指定名称,GroovyClassLoader 会默认给 Groovy 生成一个以 时间戳 + 脚本的 hash 值作为名称。

                        protected final Map<String, Class> classCache = new HashMap<String, Class>();


                        private Class doParseClass(GroovyCodeSource codeSource) {
                        ......
                        for (Object o : collector.getLoadedClasses()) {
                        Class clazz = (Class) o;
                        String clazzName = clazz.getName();
                        definePackageInternal(clazzName);
                        // 重点在这里,会将Groovy脚本类放进classCache里
                        setClassCacheEntry(clazz);
                        if (clazzName.equals(mainClass)) answer = clazz;
                        }
                        return answer;
                        }
                        protected void setClassCacheEntry(Class cls) {
                            // 吐槽一下这里的锁真大!
                        synchronized (classCache) {
                        // 以刚才默认生成名称为key,Groovy class对象为value放进classCache
                        classCache.put(cls.getName(), cls);
                        }
                        }

                        最后将刚才默认生成名称为 key、Groovy class 对象为 value 放进 classCache内存泄漏的罪魁祸首!

                        问题似乎已经找到了,因为我们在调用 groovyClassLoader.parseClass() 方法时没有指定名称,所以就算是同样的脚本也会导致 classCache 被无限 set 扩大。而 GC Roots 到内存泄漏对象的引用链的关系是 GroovyClassLoader -> classCache -> table -> Entry,GroovyClassLoader 肯定一直被应用持有(和应用程序生命周期一致),所以会导致 classCache 的元素无法被释放,造成内存泄漏。


                        三. 问题解决与优化

                        问题找到了,该考虑如何解决了!

                        上面分析了我们调用 groovyClassLoader.parseClass() 方法时没有指定名称才会导致这些问题,那我们指定名称呢?

                          // GroovyClassLoader#parseClass()源码
                          public Class parseClass(GroovyCodeSource codeSource, boolean shouldCacheSource) throws CompilationFailedException {
                          synchronized (sourceCache) {
                          // 从缓存中获取
                          Class answer = sourceCache.get(codeSource.getName());
                          if (answer != null) return answer;
                          answer = doParseClass(codeSource);
                          // 是否使用缓存
                          if (shouldCacheSource) sourceCache.put(codeSource.getName(), answer);
                          return answer;
                          }
                          }

                          从源码看确实有 API 自己指定 name,还可以使用缓存,但是这个锁的粒度太大了!肯定会影响性能,其实最新的版本已经将 cache 换成 ConcurrentHashMap,锁粒度缩小为每个 Groovy 类,但是由于目前版本比较稳定且应用内有在使用(场景不同),还是决定在应用层面去优化。

                          阿东最终决定在 GroovyClassLoader 加上一层本地缓存,加载过的类就以脚本字符串为 key 放进缓存中,并使用软引用修饰 value 防止不同的脚本类过多导致内存溢出(在进行 gc 后如果内存不足会将软引用回收掉),逻辑修改为:

                            // 本地缓存
                            private static Map<String, SoftReference<Class>> scriptCache = new ConcurrentHashMap<>();


                            public static Boolean executeGroovy(String script, Map<String, String> params) {


                            // 先从本地缓存取,没有再去使用groovyClassLoader加载
                            SoftReference<Class> softReference = scriptCache.get(script);
                            Class groovyClass;
                            if (softReference == null || softReference.get() == null) {
                            groovyClass = compiledScript(script);
                            } else {
                            groovyClass = softReference.get();
                            }
                            ......
                            }
                            // 解析
                            private static Class compiledScript(String script) {


                            try {
                            Class gvClz = groovyClassLoader.parseClass(script);
                            // 解析完放进缓存
                            scriptCache.put(script, new SoftReference<>(gvClz));
                            return gvClz;
                            } catch (Exception e) {
                            log.error("编译脚本出错,script:{}, error:{}", script, e.getMessage(), e);
                            return null;
                            }
                            }

                            大概逻辑就是:先从本地缓存拿 Groovy 类,没有再使用 GroovyClassLoader 加载

                            针对 classCache,加个定时任务每一小时清空一次。


                            四. 验证

                            还是使用 jmeter 设置 2000 线程执行 300 秒,间隔 1 秒:内存占用趋势图很平缓,Full GC 只有一次(容器启动会有三次),果然没有什么问题是加缓存解决不了的



                            功能顺利上线...


                            五. 总结

                            通过这次经历,得出了上线前压测的重要性;如果真在线上遇到内存泄漏,冷静一点,留一台实例,重启其他实例保证线上服务正常运行,并将留下实例的所有对外入口切掉(Nginx节点下线,RPC服务下线,消息队列消费者下线等),对该实例进行内存分析找出问题即可。

                            如果觉得文章不错可以点个和关注





                            1.《深入理解Java虚拟 作者:周志明

                            文章转载自阿东编程之路,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                            评论