JVM技术专题针对于Java类加载器系统研究指南 「入门篇」
Posted 浩宇の天尚
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM技术专题针对于Java类加载器系统研究指南 「入门篇」相关的知识,希望对你有一定的参考价值。
任何足够先进的科技,都与魔法无异
前提
1.针对于JVM(Java虚拟机)相关的类加载器子系统部分,主要是虚拟机赐予我们最贴近底层控制程序执行的部分,JVM采用的双亲委托的类加载机制,进行加载相关的Class文件,并且转换为相关的运行时操作对象。
2.Java 虚拟机的启动是通过引导类加载器 ( Bootstrap Class Loader ) 创建一个初始类 (Initial Class ) 来完成,这个类是由虚拟机的具体实现指定。(-Xbootclasspath)指定加载相关的路径。紧接着,Java虚拟机链接这个初始类,初始化并调用它的 public static void main(String[])方法。之后的整个执行过程都是由对此方法的调用开始。执行 main 方法中的 Java 虚拟机指令可能会导致 Java 虚拟机链接另外的一些类或接口,也可能会调用另外的方法。
3.可能在某种 Java 虚拟机的实现上,初始类会作为命令行参数被提供给虚拟机。当然,虚拟机实现也可以利用一个初始类让类加载器依次加载整个应用。初始类当然也可以选择组合上述的方式来工作。
主要针对于相关的功能服务的组件加载
类的加载过程
当Java 虚拟机将 Java 源码编译为字节码之后,虚拟机便可以将字节码读取进内存,从而进行解析、运行等整个过程,这个过程我们叫:Java 虚拟机的类加载机制。JVM 虚拟机执行 class 字节码的过程可以分为七个阶段:加载、验证、准备、解析、初始化、使用、卸载。其中加载、检验、准备、初始化和卸载这个五个阶段的顺序是固定的,而解析则未必。为了支持动态绑定,解析这个过程可以发生在初始化阶段之后。
加载
JAVA虚拟机规范并没有进行强制约束,交给虚拟机的具体实现自由把握,这个阶段通常也被称作“装载”,在加载阶段,虚拟机主要完成以下3件事情:
- 通过 “类全名” 来获取或者创建和初始化定义此类的二进制字节流(通过类加载器定位到相关的字节码Class文件,或者如果已经存在了就直接从ClassCache中去获取相关的Class字节码对象,从而获取相关方法区中的相关元数据信息)
- 将字节流所代表的静态存储结构转换为方法区的运行时数据结构(主要为字节码中的解析对应的Constant\\Method\\Field\\Attribute\\以及this_class和super_class数据信息引用等)。
- 在 java 堆中生成一个代表这个类的 java.lang.Class 对象,作为方法区这些数据的访问入口(所以我们能够通过调用类.getClass() )。
注意这里字节流不一定非得要从一个 Class 文件获取,这里既可以从 ZIP 包中读取(比如从 jar 包和 war 包中读取),也可以在运行时计算生成(动态代理),也可以由其它文件生成(比如将 JSP 文件转换成对应的 Class 类)。加载的信息存储在 JVM 的方法区。
对于数组类来说,它并没有对应的字节流,而是由 Java 虚拟机直接生成的。对于其它的类来说,Java 虚拟机则需要借助类加载器来完成查找字节流的过程。
如果上面那么多记不住: 请一定记住这句:** 加载阶段也就是查找获取类的二进制数据(磁盘或者网络)动作(如同反序列化一般变成Java对象),将类的数据(Class 的信息:类的定义或者结构)放入方法区 (内存)。
验证
验证的主要作用就是确保被加载的类的正确性。是连接阶段的第一步。说白了也就是我们加载好的 .class 文件不能对我们的虚拟机有危害。就如同接口请求的时候我们需要加参数校验,此时的.class文件就如同参数一般。他主要是完成四个阶段的验证:
- 文件格式验证:验证 .class 文件字节流是否符合 class 文件的格式的规范,并且能够被当前版本的虚拟机处理。这里面主要对魔数(Magic Number)(4Byte)、次版本号(2Byte)、主版本号(2Byte)、常量池(2Byte)等等的校验(魔数、此版本号、主版本号都是 .class 文件里面包含的数据信息,具体细节可参考class字节码的研究)。
- 元数据验证:主要是对字节码描述的信息进行语义分析,以保证其描述的信息符合 java 语言规范的要求,比如说验证这个类是不是有父类,类中的字段方法是不是和父类冲突等等。
- 字节码验证:主要是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在元数据验证阶段对数据类型做出验证后,这个阶段主要对类的方法做出分析,保证类的方法在运行时不会做出攻击虚拟机安全的事。
- 符号引用验证:它是验证的最后一个阶段,发生在虚拟机将符号引用转化为直接引用的时候。主要是对类自身以外的信息进行校验。目的是确保解析动作能够完成。
- 对整个类加载机制而言,验证阶段是一个很重要但是非必需的阶段,如果我们的代码能够确保没有问题,那么我们就没有必要去验证,毕竟验证需要花费一定的的时间。当然我们可以使用 -Xverfity:none 来关闭大部分的验证。
准备
当完成字节码文件的校验之后,JVM 便会开始为类变量分配内存并初始化。这里需要注意两个关键点,即内存分配的对象以及初始化的类型。
内存分配的对象。Java 中的变量有「类变量」和「类成员变量」两种类型,「类变量」指的是被 static 修饰的变量,而其他所有类型的变量都属于「类成员变量」。
在准备阶段,JVM 只会为「类变量」分配内存,而不会为「类成员变量」分配内存。「类成员变量」的内存分配需要等到初始化阶段才开始。
例如下面的代码在准备阶段,只会为 factor 属性分配内存,而不会为 website 属性分配内存。
public static int factor = 3;
public String website = "www.cnblogs.com/chanshuyi";
初始化的类型。在准备阶段,JVM 会为类变量分配内存,并为其初始化。但是这里的初始化指的是为变量赋予 Java 语言中该数据类型的零值,而不是用户代码里初始化的值。
例如下面的代码在准备阶段之后,sector 的值将是 0,而不是 3。
public static int sector = 3;
但如果一个变量是常量(被 static final 修饰)的话,那么在准备阶段,属性便会被赋予用户希望的值。
例如:下面的代码在准备阶段之后,number 的值将是 3,而不是 0。
public static final int number = 3;
public final int number = 1;
final 关键字在 Java 中代表不可改变的意思,意思就是说 number 的值一旦赋值就不会在改变了。既然一旦赋值就不会再改变,那么就必须一开始就给其赋予用户想要的值,因此被 final 修饰的类变量在准备阶段就会被赋予想要的值,此时即使没有static修饰也是一样的final类型只能允许赋值一次。
而没有被final修饰的类变量,其可能在初始化阶段或者运行阶段发生变化,所以就没有必要在准备阶段对它赋予用户想要的值。
解析
解析阶段是虚拟机常量池内的符号引用替换为直接引用的过程。
符号引用:符号引用是一组符号来描述所引用的目标对象,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标对象并不一定已经加载到内存中。Java 虚拟机明确在 Class 文件格式中定义的符号引用的字面量形式。
直接引用:直接引用可以是直接指向目标对象的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机内存布局实现相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定已经在内存中存在。
在解析的阶段,解析动作主要针对7类符号引用进行,它们的名称以及对于常量池中的常量类型和解析报错信息如下:
-
| | 类或接口 | CONSTANTClassInfo | java.land.IllegalAccessError
-
| | 字段 | CONSTANTFieldrefInfo | java.land.IllegalAccessError 或 java.land.NoSuchFieldError
-
| | 类方法 | CONSTANTMethodefInfo | java.land.IllegalAccessError 或 java.land.NoSuchMethodError
-
| | 接口方法 | CONSTANTInterfaceMethoderInfo | java.land.IllegalAccessError 或 java.land.NoSuchMethodError
-
| | 方法类型 | CONSTANTMethodTypeInfo |
-
| | 方法句柄 | CONSTANTMethodhandlerInfo |
-
| | 调用限定符 | CONSTANTInvokeDynamicInfo |
解析的整个阶段在虚拟机中还是比较复杂的,远比上面介绍的复杂的多,但是很多特别细节的东西我们可以暂时先忽略,先有个大概的认识和了解之后有时间在慢慢深入了。
初始化
类初始阶段是类加载过程的最后一步,在上面提到的类加载过程中,除了加载阶段用户应用程序可以通过自定义类加载器参与之外,其余的动作全部由虚拟机主导和控制。
初始化阶段,是真正开始执行类中定义的 Java 程序代码(或者说是字节码)。
准备阶段,变量已经赋值过一次系统要求的初始值(零值),而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源。(从另一个角度表达:初始化阶段是执行类构造器 () 方法的过程。)
一般来说当 JVM 遇到下面 5 种情况的时候会触发初始化:
new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。
生成这4条指令的最常见的 Java 代码场景是:使用new 关键字实例化对象的时候、读取或设置一个类的静态字段(被 final 修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用本类的静态方法的时候。
使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
当使用 JDK1.7 动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getstatic,REF_putstatic,REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。
注意这里的初始化,并不是说创造的类的实例,而是执行了类构造器(类构造器并不是我们实例的构造方法),简单来说就是只对静态变量,静态代码块进行初始化。对于构造函数只有在创建实例的时候才会执行。
cinit方法先去执行类构造器,执行结束后在执行实例构造器
类加载器
把类加载阶段的 “通过一个类的全限定名来获取描述此类的二进制字节流” 这个动作交给虚拟机之外的类加载器来完成。这样的好处在于,我们可以自行实现类加载器来加载其他格式的类,只要是二进制字节流就行,这就大大增强了加载器灵活性。
系统自带的类加载器分为三种:
启动类加载器。其它的类加载器都是 java.lang.ClassLoader 的子类,启动类加载器是由 C++ 实现的,没有对应的 Java 对象,因此在 Java 中只能用 null 代替。启动类加载器加载最为基础,最为重要的类,如 JRE 的 lib 目录下 jar 包中的类;可以采用指定-Xbootclasspath:指定相关的路径。
扩展类加载器的父类是启动类加载器,它负责加载相对次要,但又通用的类,如 JRE 的 lib/ext 目录下jar包中的类
扩展类加载器。Java核心类库提供,负责加载java的扩展库(加载 JAVA_HOME/jre/ext/*.jar 中的类),开发者可以直接使用扩展类加载器。
应用程序类加载器。Java核心类库提供。应用类加载器的父类加载器则是扩展类加载器,它负责加载应用程序路径下的类。开发者可以直接使用这个类加载器,若应用程序中没有定义过自己的类加载器,java 应用的类都是由它来完成加载的。
自定义类加载器
新建一个类继承自 java.lang.ClassLoader 重写它的 findClass 方法。将class 字节码数组转换为 Class 类的实例。调用 loadClass 方法加载即可。
从上面几个例子可以看出,分析一个类的执行顺序大概可以按照如下步骤:
- 确定类变量的初始值。在类加载的准备阶段,JVM 会为类变量初始化零值,这时候类变量会有一个初始的零值。如果是被 final 修饰的类变量,则直接会被初始成用户想要的值。
- 初始化入口方法。当进入类加载的初始化阶段后,JVM 会寻找整个 main 方法入口,从而初始化 main 方法所在的整个类。当需要对一个类进行初始化时,会首先初始化类构造器(),之后初始化对象构造器()。
- 初始化类构造器(clinit)。JVM 会按顺序收集类变量的赋值语句、静态代码块,最终组成类构造器由 JVM 执行。
- 初始化对象构造器(init)。JVM 会按照收集成员变量的赋值语句、普通代码块,最后收集构造方法,将它们组成对象构造器,最终由 JVM 执行。
- 如果在初始化 main 方法所在类的时候遇到了其他类的初始化,那么就先加载对应的类,加载完成之后返回。如此反复循环,最终返回 main 方法所在类。
以上是关于JVM技术专题针对于Java类加载器系统研究指南 「入门篇」的主要内容,如果未能解决你的问题,请参考以下文章
JVM技术专题针对于Class字节码的文件分析和研究指南 「 入门篇」
JVM技术专题针对于Class字节码的文件分析和研究指南 「 进阶篇」
JVM技术专题针对于ASM库生成和修改class文件开发指南 「 入门篇」