你真的了解一个Java类是如何被加载的吗?

190 阅读17分钟

前言

在生活中,我们经常会被问到:“你有没有对象?”作为一名程序员,这个问题听起来就像是在请求我们编写代码一样——不就是一个简单的“new”操作嘛!/doge

但是,你真的清楚在代码中“new”一个对象背后到底发生了什么吗?让我们一起探讨一下如何在Java中“new”一个对象吧。

一、Class类文件结构

首先我们得先了解一下我们经常听到了Class字节码文件(*.class)。

Class文件是一组以8个字节为基础单位的二进制流,并且各个数据项严格按顺序紧凑排列在文件中,中间没有任何的分隔符,这使得Class文中存储的内容几乎全是运行时必要的数据。

1.1、魔数

每个Class文件的头4个字节被称为魔数(Magic Number),它唯一作用是确定这个文件是否是一个能被虚拟机接受的Class文件。不仅是在Class文件,很多文件标准中都有使用魔数来进行身份识别的习惯,例如:图片格式,GIF或JPEG在文件头中都有使用魔数识别身份的习惯。

1.2、Class文件版本

紧接着魔数的4个字节存储的是Class文件的版本号,第5、6位是次版本号,7、8位才是主版本号。Java版本号是从45开始的,JDK1.1后的每个JDK大版本发布版本号向上加1。

当然版本可不仅仅只是为了标明Class文件当前的版本,而是为了兼容性,让高版本的JDK能向下兼容以前版本的Class文件,但不能运行比自己更高版本的Class文件。

1.3、常量池

紧接着主次版本号后的是常量池入口,我们可以将它比作Class文件中的资源仓库,它不仅是与其他项目关联最多的数据,通常同时也是占用Class文件空间最大的数据项目之一。

常量池中存放着两大类常量:字面量(Literal)和符号引用(Symbolic References)。

  • 字面量:接近Java语言层面中的常量概念,如文本字符串、被声明位final常量值等

  • 符号常量:较接近编译原理方面的概念,主要包括如下:

    • 被模块导出或开放的包
    • 类和接口的全限定名
    • 字段和名称的描述符
    • 方法句柄和方法类型
    • 动态调用点和动态常量

Java代码在虚拟机加载Class文件采用的是一种动态连接,也就是说不会保存各个方法、字段最终在内存中的布局信息。如果这些字段不经过运行时的转换,则永远都不会得到真正内存入口,也无法被虚拟机使用。当虚拟机做类加载时,将会从常量池获取对应的符号引用,在类创建时候或运行时解析、翻译到具体的内存地址中。关于类创建和动态加载,我们放到下面来说。

1.4、 访问标志

继常量池后的两个字节代表访问标志:用于标志用于识别类/接口层次的访问信息,包括:

  • 这个Class是类还是接口
  • 是否定义为public类型
  • 是否定义为abstract类型
  • 如果是类,是否是final修饰

1.5、类/父类/接口 索引

Class文件通过上述3项内容来确定类型的继承关系

  • 类索引确定类的全限定名
  • 父类索引用于确定这个类的父类的全限定名
  • 接口索引用于确定继承哪些接口

1.6、字段/方法/属性 表集合

字段表(Fields Table) :用于描述类或接口中声明的所有变量,包括实例变量、静态变量、枚举常量等。每个字段都有以下信息:

  • 访问标志(Access Flags) :指示字段的访问级别(public, private, protected)以及其他属性(static, final, volatile等)。
  • 名称索引(Name Index) :指向常量池中的字段名。
  • 描述符索引(Descriptor Index) :指向常量池中的字段类型的描述符。
  • 属性表(Attributes Table) :可能包含与字段相关的附加信息,如ConstantValue属性,用于指定final变量的值。

