JVM 概述

发布于 2024-09-21 12:21:32 字数 20865 浏览 9 评论 0

Java 是一种技术,由四方面组成:Java 编程语言、Java 类文件格式、Java 虚拟机和 Java 应用程序接口(Java API)

java-part

在编译环境中,使用 Java 语言编写 java 文件,然后编译为.class 字节码文件。到了 Java 平台运行环境中,字节码被装入内存后进入 JVM 虚拟机,它就会被解释器解释执行或被即时代码编译器(JIT) 有选择的转换成机器代码(缓存在系统中) 运行。JVM 通过移植接口在具体的平台和操作系统上实现,使程序与操作系统和硬件无关,在 JVM 上方的则是 Java 的基本类库和扩展类库以及它们的 API,最上方就是我们用 Java API 编写的应用程序,达到与平台无关性,虚拟机为我们做了将中间语言(字节码指令) 进行翻译和执行的工作jvm

翻译与执行

将中间语言直接翻译为机器码,追根刨底就是利用了 CPU 执行代码的原理。要让 CPU 执行一段代码,只需将 CS:IP 段寄存器指向到代码段入口 即可,CS 和 IP 分别是物理 CPU 内部的两个寄存器,CPU 在取指令时便完全依靠这两个寄存器。

CS 寄存器保存段地址,IP 保存偏移量,那么 CS 和 IP 的值就能唯一确定内存中的一个地址,在 CPU 执行机器指令之前,变通过这两个寄存器定位到目标内存位置,并将该位置处的机器指令取出来进行运算。函数跳转的本质便是修改 CS 和 IP 这两个寄存器的内容,使其指向到目标函数所在内存的首地址,这样 CPU 便能执行目标函数了。Java 虚拟机想要让物理 CPU 直接执行 Java 程序所对应的目标机器码,也得修改这两个寄存器才能实现

修改 CS:IP 段寄存器既可以使用汇编直接修改,也可以在高级语言中使用语法糖的形式修改,在 C 语言中就能通过定义函数指针实现间接修改 CS:IP 段寄存器的指向,因此可以在 C 语言中直接动态执行机器码。因此只需要将中间语言指令直接翻译成 机器码 ,然后让 CS:IP 段寄存器直接指向这段机器码,就有能力实现将中间语言翻译成机器码并动态执行的目标(JVM 中并不是完全这么做的)

虽然将中间语言直接翻译为机器码并直接运行,效率比使用 C 语言来解释执行提高了很多,但是由于中间语言有自己的一套内存管理和代码执行方式,因此执行同样的功能,虽然使用中间语言只需几行代码,但是翻译后的机器码比直接编写机器码还要多很多指令,指令数量增多,那么执行成本就会上升。即使与同样属于高级语言的 C 语言相比,其编译后所产生的的机器码也比中间语言直接翻译的机器码,数量上要精简的多。

由此可见中间语言由于本身不能直接被 CPU 执行,为了能够被 CPU 执行,中间语言在完成同样的功能时,需要 准备更多便于自我管理的上下文环境 ,最后才能执行目标机器指令,准备上下文环境最终也是依靠机器码去实现的,所以便生成了更多的机器码。为了能进一步提升性能,JVM 提供了一种机制,能将中间语言(字节码) 直接编译为 本地机器指令

除此之外 JVM 在 JIT(即时编译)、内存分配等方面倾注大量心血,能够对热点代码进行大幅度指令优化,将 Java 执行效率大幅提升。正是由于 JVM 可以在运行期基于上下文链路进行各种优化,因此动态优化后的指令质量比 C/C++编译出的指令质量更高,使部分 Java 程序性能反超 C/C++

既然中间语言在运行期能被逐个直接翻译为机器码,那么在编译期,现在本地编译成字节码,再将字节码逐个替换为机器指令,不就最终变为可以直接被 CPU 执行的、由机器码组成的程序了。这个思路在安卓和部分 JVM 所实现的 AOT(ahead of time) 特性便是这方面的尝试,但是这种方式并没有减少机器指令的数量级问题

说了这么多,Java 本身并不是中间语言,所以虚拟机读不懂 Java 语言,因此 Java 程序还需要再进行一次翻译,成为 Java 字节码指令集。Java 语言是面向对象的,而 Java 指令并不知道什么是对象,只知道压栈、读写局部变量表、调用目标方法等。在 JVM 源码中,定义了 Java 语言的全部指令集,由于 Java 的所有指令都使用 8 位二进制描述,因此 Java 的总指令数不超过 255 个

