c++ 结构体转字节码_JVM 类文件结构

96ee8aed03824d649a59889f88c6775f.png

本文记录了阅读《深入理解Java虚拟机:JVM高级特性与最佳实践 —— 周志明》的《类文件结构》章节的笔记。

4ab30a2d930e93f769d5348764df4da9.png

一、概述

代码编译结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。

在过去,我们写的程序需要经编译器翻译成由0和1构成的二进制格式才能由计算机执行,现在依然如此。

但是由于最近十年内虚拟机以及大量建立在虚拟机上的程序语言如春笋般出现并蓬勃发展,将我们编写的程序编译成二进制本地机器码(Native Code)已不再是唯一选择,越来越多的语言选择了与操作系统和机器指令集无关的、平台中立的格式作为程序编译后的存储格式。

Class文件是JVM虚拟机执行引擎的数据入口,也是Java技术体系的基础构成之一,了解Class结构对后面进一步了解虚拟机执行引擎有很重要的意义。

本章详解了Class文件的各个组成部分、定义、数据结构、使用方法。

二、无关性的基石

字节码(ByteCode)是各种不同平台的虚拟机与所有平台都统一使用的程序存储格式,是构成平台无关性的基石。而为什么本节标题里没有“平台”二字,因为虚拟机还有一种语言无关性也越来越被开发者所重视。

Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了JVM指令集符号表以及若干其他辅助信息。基于安全考虑,JVM规范要求在Class文件中使用许多强制性的语法和结构化约束,但任意一门功能性语言都可以表示为一个JVM所能接受的有效Class文件,JVM并不关心Class的来源是何种语言。

a8ae4b7e105f6144fb115bd5de97577c.png
JVM提供的语言无关性

Java语言中的各种变量关键字运算符号语义,最终都是由多条字节码命令组合而成的,因此字节码命令所能提供的语义描述能力肯定会比Java语言本身更加强大。因此,由一些Java语言本身无法有效支持的语言特性不代表字节码无法支持,也为其他语言实现了有别于Java的语言特性提供了基础。

三、Class类文件的结构

这一章主要是为了解析Class文件的数据结构,力求在保证逻辑准确的前提下,用尽量通俗的语言和案例去讲述虚拟机中与开发关系最为密切的内容。讲解数据结构不可避免的会比较枯燥,而该部分内容又确实是了解VM的重要基础之一,如果想比较深入地了解VM,那么这部分是不能不接触的。

本章中关于Class文件结构是以JDK1.4时代的JVM规范定义的为主线,该部分虽然古老,但是其指令、属性是Class文件中最重要和最基础的。同时也会对JDK1.5-1.7中添加的内容为支线进行较为简略、介绍性的讲解。

Class文件是一组以8位字节为基础单位的二进制流,各个数据项item严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用8位字节以上空间的item时,则会按照高位在前(该顺序称为“BIg-Endian”,SPARC、PowerPC处理器用该方式,而x86等使用相反的“Little-Endian”顺序。)的方式分割为若干个8位字节存储。

根据JVM规范,Class文件采用类似C语言结构体的伪结构存储数据,只有两种数据类型:无符号数

  • 无符号数:以u1、u2、u4、u8分别代表1、2、4、8个字节的无符号数,可以描述数字、索引引用、数量值、按照UTF8编码的字符串值。
  • 表:由多个无符号数或者其他表作为item构成的复合数据类型,所有表都习惯性的以“_info”结尾。它用于描述具有层次关系的复合结构数据,整个Class文件本质上就是一张,由以下item构成。

ab86c51ffb749022d149f1296d2be9f6.png
Class文件格式

无符号数和表,当需要描述同一类型但数量不定的多个数据时,会使用一个前置的容量计数器和若干个连续item的形式,称这些item为某一类型的集合。

值得一提的是,Class不同于XML等描述语言,因为没有任何分割符号,所以无论是顺序还是数量、甚至于数据存储的字节序(Byte Ordering,Class中的Big-Endian)细节,都是被严格限定的,不允许改变。下面看看这个表中各个item的具体含义。

