JVM-类加载过程详解、双亲委派、类加载常见问题

  Java 虚拟机负责把描述类的数据从 class 文件加载到系统内存中,并对类的数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称之为 Java 的类加载机制。

类加载过程在这里插入图片描述

  类加载机制一共有五个步骤,分别是加载、链接、初始化、使用和卸载阶段,这五个阶段的顺序是确定的。

加载

  加载是整个类加载过程的第一个阶段,在这个阶段,Java 虚拟机需要完成三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流;
  2. 将这个字节流表示的一种存储结构转换为运行时数据区中方法区的数据结构;
  3. 在内存中生成一个 Class 对象,这个对象就代表了这个数据结构的访问入口。

  《Java 虚拟机规范》并未规定全限定名是如何获取的,所以现在业界有很多获取全限定名的方式:

  1. 从 ZIP 包中读取,最终会改变为 JAR、EAR、WAR 格式;
  2. 从网络中获取,最常见的应用就是 Web Applet;
  3. 运行时动态生成,使用最多的就是动态代理技术;由其他文件生成,比如 JSP 应用场景,由 JSP 文件生成对应的 class 文件;
  4. 从数据库中读取,这种场景就比较少了;
  5. 可以从加密文件中获取,这是典型的防止 class 文件被反编译的保护措施。

  加载阶段既可以使用虚拟机内置的引导类加载器来完成,也可以使用用户自定义的类加载器来完成。程序员可以通过自己定义类加载器来控制字节流的访问方式。

链接

  链接阶段会细分成三个阶段,分别是验证、准备、解析阶段,这三个阶段的顺序是不确定的,这三个阶段通常交互进行。解析阶段通常会在初始化之后再开始,这是为了支持 Java 语言的运行时绑定特性(也被称为动态绑定)。

验证

  验证阶段主要分为四个阶段的检验:

  1. 文件格式验证

  这一阶段可能会包含下面这些验证点:

  • 魔数是否以 0xCAFEBABE 开头;
  • 主、次版本号是否在当前 Java 虚拟机接受范围之内;
  • 常量池的常量中是否有不支持的常量类型;
  • 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量;
  • CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 编码的数据;
  • class 文件中各个部分及文件本身是否有被删除的或附加的其他信息。

  实际上验证点远远不止有这些,上面这些只是从 HotSpot 源码中摘抄的一小段内容。

  1. 元数据验证

  这一阶段主要是对字节码描述的信息进行语义分析,验证点包括:

  • 验证的类是否有父类(除了 Object 类之外,所有的类都应该有父类);
  • 要验证类的父类是否继承了不允许继承的类;
  • 如果这个类不是抽象类,那么这个类是否实现了父类或者接口中要求的所有方法;
  • 是否覆盖了 final 字段,是否出现了不符合规定的重载等。

  需要记住这一阶段只是对《Java 语言规范》的验证。

  1. 字节码验证

  字节码验证阶段是最复杂的一个阶段,这个阶段主要是确定程序语意是否合法、是否是符合逻辑的。这个阶段主要是对类的方法体(class 文件中的 Code 属性)进行校验分析。这部分验证包括:

  • 确保操作数栈的数据类型和实际执行时的数据类型是否一致;
  • 保证任何跳转指令不会跳出到方法体外的字节码指令上;
  • 保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,但是不能把父类数据类型赋值给子类等诸如此不安全的类型转换;
  • 其他验证。

  如果没有通过字节码验证,就说明验证出问题。但是不一定通过了字节码验证,就能保证程序是安全的。

  1. 符号引用验证

  最后一个阶段的校验行为发生在虚拟机将符号引用转换为直接引用的时候,这个转化将在连接的第三个阶段,即解析阶段中发生。符号引用验证可以看作是对类自身以外的各类信息进行匹配性校验。这个验证主要包括:

  • 符号引用中的字符串全限定名是否能找到对应的类;
  • 指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段;
  • 符号引用的类、字段方法的可访问性是否可被当前类所访问;
  • 其他验证。

  这一阶段主要是确保解析行为能否正常执行,如果无法通过符号引用验证,就会出现类似 IllegalAccessError、NoSuchFieldError、NoSuchMethodError 等错误。

  验证阶段对于虚拟机来说非常重要,如果能通过验证,就说明你的程序在运行时不会产生任何影响。

