likes
comments
collection
share

六、Class对象在JVM中的初始化过程

作者站长头像
站长
· 阅读数 28

概述

JVM将字节码加载到内存中,是通过ClassLoader实现的,ClassLoader遵循了双亲委派机制,ClassLoader的实现类之间存在父子关系,优先由父来进行加载,如果父找不到该class,再由自身的findClass去加载。

JVM加载字节码到内存中的具体过程,分为3个大步骤:

  • 装载
  • 链接
  • 初始化

一、装载

装载是指的,JVM查找 class文件,并生成字节流,然后根据字节流生成内存中Class对象的过程。具体过程分为以下3个步骤

1、查找到Class文件,并生成它的字节流

ClassLoader通过类的全限定名(包名+类名)找到唯一一个class文件,这个class文件来源为

  • 某个本地路径下的class文件

  • 可以是 jar 包,zip包这种 压缩文件类型

    • 这意味着 Classloader其实包含了 自动解压缩的过程
  • 还可以是来自于网络的字节流

    • 这意味着 可以通过网络动态下载字节流加载到ClassLoader来实现动态化。

2、解析Class字节流,并生成特定数据结构存在JVM方法区

回顾Class文件结构

Class文件由无符号数组成 基本元素,多个无符号数和表,以特定结构,组成完整的Class文件。其中包括:

  1. 魔数

    Class文件的标识,魔数不正确,则不会识别为class文件

  2. 版本号

    分为主版本号和副版本号,版本号不同,也就意味着当时编译的jdk版本不同

  3. 常量池

    它是 class文件的资源库。存储了所有class的类信息,字段信息,方法信息所引用的所有常量。常量池中保存了各种表,对应了 后面的各个部分(类信息,方法信息,父类/接口信息,字段信息等)

  4. 访问标志

    代表着 类,方法,和字段前面的访问权限,如 private,public,default,protected,static,final,enum等等。

  5. 字段表,方法表

    存放了所有的字段和方法

  6. code表

    是class文件最后的部分,代表了 所有的方法所转化的指令集。

类比

这个转化的过程相当于 app开发中的一个Gson反序列化json的过程,将一个json格式的字符串,转化为一个JavaBean对象。

这里的过程,则是将一个 byte[]类型的 class文件字节流数据,转化为 JVM内特定的class数据结构,并存储在方法区。

3. 在JVM中创建出一个java.lang.Class类型的对象

这个Class类型的对象,则是 提供给外界访问这个类的唯一入口。

举个例子:如果是一个Test类,最终转化为一个 Test的Class对象,它的父类是Class,自己的名字还是 Test。

装载时机

JVM装载 class,是按需装载的,用到谁,就装载谁。而这个用到的判定有以下两点:

  • new 对象时

    比如 new Test() ,

  • Class.forName()

    这通常就是在使用 反射的场景

二、链接

验证

字节流可以在网络上传输,被程序接收到内存中,然后被classLoader装载为 Class对象。这也就意味着 class文件可以被篡改。JVM为了保证程序安全,必须对 class对象进行验证。包含以下几个方面:

  1. 文件格式检验

    文件格式通常也就是检查 class对象是否符合 class文件格式,并且能够被当前版本的JVM处理。这里提到了两个点,其实也就对应了 class文件的前面两个部分。

    • 魔数 (class文件的魔数为固定的u4长度-2个字节的16进制数)
    • 版本 (主版本+副版本,如果 编译使用的jdk版本,与 运行环境的jdk版本不兼容,此class文件也会视为无效)
  2. 元数据检验

    对字节码描述的信息进行语义分析,确保它的内容符合java语言规范

  3. 字节码检验

    通过数据流和控制流分析,确认语义时合法的 符合逻辑的

  4. 符号引用检验

    是对类自身意外的信息进行匹配行校验,比如 常量池的各种符号引用

实例分析

下面的代码用来模拟验证阶段的一些异常情况:

六、Class对象在JVM中的初始化过程

  • 魔数被篡改

    这个文件的字节码如下(查看的方式为: 用javac命令生成class文件,再用 十六进制的编辑器打开class文件):

六、Class对象在JVM中的初始化过程

我们可以在编辑器中将 前面的cafe babe修改为 cafe aaaa ,再用java命令来加载运行它。就会报错: 六、Class对象在JVM中的初始化过程

MagicValue就是魔数,这里告诉我们,魔数不正确。

我们把魔数部分还原,现在来修改 版本号:

上面class内容中的 0000 0034为主版本和副版本 号,我们将它改为 0000 0035, 再次java运行它。

六、Class对象在JVM中的初始化过程

就会报出,版本不支持的错误。

还原它,我们继续模拟 常量计数器部分的篡改。

再往后的,0036为常量计数器的值。0036为16进制数,它表示,常量池中有 54(十进制的)个常量。 如果后面的真实的常量数量和这个数字对不上,也会报错。比如我们把0036稍微改成0032(表示十进制的50),再运行则会报错:

六、Class对象在JVM中的初始化过程

这个报错是因为,常量池的容量值,和后面真实的常量数对不上。

但是这种验证,并不能百分百防止 class被篡改。

还是上面这段程序,它会打印出自身的hashCode和父类的hashCode,它的父类为 Object

下面的篡改方式,JVM的验证流程无法识别到。

