是时候了解一波虚拟机的类加载机制

栏目: 服务器 · 发布时间: 6年前

内容简介:编程语言发展的大步发展——Java类会被编译为Class文件,这里,编译的过程先不去具体了解,Class文件中存储的各种信息,包括魔数、Class文件的版本、常量池、访问标志、字段表集合等等重要信息,都需要被加载到JVM中之后才能运行和使用。虚拟机会将Class文件中的描述类的数据加载到内存中,然后对数据进行校验、转化解析和初始化,最终得到虚拟机能够直接使用的Java类型。以上粗略的概括了一下虚拟机的类加载机制。

编程语言发展的大步发展—— 代码编译的结果,从本地机器码变为字节码

Java 类到JVM执行Class文件

Java类会被编译为Class文件,这里,编译的过程先不去具体了解,Class文件中存储的各种信息,包括魔数、Class文件的版本、常量池、访问标志、字段表集合等等重要信息,都需要被加载到JVM中之后才能运行和使用。

虚拟机会将Class文件中的描述类的数据加载到内存中,然后对数据进行校验、转化解析和初始化,最终得到虚拟机能够直接使用的Java类型。以上粗略的概括了一下虚拟机的类加载机制。

Java运行期间类加载的特性

由于Java语言规定,类型的加载、连接和初始化过程都是在程序初始化期间完成的,这种策略会让Java在类加载时稍微增加了一些性能开销,但是,这样却利于Java在应用程序方面提供了高度的灵活性。另一方面,Java里天生可以动态扩展的语言特性就是基于运行期间动态加载和动态连接的这个特点实现的。例如最基础的Applet、JSP和OSGi。

类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存位置,它的整个生命周期包括:

  • 加载
  • ==验证==
  • ==准备==
  • ==解析==
  • 初始化
  • 使用
  • 卸载

其中,黄色部分是连接过程。

加载阶段

那虚拟机什么时候开始类加载的第一个阶段:加载?

答:Java虚拟机规范中并没有强制约束什么时候进行加载,这点可以交由虚拟机的具体实现来把握。这是因为虚拟机设计团队在 加载阶段 搭建了一个相当开放的平台,就是因为这样一个相当开放的平台,虚拟机才不知道类加载的第一阶段:加载 什么时候发生,以及加载了什么、如何加载。 许多举足轻重的Java技术就是建立在这一基础之上的 。 这一开放的平台,衍生出了以下这些重要技术:

  • 从ZIP包中读取,这技术就是JAR、EAR、WAR格式的基础
  • 从网络中获取,典型应用就是Applet
  • 运行时计算生成,这种场景使用最多的就是动态代理技术,在java.lang.reflect.Proxy中,就定义了许多代理 工具
  • 由其他文件生成,典型的是JSP应用,JSP会被编译为Class文件

虽然不知道类加载的第一阶段:加载 什么时候开始,但是下面还是详细了解一下第一阶段的具体内容。在加载阶段,虚拟机会完成以下3件事情:

  1. 通过一个类的全限定类名来获取定义此类的二进制字节流,也就是Class文件,这一过程是通过类加载器来完成的。
  2. 将这个字节流文件所代表的的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,这个对象没有明确实在堆内存中的,对于HotSpot而言,这个对象是放在方法区中的,作为方法区这个类的各种数据的访问入口。

对于类的加载,有两种类型:

  1. 非数组类的加载 对于非数组类的加载,准确来讲就是在加载阶段中获取二进制流的动作,这个动作是最易控制的,这是因为在加载阶段既可以使用系统提供的引导加载器来完成加载工作,也可以实现自定义的加载器来完成,自定义的加载器只需要重写一个类加载器的loadClass()方法。

  2. 数组类的加载 数组类的加载是不好控制的,因为数组类本身不是通过类加载器来创建的,它是由Java虚拟机本身直接创建的。具体的内容,这里就不详解了。

下面,有三点需要特别注意的地方:

  1. 加载阶段完成之后,虚拟机外部的二进制流会被JVM读取,并且按照JVM所需要的格式在方法区中存储,然后转化为方法区的运行时数据结构,方法区中的存储格式,是由虚拟机自定义实现的。
  2. 二进制流在方法区中存储为运行时数据结构以后,需要有一个外部接口方便程序访问,这时内存中会实例化一个java.lang.Class类的对象,特别要注意的是,这个类的对象,并没有明确的规定是存储再堆中的,而对于HotSpot而言,这个对象是存储再方法区里面的,这时为了方便程序访问方法区中的类型数据。作用就是作为这些类型数据的 外部接口
  3. 注意加载阶段和连接阶段,是交叉进行的,而不是依次进行的。
  4. 除了加载阶段用户可以通过自定义类加载器来参与类加载过程,其他的类加载节点都是有虚拟机主导和控制的,这点需要注意。

连接阶段:验证