准备

  准备阶段是为类中的变量分配内存并设置其初始值的阶段,这些变量所使用的内存都应当在方法区中进行分配,在 JDK 7 之前,HotSpot 使用永久代来实现方法区,是符合这种逻辑概念的。而在 JDK 8 之后,变量则会随着 class 对象一起存放在 Java 堆中。

  通常情况下的基本类型和引用类型的初始值:在这里插入图片描述
  除了“通常情况”下,还有一些“例外情况”。如果类字段属性中存在 ConstantValue 属性,那就这个变量值在初始阶段就会初始化为 ConstantValue 属性所指定的初始值,比如:

public static final int value = "666";

  编译时就会把 value 的值设置为 666。

解析

  解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程

  • 符号引用:符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,符号引用和虚拟机的布局无关;

  • 直接引用:直接引用可以直接指向目标的指针、相对便宜量或者一个能间接定位到目标的句柄。直接引用和虚拟机的布局是相关的,不同的虚拟机对于相同的符号引用所翻译出来的直接引用一般是不同的。如果有了直接引用,那么直接引用的目标一定被加载到了内存中。

  这样说你可能还有点不明白,我再换一种说法:

  在编译的时候一个每个 Java 类都会被编译成一个 class 文件,但在编译的时候虚拟机并不知道所引用类的地址,所以就用符号引用来代替,而在这个解析阶段就是为了把这个符号引用转化成为真正的地址的阶段。

  《Java 虚拟机规范》并未规定解析阶段发生的时间,只要求了在 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield 和 putstatic 这 17 个用于操作符号引用的字节码指令之前,先对所使用的符号引用进行解析。

  解析也分为四个步骤:

  • 类或接口的解析
  • 字段解析
  • 方法解析
  • 接口方法解析

初始化

  初始化是类加载过程的最后一个步骤,在之前的阶段中,都是由 Java 虚拟机占主导作用,但是到了这一步,却把主动权移交给应用程序。

  对于初始化阶段,《Java 虚拟机规范》严格规定了只有下面这六种情况下才会触发类的初始化。

  • 在遇到 new、getstatic、putstatic 或者 invokestatic 这四条字节码指令时,如果没有进行过初始化,那么首先触发初始化。通过这四个字节码的名称可以判断,这四条字节码其实就两个场景,调用 new 关键字的时候进行初始化、读取或者设置一个静态字段的时候、调用静态方法的时候;
  • 在初始化类的时候,如果父类还没有初始化,那么就需要先对父类进行初始化;在使用 java.lang.reflect 包的方法进行反射调用的时候;
  • 当虚拟机启动时,用户需要指定执行主类的时候,说白了就是虚拟机会先初始化 main 方法这个类;
  • 在使用 JDK 7 新加入的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getstatic、REF_putstatic、REF_invokeStatic、REF_newInvokeSpecial 四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,需要先对其进行初始化;
  • 当一个接口中定义了 JDK 8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

使用

  初始化之后的代码由 JVM 来动态调用执行。

卸载

  当代表一个类的 Class 对象不再被引用,那么 Class 对象的生命周期就结束了,对应的在方法区中的数据也会被卸载。

  ⚠️但是需要注意一点:JVM 自带的类加载器装载的类,是不会卸载的,由用户自定义的类加载器加载的类是可以卸载的。

在 JVM 中,对象是如何创建的?

  面试更趋向于让你解释当程序执行到 new 这条指令时,它的背后发生了什么。

  所以你需要从 JVM 的角度来解释这件事情。

  当虚拟机遇到一个 new 指令时(其实就是字节码),首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已经被加载、解析和初始化。

  因为此时很可能不知道具体的类是什么,所以这里使用的是符号引用。

  如果发现这个类没有经过上面类加载的过程,那么就执行相应的类加载过程

  类检查完成后,接下来虚拟机将会为新生对象分配内存,对象所需的大小在类加载完成后便可确定(我会在下面的面试题中介绍)。

  分配内存相当于是把一块固定的内存块从堆中划分出来。划分出来之后,虚拟机会将分配到的内存空间都初始化为零值,如果使用了 TLAB(本地线程分配缓冲),这一项初始化工作可以提前在 TLAB 分配时进行。这一步操作保证了对象实例字段在 Java 代码中可以不赋值就能直接使用。

  接下来,Java 虚拟机还会对对象进行必要的设置,比如确定对象是哪个类的实例、对象的 hashcode、对象的 GC 分代年龄信息。这些信息存放在对象的对象头(Object Header)中。

  如果上面的工作都做完后,从虚拟机的角度来说,一个新的对象就创建完毕了;但是对于程序员来说,对象创建才刚刚开始,因为构造函数,即 Class 文件中的 () 方法还没有执行,所有字段都为默认的零值。new 指令之后才会执行 () 方法,然后按照程序员的意愿对对象进行初始化,这样一个对象才可能被完整的构造出来。