常见汇编指令

数据传输指令 :主要在寄存器与内存、寄存器与输入/输出端口之间传输数据

// 将自然数 1 传送到 eax 寄存器
mov1 1, %eax

// 将栈顶数据弹出至 eax 寄存器
pop %eax

算术运算指令 :包括基本四则运算、浮点运算、数学运算等

// 将自然数 3 与 eax 寄存器中的数累加,并将结果存储进 eax 中
add 3, %eax

// 对 eax 寄存器中的数增加 1
inc %eax

逻辑运算指令 :与、或、非、左移、右移等指令

// 将 eax 中的数左移 1 个二进位
shl %eax, 1

// 对 al 寄存器中的数和操作数进行与运算
and al, 00111011B

串指令 :连续空间分配,连续空间取值,传送等,都需要使用串命令,很多高级语言都支持字符串运算

程序转移指令 :if..else 判断、for 循环、while 循环、函数调用等,都需要依靠程序转移指令,常见的程序转移指令包括 jmp 跳转、loop 循环、ret 等

JVM 指令

数据交换指令 : JVM 内存分为操作数栈、局部变量表、Java 堆、常量池、方法区,那么必须要有指令在支持数据在这些内存区域之间传送和交换。比如在 Java 方法中访问一个静态变量时,其运算过程必然伴随 JVM 将数据从常量池传送到操作数栈的指令调用。在硬件上直接执行的程序,其内存一般分为寄存器、数据段、堆栈、常量区、代码段,CPU 为了完成运算,必然要涉及将数据从这些内存区域传送到寄存器的指令调用。

而 JVM 执行逻辑运算的主战场是 操作数栈 (iinc 指令除外,该指令可以直接堆局部变量进行运算),不管数据在堆栈中,还是在常量池,需要执行运算时,JVM 最终都会将数据传送到操作数栈中,对比硬件执行逻辑运算的主战场是在 寄存器 ,不管将数据放在数据段还是代码段,最终 CPU 都将数据传送到寄存器中。

逻辑运算完成后,再把结果移出去。例如 iload、istore、lload、lstore、fload、fstore、dload、dstore、ldc、bipush 等指令来实现操作数栈与局部变量表之间的数据交换,getfeild、putfeild 来实现 java 堆中的对象的字段与操作数栈之间的数据交换,getstatic、putstatic 来实现类中的字段与操作数栈之间的数据交换,baload、bastore、caload、castore 来实现堆中数组与操作数栈之间的数据交换

函数调用指令 : 可以归纳到程序转移指令集中。由于 Java 中函数类型比较丰富,因此要支持更多的函数调用方式,比如函数调用指令 invokevirtual、invokeinterface、invokeespecial、invokestatic、return 等,这比硬件所支持的函数调用指令集要丰富。

比如 X86 中主要用 call 指令和 ret 指令来保存现场和恢复现场,往往伴随 CPU 物理寄存器入栈和出栈。而 JVM 没有物理寄存器,所以用操作数栈和 PC 寄存器来替代,保存现场和恢复现场则是向 Java 堆栈中亚茹一个栈帧,函数返回的时候从 Java 堆栈中弹出一个栈帧。而且 JVM 调用函数的时候,不能像 CPU 那样直接能找到对应的代码段,因为 Java 函数的代码并没有被存放到代码段中,而是被放在了一个 code 缓存中,每一个 Java 函数的代码块在这个 code 缓存中都会有一个索引位置,最终 JVM 会跳转到这个索引位置处执行 Java 函数调用。同时 Java 的函数一定是封装在类中的,因此 JVM 在执行函数调用时,还需要通过类寻址等一系列运算最终才能定位这个入口

运算指令集 : JVM 与运算相关的指令集主要有算术运算、位运算、比较运算、逻辑运算等,JVM 还为各种基本类型的运算提供不同的操作码。像 X86 也有算术运算、位运算等,但是所有操作都是直接针对寄存器中的二进制数进行的,不区分数据类型

控制转移指令 : 与 CPU 一样,JVM 规范提供了常见的控制转移指令,比如 switch、if..else、for 循环、while 循环、return 返回、break 中断、continue 继续等

