Java类加载过程

目录

概述 

类加载时机

类加载过程

加载

验证

文件格式验证

元数据验证

字节码校验

符号引用校验

准备

解析

初始化

类加载器

双亲委派模型

双亲委派被破坏


 

概述 

        Java虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称作虚拟机的类加载机制。在 Java 语言里面,类型的加载、连接和初始化的过程都是程序运行期间完成的,这种策略让 Java 语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为 Java 应用提供了极高的扩展性和灵活性。Java 天生可以可以动态扩展的语言特性就是依赖于动态加载和动态连接这个特点实现的。

类加载时机

        一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用和卸载(Unloading)七个阶段。其中验证、准备、解析三个部分统称之连接(Linking)

 

        加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班的开始,而解析阶段则不一定;它在某些情况可以在初始化阶段之后再开始,这是为了支持 Java 语言的运行时绑定特性(动态绑定或晚期绑定)。

这里说的是按部就班的开始,而不是按部就班的进行或者完成,强调这点是因为这些阶段通常都是相互交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。

        关于什么情况下需要开始类加载过程的第一阶段”加载“,《Java虚拟机规范》中并没有进行强制性的约束,这点可以交给虚拟机的具体实现来把握。但对于初始化阶段,《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行初始化(而加载、验证、准备自然需要在此之前开始):

  1. 遇到 new、getstatic、putstatic 或 invokestatic 这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段,能够生成这四条指令的典型 Java 代码场景有:

    1. 使用 new 关键字实例化对象

    2. 首次访问(读取或者设置)这个类的静态变量(被final 修饰、已在编译期把结果放入常量池的静态字段除外)和静态方法的时候

  2. 使用 java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。

  3. 当类进行初化的时候,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化

  4. 当虚拟机启动时,用户需要指定一个执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类

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

  6. 当一个接口定义了 JDK 8 新加入的默认方法(被 default关键字修饰的接口方法)时,如果这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

 这六种场景中的行为称之为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称之为被动引用。

被动引用示例:

public class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }

    public static int value = 123;
}


public class SubClass extends SuperClass{

    static {
        System.out.println("SubClass init!");
    }
}

 

// 通过子类引用父类的静态字段,不会导致子类初始化
public class Main {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}

// console result:
SuperClass init!
123

对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,指挥触发父类的初始化而不会初始化子类的初始化

// 通过数组定义来引用类,不会触发此类的初始化
public class Main {
    public static void main(String[] args) {
        SuperClass[] arr = new SuperClass[10];
    }
}

// console result:
// 常量在便宜阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
public class ConstClass {
    static {
        System.out.println("ConstClass init!");
    }

    public static final String HELLO_WORLD = "hello world";
}


public class Main {
    public static void main(String[] args) {
        System.out.println(ConstClass.HELLO_WORLD);
    }
}

// console result:
hello world

        上述代码没有输出ConstClass init!是因为虽然在 Java 源码中确实引用了 ConstClass 类的常量 HELLO_WORLD,但其实在编译阶段通过常量传播优化,已经将此常量的值 "helllo world" 直接存储在 Main 类的常量池中,以后 Main 类对常量ConstClass.HELLO_WORLD的引用,都被转化为对 Main 类自身常量池的引用了,也就是说,实际上 Main 的 Class 文件并没有 ConstClass 类的符号引用入口,这两个类在编译成 Class 文件后就已不存在任何联系了。

        接口的加载和类的加载过程稍有些不同,接口也有初始化过程,这点是于类是一致的,上面的代码是通过使用 静态语句块static{}来输出初始化信息的,但是接口不能使用静态语句块,但是编译器任然会为接口生成<clinit>()类构造器,用于初始化接口中所定义的成员变量。接口与类真正有所区别的是前面讲述的六种”有且仅有“需要触发初始化场景中的第三种:当一个类在初始化时,要求其父类全部已经初始化过了,但是一个接口在进行初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

类加载过程
 

加载

Java虚拟机需要完成以下三件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流

  • 将字节流所代表的静态存储结构转换为方法区的运行时数据结构

  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

    《Java 虚拟机规范》对这三点要求其实并不是特别具体,留给虚拟机实现与 Java应用的灵活度都是相当大的,例如”通过一个类的全限定名来获取定义此类的二进制字节流“,它并没有指明二进制字节流必须得从某个 Class 文件中获取,确切地说是根本没有指明要从哪里获取、如何获取。仅仅通过这个空隙,Java虚拟机的使用者就可以在类加载阶段构建出一个相当开放广阔的舞台,例如:

    • 从ZIP压缩包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础

    • 从网络中获取,例如 Web Applet

    • 运行时时计算生成,这种场景用的最多的就是动态代理,在java.lang.reflect.Proxy中,就是使用了ProxyGenerator.genreateProxyClass()来为特定接口生成形式为"*$Proxy"的代理类的二进制字节流

    • 由其他文件生成,典型场景就是 JSP 应用,由 JSP 文件生成对应的 Class 文件