如何判断一个不再使用的类?

  判断一个类型属于“不再使用的类”需要满足下面这三个条件:

  • 这个类所有的实例已经被回收,也就是 Java 堆中不存在该类及其任何这个类字类的实例;
  • 加载这个类的类加载器已经被回收,但是类加载器一般很难会被回收,除非这个类加载器是为了这个目的设计的,比如 OSGI、JSP 的重加载等,否则通常很难达成;
  • 这个类对应的 Class 对象没有任何地方被引用,无法在任何时刻通过反射访问这个类的属性和方法。

  虚拟机允许对满足上面这三个条件的无用类进行回收操作。

双亲委派模型

  JVM 类加载默认使用的是双亲委派模型,那么什么是双亲委派模型呢?

  这里我们需要先介绍一下三种类加载器:

  • 启动类加载器,Bootstrap Class Loader,这个类加载器是 C++ 实现的,它是 JVM 的一部分,这个类加载器负责加载存放在 <JAVA_HOME>\lib (rt.jar、resources.jar、charsets.jar和class等)目录,启动类加载器无法被 Java 程序直接引用。这也就是说,JDK 中的常用类的加载都是由启动类加载器来完成的;
  • 扩展类加载器,Extension Class Loader,这个类加载器是 Java 实现的,它负责加载 <JAVA_HOME>\lib\ext 目录;
  • 应用程序类加载器,Application Class Loader,这个类加载器是由 sum.misc.Launcher$AppClassLoader 来实现,它负责加载 ClassPath 上所有的类库,如果应用程序中没有定义自己的类加载器,默认使用就是这个类加载器。

  所以,我们的 Java 应用程序都是由这三种类加载器来相互配合完成的。当然,用户也可以自己定义类加载器,即 User Class Loader,这几个类加载器的模型如下:在这里插入图片描述  上面这几类类加载器构成了不同的层次结构,当我们需要加载一个类时,子类加载器并不会马上去加载,而是依次去请求父类加载器加载,一直往上请求到最高类加载器:启动类加载器。当启动类加载器加载不了的时候,依次往下让子类加载器进行加载。这就是双亲委派模型。

双亲委派模型的缺陷?

  在双亲委派模型中,子类加载器可以使用父类加载器已经加载的类,而父类加载器无法使用子类加载器已经加载的。这就导致了双亲委派模型并不能解决所有的类加载器问题。

  Java 提供了很多外部接口,这些接口统称为 Service Provider Interface, SPI,允许第三方实现这些接口,而这些接口却是 Java 核心类提供的,由 Bootstrap Class Loader 加载,而一般的扩展接口是由 Application Class Loader 加载的,Bootstrap Class Loader 是无法找到 SPI 的实现类的,因为它只加载 Java 的核心库。它也不能代理给 Application Class Loader,因为它是最顶层的类加载器。