对象创建与类型转换指令 : 对于创建对象指令,在语法层面使用的关键字 new 实例化对象,对应字节码指令也是 new。除此之外还有窄化类型转换、抛出异常的指令等

Java 数据结构

Java 选择使用了 字节码中间语言 这条技术路线,程序是 算法与数据结构 的有机结合体,不管是算法还是数据结构都需要被物理机器所理解,任何一门编程语言,构成算法基础的指令都会被还原为机器指令,只有物理机器才有能力执行各种各样的算法指令。虽然广义的数据结构是一种抽象概念,但在工程实践上,数据结构必然需要由一种具体的编程语言去实现,其实现背后依然是物理机器在支撑,离不开机器指令。Java 使用了 面向对象 的思想,对 JVM 来说,所有数据结构都是 Class,或者反过来说 Java 中的 Class 都是一种专门的数据结构,整个 Java 程序都由数据结构所组成,Java 算法也由数据结构的动作所驱动,所以 数据结构可以说是 Java 的核心

数据结构与物理机器之间有千丝万缕的联系,程序算法告诉物理机器应该怎么做,而数据结构则告诉物理机器拿什么去做,而数据结构的实现需要依赖数据类型的支持。任何一种编程语言最终都能实现任何一种复杂的数据结构,因为最终都是依靠机器指令实现的,区别在于编程语言所支持的数据类型越多,则实现复杂数据结构的难度和成本就越小。C 语言为了实现”数据结构”的可视化,定义了”结构体”这种类型,而且能嵌套使用,从而定义出多维度的结构。但是 C 语言中的数据结构对物理机器是有依赖的,在源代码被编译后便产生了,被转换成了对应平台上的机器指令,因此被物理机器直接识别并运行,原本 C 语言中的结构体数据类型被彻底抹去,打回最原始的类型,因此说 C 语言的数据结构依赖于特定平台上的特定编译器。而 Java 语言则完全不同,编译器在编译时能准确分析出 Java 的类型信息,类型信息通过字节码进行了格式化,但是这种编译后的、格式化的数据类型并不能直接被物理机器所识别、直接在内存中构建出对应的结构体,因此 Java 语言的类型信息并不完全在编译期维护,而是推迟到了运行期

C++/Delphi 等编程语言也具有面向对象特性,但是到了运行期就已经完全消除了类型概念,无法在运行期反射到类型的成员变量、方法等信息。而 JVM 加载 Java 类的时候,将 Java 类的类元信息保存到了内存中,这样运行期直接读取目标内存中的数据便能获取到相应的信息,这种类元信息,其实就是一种打包好的数据结构模板,并且在运行中可被识别。类型本身就是一种”闭包”的技术手段,Class 是实现将若干属性和动作打包成一个整体对象进行统一识别的策略。而当一门语言实现了完全的闭包语法策略(使用类型包装可以认为是闭包的一种),便自然而然具备了自动内存管理的技术基础,或者说实现自动内存管理更加容易。所以闭包便成为很多具备自动内存回收特性的编程语言的语法基础,例如 GO、Python、JavaScript 等。因此 Java 选择面向对象编程和内存管理模型(数据结构总是与内存管理机制联系在一起) 是一种选择的最终结果

总体而言,Java 的数据结构的实现机制是, 编译时变成字节码,运行期实现

Java 类型识别

Java 类在编译期生成的字节码有 特定的组织规律 ,因此 JVM 虚拟机在加载类时,对编译器生成的字节码信息按照固定格式进行解析,一步步解析出字节码中所存储的类型结构信息,从而在运行期完全还原出原始的 Java 类的全部结构

class 字节码文件中,数据都是以 二进制流 的形式存储的,这些字节流之间按夜歌的规定顺序排列,字节之间不存在任何空隙,对于超过 8 位的数据,将按照 Big-Endian(大端) 的顺序存储,即高位字节存储在低的地址上面,而低位字节存储到高地址上面,这也是 class 文件跨平台的关键,因为 PowerPC 架构的处理器采用 Big-Endian 的存储顺序,而 X86 系列处理器采用 Little-Endian(小端) 的存储顺序,因此为了 class 文件能在各种异构处理器架构下能保持统一的存储顺序,虚拟机必须设置统一的存储规范

