Understanding the JVM:虚拟机字节码执行引擎,Understanding the JVM:类文件结构

执行引擎是Java虚拟机最核心的组成部分之一。执行引擎在执行Java代码的时候可能有解释执行(通过解释器执行)和编译执行(通过即时编译器产生的本地代码执行)两种选择,也可能两者兼备,甚至还可能包含几个不同级别的编译器执行引擎。但从Java虚拟机规范中描述的执行引擎概念模型来说,所有的Java虚拟机的执行引擎都是一样的:它的输入是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。

思考:

  • 解释器、编译器,什么区别?
  • 代码的执行过程,本质都是代码生成二进制机器码,差异在哪?

本章重点有下面几个小节:

  • 运行时栈帧结构:JVM Stack、本地方法栈,保存现场、恢复现场;
  • 方法调用
  • 基于栈的字节码解释执行引擎

首先,栈帧是用于支持虚拟机进行方法调用方法执行数据结构(还记得不?栈帧是运行时数据区虚拟机栈的栈元素)。也就是说它就是一个类似结构体的东东,用于存放一些诸如

  • 局部变量表
  • 操作数栈
  • 动态连接
  • 方法返回地址

还有一些其他的附属信息。每一个方法从调用开始到执行完毕,就对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程

看图说话:

栈帧是针对JVM Stack和Native Method Stack来说的,这个是Thread独有的,接下来就具体分析一下栈帧中的元素:

稍微一想,局部变量表当然存储的是方法参数+方法内的变量。而这些数据被存在变量槽(Variable Slot,下称slot)的最小单位中,slot中能存放的类型为:

  • 8种基类类型(bool/byte/char/short/int/long/float/double)
  • reference
  • returnAddress:指向字节码指令的地址

我们看到,Java虚拟机规范并没有规定每个slot的大小。所以不同的虚拟机或者操作系统可以有各自的实现。当然,一个slot可以存放一个32位以内的数据类型,包含了上面3类中除long和double的其他所有类型。而64的long和double则分配两个连续slot。这里我们可能会想到多线程访问的问题,但是请记得大前提:Java虚拟机栈是线程私有的,对线程来说是原子性的。所以这里连续不连续都不会引起安全问题。

上面说完了局部变量表的东西,那么JVM如何使用它们呢?答案是索引定位。

在方法执行时,虚拟机是通过局部变量表来完成参数值到参数变量列表的传递过程的,如果是非static方法,局部变量表的第0号索引的slot默认是当前对象实例的引用,也就是this指向的对象。而且slot可以复用,比如函数内变量作用域只在一个循环内,那么后面的变量可以占用这个slot。

关于局部变量还想再说一点,在《Java编程思想》的笔记里,曾经分三章提到了类的初始化,那么说局部变量是没有初始化的。在局部变量表中找到了答案:

  • 类变量(非实例变量)有 2 次赋初始值的过程:
    • 一个是准备阶段,赋予系统初始值(有final的话在编译时会加上ConstantValue属性,那么在准备时就是常量值了);
    • 另外一次是初始化阶段,是程序员指定的值;
  • 实例变量是在使用 new 关键字后,在堆上先进行分配内存的时候获取一次数据类型的零值,然后再执行实例变量定义处的初始化(C++不允许),最后执行的是构造函数的初始化。

因此,对于类变量而言,如果程序员在定义处不指定初始值的话,准备阶段也会有默认值(数据类型的零值)。对于实例变量而言,在堆上分配的时候,由于需要分配内存,就会同时将这些内存的空间清空,赋予它们数据类型的零值。但是局部变量就不同了,因为准备阶段只处理方法区,堆只管新分配内存,而局部变量表可能复用 slot。所以,一个局部变量定义了但是没有初始值是不能使用的(一种情况是使用了新的 slot,会有零值;如果复用了 slot,那么值肯定就是错的。所以综合考虑,局部变量必须指定初值)。

Java虚拟机的解释执行引擎被称为基于栈的执行引擎,其中所指的栈就是指——操作数栈。操作数栈也常被称为操作栈。

和局部变量区一样,操作数栈也是被组织成一个以字长为单位的数组。但是和前者不同的是,它不是通过索引来访问,而是通过标准的栈操作(压栈和出栈)来访问的。比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。

