2019-03-08 | Java | Unlock

JVM-类加载

1 类加载的时机

1.1 类的生命周期

JVM_09.png

一个类从加载进内存到卸载出内存为止,一共经历7个阶段:

加载——>验证——>准备——>解析——>初始化——>使用——>卸载

其中,类加载包括5个阶段:

加载——>验证——>准备——>解析——>初始化

在类加载的过程中,以下3个过程称为连接:

验证——>准备——>解析

因此,JVM的类加载过程也可以概括为3个过程:

加载——>连接——>初始化

加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始(注意是“开始”,而不是“进行”或“完成”),而解析阶段则不一定:它在某些情况下可以在初始化后再开始,这是为了支持 Java 语言的运行时绑定。

C/C++在运行前需要完成预处理、编译、汇编、链接;而在Java中,类加载(加载、连接、初始化)是在程序运行期间完成的。

在程序运行期间进行类加载会稍微增加程序的开销,但随之会带来更大的好处——提高程序的灵活性。Java语言的灵活性体现在它可以在运行期间 动态扩展 ,所谓动态扩展就是在运行期间 动态加载 和 动态连接 。

1.2 类加载的时机

1.2.1 类加载过程中每个步骤的顺序

我们已经知道,类加载的过程包括:加载、连接、初始化,连接又分为:验证、准备、解析,所以说类加载一共分为5步:加载、验证、准备、解析、初始化。

其中加载、验证、准备、初始化的 开始 顺序是依次进行的,这些步骤开始之后的过程可能会有重叠。

而解析过程会发生在初始化过程中。

1.2.2 类加载过程中“初始化”开始的时机

JVM规范中只定义了类加载过程中初始化过程开始的时机,加载、连接过程都应该在初始化之前开始(解析除外),这些过程具体在何时开始,JVM规范并没有定义,不同的虚拟机可以根据具体的需求自定义。

初始化开始的时机:

在运行过程中遇到如下字节码指令时,如果类尚未初始化,那就要进行初始化:new、getstatic、putstatic、invokestatic。

这四个指令对应的Java代码场景是:

通过new创建对象;

读取、设置一个类的静态成员变量(不包括final修饰的静态变量);

调用一个类的静态成员函数。

使用java.lang.reflect进行反射调用的时候,如果类没有初始化,那就需要初始化;

当初始化一个类的时候,若其父类尚未初始化,那就先要让其父类初始化,然后再初始化本类;

当虚拟机启动时,虚拟机会首先初始化带有main方法的类,即主类;

Java 虚拟机规范没有强制约束类加载过程的第一阶段(即:加载)什么时候开始,但对于“初始化”阶段,有着严格的规定。有且仅有 5 种情况必须立即对类进行“初始化”:

(1)在遇到 new、putstatic、getstatic、invokestatic 字节码指令时,如果类尚未初始化,则需要先触发其初始化。

(2)对类进行反射调用时,如果类还没有初始化,则需要先触发其初始化。

(3)初始化一个类时,如果其父类还没有初始化,则需要先初始化父类。

(4)虚拟机启动时,用于需要指定一个包含 main() 方法的主类,虚拟机会先初始化这个主类。

(5)当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类还没初始化,则需要先触发其初始化。

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

1.2.3 主动引用 与 被动引用

JVM规范中要求在程序运行过程中,“当且仅当”出现上述4个条件之一的情况才会初始化一个类。如果间接满足上述初始化条件是不会初始化类的。

其中,直接满足上述初始化条件的情况叫做 主动引用 ;间接满足上述初始化过程的情况叫做 被动引用 。

那么,只有当程序在运行过程中满足主动引用的时候才会初始化一个类,若满足被动引用就不会初始化一个类。

1.2.4 被动引用的场景示例

Demo1

public class Fu{
    public static String name = "听风行";
        static{
            System.out.println("父类被初始化!");
        }
    }

    public class Zi{
        static{
            System.out.println("子类被初始化!");
        }
    }

    public static void main(String[] args){
        System.out.println(Zi.name);
    }
}