class 字节码文件采用类似 C 语言的结构体来存储数据,主要由两类数据项: 无符号数和表 。无符号数用来表述数字、索引引用和字符串等,比如 u1、u2、u4 和 u8,分别代表 1、2、4、8 字节。而表是由多个无符号数以及其他的表组成的复杂结构

一个 class 字节码文件由 10 个部分组成:MagicNumber、Version、Constant_pool、Access_flag、This_class、Super_class、Interfaces、Fields、Methods、Attributes。这些数据的类型和长度都是不同的,例如魔数占用 4 个字节,在 JVM 内使用 u4 这种自定义数据类型存放 typedef juint u4 ,juint 也是自定义类型,与平台相关,在 linux 上便定义为 typedef uint32_t juint ,而 uint32_t 仍然是自定义类型,只是一个按照 POSIX 标准的别名,在 linux 上代表的 C 语言类型是 unsigned int

常量池与 oop-klass 模型

常量池是 Java 字节码文件中比较重要的概念,是整个 Java 类的核心所在,因为常量池中记录了一个 Java 类的所有成员变量、成员方法、静态变量、静态方法、构造函数等全部信息,包括变量名、方法名、访问标识、类型信息等。JVM 内部定义了一个 C++类型 constantPoolOop 来记录解析后的常量池信息,这是个别名,其原始类型为 constantPoolOopDesc,JVM 内部为了在运行期描述 Java 类的类型信息和内部结构,定义了很多以 Desc 结尾的 oop 类,详细在/src/share/vm/oops/oopsHierarchy.hpp 文件内

JVM 内部对 Java 对象的表现模型便是 “oop-klass”模型 。Hotspot 虚拟机在内部使用两组类来表示 Java 的类和对象: 1、 oop (ordinary object pointer),用来描述对象实例信息 2、 klass ,用来描述 Java 类,是虚拟机内部 Java 类型结构的对等体 JVM 内部定义了各种 oop-klass,在 JVM 看来,不仅 Java 类是对象,Java 方法也是对象,字节码常量池也是对象,一切皆对象。JVM 使用不同的 oop-klass 模型来表示各种不同的对象,这些不同的模型就使用不同的 oop 类和 klass 类来表示,由于 JVM 使用 C/C++编写,因此这些 oop 和 klass 类便是各种不同的 C++类。对于 Java 类型与实例对象,JVM 使用 instanceOop 和 instanceKlass 这 2 个 C++类来表示

根据 oop 与 klass 体系的定义,可以发现 oop 与 klass 基本被划分来分别描述 instance、method、constaneMethod、methodData、array、objArray、typeArray、constantPool、constantPoolCache、klass、compoiledICHolder 这几种模型,每种模型对应一个 xxxOopDesc 和对应的 xxxKlass,即分别用来描述 Java 类类型和类型指针、Java 方法类型和方法指针、常量池类型及指针、基本数据类型的数组类型及指针、引用类型的数组类型及指针、常量池缓冲类型及指针、Java 类实例对象类型及指针。HotSpot 认为使用这几种类型就能勾画出 Java 程序的全部:数据、方法、类型、数组和实例

oop-klass

oop 其实就是普通对象指针,指向 klass 类实例。比如 ClassA a = new ClassA() ,HotSpot 在执行到这里时,会先将 ClassA 这个类型加载到方法区,然后在堆中为其实例对象 a 开辟一块内存空间存放实例数据,在 JVM 加载 ClassA 到方法区时,JVM 就会创建一个 instanceKlass,其保存了 ClassA 这个 Java 类定义的一切信息,随意 intanceKlass 就是 ClassA 这个 Java 类类型结构的对等体。而 instanceOop 这个”普通对象指针”对象中包含了一个指针,指向 instanceKlass 这个实例,在 JVM 实例化 ClassA 时 JVM 又会创建一个 instanceOop,通过这个指针,JVM 便可以在运行期获取这个类实例对象的类元信息

oopDesc 类是虚拟机核心数据结构:

class oopDesc {
private:
    volatile markOop _mark;
    union _metadata {
        Klass* _klass;
        narrowKlass _compressed_klass;
    } _metadata;
    // ...
}

除去友元类 VMStructs 以及用于内存屏障的_bs,oopDesc 重要的两个成员变量_mark 和_metadata。 _mark 是一种标记,Java 类在整个生命周期中,涉及到线程状态、并发锁、GC 分代信息等内部表示都打在_mark 变量上。而 _metadata 用于标识元数据(Java 类的结构信息),起到指针的作用,指向 Java 类的数据结构被解析后所保存的内存位置