方法表(Methods Table) :包含了类或接口中声明的所有方法的信息。每个方法条目包含:

  • 访问标志(Access Flags) :指示方法的访问级别和其他属性(如abstract, static, final等)。
  • 名称索引(Name Index) :指向常量池中的方法名。
  • 描述符索引(Descriptor Index) :指向常量池中的方法参数列表及返回类型。
  • 属性表(Attributes Table) :可能包含与方法相关的附加信息,例如Code属性(包含字节码)、ExceptionTable属性(列出可能抛出的异常)等。

属性表(Attributes Table) :是.class文件中的一个通用扩展点,它可以出现在类文件的顶部、字段表内或方法表内。属性表用于存储非必要信息,即那些不是编译器或JVM严格要求的信息,例如:

  • SourceFile:指定源代码文件的名字。
  • LineNumberTable:提供源代码行号和字节码指令之间的映射。
  • LocalVariableTable:提供局部变量与其作用域范围之间的映射。
  • Deprecated:标记已弃用的类、字段或方法。
  • Synthetic:标记合成的字段或方法,即那些并非源自源代码而是编译器自动生成的。

以上是.class文件中几个主要表格的简要说明。这些表格共同构成了一个完整的类定义,使得JVM可以加载并执行这些类

二、类加载机制

上面我们学习了Class字节码文件大概的存储格式,在Class文件中描述各个类的信息,最终要加载到我们虚拟机内存中才能被运行和使用,而对于JVM是如何加载这些Class文件?Class文件中信息进入JVM会发生什么?都是我们要来了解的。

2.1、类加载时机

一个类从被加载到虚拟机内存开始,到卸载回收为止,它的整个生命周期会经历 : 加载 、验证、准备、解析、初始化、使用、卸载这7个阶段,其中验证、准备、解析这三个部分又统称为连接。这7个发生阶段如下:

image-20241011212219611

看到这里,我们就开始好奇了,那关于什么情况下需要开始类加载过程的第一个阶段“加载”呢,在《Java虚拟机规范》中其实并没有强制约束“加载”在何时发生,但对于初始化阶段,则是有严格规定:有且只有如下六种情况必须立刻对类进行“初始化”(而加载、验证、准备则需先被触发):

  1. 遇到new/getstaic/putstatic/invokestatic这四条字节码指令触发

    1. 使用new关键字实例化对象时候
    2. 读取或设置一个类型的静态字段时
    3. 调用一个类型的静态方法时候
  2. 对类型进行反射调用。如果类型没有经过初始化,则会触发初始化

  3. 虚拟机启动时,需指定一个执行主类,JVM会初始化该主类

  4. 初始化一个类时,发现父类没有初始化,则会先触发父类初始化

  5. 当使用JDK7中新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandler实例最后的解析结果为REF_getStatic,REF_putStatic,REF_invokeStatic,REF_newInvokeSpecial四种类型的方法句柄,并且这个方法对应的类没有进行初始化,则先触发其初始化

  6. 一个接口中定了JDK8新加入的默认方法时,如果这个接口的实现类发生了初始化,要先将接口进行初始化

2.2、类加载过程

在正式讲解之前,我们再来举个栗子说说这个类加载过程吧。假如你是一个程序员大厨,你要烹饪一道复杂的菜肴:首先,你需要找到正确的菜谱(加载),确保菜谱是完整且正确的(验证),然后准备好所有需要的食材并按份量放置好(准备),接着确认所有厨具的位置以便顺利使用(解析),最后按照菜谱的步骤一步步烹饪,直到美味的菜肴完成(初始化)。

2.2.1、加载

加载是指通过完全限定名查找Class文件二进制数据并将其加载进内存的过程。大致流程如下:

  1. 通过一个类的完全限定名查找定位.class文件,并获取其二进制字节流数据
  2. 把字节流所代表的静态存储结构转换为方法区的运行时数据结构
  3. 在内存中生成一个代表该类的java.lang.Class对象,作为这个方法区这个类的各种数据入口

注意:“通过一个类的完全限定名查找定位.class文件,并获取其二进制字节流数据”,并没有指明说只能在一个Class文件中获取,也可以说,没指明要从哪获取、如何获取。

