性能文章>一起 fastjson 和 Spring-Mongo 联合作妖的类卸载事故排查>

一起 fastjson 和 Spring-Mongo 联合作妖的类卸载事故排查原创

546545

问题背景

有同学反馈,在自己的业务中调用 groovy 脚本动态生成一些 class 的时候,出现了类无法卸载的现象,下图来自你假笨大神 PerfMa 公司 的 XElephant 「 https://memory.console.heapdump.cn/ 」

如果想离线分析也可以用 JProfile(付费)、YourKit 等工具。

可以看到有 4808 个 classloader,这些 classloader 加载的类总数是 9612,加载的类其中一个是我们 groovy 中定义的 com.yuping.app214c2d6e_8f0e_209a_7cbf_81130c799181.bookDataModel 类。

这些类都无法被 GC 卸载。对应的启动参数如下:

java -Xmx2688M -Xms2688M -Xmn960M 
-XX:MaxMetaspaceSize=512M -XX:MetaspaceSize=512M   
-XX:+UseConcMarkSweepGC 
-XX:+UseCMSInitiatingOccupancyOnly 
-XX:CMSInitiatingOccupancyFraction=70 
-XX:+CMSClassUnloadingEnabled 
-XX:+ParallelRefProcEnabled 
-XX:+CMSScavengeBeforeRemark 
-XX:ErrorFile=/tmp/hs_err_pid%p.log   
-Xloggc:/tmp/gc.log 
-XX:+PrintGCDetails 
-XX:+PrintGCDateStamps 
-verbose:class 
-XX:+PrintClassHistogramBeforeFullGC 
-XX:+PrintClassHistogramAfterFullGC 
-XX:+PrintCommandLineFlags 
-XX:+PrintHeapAtGC 
-XX:-DisableExplicitGC 
-jar  target/groovy-demo-project-1.0-SNAPSHOT.jar

经查看,这个参数是允许 CMS 类卸载的。

业务逻辑

大致的逻辑如下,就是从 db 中动态加载一段 groovy 脚本

@Service
public class MyService {
    @Resource
    private MongoTemplate mongoTemplate;

    void insert() {

        GroovyClassLoader groovyClassLoader = null;
        try {
            groovyClassLoader = new GroovyClassLoader();

            Class<? extends BaseClazz> dataModelClazz = groovyClassLoader.parseClass("groovy content");
            // 真实业务是这个 data 是从外部传进来的,有数据的数据结构,这里简化处理
            JSONObject data = new JSONObject(); 
            data.put("id", UUID.randomUUID().toString());
            data.put("enterpriseCode""foo");

            BaseClazz model = JSON.toJavaObject(data, dataModelClazz);
            BaseClazz newModel = mongoTemplate.insert(model, "test_ya");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            groovyClassLoader.clearCache();
        }
    }

}

groovy 脚本的内容大概如下,是一个简单的子类定义:

package com.yuping.app214c2d6e_8f0e_209a_7cbf_81130c799181

import com.imdach.demo.BaseClazz

class bookDataModel extends BaseClazz {
    String author
    String charter
    // ... 省略很多字段和方法
}

拿到这个问题的时候,第一个我想的是类卸载的条件到底是什么。

  • 首先第一个要求是「这个类的所有实例(instance)不可达、被 GC」,不然实例还在,类没了,就好比人没有了灵魂,是不行的。

  • 第二个要求是该类的 ClassLoader 不可达、被 GC,这也好理解,ClassLoader 需要持有 Class 的引用,不然无法判断一个类是否已经加载,无法实现类加载基本的功能。

  • 第三个要求,没有被其它 GC Root 引用,这个好理解,这个对所有的场景都适用,可达对象不应该被回收。

  • 第四个要求:触发 GC(FullGC),类卸载的场景是比较少见的,以 CMS 为例,类卸载在 FullGC 时触发。

现在来看上面的条件,第一个条件类实例不可达,这个比较显而易见,这里的类实例都是局部变量,函数调用完就不可达了。

第二个条件 ClassLoader 不可达,这个在这个场景下是 OK 的,每次加载 groovy 脚本都是新建的 ClassLoader,调用完就可以被 GC 了。

第三个条件 没有被其它 GC Root 引用,这个目前无法确定,晚点 dump 内存来看。

第四个条件,触发 GC(FullGC),这个也可以排除,已经手动触发过,且在 dump 堆内存时候本来会触发一次 FullGC。