扩展: OpenJDK9 Hotspot :oops,klass 与 handleHotSpotVM 对象机制实现浅析#1 JVM 源码分析之 Java 类的加载过程 JVM 源码分析之 Java 对象的创建过程

大端与小端

数据在不同平台寄存器、内存、硬盘上的存储格式不同 ——— 数据存储顺序不同,这种不同的存储顺序衍生了计算机底层两个概念:大端与小端

大端和小端的概念是由一位网络协议开创者提出的,因为只有在跨平台以及网络程序中才会涉及到一个叫做”字节序”的问题,这也是一个必须被考虑的基础问题。字节序就是指字节的顺序,就是数值大于一个字节类型的数据在内存中存放的顺序。在各种计算机体系结构中,对于字节、字的存储机制各有不同,通信双方交流的信息单元(比特、字节、字、双字等) 的存储顺序不同,因此需要考虑双方数据传送顺序,如果传送顺序达不成一致,通信双方将无法进行正确的编码解码从而导致通信失败。目前在各种体系计算机中通常采用的直接存储机制主要有两种:Big-Endian 和 Little-Endian。大端为高位字节存储在内存的低地址上面,而低位字节存储到内存的高地址上面。小端正好相反,低位字节排放在内存的低地址端,高位直接排放在内存的高地址端

在计算机体系结构中,内存由存储单元构成,一个存储单元的长度是 1 个字节,即每个存储单元都对应着 1 个字节,能够存储 8bit 数据。但是 C 及其他高级语言中除了 8bit 的 char 外,还有 16bit 的 short,32bit 的 int 和 64bit 的 long(int 与 long 看具体编译器和 CPU 平台架构),那么物理内存的存储单位是 1 字节,而现代计算机总线线宽和寄存器的宽度都大于 1 个字节,这就造成存储器宽度与内存存储单元宽度之间不一致。而软件程序中很多操作都涉及数据在内存和寄存器之间的传送,因为计算机不允许数据直接在内存之间传送,更不支持数据直接从内存传送到外部设备,唯一支持不同部件之间直接数据传送的只有寄存器。由于高级语言中不能直接操作寄存器,所以所有的数据传送的指令以及针对寄存器读写的指令都被封装成面向变量的编程,而变量的存储介质是内存,所以 高级编程语言中所有数据传送指令都必须经过寄存器的中转 。寄存器的宽度越大,就意味着 CPU 传送数据的能力越强,对于一个数据来说本身是有高字节与低字节区分的,而一个寄存器也会区分高低,那么由于不同厂家定义的 CPU 标准不同,寄存器左端为高位还是右端为高位没有统一的标准。所以大小端的问题,本质上是由 寄存器引起的

虽然大端小端问题存在于内存、寄存器、计算机总线甚至软件中,但是得益于整个软硬件架构的良好设计,无论往内存中写入还是读取数据,由于采用的是同一套标准,要么全是大端模式,要么全是小端模式,所以不会产生大小端数据转换的问题。但是当网络传输和文件共享时,由于数据在网络的一端写入,另一端读取,网络两端的 CPU 架构并不总是相同,很可能出现大小端不同的场景,这时不进行大小端转换,数据必定会出现不一致。在遇到这个问题时,就必须处理大小端问题,其中在 linux 平台可以调用 bswap 指令进行字节序反转

大小端问题不仅出现在计算机硬件体系中,软件中也同样存在,绝大多数下都被编译器处理了,Java 所输出的直接信息全部是大端模式,比如对于魔数,字节码文件的写入顺序一定是 0xCA 0xFE 0xBA 0xBE,这种写入顺序不受计算机硬件大小端影响,这是 Java 与其他编程语言比如 C 的一个重要区别,因为 C 在将 int 类型魔数写入字节码文件之前,需要将魔数信息写入变变量,由于硬件体系关系,从寄存器写入内存时可能魔数的字节序已经发生反转。但是对于 Java 来说,Java 编译器一般由 Java 开发,因此字节码文件写入由 Java 语言完成,而读取字节码文件的 JVM 一般由 C/C++混合写成,因此 Java 字节码的写入与读取端属于两种不同的编程语言,因此 引起 Java 字节码的字节序读取不一致是读取端编程语言的大小端导致的 ,这时就需要对字节序进行反转的兼容性处理