虚拟机在操作数栈中存储数据的方式和在局部变量区中是一样的:如int、long、float、double、reference和returnType的存储。对于byte、short以及char类型的值在压入到操作数栈之前,也会被转换为int。

虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。比如,iadd指令就要从操作数栈中弹出两个整数,执行加法运算,其结果又压回到操作数栈中,看看下面的示例,它演示了虚拟机是如何把两个int类型的局部变量相加,再把结果保存到第三个局部变量的:

  1. begin
  2. iload_0 // push the int in local variable 0 onto the stack
  3. iload_1 // push the int in local variable 1 onto the stack
  4. iadd // pop two ints, add them, push result
  5. istore_2 // pop int, store into local variable 2
  6. end

在这个字节码序列里,前两个指令iload_0和iload_1将存储在局部变量中索引为0和1的整数压入操作数栈中,其后iadd指令从操作数栈中弹出那两个整数相加,再将结果压入操作数栈。第四条指令istore_2则从操作数栈中弹出结果,并把它存储到局部变量区索引为2的位置。下图详细表述了这个过程中局部变量和操作数栈的状态变化,图中没有使用的局部变量区和操作数栈区域以空白表示。

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在前面知道,class文件中的常量池有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候转换成直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期间转换为直接引用,这部分称为动态连接

一个方法执行结束有两种情况:

  • 正常结束。绝大多数程序都会正常结束
  • 异常结束。异常结束指的是在方法内部无法处理异常(没有匹配的异常处理器),那么方法就会异常退出。一个方法只要是异常退出,是不会给调用者任何返回值的。

无论何种方式的方法退出,都需要返回到方法被调用的位置,用于恢复上下文供程序继续处理。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这个信息。

方法退出相当于出栈,可能执行的操作有:

  • 恢复上层调用方法的局部变量表和操作数栈
  • 把返回值(如果有的话)压入调用者栈帧的操作数栈中
  • 调整PC计数器指向下一条指令

这部分Java虚拟机规范没有明确规定,具体实现是虚拟机自己的事情。在实际开发中,一般会把动态连接、方法返回地址和其他附加信息全部归为一类,称为栈帧信息

方法调用阶段的唯一目的是:

确定被调用方法的版本(即调用哪一个版本),暂时还不涉及方法内部的具体运行过程。意思很明显,将在所有的重载、覆盖函数中确定应该调用哪个版本(找到要调用的方法)

Tips:

这里需要说明一点,Class文件的编译过程不包含传统编译中的连接步骤,一切方法调用在Class文件中都只是符号引用,而不是方法在实际运行时内存布局的入口地址(直接引用),这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法的调用过程变得复杂,需要在类加载期间甚至到运行时才能确定目标方法的直接引用

什么是解析?

在class文件的二进制字节流中,所有的方法调用都是通过符号引用进行的。那么,在进入JVM后,有一部分方法调用就可以从符号引用转变为直接引用,要求就是:编译期已知,运行期不可变。所以,符合解析条件的都是在编译期确定下来的方法调用。大致想下就能想出来几种,比如类的静态方法、private修饰的方法、实例构造器等。嗯,正规来说,在Java语言中,符合“编译期可知,运行期不可变”的方法主要有:静态方法和私有方法,前者直接与类型关联,后者在外部不可访问。这两种方法都不可能通过继承或者别的方式重写出其他版本,因此他们都适合在类加载阶段进行解析(解析的都必须在编译期确定哦)。

Java虚拟机一共提供了 4 条方法调用字节码指令,分别是:

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器方法(看仔细,不是)、私有方法和父类方法
  • invokevirtual:调用所有的虚方法
  • invokeinterface:调用接口方法,会在运行时确定一个实现该接口的对象

只要能被invokestatic和invokespecial调用的方法,才可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器和父类方法,它们在类加载的时候就会把符号引用解析成直接引用。这些方法可以称为非虚方法,与之相反的invokevirtual和invokeinterface就是虚方法了,这些就需要在运行时确定实现该接口的对象。

解析调用一定是一个静态的过程,在编译期间就能完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再完成。而分派调用则可能是静态的或者动态的。

