MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java中的类加载机制与双亲委派模型

2021-02-114.6k 阅读

Java中的类加载机制基础

在Java编程中,类加载机制是一项至关重要的特性。当我们编写Java代码时,定义的类并不会立即被JVM(Java Virtual Machine)所使用。只有当程序运行过程中需要使用某个类时,JVM才会通过类加载机制将该类的字节码文件加载到内存中,并将其转化为可以运行的Java类型。

Java类加载机制主要分为三个阶段:加载(Loading)、链接(Linking)和初始化(Initialization)。

加载(Loading)

加载是类加载机制的第一个阶段。在这个阶段,JVM需要完成以下三件事:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。这通常意味着从本地文件系统中读取.class文件,但也可以从网络、jar包等其他来源获取。例如,当我们运行一个简单的Java应用程序,JVM会首先在指定的类路径(classpath)下查找对应的.class文件。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。方法区是JVM内存的一个区域,用于存储已被加载的类信息、常量、静态变量等数据。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

以下是一个简单的代码示例,帮助理解类加载的过程。假设我们有一个简单的HelloWorld类:

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, World!");
    }
}

当我们运行这个程序时,JVM首先会在类路径下查找HelloWorld.class文件,然后将其字节流加载到内存,并在方法区构建相关的数据结构,同时生成HelloWorld类对应的Class对象。

链接(Linking)

链接阶段负责将加载后的类合并到JVM的运行时状态中。它又细分为三个小阶段:验证(Verification)、准备(Preparation)和解析(Resolution)。

  1. 验证(Verification):这一步主要目的是确保被加载类的正确性。JVM会检查字节码文件的格式是否符合规范,例如是否以正确的魔数(Magic Number)开头,常量池中的常量类型是否正确等。如果字节码文件不符合规范,JVM会抛出VerifyError异常。
  2. 准备(Preparation):在这个阶段,JVM会为类的静态变量分配内存,并设置默认初始值。这里需要注意的是,对于基本数据类型,会设置其对应的默认值,如int类型默认值为0,boolean类型默认值为false等。而对于引用类型,默认值为null。例如,对于以下类:
public class StaticVariableExample {
    public static int staticInt;
    public static String staticString;
}

在准备阶段,staticInt会被初始化为0,staticString会被初始化为null。 3. 解析(Resolution):解析阶段是将常量池中的符号引用替换为直接引用的过程。符号引用是一种在编译时由一组符号来描述所引用的目标,而直接引用则是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。例如,当一个类引用另一个类的方法时,在编译时会生成符号引用,在解析阶段会将其替换为直接引用,以便在运行时能够准确找到该方法。

初始化(Initialization)

初始化是类加载机制的最后一个阶段。在这个阶段,JVM会执行类的初始化代码,也就是类构造器<clinit>()方法。<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static {})中的语句合并产生的。例如:

public class InitializationExample {
    public static int num = 10;
    static {
        System.out.println("Static block is executed");
    }
}

在初始化阶段,会先执行静态变量num的赋值操作,将其赋值为10,然后执行静态语句块中的输出语句,打印出Static block is executed

需要注意的是,只有当对类进行主动使用时,才会触发类的初始化。主动使用包括以下几种情况:

  1. 创建类的实例,也就是使用new关键字。
  2. 访问类的静态变量或为静态变量赋值。
  3. 调用类的静态方法。
  4. 使用java.lang.reflect包的方法对类进行反射调用。
  5. 初始化一个类的子类。
  6. 当虚拟机启动时,用户指定的主类(包含main方法的类)会被初始化。

双亲委派模型

模型结构与原理

双亲委派模型是Java类加载机制的核心。在这种模型下,类加载器被分为三个主要层次:启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)和应用程序类加载器(Application ClassLoader),同时还可以有用户自定义类加载器。

  1. 启动类加载器(Bootstrap ClassLoader):它是最顶层的类加载器,由C++实现,负责加载Java核心类库,如java.lang包下的类。这些类位于$JAVA_HOME/jre/lib目录下,或者被-Xbootclasspath参数指定的路径中。由于它是用C++实现的,在Java代码中无法直接获取到它的引用。
  2. 扩展类加载器(Extension ClassLoader):它由Java代码实现,继承自java.lang.ClassLoader类。负责加载$JAVA_HOME/jre/lib/ext目录下,或者被java.ext.dirs系统变量所指定的路径中的类库。
  3. 应用程序类加载器(Application ClassLoader):也由Java代码实现,通常也被称为系统类加载器。它负责加载用户类路径(classpath)上所指定的类库,我们日常开发的应用程序中的类一般都是由它来加载的。在Java代码中,可以通过ClassLoader.getSystemClassLoader()方法获取到该类加载器。