Java 字节码

java-byte-code

例子:

public class Test {
    public int a = 3;
    static Integer b = 6;
    private String c = "test";

    public static void main(String[] args) {
        Test test = new Test();
        test.a = 8;
        b = 9;
    }

    private void test() {
        this.a = 4;
    }
}

使用 javac 编译后,通过命令 javap -verbose Test.class 分析字节码信息:

Classfile /D:/workspace/Test.class
  Last modified 2019-3-11; size 608 bytes
  MD5 checksum 481e800a4bba6533c59c0c931cb49026
  Compiled from "Test.java"
public class Test
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #9.#26         // java/lang/Object."<init>":()V
   #2 = Fieldref           #5.#27         // Test.a:I
   #3 = String             #22            // test
   #4 = Fieldref           #5.#28         // Test.c:Ljava/lang/String;
   #5 = Class              #29            // Test
   #6 = Methodref          #5.#26         // Test."<init>":()V
   #7 = Methodref          #30.#31        // java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
   #8 = Fieldref           #5.#32         // Test.b:Ljava/lang/Integer;
   #9 = Class              #33            // java/lang/Object
  #10 = Utf8               a
  #11 = Utf8               I
  #12 = Utf8               b
  #13 = Utf8               Ljava/lang/Integer;
  #14 = Utf8               c
  #15 = Utf8               Ljava/lang/String;
  #16 = Utf8               <init>
  #17 = Utf8               ()V
  #18 = Utf8               Code
  #19 = Utf8               LineNumberTable
  #20 = Utf8               main
  #21 = Utf8               ([Ljava/lang/String;)V
  #22 = Utf8               test
  #23 = Utf8               <clinit>
  #24 = Utf8               SourceFile
  #25 = Utf8               Test.java
  #26 = NameAndType        #16:#17        // "<init>":()V
  #27 = NameAndType        #10:#11        // a:I
  #28 = NameAndType        #14:#15        // c:Ljava/lang/String;
  #29 = Utf8               Test
  #30 = Class              #34            // java/lang/Integer
  #31 = NameAndType        #35:#36        // valueOf:(I)Ljava/lang/Integer;
  #32 = NameAndType        #12:#13        // b:Ljava/lang/Integer;
  #33 = Utf8               java/lang/Object
  #34 = Utf8               java/lang/Integer
  #35 = Utf8               valueOf
  #36 = Utf8               (I)Ljava/lang/Integer;
{
  public int a;
    descriptor: I
    flags: ACC_PUBLIC

  static java.lang.Integer b;
    descriptor: Ljava/lang/Integer;
    flags: ACC_STATIC

  public Test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_3
         6: putfield      #2                  // Field a:I
         9: aload_0
        10: ldc           #3                  // String test
        12: putfield      #4                  // Field c:Ljava/lang/String;
        15: return
      LineNumberTable:
        line 1: 0
        line 2: 4
        line 4: 9

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #5                  // class Test
         3: dup
         4: invokespecial #6                  // Method "<init>":()V
         7: astore_1
         8: aload_1
         9: bipush        8
        11: putfield      #2                  // Field a:I
        14: bipush        9
        16: invokestatic  #7                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
        19: putstatic     #8                  // Field b:Ljava/lang/Integer;
        22: return
      LineNumberTable:
        line 7: 0
        line 8: 8
        line 9: 14
        line 10: 22

  static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: bipush        6
         2: invokestatic  #7                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
         5: putstatic     #8                  // Field b:Ljava/lang/Integer;
         8: return
      LineNumberTable:
        line 3: 0
}
SourceFile: "Test.java"

通过 UltraEdit 打开 Test.class,每行 16 个字节,用十六进制显示:test-16

魔数 MagicNumber

所有.class 字节码文件前 4 个字节都是魔数,且值一定是十六进制数值 0xCAFEBABE。如果起始 4 个字节不是则 JVM 将认为该文件不是.class 字节码文件,而拒绝解析加载该文件,从而防止加载非 class 文件而造成虚拟机崩溃

版本号 Version

