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

点击上方“服务端思维”,选择“设为星标”

回复”669“获取独家整理的精选资料集

回复”加群“加入全国服务端高端社群「后端圈」

8f20ff3a98c3a1a26d346ad0f71b5042.png

作者 | 挖坑的张师傅

出品 | 张师傅的博客

问题背景

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

2af2e9fa9970c61a7dab3bd6c55e5b19.png

如果想离线分析也可以用 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

878444fe6bcb474e0ea887b85fa9210e.png

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

a9781a91a061d3aeac054a67eb4ae6d6.png

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

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

8a7053b3abbd935b9712c3dfb2e0fd3a.png

为啥会被放到 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 相关的信息。

f465bca8f71a115ee0cfab4e2993792c.png

可以看到  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 链。

9467c0244ed994709850b41267add119.png

可以看到 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 查看,也找不到相关的类存在。

a1b22ed33fe643b35086c242f856a1d5.png

小结

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

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

— 本文结束 —

0d48197e2816582503beae97431f41aa.gif

● 漫谈设计模式在 Spring 框架中的良好实践

● 颠覆微服务认知:深入思考微服务的七个主流观点

● 人人都是 API 设计者

● 一文讲透微服务下如何保证事务的一致性

● 要黑盒测试微服务内部服务间调用,我该如何实现?

8f1dd97fe68381e5b51eed5361a47d48.png

关注我,回复 「加群」 加入各种主题讨论群。

对「服务端思维」有期待,请在文末点个在看

喜欢这篇文章,欢迎转发、分享朋友圈

79ea76575dba3d96d91757aed82913b0.png

在看点这里

3604f255c5784546f0360f003140e7a4.gif

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值