Administrator
发布于 2019-03-18 / 3694 阅读
80

一次 Metaspace OOM 的排查:动态代理类撑爆元空间

凌晨三点的告警:Metaspace OOM

3 月 15 号凌晨 3 点 12 分,监控告警把值班同学叫起来了:报表服务的一台实例 Full GC 频繁,接口全部超时。我早上到公司看日志,第一行就是:

java.lang.OutOfMemoryError: Metaspace
    at java.lang.ClassLoader.defineClass1(Native Method)
    at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
    at org.springframework.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:538)
    at org.springframework.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:363)
    at org.springframework.cglib.proxy.Enhancer.generate(Enhancer.java:582)
    at org.springframework.cglib.proxy.Enhancer.createHelper(Enhancer.java:569)
    at org.springframework.cglib.proxy.Enhancer.create(Enhancer.java:386)
    at com.xxx.report.TemplateExporter.buildProxy(TemplateExporter.java:74)
    at com.xxx.report.TemplateExporter.export(TemplateExporter.java:41)

运维说重启之后好了,但隔了 6 小时又挂了,一夜重启了三次。

看监控:元空间只涨不落

我们的 JVM 参数是标准的 JDK 8 配置(公司统一模板):

-Xms4g -Xmx4g -Xmn1g -XX:+UseConcMarkSweepGC -XX:+UseParNewGC
-XX:+CMSClassUnloadingEnabled -XX:+PrintGCDetails -Xloggc:/data/logs/gc.log

注意里面没有 -XX:MaxMetaspaceSize。JDK 8 用 Metaspace 取代了永久代,它分配在本地内存(native memory),默认不限制大小,只受机器物理内存约束。这既是好事也是坏事:正常应用不会被 PermGen 那些默认值卡住,但一旦泄漏,就会一路吃到把整个机器的内存耗光。

从监控曲线上看,Metaspace 使用量(MU)从启动时的 138M 一路爬到 1.9G,全程没有一次回落,直到进程挂掉。老年代(OU)一直在 1.6G 左右震荡,很健康。

$ jstat -gcutil 28417 5000
  S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
  0.00  34.21  71.55  41.83  74.66  72.04   3182   41.227    19    6.534   47.761
  0.00  34.21  78.92  41.83  75.31  72.61   3190   41.338    19    6.534   47.872
  0.00  34.21  86.10  41.83  76.08  73.29   3198   41.451    19    6.534   47.985

M 列(Metaspace 使用率)在 5 秒的间隔里稳定上涨,而且 FGC 没有任何下降效果。这说明这些类元数据都有强引用,GC 回收不掉

到底是谁在不停地造类

在测试环境复现的时候加了两个参数,把类的加载和卸载都打出来:

-XX:+TraceClassLoading -XX:+TraceClassUnloading

日志里刷屏的都是同一个模式:

[Loaded com.xxx.report.TemplateExporter$$EnhancerByCGLIB$$8f3a1c02 from __JVM_DefineClass__]
[Loaded com.xxx.report.TemplateExporter$$EnhancerByCGLIB$$c41b7e95 from __JVM_DefineClass__]
[Loaded com.xxx.report.TemplateExporter$$EnhancerByCGLIB$$2d9f0087 from __JVM_DefineClass__]

六个小时内这类日志有 12.8 万条,而 TraceClassUnloading 一条都没出现。CGLib 生成代理类时,类名后面那段 hash 是随机的,每生成一次都是一个全新的类

jmap 看类加载器的分布,问题更清楚了:

$ jmap -clstats 28417 | head -20
 class_loader            classes   bytes        parent_loader      alive?  type
 <bootstrap>              2714     5012872        null             live   <internal>
 0x00000006c02b1e40        3       14208         0x00000006c0011000 dead   TemplateClassLoader
 0x00000006c02b2a80        3       14208         0x00000006c0011000 dead   TemplateClassLoader
 0x00000006c02b36c0        3       14208         0x00000006c0011000 dead   TemplateClassLoader
 ...(共 3721 个 TemplateClassLoader)

3700 多个 TemplateClassLoader 实例,状态全是 dead,却一个都没被回收。

根因一:每次导出都 new 一个 ClassLoader

看那段代码(我简化过):

@Service
public class TemplateExporter {

    public byte[] export(String templateId, List<Map<String, Object>> rows) {
        // 每次调用都新建一个 ClassLoader 去加载模板类
        TemplateClassLoader loader = new TemplateClassLoader(templateId);
        Class<?> templateClass;
        try {
            templateClass = loader.loadClass("com.xxx.report.TemplateExporter");
        } catch (ClassNotFoundException e) {
            throw new IllegalStateException(e);
        }

        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(templateClass);
        enhancer.setCallback(new ExportInterceptor(rows));    // 无状态的拦截器
        Object proxy = enhancer.create();                     // 每次生成新的代理类
        return ((Exporter) proxy).doExport();
    }
}

CGLib 内部其实有类缓存,AbstractClassGenerator 用一个以 ClassLoader 为 key 的 WeakHashMap 存已经生成过的类:

// org.springframework.cglib.core.AbstractClassGenerator
private static volatile Map<ClassLoader, ClassLoaderData> CACHE =
        new WeakHashMap<ClassLoader, ClassLoaderData>();

protected Object create(Object key) {
    ClassLoader loader = getDefaultClassLoader();
    Map<ClassLoader, ClassLoaderData> cache = CACHE;
    ClassLoaderData data = cache.get(loader);
    if (data == null) {
        synchronized (AbstractClassGenerator.class) {
            cache = CACHE;
            data = cache.get(loader);
            if (data == null) {
                Map<ClassLoader, ClassLoaderData> newCache =
                        new WeakHashMap<ClassLoader, ClassLoaderData>(cache);
                data = new ClassLoaderData(loader);
                newCache.put(loader, data);
                CACHE = newCache;
            }
        }
    }
    // ...
}