根据字节码文件规范,魔数之后的 4 个字节为版本信息,前两个字节表示 minor version,即 次版本号 ,后两个字节表示 major versoin,即 主版本号 。在这里我们的次版本号为 0,主版本号为 0x0034 对应 52,目前的 version 为 1.8(0 52),因为我本地用的是 JDK1.8,javac 决定了 class 文件的版本号,当用 java.exe 运行字节码文件时,首先检查字节码文件的版本号。当字节码文件版本号对应的 JDK 版本小于等于 java.exe 的版本时可以正常运行,软件兼容,反之不行,会报错

常量池 Constant_pool

一个 Java 类中绝大多数的信息都由常量池描述 ,尤其是类中定义的变量和方法,都由常量池保存,其主要由常量池数量和常量池数组两部分组成,常量池数量紧跟在版本号后面,占 2 个字节;常量池数组则跟在常量池数量后面,其由多个元素组成,常量池数组中不同元素的类型、结构都是不同的,长度也是不同的,但是每一种元素的第一个数据都是一个 u1 类型,该字节为标志位,占用 1 个字节。JVM 解析常量池时,根据这个 u1 类型来获取该元素的具体类型

常量池不同元素的结构与类型不同,JVM 定义了 元素类型 ,并针对类型进行专门的解析。例如 CONSTANT_Class_info(7:表示类或接口)、CONSTANT_Fieldref_info(9:字段信息表)、CONSTANT_Methodref_info(10:方法)、CONSTANT_String_info(8:java.lang.String 类型的常量对象)、CONSTANT_MethodHandle_info(15:方法句柄表) 等等,具体可以在网上找到定义,不同元素类型的结构比如:

// CONSTANT_Class_info    表示类或接口
CONSTANT_Class_info {
    u1 tag;    // 7
    u2 name_index;    // 指向全限定名常量项的索引
}

// CONSTANT_Fieldref_info
CONSTANT_Fieldref_info {
    u1 tag;    // 9
    u2 class_index;    // 指向声明字段的类或接口描述符 CONSTANT_Class_info 的索引项
    u2 name_and_type_index;    // 指向字段描述符 CONSTANT_NameAndType_info 的索引项
}

首先看本例中在版本号后的是 0x0025,即常量池长度 37,JVM 规定不使用第 0 个元素,那么实际上一共有 36 个常量池元素。后面跟的是 0x0A,即 10 类中方法的符号引用,由 u1:tag+u2:index+u2:index 组成,则为 1+2+2 个字节,即 0x0A,0x0009,0x001A

访问标识 Access_flag

保存 当前类的访问权限 ,结构类型是 u2,图中 140h 找到,0x0021,即 0x0001 为 public,0x0020 允许使用 invokespecial 字节码指令(JDK1.2 以后编译该标志为真)

继承信息 This_class、Super_class、Interfaces

This_class 保存 当前类的全局限定名在常量池里的索引 ,结构类型是 u2,在访问标识后面的为 0x0005,通过上面使用 javap -verbose 打印出来的常量池信息,找到#5 为#29,就是 Test,即类的全限定名就是 Test

Super_class 保存 当前类的父类的全局限定名在常量池里的索引 ,结构类型是 u2,即 0x0009,同样查找#9 得知为#33,即 java/lang/Object,显然没有显示继承任何基类,编译时默认继承 java.lang.Object

Interfaces 保存 当前实现的接口列表 ,包含 interfaces_count 和 interfaces[interfaces_count],即实现接口数量和这些接口的全局限定名的索引数组,interfaces_count 的结构类型是 u2,即接口数量为 0x0000,所以说明没有实现任何接口,因此字节码文件中没有 interfaces 信息

字段信息 Fields + attributes

Fields 保存 当前类的成员列表 ,包含 fields_count 和 fields[fields_count],即类变量、实例变量的字段总数和字段详细信息的列表。fields_count 的结构类型是 u2,即 0x0003,有 3 个成员变量

fields 结构长度不确定,不同的变量类型所占的长度是不同的,该记录中定义了各个变量的详细信息,包括变量名、变量类型、访问标识、属性等:

field_info {
    u2 access_flags;    // 标识变量访问标识,值是可选的:public、private、protected、static、final、volatile、transient、是否编译器自动产生、是否为 enum
    u2 name_index;    // 变量的简单名称引用,其值指向常量池的索引
    u2 descriptor_index;    // 变量的类型信息引用,其值指向常量池的索引
    u2 attributes_count;    // 属性数量
    attribute_info attributes[attributes_count];
}