于是根据这一特点,一些大佬们则玩出了花样:

  • 从ZIP压缩包中读取,最终为Jar、War格式打下了基础
  • 运行时计算生成,这种场景最经典的就是动态代理技术,在java.lang.reflect.Proxy中,就是采用了ProxyGenerator.generateProxyClass()来为特定接口生成形式为"*$Proxy"的代理类二进制字节流
  • .........

2.2.2、连接

2.2.2.1、验证

验证是连接阶段的第一步,这一阶段的目的是确保Class文件中字节流中包含的信息符合《Java虚拟机规范》的全部约束,保证这些信息当作代码运行后不会危害虚拟机安全。

验证阶段主要包括四种验证:文件格式验证(魔数、版本号等)、元数据验证、字节码验证以及符号引用验证。

  1. 文件格式验证:验证字节流是否符合Class文件格式规范,并且能被当前版本虚拟机处理,如:

    1. 是否以魔数 oxCAFEBABE开头
    2. 主次版本号是否能被JVM接受
    3. 。。。。
  2. 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》要求,该阶段包括:

    1. 这个类是否有父类
    2. 该类父类是否继承了不可被继承的类(如final修饰的类)
    3. 。。。。
  3. 字节码验证:通过数据流分析和控制流分析,确定程序语义是否合法、符合逻辑。也就是对类的方法进行校验分析

  4. 符号引用验证:该校验发生在符合引用转直接引用时,该校验目的是为了确保解析行为能正常执行,如果无法通过符号引用验证,则会抛出一个java.lang.IncompatibleClassChangeError的子类异常、

2.2.2.2、准备

准备阶段是正式为类中定义的静态变量分配内存并设置类变量初始值的阶段,该阶段仅仅包含类变量,不包括实例变量。

2.2.2.3、解析

该阶段是虚拟机将常量池中符号引用替换成直接引用的过程,作用是让JVM能够在运行时更高效地访问类的成员,并且确保这些成员的存在性和可达性。

解析动作主要针对类、接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符这7类符号进行。

2.2.3、初始化

类的初始化是类加载过程中的最后一个步骤。在之前的步骤中,除了加载阶段可以通过用户自定义的类加载器参与外,其余步骤如验证、准备等都是由JVM(Java虚拟机)主导执行的。直到初始化阶段,JVM才开始真正执行类中编写的Java代码,将控制权交还给应用程序。

在初始化阶段,JVM会执行以下操作:

  1. 执行静态初始化块:如果有任何静态初始化块(static initializer blocks),它们将在类首次被加载时执行。这些初始化块用于执行一些一次性的操作,如初始化静态变量等。
  2. 执行类构造器:类构造器 <clinit> 方法会被调用,这个方法是由编译器自动生成的,用来初始化类的静态变量。如果类中有多个静态变量,它们的初始化顺序遵循在源代码中出现的顺序。
  3. 执行类的初始化逻辑:除了静态变量的初始化之外,还包括执行任何静态语句块中的代码,确保类在使用前已经准备好所有必要的资源。
  4. 触发父类初始化:如果一个类继承了另一个类,则会在子类初始化之前先初始化其父类。这意味着继承链中的所有父类都会按照从基类到派生类的顺序依次初始化。
  5. 执行顺序保障:保证初始化的顺序遵循依赖关系,即如果一个类依赖于另一个类的初始化结果,那么被依赖的类将先于依赖它的类被初始化。

初始化阶段确保了类及其静态成员在使用前已经完全准备好,这是类加载过程中的关键一步,因为它标志着类从被动加载转变为主动准备使用。

2.2.4、使用、卸载

在Java中,当一个类完成了加载流程,意味着其对应的Class对象已经被创建并存在于内存之中。此时可以通过这个Class对象来创建该类的具体实例进行使用,这便是所谓的“使用阶段”。