双亲委派模型的工作原理如下:当一个类加载器收到类加载请求时,它首先不会自己去尝试加载这个类,而是把请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中。只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子类加载器才会尝试自己去加载。

代码示例展示双亲委派模型

以下通过代码示例来展示双亲委派模型的工作机制。首先,定义一个简单的类:

public class ParentDelegationExample {
    public static void main(String[] args) {
        ClassLoader classLoader = ParentDelegationExample.class.getClassLoader();
        while (classLoader != null) {
            System.out.println("ClassLoader: " + classLoader);
            classLoader = classLoader.getParent();
        }
        System.out.println("Bootstrap ClassLoader (null in Java code)");
    }
}

当运行上述代码时,会输出如下信息:

ClassLoader: sun.misc.Launcher$AppClassLoader@18b4aac2
ClassLoader: sun.misc.Launcher$ExtClassLoader@7f31245a
Bootstrap ClassLoader (null in Java code)

从输出结果可以看出,ParentDelegationExample类是由应用程序类加载器加载的,而应用程序类加载器的父类是扩展类加载器,扩展类加载器的父类是启动类加载器(在Java代码中获取到为null)。这清晰地展示了双亲委派模型中类加载器的层次结构。

双亲委派模型的优势

  1. 避免类的重复加载:由于双亲委派模型的工作机制,当一个类被某个类加载器加载后,后续的加载请求会首先在父类加载器中查找,避免了同一个类被不同类加载器重复加载的问题。例如,如果java.lang.String类已经被启动类加载器加载,那么其他类加载器在收到加载java.lang.String类的请求时,会先委托给父类加载器,而父类加载器已经加载过该类,就不会再次加载。
  2. 保证Java核心类库的安全性:启动类加载器负责加载Java核心类库,这些类库是Java运行的基础。通过双亲委派模型,其他类加载器无法加载与核心类库中同名的类,从而保证了Java核心类库的唯一性和安全性。例如,如果恶意代码试图自定义一个java.lang.String类并加载,由于双亲委派机制,这个自定义的类会首先被委托给启动类加载器,而启动类加载器已经加载了真正的java.lang.String类,因此恶意代码自定义的类不会被加载,从而保证了系统的安全。

打破双亲委派模型

虽然双亲委派模型在大多数情况下能够很好地工作,但在某些特定场景下,可能需要打破这种模型。

为什么要打破双亲委派模型

  1. 解决类加载器的依赖关系冲突:在一些大型的应用框架中,可能会存在多个不同版本的类库,这些类库之间可能存在依赖关系冲突。例如,应用A依赖libA-1.0.jar,而应用B依赖libA-2.0.jar,并且这两个版本的libA对同一个类的实现有所不同。如果采用双亲委派模型,只能有一个版本的libA被加载,这就会导致依赖冲突。打破双亲委派模型可以让不同的类加载器分别加载不同版本的类库,从而解决依赖冲突问题。
  2. 实现热插拔、热部署:在一些需要实现热插拔、热部署功能的应用中,需要在运行时动态加载新的类或者替换已有的类。而双亲委派模型下,类一旦被加载,就很难在运行时进行替换。打破双亲委派模型可以实现类的动态加载和替换,满足热插拔、热部署的需求。

如何打破双亲委派模型

  1. 自定义类加载器:通过继承java.lang.ClassLoader类,并重写loadClass方法来打破双亲委派模型。在重写的loadClass方法中,可以不遵循双亲委派的规则,自行实现类的加载逻辑。例如:
public class CustomClassLoader extends ClassLoader {
    @Override
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            // 首先检查该类是否已经被加载
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long startTime = System.nanoTime();
                try {
                    if (getParent() != null) {
                        c = getParent().loadClass(name);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // 如果父类加载器无法加载,则自己尝试加载
                    c = findClass(name);
                }
                long endTime = System.nanoTime();
                System.out.println("Loading class " + name + " took " + (endTime - startTime) + " nanoseconds");
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 自定义类加载逻辑,例如从指定路径读取字节码文件
        byte[] classBytes = loadClassBytes(name);
        if (classBytes == null) {
            throw new ClassNotFoundException(name);
        }
        return defineClass(name, classBytes, 0, classBytes.length);
    }

    private byte[] loadClassBytes(String name) {
        // 从自定义路径读取字节码文件的逻辑
        // 这里省略实际的文件读取代码
        return null;
    }
}

在上述代码中,CustomClassLoader重写了loadClass方法,在父类加载器无法加载类时,自己尝试通过findClass方法来加载类,从而打破了双亲委派模型。

  1. 线程上下文类加载器:Java提供了线程上下文类加载器(Thread Context ClassLoader)来打破双亲委派模型。线程上下文类加载器可以通过Thread.currentThread().setContextClassLoader(ClassLoader)方法进行设置,通过Thread.currentThread().getContextClassLoader()方法获取。许多服务提供者接口(SPI,Service Provider Interface),如JDBC、JNDI等,都使用线程上下文类加载器来加载实现类。例如,在JDBC中,DriverManager需要加载不同数据库厂商提供的驱动类,这些驱动类通常由应用程序类加载器加载,而DriverManager本身是由启动类加载器加载的。由于启动类加载器无法加载应用程序类路径下的驱动类,因此通过线程上下文类加载器来实现逆向加载,即由上层类加载器调用下层类加载器来加载类,从而打破了双亲委派模型。以下是一个简单的示例:
import java.sql.Driver;
import java.sql.DriverManager;
import java.util.Iterator;
import java.util.ServiceLoader;