1 魔数 和 Class文件版本

每个Class文件的头4个字节是魔数(Magic Number),唯一作用是确定该文件是否为一个能被VM接收的Class文件。用魔数而非扩展名是出于安全,因为扩展名可以随意更改。每个不同类型的文件都有它的魔数,Class文件的是一个有“浪漫气息”的值:0xCAFEBABE(咖啡宝贝)。

后续的4个字节是Class的版本号:第5、6字节是次版本号(Minor Version),第7、8个字节是主版本号(Major Version)。高版本的JDK可以向下兼容,但不能运行之后版本的Class,即使没有任何错误,VM也拒绝运行超过其版本号的Class文件。下面列出了JDK1.1-1.7的版本号。

a1dd2f164407f5e396eb3f8a7a96ce9f.png
Class文件版本号

2 常量池

接着的就是常量池入口,常量池是Class文件的资源仓库,是与其他item关联最多的数据类型,也是占用空间最大的item之一,也是第一个出现的表类型item。

由于常量池中常量的数量不固定,所以在入口需要放置一项u2类型的常量池容量计数器(constant_pool_count),该计数器从1开始而非0。如下图所示,该值为22,就代表常量池有21项常量,索引为1-21。空出第0项常量的目的在于,满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义。Class之中,只有该值的计数从1开始,其他集合类型还是从0开始。

5988a35c86793ff774f1ccd3cd97cf4c.png
常量池结构

常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量接近于Java语言的常量概念,如文本字符串、final常量等;符号引用则属于编译原理的概念,包括:类和接口的全限定名(Fully Qualified Name)、字段的名称和描述符(Descriptor)、方法的名称和描述符。

Java代码在做javac编译的时候,并不像C和C++有“连接”的步骤,而是在VM加载Class时做动态连接。也就是说,Class中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换就无法得到真正的内存入口地址,也就无法被VM使用。当VM运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。关于类的创建和动态连接,会在 类加载过程 中再详解。

常量池中每一项常量都是一个表,在JDK1.7之前有11种结构不同的表结构,而在1.7中,为了更好支持动态语言调用,额外增加了3种。将在 字节码执行和方法调用时详细讲解。

这14种表都有一个共同特点,就是表开始的第一位是一个u1类型的标志位 tag,代表当前常量属于哪种常量类型。其取值和意义如下表。由于这14种常量均有自己的结构,所以常量池是最繁琐的数据。

8cdaa53d5594516c96dea509c05fbf41.png
常量池的项目类型

由于Class文件中方法、字段都需要引用utf8 info常量来描述名称,所以该类型的最大长度也就是Java中方法、字段名的最大长度。这里的最大长度就是length的最大值,即u2最大值65535,所以如果Java程序中定义了超过64KB的英文字符变量或方法名,就无法编译。

常量池中的常量可以借助JDK的bin目录中的javap字节码分析工具来得到,调用 javap -verbose TestClass可以得到以下代码。

833d653e5bce411783453ce6e9ad8caf.png
使用Javap命令输出常量表

可以看到其中的I、V、init、LineNumberTable、LocalVariableTable似乎在代码中任何一处都没有出现,那它们从何而来?它们会被后面的字段表 field info、方法表 method info、属性表 attribute info引用到,用来描述一些不方便用“固定字节”进行表达的内容,比如返回值是什么、有几个参数、参数类型等。因为Java的类是无穷无尽的,无法通过简单的无符号字节描述每个方法用了什么类,因此在描述方法的这些信息时,需要引用常量表重的符号引用进行表达。下面给出14种常量的详细定义总结。

c4beb6b4d1156c0f7a136a7992e75da3.png
14种常量的总结 1

b969d3daf5bd1d729d90ef1f0398ef09.png
14种常量的总结 2

3 访问标志

