Java类加载机制

2023年 7月 15日 19.1k 0

0.jpg

当Java程序运行时,Java虚拟机会根据类的全限定名查找对应的.class文件并加载到内存中。加载过程包括读取字节码文件、解析文件结构、创建java.lang.Class对象等。加载之后,Java虚拟机会对类进行链接(验证、准备和解析)、初始化(执行静态字段和静态代码块)等操作,最终将类的实例化、方法调用等操作映射到操作系统的执行过程。

class文件简介

Java的.class文件是由Java编译器(如javac)将源代码编译后生成的字节码文件。它是一种平台无关的二进制格式,包含了Java程序的类、方法、变量等信息。当程序运行时,Java虚拟机(JVM)会加载.class文件并对其进行解释或编译成本地机器指令来执行。

任何一个Class文件都对应着唯一的一个类或接口的定义信息,但是反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以动态生成,直接送入类加载器中)

1.png

Class文件是一组以8个字节为基础单位的二进制流 ,其结构遵循Java虚拟机规范,主要组成部分如下:

  • 魔数(Magic Number): .class文件的前4个字节是魔数,用于表示这是一个有效的Java字节码文件。魔数的值为0xCAFEBABE
  • 版本信息: 随后的4个字节表示版本信息,包括次版本号和主版本号。这些信息用于表示字节码文件的Java虚拟机版本,确保字节码与虚拟机的兼容性。
  • 常量池(Constant Pool): 常量池是.class文件的重要组成部分,它包含了类、字段、方法等的符号引用信息,以及字面量和其他常量值。常量池表中的每一项都是一个变长结构,可以表示各种类型的常量,如字符串、类名、接口名、字段名、方法名等。
  • 访问标志(Access Flags): 访问标志用于表示类或接口的访问权限和属性,如publicfinalabstract等。
  • 类索引、父类索引和接口索引: 这些索引分别表示当前类、父类和实现的接口在常量池中的位置。
  • 字段表(Fields): 字段表包含了类或接口中声明的字段信息,如字段的访问权限、名称、描述符等。
  • 方法表(Methods): 方法表包含了类或接口中声明的方法信息,如方法的访问权限、名称、描述符、字节码指令等。
  • 属性表(Attributes): 属性表包含了类、字段、方法等的附加信息,如源文件名、代码行号、局部变量表等。其中,Code属性是方法的重要组成部分,它包含了方法的字节码指令、异常处理表、局部变量表等信息。
  • Java类在Java虚拟机(JVM)中的生命周期

    2.jpg

    Java类在Java虚拟机(JVM)中的生命周期涵盖了以下几个阶段:加载(Loading)、链接(Linking)、初始化(Initialization)和卸载(Unloading)。下面是对每个阶段的详细介绍:

  • 加载(Loading): 这是类生命周期的第一个阶段。在这个阶段,JVM使用类加载器读取.class文件(包含Java字节码的二进制文件),解析文件内容,生成对应的java.lang.Class对象。这个过程涉及到文件的读取、数据解析,以及在内存中创建表示此类的数据结构。
  • 链接(Linking): 这个阶段包括验证、准备和解析三个子阶段。
    • 验证(Verification): JVM验证加载的字节码文件的正确性,包括文件格式、元数据、字节码指令、符号引用等的验证。这个过程是为了确保类文件的结构和内容符合Java虚拟机规范,没有安全问题。
    • 准备(Preparation): JVM为类中的静态变量(static field)分配内存空间,并初始化为默认值。例如,为static int i;分配内存空间,初始值为0。
    • 解析(Resolution): JVM将常量池中的符号引用替换为直接引用。符号引用是一组符号来描述所引用的目标,包括类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等。直接引用是指向目标的指针、相对偏移量或者其他类型的指针。
  • 初始化(Initialization): 在这个阶段,JVM执行类的初始化代码,包括静态变量的初始化和静态代码块。初始化代码按照它们在源代码中出现的顺序来执行。例如,static int i = 5;在这个阶段,变量i被初始化为5。
  • 使用(Usage): 在初始化之后,类的实例可以被应用程序创建(new)和使用。创建实例、调用方法、访问字段等操作都是在这个阶段进行的。
  • 卸载(Unloading): 当类的java.lang.Class对象不再被引用,类加载器可以被垃圾收集器回收,此时,类会被卸载。在类被卸载之前,JVM会调用其finalize()方法(如果这个类重写了这个方法)。这个阶段是类生命周期的最后一个阶段,完成之后,类的数据结构在内存中的空间会被回收。
  • 以上就是Java类在JVM中的生命周期。值得注意的是,类的生命周期与对象的生命周期是不同的,对象的生命周期主要关注对象的创建、使用和垃圾收集。

    • 加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始
    • JVM使用了一种称为"延迟加载"或"懒加载"的策略来加载类。这意味着,只有当一个类第一次被真正使用到时(例如,创建类的实例,访问类的静态字段,调用类的静态方法等),JVM才会加载这个类。

    类加载器与类加载策略

    • 类加载器的加载策略是在类加载器的实现中确定的
    • 一个类的全限定名以及加载该类的加载器两者共同形成了这个类在JVM中的惟一标识

    3.jpg

    Java类加载器(ClassLoader)在Java运行时环境中负责将字节码文件(.class)加载到内存中,为类的实例化、方法调用等提供支持。在Java中,类加载器是以java.lang.ClassLoader类的形式实现的。以下是Java中常见的几种类加载器:

  • 引导类加载器(Bootstrap ClassLoader): 负责加载Java核心类库的类,如java.lang.*等。引导类加载器不是Java类,而是由C/C++实现的,它是虚拟机的一部分。引导类加载器无法被直接引用。
  • 扩展类加载器(Extension ClassLoader): 负责加载Java扩展类库的类,如javax.*等。扩展类加载器是java.net.URLClassLoader的子类,它的父类加载器是引导类加载器。扩展类加载器主要加载$JAVA_HOME/lib/ext目录下的类库。
  • 应用类加载器(Application ClassLoader): 负责加载应用程序类库或用户自定义的类。应用类加载器也是java.net.URLClassLoader的子类,它的父类加载器是扩展类加载器。应用类加载器加载CLASSPATH环境变量下的类库,它是Java应用程序的默认类加载器。
  • 自定义类加载器(Custom ClassLoader): 用户可以通过继承java.lang.ClassLoader类或其子类,并重写loadClass方法来实现自定义的类加载器。自定义类加载器可以实现特定的类加载策略,如热部署、模块化等。自定义类加载器的父类加载器可以在创建时指定,没有指定的话默认为应用类加载器。
  • Java类加载器遵循双亲委派模型(Parent Delegation Model)。当一个类需要被加载时,会先交由其父类加载器尝试加载,直到引导类加载器。若父类加载器无法加载该类,则会由当前类加载器尝试加载。这个模型可以避免类的重复加载,保证Java核心类库的安全。

    双亲委派模型(Parent Delegation Model)

    classloader的双亲委托机制是指多个类加载器之间存在父子关系的时候,某个class类具体由哪个加载器进行加载的问题。

    其具体的过程表现为:

    • 当一个类加载的过程中,它首先不会去加载,而是委托给自己的父类去加载,父类又委托给自己的父类。因此所有的类加载都会委托给顶层的父类,即Bootstrap Classloader进行加载。
    • 如果父类自己无法完成这个加载请求,子加载器才会尝试自己去加载。

    双亲委托机制的作用:避免类重复加载导致冲突,保证Java核心库的安全。

    例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的ClassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱。

    启动类/引导类:Bootstrap ClassLoader

    Bootstrap ClassLoader负责加载存放在\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。

    启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时, 如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可。

    扩展类加载器:Extension ClassLoader

    Extension ClassLoader是在类sun.misc.Launcher$ExtClassLoader 中以Java代码的形式实现的。它负责加载\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。我们就可以将我们自己的包放在以上目录下,就会自动加载进来了。

    应用程序类加载器:Application Classloader

    Application Classloader 由sun.misc.Launcher$AppClassLoader来实现,ClassLoader类中的getSystemClassLoader()方法的返回值就是应用程序类加载器,它负责加载用户类路径(ClassPath)上所有的类库。

    用户路径:环境变量classpath或者系统属性java.class.path

    自定义类加载器

    我们可以自定义类加载器,满足特殊的类加载需求,如解决类冲突,实现热加载,实现jar包的加密保护。

    可以通过两种方式实现类加载器

  • 继承java.lang.ClassLoader,重写findClass()方法
  • 继承URLClassLoader类,重写loadClass方法
  • 如果没有太复杂的需求,可以直接继承URLClassLoader类,重写loadClass方法,具体可参考AppClassLoaderExtClassLoader

    继承java.lang.ClassLoader

    Java提供了java.lang.ClassLoader抽象类,你可以通过继承这个类并重写findClass方法来实现自定义类加载器。

    以下是一个简单的自定义类加载器的示例:

    public class MyClassLoader extends ClassLoader {
        @Override
        protected Class findClass(String name) throws ClassNotFoundException {
            byte[] b = loadClassData(name);
            if (b == null) {
                throw new ClassNotFoundException();
            } else {
                return defineClass(name, b, 0, b.length);
            }
        }
    
        private byte[] loadClassData(String name) {
            // implement your code to load class data
            // this can be read from a file, network, or any other source
            return null;
        }
    }
    
    

    在上面的示例中,findClass方法是当父类加载器找不到类时会被调用的方法。在这个方法中,你需要实现从特定源加载类数据的逻辑。加载的类数据应以字节码形式返回,然后通过defineClass方法将这些字节码转换为Class对象。

    参数String name通常表示类的全限定名(Fully Qualified Class Name, FQCN)。全限定名包括类所在的包名和类名,例如java.lang.String

    继承URLClassLoader

    URLClassLoader是Java提供的一个类加载器,它可以从指定的URL列表加载类和资源。如果你的类文件位于某个URL可访问的位置(例如网络位置或本地文件系统),你可以通过继承URLClassLoader来创建自定义类加载器。

    以下是一个简单的示例:

    import java.net.URL;
    import java.net.URLClassLoader;
    
    public class MyURLClassLoader extends URLClassLoader {
        public MyURLClassLoader(URL[] urls) {
            super(urls);
        }
    
        @Override
        public Class findClass(String name) throws ClassNotFoundException {
           return super.findClass(name);
        }
    }
    
    

    在这个示例中,MyURLClassLoader继承了URLClassLoader。我们传递一个URL数组到超类的构造器,这些URL指示类加载器从哪些位置加载类和资源。

    然后,我们覆盖了findClass方法。在这个例子中,我们只是简单地调用了超类的findClass方法,但如果需要,你可以添加自定义的类加载逻辑。

    以下是如何使用这个自定义类加载器的示例:

    URL url = new File("./myClasses/").toURI().toURL(); // or any valid URL
    URL[] urls = new URL[]{url};
    MyURLClassLoader myLoader = new MyURLClassLoader(urls);
    Class myClass = myLoader.loadClass("com.example.MyClass");
    
    

    在这个示例中,我们创建了一个新的MyURLClassLoader实例,指定了类文件的位置(在这个例子中,类文件位于本地文件系统的./myClasses/目录,你可以替换为任何有效的URL)。然后,我们使用loadClass方法加载了一个名为"com.example.MyClass"的类。

    反向委派,线程上下文加载器与SPI

    4.jpg
    **反向委派(Inverse of Delegation)**是Java类加载器中使用的一种特殊的加载策略。在Java的标准类加载机制(即双亲委派模型)中,类加载器在加载类时首先会委派给父类加载器,只有当父类加载器无法加载时才自己加载。但在反向委派中,类加载器会尝试首先加载类,只有当自己无法加载时才委派给父类加载器。

    这种策略在某些情况下是必要的,例如在容器(如Tomcat)中,容器需要使用自己的类加载器来加载应用,以防止应用的类和容器的类发生冲突。

    线程上下文类加载器是一个指向类加载器的引用,具体使用哪种类加载策略由指向的类加载器决定。

    默认情况下,线程会继承其父线程的上下文类加载器。可以通过Thread.getContextClassLoader()方法获取线程的上下文类加载器,也可以通过Thread.setContextClassLoader(ClassLoader cl)方法设置线程的上下文类加载器。

    由于线程上下文类加载器可以被设置为任何一个类加载器,包括使用反向委派策略的类加载器,因此,通过线程上下文类加载器,可以实现反向委派(Inverse of Delegation)策略。例如,在Java的一些Web容器(如Tomcat)中,每个Web应用有自己的类加载器,这个类加载器通常使用的是反向委派策略。

    SPI

    SPI(Service Provider Interface)是Java提供的,允许第三方提供具体实现的一种服务发现机制。它允许核心库(或者框架)的代码调用由第三方实现的接口,而不需要核心库代码对这些实现有任何了解。这种机制使得核心库(或者框架)的功能可以被扩展或替换。

    在Java的类加载模型中,类加载器有一个重要的特性,那就是双亲委派模型。这个模型的基本原则是,当一个类加载器需要加载一个类时,它首先会将这个任务委派给它的父类加载器,只有当父类加载器无法完成这个任务时,它才会尝试自己去加载。这种模型可以确保Java核心库的类型安全,但它也带来了一个问题,那就是父类加载器不能访问子类加载器加载的类

    然而,在SPI机制中,我们通常希望系统的核心库代码(如JDBC、JNDI等)能够调用并使用由应用级类加载器加载的第三方实现类。这就需要一种机制来克服双亲委派模型的限制,而线程上下文类加载器就是这种机制的实现。

    线程上下文类加载器的设计目标就是为了成为一个能够被父类加载器请求,来加载特定资源或者类的加载器。当SPI接口被系统类库加载时,Java会使用当前线程的上下文类加载器,来加载对应的实现类。这样,即使SPI接口是由父类加载器加载的,它也能找到并加载由应用级类加载器加载的实现类。

    例如,当JDBC驱动程序是由应用级类加载器加载的,而JDBC类库是由系统类加载器加载的时候,JDBC类库代码可以通过线程上下文类加载器,来加载和使用应用级类加载器加载的JDBC驱动程序。

    通过线程上下文类加载器,Java的SPI机制可以正常工作,即使在使用双亲委派模型的类加载环境中。

    • SPI是一种服务加载机制,它需要依赖类加载器(可能就是线程上下文类加载器)去加载类
    • 类加载器在加载类时,会根据具体的加载策略(例如双亲委派或反向委派)来决定如何加载类。

    classloader的使用场景

    自定义类加载器的一些应用场景:

    • 热部署(Hot Deployment): 当应用程序需要实时更新部分功能时,可以使用自定义类加载器实现不重启应用程序的情况下加载新的类。
    • 模块化(Modularization): 自定义类加载器可以实现模块化应用程序,不同模块使用不同的类加载器加载,可以避免类名冲突和实现模块间的隔离。
    • 加密和解密(Encryption and Decryption): 自定义类加载器可以在加载类的过程中对类文件进行解密操作,实现对类文件的保护。
    • 依赖冲突

    解决依赖冲突

    • 一个类的全限定名以及加载该类的加载器两者共同形成了这个类在JVM中的惟一标识

    5.png

    问题场景:

    某个服务引用了消息中间件和微服务中间件,这两个中间件也同时引用了fastjson-2.0和fastjson-3.0版本,而业务自己本身也引用了fastjson-1.0版本。这三个版本表现不同之处在于classA类中方法数目不相同,我们根据maven依赖处理的机制,引用路径最短的fastjson-1.0会真正作为应用最终的依赖,其它两个版本的fastjson则会被忽略,那么中间件在调用method2()方法的时候,则会抛出方法找不到异常。

    6.png

    解决方案是使用不同的类加载器进行fastjson的加载。

    以下是一个简单的例子,我们先创建一个自定义类加载器

    public class CustomClassLoader extends ClassLoader {
        private String classpath;
    
        public CustomClassLoader(String classpath) {
            this.classpath = classpath;
        }
    
        @Override
        protected Class findClass(String name) throws ClassNotFoundException {
            try {
                byte[] data = loadClassData(name);
                return this.defineClass(name, data, 0, data.length);
            } catch (IOException e) {
                throw new ClassNotFoundException(name);
            }
        }
    
        private byte[] loadClassData(String classname) throws IOException {
            String path = classpath + classname.replace('.', '/') + ".class";
            InputStream is = new FileInputStream(path);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 4096;
            byte[] buffer = new byte[bufferSize];
            int bytesNumRead;
            while ((bytesNumRead = is.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            return baos.toByteArray();
        }
    }
    
    

    然后,您可以为每个中间件和业务代码创建一个CustomClassLoader实例,指定它们各自的类路径,例如:

    CustomClassLoader dubboClassLoader = new CustomClassLoader("/path/to/dubbo/classes");
    CustomClassLoader metaqClassLoader = new CustomClassLoader("/path/to/metaq/classes");
    CustomClassLoader businessClassLoader = new CustomClassLoader("/path/to/business/classes");
    
    

    然后,您可以使用这些类加载器来加载和执行对应的代码,例如:

    Class dubboClass = dubboClassLoader.loadClass("com.example.DubboClass");
    Object dubboInstance = dubboClass.newInstance();
    Method dubboMethod = dubboClass.getMethod("methodToInvoke");
    dubboMethod.invoke(dubboInstance);
    
    

    同样的,对于消息中间件和业务代码也可以这样做。

    热部署

    • 启动Java应用时,JVM虚拟机需要将所有的应用程序重新装载到整个虚拟机,一个复杂的应用程序所包含的jar包可能有上百兆,每次微小的改动都是全量加载。
    • 通过classloader我们可以完成对变更内容的加载,然后快速的启动。
    • 常用的热加载方案有好几个,spring官方推荐的热加载方案是 spring boot devtools。

    7.png

    通常一个项目的代码由以上四部分组成,即基础类、扩展类、二方包/三方包、以及我们自己编写的业务代码组成。上面的一排是我们通常的类加载结构,其中业务代码和二方包/三方包是由应用加载器加载的。而实际开发和调试的过程中,主要变化的是业务代码,并且业务代码相对二方包/三方包的内容来说会更少一些。因此我们可以将业务代码单独通过一个自定义的加载器Custom Classloader来进行加载,当监控发现业务代码发生改变后,我们重新加载启动,老的业务代码的相关类则由虚拟机的垃圾回收机制来自动回收。其工程流程大概如下:

    8.png

    RestartClassLoader为自定义的类加载器,其核心是loadClass的加载方式,我们发现其通过修改了双亲委托机制,默认优先从自己加载,如果自己没有加载到,从从parent进行加载。这样保证了业务代码可以优先被RestartClassLoader加载。进而通过重新加载RestartClassLoader即可完成应用代码部分的重新加载。

    参考

    原来热加载如此简单,手动写一个 Java 热加载吧

    探秘 Java 热部署二(Java agent premain)

    jvm类加载器,类加载机制详解,看这一篇就够了

    Java 类加载器(ClassLoader)的实际使用场景有哪些?

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论