记一次堆外内存泄漏排查过程

现象

最近把A,B两个服务合并成了一个服务。合并后上线两天后服务器开始报警,内存满了
到服务器上使用top命令查看,jvm进程已经占了11g的内存(指的rss)。
记一次堆外内存泄漏排查过程_第1张图片
其中
rss实际占用内存,vsz虚拟内存
RSS( Resident Set Size )常驻内存集合大小,表示相应进程在RAM中占用了多少内存,并不包含在SWAP中占用的虚拟内存。即使是在内存中的使用了共享库的内存大小也一并计算在内,包含了完整的在stack和heap中的内存。
VSZ (Virtual Memory Size),表明是虚拟内存大小,表明了该进程可以访问的所有内存,包括被交换的内存和共享库内存。
但是通过jstat -gc,jmap -heap命令查看堆内存,和相关启动参数发现堆内存上限设置的为4g。那么这多于内存是从哪来的呢。

问题排查

工具排查

虽然先猜了是堆外内存的问题,但还是通过dump堆文件,使用mat分析,连接jconsole等方式观察。发现4g的堆内存也没有满,堆内存是完全没问题的。
使用arthas查看memory

curl -O https://arthas.aliyun.com/arthas-boot.jar

发现arthas中统计的堆内和堆外内存远小于jvm进程占用的内存,但是这里看到的非堆只包含了code_cache和metaspace,nio相关的direct,mapped的部分,那有没有可能是其他非堆的部分有问题呢?
这里是随便找的图,没有截图,看下去arthas的内存显示效果,可以看到非堆内存的的最大值和目前占有的内存total
image.png

Native Memory Tracking