分派调用过程将会揭示Java多态特性是如何实现的,比如重载和重写,这里的实现当然不是语法那么low,我们关心的是JVM如何确定正确的目标方法。而分派共分为四种:(静态分派:重载,动态分派:重写Override)

  • 静态单分派
  • 静态多分派
  • 动态单分派
  • 动态多分派

结论我们先记住:

  • 重载:参数静态类型
  • 重写:参数动态类型

首先我们说明静态分派。首先是一段在面试中经常出现的代码:

  1. public class StaticDispatch {
  2. static abstract class Human {
  3. }
  4. static class Man extends Human {
  5. }
  6. static class Woman extends Human {
  7. }
  8. public void sayHello(Human guy) {
  9. System.out.println("hello, Human");
  10. }
  11. public void sayHello(Man guy) {
  12. System.out.println("hello, Man");
  13. }
  14. public void sayHello(Woman guy) {
  15. System.out.println("hello, Woman");
  16. }
  17. public static void main(String []args) {
  18. Human man = new Man();
  19. Human woman = new Woman();
  20. StaticDispatch staticDispatch = new StaticDispatch();
  21. staticDispatch.sayHello(man);
  22. staticDispatch.sayHello(woman);
  23. }
  24. } 请思考一下答案应该是神马呢?*(为什么使用静态方法?)*

正确答案是:

  1. hello, Human
  2. hello, Human

如何感觉到惊诧就对了,下面我们解释。对于那些完全无压力而且能说出原因的人,请你们洗洗睡吧。咳咳,进入正题。这里我们需要定义两个重要概念:

  1. Human man = new Man();

我们把上面的Human称为变量man的静态类型,后面的Man称为man的实际类型。它们的区别在于:

变量本身的静态类型不会改变,而且在编译期就可以知道;而实际类型变化的结果到运行时才能确定,编译时无法知道。

下面是例子

  1. //实际类型变化
  2. Human man = new Man();
  3. man = new Woman();
  4. //静态类型变化
  5. sayHello((Man)man);
  6. sayHello((Woman)man);

知道了这个回到刚才那个例子就很清楚了,在main中man和woman的静态类型都是Human,但编译器在重载时是通过参数的静态类型而不是实际类型作为判断依据的。因为静态类型是编译期已知的,所以javac会在编译时确定该调用哪个版本,在本例子中就是sayHello(Human guy)了。

所有依赖静态类型的分派都称为静态分派,而静态分派最典型的应用就是重载。静态分派发生在编译时期,因此确定静态分派的动作实际上跟JVM无关。但是也有例子,即使编译器能精确的判断上个例子,但是对于一些无法知道静态类型的变量(比如字面值),编译器只好靠猜了,它会尽量选择最符合语境的方法。下面是一个例子:

  1. import java.io.Serializable;
  2. public class Overload {
  3. public static void sayHello(Object org) {
  4. System.out.println("hello Object");
  5. }
  6. public static void sayHello(int org) {
  7. System.out.println("hello int");
  8. }
  9. public static void sayHello(long org) {
  10. System.out.println("hello long");
  11. }
  12. public static void sayHello(Character org) {
  13. System.out.println("hello Character");
  14. }
  15. public static void sayHello(char org) {
  16. System.out.println("hello char");
  17. }
  18. public static void sayHello(char... org) {
  19. System.out.println("hello char...");
  20. }
  21. public static void sayHello(Serializable org) {
  22. System.out.println("hello Serializable");
  23. }
  24. public static void main(String[] args) {
  25. sayHello('a');
  26. }
  27. }/*output:
  28. hello char
  29. */

很明显的结果。请依次注释掉char/int/long/Character/Serializable/Object,这时候应该只剩下char …了。输出结果其实很容易知道。

需要说明的是,可变形参的重载优先级是最低的,上面8种版本只有当其他7种都注释的情况才会出现hello char…。这个代码演示了编译期选择静态分派目标的过程,这个过程是Java实现方法重载的本质。

