jvm-class文件和类的加载过程
文章目录
class文件
java所推出的口号是:一次编译,处处执行。这种跨平台性主要就是由屏蔽操作系统底层的jvm所实现的,java程序从编译到执行有几个阶段,首先是java程序被javac编译器编译成.class的字节码文件,jvm加载并运行这一个中间文件,再通过jvm自身的接近底层的方法,实现在window,macos和linux上的跨平台。
我的个人理解是,java为了保证跨平台性牺牲了一部分效率,这部分牺牲主要体现在要多一个通用的字节码文件的编译,而不是直接就从java变成机器能够执行的汇编了。
class文件的结构
字节码文件主要有几个部分构成:
- 魔数和大小版本号:“cafebabe”,大版本号码就是-44,比如jdk8的版本号是52
- 常量池:最核心的部分,主要是字面量和符号引用,注意:在class文件中的都是符号引用,可以想象成逻辑地址,而在类加载的解析过程中,才会变为物理地址的引用。
- 字段表:当前类的变量
- 方法表:当前类的方法
- 属性:很复杂的一个表示
案例:
1 | public class ClassFile { |
魔数和大小版本号
首先,改变一个文件的后缀名是不会改变其文件本身的编码结构的,jvm不能仅仅是校验后缀是不是.class来判断是否为一个字节码文件,所以在文件内容的开头有一个cafebabe的校验,只有以这个为开头的才是jvm所需要的字节码文件,当然也不仅仅是要满足这一点,在后续类加载过程的校验过程中会详细说明。
其次大版本号码就是当前jdk版本+44,还有小版本,这一部分是为了jvm在校验的过程中判断当前的class是否能够兼容版本,一般当然是只能向下兼容。、
常量池
在常量池之前,需要补充一下各种名以及描述符
- 全限定名和非限定名:Class文件中的类和接口,都是使用全限定名,又被称作Class的二进制名称。例如“com/ikang/JVM/classfile”是这个类的全限定名,仅仅是把类全名中的“.”替换成了“/”而已,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“;”表示全限定名结束。
非限定名又被称作简单名称,Class文件中的方法、字段、局部变量、形参名称,都是使用简单名称,没有类型和参数修饰,例如这个类中的getK()方法和k字段的简单名称分别是“getK”和“m”。
非限定名不得包含ASCII字符. ; [ / ,此外方法名称除了特殊方法名称< init >和< clinit >方法之外,它们不能包含ASCII字符<或>,字段名称或接口方法名称可以是< init >或< clinit >,但是没有方法调用指令可以引用< clinit >,只有invokespecial指令可以引用< init >。- 描述符:在class文件里的方法和基本数据类型也有特定的写法,一般来说基本数据类型都是首字母大写作为描述,比如int就为I,其中long特殊,描述为J,void为V,引用类型为L+类的全限定名,对于数组类型,每一维度将使用一个前置的“[”字符来描述,如一个定义为“java.lang.String[][]”类型的二维数组,将被记录为:“[[Ljava/lang/String;”,一个整型数组“int[]”将被记录为“[I”
方法描述符的写法看一眼就会,比如Object m(int i, double d, Thread t) {… }则描述符为(IDLjava/lang/Thread;)Ljava/lang/Object;
言归正传,常量池有两部分内容:字面量和符号引用。字面量就是一个变量的对应值,符号引用就是名称,主要有三部分名称:类与接口的全限定名,字段的名称和描述符(NameAndType),方法的名称和描述符(NameAndType)。
每一个字段都有自己的结构,具体见这篇博客。
说几个比较重要的:
- CONSTANT_Utf8_info和CONSTANT_String_info:前者是真正存储字符串本体的,而后者的结构只包含引用utf8的索引位置,为什么这样设计?当多个String名字不同但是值相同的时候,就可以只创建一条CONSTANT_Utf8_info常量,多个String可以引用,从而节省空间;其次是有可能符号引用也是当前值,例如声明了String a = “a”,这样多少也能节约空间。
- CONSTANT_NameAndType_info:name就是字段或者方法的非限定名,type是其描述符,这个同样是引用类型,放的是CONSTANT_Utf8_info的索引。例如println:(Ljava/lang/String;)V
- CONSTANT_Fieldref_info和CONSTANT_Methodref_info:两个字段,前者是这个字段或者方法的名字引用,后者是CONSTANT_Methodref_info的引用
- CONSTANT_Class_info:类的全限定名,注意所有的类都会有java/lang/Object这个类的class常量,因为除了Object本身,其余都是继承自它。
访问标志
用2字节作为访问标志,其中16位的某些位置可能代表了某些修饰词,例如public,final,interface等
当前类,父类和接口索引
类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于 Java 语言的单继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。接口索引集合用来描述这个类实现了那些接口,这些被实现的接口将按 implements (如果这个类本身是接口的话则是extends) 后的接口顺序从左到右排列在接口索引集合中。
字段表
- access_flags: 字段的作用域(public ,private,protected修饰符),是实例变量还是类变量(static修饰符),可否被序列化(transient 修饰符),可变性(final),可见性(volatile 修饰符,是否强制从主内存读写)。
- name_index: 对常量池的引用,表示的字段的名称;
- descriptor_index: 对常量池的引用,表示字段和方法的描述符;
- attributes_count: 一个字段还会拥有一些额外的属性,attributes_count 存放属性的个数;
- attributes[attributes_count]: 存放具体属性具体内容。
这里说一下字段里面常见的属性:
ConstantValue:一个字段有了这个说明是被static修饰了的。目前Sun Javac编译器的选择是:如果同时使用final和static来修饰一个变量(按照习惯,这里称“常量”更贴切),并且这个变量的数据类型是基本类型或者java.lang.String的话,就生成ConstantValue属性来进行初始化,即编译的时候;如果这个变量没有被final修饰,或者并非基本类型及字符串,则将会选择在<clinit>方法中进行初始化,即类加载的时候。
方法表
内容基本上与上面一致,也是有访问标志,name,descriptor和属性,这里的属性只要方法内不为空都会有code这个属性
方法里常见的属性:
- Code:Java方法体里面的代码经过Javac编译之后,最终变为字节码指令存储在Code属性内,Code属性出现在在method_info结构的attributes表中,但在接口或抽象类中就不存在Code属性(JDK1.8可以出现了)。一个方法中的Code属性值有一个。在整个Class文件中,Code属性用于描述代码,所有的其他数据项目都用于描述元数据。在字节码指令之后的是这个方法的显式异常处理表(下文简称异常表)集合,异常表对于Code属性来说并不是必须存在的。
- LineNumberTable:位于Code属性中,描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。主要是如果抛出异常时,编译器会显示行号,比如调试程序时展示的行号,就是这个属性的作用。Code属性表中,LineNumberTable可以属性可以按照任意顺序出现。在Code属性 attributes表中,可以有不止一个LineNumberTable属性对应于源文件中的同一行。也就是说,多个LineNumberTable属性可以合起来表示源文件中的某行代码,属性与源文件的代码行之间不必有一一对应的关系。
- exception_table: 在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的,而是采用异常表来完成的。 异常表中每一个数据由4部分组成,分别是start_pc、end_pc、handler_pc和catch_type。这4项表示从方法字节码的start_pc偏移量开始(包括)到end_pc 偏移量为止(不包括)的这段代码中,如果遇到了catch_type所指定的异常, 那么代码就跳转到handler_pc的位置执行,handler_pc即一个异常处理器的起点。在这4项中, start_pc、end_pc和handlerpc 都是字节码的编译量, 也就是在code[code_length]中的位置, 而catch_type为指向常量池的索引,它指向一个CONSTANT_Class_info 类,表示需要处理的异常类型。如果catch_type值为0,那么将会在所有异常抛出时都调用这个异常处理器,这被用于实现finally语句