JVM - 类加载机制

JVM 解密 —— 类加载机制

1. 核心理论:什么是类加载?

我们编写的 .java 文件,经过编译器编译后,会生成 .class 字节码文件。类加载机制 (Class Loading Mechanism) 就是指虚拟机把 .class 文件中描述的类数据,从磁盘加载到内存中,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型(java.lang.Class 对象)的过程。

一个类的完整生命周期包括:加载、验证、准备、解析、初始化、使用、卸载七个阶段。其中,验证、准备、解析三个部分统称为链接 (Linking)


2. 深度剖析:类加载的过程

一个类的加载过程主要分为三个大阶段:加载、链接和初始化。

2.1 加载 (Loading)

这是“类加载”过程的第一个阶段,JVM 在这个阶段主要完成三件事:

  1. 获取字节流: 通过一个类的全限定名(如 com.study.jvm.MyClass),找到对应的 .class 文件,并获取其二进制字节流。
  2. 转换数据结构: 将这个字节流所代表的静态存储结构,转换为方法区中的运行时数据结构。
  3. 创建 Class 对象: 在堆内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
  • 简单来说,找到 .class 文件并把它读到内存里。

2.2 链接 (Linking)

  1. 验证 (Verification):
    • 这是链接阶段的第一步,目的是确保被加载的 .class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。这包括文件格式验证、元数据验证、字节码验证和符号引用验证。
    • 简单来说,检查读进来的文件是不是安全的、没被篡改过的。
  2. 准备 (Preparation):
    • 类变量(即 static 修饰的变量)分配内存并设置其初始零值
    • 举例:public static int value = 123; 在准备阶段 value 的值是 0,而不是 123
    • 特例:public static final int value = 123; 这种常量,则会在准备阶段就直接赋值为 123
    • 简单来说,给类的静态变量在内存里找个位置安家,并先用默认值(0, false, null 等)填充。
  3. 解析 (Resolution):
    • 将常量池内的符号引用替换为直接引用的过程。
    • 举例:在编译时,我们不知道一个方法或一个类在内存中的具体地址,只能用一个符号(比如 "com.study.MyClass") 来代替。在解析阶段,JVM 就会把这些符号替换成指向方法区中具体位置的内存地址指针。
    • 简单来说,就是把类、方法、字段的“名字”(如 com.study.MyClass)替换成它们在内存中的实际地址或偏移量。

2.3 初始化 (Initialization)

这是类加载过程的最后一步,也是真正开始执行类中定义的 Java 程序代码的阶段。主要工作是执行类构造器 <clinit>() 方法。

  • <clinit>() 方法:这个方法是由编译器自动收集类中所有类变量的赋值动作静态语句块 (static{}) 中的语句合并产生的。JVM 会保证在子类的 <clinit>() 方法执行前,其父类的 <clinit>() 方法已经执行完毕。
  • 赋值:在这个阶段,之前在准备阶段被赋予零值的静态变量,会被赋予代码中指定的真正初始值
  • 例如:public static int value = 123; 的赋值动作就是在这里执行的。
  • 触发时机:初始化是“懒加载”的,只有当类被首次主动使用时(如 new 一个对象、调用静态方法、访问静态字段等),才会触发。

3. 核心重点:双亲委派模型 (Parents Delegation Model)

双亲委派模型是 Java 类加载器(ClassLoader)的工作机制,是面试中最高频的考点。

3.1 类加载器

Java 系统中主要有三类加载器:

  1. 启动类加载器 (Bootstrap ClassLoader): C++ 实现,负责加载 Java 的核心库(如 JAVA_HOME/lib 目录下的 rt.jar)。
  2. 扩展类加载器 (Extension ClassLoader): Java 实现,负责加载 JAVA_HOME/lib/ext 目录下的库。
  3. 应用程序类加载器 (Application ClassLoader): Java 实现,也叫系统类加载器。它负责加载用户类路径(Classpath)上所指定的类库,我们自己编写的代码通常都是由它来加载的。

除了这些,开发者还可以自定义类加载器。

3.2 工作过程

当一个类加载器收到类加载的请求时,它的工作流程如下:

  1. 委派给父加载器: 它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。
  2. 逐级向上委派: 每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中。
  3. 父加载器尝试加载: 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会自己去尝试加载
  • 生活比喻: 想象一个大公司里的“汇报制度”。
    • 你(应用程序类加载器)接到一个任务(加载类的请求)。
    • 你不会自己马上就做,而是先汇报给你的直接上级——经理(扩展类加载器)。
    • 经理也不会自己做,他会先汇报给他的上级——CEO(启动类加载器)。
    • CEO 先尝试解决这个任务。如果他发现这是核心业务,他自己就解决了。
    • 如果 CEO 发现这不是他的核心业务,他会批示给经理:“这个你来办”。
    • 经理尝试解决,如果发现也不是他分管的扩展业务,他会再批示给你:“这个还是你来办吧”。
    • 最后,你才会亲自去执行这个任务。