输出结果:

父类被初始化!

听风行

原因分析:

本示例看似满足初始化时机的第一条:当要获取某一个类的静态成员变量的时候如果该类尚未初始化,则对该类进行初始化。

但由于这个静态成员变量属于Fu类,Zi类只是间接调用Fu类中的静态成员变量,因此Zi类调用name属性属于间接引用,而Fu类调用name属性属于直接引用,由于JVM只初始化直接引用的类,因此只有Fu类被初始化。

Demo2

public class A{
    public static void main(String[] args){
        Fu[] arr = new Fu[10];
    }

}

输出结果:

并没有输出“父类被初始化!”

原因分析:

这个过程看似满足初始化时机的第一条:遇到new创建对象时若类没被初始化,则初始化该类。

但现在通过new要创建的是一个数组对象,而非Fu类对象,因此也属于间接引用,不会初始化Fu类。

Demo3

public class Fu{
    public static final String name = "听风行";
        static{
            System.out.println("父类被初始化!");
        }
    }

    public class A{
        public static void main(String[] args){
            System.out.println(Fu.name);
        }
    }
}

输出结果:

听风行

原因分析:

本示例看似满足类初始化时机的第一个条件:获取一个类静态成员变量的时候若类尚未初始化则初始化类。

但是,Fu类的静态成员变量被final修饰,它已经是一个常量。被final修饰的常量在Java代码编译的过程中就会被放入它被引用的class文件的常量池中(这里是A的常量池)。所以程序在运行期间如果需要调用这个常量,直接去当前类的常量池中取,而不需要初始化这个类。

Demo4

/**
 * 被动引用 Demo1:
 * 通过子类引用父类的静态字段,不会导致子类初始化。
 */
class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }
    public static int value = 123;
}

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

public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
        // SuperClass init!
    }
}

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

Demo5

/**
 * 被动引用 Demo2:
 * 通过数组定义来引用类,不会触发此类的初始化。
 */

public class NotInitialization {
    public static void main(String[] args) {
        SuperClass[] superClasses = new SuperClass[10];
    }
}