在Java的垃圾回收机制中,如果某个Class对象不再被任何地方引用,则理论上它可以被视为可回收的对象,进而导致与之相关的类数据被卸载。然而,有一些例外情况需要注意:

  • 对于Java虚拟机(JVM)自带的类加载器所加载的类,这类类在JVM的整个生命周期内都不会被卸载。原因是JVM自身会一直持有对这些类加载器的引用,而这些类加载器也会一直持有它们所加载的Class对象的引用。因此,从JVM的角度来看,这些Class对象始终是可达的,从而避免了被卸载的命运。
  • 相比之下,由用户自定义的类加载器加载的类则没有这样的保证。如果没有任何引用指向由自定义类加载器加载的Class对象,那么这些类有可能会被卸载。

总结来说,JVM自带的类加载器所加载的类在其生命周期内不会被卸载,而用户自定义类加载器加载的类则可能由于失去所有引用而被卸载。

2.3、类加载器

2.3.1、类加载器

类加载器(Class Loader)是 JVM 的一部分,它的主要功能是根据类的全限定名(全路径名)来加载类。Java 的类加载机制遵循“类加载器委托模型”,即类加载请求会首先传递给父类加载器,只有父类加载器无法完成加载时,才会由子类加载器来尝试加载。我们可以用个例子来说说:

假设我们有一家餐厅,餐厅有不同的部门(类似于类加载器层次结构),每个部门都有自己的职责范围(类似于类加载器的职责)。当顾客点餐时(类似于加载类请求),服务员(类似于应用程序类加载器)会先询问厨房(类似于引导类加载器)是否有现成的食物(类似于核心类库)。如果没有,服务员再询问采购部(类似于扩展类加载器)是否有库存食材(类似于JDK扩展)。最后,如果前两者都无法满足需求,服务员将亲自去市场采购(类似于应用程序类加载器加载用户自定义类)。

虚拟机提供了三种类加载器,同时也可以自己实现,如下:

image-20241012084554786

下面我们就来看看 Java 中几种常见的类加载器以及它们各自的特点。

2.3.1.1、引导类加载器(Bootstrap ClassLoader)

引导类加载器也被称为启动类加载器或根类加载器,它是 JVM 的一部分,用 C++ 实现。引导类加载器的主要任务是加载 <JAVA_HOME>\lib 路径下的核心类库,或者是通过 -Xbootclasspath 参数指定的 jar 包。值得注意的是,这个类加载器只加载那些包名为 javajavaxsun 开头的类,以确保系统的安全性。而且,引导类加载器只为 JVM 提供服务,开发者是无法直接使用它的。

2.3.1.2、扩展类加载器(Extension ClassLoader)

扩展类加载器是由 Sun Microsystems 实现的,它位于 HotSpot 源码目录中的 sun.misc.Launcher$ExtClassLoader。扩展类加载器负责加载 <JAVA_HOME>\lib\ext 目录下的类库,或者是通过 -Djava.ext.dirs 系统属性指定的位置中的类库。与引导类加载器不同,扩展类加载器是可以被开发者直接使用的。

2.3.1.3、应用程序类加载器(Application ClassLoader)

应用程序类加载器(Application ClassLoader),也叫做系统类加载器。它同样是由 Sun Microsystems 实现的,位于 HotSpot 源码目录中的 sun.misc.Launcher$AppClassLoader。应用程序类加载器负责加载 -classpath-Djava.class.path 指定路径下的类库。它是应用程序的默认类加载器,可以通过 ClassLoader.getSystemClassLoader() 方法获取到。和扩展类加载器一样,应用程序类加载器也是可以直接被开发者使用的。

一般情况下,该类加载器是程序的默认类加载器,我们可以通过ClassLoader.getSystemClassLoader()方法可以直接获取到它。

2.3.1.4、用户自定义类加载器

有时候,标准的类加载器无法满足某些特定的需求,这时就需要自定义类加载器了。用户自定义类加载器通常是通过继承 java.lang.ClassLoader 类来实现的。开发者可以根据自己的需求重写 findClass 方法来实现特定的类查找和加载逻辑。

总的来说,Java 的类加载机制为我们提供了很大的灵活性。通过理解这些类加载器的作用,我们可以更好地管理我们的应用程序,确保它们在运行时能够正确地加载所需的类。