相对于类加载过程中的其他阶段,非数组类型的加载阶段(准确来说,是加载阶段中获取类的二进制文件流的动作)是开发人员可控性最强的阶段。加载阶段可以使用 Java虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员通过定义自己的类加载器去控制字节流的获取方式(重写一个类加载器的findClass()或者loadClass()方法),实现根据自己的想法来赋予应用程序获取运行代码的动态性。

加载阶段结束之后,Java虚拟机外部的二进制字节流就按照虚拟机设定的格式存储在方法区之中了,方法区中数据的存储格式完全由虚拟机实现自行定义,数据类型妥善安置在方法区中后,会在 Java堆内存中实例化一个java.lang.Class类对象,这个对象将作为程序访问方法区中的类型数据的外部接口。

加载阶段与连接阶段的部分动作(部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但是这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始事件仍然保持着固定的先后顺序

验证

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

从代码量和耗费的执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重。从整体上看,验证阶段大致会完成下下面四个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证

文件格式验证

第一阶段要验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理

  • 是否以魔数0xCAFEBABE开头

  • 主、次版本号是否在当前 Java虚拟机接受范围之内

  • 常量池中的常量是否有不被支持的常量类型

  • 指向常量的各种索引值中是否有指向不存在的常量或者不符合类型的常量

实际上第一阶段验证的远不止这些,该验证阶段的主要目的是保证输入的字节流能正确地解析并存储与方法区之内,格式上符合一个 Java类型信息的要求。这阶段的验证是基于二进制字节流来进行的,只有通过这个了这个阶段的验证之后,这段字节流才被允许进入 Java虚拟机内存的方法区中存储,所以后面的三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了

元数据验证

第二阶段是对字节码描述的信息进行语义分析,以保证其描述信息符合要求

  • 这个类是否有父类(除了java.lang.Object之外,所有类都应当有父类)

  • 这个类是否继承了不允许被继承的类(被 final 修饰的类)

  • 如果这个类不是抽象类,是否实现了其父接口或接口之中要求实现的所有方法

  • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现了不符合规范的重载)

这一阶段主要的目的是对类的元数据进行语义校验

字节码校验