所以接下来就是看这个 class 有没有被 GC Root 引用。

对象被谁引用

我们找到其中一个类,比如第一个,它的地址是 0x79357f308

接下来,切换到「对象视图」界面,通过对象地址找到这个对象,找到这个对象更详细的信息。

首先看到了 fastjson 库,这货咋掺和进来了,我不就是调用了你这个工具人做了一下序列化吗?

可以看到 com.yuping.app214c2d6e_8f0e_209a_7cbf_81130c799181.bookDataModelcom.alibaba.fastjson.util.IdentityHashMap$Entry 引用,看名字也可以猜到,bookDataModel 类被放到了 fastjson 的一个 hashmap 里了。

为啥会被放到 hashmap 里,看看它做了什么骚操作。全局搜索一下 IdentityHashMap 被什么引用,看到被 SerializeConfig、ParserConfig 引用,ParserConfig 里面有一个 static 的 IdentityHashMap 字段 global,后面的调用都是用 static 变量,这个 static 的类变量不会被 GC。

public class ParserConfig {
    public static ParserConfig getGlobalInstance() {
        return global;
    }
     public static ParserConfig                              global                = new ParserConfig();

    private final IdentityHashMap<Type, ObjectDeserializer> deserializers         = new IdentityHashMap<Type, ObjectDeserializer>();

FastJson 做解析的过程中,会把 com.yuping.app214c2d6e_8f0e_209a_7cbf_81130c799181.bookDataModel 类放到 IdentityHashMap 中,这下凉凉,global 这个 GC Root 持有 deserializers 这个 IdentityHashMap,IdentityHashMap 里面存放了 bookDataModel 类。

到了这一步,我们可以先把 FastJson 的问题先解决了,我找了一下,它有一个手动清空的函数

public class ParserConfig {

    public void clearDeserializers() {
        this.deserializers.clear();
        this.initDeserializers();
    }
}

这样我就可以把那个 hashmap 清空了,这样也就不持有那个类的引用了。

@Service
public class MyService {
    @Resource
    private MongoTemplate mongoTemplate;

    void insert() {

        GroovyClassLoader groovyClassLoader = null;
        try {
            //省略
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            groovyClassLoader.clearCache();
            // 增加下面这两行
            SerializeConfig.getGlobalInstance().clearSerializers();
            ParserConfig.getGlobalInstance().clearDeserializers();
        }
    }
}

本以为问题就解决了,放心的让开发同学去改一下,然后就等着说「问题解决了」,结果说,类还是没有卸载,啪啪啪打脸。

二战类卸载

再次让开发的小姐姐帮忙 dump 了内存,接下来继续上面的流程,发现确实类还在被其它对象引用,只不过这次已经没有 FastJson 了,这次多了很多 Spring 相关的信息。

可以看到  bookDataModel 类被 org.springframework.data.util.ClassTypeInformation 对象的 type 字段引用,ClassTypeInformation 类的定义如下。

public class ClassTypeInformation<Sextends TypeDiscoverer<S{
 private final Class<S> type;
}

这里的 type 字段存的就是我们 groovy 生成的 bookDataModel 类 class。

展开其中一个  org.springframework.data.util.ClassTypeInformation, 往上层查看 GC 链。

可以看到 ClassTypeInformation 对象被 MongoMappingContext 对象的 persistentEntities 字段所引用。

public abstract class AbstractMappingContext {
 private final Map<TypeInformation<?>, Optional<E>> persistentEntities = new HashMap<>();
}

public class MongoMappingContext extends AbstractMappingContext {
}

因为 MongoMappingContext 是长期存在的 Spring 单例 Bean,所以 persistentEntities 不会被 GC,它引用 ClassTypeInformation,ClassTypeInformation 引用 bookDataModel 类,导致 bookDataModel 类无法被回收。

到这里,我们就比较清楚了原因。至于这么解决,这个我就不太懂了,需要熟悉 spring-mongodb 的同学看下怎么绕过 spring 里的这套缓存机制,重新定制一个 AbstractMongoConfiguration,让 Spring 不缓存即可(我不会)。

我这里有一个很不成熟的解法,直接用裸的 mongodb-java-driver,经测试是 OK 的,但是不推荐。

@Service
public class MyService {
    @Resource
    private MongoTemplate mongoTemplate;