需要在jvm上加启动参数(注意,加这个参数会导致,jvm性能下降5%-10%,在排查完毕问题后需要去掉这个参数。

-XX:NativeMemoryTracking=detail

heap:堆内存,即-Xmx限制的最大堆大小的内存。
class:加载的类与方法信息,其实就是 metaspace,包含两部分: 一是
metadata,被-XX:MaxMetaspaceSize限制最大,默认无限制。会一直申请内存
Metaspace 分为两个区域:non-class part 和 class part。

  • class part:存放 Klass 对象,需要一个连续的不超过 4G 的内存
  • non-class part:包含其他的所有 metadata

thread:线程与线程栈占用内存,每个线程栈占用大小受-Xss限制,但是总大小没有限制,一般一个线程占用1M
code:JIT 即时编译后(C1 C2 编译器优化)的代码占用内存,受-XX:ReservedCodeCacheSize限制
gc:垃圾回收占用内存,例如垃圾回收需要的 CardTable,标记数,区域划分记录,还有标记 GC Root等等,都需要内存。这个不受限制,一般不会很大的。Parallel GC 不会占什么内存,G1 最多会占堆内存 10%左右额外内存,ZGC 会最多会占堆内存 15~20%左右额外内存,但是这些都在不断优化。(注意,不是占用堆的内存,而是大小和堆内存里面对象占用情况相关)
compiler:C1 C2 编译器本身的代码和标记占用的内存,这个不受限制,一般不会很大的
internal:命令行解析,JVMTI 使用的内存,这个不受限制,一般不会很大的,直接内存也会算到这里。可以 XX:MaxDirectMemorySize 限制最大值,默认与-Xmx相同(这也是为什么通常jvm最大堆内存设置为机器内内存一般的原因)
symbol:常量池占用的大小,字符串常量池受-XX:StringTableSize个数限制,总内存大小不受限制
Native Memory Tracking:内存采集本身占用的内存大小,如果没有打开采集(那就看不到这个了)
Arena Chunk:所有通过 arena 方式分配的内存,这个不受限制,一般不会很大的
通过命令持续观察

jcmd pid VM.native_memory
24927:

Native Memory Tracking:

Total: reserved=6484560KB, committed=5263488KB
-                 Java Heap (reserved=4194304KB, committed=4194304KB)
                            (mmap: reserved=4194304KB, committed=4194304KB)

-                     Class (reserved=1181067KB, committed=149859KB)
                            (classes #25039)
                            (malloc=3467KB #51179)
                            (mmap: reserved=1177600KB, committed=146392KB)

-                    Thread (reserved=378725KB, committed=378725KB)
                            (thread #368)
                            (stack: reserved=377092KB, committed=377092KB)
                            (malloc=1203KB #1853)
                            (arena=430KB #734)

-                      Code (reserved=262843KB, committed=81171KB)
                            (malloc=13243KB #18122)
                            (mmap: reserved=249600KB, committed=67928KB)

-                        GC (reserved=227905KB, committed=227905KB)
                            (malloc=39489KB #32009)
                            (mmap: reserved=188416KB, committed=188416KB)

-                  Compiler (reserved=401KB, committed=401KB)
                            (malloc=271KB #619)
                            (arena=131KB #3)

-                  Internal (reserved=190870KB, committed=190870KB)
                            (malloc=190838KB #47664)
                            (mmap: reserved=32KB, committed=32KB)

-                    Symbol (reserved=30255KB, committed=30255KB)
                            (malloc=27114KB #288969)
                            (arena=3141KB #1)

-    Native Memory Tracking (reserved=7206KB, committed=7206KB)
                            (malloc=251KB #3796)
                            (tracking overhead=6955KB)

-               Arena Chunk (reserved=2792KB, committed=2792KB)
                            (malloc=2792KB)

-                   Unknown (reserved=8192KB, committed=0KB)
                            (mmap: reserved=8192KB, committed=0KB)

发现,上面的各项(指committed)加起来看,与tom命令查看的内存基本是对上了。
两个指标发现异常
class和internal
观察发现,class空间最大,经过运行几个小时由100m涨到了500m还在不断增加。
internal也在不断增加
于是自然想到了先通过参数设置上限,于是加上了两个参数准备继续观察

-XX:MaxMetaspaceSize=512m -XX:MaxDirectMemorySize=512m

解决元空间占用过高问题

看到class区占用高,首先想到了是不是哪里有动态加载类的操作。比如经常使用cglib,动态代理的地方。于是首先被怀疑的就是项目中的Aviator。可以动态编译脚本进行执行。
于是本地写了段代码进行测试

for(int i =0 ;i<=10000;i++) {
Expression compile = instance.compile("textHandler.textLabelStyleAddAttribute(text,'max-width:100%', seq.array(java.lang.String, 'img', 'video'))",false);
  Map<String, Object> parameterMaps = new HashMap<>();
        
}

果然监控这段代码,发现class空间内存不断飙高,实际上arthas也能看出元空间的变化。
于是查相关的资料才发现,Aviator确实存在内存泄漏的问题。



“每个表达式都会生成一个匿名类(java lambda 对应的匿名类),因此如果你的表达式都是动态生成的,那么可能会生成大量的匿名类,占满 metaspace 就会触发 full gc。”, 原来坑在这里终于找到了。
** 备注:metaspece和老年代**
在jdk1.8中,永久代已经不存在,存储的类信息、编译后的代码数据等已经移动到了元空间(MetaSpace)中,元空间并没有处于堆内存上,而是直接占用的本地内存(NativeMemory)。
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。


而我们的项目没有设置元空间的最大限制,因此随着运行时间的增加,元空间内存占用也是越来越高。实际上fullgc时也会清理直接内存,元空间等。又由于我们使用的垃圾回收器也是G1。在G1垃圾收集器中,最好的优化状态就是通过不断调整分区空间,避免进行full gc,可以大幅提高吞吐量G1在一般情况下都不会触发fullgc,就导致这些内存一直得不到释放并且不断在增加。
按照解决方案修改,编译加cache参数。进行缓存,避免不断重读的编译脚本解决问题
【方案】
aviator通常采取对方式是直接执行表达式, 事实上 Aviator 背后都帮你做了编译并执行的工作。当然你可以自己先编译表达式, 返回一个编译的结果, 然后传入不同的env来复用编译结果, 提高性能, 这是更推荐的使用方式。示例:

for(int i =0 ;i<=10000;i++) {
Expression compile = instance.compile("textHandler.textLabelStyleAddAttribute(text,'max-width:100%', seq.array(java.lang.String, 'img', 'video'))",true);
  Map<String, Object> parameterMaps = new HashMap<>();
        
}

限制不住的internal

将上面的改之后发到线上,观察class空间果然稳定再100M+
就当我以为解决问题的时候,随着时间的增加发现jvm内存占用还是越来越高
使用VM.native_memory分析
发现internal还是在不断增加,这时我并不慌,因为前面我已经加了限制最大直接内存的参数。大不了到达了最大值触发full gc。依然可以可以限制住内存的增加。
结果经过观察我傻眼了。internal的内存根本不受参数的限制,逐渐上升占了1g的内存并且还在不断增加。
(其实这里在使用arthas的查看内存的时候就应该发现了,但是在arthas上看到direct占用非常小。在native_memory上看到的internal看到的又很大,让我对arthas的内存分析产生了怀疑)
那么就说明internal这部分内存并不是直接内存。
这时jvm能加限制内存的参数我都加上了,也限制不住。
可以说已经是黔驴技穷了。

有用的笨方法

好在对线上常用的一个业务流程,在测试尝试,复现出了internal不断增加的场景。
那么接下来就是不断对这个代码流程进行排查的过程了。
排查方法也很笨就是对怀疑的代码进行单独测试,或者注释掉其他代码执行进行观察能是internal内存上升的代码。,经过大半天的不断尝试(十分痛苦,因为internal上升因素很多),最终基本找到了

private static final AviatorEvaluatorInstance instance = AviatorEvaluator.newInstance();

instance.addStaticFunctions("authorHandler", AuthorHandler.class);

还是和Aviator相关,查看了其内部的源代码。
在ClassMethodFunction中

 public ClassMethodFunction(Class<?> clazz, boolean isStatic, String name, String methodName, List<Method> methods) throws IllegalAccessException, NoSuchMethodException {
        this.name = name;
        this.clazz = clazz;
        this.isStatic = isStatic;
        this.methodName = methodName;
        if (methods.size() == 1) {
            this.handle = MethodHandles.lookup().unreflect((Method)methods.get(0)).asFixedArity();
            this.pTypes = ((Method)methods.get(0)).getParameterTypes();
            if (!isStatic) {
                Class<?>[] newTypes = new Class[this.pTypes.length + 1];
                newTypes[0] = this.clazz;
                System.arraycopy(this.pTypes, 0, newTypes, 1, this.pTypes.length);
                this.pTypes = newTypes;
            }

            if (this.handle == null) {
                throw new NoSuchMethodException("Method handle for " + methodName + " not found");
            }
        } else {
            this.methods = methods;
        }

    }

最终测试出因为一段反射代码会导致internal

   MethodHandle methodHandle = MethodHandles.lookup().unreflect(AuthorHandler.class.getMethods()[0]).asFixedArity();

虽然不懂为什么,查了下网上的相同内存泄漏问题。
jvm bug:https://bugs.openjdk.org/browse/JDK-8152271
就是上面这个bug,频繁使用MethodHandles相关反射,会导致过期对象无法被回收,同时会引发YGC扫描时间增长,导致性能下降。

问题解决

由于jvm 1.8已经明确表示,不会在1.8处理这个问题,会在java 重构。但是我们短时间也没办法升级到java 。所以没办法通过直接升级JVM进行修复。由于问题是频繁使用反射,所以考虑了添加缓存,让频率降低,从而解决性能下降和内存泄漏的问题。
而我们项目使用addStaticFunctions的用法有问题

instance.addStaticFunctions("authorHandler", AuthorHandler.class);

改之前
每次执行脚本都addStaticFunctions,导致内存泄漏的问题一直都在发生。但是这个写法有问题因为addStaticFunctions在初始化执行一次就够了,而不用每次都执行一次。因此做了调整

public static Object execute(String script, Object parameters) {
        log.info("aviator script :{} parameters:{}", script, parameters);
        try {
            instance.addStaticFunctions("authorHandler", AuthorHandler.class);
            instance.addStaticFunctions("categoryHandler", CategoryHandler.class);
            instance.addStaticFunctions("contentTypeHandler", ContentTypeHandler.class);
            instance.addStaticFunctions("coverHandler", CoverHandler.class);
            instance.addStaticFunctions("listHandler", ListHandler.class);
            instance.addStaticFunctions("localDateTimeHandler", LocalDateTimeHandler.class);
            instance.addStaticFunctions("objectHandler", ObjectHandler.class);
            instance.addStaticFunctions("tagHandler", TagHandler.class);
            instance.addStaticFunctions("textHandler", TextHandler.class);
            instance.addStaticFunctions("videoHandler", VideoHandler.class);
            instance.addStaticFunctions("keywordHandler", KeywordHandler.class);
            instance.addStaticFunctions("captionHandler", CaptionHandler.class);

            Expression compile = instance.compile(script, true);
            Map<String, Object> parameterMaps = new HashMap<>();
            if (parameters instanceof Map) {
                parameterMaps = (Map<String, Object>) parameters;
            } else {
                List<String> variables = compile.getVariableNames();
                for (String v : variables) {
                    parameterMaps.put(v, parameters);
                }
            }
            Object execute = compile.execute(parameterMaps);
            log.info("aviator execute: {}", execute);
            return execute;
        } catch (IllegalAccessException | NoSuchMethodException e) {
            log.error("aviator 脚本执行失败", e);
            throw new AviatorScriptBuildException(String.format("aviator execute error script:%s parameters:%s", script, parameters));
        } catch (ExpressionSyntaxErrorException e) {
            log.error(e.getMessage());
            throw new AviatorScriptBuildException("脚本编译失败");
        } catch (Exception e) {
            log.error(e.getMessage());
            throw new AviatorScriptExecuteException(e.getMessage());
        }
    }

调整后

@Slf4j
public class AviatorUtil {


    public static final AviatorEvaluatorInstance instance = AviatorEvaluator.newInstance();

    static {
        try {
            instance.addStaticFunctions("authorHandler", AuthorHandler.class);
            instance.addStaticFunctions("categoryHandler", CategoryHandler.class);
            instance.addStaticFunctions("contentTypeHandler", ContentTypeHandler.class);
            instance.addStaticFunctions("coverHandler", CoverHandler.class);
            instance.addStaticFunctions("listHandler", ListHandler.class);
            instance.addStaticFunctions("localDateTimeHandler", LocalDateTimeHandler.class);
            instance.addStaticFunctions("objectHandler", ObjectHandler.class);
            instance.addStaticFunctions("tagHandler", TagHandler.class);
            instance.addStaticFunctions("textHandler", TextHandler.class);
            instance.addStaticFunctions("videoHandler", VideoHandler.class);
            instance.addStaticFunctions("keywordHandler", KeywordHandler.class);
            instance.addStaticFunctions("captionHandler", CaptionHandler.class);
        } catch (IllegalAccessException | NoSuchMethodException e) {
            log.error("aviator 脚本执行失败", e);
            throw new AviatorScriptBuildException("脚本初始化失败");
        }
    }

    /**
     * 执行 Aviator 脚本
     *
     * @param script     脚本
     * @param parameters 参数
     * @return 执行结果
     * @see 编译和执行
     * @see 未初始化全局变量
     */
    public static Object execute(String script, Object parameters) {
        log.info("aviator script :{} parameters:{}", script, parameters);
        try {

            Expression compile = instance.compile(script, true);
            Map<String, Object> parameterMaps = new HashMap<>();
            if (parameters instanceof Map) {
                parameterMaps = (Map<String, Object>) parameters;
            } else {
                List<String> variables = compile.getVariableNames();
                for (String v : variables) {
                    parameterMaps.put(v, parameters);
                }
            }
            Object execute = compile.execute(parameterMaps);
            log.info("aviator execute: {}", execute);
            return execute;
        }  catch (ExpressionSyntaxErrorException e) {
            log.error(e.getMessage());
            throw new AviatorScriptBuildException("脚本编译失败");
        } catch (Exception e) {
            log.error(e.getMessage());
            throw new AviatorScriptExecuteException(e.getMessage());
        }
    }
}

至此,这个堆外内存泄漏的问题完全解决,可以说坑连着坑。

你可能感兴趣的:(jvm,java,jvm)