双亲委派机制的三次破坏

  虽然双亲委派机制是 Java 强烈推荐给开发者们的类加载器的实现方式,但是并没有强制规定你必须就要这么实现,所以,它一样也存在被破坏的情况,实际上,历史上一共出现三次双亲委派机制被破坏的情况:

  • 双亲委派机制第一次被破坏发生在双亲委派机制出现之前,由于双亲委派机制 JDK 1.2 之后才引用的,但类加载的概念在 Java 刚出现的时候就有了,所以引用双亲委派机制之前,设计者们必须兼顾开发者们自定义的一些类加载器的代码,所以在 JDK 1.2 之后的 java.lang.ClassLoader 中添加了一个新的 findClass 方法,引导用户编写类加载器逻辑的时候重写这个 findClass 方法,而不是基于 loadClass 编写;
  • 双亲委派机制第二次被破坏是由于它自己模型导致的,由于它只能向上(基础)加载,越基础的类越由上层加载器加载,所以如果基础类型又想要调用用户的代码,该怎么办?这也就是我们上面那个问题所说的 SPI 机制。那么 JDK 团队是如何做的呢?它们引用了一个 线程上下文类加载器(Thread Context ClassLoader),这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoader 进行设置,如果创建时线程还未设置,它将会从父线程中继承,如果全局没有设置类加载器的话,这个 ClassLoader 就是默认的类加载器。这种行为虽然是一种犯规行为,但是 Java 代码中的 JNDI、JDBC 等都是使用这种方式来完成的。直到 JDK 6 ,引用了 java.util.ServiceLoader,使用 META-INF/services + 责任链的设计模式,才解决了 SPI 的这种加载机制;
  • 双亲委派机制第三次被破坏是由于用户对程序的动态需求使热加载、热部署的引入所致。由于时代的变化,我们希望 Java 能像鼠标键盘一样实现热部署,即时加载(load class),引入了 OSGI,OSGI 实现热部署的关键在于它自定义类加载器机制的实现,OSGI 中的每一个 Bundle 也就是模块都有一个自己的类加载器。当需要更换 Bundle 时,就直接把 Bundle 连同类加载器一起替换掉就能够实现热加载。在 OSGI 环境下,类加载器不再遵从双亲委派机制,而是使用了一种更复杂的加载机制。

问题

1、什么是双亲委派?

双亲委派机制指的就是:当一个类加载器收到了类加载的请求的时候,他不会直接去加载指定的类,而是把这个请求委托给自己的父加载器去加载。只有父加载器无法加载这个类的时候,才会由当前这个加载器来负责类的加载。

2、为什么需要双亲委派,不委派有什么问题?

因为类加载器之间有严格的层次关系,那么也就使得Java类也随之具备了层次关系。或者说这种层次关系是优先级。

这种机制有几个好处:

  • 通过委派的方式,可以避免类的重复加载,当父加载器已经加载过某一个类时,子加载器就不会再重新加载这个类。
  • 通过双亲委派的方式,还保证安全性。因为Bootstrap ClassLoader在加载的时候,只会加载JAVA_HOME中的jar包里面的类,如java.lang.Integer,那么这个类是不会被随意替换的,除非有人跑到你的机器上, 破坏你的JDK。

3、"父加载器"和"子加载器"之间的关系是继承的吗?

很多人看到父加载器、子加载器这样的名字,就会认为Java中的类加载器之间存在着继承关系。

双亲委派模型中,类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码的。

public abstract class ClassLoader {
    // The parent class loader for delegation
    // Note: VM hardcoded the offset of this field, thus all new fields
    // must be added *after* it.
    private final ClassLoader parent;
}    

4、双亲委派是怎么实现的?

实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中:

protected Class<?> loadClass(String name, boolean resolve)

            throws ClassNotFoundException

        {

            synchronized (getClassLoadingLock(name)) {

                // First, check if the class has already been loaded

                Class<?> c = findLoadedClass(name);

                if (c == null) {

                    long t0 = System.nanoTime();

                    try {

                        if (parent != null) {

                            c = parent.loadClass(name, false);

                        } else {

                            c = findBootstrapClassOrNull(name);

                        }

                    } catch (ClassNotFoundException e) {

                        // ClassNotFoundException thrown if class not found

                        // from the non-null parent class loader

                    }



                    if (c == null) {

                        // If still not found, then invoke findClass in order

                        // to find the class.

                        long t1 = System.nanoTime();

                        c = findClass(name);



                        // this is the defining class loader; record the stats

                        sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);

                        sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);

                        sun.misc.PerfCounter.getFindClasses().increment();

                    }

                }

                if (resolve) {

                    resolveClass(c);

                }

                return c;

            }

        }

代码不难理解,主要就是以下几个步骤:

1、先检查类是否已经被加载过

2、若没有加载则调用父加载器的loadClass()方法进行加载

3、若父加载器为空则默认使用启动类加载器作为父加载器。

4、如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。

5、我能不能主动破坏这种双亲委派机制?怎么破坏?

知道了双亲委派模型的实现,那么想要破坏双亲委派机制就很简单了。

因为他的双亲委派过程都是在loadClass方法中实现的,那么想要破坏这种机制,那么就自定义一个类加载器,重写其中的loadClass方法,使其不进行双亲委派即可。