常量池后面的2个字节是访问标志 access flags,用于识别一些类或接口层次的访问信息,包括:Class是类还是接口、是否为public、是否为abstract、是否为final。具体如下。access flags一共可以使用16个标志位,当前只定义了其中8个,没有使用的一切都为0。

3e0d3b80d917723db2ba1caad46bdf72.png
访问标志

4 类索引、父类索引、接口索引集合

类索引 this class 和 父类索引 super class都是一个u2类型数据,接口索引集合 interfaces 是一组u2类型数据的集合,Class文件种由这3项数据确定这个类的继承关系。

  • 类索引确定该类的全限定名。指向一个Constant Class info的累描述符常量。
  • 父类索引确定父类的全限定名。指向一个Constant Class info的累描述符常量。除了java.lang.Object之外,所有Java类都有父类,因此除Object之外,所有类的父类索引都不为0。
  • 接口索引集合用来描述该类实现了哪些接口,按implements或extends顺序从左到右排列。入口的第一项是u2类型的接口计数器 interfaces count,表示索引表的容量。如果未实现任何接口,则为0。

5 字段表集合

字段表 field info 用于描述接口或者类中声明的变量。字段 field 包括类级变量实例级变量包括方法内的局部变量。描述一个字段可以包括:作用域 public private protected、类变量 static、可变性 final、并发可见性 volatile、可序列化 transient、数据类型、字段名称。这些信息,修饰符都是bool值,适合用标志位表示;字段名称就用常量池的常量来描述。

f29c628478cc7e16f793cfd4da9cfadc.png
字段表结构

其中的access flags是字段修饰符,与类的access flag非常相似,详见下表。

283b1343783cc98d7377e814c1714aad.png
字段访问标志

这里解释一下简单名称、描述符、全限定名这三个字符串概念。

  • 全限定名:org/fenixsoft/clazz/TestClass 就是全限定名,仅仅将类全名的“.”替换成 “/”,为了让连续多个全限定名不混淆,会假如一个“;”表示结束。
  • 简单名称:表示没用类型和参数修饰的方法或字段名,例如 inc()方法和m字段的简单名称是 inc 和 m
  • 描述符:描述字段数据类型、方法参数列表和返回值。基本类型和void用大写字符表示,对象类型用L加全限定名表示。如下表所示。

48f55dc16642ca452601e959866b965a.png
描述符标识字符含义