    void insert() {

        GroovyClassLoader groovyClassLoader = null;
        try {
            groovyClassLoader = new GroovyClassLoader();
            File f = new File("test.groovy");
            Class<? extends BaseClazz> dataModelClazz = groovyClassLoader.parseClass(FileUtils.readFileToString(f));
            JSONObject data = new JSONObject();
            data.put("id", UUID.randomUUID().toString());
            data.put("enterpriseCode""foo");
            BaseClazz model = JSON.toJavaObject(data, dataModelClazz);

            CodecRegistry pojoCodecRegistry = fromProviders(PojoCodecProvider.builder().automatic(true).build());
            CodecRegistry codecRegistry = fromRegistries(MongoClientSettings.getDefaultCodecRegistry(), pojoCodecRegistry);
            MongoClientSettings clientSettings = MongoClientSettings.builder()
                    .applyConnectionString(new ConnectionString("mongodb://localhost:27017"))
                    .codecRegistry(codecRegistry)
                    .build();

            try (MongoClient mongoClient = MongoClients.create(clientSettings)) {
                MongoDatabase mongoDatabase = mongoClient.getDatabase("seewo_easi_pass");
                MongoCollection collection = mongoDatabase.getCollection("test_ya", dataModelClazz);
                InsertOneResult result = collection.insertOne(model);
                System.out.println(result);
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            groovyClassLoader.clearCache();
            SerializeConfig.getGlobalInstance().clearSerializers();
            ParserConfig.getGlobalInstance().clearDeserializers();
        }
    }
}

经过实验,GC 过后确实可以将类卸载,通过对内存 dump 查看,也找不到相关的类存在。

小结

后面我大概搜了一下,关于 FastJson IdentityHashMap 有关的内存问题网友们也遇到过不少,看来大家踩的坑还不少。至于 MongoDB 这个是真没有想到会遇到,可能作者也没有想到,还会有人动态生成类和对应的类实例,然后插入 mongodb 吧。

能复现的问题,其实都不是问题,解决只是一个时间问题。上面的解决思路,可能都是错的,看看思路就好。

 

请先登录,查看4条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

不起眼,但是足以让你有收获的JVM内存分析案例
分析 这个问题说白了,就是说有些int[]对象不知道是哪里来的,于是我拿他的例子跑了跑,好像还真有这么回事。点该 dump 文件详情,查看相关的 int[] 数组,点该对象的“被引用对象”,发现所
从一起GC血案谈到反射原理
前言 首先回答一下提问者的问题。这主要是由于存在大量反射而产生的临时类加载器和 ASM 临时生成的类,这些类会被保留在 Metaspace,一旦 Metaspace 即将满的时候,就会触发 Fu
关于内存溢出,咱再聊点有意思的?
概述 上篇文章讲了JVM在GC上的一个设计缺陷,揪出一个导致GC慢慢变长的JVM设计缺陷,可能有不少人还是没怎么看明白的,今天准备讲的大家应该都很容易看明白 本文其实很犹豫写不写,因为感觉没有
协助美团kafka团队定位到的一个JVM Crash问题
概述 有挺长一段时间没写技术文章了,正好这两天美团kafka团队有位小伙伴加了我微信,然后咨询了一个JVM crash的问题,大家对crash的问题都比较无奈,因为没有现场,信息量不多,碰到这类问题我
又发现一个导致JVM物理内存消耗大的Bug(已提交Patch)
概述 最近我们公司在帮一个客户查一个JVM的问题(JDK1.8.0_191-b12),发现一个系统老是被OS Kill掉,是内存泄露导致的。在查的过程中,阴差阳错地发现了JVM另外的一个Bug。这个B
JVM实战:优化我的IDEA GC
IDEA是个好东西,可以说是地球上最好的Java开发工具,但是偶尔也会卡顿,仔细想想IDEA也是Java开发的,会不会和GC有关,于是就有了接下来对IDEA的GC进行调优 IDEA默认JVM参数: -
不起眼,但是足以让你收获的JVM内存案例
今天的这个案例我觉得应该会让你涨姿势吧,不管你对JVM有多熟悉,看到这篇文章,应该还是会有点小惊讶的,不过我觉得这个案例我分享出来,是想表达不管多么奇怪的现象请一定要追究下去,会让你慢慢变得强大起来,
如何通过反射获得方法的真实参数名(以及扩展研究)
前段时间,在做一个小的工程时,遇到了需要通过反射获得方法真实参数名的场景,在这里我遇到了一些小小的问题,后来在部门老大的指导下,我解决了这个问题。通过解决这个问题,附带着我了解到了很多新的知识,我觉得