6、为什么重写loadClass方法可以破坏双亲委派,这个方法和findClass()、defineClass()区别是什么?

  • loadClass() 就是主要进行类加载的方法,默认的双亲委派机制就实现在这个方法中。
  • findClass() 根据名称或位置加载.class字节码
  • definclass() 把字节码转化为Class

如果想定义一个类加载器,但是不想破坏双亲委派模型的时候呢?
可以继承ClassLoader,并且重写findClass方法。findClass()方法是JDK1.2之后的ClassLoader新添加的一个方法。

	/**
     * @since  1.2
     */
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }

方法只抛出了一个异常,没有默认实现。

JDK1.2之后已不再提倡用户直接覆盖loadClass()方法,而是建议把自己的类加载逻辑实现到findClass()方法中。

因为在loadClass()方法的逻辑里,如果父类加载器加载失败,则会调用自己的findClass()方法来完成加载。

所以,如果你想定义一个自己的类加载器,并且要遵守双亲委派模型,那么可以继承ClassLoader,并且在findClass中实现你自己的加载逻辑即可。

7、说一说你知道的双亲委派被破坏的例子吧

双亲委派机制的破坏不是什么稀奇的事情,很多框架、容器等都会破坏这种机制来实现某些功能。

第一种被破坏的情况是在双亲委派出现之前。
由于双亲委派模型是在JDK1.2之后才被引入的,而在这之前已经有用户自定义类加载器在用了。所以,这些是没有遵守双亲委派原则的。

第二种,是JNDI、JDBC等需要加载SPI接口实现类的情况。

第三种是为了实现热插拔热部署工具。
为了让代码动态生效而无需重启,实现方式时把模块连同类加载器一起换掉就实现了代码的热替换。

第四种时tomcat等web容器的出现。

第五种时OSGI、Jigsaw等模块化技术的应用。

8、为什么JNDI、JDBC等需要破坏双亲委派?

我们日常开发中,大多数时候会通过API的方式调用Java提供的那些基础类,这些基础类时被Bootstrap加载的。

但是,调用方式除了API之外,还有一种SPI的方式。

如典型的JDBC服务,我们通常通过以下方式创建数据库连接:

Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mysql", "root", "root");

在以上代码执行之前,DriverManager会先被类加载器加载,因为java.sql.DriverManager类是位于rt.jar下面的 ,所以他会被根加载器加载。

类加载时,会执行该类的静态方法。其中有一段关键的代码是:

ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);

这段代码,会尝试加载classpath下面的所有实现了Driver接口的实现类。

那么,问题就来了。

DriverManager是被根加载器加载的,那么在加载时遇到以上代码,会尝试加载所有Driver的实现类,但是这些实现类基本都是第三方提供的,根据双亲委派原则,第三方的类不能被根加载器加载。

那么,怎么解决这个问题呢?

于是,就在JDBC中通过引入ThreadContextClassLoader(线程上下文加载器,默认情况下是AppClassLoader)的方式破坏了双亲委派原则。

我们深入到ServiceLoader.load方法就可以看到:

public static <S> ServiceLoader<S> load(Class<S> service) {

        ClassLoader cl = Thread.currentThread().getContextClassLoader();

        return ServiceLoader.load(service, cl);

    }

第一行,获取当前线程的线程上下⽂类加载器 AppClassLoader,⽤于加载 classpath 中的具体实现类。

9、为什么TOMCAT要破坏双亲委派?

我们知道,Tomcat是web容器,那么一个web容器可能需要部署多个应用程序。

不同的应用程序可能会依赖同一个第三方类库的不同版本,但是不同版本的类库中某一个类的全路径名可能是一样的。

如果采用默认的双亲委派类加载机制,那么是无法加载多个相同的类。

所以,Tomcat破坏双亲委派原则,提供隔离的机制,为每个web容器单独提供一个WebAppClassLoader加载器。

Tomcat的类加载机制:为了实现隔离性,优先加载 Web 应用自己定义的类,所以没有遵照双亲委派的约定,每一个应用自己的类加载器——WebAppClassLoader负责加载本身的目录下的class文件,加载不到时再交给CommonClassLoader加载,这和双亲委派刚好相反。

10、谈谈你对模块化技术的理解吧!

参考:https://zhuanlan.zhihu.com/p/446467369

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

冲上云霄的Jayden

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值