看第一个 0x0001,即 public 的;第二个 0x000A,即常量池#10 变量名为 a;第三个 0x000B,即常量池#11 数据类型为 I(对应基本类型 int);第四个 0x0000,即变量 a 的属性数量是 0,字节码文件中不包含 attributes 信息,因此第一个变量只占用了 8 个字节。后面 2 个也是同样的分析方法

方法信息 Methods + attributes

Methods 保存 当前类的方法列表 ,包含 methods_count 和 methods[methods_count],即该类或接口显示定义的方法总数和方法信息的详细列表。methods_count 的结构类型是 u2,即 0x0004,Test 类有 4 个方法。然而我们只写了 2 个方法,那是因为编译期间,编译器会自动为一个类增加 void () 这样的方法,作用主要是执行类的初始化,源程序中所有 static 类型的变量和被 static{}包围的程序都在这个方法中被初始化和执行。另外由于没有定义构造方法,所以编译器会自动添加一个默认的构造方法

method_info 的结构与 field_info 一样,仅在访问标志位和属性表集合的可选项有所不同。那么来看第一个 0x0001,即 public 方法;第二个 0x0010,即常量池#16 方法为;第三个 0x0011,即常量池#17 描述信息为()V,表示没有入参返回 void;第四个 0x0001,即当前方法包含 1 个属性;对于 attributes 结构简略来说包含 9 大属性:Code、ConstantValue、Deprecated、Exceptions、InnerClasses、LineNumberTale、LocalVariableTable、SourceFile、Synthetic,而每一种属性又都是一个复合结构,均有各自的表结构

比如看第一个值 0x0012,即常量池#18 表示 Code 方法表,后面跟的是 u4 类型的 attribute_length,即 0x00000030,后面跟的又是 u2 类型的 max_stack 和 u2 类型的 max_locals,即 0x0002 和 0x0001,再后面的是 u4 类型的 code_length,即 0x00000010,之后是 code 属性, 真正开始描述 Java 方法所对应的字节码指令 ,这是 Java 的精华所在,其长度由前面的 code_length 决定,因此这 16 位字节码值是 0x2A B7 00 01 2A 06 B5 00 02 2A 12 03 B5 00 04 B1。

JVM 是基于栈的指令集系统,其设计的指令仅占 1 字节,因此最多描述 256 种指令,JVM 的指令总数只有 200 多个,同时 JVM 的指令属于一元操作数类型,其后面只有一个操作数(很多指令后不跟操作数,比如 return),因此 JVM 指令需要区别对待,有些字节是 代表指令 ,但是有些则 代表数据(操作数)

下面分析当前方法的指令逻辑:

  • 第一个字节一定代表指令,不会是操作数,这里是 0x2A,查询 JVM 指令集为 aload_0(将第 0 个引用类型本地变量推送至栈顶);
  • 第二条指令为 0xB7,查询为 invokespecial (调用超类构造方法,实例初始化方法,私有方法),由于是一元指令,后面跟的是 u2 类型的操作数,即 0x0001 表示常量池 #1 方法 java/lang/Object.”":()V;
  • 第三条指令为 0x2A 和第一条指令一样;
  • 第四条指令为 0x06,查询为 iconst_3(将 int 型 3 推送至栈顶);
  • 第五条指令为 0xB5,查询为 putfield(为指定的类的实例域赋值),那么看后面的操作数 0x0002,即常量池 #2 操作数 Test.a:I;
  • 第六条指令还是 0x2A;第七条指令 0x12,查询为 ldc(将 int, float 或 String 型常量值从常量池中推送至栈顶),那么看操作数 0x03,即常量池 #3 为 String test;
  • 第七条指令为 0xB5,操作数为 0x0004,即常量池 #4 为 Test.c:Ljava/lang/String;
  • 最后第八条指令为 0xB1,查询为 return(从当前方法返回 void)。其实这就是对应了通过 javap -verbose 打印出来的 public Test() 下的描述

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

俏︾媚

暂无简介

0 文章
0 评论
21 人气
更多

推荐作者

风暴

文章 0 评论 0

乐玩

文章 0 评论 0

英雄似剑

文章 0 评论 0

秋风の叶未落

文章 0 评论 0

luoshaoja

文章 0 评论 0

吴彦文

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文