下面说说动态分派。它和多态性的另外一个重要体现——重写(Override)有很大的关联。废话少说,上代码

  1. public class DynamicDispatch {
  2. static abstract class Human {
  3. protected abstract void sayHello();
  4. }
  5. static class Man extends Human {
  6. @Override
  7. protected void sayHello() {
  8. System.out.println("hello, Man");
  9. }
  10. }
  11. static class Woman extends Human {
  12. @Override
  13. protected void sayHello() {
  14. System.out.println("hello, Woman");
  15. }
  16. }
  17. public static void main(String[] args) {
  18. Human man = new Man();
  19. Human woman = new Woman();
  20. man.sayHello();
  21. woman.sayHello();
  22. man = new Woman();
  23. man.sayHello();
  24. }
  25. }/*output:
  26. hello, Man
  27. hello, Woman
  28. hello, Woman
  29. */

这次代码运行的结果对于面向对象思维的程序员来说是很容易接受的。现在的问题还是一样:JVM是如何调用正确的方法呢?

最简单的方法就是打印一下StaticDispatch和DynamicDispatch的字节码(先编译,然后用javap -c XXX),然后大概看看程序的逻辑。然后是这样的:

  • StaticDispatch[编译期]:前面说过,重载是由静态类型决定的。那么,编译器在处理重载函数时,使用哪个版本的重载函数就取决于传入参数的静态类型。并且因为静态类型是编译期可知的,所以在编译阶段,javac编译器就根据参数的静态类型决定使用哪个版本,同时把这个方法的符号引用写入到invokevirtual指令的参数中。在本例子中,就是编译器看到main中调用sayHello()的参数静态类型是Human,于是就把sayHello(Human guy)写入到main中2个调用sayHello()的invokevirtual指令中。
  • DynamicDispatch[运行期]:对于多态来说,重写使用的是参数的实际类型。因为参数的实际类型是在运行期才能知道的,所以就需要学习一下invokevirtual的多态查找过程(上面那个是编译期写死使用哪个版本):

    • 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C
    • 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,进行访问权限验证,通过则返回这个方法的直接引用,查找过程结束;否则返回java.lang.IllegalAccessError
    • 否则,按照继承关系从下到上依次对C的各个父类进行第2步的搜索和验证过程
    • 如果还是没有找到合适的方法,就抛出java.lang.AbstractMethodError

Tips:

由于invokevirtual指令执行的第一步就是在运行时确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用,这个过程就是Java重写的本质。我们把这种在运行时确定方法执行版本的过程成为动态分派。

经过上面静态分派、动态分派的讲解,我们还得思考一个问题:为什么重载是静态分派,而重写是动态分派呢?

经过上面2个例子的分析,我们应该能总结出来它们实现的原理。本质上来说,这是面向对象的多态特征的应用,它提供了一种运行时类型调整的方法。因为面向过程是无法实现多态机制的。具体而言,重载的时候根据参数的静态类型就可以进行方法版本的选择;而重写是多态的特性,我们想使用的类型只有在运行时才能确定,所以它是根据参数的实际类型来进行方法选择。 嗯,最后说一下。因为运行时的动态分派非常频繁,为了性能考虑,Java会为类在方法区中建立一个虚方法表(和C++一样的啦),如果是接口,那么就是一个虚接口表,然后在这个表中进行查找。而不是大海捞针式。而这个方法区的方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始完成。

第一遍看的时候没搞清楚这啥玩意,目前看了第三遍,终于算是稍微清晰一点了。。。补一发T_T

  1. package jvm;
  2. public class Dispatch {
  3. static class QQ {
  4. }
  5. static class _360 {
  6. }
  7. public static class Father {
  8. public void hardChoice(QQ arg) {
  9. System.out.println("father choose QQ");
  10. }
  11. public void hardChoice(_360 arg) {
  12. System.out.println("father choose 360");
  13. }
  14. }
  15. public static class Son extends Father {
  16. @Override
  17. public void hardChoice(QQ arg) {
  18. System.out.println("son choose QQ");
  19. }
  20. @Override
  21. public void hardChoice(_360 arg) {
  22. System.out.println("son choose 360");
  23. }
  24. }
  25. public static void main(String[] args) {
  26. Father father = new Father();
  27. Father son = new Son();
  28. father.hardChoice(new _360());
  29. son.hardChoice(new QQ());
  30. }
  31. }/*output:
  32. father choose 360
  33. son choose QQ
  34. */

