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

16,526 阅读5分钟

问题背景

有同学反馈,在自己的业务中调用 groovy 脚本动态生成一些 class 的时候,出现了类无法卸载的现象,下图来自你假笨大神 PerfMa 公司 的 XElephant 「 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<S> extends 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 吧。

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