公司动态
热部署 12 次后 Metaspace 涨了 400MB:打破双亲委派之后,我们踩的 3 个坑
title: 热部署 12 次后 Metaspace 涨了 400MB打破双亲委派之后我们踩的 3 个坑tags: [JVM, 类加载器, 双亲委派, Metaspace, Java]category: 后端一个「越用越胖」的规则引擎我们有个营销规则引擎业务方在后台改规则系统把规则编译成 Java 类动态加载进来不用重启。上线半年一直没事直到某次大促前压测运维发来一张图Metaspace 从 180MB 一路涨到 620MBFull GC 触发了 7 次每次停顿 1.4 秒。规则本身只有 200 多条编译出来的 class 文件加起来不到 3MB。600MB 是从哪来的答案是每次热更新都新建了一个类加载器而旧的那批一个都没被回收。这篇把那次排查涉及的双亲委派机制、打破委派的正确姿势、以及类加载器泄漏的判定方法一起讲清楚。环境是 JDK 11.0.14、Spring Boot 2.5.6。先把双亲委派的代码看一遍双亲委派不是什么玄学就是ClassLoader#loadClass里的十几行protected Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 1. 先查这个加载器自己是否已经加载过 Class? c findLoadedClass(name); if (c null) { long t0 System.nanoTime(); try { if (parent ! null) { // 2. 有父加载器就交给父加载器 c parent.loadClass(name, false); } else { // 3. 没有父加载器说明到顶了交给启动类加载器 c findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 父加载器加载失败是正常情况吞掉继续 } if (c null) { // 4. 父加载器都搞不定才轮到自己 long t1 System.nanoTime(); c findClass(name); sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); } } if (resolve) { resolveClass(c); } return c; } }几个容易被忽略的细节第 2 行的getClassLoadingLock在支持并行加载的加载器上返回的是每个类名一把锁而不是整个加载器一把锁。这是 JDK 7 之后的优化否则多线程加载不同类会互相阻塞。第 5 行findLoadedClass查的是当前加载器的已加载记录不是全局的。同一个类被不同加载器加载会产生两份 Class 对象这是后面所有ClassCastException的根源。第 15 行把ClassNotFoundException吞掉这是委派机制能「逐级降级」的关键。真正要自定义加载逻辑应该重写findClass第 21 行调用的而不是loadClass。重写loadClass等于把委派整个接管过来很容易出错。双亲委派解决的核心问题只有一个保证同一个全限定名的类在一条加载器链上只有一份。你在应用里写一个java.lang.String它永远加载不进去因为委派到启动类加载器时就已经找到了 JDK 自带的那个。什么时候必须打破它规则很清楚但有三类场景绕不过去场景为什么必须打破典型实现Web 容器隔离多个应用两个 war 可能依赖同一个库的不同版本TomcatWebappClassLoaderSPI 加载实现类接口在 rt.jar实现在 classpath父加载器看不到子的东西线程上下文类加载器热部署 / 动态代码同一个类名要能加载新版本自定义URLClassLoader模块化隔离插件之间不能互相看见OSGi、Java 9 模块我们的规则引擎属于第三类。原始实现大致是这样public class RuleClassLoader extends ClassLoader { private final MapString, byte[] classBytes; // 编译后的字节码 public RuleClassLoader(MapString, byte[] classBytes, ClassLoader parent) { super(parent); this.classBytes classBytes; } Override protected Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { Class? c findLoadedClass(name); if (c null) { if (name.startsWith(com.xxx.rule.generated.)) { // 规则类自己加载不委派给父加载器打破点 byte[] bytes classBytes.get(name); if (bytes ! null) { c defineClass(name, bytes, 0, bytes.length); } } if (c null) { // 其他类仍然走标准委派保证 JDK 类和框架类只有一份 c super.loadClass(name, false); } } if (resolve) { resolveClass(c); } return c; } } }这段代码本身没错写法也算规范——只对特定包名打破委派其余全部交回父加载器。这是我认为打破委派时唯一正确的姿势范围越窄越好。真正的问题出在使用它的地方。第一个坑加载器被静态引用钉死了热更新的代码是这样写的Component public class RuleEngine { // 每次更新都会替换成新的加载器 private volatile RuleClassLoader currentLoader; private volatile MapString, Rule ruleCache new ConcurrentHashMap(); public void reload(MapString, byte[] compiled) { RuleClassLoader loader new RuleClassLoader(compiled, getClass().getClassLoader()); MapString, Rule newRules new ConcurrentHashMap(); for (String className : compiled.keySet()) { Class? clazz loader.loadClass(className); newRules.put(className, (Rule) clazz.getDeclaredConstructor().newInstance()); } this.currentLoader loader; this.ruleCache newRules; // 老的 map 被替换看上去旧对象都能回收 } }看起来很干净老的ruleCache被整体替换旧规则对象没人引用了旧加载器也应该跟着走。但 Metaspace 就是不降。用 MAT 分析 heap dump才发现旧的RuleClassLoader被这条链拴着RuleClassLoader ← ClassLoader.classes (Vector) ← 生成的 RuleImpl_20241108.class ← 某个静态 MapString, Rule 里的实例 ← 罪魁祸首 ← com.xxx.monitor.RuleMetricsCollector.CACHE (static)监控模块里有个静态 Map用来统计每条规则的执行耗时key 是规则名value 里存了规则实例本身。这个 Map 从来没清理过。一个规则实例 → 它的 Class → 加载它的 ClassLoader → 这个加载器加载的所有 Class。一条静态引用就把整个加载器扣下来了。类加载器的回收条件比普通对象苛刻得多必须同时满足三条该加载器加载的所有类的实例都已被回收该加载器本身没有被任何地方引用这些 Class 对象没有在任何地方被反射引用。差一条都不行。而静态字段是 GC Root天然违反第一条。第二个坑ClassCastException 来得莫名其妙修完泄漏之后又冒出一个新问题规则更新后某些代码路径开始抛ClassCastException异常信息看着像在说胡话java.lang.ClassCastException: com.xxx.rule.generated.DiscountRule cannot be cast to com.xxx.rule.generated.DiscountRule同一个类名转不成自己。这正是双亲委派被打破后最经典的症状JVM 判定两个类是否相同看的是「全限定名 类加载器」这个组合不是名字。复现代码Test void 不同加载器加载的同名类不相等() throws Exception { MapString, byte[] bytes compileRule(DiscountRule); RuleClassLoader loaderA new RuleClassLoader(bytes, parentLoader); RuleClassLoader loaderB new RuleClassLoader(bytes, parentLoader); Class? ca loaderA.loadClass(com.xxx.rule.generated.DiscountRule); Class? cb loaderB.loadClass(com.xxx.rule.generated.DiscountRule); assertEquals(ca.getName(), cb.getName()); // 名字一样 assertNotSame(ca, cb); // 但不是同一个 Class 对象 assertNotEquals(ca.getClassLoader(), cb.getClassLoader()); Object instance ca.getDeclaredConstructor().newInstance(); // 下面这行必然抛 ClassCastException assertThrows(ClassCastException.class, () - cb.cast(instance)); }我们的问题是有个异步线程在规则更新的瞬间还持有旧加载器产出的实例回调时用新加载器的类型去接就炸了。解法是让接口留在父加载器Rule接口由应用类加载器加载规则实现类由RuleClassLoader加载。这样不管实现类换了多少个加载器向上转型到Rule接口永远安全。这也是 SPI、Tomcat、各种插件框架统一采用的模式——打破委派的边界必须划在接口之下不能把接口也隔离掉。第三个坑线程上下文类加载器不是万能钥匙修完前两个坑还剩一个偶发问题规则里如果用到 JDBC 或者某些框架的 SPI 加载会报ServiceConfigurationError。原因是 SPI 的实现类查找依赖线程上下文类加载器TCCL而我们的规则执行跑在一个自建线程池里线程创建时继承的是父线程的 TCCL未必是我们期望的那个。处理方式很直接执行前显式设置、执行后还原public Object executeRule(Rule rule, RuleContext ctx) { Thread current Thread.currentThread(); ClassLoader original current.getContextClassLoader(); try { // 让规则执行期间的 SPI 查找能看见规则加载器 current.setContextClassLoader(rule.getClass().getClassLoader()); return rule.execute(ctx); } finally { // 必须还原否则线程池复用时会把加载器泄漏给下一个任务 current.setContextClassLoader(original); } }finally里的还原不是可选项。线程池里的线程是复用的如果不还原这个线程后续执行的所有任务都会带着旧加载器既可能触发上面说的ClassCastException又会让加载器无法回收——泄漏原地复活。我们上一版代码就是漏了finally结果修完静态 Map 之后 Metaspace 还是慢慢涨多花了半天才找到。复盘数据修复前热更新 12 次后 Metaspace 从 180MB 涨到 620MBFull GC 7 次最长停顿 1.4s。修复后连续热更新 50 次Metaspace 稳定在 195MB 上下波动加载器数量通过jcmd pid VM.classloader_stats观察始终不超过 3 个当前 上一个 正在回收的。定位耗时静态 Map 那条引用链花了约 4 小时其中大部分时间在 MAT 里翻 dominator treeTCCL 那个坑花了 3 小时。一个有用的日常检查命令jcmd pid VM.classloaders它会把加载器树打出来。如果你看到几十个同名的自定义加载器堆在那基本可以确认泄漏了。我的判断打破双亲委派这件事我的态度是能不打破就别打破非打破不可就把范围压到最小。具体到几个常见诉求只是想加载外部 jar—— 用标准URLClassLoader别自己重写loadClass。它已经处理好委派了。想做插件隔离—— 接口放父加载器实现放子加载器这是唯一不会出岔子的分层方式。想做热部署—— 先问一句业务是否真的不能重启。说实话在容器化和滚动发布已经很成熟的今天为了「不重启」引入自定义类加载器收益往往不如想象中大。我们那个规则引擎如果重来一次我会更倾向于把规则做成解释执行的表达式比如 Aviator、QLExpress而不是编译成 class 动态加载。表达式引擎没有类加载器泄漏这一类问题调试也简单得多。动态加载 class 适合的是那种规则数量少、变更频率极低、但逻辑复杂到表达式表达不了的场景。如果你的规则能用表达式写清楚就不要碰类加载器。思考题Tomcat 的WebappClassLoader打破了双亲委派优先加载WEB-INF/classes下的类。那么问题来了如果你在自己的 war 包里放一个javax.servlet.Servlet接口的同名类Tomcat 会加载你的还是容器的提示看看 Tomcat 的delegate属性默认值以及WebappClassLoaderBase#filter方法里对哪些包名做了特殊处理。你线上遇到过类加载器泄漏吗用什么工具定位的评论区聊聊。