public class SPIExample {
    public static void main(String[] args) {
        // 获取线程上下文类加载器
        ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
        // 加载SPI服务提供者
        ServiceLoader<Driver> serviceLoader = ServiceLoader.load(Driver.class, contextClassLoader);
        Iterator<Driver> iterator = serviceLoader.iterator();
        while (iterator.hasNext()) {
            Driver driver = iterator.next();
            try {
                DriverManager.registerDriver(driver);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

在上述代码中,通过ServiceLoader.load(Driver.class, contextClassLoader)方法,使用线程上下文类加载器来加载Driver接口的实现类,从而打破了双亲委派模型。

类加载机制与双亲委派模型在实际项目中的应用

在Web应用开发中的应用

在Web应用开发中,类加载机制和双亲委派模型有着广泛的应用。以Tomcat服务器为例,Tomcat为每个Web应用都提供了独立的类加载器,这是为了保证不同Web应用之间的类相互隔离,避免类冲突。

Tomcat的类加载器结构相对复杂,除了遵循双亲委派模型的部分,还引入了一些特殊的机制。例如,Tomcat的CommonClassLoader负责加载Tomcat服务器和所有Web应用共享的类库,CatalinaClassLoader负责加载Tomcat服务器自身使用的类库,而每个Web应用都有自己的WebappClassLoaderWebappClassLoader在加载类时,首先尝试在自己的类路径下加载类,如果找不到再委托给父类加载器。这种机制打破了传统双亲委派模型中严格的自上而下的委托顺序,使得Web应用可以优先加载自己的类库,避免与服务器其他部分的类库冲突。

例如,不同的Web应用可能使用不同版本的Spring框架。通过Tomcat的这种类加载机制,每个Web应用的WebappClassLoader可以加载各自版本的Spring相关类,保证了各个Web应用之间的独立性。

在微服务架构中的应用

在微服务架构中,每个微服务都是一个独立的应用,可能使用不同版本的相同类库。类加载机制和双亲委派模型同样起到了关键作用。

以Spring Cloud微服务框架为例,每个微服务可以通过自定义类加载器或者合理配置类路径来实现类的隔离和正确加载。例如,微服务A可能使用feign-1.0.0版本,而微服务B可能使用feign-2.0.0版本。通过配置各自的类加载器和类路径,每个微服务可以独立加载所需版本的feign类库,避免了版本冲突。

同时,在微服务之间进行通信时,可能会涉及到跨微服务的类传递和加载。例如,微服务A调用微服务B的接口,返回的数据对象可能包含特定的类。这就需要在微服务之间保证类加载的一致性。可以通过使用相同的基础类库版本,或者在通信时对数据进行序列化和反序列化,确保接收方能够正确加载相关类。

在框架开发中的应用

在开发一些大型的Java框架时,如Spring框架,类加载机制和双亲委派模型也有着重要的应用。

Spring框架使用了自定义的类加载器来实现一些特殊的功能,比如加载配置文件、扫描组件等。Spring的ApplicationContext在初始化时,会通过类加载器来加载各种配置文件和Bean定义。例如,通过ClassPathXmlApplicationContext来加载位于类路径下的XML配置文件时,就需要类加载器来定位和读取这些文件。

同时,Spring框架为了支持不同的环境和应用场景,也需要处理类加载的兼容性问题。例如,在一个Web应用中使用Spring,需要与Web容器的类加载机制进行协调,确保Spring相关的类和Web应用自身的类能够正确加载和协同工作。

类加载机制与双亲委派模型的常见问题及解决方法

类找不到异常(ClassNotFoundException)

  1. 问题原因:当JVM在类加载过程中无法找到指定的类时,会抛出ClassNotFoundException异常。常见原因包括:
    • 类路径配置错误,导致类加载器无法找到对应的.class文件。例如,在命令行运行Java程序时,没有正确设置classpath参数;在Web应用中,相关类库没有正确部署到Web应用的类路径下。
    • 类加载器层次结构错误,例如自定义类加载器没有正确设置父类加载器,导致无法按照双亲委派模型进行类加载。
  2. 解决方法
    • 检查类路径配置。在命令行运行时,确保classpath参数包含了所有需要的类库路径。在Web应用中,检查Web容器的部署配置,确保相关类库被正确部署到Web应用的WEB - INF/lib目录下或者其他正确的类路径位置。
    • 检查类加载器的层次结构。如果使用自定义类加载器,确保父类加载器设置正确,遵循双亲委派模型的规则。例如,在自定义类加载器的构造函数中,正确设置父类加载器:
public class CustomClassLoader extends ClassLoader {
    public CustomClassLoader(ClassLoader parent) {
        super(parent);
    }
    // 其他类加载器相关代码
}

类转换异常(ClassCastException)

  1. 问题原因:当试图将一个对象转换为它实际上不是的类型时,会抛出ClassCastException异常。在类加载机制中,这可能是由于不同的类加载器加载了同一个类的不同版本,导致JVM认为它们是不同的类型。例如,类A被类加载器CL1加载,而在另一个地方,类A被类加载器CL2加载,当尝试将CL1加载的类A的对象转换为CL2加载的类A的类型时,就会抛出该异常。
  2. 解决方法
    • 确保在整个应用中,同一个类只由一个类加载器加载。可以通过统一类库版本,避免不同版本的类库被不同类加载器加载。例如,在Maven项目中,通过统一依赖版本来确保所有模块使用相同版本的类库。
    • 如果确实需要使用不同版本的类库,可以通过自定义类加载器来实现类的隔离。例如,为不同版本的类库分别创建独立的类加载器,并且确保在使用这些类时,使用对应的类加载器来加载,避免类型转换问题。

静态变量初始化问题

  1. 问题原因:在类加载的初始化阶段,静态变量的初始化顺序和依赖关系可能会导致问题。例如,一个静态变量依赖于另一个静态变量的初始化结果,但由于初始化顺序不当,可能导致依赖的静态变量还未初始化,从而出现错误。
  2. 解决方法
    • 确保静态变量的初始化顺序合理。在定义静态变量时,将相互依赖的静态变量按照正确的依赖顺序进行定义。例如:
public class StaticVariableInitializationExample {
    public static int num1 = 10;
    public static int num2 = num1 * 2;
}

在上述代码中,num2依赖于num1的初始化结果,按照这种顺序定义可以确保num1先被初始化,num2再根据num1的值进行初始化。 - 如果静态变量的初始化逻辑较为复杂,可以将其放在静态语句块中,通过控制静态语句块的执行顺序来保证正确的初始化。例如:

public class StaticBlockInitializationExample {
    public static int num1;
    public static int num2;
    static {
        num1 = 10;
        num2 = num1 * 2;
    }
}