2.3.2、双亲委派机制

Java 的类加载器不仅仅是将字节码加载到 JVM 中那么简单,它还涉及到了一个关键的设计模式——双亲委派机制。这个机制确保了 Java 的安全性、稳定性,并减少了内存空间的占用。今天,我们就一起来探讨一下什么是双亲委派机制,以及它为什么如此重要。

双亲委派机制是一种类加载机制,它规定每一个类加载器在加载类之前,都先将其加载请求向上委托给父类加载器去完成,只有当父类加载器无法完成加载时,才会尝试自己加载。这种机制可以防止重复加载相同的类,同时也保证了 Java 核心 API 的稳定性和安全性。双亲委派的核心思想在于两点:

  1. 自下向上检查类是否已经被加载
  2. 从上至下尝试加载类

具体加载步骤如下:

image-20241012092509913

那知道了双亲委派机制是怎样的,那它肯定是是有什么过人之处的,让我们来进一步了解为什么需要双亲委派机制?

  • 避免重复加载

    • 通过双亲委派机制,可以确保同一个类只会被加载一次,从而避免了内存中出现多个相同类的实例,节省了内存空间。
  • 维护 API 的稳定性

    • Java 的核心 API 类库由最顶层的引导类加载器加载,这意味着这些类一旦被加载,就不会被重新加载或替换。这保证了核心 API 的稳定性和一致性,防止了不同版本的类共存所带来的问题。
  • 提高安全性

    • 双亲委派机制通过层次结构确保了核心类库的安全性。外部的应用程序类加载器无法加载核心类库,只能加载应用程序级别的类。这样可以防止恶意代码篡改核心类库的行为。

2.3.3、打破双亲委派机制

虽然 Java 的类加载器遵循双亲委派机制,但这并不意味着我们不能创建自定义的类加载器。实际上,通过继承 java.lang.ClassLoader 类并重写 findClass 方法,我们可以实现自己的类加载逻辑。正如俗话所说,“虽然你很好,但我有更好的”。不过,即使是在自定义类加载器中,我们也应该尽量遵循双亲委派机制,以确保系统的稳定性和安全性。

那为什么要打破双亲委派机制?

双亲委派机制虽然提供了很好的稳定性和安全性,但在某些应用场景下,我们可能需要更高的灵活性。以下是几种常见的情景,它们需要打破双亲委派机制:

热部署

在应用服务器(如 Tomcat)中,热部署是指在不重启应用的情况下更新应用程序。为了实现这一点,需要在重新部署 Web 应用时隔离旧的应用版本和新的应用版本。这时,应用服务器会使用自定义的类加载器来加载新的应用。

  • Tomcat 实现:Tomcat 在热部署时,使用自定义的类加载器加载新的应用版本。具体做法是,自定义类加载器先尝试加载类,如果加载失败,则委托给父类加载器。

插件系统

许多大型应用,如集成开发环境(IDE)或游戏,拥有自己的插件系统。为了避免插件间的类加载冲突,每个插件可能会使用自己的类加载器来加载类。

如何打破双亲委派机制?打破双亲委派机制有多种方式,以下是一些常见的实现方法:

  1. 重写 loadClass 方法

通过重写 loadClass 方法,可以实现自定义的类加载逻辑,从而绕过双亲委派机制。例如,Tomcat 就是通过这种方式实现自定义类加载器的。

// 测试类加载器
public class TestClassLoader extends ClassLoader {
​
    // 接收到的class文件本地的存储位置
    private String rootDirPath;
​
    public TestClassLoader(String rootDirPath) {
        this.rootDirPath = rootDirPath;
    }
    