在main中调用了2次 hardChoice(),一次是通过father调用,一次是通过son调用。那么,我们就来具体分析一下:

  • 编译阶段编译器的选择:前面知道了是静态分派。既然是静态的,首先得知道静态类型是Father还是Son,这是一个宗量。然后选择重载版本的参数类型是QQ还是_360,这又是一个宗量。加起来之后,静态分派总共有两个宗量。所以 Java 语言的静态分派属于多分派类型
  • 运行阶段虚拟机的选择:经过编译期的静态分派,我们知道 father 最终执行的方法是 hardChoice(360), son 最终执行的方法是 hardChoice(QQ), 那么不管360是哪种360,QQ 是腾讯QQ 还是奇瑞 QQ,虚拟机都不会关心,只要你是 QQ 类型就可以。那么,最终选择方法的关键在于这个方法的接收者的实际类型,所以只有一个宗量。

综上可知:

Java是静态多分派,动态单分派。具体可以google之,不过我看了一大圈下来还是没人能讲清楚的。等学到visitor设计模式再看看吧。。。。

前面说过,字节码的执行分为解释执行和编译执行,下面就来讲一下。

这个看了之后就是编译原理的流程,javac完成的工作有:

程序源代码 -> 词法分析 -> 语法分析到抽象语法树 -> 字节码

剩下的解释运行被实现在JVM中。怎么实现呢?Java编译器生成的字节码应该属于一种基于栈的指令集架构。而物理机多采用的是x86架构(也就是寄存器架构,二地址指令集)。大体上可以用一个例子来说明:比如计算1+1:

基于栈的指令集:

  1. iconst_1
  2. iconst_1
  3. iadd
  4. istore_0

两条iconst_1指令连续把2个常量1压入栈中,iadd把2个1弹出,结算结果为2后放入栈顶。然后istore_0把2放到局部变量表的0号slot中。

基于寄存器的指令集:

  1. mov eax, 1
  2. add eax, 1

mov把EAX寄存器的值设为1,然后add指令再把这个值+1,结果还是保存在EAX寄存器中。

那么这两种哪个更好呢?其实,两者各有优劣,要不然就不会出现两雄争霸的局面了。

  • 基于栈的指令集最主要优点是跟机器无关,具有移植性;缺点就是执行速度较慢,所有主流物理机的指令集都是寄存器架构从侧面说明了这一点。
  • 寄存器和硬件息息相关,程序依赖寄存器就会失去移植性。但是寄存器最主要的优点是速度快,因为频繁的入栈出栈会产生相当多的指令,而且栈是实现在内存中,而对于处理器来说,内存始终是执行速度的瓶颈。

下面用一个简单的例子来说明基于栈的解释器执行过程,首先是例子:

  1. public int calculate() {
  2. int a = 100;
  3. int b = 200;
  4. int c = 300;
  5. return (a + b) * c;
  6. }

编译后通过javap -c A得到字节码:

  1. public int calculate();
  2. Code:
  3. 0: bipush 100
  4. 2: istore_1
  5. 3: sipush 200
  6. 6: istore_2
  7. 7: sipush 300
  8. 10: istore_3
  9. 11: iload_1
  10. 12: iload_2
  11. 13: iadd
  12. 14: iload_3
  13. 15: imul
  14. 16: ireturn

我们把每条指令解释一下:

  1. bipush 100,把100推入操作数栈顶
  2. istore_1,把栈顶元素出栈并存放到局部变量表的1号slot(因为calculate不是static的,0号slot是指向本对象的this)
  3. sipush 200一直到istore_3都是重复1-2步骤
  4. iload_1,将1号slot的值复制到栈顶
  5. iload_2,将2号slot的值复制到栈顶
  6. iadd,出栈两个元素,将相加结果300压入栈顶
  7. iload_3,将3号slot的值复制到栈顶
  8. imul,出栈两个元素,将相乘结果90000压入栈顶
  9. ireturn,将栈顶元素返回给调用者

上面只是一个简单的例子,在实际应用中,复杂的代码JVM会做很多优化,这里仅仅为了说明问题,所以比较简单。

Understanding the JVM:类文件结构

Java作为一门优秀的语言,必定有它的过人之处。当初Java诞生时的口号是:

Write Once, Run Anywhere.

