<span>jvm字节码和类加载机制</span>
Class类文件的结构
任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(类和接口也可以用反射的方式通过类加载器直接生成)
Class文件时一组以8位字节为基础单位的二进制流,各个数据都严格按照顺序紧凑排列在Class文件中,没有任何分隔符。
Class文件格式采用一种类似C语言结构体的伪结构存储数据,这种结构中只包含无符号数和表两种类型。
无符号数
- 无符号数属于基本数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节、8个字节的无符号数
- 无符号数可以用来描述数字、引用、数量值或者按照utf编码的字符串值。
表
-
表是由多个无符号整数或者其他表构成的符合数据类型,都由"_info"结尾。
-
表用于描述有层次关系的复合结构的数据,整个Class文件实际上就是一张表。
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
编辑器用16进制打开类文件
0 1 2 3 4 5 6 7 8 9 A B C D E F
ca fe ba be 00 00 00 34 00 3f 0a 00 0a 00 2b 08
00 2c 09 00 0d 00 2d 06 40 59 00 00 00 00 00 00
09 00 0d 00 2e 09 00 2f 00 30 08 00 31 0a 00 32....
magic
类文件第一个数据为u4,我们查看16进制文件前4个字符是cafebabe
,它用来确定这个文件是否为一个能被虚拟机接受的Class文件。
minor_version、major_version
u4后的两个u2,即00 00 00 34用来代表jdk的主次版本。
常量池
常量池是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据,也是Class文件中第一个出现的表类型数据项目。
存放类型
存放类型包含:
- 字面量:文本字符串、声明为final的常量值等。
- 符号引用:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。不会保存它们最终的信息,因为这是无意义的,它们不经过运行期转换的话得不到真正的入口地址,也就无法被虚拟机使用。
类加载的时机
类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括以下 7 个阶段:加载、验证、准备、解析、初始化、使用、卸载
加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始(注意是“开始”,而不是“进行”或“完成”),而解析阶段则不一定:它在某些情况下可以在初始化后再开始,这是为了支持 Java 语言的运行时绑定(多态)。
对类进行初始化的情况
虚拟机规范严格规定了有且只有5钟情况必须立即对类进行初始化:
-
使用 new、getstatic、putstatic、或invokestatic这四条字节码命令时,后三个命令分别代表对类的静态变量进行操作,调用类的静态方法。生成这四条指令最常见的场景为:new一个对象的时候、读取或者赋值给类的静态变量的时候(被final修饰的除外,因为已经在编译期把结果放入了常量池)、以及调用一个静态方法的时候。
-
反射调用类的时候,如果类未被初始化需要进行初始化
-
当实例化某类时,其父类没被初始化,需要初始化父类
-
当虚拟机启动时,用户指定的执行的主类(包含main方法的类),虚拟机会先初始化这个主类
-
当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类还没初始化,则需要先触发其初始化。
这 5 种场景称为对一个类进行主动引用(有且只有这五种才可以触发类的初始化),除此之外,其它所有引用类的方式都不会触发初始化,称为被动引用。
被动引用反例
/**
* 被动引用 Demo1:
* 通过子类引用父类的静态字段,不会导致子类初始化。
*/
class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(SubClass.value);
// SuperClass init!
}
}
对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
/**
* 被动引用 Demo2:
* 通过数组定义来引用类,不会触发此类的初始化。
*/
public class NotInitialization {
public static void main(String[] args) {
SuperClass[] superClasses = new SuperClass[10];
}
}
new数组对象时并不会触发SuperClass
类的初始化,而是在这段代码里触发一个名为Lorg.fenixsoft.classloading.SuperClass
的类初始化,他直接继承自Object
类,由虚拟机来产生和触发。
/**
* 被动引用 Demo3:
* 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
*/
class ConstClass {
static {
System.out.println("ConstClass init!");
}
public static final String HELLO_ZHIYIN = "Hello ZhiYin";
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(ConstClass.HELLO_ZHIYIN);
}
}
JVM在编译期进行了传播优化,将ConstClass类中的常量放入了NotInitialization的常量池中,事实上这个常量已经和ConstClass类没有了联系,不会触发初始化。
类加载的过程
类加载过程包括 5 个阶段:加载、验证、准备、解析和初始化。
加载
“加载”是“类加载”过程的第一步,在加载阶段,虚拟机需要完成以下三件事情:
-
通过一个类的全限定名(com.zhiyin.TestClass)来获取定义此类的二进制字节流
-
将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
-
在内存中(HostSpot在方法区)生成一个代表该类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口
获取二进制字节流
对于 Class 文件,虚拟机没有指明要从哪里获取、怎样获取。除了直接从编译好的 .class 文件中读取,还有以下几种方式:
- 从 zip 包中读取,如 jar、war等
- 从网络中获取,如 Applet
- 通过动态代理技术生成代理类的二进制字节流
- 由 JSP 文件生成对应的 Class 类
- 从数据库中读取,如 有些中间件服务器可以选择把程序安装到数据库中来完成程序代码在集群间的分发。
“数组类”与“非数组类”加载情况的不同
- 非数组类由加载器来进行加载
- 数组类由于没有字节流,由jvm直接创建,如果数组中的对象是引用类,递归采用加载器进行加载
注意事项
- 虚拟机规范未规定 Class 对象的存储位置,对于 HotSpot 虚拟机而言,Class 对象比较特殊,它虽然是对象,但存放在方法区中。
- 加载阶段与连接阶段的部分内容交叉进行,加载阶段尚未完成,连接阶段可能已经开始了。但这两个阶段的开始时间仍然保持着固定的先后顺序。
验证
验证意义
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
验证过程
-
文件格式验证:第一阶段是验证字节流是否符合Class文件的规范
- 是否以
0xCAFEBABE
开头 - 主次版本号是否能被当前版本虚拟机处理
- 常量池中常量是否有不被支持的类型
- 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量
- Class文件中各个部分是否有被删除或额外添加的内容等....
- 是否以
-
元数据验证:第二阶段是对字节码描述的信息进行语义分析,保证符合Java语言要求
- 这个类是否有父类(除了Object之外都应该有父类)
- 这个类是否继承了不允许被继承的类(final类)
- 如果不是抽象类是否实现了父类或接口中要求被实现的方法
- 类中的字段和方法是否与父类发生矛盾(同名同参函数等)....
-
字节码验证:本阶段是验证过程中最复杂的一个阶段,是对方法体进行语义分析,保证方法在运行时不会出现危害虚拟机的事件。
-
符号引用验证:最后一个阶段的验证时发生在虚拟机将符号引用转化为直接引用的时候。这个动作在连接的第三阶段——解析阶段中发生,校验以下内容:
- 符号引用中通过字符串描述的全限定名是否能找到对应的类
- 在指定类中是否存在合法的字段、方法描述符
- 检查符号引用中的类、字段、方法是否可被当前类访问(private、protected、public、default)
符号引用验证如果没有通过,会抛出一个
java.lang.IncompatibleClassChangeError
异常的子类,如常见的java.lang.NoSuchFieldError、java.lang.NoSuchMethodError
等
注意事项
对于虚拟机的类加载机制而言,验证是一个很重要的、但不是必须的(因为对程序运行期无影响)一个阶段,如果运行的全部代码(包括自己编写的以及第三方包中的代码)都已经被反复使用和验证过,那么在实施阶段就可以使用-Xverify:none
来关闭大部分类的验证过程,以缩短虚拟机类加载的时间
准备
准备阶段是正式为类变量(被 static修饰的变量)分配内存并设置类变量初始值(通常为零值,引用类型为null)的阶段,这些变量所使用的内存将在方法去区中进行分配。如下语句中:
public static int value = 666;
value
变量在准备阶段之后初始值变为0而不是666,变为666的过程是在初始化阶段进行。
上面说到通常情况下是零值,特殊情况为该变量同时被final修饰,是常量。
public static final int value = 666;
编译时value就会生成ConstantValue属性(定义为常量),在准备阶段虚拟机就会依据ConstantValue的设置将value赋值为666.
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
class二进制字节流中的引用关系都是符号引用没有真正的意义,解析之后将会变成直接指向目标的指针。
初始化
类初始化阶段是类加载过程的最后一步,是执行类构造器方法的过程。
类构造器方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static {} 块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。
静态语句块中只能访问定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块中可以赋值,但不能访问。如下方代码所示:
public class Test {
static {
i = 0; // 给变量赋值可以正常编译通过
System.out.println(i); // 这句编译器会提示“非法向前引用”
}
static int i = 1;
}
类构造器方法不需要显式调用父类构造器,虚拟机会保证在子类的类构造器方法执行之前,父类的类构造器方法方法已经执行完毕。
由于父类的类构造器方法方法先执行,意味着父类中定义的静态语句块要优先于子类的变量赋值操作。如下方代码所示:
static class Parent {
public static int A = 1;
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B); // 输出 2
}
类构造器方法不是必需的,如果一个类没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成它。
接口中不能使用静态代码块,但接口也需要通过类构造器方法为接口中定义的静态成员变量显式初始化。但接口与类不同,接口的类构造方法不需要先执行父类的类构造方法方法,只有当父接口中定义的变量使用时,父接口才会初始化。
虚拟机会保证一个类的类构造方法在多线程环境中被正确加锁、同步。如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的类构造方法。
参考《深入理解Java虚拟机》、Jvm官方规范