深入学习虚拟机类加载过程

JVM的类加载机制是指虚拟机

把描述类的数据从class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型的实现过程。

类加载过程具体可以分成下面几个步骤:

(1)装载:查找和导入Class文件;

(2)链接:把类的二进制数据合并到JRE中;
 校验:检查载入Class文件数据的正确性;
 准备:给类的静态变量分配存储空间,赋默认值;
 解析:将符号引用转成直接引用;

(3)初始化:对类的静态变量,静态代码块执行初始化操作。

 加载 Loading过程

加载是类加载过程的第一个阶段,
在加载阶段,虚拟机需要完成以下工作:
通过一个类的全限定名来获取其定义的二进制字节流;
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为对方法区中这些数据的访问入口。

注意,这里的二进制字节流并不只是单纯地从 Class 文件中获取,比如它还可以从 Jar 包中获取、从网络中获取、由其他文件生成(JSP 应用)等。
相对于类加载的其他阶段而言,加载阶段是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。
加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在 Java 堆中也创建一个 java.lang.Class 类的对象,这样便可以通过该对象访问方法区中的这些数据。

(1)ClassLoader 类加载器

类加载器用于实现类的加载动作,但它在 Java 程序中起到的作用却不限于类的加载阶段。

对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在就 Java 虚拟机中的唯一性,也就是说,即使两个类来源于同一个 Class 文件,只要加载它们的类加载器不同,那这两个类就必定不相等。

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

Java 开发人员的角度来看,类加载器可以大致划分为以下三类:

启动类加载器,Bootstrap ClassLoader:
负责加载存放在JDKjreli(JDK 代表 JDK 的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如 rt.jar,所有的java.*开头的类均被 Bootstrap ClassLoader 加载)。

启动类加载器是无法被 Java 程序直接引用的。
扩展类加载器,Extension ClassLoader:
该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载JDKjrelibext目录中,或者由 java.ext.dirs 系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。
应用程序类加载器,Application ClassLoader:
该类加载器由 sun.misc.Launcher$AppClassLoader 来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,
如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。

因为 JVM 自带的 ClassLoader 只是懂得从本地文件系统加载标准的 java class 文件,因此如果编写了自己的 ClassLoader,便可以做到如下几点:
在执行非置信代码之前,自动验证数字签名。
动态地创建符合用户特定需要的定制化构建类。
从特定的场所取得 java class,例如数据库中和网络中。

(2)双亲委派模型

这种层次关系称为类加载器的双亲委派模型。
双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,
因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。

使用双亲委派模型来组织类加载器之间的关系,主要是虚拟机出于稳定和安全性的考虑。
例如,类java.lang.Object 类存放在JDKjrelib下的 rt.jar 之中,因此无论是哪个类加载器要加载此类,最终都会委派给BootstrapClassLoader进行加载,这边保证了 Object 类在程序中的各种类加载器中都是同一个类。
试想如果一个人写了一个恶意的基础类(如java.lang.String)并加载到JVM将会引起严重的后果,但有了全盘负责制,java.lang.String永远是由根装载器来装载,避免以上情况发生。

类装载器有载入类的需求时,会先请示其Parent使用其搜索路径帮忙载入,如果Parent 找不到,那么才由自己依照自己的搜索路径搜索类,

下面举一个例子来说明:

Public class Test{

Public static void main(String[] arg){

ClassLoader c = Test.class.getClassLoader(); //获取Test类的类加载器

System.out.println(c);

ClassLoader c1 = c.getParent(); //获取c这个类加载器的父类加载器

System.out.println(c1);

ClassLoader c2 = c1.getParent();//获取c1这个类加载器的父类加载器

System.out.println(c2);

}

}

  

运行结果:

...AppClassLoader...  

...ExtClassLoader...  

Null   

可以看出Test是由AppClassLoader加载器加载的。

AppClassLoader的Parent 加载器是 ExtClassLoader 但是ExtClassLoader的Parent为 null ,需要注意的是,

Bootstrap Loader是用C++语言写的,逻辑上并不存在Bootstrap Loader的类实体,所以在java程序代码里试图打印出其内容时,我们就会看到输出为null。

 链接过程

这个过程可以继续分为三个阶段:

(1)验证

验证的目的是为了确保 Class 文件中的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。
不同的虚拟机对类验证的实现可能会有所不同,但大致都会完成以下四个阶段的验证:文件格式的验证、元数据的验证、字节码验证和符号引用验证。
文件格式的验证:验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理,
该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内。经过该阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的三个验证都是基于方法区的存储结构进行的。
元数据验证:对类的元数据信息进行语义校验(其实就是对类中的各数据类型进行语法校验),保证不存在不符合 Java 语法规范的元数据信息。
字节码验证:该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。
符号引用验证:这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段中发生该转化,后面会有讲解),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。

(2)准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
这里所设置的初始值通常情况下是数据类型默认的零值(如 0、0L、null、false 等),而不是被在 Java 代码中被显式地赋予的值。
假设一个类变量的定义为:

public static int value = 3;

那么变量 value 在准备阶段过后的初始值为 0,而不是 3,因为这时候尚未开始执行任何 Java 方法,而把 value 赋值为 3 的 putstatic 指令是在程序编译后,存放于类构造器 ()方法之中的,所以把 value 赋值为 3 的动作将在初始化阶段才会执行。
下表列出了 Java 中所有基本数据类型以及 reference 类型的默认零值:


这里还需要注意如下几点:

对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
对于同时被 static 和 final 修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;

而只被 final 修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。
对于引用数据类型 reference 来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。
如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。

如果类字段的字段属性表中存在 ConstantValue 属性,即同时被 final 和 static 修饰,那么在准备阶段变量 value 就会被初始化为 ConstValue 属性所指定的值。

假设上面的类变量 value 被定义为:

public static final int value = 3;

编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 3。我们可以理解为 static final 常量在编译期就将其结果放入了调用它的类的常量池中。

(3)解析

解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程,这里要针对Class文件中的不同内容进行区别处理,

解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,

分别对应于常量池中的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info 四种常量类型。

1、类或接口的解析:判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。
2、字段解析:对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;

如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束。

3、类方法解析:对类方法的解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口。
4、接口方法解析:与类方法解析步骤类似,知识接口不会有父类,因此,只递归向上搜索父接口就行了。

 初始化

初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的 Java 程序代码。
在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源。

在Java中,对于类有且仅有四种情况会对类进行初始化。
(1)使用new关键字实例化对象的时候,读取或设置一个类的静态字段时候(除final修饰的static外),调用类的静态方法时候,都只会初始化该静态字段或者静态方法所定义的类。
(2)使用reflect包对类进行反射调用的时候,如果类没有进行初始化,则先要初始化该类。
(3)当初始化一个类的时候,如果其父类没有初始化过,则先要触发其父类初始化。
(4)虚拟机启动的时候,会初始化一个有main方法的主类。

注意:

通过子类引用父类静态字段,只会初始化父类不会初始化子类;

通过数组定义来引用类,也不会触发该类的初始化;

常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此也不会触发定义常量的类的初始化。

联想String.Intern()操作。

参考 极客学院·深入理解Java虚拟机

原文地址:https://www.cnblogs.com/binyue/p/4736558.html