概述
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文件。其中包括:
-
魔数
Class文件的标识,魔数不正确,则不会识别为class文件
-
版本号
分为主版本号和副版本号,版本号不同,也就意味着当时编译的jdk版本不同
-
常量池
它是 class文件的资源库。存储了所有class的类信息,字段信息,方法信息所引用的所有常量。常量池中保存了各种表,对应了 后面的各个部分(类信息,方法信息,父类/接口信息,字段信息等)
-
访问标志
代表着 类,方法,和字段前面的访问权限,如 private,public,default,protected,static,final,enum等等。
-
字段表,方法表
存放了所有的字段和方法
-
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对象进行验证。包含以下几个方面:
-
文件格式检验
文件格式通常也就是检查 class对象是否符合 class文件格式,并且能够被当前版本的JVM处理。这里提到了两个点,其实也就对应了 class文件的前面两个部分。
- 魔数 (class文件的魔数为固定的u4长度-2个字节的16进制数)
- 版本 (主版本+副版本,如果 编译使用的jdk版本,与 运行环境的jdk版本不兼容,此class文件也会视为无效)
-
元数据检验
对字节码描述的信息进行语义分析,确保它的内容符合java语言规范
-
字节码检验
通过数据流和控制流分析,确认语义时合法的 符合逻辑的
-
符号引用检验
是对类自身意外的信息进行匹配行校验,比如 常量池的各种符号引用
实例分析
下面的代码用来模拟验证阶段的一些异常情况:
-
魔数被篡改
这个文件的字节码如下(查看的方式为: 用javac命令生成class文件,再用 十六进制的编辑器打开class文件):
我们可以在编辑器中将 前面的cafe babe
修改为 cafe aaaa
,再用java命令来加载运行它。就会报错:
MagicValue就是魔数,这里告诉我们,魔数不正确。
我们把魔数部分还原,现在来修改 版本号:
上面class内容中的 0000 0034
为主版本和副版本 号,我们将它改为 0000 0035
, 再次java运行它。
就会报出,版本不支持的错误。
还原它,我们继续模拟 常量计数器部分的篡改。
再往后的,0036
为常量计数器的值。0036
为16进制数,它表示,常量池中有 54(十进制的)个常量。 如果后面的真实的常量数量和这个数字对不上,也会报错。比如我们把0036
稍微改成0032
(表示十进制的50),再运行则会报错:
这个报错是因为,常量池的容量值,和后面真实的常量数对不上。
但是这种验证,并不能百分百防止 class被篡改。
还是上面这段程序,它会打印出自身的hashCode
和父类的hashCode
,它的父类为 Object
。
下面的篡改方式,JVM的验证流程无法识别到。
通过javap -v Foo
命令,可以看到 Foo的常量池的具体信息:
其中,红色数字1,表示 Object的hashCode方法引用。 红色数字2,表示 Foo自己的hashCode方法引用。
而常量池中的方法表结构为:
仔细看常量池,#5 = Methodref #16.#32
表示的是,该方法所属的类为位置为#2的常量,也就是java.lang.Object。
我们可以在 class内容中,将这部分篡改掉,本来应该指向 Object,但是我让他指向 Foo。篡改之前,运行结果为:
csharp
superCode is 2018232323
thisCode is 111
篡改之后,再次运行,两次打印的结果相同:
说明 父类的hashCode方法引用被篡改了,但是 JVM并未发现。
Class文件安全措施
即使没有 java源代码,然而工程师仍然可以对class文件进行篡改,并且逃过JVM的验证过程。所以,真实的开发中,会利用加固,混淆,加解密等过程保证程序安全。
准备
准备为 链接阶段的第二步, 准备的主要目的是为了给类中的静态变量分配内存并赋予初始值(0)。
比如下面的代码,在这个步骤中就会给value分配一个int能占用的内存,并赋值为0.
java
public static int value = 100;
但是有一个例外,那就是静态常量:
java
public static final int value = 100;
它会分配一个int空间之后,直接赋值100.
Java中默认值如下:
- int,long,short,char,byte,float,double,boolean 都是0
- 引用类型赋值为null
解析
最后一个阶段,解析。这个环节中,将 常量池中的符号引用转化为直接引用。也就是具体的内存地址。包括:类,接口,字段,方法等。
比如上面的invokeVirtual
方法,指向了常量池中的#4
,也就是第四个位置的符号引用,而这个符号引用,则指向了 #2,#30
,也就是 Foo
类的print
方法。
形象的比喻: 微信好友列表中的每一个好友,就是一个个的符号引用,我们决定给谁发送消息,直接指定这个符号引用就可了。但是实际上发出的消息,微信后台会搜索到这个符号引用对应的真实的用户设备的ip地址,将信息发送给他
。 换算到 JVM中,也就是 JVM将 虚的符号引用转化为内存中 实际内存地址
。
三、初始化
初始化阶段,是执行类构造器 《init》方法的过程,并真正初始化类变量。 在链接的第二阶段(准备),只是给静态变量分配内存并赋予默认0值。
java
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修饰的变量,则只有在对象实例化的时候才会执行.
比如以下代码:
value会在此阶段执行赋值为1的操作。 静态代码块也会执行。 但是 非静态代码块只有在创建对象时才会执行。
主动引用与被动引用的区别
上面说的六种情况都是主动引用。这里有个特殊情况,不在主动引用范围内:
Child继承自Parent,如果直接使用 Child继承自Parent的value值,不会触发 Child的class初始化,因为没必要
,确实,也符合逻辑,并没有用到真正只属于Child的变量,不必初始化Child。Child中的静态代码也没有执行。一个class是否执行了初始化,可以通过它的静态代码块有没有被执行来判断
。 对于静态变量,只有直接定义在Child中,才会跟随Child的初始化而初始化。
面试题
一个类的静态代码块,非静态代码块,构造函数之间是什么样的执行顺序。
根据本文的内容可以很容易得出结论: 一个类Class对象作为 这个类的唯一入口,肯定是第一个执行的,也就是说,静态代码块一定是第一个执行,伴随执行的还有它的静态成员变量的初始化。 然后是 非静态代码块,它是在构造函数之前执行。 最后才是 构造函数,由于构造函数可以重载,只有确定了哪一个构造函数要被执行之后,再执行构造函数本身。
如果还考虑上面所说的继承关系,那么执行的顺序如下:
-
- 父类的静态变量和静态代码块
-
- 子类的静态变量和静态代码块
-
- 父类的非静态变量和非静态代码块
-
- 父类的构造函数
-
- 子类的非静态变量和非静态代码块
-
- 子类的构造函数
总结
Class对象的整个初始化的三个阶段:
- 装载 (查找到字节流,并生成Class对象)
- 链接(要验证class是否合法,并解析到JVM中使之能被JVM使用)
- 初始化 (对类中static修饰的变量进行赋值,并 执行static修饰的代码块)