通过javap -v Foo命令,可以看到 Foo的常量池的具体信息:

六、Class对象在JVM中的初始化过程

其中,红色数字1,表示 Object的hashCode方法引用。 红色数字2,表示 Foo自己的hashCode方法引用。

而常量池中的方法表结构为:

六、Class对象在JVM中的初始化过程

仔细看常量池,#5 = Methodref #16.#32表示的是,该方法所属的类为位置为#2的常量,也就是java.lang.Object。

我们可以在 class内容中,将这部分篡改掉,本来应该指向 Object,但是我让他指向 Foo。篡改之前,运行结果为:

superCode is 2018232323
thisCode is 111

篡改之后,再次运行,两次打印的结果相同:

六、Class对象在JVM中的初始化过程

说明 父类的hashCode方法引用被篡改了,但是 JVM并未发现。

Class文件安全措施

即使没有 java源代码,然而工程师仍然可以对class文件进行篡改,并且逃过JVM的验证过程。所以,真实的开发中,会利用加固,混淆,加解密等过程保证程序安全。

准备

准备为 链接阶段的第二步, 准备的主要目的是为了给类中的静态变量分配内存并赋予初始值(0)。

比如下面的代码,在这个步骤中就会给value分配一个int能占用的内存,并赋值为0.

public static int value = 100;    

但是有一个例外,那就是静态常量:

public static final int value = 100;  

它会分配一个int空间之后,直接赋值100.

Java中默认值如下:

  • int,long,short,char,byte,float,double,boolean 都是0
  • 引用类型赋值为null

解析

最后一个阶段,解析。这个环节中,将 常量池中的符号引用转化为直接引用。也就是具体的内存地址。包括:类,接口,字段,方法等。

六、Class对象在JVM中的初始化过程

比如上面的invokeVirtual方法,指向了常量池中的#4 ,也就是第四个位置的符号引用,而这个符号引用,则指向了 #2,#30,也就是 Foo类的print方法。

形象的比喻: 微信好友列表中的每一个好友,就是一个个的符号引用,我们决定给谁发送消息,直接指定这个符号引用就可了。但是实际上发出的消息,微信后台会搜索到这个符号引用对应的真实的用户设备的ip地址,将信息发送给他。 换算到 JVM中,也就是 JVM将 虚的符号引用转化为内存中 实际内存地址

三、初始化

初始化阶段,是执行类构造器 《init》方法的过程,并真正初始化类变量。 在链接的第二阶段(准备),只是给静态变量分配内存并赋予默认0值。

public static int value = 100;    

初始化的时机

JVM中严格规定了class初始化的时机。

  • 1.JVM启动时,会初始化main方法及其主类(安卓中的ActivityThread里有一个main方法
  • 2.new创建对象时,如果这个对象的class还没有被初始化
  • 3.当用到静态变量或者方法时,如果这个class还没有被初始化
  • 4.子类初始化时,如果父类还没有被初始化,则父类会先初始化
  • 5.使用反射时(Class.forName),如果没有初始化
  • 6.第一次调用 java.lang.invoke.MethodHandle实例时,需要初始化Methodhandler指向方法所在类

初始化类变量的范围

初始化的执行仅限于以下范围:有static修饰的变量 或者代码块

而没static修饰的变量,则只有在对象实例化的时候才会执行.

比如以下代码: 六、Class对象在JVM中的初始化过程

value会在此阶段执行赋值为1的操作。 静态代码块也会执行。 但是 非静态代码块只有在创建对象时才会执行。

主动引用与被动引用的区别

上面说的六种情况都是主动引用。这里有个特殊情况,不在主动引用范围内:

六、Class对象在JVM中的初始化过程

六、Class对象在JVM中的初始化过程

Child继承自Parent,如果直接使用 Child继承自Parent的value值,不会触发 Child的class初始化,因为没必要,确实,也符合逻辑,并没有用到真正只属于Child的变量,不必初始化Child。Child中的静态代码也没有执行。一个class是否执行了初始化,可以通过它的静态代码块有没有被执行来判断。 对于静态变量,只有直接定义在Child中,才会跟随Child的初始化而初始化。

面试题

一个类的静态代码块,非静态代码块,构造函数之间是什么样的执行顺序。

根据本文的内容可以很容易得出结论: 一个类Class对象作为 这个类的唯一入口,肯定是第一个执行的,也就是说,静态代码块一定是第一个执行,伴随执行的还有它的静态成员变量的初始化。 然后是 非静态代码块,它是在构造函数之前执行。 最后才是 构造函数,由于构造函数可以重载,只有确定了哪一个构造函数要被执行之后,再执行构造函数本身。

如果还考虑上面所说的继承关系,那么执行的顺序如下:

    1. 父类的静态变量和静态代码块
    1. 子类的静态变量和静态代码块
    1. 父类的非静态变量和非静态代码块
    1. 父类的构造函数
    1. 子类的非静态变量和非静态代码块
    1. 子类的构造函数

总结

Class对象的整个初始化的三个阶段:

  • 装载 (查找到字节流,并生成Class对象)
  • 链接(要验证class是否合法,并解析到JVM中使之能被JVM使用)
  • 初始化 (对类中static修饰的变量进行赋值,并 执行static修饰的代码块)