Class类文件结构、类加载机制以及字节码执行
一、Class类文件结构
Class类文件严格按照顺序紧凑的排列,由无符号数和表构成,表是由多个无符号数或其他数据项构成的符合数据结构。
Class类文件格式按如下顺序排列:
类型 | 名称 | 数量 |
u4 | magic(魔术) | 1 |
u2 | minor_version(次版本号) | 1 |
u2 | major_version(主版本号) | 1 |
u2 | constant_pool_count(常量个数) | 1 |
cp_info | constant_pool(常量池表) | constant_pool_count-1 |
u2 | access_flags(类的访问控制权限) | 1 |
u2 | this_class(类名) | 1 |
u2 | super_class(父类名) | 1 |
u2 | interfaces_count(接口个数) | 1 |
u2 | interfaces(接口名) | interfaces_count |
u2 | fields_count(域个数) | 1 |
field_info | fields(域的表) | fields_count |
u2 | methods_count(方法的个数) | 1 |
method_info | methods(方法表) | methods_count |
u2 | attributes_count(附加属性的个数) | 1 |
attribute_info | attributes(附加属性的表) | attributes_count |
魔术用来判断该文件是否是Class类文件。
常量池的个数从1开始计数,所以常量池的个数为nstant_pool_count-1。常量池主要存放两大类常量,字面量以及符号引用。符号引用包括:类和接口的权限定名,字段名称和描述符,方法的名称和描述符。常量池的每一项表都是一个表,中共有11中表,具体可以看《深入理解java虚拟机》Page146,上面很详细的介绍而这11中常量,字面量的结构都是一个u1长度的tag,表示这个常量的类型,一个u2长度的length,表示这个常量的长度,以及length个u1长度的bytes(u1,u2,u4,u8分别代表1个字节,2个字节,4个字节,8个字节)。
常量池后面紧接着是类的访问权限控制符,类以及父类的全限定名,以及接口的个数,之后是接口的全限定名,全限定名都是指向常量池的符号引用。
再下面就是字段的个数,以及相应个数的表示字段的表,字段表的结构为:
类型 | 名称 | 数量 |
u2 | access_flag(字段修饰符) | 1 |
u2 | name_index(字段的简单名称) | 1 |
u2 | descriptor_index(字段的描述符) | 1 |
u2 | attributes_count (字段的额外属性的个数) | 1 |
attribute_info | attributes(字段的额外属性) | attributes_count |
全限定名:com/froest/TestClass;把comm.froest.TestClass中的"."换成"/",并且在最后加上";"就成为了全限定名,简单名称就是域的名称或者方法的名称;比如有方法 int getList(int a,char b,long c),那么该方法的描述符为:(ICJ)I;I为int类型的描述符,C为char类型的描述符,J为long类型的描述符,参数列表用"()",最后加上返回值的,描述符。
方法表的结构和字段表一样
在Class文件、字段、方法表中都可以携带自己的属性表结合,用于描述某些场景专有的信息。虚拟机预定义的属性如下表所示:
属性名称 | 使用位置 | 含义 |
Code | 方法表 | java代码编译成的字节码指令 |
ConstantValue | 字段表 | final关键字定义的常量值 |
Deprecated | 类、方法表、字段表 | 被声明为Deprecated的方法和字段 |
Exceptions | 方法表 | 方法抛出的异常 |
InnerClasses | 类文件 | 内部类列表 |
LineNumberTable | Code属性 | java源码的行号和字节码指令的对应关系 |
LocalVariableTable | Code属性 | 方法的局部变量描述 |
SourceFile | 类文件 | 源文件名称 |
Synthetic | 类、方法表、字段表 | 表示方法或字段为编译器自动生成 |
下面具体讲下Code属性,其他属性可以在《深入理解java虚拟机》中找到。Code属性的表结构如下:
类型 | 名称 | 数量 |
u2 | attribute_name_index(指向常量池中的”Code“常量,表示这个是"Code"属性) | 1 |
u4 | attribute_length("Code"属性的长度) | 1 |
u2 | max_stack(操作数栈的最大深度) | 1 |
u2 | max_locals(局部变量表的最大空间,以slot为一个基本单位) | 1 |
u4 | code_length(方法的字节码指令的长度) | 1 |
u1 | code(方法的字节码指令) | code_length |
u2 | excepion_table_length(方法体重用try-catch捕获的异常类型的个数) | 1 |
exception_info | exception_table(方法体重用try-catch捕获的异常类型) | excepion_table_length |
u2 | attributes_count(方法表的属性的个数) | 1 |
attribute_info | attributes(方法表的属性) | attributes_count |
其中max_locals不一定是所有的局部变量的总和,因为有些局部变量是有作用域的,离开了作用域,这个局部变量就失去了作用,他所占用的slot也就可以被重用,所以max_locals可以小于等于方法中的所有的局部变量的总和。字节码指令只占用一个字节,用u1表示。局部变量的顺序,按照this,参数,局部变量。也就是第一个slot用来存放this(指向常量池中该类的符号引用,是一个地址),参数在局部变量中从第2个slot开始存放。
二、类加载机制
类加载按加载,连接,初始化这个顺序进行的,其中连接又可以细分为验证,准备,解析三个阶段,部分解析可以在初始化开始之后再开始,这样可以支持java的运行时绑定。虽然部分解析可以在初始化阶段开始以后再开始,但是这部分的初始化还是需要当前的部分解析以后才可以初始化。java虚拟机规范中严格规定了有且之友中情况必须立即对类进行初始化:
1)遇到new创建实例,getstatic获取类的静态字段,putstatic设置静态字段,invokestatic调用类的静态方法
2)用java.lang.reflect包方法对类进行反射调用的时候,如果这个类没有初始化过,那么先触发其初始化
3)初始化一个类的时候,如果父类没有进行初始化,那么必须先触发其父类的初始化
4)当虚拟机启动的时候,需要指定一个执行的主类,虚拟机会先初始化这个主类
用new关键字创建数组不会触发相应的类初始化。调用一个类的静态常量也不会触发该类的初始化,因为调用类在编译阶段就已经把常量转化为对自己的常量池的引用,例:
1 class ConstClass { 2 static { 3 System.out.println("ConstClass init"); 4 } 5 public final static String HELLODWORLD = "hello world"; 6 } 7 8 public class NotInitialization { 9 public static void main(String[] args) { 10 System.out.println(ConstClass.HELLODWORLD); 11 } 12 }
加载阶段是整个类加载阶段的第一个阶段,在加载阶段主要完成3件事情:
1)通过类的全限定名来回去定义此类的二进制流
2)将这个二进制流所代表的静态存储结构转化为方法区的运行时数据结构
3)在java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。
验证阶段是连接阶段的第一步,则以不的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段分为4中验证:Class文件格式的验证,元数据的验证,字节码的验证,符号引用验证。Class文件格式验证为了验证是否符合Class文件的格式;元数据验证是为了对类的元数据信息进行语义校验,保证不存在不符合java语义规范的元数据信息;字节码验证主要是对方法体中的字节码进行校验分析;符号引用验证主要是为了给解析阶段符号引用转化为直接引用做准备,对类自身以外的信息(常量池中的各种符号引用)进行匹配性校验。
准备阶段是正式为类变量(被static修饰的变量)分配初始值。
public static int a = 123;//类变量在准备阶段初始化的值为0,而在初始化阶段,在<cinit>构造方法中会把a的值初始化为123
public static final int a = 123;//用final修饰的类变量在准备阶段,会把a的值初始化为123
解析阶段就是把虚拟机在常量池中的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用。
类或接口的解析,假设当前代码所属的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那么需要按下面的步骤执行:
1)如果C不是数组类型,那么虚拟机会把代表N的全限定名传给D的类加载器去加载这个类C,加载过程,由于元数据、字节码验证的需要,又可能触发其他相关的类的加载动作,一旦加载过程抛出任何异常,解析过程就会失败。
2)如果C是个数组类型,并且数组的元素类型为对象,N的描述符为“[Ljava.lang.Integer”的形式,那么将会按照第一点规则加载数组的元素类型,接着有虚拟机生成一个代表此数组围堵和元素的数组对象。
3)如果上述步骤没有出现任何异常,那么C在虚拟机中已经成为一个有效的类了,但是在解析完成前还要确认C是否具备对D的访问权限。
字段解析,假设字段所属的类为C(字段表中的class_index属性表示常量池中的class的全限定名):
1)在C中查找是否有简单名称和描述符都相同的字段,如果有,返回这个字段的直接引用,查找结束
2)如果C实现了接口,那么会按照继承关系(接口可以继承多个接口)从上往下递归搜索各个接口以及它的父接口,如果找到,查找结束
3)如果C不是Object类的话,将会按照继承关系从上往下递归搜索其父类,如果找到,查找结束
4)否则查找失败
如果找到了,那么验证将会验证这个字段的权限
类方法解析,假设这个方法所在的类为C(方法表中的class_index表示常量池中的class的全限定名)
1)如果在类方法表中发现这个class_index中的所以C是个接口,查找失败
2)如果在C类中找到了(方法的简单名称和描述符一致),返回这个方法的直接引用,查找结束
3)否则,在C的父类中递归查找,如果找到,返回这个方法的直接引用,查找结束
4)否则,在C的接口列表以及他们的父接口中递归查找,如果找到,返回这个方法的直接引用,查找结束
5)否则,查找失败
如果找到了,验证是否有权限。
接口方法解析,和类方法解析类似,只是第一步不一样,接口方法解析的第一步为:如果在类方法表中发现这个class_index中的所以C是个类,查找失败。
初始化过程是执行类构造器<cinit>()方法的过程,<cinit>()会字段收集类中的所有类变量以及静态语句块(static{}),在初始化<cinit>()方法的时候,虚拟机会自动调用父类的<cinit>()方法,接口的<cinit>()方法可以到使用的时候在去初始化,虚拟机会保证<cinit>()方法在多线程环境先被正确的加锁和同步。还有一个<init>()方法,这个方法是实例构造器,在创建实例的时候会被调用并且初始化。
任意一个类,都需要加载它的类加载器和这个类本身一同确定其在java虚拟机中的唯一性。
1 package com.froest.excel; 2 3 import java.io.InputStream; 4 5 public class ClassLoaderTest { 6 public static void main(String[] args) throws Exception { 7 ClassLoader myClassLoader = new ClassLoader() { 8 9 @Override 10 public Class<?> loadClass(String name) throws ClassNotFoundException { 11 try { 12 String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class"; 13 InputStream is = getClass().getResourceAsStream(fileName); 14 if (is == null) { 15 return super.loadClass(name); 16 } 17 byte[] b = new byte[is.available()]; 18 is.read(b); 19 return defineClass(name, b, 0, b.length); 20 } catch (Exception e) { 21 throw new ClassNotFoundException(name); 22 } 23 } 24 25 }; 26 Object obj = myClassLoader.loadClass("com.froest.excel.ClassLoaderTest").newInstance(); 27 System.out.println(obj.getClass()); 28 System.out.println(obj instanceof com.froest.excel.ClassLoaderTest); 29 } 30 }
上面代码执行的结果为:
class com.froest.excel.ClassLoaderTest
false
第一个输出表示obj确实是com.froest.excel.ClassLoaderTest实例化出来的对象,但是第二个类型检查确实false,这是因为虚拟机的内存中有两个ClassLoaderTest类,一个是应用程序加载器加载的,另外一个是我们自定义的类加载器加载的,虽然是同一个Class,但是还是独立的两个类。
类加载器使用双亲委派模型,这样要加载一个类,首先查找这个类是否已经被加载过,如果没有,那么类加载器会把这个类委派给这个加载器的父类去进行加载,如果父类不能加载,那么再自己加载。
三、字节码执行
首先看一个数据结构---栈帧,栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,他是虚拟机运行时数据区的虚拟机栈的栈元素。栈帧中从上到下一次存储了局部变量表,操作数栈,动态链接,返回地址等信息,每个方法从调用开始到调用结束,都对应着一个栈帧从入栈到出栈的过程。一个线程中只有栈顶的栈帧才是有效的,称为当前栈帧,这个栈帧所关联的方法就是当前方法。
操作数栈中的元素的数据类型要与字节码指令的序列完全一致。
经过优化的虚拟机令两个栈帧的局部变量表重叠一部分,公用一部分数据,这样可以减少额外的参数的复制传递了。
动态链接就是在运行期间把符号引用转化为直接引用的过程,相对于静态解析(在加载阶段的解析阶段把符号引用转化为直接引用)
方法的返回地址,方法返回有两种类型,一种是正常完成出口,另一种是异常完成出口,方法退出的过程等同于栈帧出栈,因此栈帧出栈的时候可能执行的操作有:恢复上层调用方法的局部变量表和操作数栈,把返回值压入调用方法的操作数栈中,调整PC计数器的值以执行方法调用指令的后一条指令等。
java虚拟机中的调用指令有invokestatic(调用静态方法),invokespecial(调用实例构造器(<init>()方法),私有方法,父类方法),invokevirtual(调用所有的虚方法),invokeinterface(调用接口方法,会在运行时再确定一个实现此接口的对象)。只要能被invokestatic和invokespecial指令调用的方法,这些方法叫做非虚方法,都可以在解析阶段确定唯一的调用版本,这种方法在类加载的时候就会符号引用解析为直接引用。相反的被invokevirtual和invokeinterface指令调用的方法叫做虚方法(除了final方法,因为final方法不允许被修改,只有一种形式)。
静态分派:所有依赖静态类型来定位方法执行版本的分派动作都称为静态分派,静态分派的典型应用就是方法重载。
动态分派:在运行期根据实际类型确定方法的执行版本的分派过程称为动态分派,动态分派的典型应用就是方法重写。
宗量:方法的接受者和方法的参数统称为方法的宗量。
单分派:根据一个宗量对目标方法进行选择
多分派:根据多个宗量对目标方法进行选择
java是一种静态多分派,动态单分派语言。
类的方法区会保存一张虚方法表,存放方法的实际入口地址,如果没有重写父类的方法,那么入口与父类的一样,如果重写了父类的方法,那么方法的入口地址指向自己的方法入口地址。方法表一般在类加载的连接阶段进行初始化,准备了类变量的初始值之后,虚拟机会把该类的方法表也初始化完毕,这是java实现动态分派方法。
爱情终将消失于茫茫的时间洪流之中,沉淀于厚重的黄泥沙丘之下...