对于数组类型,每一个维度将使用前置字符 “[”描述,如定义 :

  • 二维数组 java.lang.String[][],将被记录为“[[Ljava/lang/String;”
  • 整形数组 int[] 将记录为“[I”

方法的描述符顺序是,参数列表、返回值。参数列表按照参数顺序放在小括号内

  • void inc() 描述符为“()V”
  • java.lang.String toString() 描述符为“()Ljava/lang/String;”
  • int indexOf(char[]source, int sourceOffset, int sourceCount, char[]target, int targetOffset, int targetCount, int fromIndex) 描述符为“(CII[CIII)I”

字段表不会列出从超类或父接口中继承的字段,但可能列出原本Java代码中不存在的字段,比如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。另外,在Java中,字段无法重载,两个字段的类型和修饰符不管是否相同,都必须用不同名称;但是字节码中,如果两个字段描述符不一致,那么重名就合法。

6 方法表集合

Class文件中对于方法的描述和字段的描述几乎完全一致。包括如下信息。

15c280eccd61dd69f6e2ca37ed46eab3.png
方法表结构

由于volatile和transient无法修饰方法,而synchronized、native、strictfp、abstract可以修饰,其access flags有所变化,如下所示。

10fe54438f1952ccb674536e9a7f1aee.png
方法访问标志

至此,方法的定义都在方法表中,那么代码呢?代码经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性中,属性表是Class中最具扩展性的一种数据项目,在“7 属性表集合”中详细说明。

如果父类方法在子类中没用被重写 Override,则方法表集合中不会有父类方法信息,但有可能出现编译器自动添加的方法,典型的如类构造器“<clinit>”方法和实例构造器“<init>”方法。

Java中,重载 Overload 需要有相同的简单名称和不同的函数签名,不包括返回值,所以无法靠返回值对一个方法重载。但是Class文件格式中,只要描述符不是完全一致的两个方法,也可以共存,也就是说,返回值不同,也可以合法共存。

7 属性表集合

属性表 attribute info 已经在之前出现多次,在Class文件、字段表、方法表都可以有自己的属性表集合,描述某些场景的专有信息。

Class文件中,其他的item必须有严格的顺序、长度、内容,而属性表集合不需要有严格的顺序,只要不与已有属性名重复,任何编译器都可以向属性表中写入自己定义的属性信息,JVM会忽略它不认识的属性。JVM规范中定义了21项,如下所示。

317e030368ac4f699c8e34ee1f9b2df2.png
JVM规范预定义属性 1

7efb18bc6ae78b69111221ca6be45333.png
JVM规范预定义属性 2

对于每个属性,它的名称需要从常量池中引用一个 Constant Uft8 info类型来表示,属性值的结构可以完全自定义,只需要通过一个 u4的长度属性说明属性值的占用位数即可。一个属性表应满足以下结构。

c015a8fb1c28ff445322149d02cc2bf7.png
属性表结构

(1)Code属性

Java程序方法体经过编译后,变为字节码指令存储在Code属性内,出现在方法表的属性集合中,并非所有方法表都有该属性,如接口和抽象类的方法就没有Code。其结构如下所示。

fce3b0e10cd3ae92b54c8708c6472944.png

Java程序的信息可以分为 代码(Code,方法体的Java代码)和元数据(Metadata,类、字段、方法定义、其他信息)两个部分,那么整个Class文件中,Code属性就描述代码,其他item都是描述元数据。能阅读字节码也是分析Java代码语义问题的必要工具和基本技能。

对原始java代码

public class TestClass{
    private int m;
    public int inc(){
        return m+1
    }
}

用javap -verbose TestClass输出,有以下code字节码。

11d91fd366f743959a297dbeb3b41171.png
javap输出字节码指令

8ec938637748ad140df3a0c90e358111.png
Code属性结构实例
  • 2A aload_0:将第0个Slot的reference类型的本地变量推到操作数栈顶
  • B7 invokespecial:以栈顶的reference类型数据所指对象作为方法接收者
  • 000A 是invokespecial 的参数,查询常量池,其常量为<init>方法的符号引用
  • B1 return:返回此方法,返回值为 void,执行后,方法结束。

在本节中,书里第205-219页详解了11个常用属性,但是这里限于篇幅和时间并不打算列出,而是等有需要的时候再来查。书中详细讲了Code属性,它也是Class文件中最重要的一个属性,这里也就只提一下Code属性。

四、字节码指令简介

JVM指令由一个字节长度的、代表某种特定操作含义的数字(操作码 Opcode)以及跟随其后的零至多个代表此操作所需的参数(操作数 Operands)构成。由于JVM面向操作数栈而非寄存器,所以大多数指令都没有操作数,只有操作码。

字节码指令集特点鲜明、优劣突出。

  • 由于限制了其长度为1字节,意味着操作码总数不能超过256条;并且Class文件放弃了编译后代码的操作数长度对齐,意味着VM处理超过1字节的数据时,就必须在运行时从字节重建出具体的数据结构,比如有一个16位数,就只能是存为byte1和byte2,然后计算为 byte1 << 8 | byte2,这会导致在解释字节码时损失性能。
  • 但是优势也很明显,放弃了操作数长度对齐,就可以省略很多填充和间隔符;用1字节代表操作码,是为了获得短小精干的编译代码。这种追求小数据量、高传输率的设计是Java语言设计之初面向网络、智能家电的技术背景决定的,并一直沿用至今。

如果不考虑异常,JVM解释器可以使用以下伪码当做最基本的执行模型来理解,这个执行模型虽然简单,但依然可以有效工作。

9422158d92cec8bba9c694bc643db5e0.png
JVM解释器基本执行模型伪码

1 字节码与数据类型

JVM指令集中的大多数指令都包含了其对于的操作数类型信息。iload表示从局部变量表加载int数据到操作数栈。fload就加载float。其他还有:l long、s short、b byte、c char、d double、a reference。arraylength就没有数据类型。指令集与支持类型如下表所示。

a99f489e8f7a2baeb496ac201167f23f.png
JVM指令集所支持数据类型 1

c1d8ddc3b1de5c000c8fa4750988ba99.png
JVM指令集所支持数据类型 2

2 加载和存储指令

加载和存储用于将数据在栈帧的局部变量表和操作数栈之间来回传输。主要有:

  • 局部变量加载到操作栈:iload、iload_<n>(后续也有),lload、fload、dload、aload
  • 将数值从操作栈存储到局部变量表:istore、istore_<n>(后续也有)、lstore、fstore、dstore、astore
  • 常量加载到操作栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_ml、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>
  • 扩充局部变量表的访问索引指令:wide

3 运算指令

运算或算术指令用于对两个操作数栈的值进行运算,并将结果存入到操作栈顶。主要如下。

67d02016394a0c2ef01d73a118f53455.png

d7d23faf52559462e7c01c6fbf6a8e2d.png

4 类型转换

类型转换指令用于将不同的数字类型进行相互转换,一般用于实现显式类型转换。

宽化类型转换(Widening Numeric Conversions)都无需显示的转换指令:int到long、float、double;long到float、double;float到double。

窄化类型转换(Narrowing Numeric Conversions)必须使用转换指令,包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l、d2f。这些指令会导致产生不同的正负号,数量级,很可能导致精度丢失。

int和long窄化时,就简单的丢弃高位。浮点的窄化有以下规则:

017bed77a7d50f4748fdb8587eed9843.png

5 对象创建和访问

实例和数组都是对象,但是它们的指令不同。

273d0be3170a6c6ede17b2056500a613.png

6 操作数栈管理指令

操作数栈和普通栈一样,有一些常用方法,如下:

9942b709feeb26a95ae15442fe9abca4.png

7 控制转移指令

控制转移指令可以让JVM从指定位置从指定位置指令继续执行程序,可以被视作,控制转移指令在修改PC寄存器的值,主要如下:

00af2b1a5ab3c5d31fd2e9d7d660c4d1.png

8 方法调用和返回指令

具体有以下5条指令:

b93d0a93fcd0ae482050642632160b94.png

9 异常处理指令

Java程序中显式抛出异常的throw都由 athrow指令完成,catch是由异常表完成的。

10 同步指令

JVM可以支持方法级同步和方法内部一段指令序列的同步,都是用管程 Monitor 来支持的。

方法级同步是隐式的,无需字节码指令控制,在方法调用和返回操作中实现。

JVM指令集中由monitorenter和monitorexit来支持synchronized关键字的语义。

五、公有设计和私有实现

JVM规范描绘了JVM应有的共同程序存储格式:Class文件格式字节码指令集。它们与硬件、OS、JVM实现完全独立,它们是程序在各种Java平台实现之间互相安全交互的手段。

很有必要去理解公有设计和私有实现的分界线。JVM必须能读取Class文件并精确实现包含在其中的JVM代码的语义。而实现者可以在满足规范的情况下对实现做出一些修改和优化,只要优化的Class可以正确被读取,那么就可以使用任意方式去实现这些语义,虚拟机实现者可以利用这种伸缩性让JVM获得更好的性能、更低的内存消耗、更好的移植性。

虚拟机实现有两种方式:

  • 将输入的JVM代码在加载或执行时翻译成另外一种虚拟机的指令集
  • 将输入的JVM代码在加载或执行时翻译成宿主机本地CPU的本地指令集(JIT代码生成技术)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值