验证是连接阶段的第一步,目的是确保Class字节流文件中的数据内容符合虚拟机的规范,不会危害到虚拟机自身的安全。如果虚拟机不对输入的字节流Class文件作检查,对其完全信任的话,虚拟机很可能会因为加载了有害的字节流文件二导致系统崩溃。所以,验证对于虚拟机来说是一项很重要的工作。

从整体来看,验证阶段分为4个阶段的验证工作:

  1. 文件格式验证
  2. 元数据验证
  3. 字节码验证
  4. 符号引用验证

有效的文件格式是啥?

对于JVM来说,任何一个拥有唯一一个类或接口的定义信息的Class文件,就是有效的文件格式,这种有效的文件格式又称作为"Class文件格式"。Class类文件格式包括魔数、Class文件的版本号、常量池、访问标志、字段表集合等等。也就是说,这一阶段可能包含下面这些验证点:

  • 是否以魔数开头
  • 主、次版本号是否在虚拟机版本范围之内
  • 常量池中的常量是否都合法
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据 ....

所以验证阶段的第一阶段,就是要验证输入的字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。对文件格式验证并且验证成功后,字节流文件就会进入内存的方法区中进行存储。 总结一下,这一验证阶段几个重要的点:

  1. 该验证阶段是基于二进制字节流进行的。
  2. 通过该验证阶段的二进制字节流文件,会进入内存的方法区中进行存储。
  3. 除此阶段之外的其他三个阶段,都是基于方法区的数据进行的,不会再直接操作字节流。

元数据验证

这一阶段就是对字节码描述的信息进行语义分析,确保这些描述信息符合Java的语言规范。验证点可能包括如下:

  • 这个类是否有父接口(除了java.lang.Object类之外,所有的类都有父类)
  • 这个父类是否继承了不可继承的类(被final修饰的类) ...

字节码验证

这一阶段是最复杂的,是因为这一阶段主要验证程序语义是否合法的、符合逻辑的。这一阶段是基于"元数据验证"的,待元数据都合法之后,字节码验证就对方法体内的程序语义进行验证,保证方法体内的程序不会危害到虚拟机的自身安全。常见的验证点可能包括如下:

  • 在操作数栈中放置了一个int型的数据,但是使用时却把一个long型的数据加载至本地变量表中
  • 保证跳转指令不跳转到方法体外的字节码指令上

符号引用验证

什么是符号引用? 符号引用是一一组符号来描述需要引用的目标,虽然不知道引用目标的地址,但是可以使用任何形式的字面量来代表该目标。比如全限定类名java.lang.Object就代表的是这个对象的符号引用。这一验证阶段是发生在在虚拟机将 符号引用 转化为 直接引用 的时候,而这个转化的过程又是发生在连接的第三个阶段——解析阶段中发生。这一阶段可能包含以下的验证点:

  • 符号引用中代表的全限定类名是否能找到对应的类
  • 在指定类中是否存在符合方法的字段描述符以及描述方法名称和字段的字面量
  • 符号引用中的类、方法、字段的访问性(private、protected、public、default)是否被当前类访问。

验证阶段总结:这部分虽然对于虚拟机加载非常关键,但却并不是必须的,因为对于已被反复使用以及验证过的类,可以不进行验证就可安全的使用。可以使用-Xverify:none参数来关闭大部分的验证操作。

连接阶段:准备

准备阶段是正式为 类变量 分配内存空间并设置类变量初始值,这些变量都是在方法区中进行内存分配的。就在这内存分配的操作里面,有很多小细节需要注意:

  1. 这里的操作的是 类变量 (被static修饰的变量),而不是实例变量,实例变量会在对象被实例化的时候被一起分配到Java堆中。
  2. 这里的 类变量初始值 是通常情况下的零值,而不是代码上所附的值。如:public static int value = 123,在准备阶段,value的类初始值为0,而不是123。只有当程序编译、初始化类过后,存放在()方法中的putstatic指令才会执行将123赋值给value的动作,赋值完过后,value的值才为123。 这里提一个小问题,对于类的成员变量,局部变量,赋值动作都是什么时候发生的呢?
  3. 对于类字段属性表中存在的ConstantValue属性,在准备阶段类变量会被初始化为ConstantValue属性所指的值。那么ConstantValue属性是什么呢?ConstantValue。这里简单介绍下时如何给ConstantValue属性指定值——使用 static final。这是因为static final修饰的字段在javac编译时,会生成ConstantValue属性,在类加载的准备阶段直接把ConstantValue的值赋给该字段。所以:public static final int value = 123,在准备阶段value的值就是123。

连接阶段:解析

