一个Spring Boot应用启动时打印了"NoClassDefFoundError",但同一个jar明明在classpath里。一个Tomcat上部署了两个应用,各自依赖不同版本的Guava,却没有类冲突。这一切都靠ClassLoader的两层设计——隔离机制和委托机制。这篇讲清楚ClassLoader的底层运作,以及那些"看似诡异"的类加载问题是怎么产生的。
一、双亲委派模型:为什么说它是"反直觉"的?
1.1 标准模型
Java的类加载请求不是"从下往上找",而是"从上往下委托"
Bootstrap ClassLoader (C++实现,加载rt.jar) ↑ 委派 Extension ClassLoader (JDK 9+ 改名为 Platform ClassLoader) ↑ 委派 Application ClassLoader (加载classpath) ↑ 委派 用户自定义 ClassLoader当自定义ClassLoader要加载java.lang.String时:
- 它不是自己去加载,而是先问Application ClassLoader
- Application ClassLoader再问Extension
- Extension再问Bootstrap
- Bootstrap一看"String是我的"→自己加载→一路返回
为什么叫"反直觉":因为请求向上委托,加载结果向下传递。你不会在自定义ClassLoader里加载到JDK核心类,它们全被Bootstrap截胡了。这保证了java.lang.String永远是同一个类,不管谁加载它。
1.2 核心源码
// java.lang.ClassLoader.loadClass() 简化版protectedClass<?>loadClass(Stringname,booleanresolve)throwsClassNotFoundException{synchronized(getClassLoadingLock(name)){// 1. 检查是否已经加载过Class<?>c=findLoadedClass(name);if(c==null){try{// 2. 向上委派给父加载器if(parent!=null){c=parent.loadClass(name,false);}else{c=findBootstrapClassOrNull(name);}}catch(ClassNotFoundExceptione){// 父加载器找不到}// 3. 父加载器没找到,自己加载if(c==null){c=findClass(name);}}returnc;}}二、双亲委派是怎么被破坏的?
"破坏双亲委派"在业界是个被说烂的概念。实际有两个完全不同的场景:
2.1 场景一:JDBC驱动加载(SPI)
// 获取数据库连接(JDK核心API)Connectionconn=DriverManager.getConnection(url,user,pass);问题是:
DriverManager在rt.jar里,由Bootstrap ClassLoader加载com.mysql.jdbc.Driver在应用classpath里,Application ClassLoader才能加载- Bootstrap ClassLoader根本看不到MySQL的Driver类
解决方案:ServiceLoader(SPI机制)打破了向上委托。
// DriverManager.getConnection() 内部// 注意:这里用的是 Thread.currentThread().getContextClassLoader()ServiceLoader<Driver>loadedDrivers=ServiceLoader.load(Driver.class);// Thread Context ClassLoader 默认是 Application ClassLoader// 所以Bootstrap ClassLoader可以通过TCCL"向下"加载类关键点:SPI允许核心API的类(由Bootstrap加载)通过线程上下文类加载器(TCCL)获取应用类路径下的实现类——这是对"向上委托"的第一次破坏。
2.2 场景二:Tomcat类隔离
# Tomcat的类加载结构Common ClassLoader(加载Tomcat自身 + shared libs)|├── Webapp1 ClassLoader(加载WEB-INF/lib/*, WEB-INF/classes/*)|双亲是 Common,但优先自己加载|└── Webapp2 ClassLoader(加载WEB-INF/lib/*, WEB-INF/classes/*)双亲是 Common,但优先自己加载Tomcat的WebappClassLoader重写了loadClass方法,改变了加载顺序:
// Tomcat WebappClassLoader 的加载顺序: 1. 自己找(先检查自己是否已加载) 2. 允许Webapp自己加载(优先WEB-INF下的类) 3. 再委派给父加载器这就是为什么两个Webapp可以用不同版本的Guava——每个Webapp自己的WebappClassLoader优先从WEB-INF/lib加载Guava,互不影响。
三、JDK 9模块化之后的改变
JDK 9的模块化(JPMS)引入了一些新概念:
# 查看模块间依赖java--list-modules# 常见模块# java.base - 所有模块的基座(java.lang, java.util, java.io...)# java.sql - JDBC相关# java.xml - XML解析# jdk.unsupported - sun.misc.Unsafe对ClassLoader的影响:
// JDK 8及之前Class.forName("com.sun.internal.jndi.DNS");// JDK 9+:需要显式add-opens// java --add-opens java.base/com.sun.internal.jndi=ALL-UNNAMED核心变化:
| 维度 | JDK 8 | JDK 9+ |
|---|---|---|
| Extension ClassLoader | 存在 | 改为Platform ClassLoader,不加载类 |
| 核心类 | rt.jar | 拆分为几十个模块 |
| 内部API访问 | 默认可反射 | 默认不可访问,需–add-opens |
| Bootstrap加载器 | Java中不可见 | 仍然是底层加载器 |
生产影响:如果你还在用JDK 8的反射访问JDK内部类(比如sun.misc.Unsafe的某些路径),迁移到JDK 17+需要加--add-opens参数。
四、实战:定位类加载问题
4.1 看一个类是从哪里加载的
# 启动时打印所有类的加载来源-XX:+TraceClassLoading# 输出# [Loaded java.lang.System from /jdk/lib/modules]# [Loaded com.example.MyClass from file:/app/classes/]4.2 运行时查看
# arthas:看一个类被哪个ClassLoader加载[arthas@27654]classloader-c<hash>-rjava.util.HashMap# 输出# class info: java.util.HashMap# classLoader: BootstrapClassLoader// 代码里直接看Class<?>clazz=OrderService.class;System.out.println(clazz.getClassLoader());// 输出: jdk.internal.loader.ClassLoaders$AppClassLoader@xxxx4.3 经典异常分析
NoClassDefFoundError vs ClassNotFoundException:
# ClassNotFoundException:主动找类时找不到Class.forName("com.example.Driver")# → 抛出ClassNotFoundException# NoClassDefFoundError:一个类在编译期存在、运行期没了# 比如 OrderService implements Serializable# 但OrderService的jar不在classpath里# → 抛出NoClassDefFoundError一条实用判断:
| 异常 | 含义 | 常见根因 |
|---|---|---|
ClassNotFoundException | 加载时类不存在 | jar缺失或版本不对 |
NoClassDefFoundError | 类曾经存在但现在找不到 | 依赖的另一个类不可用 |
LinkageError | 同一个jar被两个ClassLoader加载 | 类冲突 |
UnsupportedClassVersionError | 类版本高于JVM版本 | 用高版本JDK编译了代码 |
4.4 依赖冲突的终极解法
# Maven:查看完整的依赖树,找到冲突mvn dependency:tree-Dverbose|grep"guava"# Gradlegradle dependencies--configurationcompileClasspath# JDK自带的工具:可选的类隔离解决方案jlink --module-path /jmods --add-modules java.base--output/mini-jre五、总结
- 双亲委派的核心价值是安全——确保JDK核心类不被篡改,不被多个加载器重复加载
- SPI(JDBC、JNDI等)破坏了向上委托——通过Thread Context ClassLoader让Bootstrap加载的API能找到应用层的实现
- Tomcat WebappClassLoader破坏了"优先委派"——改为"优先自己加载",实现应用间类隔离
- JDK 9模块化后,内部API不可反射访问——迁移前检查
--add-opens需求 NoClassDefFoundError和ClassNotFoundException定位思路不同——一个看jar版本,一个看类被谁依赖