缓存的 key 是 ClassLoader,而我们每次导出都 new 一个新的 TemplateClassLoader,所以缓存永远命中不了,每个请求都要重新生成一个代理类。这个设计在正常用法下(同一个 ClassLoader)是有效的,我们等于把它废掉了。

根因二:类加载器卸不掉

如果只是生成类,类不用了应该能被回收。要卸载一个类,必须满足三个条件,缺一不可:

  • 该类的所有实例都已被回收
  • 加载该类的 ClassLoader 已被回收
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用

我们的代码三条全踩了。ExportInterceptor 里持有了 rows 的引用,而这个拦截器被 CGLib 生成的代理类静态引用着(CGLib 会在代理类里生成 CGLIB$THREAD_CALLBACKS 这类静态字段)。更致命的是我在另一个地方还加了个"优化":

// 本意是缓存,实际上制造了泄漏
private static final Map<String, Object> PROXY_CACHE = new HashMap<>();

public Object getProxy(String templateId, Class<?> templateClass) {
    Object proxy = PROXY_CACHE.get(templateId);
    if (proxy == null) {
        proxy = doCreateProxy(templateClass);
        PROXY_CACHE.put(templateId, proxy);     // 强引用!
    }
    return proxy;
}

这个静态 Map 强引用着代理对象,代理对象的类强引用着 TemplateClassLoaderTemplateClassLoader 又强引用着它加载过的所有类。整条链都活着,谁也回收不掉。而且这个 HashMap 在并发写的时候还有死循环风险(JDK 7 的经典问题,JDK 8 改成了尾插法不会死循环,但会有数据丢失)。

先止血:给 Metaspace 加上限

出事当天要恢复服务,第一步是加参数:

-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m

这里要说明清楚:加上限不是解决问题,是让问题可控MetaspaceSize 是触发 Metaspace GC 的初始阈值(不是初始大小),MaxMetaspaceSize 是硬上限。设了之后进程会在 512M 的时候 OOM 退出,比吃光机器 8G 内存、把同机器上的 Redis 一起拖死要好得多。同时监控上有了明确的告警点,不用等到机器 OOM killer 出手。

我们顺便把 GC 日志里的类卸载统计也打开了,方便观察修复效果:

-XX:+PrintClassHistogramBeforeFullGC -XX:+TraceClassUnloading

根治:三处改动

改动一:ClassLoader 复用

模板类的加载没有理由每次新建。按 templateId 缓存 ClassLoader,并且用 ConcurrentHashMap

private final Map<String, TemplateClassLoader> loaders = new ConcurrentHashMap<>();

private TemplateClassLoader loaderOf(String templateId) {
    return loaders.computeIfAbsent(templateId, TemplateClassLoader::new);
}

改动二:缓存代理对象,而不是每次重建

拦截器里那些 rows 是无状态的,把它改成方法参数传进去,代理对象就可以做成单例:

private final Map<String, Exporter> exporters = new ConcurrentHashMap<>();

public byte[] export(String templateId, List<Map<String, Object>> rows) {
    Exporter exporter = exporters.computeIfAbsent(templateId, id -> {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(loaderOf(id).loadTemplateClass());
        enhancer.setCallback(NoOp.INSTANCE);      // 不再持有业务数据
        return (Exporter) enhancer.create();
    });
    return exporter.doExport(rows);               // 数据走参数
}

改动三:其实可以不用 CGLib

改造的时候我问了自己一个问题:这里为什么需要代理?答案是"当年有人觉得动态改模板很酷"。实际上模板之间的差异只是字段映射规则,用配置 + 反射就够了。第二版我们把 CGLib 完全去掉了,改成读配置生成字段映射表,代码从 180 行降到 60 行。

修复效果

指标修复前修复后
Metaspace 使用量(48 小时)138M → 1.9G 持续上涨稳定在 142M
加载类总数(6 小时)128,4003,200(总量)
TemplateClassLoader 实例数3,7217(模板数量)
类卸载日志0 条模板过期时有输出
接口 P99因频繁 FGC 波动到 4s稳定 68ms

小结

  • Metaspace 只涨不落,基本可以断定是类加载器泄漏。类元数据要被卸载,必须同时满足"实例死、ClassLoader 死、Class 对象没人引用"三个条件,任何一条强引用都会让它留下来。
  • CGLib 的类缓存以 ClassLoader 为 key,每次 new ClassLoader 就等于禁用缓存,每个请求都会生成一个新的代理类。看到日志里 $$EnhancerByCGLIB$$ 后面跟不同的随机后缀刷屏,就是典型的这个症状。
  • -XX:MaxMetaspaceSize 是止损用的,它把"耗尽物理内存拖垮整机"变成"这个进程自己 OOM 掉"。真正的修复是让类能停下来或者能被回收。
  • 写"缓存"的时候一定要想清楚:缓存的 value 会不会间接引用住 ClassLoader、ThreadLocal 这种生命周期很长的东西。static 容器是类加载器泄漏的头号帮凶。

事后的一个反思:这个模块我入职时就觉得设计得奇怪,但因为"能跑"就没动它。元空间泄漏这种问题,早发现的成本比凌晨三点起来处理要低得多。现在我每周会看一次所有服务的 Metaspace 曲线,如果它不是平的,一定有问题。

参考