    // 读取Class字节流并解密的方法
    private byte[] getClassDePass(String className) throws IOException {
        String classpath = rootDirPath + className;
​
        // 模拟文件读取过程.....
        FileInputStream fis = new FileInputStream(classpath);
        ByteArrayOutputStream stream = new ByteArrayOutputStream();
        int bufferSize = 1024;
        int n = 0;
        byte[] buffer = new byte[bufferSize];
        while ((n = fis.read(buffer)) != -1)
            // 模拟数据解密过程.....
            stream.write(buffer, 0, n);
        byte[] data = stream.toByteArray();
​
        // 模拟保存解密后的数据....
        return data;
    }
​
    // 重写了父类的findClass方法
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 读取指定的class文件
        byte[] classData = getClassDePass(name);
        // 如果没读取到数据,抛出类不存在的异常
        if (classData == null)
            throw new ClassNotFoundException();
        // 通过调用defineClass方法生成Class对象
        return defineClass(name,classData,0,classData.length);
    }
}

2. OSGi 实现

OSGi(Open Service Gateway Initiative)是一个模块化平台,它提供了一整套类加载机制,允许同级类加载器之间的调用。这种方式打破了传统的双亲委派模型,为模块化应用提供了更大的灵活性。

  1. 使用 SPI 机制 + 线程上下文类加载器

一些框架,如 JNDI 和 JDBC,使用 SPI(Service Provider Interface)机制来实现插件化功能。在这种情况下,线程上下文类加载器(Thread Context ClassLoader, TCCl)用于加载服务提供者类。这种方法也打破了双亲委派机制,允许特定的服务提供者类在特定的上下文中加载。

三、创建一个对象的过程

最后,我们已经了解了Class类文件结构、类加载机制、双亲委派模型,其实我们现在已经掌握了创建一个对象的基本过程了,如图:

image-20241012110640872

  1. Javac 编译器编译

    • 通过对 Java 源代码进行编译,将其转换为字节码文件(.class 文件)。
  2. 类加载器加载

    • 当虚拟机遇到new 指令时,它将执行以下步骤:

      1. 类加载检查:

        • 虚拟机会检查这条 new 指令的参数是否能够在常量池中定位到一个类的符号引用。
        • 并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。
        • 如果没有,那么必须先执行相应的类加载过程。
      2. 分配内存:

        • 在类加载检查通过后,虚拟机将为新生对象分配内存。
        • 对象所需内存大小在类加载完成后即可确定。
        • 分配空间任务相当于从 Java 堆中划出一块确定大小的内存。
      3. 初始化零值:

        • 内存分配完成后,虚拟机需要将分配到的内存空间初始化为零值(不包括对象头)。
        • 这一步骤保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能够访问到这些字段的数据类型所对应的零值。
      4. 进行必要设置(如对象头):

        • 初始化零值后,虚拟机需要对对象进行必要的设置,如这个对象是哪个类的实例,如何找到类的元数据信息,对象的哈希码,GC 分代年龄等。
        • 这些信息存储在对象头中。
        • 根据虚拟机当前运行状态的不同(如是否启用偏向锁),对象头会有不同的设置方式。
      5. 执行构造函数(init 方法):

        • 在上述工作完成后,从虚拟机的角度看,新的对象已经产生。
        • 但从 Java 程序的角度来看,对象创建才刚刚开始——构造函数(即 class 文件中的 <init> 方法)还未执行。
        • 所有的字段都还为零值,对象需要的其他资源和状态信息还未按照预期构造好。
        • 所以,执行 new 指令后,通常会接着执行构造函数,将对象按照程序员的意图进行初始化,这样才构造出一个真正可用的对象。
  3. 使用

    • 对象创建并初始化完毕后,即可在程序中使用。

四、总结

本文之后,我们基本上已经了解了Java类对象是如何被加载到JVM内存中的。从编译后的字节码文件,到类加载器如何一步步地将这些字节码文件加载、验证、准备、解析和初始化,直至最终创建出一个可用的对象,每一个步骤都充满了细节和技术考量。在此基础上,我们探讨了类加载器的不同层次和它们之间的协作模式。

在此,向各位提个问:大家是如何理解Java反射机制的?反射机制又是如何通过类的路径名来创建一个类的呢?

最后的最后,如果大家有任何意见或想分享的想法,欢迎在评论区留言交流!