当前位置: 首页 > news >正文

热部署 12 次后 Metaspace 涨了 400MB:打破双亲委派之后,我们踩的 3 个坑


title: 热部署 12 次后 Metaspace 涨了 400MB:打破双亲委派之后,我们踩的 3 个坑
tags: [JVM, 类加载器, 双亲委派, Metaspace, Java]
category: 后端


一个「越用越胖」的规则引擎

我们有个营销规则引擎,业务方在后台改规则,系统把规则编译成 Java 类动态加载进来,不用重启。上线半年一直没事,直到某次大促前压测,运维发来一张图:Metaspace 从 180MB 一路涨到 620MB,Full 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 可能依赖同一个库的不同版本TomcatWebappClassLoader
SPI 加载实现类接口在 rt.jar,实现在 classpath,父加载器看不到子的东西线程上下文类加载器
热部署 / 动态代码同一个类名要能加载新版本自定义URLClassLoader
模块化隔离插件之间不能互相看见OSGi、Java 9 模块

我们的规则引擎属于第三类。原始实现大致是这样:

public class RuleClassLoader extends ClassLoader { private final Map<String, byte[]> classBytes; // 编译后的字节码 public RuleClassLoader(Map<String, 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 Map<String, Rule> ruleCache = new ConcurrentHashMap<>(); public void reload(Map<String, byte[]> compiled) { RuleClassLoader loader = new RuleClassLoader(compiled, getClass().getClassLoader()); Map<String, 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 ← 某个静态 Map<String, Rule> 里的实例 ← 罪魁祸首 ← com.xxx.monitor.RuleMetricsCollector.CACHE (static)

监控模块里有个静态 Map,用来统计每条规则的执行耗时,key 是规则名,value 里存了规则实例本身。这个 Map 从来没清理过。

一个规则实例 → 它的 Class → 加载它的 ClassLoader → 这个加载器加载的所有 Class。一条静态引用,就把整个加载器扣下来了。

类加载器的回收条件比普通对象苛刻得多,必须同时满足三条:

  1. 该加载器加载的所有类的实例都已被回收;
  2. 该加载器本身没有被任何地方引用;
  3. 这些 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 { Map<String, 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 涨到 620MB,Full GC 7 次,最长停顿 1.4s。
  • 修复后:连续热更新 50 次,Metaspace 稳定在 195MB 上下波动,加载器数量通过jcmd <pid> VM.classloader_stats观察始终不超过 3 个(当前 + 上一个 + 正在回收的)。
  • 定位耗时:静态 Map 那条引用链花了约 4 小时,其中大部分时间在 MAT 里翻 dominator tree;TCCL 那个坑花了 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方法里对哪些包名做了特殊处理。

你线上遇到过类加载器泄漏吗?用什么工具定位的?评论区聊聊。

http://www.jsqmd.com/news/1327323/

相关文章:

  • 文件传输在设备通信中的实现原理
  • 二阶锥规划在配电网无功优化中的Matlab实现
  • 郑州新郑市家电坏了不用东找西找:新建路一站式全品类维修实测 - 观金堂
  • Logisim-evolution时序仿真完整指南:掌握数字电路设计的5个关键技巧
  • Linux 驱动内存子系统核心知识精要(纯干货版)
  • OBS多路RTMP推流插件:一键实现多平台直播同步的终极解决方案
  • 2026年纸皮核桃加工厂家挑选攻略 国内靠谱企业实测盘点 - 比奇堡111
  • 车载Audio子系统开发实战:从ALSA驱动到设备树配置全解析
  • 2026 福州马尾区家电故障自检手册:马尾镇这些小毛病自己能先查 - 观金堂
  • GGSLB全局负载均衡策略详解
  • 2026锦州买华为手机选哪家?三家合规授权门店综合实力** - 产品推荐官
  • MQTT Broker存在的必要性
  • AI辅助英语阅读的黄金4.7秒法则(神经语言学验证+眼动追踪实验首发)
  • 【计算机毕业设计】基于大模型的游戏交易系统的设计与实现
  • 10人如何借助一台高配置工作站进行SolidWorks高效设计?
  • 2026年辽阳厨师考试机构 满足多元考证需求 推荐合规靠谱选项 - 滚动商讯
  • AI生成3D模型后怎么自动生成贴图?用V2Fun从基础纹理到可用材质
  • 2026 广州钻石回收哪家靠谱?易奢福到店核验当面估价 - 奢侈品回收实体店探店
  • 终极指南:Universal-Updater让3DS自制软件管理变得像玩游戏一样简单
  • 学术会议投稿与参会全流程详解:从选会到检索的完整指南 - RDLink研发家
  • 每日论文解读(8.3)2——AgentHPOBench:面向序列超参数优化的LLM Agent评估基准
  • 揭秘Transformer推理功耗黑洞:5个被99%团队忽略的能效优化关键节点
  • 2024Q2最新数据:已部署AI新质生产力的企业订单交付周期缩短41%,你还在用旧范式决策?
  • 一文读懂档案袋怎么密封?避开90%人踩中的档案失效误区 - 资讯报道
  • 5个选项搞定工业以太网交换机选型
  • 抢抓团餐产业升级新机遇,仙津饮品依托核心产品力拓展农产品渠道饮料合作空间,助力团餐食材供应多元化 - U渠道
  • 2026燕郊全屋定制工厂** 两大主流源头工厂实力对比 - 资讯综合
  • 阜阳企业布局豆包流量:读懂豆包排名优化与GEO优化落地思路
  • 5分钟快速搭建原神私服:KCN-GenshinServer图形化一键服务端终极指南
  • 极简高端工装风!仿石材铝单板适配各类公装项目