对于解析阶段,有两个非常重要的概念: 符号引用直接引用

  • 符号引用

    符号引用是一组用来描述所引用对象的符号,这符号的字面量形式都已经明确的定义在了Java虚拟机规范的Class文件格式中,不符合虚拟机规范的字面量是无法作为字面量的。在Class文件格式中,它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodef_info等形式出现。另外,符号引用的实现与内存布局无关,引用的目标也不一定已经加载到了内存中。

  • 直接引用

    直接引用有三种类型,分别为"直接指向目标的指针"、"相对偏移量"、"目标的句柄(引用)"。直接引用于内存中的布局有关,如果存在目标的直接引用,那么引用的目标就已经存在内存中了。

介绍完符号引用和直接引用后,就可以解释一下解析阶段的作用了。 解析阶段就是虚拟机将常量池中的符号引用转化为直接引用的过程。 重要的事情说三遍,常量池的符号引用!常量池的符号引用!常量池的符号引用!

初始化

类初始化阶段是类加载过程的最后一步。到了这一阶段,虚拟机才真正开始执行类中定义的Java程序代码(字节码)。这里提一个问题,什么时候执行类的初始化呢?

对于类初始化阶段,虚拟机规范则严格规定了 有且只有 5种情况必须立即执行对类进行"初始化"。

  1. 遇到new、getstatic、putstatic或invokestatic这4条字节码指令的时候,如果没有进行类的初始化,则需要先触发其初始化。这4条字节码指令分别对应着Java代码的实例化对象的关键字——new,读取静态变量,设置静态变量(被final修饰、已在编译期把结果放在常量池的静态字段除外,也就是static final触发的ConstantValue)以及调用静态方法。
  2. 使用了java.lang.reflect包的方法对类进行了反射调用,如果该类没有进行过初始化,则会触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类还没有过初始化,则先触发其父类的初始化。
  4. 当虚拟机启动的时候,如果用户指定了一个包含main()方法的主类,虚拟机会先触发这个主类。
  5. 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄锁对应的类没有进行过初始化,则需要先触发其初始化。

这里,需要先引入一个概念,就是类构造器< clinit >()方法。该方法实际上是由编译期自动收集类中的所有类变量的 赋值动作 和静态语句块(static{})中的 语句 合并产生的,也就是说< clinit >()方法里就包含两部分内容:赋值动作、static代码块逻辑。

下面总结一下()方法的特点:

  • < clinit >()方法与类的构造函数(或者说实例构造器< init >()方法)不同,因为()方法不需要显示的调用父类构造器,虚拟机会保证在子类的< clinit >()方法执行之前,父类的< clinit >()方法已经执行完毕。因此在虚拟机中第一个被执行的< clinit >()方法一定是java.lang.Object的。
  • 父类和子类中类变量赋值和静态代码块执行顺序:父类类变量赋值 -> 父类静态代码块 -> 子类类变量赋值 -> 子类静态代码块。
  • < clinit >()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译期就可以不为这个类生成< clinit >()方法。
  • 在一个类的生命周期中,类构造器< clinit >()最多会被虚拟机调用一次,而实例构造器< init >()则会被虚拟机调用多次,只要 程序员 还在创建对象。
  • 虚拟机会保证一个类的< clinit >()方法在多线程环境中被正确的加锁、同步。如果一个线程在调用< clinit >()方法时,其他线程会被阻塞。所以说,如果< clinit >()方法中有很耗时的操作,那么会造成多个线程阻塞,虽然实际上这种阻塞往往很隐蔽。需要值得注意的是,如果一个线程已经执行完< clinit >()了,那么其他线程被唤醒之后就不会执行()方法了,因为< clinit >()方法只会执行一次。
  • 类初始化(< clinit >()方法)和实例初始化(< init >()方法)之间并没有严格的先后顺序,没有规定必须要执行完类初始化后才能执行实例初始化。实例初始化可以在类初始化完成之前完成。空口无凭

另外,顺便总结一下< clinit >()方法和< init >()方法的执行顺序。

类实例化的一般过程是:父类的类构造器< clinit >() -> 子类的类构造器< clinit >() -> 父类的成员变量和实例代码块 -> 父类的构造函数< init >() -> 子类的成员变量和实例代码块 -> 子类的构造函数< init >()

非一般情况下实例化的过程:类的成员变量和实例代码块 -> 类的构造函数< init >() -> 类的类构造器< clinit >()


以上所述就是小编给大家介绍的《是时候了解一波虚拟机的类加载机制》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

High Performance JavaScript

High Performance JavaScript

Nicholas C. Zakas / O'Reilly Media / 2010-4-2 / USD 34.99

If you're like most developers, you rely heavily on JavaScript to build interactive and quick-responding web applications. The problem is that all of those lines of JavaScript code can slow down your ......一起来看看 《High Performance JavaScript》 这本书的介绍吧!

图片转BASE64编码
图片转BASE64编码

在线图片转Base64编码工具

随机密码生成器
随机密码生成器

多种字符组合密码

html转js在线工具
html转js在线工具

html转js在线工具