第2章:类加载机制——Java程序的“投胎转世”
大家好,我是你们的老朋友。今天我们来聊聊类加载机制。说实话,这个知识点在面试里被问烂了,但真正能讲清楚的人不多。我见过太多人把“双亲委派”背得滚瓜烂熟,一问“为什么要这么设计”就卡壳了。
类加载,说白了就是JVM把.class文件从硬盘上捞到内存里,然后把它变成一个真正的Java对象的过程。嗯,你可以把它想象成“投胎转世”——字节码是灵魂,JVM是轮回通道,而Class对象就是那个活生生的人。
2.1 类加载的生命周期:七个步骤,一步都不能少
一个类从被加载到被卸载,总共经历七个阶段。我习惯把它们分成两组:前五个是“出生到成年”,后两个是“养老送终”。
| 阶段 | 说明 | 我的经验 |
|---|---|---|
| 加载 | 找到.class文件,读入内存 | 最常见的问题是找不到类,ClassNotFoundException |
| 验证 | 检查字节码是否合法 | 我遇到过篡改字节码导致的诡异问题 |
| 准备 | 为静态变量分配内存并赋默认值 | 注意:这里不是赋你写的值,是零值 |
| 解析 | 把符号引用变成直接引用 | 说白了就是“找地址”的过程 |
| 初始化 | 执行静态代码块和静态变量赋值 | 这里最容易出并发问题 |
| 使用 | 创建对象、调用方法 | 正常干活阶段 |
| 卸载 | 类被GC回收 | 很少发生,条件很苛刻 |
2.1.1 加载阶段:找到你的“出生证明”
加载阶段做什么?说白了就是三件事:
- 通过类的全限定名获取二进制字节流
- 把字节流转换成方法区的运行时数据结构
- 在堆中生成一个Class对象,作为访问入口
这里有个坑,我踩过。有一次项目里用了自定义类加载器,结果加载路径配错了,跑起来全是ClassNotFoundException。排查了半天,最后发现是路径少了个斜杠。嗯,细节决定成败。
2.1.2 验证阶段:字节码的“体检”
验证阶段,JVM会检查字节码是不是合法的。你想想看,如果随便来个人改个字节码就往JVM里塞,那不乱套了?
验证包括四个动作:
- 文件格式验证:魔数、版本号对不对
- 元数据验证:类有没有父类、是不是继承了final类
- 字节码验证:操作数栈类型对不对
- 符号引用验证:引用的类、字段、方法是否存在
2.1.3 准备阶段:给静态变量“安家”
准备阶段,JVM会为静态变量分配内存,并赋默认值。注意,是默认值,不是你在代码里写的值。
public class MyClass {
public static int count = 10; // 准备阶段:count = 0
public static final int MAX = 100; // 准备阶段:MAX = 100(final直接赋值)
}
为什么会这样?因为准备阶段还没执行任何Java代码。真正的赋值要等到初始化阶段。但final变量是个例外——编译期就确定了,所以直接赋值。
2.1.4 解析阶段:把“名字”变成“地址”
解析阶段,JVM把符号引用替换成直接引用。符号引用是什么?就是你在字节码里看到的那些字符串,比如“java/lang/String”。直接引用是什么?就是内存里的真实地址。
我个人习惯把解析阶段理解为“查通讯录”——你拿着人名(符号引用),找到他的电话号码(直接引用)。
2.1.5 初始化阶段:真正开始干活
初始化阶段,JVM会执行
public class InitDemo {
public static int x = 10;
static {
System.out.println("静态代码块执行了");
x = 20;
}
}
这里有个经典问题:多线程下初始化会不会出问题?答案是会的。我遇到过两个线程同时触发一个类的初始化,结果静态代码块执行了两次。虽然JVM保证了
2.2 类加载器:谁负责“接生”
类加载器,就是负责把类加载到JVM里的组件。JVM自带了三个类加载器:
| 类加载器 | 加载路径 | 说明 |
|---|---|---|
| Bootstrap ClassLoader | rt.jar、核心类库 | C++实现,没有对应的Java对象 |
| Extension ClassLoader | jre/lib/ext | 加载扩展库 |
| Application ClassLoader | classpath | 加载我们自己写的类 |
你想想看,这三个加载器是有层级关系的。Bootstrap是最顶层的,它加载核心类。Extension是第二层,加载扩展类。Application是第三层,加载应用类。
2.3 双亲委派模型:Java的“家族规矩”
双亲委派模型,说白了就是:当一个类加载器收到加载请求时,它不会自己先加载,而是先让父加载器去加载。父加载器搞不定,它才自己上。
为什么要这么设计?我举个例子你就明白了。假设你自己写了一个java.lang.String类,里面放了恶意代码。如果没有双亲委派,你的String类就会被加载,那整个JVM就乱套了。但有了双亲委派,加载String类时,Application ClassLoader会先问父加载器,一路问到Bootstrap ClassLoader。Bootstrap发现rt.jar里已经有String了,就直接返回,你的恶意String根本不会被加载。
2.4 打破双亲委派:什么时候需要“叛逆”
双亲委派虽好,但有些场景下必须打破它。我遇到过几个典型场景:
- Tomcat等Web容器: 每个Web应用需要有自己的类加载器,隔离不同应用的类
- OSGi模块化: 每个模块可以有自己的类加载器,实现热部署
- JDBC驱动加载: 典型的“逆向委派”,ServiceLoader机制
怎么打破双亲委派?很简单,重写loadClass方法,不调用父类的loadClass就行了。
public class MyClassLoader extends ClassLoader {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 不遵守双亲委派,自己先加载
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
InputStream is = getClass().getResourceAsStream(fileName);
if (is == null) {
return super.loadClass(name);
}
try {
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);
} catch (IOException e) {
throw new ClassNotFoundException(name);
}
}
}
2.5 实战经验:类加载问题排查
最后分享几个我常用的排查技巧:
- 查看类加载器: 用getClass().getClassLoader()查看当前类的加载器
- 打印加载过程: 加 -XX:+TraceClassLoading 参数,看哪些类被加载了
- 检查类冲突: 用 -verbose:class 看类是从哪个jar包加载的
我记得有一次线上出现NoSuchMethodError,排查了半天,最后发现是classpath里有两个不同版本的jar包,加载了旧版本的类。用TraceClassLoading一看,问题一目了然。
好了,类加载机制就讲到这里。下一章我们聊聊JVM内存模型,看看对象在内存里是怎么“住”的。记住一句话:理解了类加载,你就理解了Java程序的“投胎转世”。