看到这条堆栈的时候,我第一反应是:又是类加载器打架。TongWeb 7049m10 上部署应用,日志里突然冒出一句java.lang.ClassCastException: xxx cannot be cast to com.tongweb.eclipse.jdt.internal.compiler.lookup.TypeBinding,如果你手里正好也有一个叫"lqw"的环境或者工单,那大概率会经历一段"看日志觉得什么都没错,但功能就是挂"的诡异时间。
这条报错不是普通的类型强转失败,它牵扯到应用服务器内置的 Java 编译器实现、应用自身的依赖打包方式,以及 Web 应用的类加载隔离机制。我把这次完整的分析过程、排查顺序和落地方案整理出来,希望能帮正在跟 TongWeb、跟 JDT 编译链路较劲的同学少走几步弯路。不管你是刚接触国产中间件的初级开发,还是负责应用迁移上线的运维,这套思路都应该够用。
1. 先看懂这条报错到底在说什么
1.1 报错现象的完整形态
先还原一下现场。应用部署到 TongWeb 7049m10 后,首次请求某个 JSP 页面,或者触发了某个动态编译逻辑时,控制台或应用日志里出现类似这样的堆栈:
java.lang.ClassCastException: class org.eclipse.jdt.internal.compiler.lookup.ReferenceBinding cannot be cast to class com.tongweb.eclipse.jdt.internal.compiler.lookup.TypeBinding (org.eclipse.jdt.internal.compiler.lookup.ReferenceBinding and com.tongweb.eclipse.jdt.internal.compiler.lookup.TypeBinding are in unnamed module of loader 'app' and loader 'ParallelWebappClassLoader' respectively)关键信息有两部分。前面的class A cannot be cast to class B说明代码试图把 A 类型对象强转为 B 类型;后面括号里的are in unnamed module of loader 'app' and loader 'ParallelWebappClassLoader' respectively是 JVM 在告诉你:这两个类型虽然名字看着像,但它们出自两个不同的类加载器。
在 TongWeb 里,应用自己的类和依赖由ParallelWebappClassLoader这类 Web 应用类加载器加载,而服务器内部组件用的类由更上层的 Common/Server 类加载器加载。同一个全限定名出现在两个加载器里,JVM 一定认为它们是两个截然不同的类,强转必然失败。
1.2 TypeBinding 是 JDT 编译器内部的"类型符号"
com.tongweb.eclipse.jdt.internal.compiler.lookup.TypeBinding这串名字看着很长,拆开解读并不复杂。org.eclipse.jdt是 Eclipse 的 Java 开发工具集,internal.compiler.lookup是 Eclipse 编译器(也叫 ECJ,Eclipse Compiler for Java)的内部包路径,TypeBinding则是编译器在编译 Java 源码时使用的类型绑定对象。
打个比方:ECJ 在把 Java 源码变成字节码的过程中,需要在内存里维护一张"类型登记表",记录每一个类、接口、数组、基本类型到底是什么、有什么字段方法、继承关系如何。TypeBinding就是这张表里"类型条目"的公共抽象基类。ReferenceBinding、ArrayBinding、BaseTypeBinding等具体类型都继承自它。
JSP 编译、动态 Java 代码编译、注解扫描等场景都会经过这套编译器内部结构。一般来说,应用代码不应该直接接触到TypeBinding,如果堆栈里出现了这个类,说明某个组件已经钻进了编译器内部。
1.3 ClassCastException 为什么会出现在这里
很多人不理解:为什么服务器自己内部强转都会失败?
原因通常是"调用方把自己的类传给了服务器,服务器按自己的类型去接"。TongWeb 内置的 JDT 编译器经过包名重定位,变成了com.tongweb.eclipse.jdt.*命名空间;而应用侧如果自己打包了标准的org.eclipse.jdt.*或旧版 ECJ,两边就是完全不同的两套类。
举个例子,应用里有一个动态编译工具,先把一段源码编译为 Class,过程中产生了org.eclipse.jdt.internal.compiler.lookup.ReferenceBinding对象;这个对象在某个环节被传给 TongWeb 的编译回调,服务器端代码期待的是自家的com.tongweb.eclipse.jdt.internal.compiler.lookup.TypeBinding,于是强转瞬间崩掉。
理解这个链条之后,排查方向就清晰了:谁把编译器对象传给了谁,以及为什么会有两套 JDT。
2. 为什么偏偏是 TongWeb 和 JDT 容易撞车
2.1 服务器内置编译器的两套命名空间
TongWeb 基于 Tomcat 内核,但做了大量国产化和自研改造。为了不对标准 Eclipse JDT 的包名产生冲突,TongWeb 对内置的 ECJ 做了包名 relocation,把org.eclipse.jdt.*改成了com.tongweb.eclipse.jdt.*。
正常情况下,应用不打包任何 JDT 相关依赖,只会用到服务器提供的这一套,一切安好。问题出现在应用自带了 ECJ/JDT 依赖时:国内很多项目为了支持热部署、动态规则脚本、JSP 离线预编译、或者某些代码生成工具,会在WEB-INF/lib里放一个ecj-3.x.jar或org.eclipse.jdt.core-3.x.jar。这个时候类路径上就出现了两个"编译内核":
| 来源 | 包名 | 类加载器 |
|---|---|---|
| TongWeb 服务器内置 | com.tongweb.eclipse.jdt.* | Server/Common 类加载器 |
| 应用自带的 ECJ/JDT | org.eclipse.jdt.* | ParallelWebappClassLoader |
只要某个调用横跨了两套类,就会出现 1.3 里那种cannot be cast to com.tongweb.eclipse.jdt.internal.compiler.lookup.TypeBinding异常。
2.2 最常见的三类触发场景
这类问题我在不同项目里见过好几次,触发源基本都是下面三类。
首先是 Lombok。Lombok 在编译期大量使用 ECJ 的注解处理 API,但它主要作用于 Maven/Gradle 构建阶段,不会直接跑在运行期。不过有些项目把 Lombok 错误地当成运行期依赖打进了 war 包,TongWeb 的 JSP 编译链路扫描到 Lombok 的注解处理器时,就可能发生编译器内部类型串味。
其次是动态编译工具。很多规则引擎、低代码平台、脚本化功能会在运行期用javax.tools.JavaCompiler或者直接封装 ECJ 做 Java 源码编译。这类工具往往自己打包了 JDT,而且会通过反射操作编译器内部 API,最容易踩中TypeBinding的强转坑。
第三类是字节码增强组件。CGLIB、ASM、Java Instrumentation 代理、APM 探针等,它们在运行期操作类时偶尔会触发编译器内部逻辑。尤其是自定义 ClassLoader 的场景,一旦把应用里的 JDT 类带入服务器编译链路,报错就很随机,有时候重启一下又好了,实际上只是类加载顺序变了。
2.3 版本错配:7049m10 的内部接口并不稳定
TongWeb 7049m10 内置的 JDT 版本不是固定的,厂商会在补丁里升级 ECJ。而internal.compiler.lookup这类包是 Eclipse 内部的私有实现,不同版本的TypeBinding继承结构、方法签名、字段布局都可能变化。
如果你的应用里打包的 ECJ 版本恰好和 TongWeb 内置版本不同,即使通过某种方式把包名统一成了org.eclipse.jdt.*,在lookup包内部的兼容性也可能出问题。比如你在应用里用的是 ECJ 3.16,服务器内置的是 ECJ 3.21,某些内部方法返回值从TypeBinding变成了更具体的子类,或者改变了绑定对象的构造方式,强转就会失败。
这就是为什么我排查这类问题,第一步从来不是急着改代码,而是先把"服务器到底用哪个 JDT、应用侧到底打包了哪个 JDT"搞清楚。
3. 排查这条路,我建议按这个顺序走
3.1 先抓完整堆栈,定位谁在强转
报错日志不要只看第一行,Caused by和后面的at xxx.xxx.xxx才是真正有用的信息。把完整堆栈贴到文本编辑器里,找到最顶层、离你的业务代码最近的那几行,看清楚是哪一帧发起的 cast。
一般情况下,堆栈会指向几个固定位置:
org.apache.jasper.compiler.JDTCompiler.generateJavaClass一类的 JSP 编译逻辑com.tongweb...compiler...generateCode之类的服务器编译回调- 应用里的
JavaCompilerApi、EclipseCompiler、GroovyClassLoader等自定义编译入口
如果堆栈指向 JSP 编译逻辑,说明是应用里某个框架在 JSP 编译阶段触发;如果指向自定义编译入口,说明应用自己的代码参与其中,要重点查那个工具的依赖。用jstack拉线程栈也行,但多数情况下日志已经够用了,关键是不要被前面一堆ClassNotFoundException之类无关信息带偏。
3.2 确认 TypeBinding 到底被加载了几次
定位到类加载器冲突后,用最直接的方式确认:看看TypeBinding.class在 JVM 里到底有几个版本。我惯用的命令是这样:
# 通过 jcmd 查看类加载信息 jcmd <pid> VM.class_hierarchy -i -s com.tongweb.eclipse.jdt.internal.compiler.lookup.TypeBinding # 或者在服务器日志里开启类加载追踪 java -verbose:class -jar ./startup.jar 2>&1 | grep "TypeBinding"如果输出里同时出现com.tongweb.eclipse.jdt.internal.compiler.lookup.TypeBinding和org.eclipse.jdt.internal.compiler.lookup.TypeBinding,并且加载器不同,那基本就是依赖重复没跑了。
也可以用一个小接口来做运行时判断,把这个接口临时放到你的应用里,请求一次看输出:
ClassLoader cl = Thread.currentThread().getContextClassLoader(); System.out.println(cl.getResource("com/tongweb/eclipse/jdt/internal/compiler/lookup/TypeBinding.class")); System.out.println(cl.getResource("org/eclipse/jdt/internal/compiler/lookup/TypeBinding.class"));如果两个getResource都返回了 jar 路径,说明你应用的类路径上同时存在这两个包;如果第一个返回 null、第二个有路径,说明应用侧只有标准 JDT。这一步能把问题性质锁定。
3.3 扫描依赖冲突:maven dependency tree 与 jar 定位
确认运行期存在双 JDT 之后,回到构建工程里找根因。先用 Maven 看依赖树:
mvn dependency:tree -Dincludes=org.eclipse.jdt:org.eclipse.jdt.core mvn dependency:tree -Dincludes=org.eclipse.jdt:ecj如果是 Gradle 项目:
gradle dependencies --configuration runtimeClasspath | grep -i "eclipse.jdt\|ecj"如果工程里没有直接依赖但 war 包里却有 ecj,八成是某个二方库传递过来的,用mvn dependency:tree -Dverbose看完整路径,确认是哪个模块引了它。
然后直接检查最终产物:
# 以 war 包为例,列出所有与 jdt/ecj 相关的 jar jar tf your-app.war | grep -i "ecj\|jdt\|compiler" # 检查服务器 lib 目录下有没有同样文件 ls -l /TongWeb/lib/ | grep -i "ecj\|jdt"把应用侧和服务器侧的文件名、版本全部列出来,做个对照表。这一步做完,影响面就很清楚了。
3.4 顺带看一眼 TongWeb 的类加载策略配置
TongWeb 的类加载策略默认是 Web 应用优先,也就是说WEB-INF/lib下的类会优先于服务器公共库被应用加载。这种策略好处是应用可移植性强,坏处就是应用里的 JDT 会"遮蔽"服务器的同名类。
如果需要验证,打开 TongWeb 的conf/context.xml或者应用自己的META-INF/context.xml,看delegate属性:
<Context delegate="false">delegate="false"表示 Web 应用优先;delegate="true"表示父加载器优先。这不一定是你这次问题的唯一原因,决定好排查方向前,先把这个配置记录下来,后面方案选择会用到。
4. 给出可落地的处理方案
4.1 首选方案:让应用放弃自带 JDT,统一走服务器编译器
大多数情况下,应用完全没有必要自己打包 JDT。如果你排查后发现,应用里的 ECJ 只是被某个工具"顺手"加载了,或者只是传递依赖带进来的,最干净的处理方式就是把它从发布产物中剔除。
在pom.xml里排除传递依赖:
<dependency> <groupId>com.example</groupId> <artifactId>some-rules-engine</artifactId> <version>1.2.0</version> <exclusions> <exclusion> <groupId>org.eclipse.jdt</groupId> <artifactId>org.eclipse.jdt.core</artifactId> </exclusion> <exclusion> <groupId>org.eclipse.jdt</groupId> <artifactId>ecj</artifactId> </exclusion> </exclusions> </dependency>然后重新打包,用 3.3 的 jar 命令检查一遍,确认WEB-INF/lib下没有任何 jdt/ecj 文件。之后应用里的动态编译需求由 TongWeb 自带的编译器接管。
这样做的前提是:你确认那个业务工具并不强制要求使用自己那套 JDT。实际操作里 90% 的业务场景都不依赖,比如只是生成临时 Class、跑一段 Java 表达式,换到服务器内置编译器完全没感觉。改完重启,问题通常直接消失。
4.2 如果业务必须用 ECJ:把依赖做名字重定位或隔离
有些场景确实绕不开 ECJ,比如自研的脚本引擎深度使用了 Eclipse 编译器的内部 API,或者某个二方库写死了org.eclipse.jdt.*。这种情况不要硬刚,思路就一个字:隔离。
方案一是用 Maven Shade 插件把 JDT 重新定位到公司自己的命名空间:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.4.1</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> <configuration> <relocations> <relocation> <pattern>org.eclipse.jdt</pattern> <shadedPattern>com.yourcompany.jdt</shadedPattern> </relocation> </relocations> <filters> <filter> <artifact>org.eclipse.jdt:org.eclipse.jdt.core</artifact> <excludes> <exclude>META-INF/*.SF</exclude> <exclude>META-INF/*.DSA</exclude> </excludes> </filter> </filters> </configuration> </execution> </executions> </plugin>这样 Android 应用里加载的就是com.yourcompany.jdt.*,和 TongWeb 的com.tongweb.eclipse.jdt.*、标准 JDT 都不冲突。注意,relocation 之后,那个二方库内部如果用了反射、字符串形式的类名、SPI 配置文件,可能也要同步适配,这是改名方案的隐性成本。
方案二更轻一点,不重命名,而是给动态编译工具单独起一个URLClassLoader,只加载它自己的 ECJ jar,不让它进入 TongWeb 的类加载路径。这个方案适合"工具是独立模块、源码可以改"的情况,隔离做得干净,后续升级也方便。
4.3 动态编译需求可以考虑替换编译器实现
如果你们的动态编译功能只是为了编译一些简单的规则、表达式或者小段 Java 代码,其实是可以用更轻量的方案替换掉整套 ECJ 的。
比如 Janino,它的体积小、不需要完整 JDT 内部结构,适合编译表达式和简单类;对 Groovy 脚本支持好的团队,也可以直接把 Groovy 的 CompilerConfiguration 里的类加载器指定到独立加载器,隔离效果也很明显。
替换的前提是评估业务复杂度。如果只是像1+1、a > b ? c : d这种规则表达式,Janino 完全够用;如果业务上有复杂的泛型、注解、内部类生成,那还是维持 ECJ 并做好隔离更稳妥。不要为了拆炸弹而把整个引擎换掉,风险要控制住。
4.4 调整 TongWeb 类加载委托策略
在一些特定场景下,通过配置调整类加载顺序也能规避问题。把应用的context.xml设置为父加载器优先:
<Context delegate="true">这样WEB-INF/lib里的 JDT 就不会轻易"盖过" TongWeb 公共目录下的编译器类。但要注意,delegate="true"会影响所有类加载,不只是 JDT。某些应用依赖 Web 应用类优先的特性(比如自己打包了旧版本的 Spring、MyBatis、日志框架),改完之后可能冒出别的问题。
我的建议是:这个开关作为短期规避可以,但长期来看还是要把依赖理清楚。类加载器策略是全局的,为了一个 JDT 去调整全局策略,属于"为了修一颗牙把全身麻醉",除非你能确认应用里没有任何其他依赖冲突,不然不推荐作为主方案。
4.5 兜底方案:代码层面兜住强转异常
上面的方案都实施完,如果还残留个别边界场景,可以在动态编译入口加一层异常捕获和降级逻辑。比如用反射重写调用,避免直接强转:
Object binding = someInternalObject; if (binding.getClass().getName().equals("com.tongweb.eclipse.jdt.internal.compiler.lookup.TypeBinding")) { // 按 TongWeb 的 TypeBinding 走逻辑 } else if (binding.getClass().getName().equals("org.eclipse.jdt.internal.compiler.lookup.TypeBinding")) { // 按标准 JDT 走逻辑 }这只是兜底,不是根治。它能让你先保证业务不中断,腾出时间去做依赖整改。很多线上问题其实需要"先止血再治病",这种反射判断就是止血手段,但要记得后续把根因清掉,不然代码里到处都是分支判断,维护起来非常痛苦。
另外,TongWeb 本身也在出补丁。如果 7049m10 后厂商发布了修复类加载冲突的补丁版本,建议在验证过兼容性后及时升级。服务器中间件这种底层组件,长期留在有已知缺陷的版本上,风险只会越攒越多。
5. 常见问题与排查技巧速查
| 症状 | 可能原因 | 第一步排查 | 推荐处理 |
|---|---|---|---|
| 首次访问 JSP 报 TypeBinding cast | JSP 编译链路加载到两套 JDT | 看堆栈是否指向 JSP 编译 | 剔除应用自带 ECJ/JDT |
| 规则引擎触发时偶发报错 | 动态编译工具自带 JDT 并传给服务器 | dependency:tree定位传递路径 | 为动态编译工具做独立 ClassLoader |
| Lombok 打包进 war 后报错 | Lombok 注解处理器与服务器编译链路冲突 | 检查 Lombok 作用域是否打入 war | 改为provided作用域 |
| 重启后时好时坏 | 类加载顺序不稳定 | 开启-verbose:class对比两次加载路径 | 统一依赖版本并固化发布清单 |
| 同一个报错在 Tomcat 不出现、TongWeb 出现 | 服务器重定位包名导致双命名空间 | 确认com.tongweb.eclipse.jdt是否只此一家 | 用重定位或隔离方案 |
5.1 用 Arthas 快速定位类的来源
如果你习惯用 Arthas,定位这种类加载器问题会更快。直接进到 JVM 里查看类是哪个加载器加载的:
# 进入 Arthas 控制台 java -jar arthas-boot.jar # 查看指定类的加载器信息 classloader -c <classloader-hash> # 可以先列出所有 classloader sc -d com.tongweb.eclipse.jdt.internal.compiler.lookup.TypeBindingsc -d会输出类的ClassLoader哈希、加载到的 jar 路径、Annotations 等信息,一眼就能看出类到底来自服务器 lib 还是应用 lib。再对比标准org.eclipse.jdt的那一份,两列的差异就是问题所在。
5.2 被 Lombok 坑过的经验
Lombok 这个坑有点隐蔽,因为大多数项目只用它在编译期生成 getter/setter,运行期根本不需要。但如果你项目里 Lombok 被做成了compile作用域,并且最后打进了 war 包,TongWeb 在编译 JSP 时扫描注解处理器,就可能在内部触发 ECJ 的类型绑定逻辑。
排查时不要只盯着业务代码,先把 Lombok 的作用域改掉:
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <scope>provided</scope> </dependency>改了之后重新打包,看WEB-INF/lib下没有lombok.jar,说明作用域生效了。这个改动本身没有副作用,属于顺手清理。
5.3 "重启一下又好了"背后的真相
这类偶发性问题最迷惑人。TypeBinding的加载顺序在 JVM 里受启动路径、JSP 预编译、会话恢复等因素影响,有时候这次启动物理类先被服务器加载,下次可能就被应用类抢先加载了,表现出来就是时好时坏。
遇到这种"玄学",不要急着反复重启,先做两件事:第一,在启动脚本里固定加上-verbose:class,把日志留到文件里,复现时对比两次的加载顺序;第二,用 Arthas 的classloader命令记录当前类加载情况。两次对比之后,通常能发现某次是应用类先加载,某次是服务器类先加载,根因就浮出来了。
6. 如何长期避免这类问题
6.1 把依赖清单与中间件兼容性管理起来
这次踩坑之后,我觉得最重要的一件事是把"应用依赖"和"中间件内置组件"的兼容性纳入日常管理,而不是等运维修工单来了再去翻。
具体做法是:在项目里维护一张中间件兼容性清单,记录每个中间件版本下哪些 jar 不允许出现在应用包里。拿 TongWeb 来说,至少应该包含:org.eclipse.jdt.core、ecj、org.eclipse.jdt.compiler这三条。每个版本迭代时,用mvn dependency:tree和 war 包 jar 清单做一次自动检查,脚本不过就 fail 构建。
这类工具也可以顺手用开源方案实现,比如 dependency-check 或者自定义一个三五行的 shell 脚本,把构建产物里的 jar 列表拉出来,和黑名单对比:
jar tf target/app.war | grep -E "WEB-INF/lib/.*(jdt|ecj).*\.jar" && echo "发现禁止依赖" && exit 1成本极低,收益很高。
6.2 上线前的类加载健康检查
发布前,我建议在应用里加一个隐藏的管理接口,动态输出关键类的加载情况。不需要很复杂,能返回三个信息就行:类型名称、加载它的 ClassLoader、实际 jar 路径。
比如这样一段简单的 Spring MVC 接口(没有 Spring 就写个 Servlet 也行):
@RestController @RequestMapping("/internal/classpath") public class ClasspathCheckController { @GetMapping("/jdt") public Map<String, String> checkJdt() { Map<String, String> result = new HashMap<>(); inspect(result, "com.tongweb.eclipse.jdt.internal.compiler.lookup.TypeBinding"); inspect(result, "org.eclipse.jdt.internal.compiler.lookup.TypeBinding"); return result; } private void inspect(Map<String, String> result, String className) { try { Class<?> clazz = Class.forName(className); result.put(className, clazz.getClassLoader() + " -> " + clazz.getProtectionDomain().getCodeSource().getLocation()); } catch (ClassNotFoundException e) { result.put(className, "NOT FOUND"); } } }这个接口平时关掉或者做权限控制,上线后手动调一次,几秒钟就能确认类加载状态是否正确。比等到用户报错再排查高效得多。
6.3 告警与统一排查 SOP
日志侧也要做一层防护。ClassCastException本身很常见,不应该直接建一条宽泛的告警,但"目标类型为com.tongweb.eclipse.jdt.internal.compiler.lookup.TypeBinding"这个特征非常明确,可以单独建一条告警规则。
监控平台里加一个关键字匹配告警,命中后自动带上当前应用的版本号、启动时间、近期发布记录,并通知对应负责人。同时把排查流程沉淀成内部 SOP,我建议至少包含这几步:
- 拿到完整堆栈,确认是否指向编译链路;
- 对比应用包内和服务器 lib 下的 jdt/ecj 文件;
- 用
getResource或 Arthas 确认双份类是否存在; - 按"剔除依赖 -> 命名重定位 -> 独立 ClassLoader -> 升级补丁"的顺序处理;
- 处理完回归 JSP 编译、动态脚本、规则引擎三条核心链路。
能把这个 SOP 固定下来,下次再遇到同类问题,处理时间可以从半天压缩到半小时以内。
最后分享一点个人体会
我在处理这类"应用和中间件打架"的问题时,最大的感受是:报错本身往往不值钱,真正值钱的是它背后暴露出来的构建和发布流程缺陷。TypeBinding这个类很偏,但类加载器冲突在应用服务器上是永远躲不开的课题。谁把什么依赖带进了包里,是用provided还是compile作用域,中间件升级时有没有对照兼容性清单,这些日常细节才是保证线上稳定的根本。
如果你现在手头正被这个报错折磨,我建议先别急着找 TongWeb 厂商提工单,按第 3 节的流程把双份 JDT 和加载器查清楚,大概率问题出在应用自身上。确定之后再决定是剔除、隔离还是升级,一两个小时就能解完。踩过几次坑之后你也会习惯成自然,下次看到cannot be cast to,下意识就会先问一句:这个类是谁加载的?