这段代码不会触发父类的初始化,但会触发“[L 全类名”这个类的初始化,它由虚拟机自动生成,直接继承自 java.lang.Object,创建动作由字节码指令 newarray 触发。

Demo6

/**
 * 被动引用 Demo3:
 * 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
 */
class ConstClass {
    static {
        System.out.println("ConstClass init!");
    }
    public static final String HELLO_BINGO = "Hello Bingo";

}

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

编译通过之后,常量存储到 NotInitialization 类的常量池中,NotInitialization 的 Class 文件中并没有 ConstClass 类的符号引用入口,这两个类在编译成 Class 之后就没有任何联系了。

1.2.5 接口的初始化(接口的加载过程)

接口和类都需要初始化,接口和类的初始化过程基本一样,不同点在于:类初始化时,如果发现父类尚未被初始化,则先要初始化父类,然后再初始化自己;但接口初始化时,并不要求父接口已经全部初始化,只有程序在运行过程中用到当父接口中的东西时才初始化父接口。

当一个类在初始化时,要求其父类全部都已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,当真正用到父接口的时候才会初始化。

2 类加载的过程

通过之前的介绍可知,类加载过程共有5个步骤,分别是:加载、验证、准备、解析、初始化。其中,验证、准备、解析称为连接。下面详细介绍这5个过程JVM所做的工作。

2.1 加载

注意:“加载”是“类加载”过程的第一步,不能混淆这两个名词。

在加载过程中,JVM主要做3件事情:

(1)通过一个类的全限定名来获取这个类的二进制字节流,即class文件:

在程序运行过程中,当要访问一个类时,若发现这个类尚未被加载,并满足类初始化时机的条件时,就根据要被初始化的这个类的全限定名找到该类的二进制字节流,开始加载过程。

(2)将二进制字节流的存储结构转化为特定的数据结构,存储在方法区中;[将二进制字节流所代表的静态结构转化为方法区的运行时数据结构]

(3)在内存中创建一个代表该类的java.lang.Class类型的对象,作为方法区这个类的各种数据的访问入口。

接下来程序在运行过程中所有对该类的访问都通过这个类对象,也就是这个Class类型的类对象是提供给外界访问该类的接口。

(1)获取二进制字节流

JVM规范对于加载过程给予了较大的宽松度。一般二进制字节流都从已经编译好的本地class文件中读取,此外还可以从以下地方读取:

a.从压缩包zip中读取,如:Jar、War、Ear等。

b.从网络中获取,从网络中获取二进制字节流。如:Applet。

c.通过动态代理计数生成代理类的二进制字节流

d.从其它文件中动态生成,如:从JSP文件中生成Class类。

e.从数据库中读取,将二进制字节流存储至数据库中,然后在加载时从数据库中读取。有些中间件会这么做,用来实现代码在集群间分发。如:有些中间件服务器可以选择把程序安装到数据库中来完成程序代码在集群间的分发。

(2)类 和 数组加载过程的区别?

数组也有类型,称为“数组类型”。如:

String[] str = new String[10];

这个数组的数组类型是Ljava.lang.String,而String只是这个数组中元素的类型。

当程序在运行过程中遇到new关键字创建一个数组时,由JVM直接创建数组类,再由类加载器创建数组中的元素类。

而普通类的加载由类加载器完成。既可以使用系统提供的引导类加载器,也可以使用用户自定义的类加载器。

a.非数组类加载阶段可以使用系统提供的引导类加载器,也可以由用户自定义的类加载器完成,开发人员可以通过定义自己的类加载器控制字节流的获取方式(如重写一个类加载器的 loadClass() 方法)

b.数组类本身不通过类加载器创建,它是由 Java 虚拟机直接创建的,再由类加载器创建数组中的元素类。

(3)加载过程的注意点

a.JVM规范并未给出类在方法区中存放的数据结构

类完成加载后,二进制字节流就以特定的数据结构存储在方法区中,但存储的数据结构是由虚拟机自己定义的,JVM规范并没有指定。

b.JVM规范并没有指定Class对象存放的位置

在二进制字节流以特定格式存储在方法区后,JVM会创建一个java.lang.Class类型的对象,作为本类的外部接口。既然是对象就应该存放在堆内存中,不过JVM规范并没有给出限制,不同的虚拟机根据自己的需求存放这个对象。HotSpot将Class对象存放在方法区。

c.加载阶段和连接阶段是交叉的

通过之前的介绍可知,类加载过程中每个步骤的开始顺序都有严格限制,但每个步骤的结束顺序没有限制。也就是说,类加载过程中,必须按照如下顺序开始:

加载、连接、初始化,但结束顺序无所谓,因此由于每个步骤处理时间的长短不一就会导致有些步骤会出现交叉。

虚拟机规范未规定 Class 对象的存储位置,对于 HotSpot 虚拟机而言,Class 对象比较特殊,它虽然是对象,但存放在方法区中。

加载阶段与连接阶段的部分内容交叉进行,加载阶段尚未完成,连接阶段可能已经开始了。但这两个阶段的开始实践仍然保持着固定的先后顺序。

2.2 验证

验证阶段比较耗时,它非常重要但不一定必要,如果所运行的代码已经被反复使用和验证过,那么可以使用-Xverify:none参数关闭,以缩短类加载时间。

2.2.1 验证的目的

验证是为了保证二进制字节流中的信息符合虚拟机规范,并没有安全问题。验证阶段确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

为什么需要验证?

虽然Java语言是一门安全的语言,它能确保程序猿无法访问数组边界以外的内存、避免让一个对象转换成任意类型、避免跳转到不存在的代码行,如果出现这些情况,编译无法通过。也就是说,Java语言的安全性是通过编译器来保证的。

但是我们知道,编译器和虚拟机是两个独立的东西,虚拟机只认二进制字节流,它不会管所获得的二进制字节流是哪来的,当然,如果是编译器给它的,那么就相对安全,但如果是从其它途径获得的,那么无法确保该二进制字节流是安全的。通过上文可知,虚拟机规范中没有限制二进制字节流的来源,那么任意来源的二进制字节流虚拟机都能接受,为了防止字节流中有安全问题,因此需要验证!

2.2.2 验证的过程

(1)文件格式验证

这个阶段主要验证输入的二进制字节流是否符合class文件结构的规范。二进制字节流只有通过了本阶段的验证,才会被允许存入到方法区中。

本验证阶段是基于二进制字节流的,而后面的三个验证阶段都是在方法区中进行,并基于类特定的数据结构的。

通过上文可知,加载开始前,二进制字节流还没进方法区,而加载完成后,二进制字节流已经存入方法区。而在文件格式验证前,二进制字节流尚未进入方法区,文件格式验证通过之后才进入方法区。也就是说,加载开始后,立即启动了文件格式验证,本阶段验证通过后,二进制字节流被转换成特定数据结构存储至方法区中,继而开始下阶段的验证和创建Class对象等操作。这个过程印证了:加载和验证是交叉进行的。

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

a.是否以魔数 0XCAFEBABE 开头

b.主次版本号是否在当前虚拟机处理范围内

c.常量池是否有不被支持的常量类型

d.指向常量的索引值是否指向了不存在的常量

e.CONSTANT_Utf8_info 型的常量是否有不符合 UTF8 编码的数据

f.......

(2)元数据验证

本阶段对方法区中的字节码描述信息进行语义分析,确保其符合Java语法规范。

(3)字节码验证

本阶段是验证过程的最复杂的一个阶段。对方法体进行语义分析,保证方法在运行时不会出现危害虚拟机的事件。

(4)符号引用验证,本阶段验证发生在解析阶段,确保解析能正常执行。

2.3 准备

准备阶段是正式为类变量(或称“静态成员变量”)分配内存并设置初始值的阶段。这些变量(不包括实例变量)所使用的内存都在方法区中进行分配。

准备阶段完成两件事情:

(1)为已经在方法区中的类中的静态成员变量分配内存

类的静态成员变量也存储在方法区中。

(2)为静态成员变量设置初始值

初始值为0、false、null等。

示例1:

public static String name = “听风行”;

在准备阶段,JVM会在方法区中为name分配内存空间,并赋上初始值null。

给name赋上”柴毛毛”是在初始化阶段完成的。

示例2:

public static final String name = “听风行”;

被final修饰的常量如果有初始值,那么在编译阶段就会将初始值存入constantValue属性中,在准备阶段就将constantValue的值赋给该字段。

2.4 解析

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

2.5 初始化

类初始化阶段是类加载过程的最后一步,是执行类构造器() 的过程。

() 方法由编译器自动产生,收集类中static{}代码块中的类变量赋值语句和类中静态成员变量的赋值语句。在准备阶段,类中静态成员变量已经完成了默认初始化,而在初始化阶段,clinit()方法对静态成员变量进行显示初始化。

() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static {} 块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。

初始化过程的注意点:

clinit()方法中静态成员变量的赋值顺序是根据Java代码中成员变量的出现的顺序决定的。

静态代码块能访问出现在静态代码块之前的静态成员变量,无法访问出现在静态代码块之后的成员变量。

静态代码块能给出现在静态代码块之后的静态成员变量赋值。

构造函数init()需要显示调用父类构造函数,而类的构造函数clinit()不需要调用父类的类构造函数,因为虚拟机会确保子类的clinit()方法执行前已经执行了父类的clinit()方法。

如果一个类/接口中没有静态代码块,也没有静态成员变量的赋值操作,那么编译器就不会生成clinit()方法。

接口也需要通过clinit()方法为接口中定义的静态成员变量显示初始化。

接口中不能使用静态代码块。

接口在执行clinit()方法前,虚拟机不会确保其父接口的clinit()方法被执行,只有当父接口中的静态成员变量被使用到时才会执行父接口的clinit()方法。

虚拟机会给clinit()方法加锁,因此当多条线程同时执行某一个类的clinit()方法时,只有一个方法会被执行,其它的方法都被阻塞。并且,只要有一个clinit()方法执行完,其它的clinit()方法就不会再被执行。因此,在同一个类加载器下,同一个类只会被初始化一次。

静态语句块中只能访问定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但不能访问。如下方代码所示:
public class Test {
static {
i = 0; // 给变量赋值可以正常编译通过
System.out.println(i); // 这句编译器会提示“非法向前引用”
}
static int i = 1;
}

() 方法不需要显式调用父类构造器,虚拟机会保证在子类的 () 方法执行之前,父类的 () 方法已经执行完毕。

由于父类的 () 方法先执行,意味着父类中定义的静态语句块要优先于子类的变量赋值操作。如下方代码所示:

static class Parent {
    public static int A = 1;
    static {
        A = 2;
    }
}

static class Sub extends Parent {
    public static int B = A;
}

public static void main(String[] args) {
    System.out.println(Sub.B); // 输出 2
}

() 方法不是必需的,如果一个类没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成 () 方法。

接口中不能使用静态代码块,但接口也需要通过 () 方法为接口中定义的静态成员变量显式初始化。但接口与类不同,接口的 () 方法不需要先执行父类的 () 方法,只有当父接口中定义的变量使用时,父接口才会初始化。

虚拟机会保证一个类的 () 方法在多线程环境中被正确加锁、同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 () 方法。

3 类加载器

3.1 类与类加载器

类加载器的作用:将class文件加载进JVM的方法区,并在方法区中创建一个java.lang.Class对象作为外界访问这个类的接口。

类与类加载器的关系:比较两个类是否相等,只有当这两个类由同一个加载器加载才有意义;否则,即使同一个class文件被不同的类加载器加载,那这两个类必定不同,即通过类的Class对象的equals执行的结果必为false。

任意一个类,都由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间。

因此,比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那么这两个类就必定不相等。

这里的“相等”,包括代表类的 Class 对象的 equals() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况。

3.2 类加载器种类

JVM提供如下三种类加载器:

(1)启动类加载器(Bootstrap ClassLoader): 负责加载Java_Home\lib中的class文件。

    负责将存放在 <JAVA_HOME>\lib 目录中的,并且能被虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。

(2)扩展类加载器(Extension ClassLoader): 负责加载Java_Home\lib\ext目录下的class文件。

    负责加载 <JAVA_HOME>\lib\ext 目录中的所有类库,开发者可以直接使用扩展类加载器。

(3)应用程序类加载器(Application ClassLoader): 负责加载用户classpath下的class文件。

    由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也称它为“系统类加载器”。它负责加载用户类路径(classpath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

JVM_10.png

当然,如果有必要,还可以加入自己定义的类加载器。

3.3 双亲委派模型

3.3.1 双亲委派模型的定义

双亲委派模型是描述类加载器之间的层次关系。它要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。(父子关系一般不会以继承的关系实现,而是以组合关系来复用父加载器的代码)

3.3.2 工作过程

如果一个类加载器收到了加载类的请求,它首先将请求交由父类加载器加载;若父类加载器加载失败,当前类加载器才会自己加载类。

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

在 java.lang.ClassLoader 中的 loadClass() 方法中实现该过程。

3.3.3 作用

像java.lang.Object这些存放在rt.jar中的类,无论使用哪个类加载器加载,最终都会委派给最顶端的启动类加载器加载,从而使得不同加载器加载的Object类都是同一个。

相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并放在 classpath 下,那么系统将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证。

3.3.4 原理

双亲委派模型的代码在java.lang.ClassLoader类中的loadClass函数中实现,其逻辑如下:

首先检查类是否被加载;

若未加载,则调用父类加载器的loadClass方法;

若该方法抛出ClassNotFoundException异常,则表示父类加载器无法加载,则当前类加载器调用findClass加载类;

若父类加载器可以加载,则直接返回Class对象;

评论加载中