3.3 为什么需要双亲委派模型?

这种设计主要有两个目的:

  1. 避免类的重复加载: 如果一个类已经被父加载器加载过了,子加载器就不会再次加载,保证了内存中该类的唯一性。
  2. 保证安全性: 防止核心 API 库被篡改。例如,你自己写一个恶意的 java.lang.String 类,如果没有双亲委派,你就可以用自己的类加载器加载它,从而替代掉系统核心的 String 类,这是非常危险的。但在双亲委派模型下,加载 java.lang.String 的请求最终会到达顶层的启动类加载器,它会加载系统自带的 String 类,你写的那个恶意版本根本没有机会被加载。

4. 打破双亲委派模型 (高频面试题)

4.1 为什么需要打破?

双亲委派模型很好地解决了类的统一和安全问题,但它不是万能的。它的核心缺陷是:父加载器无法加载子加载器路径下的类

当一个由父加载器(如启动类加载器)加载的类,在运行时需要调用或依赖一个只能被子加载器(如应用程序类加载器)加载的类时,双亲委派模型就无法满足需求了。

4.2 例子一:JDBC (SPI 机制)

这是最经典的打破双亲委派模型的例子。

  • 问题

    • java.sql.DriverManager 是 Java 的核心 API,它位于 rt.jar 中,因此是由启动类加载器加载的。
    • com.mysql.jdbc.Driver (MySQL 驱动) 是一个第三方的 JAR 包,它位于我们应用的 classpath 下,是由应用程序类加载器加载的。
    • DriverManager 的一个核心功能是管理和加载所有已注册的数据库驱动。在其 getConnection() 方法内部,需要去实例化 com.mysql.jdbc.Driver
    • 矛盾:父加载器(启动类加载器)需要去加载一个只有子加载器(应用程序类加载器)才能找到的类。
  • 解决方案:线程上下文类加载器 (Thread Context ClassLoader)

    1. DriverManager 在加载驱动时,并不使用自己的启动类加载器
    2. 相反,它会通过 Thread.currentThread().getContextClassLoader() 获取到当前线程的上下文类加载器
    3. 这个上下文类加载器通常就是应用程序类加载器
    4. 然后,DriverManager 使用这个从子线程获取的应用程序类加载器,去反向加载并实例化数据库驱动类。
  • 总结:JDBC 通过线程上下文类加载器,实现了“父加载器请求子加载器去完成类加载”的动作,巧妙地打破了双亲委派的限制。

4.3 例子二:Tomcat (Web 应用隔离)

  • 问题
    • 隔离性:一个 Tomcat 服务器上可能需要同时运行多个独立的 Web 应用程序。如果这些 Web 应用都使用同一个类加载器,可能会互相干扰。例如,应用 A 和应用 B 可能依赖不同版本的同一个库。
    • 热部署:当我们需要更新一个 Web 应用时,希望只重新加载这个应用的类,而不是重启整个 Tomcat 服务器。
  • Tomcat 如何打破双亲委派
    1. Tomcat 为每一个 Web 应用都创建了一个独立的类加载器,即 WebAppClassLoader
    2. WebAppClassLoader 收到加载类的请求时,它改变了加载顺序
      • 优先在自己的 WEB-INF/classesWEB-INF/lib 目录下查找。这样可以保证每个应用优先使用自己私有的类库,从而实现了应用之间的隔离。
      • 如果自己找不到,再向上委派给父加载器(如 AppClassLoader),去加载一些公共的类库。
  • 总结:Tomcat 通过为每个应用创建独立的类加载器,并重写了 loadClass 方法,改变了加载顺序(优先加载自己),打破了双亲委派模型,从而实现了应用间的类库隔离热部署

4.4 其他例子

  • SPI (Service Provider Interface):如上所述,JDBC 就是 SPI 的一个典型应用。其他很多框架,如 JNDI、JAXP 等,都使用了类似 SPI 的机制,通过线程上下文类加载器来加载服务提供者的实现类。
  • OSGi (Open Service Gateway Initiative):OSGi 是一个模块化规范,它的类加载机制更为复杂,是一个网状的结构,也打破了双亲委派模型。
posted @ 2026-01-21 16:07  我是刘瘦瘦  阅读(0)  评论(0)    收藏  举报