第三验证阶段是整个验证阶段过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的,这阶段就是对类的方法体(Class 文件中的 code 属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于在操作栈放置了一个 int 类型的数据,使用时却按照 long 类型来加载入本地变量表中的这种情况

  • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上

  • 保证方法体中的类型转换总是有效的

如果一个类型中有方法体的字节码没有通过字节码校验,那它肯定是有问题的;但如果一个方法体通过了字节码验证,也仍然不能保证它一定就是安全的。、

符号引用校验

最后一个校验发生在虚拟机将符号引用转换成直接引用的时候,这个转换动作发生在连接的第三阶段 - 解析阶段中发生,符号引用可以看作是对类自身以外(常量池的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等属性

  • 符号引用通过字符串描述的全限定名是否能找到对应的类

  • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。

  • 符合引用中的类、字段、方法的可访问性(public、private...)是否可悲当前类访问

符号引用校验的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,Java 虚拟机会抛出一个java.lang.IncompatibleClassChangeError的子类异常。

验证阶段对于 Java虚拟机的类加载机制来说是一个非常重要的、但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响了

       

准备

        准备阶段是正式为类中定义的静态变量分配内存并设置类变量初始值的阶段,从概念上讲 ,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到的是方法区本身是一个逻辑上的区域,在 JDK 7 及之前,HotSpot 使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在 JDK 8 及之后,类变量则会随着 Class 对象一起存放在 Java 堆中。

关于准备阶段,首先是这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量会在对象实例化时随着对象一起分配在 Java 堆中。其次,这里说的初始值 通常情况 是 数据类型的零值,例如:

public static int value = 123;

那变量 value 在准备阶段过后的初始值为 0 而不是 123,因为此时尚未开始执行任何 Java 方法,而把 value 赋值给 123 的putstatic 指令是程序被编译后,存放于类构造器<clinit>()方法之后,所以把 value 赋值为 123 的动作要到类的初始化阶段才会被执行。

在特殊情况下,如果该字段的字段属性表中存在 ConstantValue 属性(被 final 修饰的基本类型和字符串类型),那么在准备阶段变量值就会被 初始化为 ConstantValue 属性所指定的初始值

public static final int value = 123;

 

解析

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

  • 符号类型:以一组符合来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可

  • 直接引用:是可以指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄

public class Load2{
    public static void main(Sting[] args){
        ClassLoader classloader = Load2.class.getClassLoader();
        // loadClass 方法不会导致类的解析和初始化
        Class<?> c = classloader.loadClass("com.yang.load.C");
        System.in.read();
    }
}

class C{
    D d = new D();
}

class D{
}

 

new C();

 

初始化

        初始化阶段就是执行类构造器<clinit>()方法的过程,<client>()并不是程序员在 Java代码中直接编写的方法,它是 Javac 编译器自动生成的。<client>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态代码块中的代码合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态代码块只能访问到定义在该静态代码块之前的变量,定义在后面的变量,静态代码块可以为其赋值,但是不能访问。

<client>()方法与类的构造函数(<init>()方法)不同,它不需要显式的调用父类构造器,Java 虚拟机会保证在子类的<client>()方法执行前,父类的<client>()方法已经执行完毕,因此在 Java虚拟机中,第一个被执行的<client>()方法的类型肯定是java.lang.Object。由于父类的<client>()方法先执行,也就意味着父类中定义的静态代码块要优于子类的变量赋值操作。

<client>()方法对类或者接口来说并不是必需的,如果一个类中没有静态代码块和对变量的赋值操作,那么编译器可以不为这个类生成<client>()方法。

接口中不能使用静态代码块,但是仍然有变量初始化的赋值操作,因此接口与类一样,都会生成<client>()方法,但是不同的是,执行接口的<client>()方法不需要先执行父接口的 <client>() 方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化,此外,接口的实现类在初始化时一样不会执行接口的<client>()方法,

Java 虚拟机必须保证一个类的<client>()方法在多线程环境中被正确的加锁同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<client>()方法,其他线程都需要阻塞等待,知道活动线程执行完毕<client>()方法。

类加载器

        Java虚拟机设计团队有意把类加载阶段中的"通过一个类的全限定名来获取描述该类的二进制文件"这个动作放到虚拟机外部来实现,以便让应用程序自己决定如何去获取所需的类,实现这个动作的代码被称之为 类加载器(Class Loader)

        类加载器虽然只用于实现类的加载动作,但它在 Java程序中起到的作用却远超类加载阶段,对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在 Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间,更通俗的说:比较两个类是否相等,只有这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源同一个 Class 文件,被同一个虚拟机加载,只要加载他它们的类加载器不同,那这两个类就必定不相等。

相等包括类的 Class对象的 euquals()方法、isAssignableFrom()方法、isInstance()方法,也包括使用 instanceof 关键字对对象所属关系判定等各种情况

双亲委派模型

 

站在 Java虚拟机的角度来看,只存在两种不同的类加载器,一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 实现,是虚拟机的一部分,另一种就是其他所有的类加载器,这些加载器都由 Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader

在 Java开发人员的角度来看,Java一直保持着三层类加载器、双亲委派的类加载架构

  • 启动类加载器(Bootstrap Class Loader):负责加载存放在<JAVA_HOME>\lib目录

  • 扩展类加载器(Extension Class Loader):这个类加载器是在sun.misc.Launcher$ExtClassLoader中以 Java代码的形式实现。负责加载<JAVA_HOME>\lib\ext目录中,JDK 开发人员允许用户将具有通过性的类库放置在 ext 目录里以扩展 JavaSE 的功能。

  • 应用程序类加载器(Application Class Loader):由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)上所有的类库,如果程序中没有自定义过自己的类加载器,一般情况下这个就是程序默认的类加载器。

 

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

双亲委派模型对于保证 Java程序的稳定性运作极为重要,但它的实现却异常简单,代码在java.lang.ClassLoaderloadClss()方法中。

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;
        }
    }

双亲委派模型并不是一个具有强制性约束的模型,而是由 Java作者推荐给开发者们的类加载器实现方式

双亲委派被破坏

  • 自身缺陷导致被破坏,双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类越由上层的加载器进行加载)基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的 API 存在,但是程序设计往往没有绝对不变的完美规则,如果有基础类型又要调用回用户的代码,那该怎么办?

    • 例如 JNDI 服务,JNDI现在是 Java 的标准服务,它的代码是由启用类加载器来完成加载。但 JNDI 存在的目的就是对资源进行查找和集中管理,他需要调用由其他厂商实现并部署在应用程序中的 ClassPath 下的 JNDI 服务提供者接口的代码,但是启动类加载器是绝对不可能认识、加载这些代码,那怎么办?

    • 为了解决上述问题,Java 设计团队引入了 线程上下文类加载器(Thread Context ClassLoader),这个类加载器可以通过java.lang.Thread类中的setContext-ClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父加载器中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

    • 有了线程上下文切换器,JNDI 服务使用这个 线程上下文类加载器去加载所需的 SPI 服务代码,这是一种父类加载器去请求子类加载 器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类 加载器,已经违背了双亲委派模型的一般性原则,Java 中涉及 SPI 的加载基本上都采用这种方式来完成,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。 不过,当 SPI 的服务提供者多于一个的时候,代码就只能根据具体提供者的类型来硬编 码判断,为了消除这种极不优雅的实现方式,在 JDK 6 时,JDK 提供了 java.util.ServiceLoader 类,以 META-INF/services 中的配置信息,辅以责任链模式,这才 算是给 SPI 的加载提供了一种相对合理的解决方案。

  • 用户对程序动态性的追求而导致的,的“动态性”指的是一些非常“热”门的名词:代码热替换(Hot Swap)、模块热部署 (Hot Deployment)等。说白了就是希望 Java 应用程序能像我们的电脑外设那样,接上 鼠标、U 盘,不用重启机器就能立即使用,

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值