可见Java最大的特点在于平台无关性。我们知道,计算机只认识0和1,所以无论我们写的任何程序,最终都会被翻译成0和1组成的机器码才能被计算机识别并运行。但由于最近十年计算机各个方向技术的突飞猛进,将我们编写的程序翻译成二进制本地机器码(Native Code)已不再是唯一选择,越来越多的语言选择了与操作系统和机器指令集无关的、平台中立的格式作为程序编译后的存储格式

那么,这么平台无关性是建立在什么基础上呢?

平台无关性实现在操作系统的应用层上。Sun公司及其虚拟机提供商发布了许多可以运行在不同平台上的虚拟机,这些虚拟机的输入都是字节码,输出则是各个平台不同的机器码。那么,只要我们使用Java编译器将源代码编译成符合虚拟机的输入规范.class文件(平台无关),虚拟机就会自动的将.class文件中的字节码翻译成本平台的机器码(平台有关)。本质上来说,是虚拟机完成了平台无关性的任务。即,通过虚拟机,屏蔽底层操作系统的差异

在Java发展初期,发布文档时都会有Java语言规范Java虚拟机规范,而发布Java虚拟机规范就是为了让更多语言实现平台无关性。只要其他语言的编译器将源代码编译成符合Java虚拟机规范的输入格式,就可以被虚拟机执行。比如下图:

接下来,就让我们揭开JVM平台平台无关性的入门条件——class类文件结构。

我大概翻了这一小节,主要是讲字节码的,就好像将IP数据报协议一样,每个字段代表什么含义、占几个字节,是非常琐碎的。所以我决定从整体上把握,先知道每个字段是什么含义。等到需要研究字节码的时候再仔细研究。

首先需要说明的是:

Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部都是程序运行的必要数据,没有空隙存在。当遇到需要占用8个字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8字节进行存储(大小端)。 根据Java虚拟机规范,Class文件采用一种类似于C语言结构体的东西来存储数据,里面有两种数据类型:

  • 无符号数:是基本的数据类型,以u1/u2/u4/u8代表1字节、2字节、4字节、8字节的无符号数。用来描述数字、索引引用、数量值,或者UTF-8的字符串值
  • 表:由多个无符号数或其他表作为数据项构成的复合数据类型,表习惯以_info结尾。用于描述有层次关系的复合结构的数据,比如整个Class文件本质上就是一张表

因为Class文件的结构没有任何分隔符,所以无论是顺序还是数量,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。下面就简单说明一下Class文件的结构:

  1. 魔数:每个Class文件的头4个字节成为魔数,唯一作用是用于确定这个文件是否为一个能被虚拟机接受的Class文件(其他类似的有字符编码的BOM头)。目前值为0xCAFEBABE
  2. 版本:紧接着魔数后4个字节为Class文件的版本号,5、6字节为次版本号,7、8字节为主版本号。
  3. 常量池:紧接着版本号的是常量池入口,常量池是Class文件结构中与其他项目关联最多的数据结构,也是占用Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目。因为常量池中常量数目不确定,所以入口是一个u2类型的数据,代表常量池容量。(从1开始计数,比如换算成十进制22,就代表有21个常量,0代表的是表达“不引用任何一个常量池项目”的意思。)
  4. 访问标志:紧接着常量池之后的2个字节代表访问标志,用于识别一些类或借口层次的访问信息,包括:这个Class是类还是借口;是否定义为public类型,是否定义为abstract类型;如果是类的话,是否被声明为final的等等。说白了,全是跟访问相关的数据。
  5. 类索引、父类索引与接口索引集合:类索引和父类索引都是u2类型的数据,而接口索引集合是一组u2类型的数据的集合,Class文件中由这三项数据来确定这个类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。然后接口索引就是implements(或者是接口extends其他接口)后的接口顺序从左到右排序在这个集合中
  6. 字段表集合
  7. 方法表集合
  8. 属性表集合

随着JDK的发展,Class文件结构却一直比较稳定,修修补补的主要是在访问标识、属性表这些在设计上本就可以扩展的数据结构中添加内容。

Class文件是Java虚拟机的入口,本章讲了Class文件结构中的各个组成部分,以及每个部分的定义、数据结构和使用方法。

 

posted @ 2021-12-11 22:26  CharyGao  阅读(20)  评论(0)    收藏  举报