国庆节快乐 2024年10月2日17:49:22
目录
前言
magic 数
文件版本
使用JClassLib观察class文件
一般信息
接口
常量池
字段
方法
常量池计数器
常量池
类型
CONSTANT_Methodref_info
CONSTANT_Class_info
类型结构总表
访问标志
类索引, 父类索引与接口索引
字段表
方法表
属性表
作者@NICEFF_KING, 文中描述有错误的地方请在评论区不吝教诲
前言
对于Java的各种版本来说, 一般都遵从这个特点, 高版本的JDK是可以编译低版本的java源代码的, 因此在CLass文件各项细节, 几乎没有随着版本有太大的改变, 甚至没有做出任何修改, 后续的jdk版本来说, 更新只是在原有的结构上进行扩充, 熙增新增.
前面我们讲解, 一个java源代码文件, 可以被编译器编译为JVM可以识别的字节码文件, 这个字节码文件是一个二进制文件(二进制流), 各个数据项是严格按照某种特定的顺序排列在文件之后, 中间没有加任何间隔符.
Class文件是以8个字节为基础的二进制文件, 其文件格式采用类似于C语言结构体的类似的数据结构来存储数据, Class文件中存在两种数据类型:
- 无符号数
- 表
无符号数对应的是基本数据类型, 无符号数通常使用u1, u2, u4, u8(u后面的数字代表的是n个字节, 例如u1就是一个字节的无符号数, u既是unsigned) 来表示无符号数.
表则由多个无符号数, 或者其他表作为其构成的复合型数据结构, 为了便于区分表和无符号数, 所有的表的命名都习惯性的加上 _info后缀. 整个Class文件就可以看做是一个表.
下图是Class文件的格式:
下面我们来看看各个数据项的特殊含义, 在开始之前, 你应该写上一个类, 然后将其编译, 然后使用16进制编辑器查看class文件, 如下是一段代码, 以及它编译后的字节码文件:
public class Test {private int x;public static void main(String[] args) {}public int inc() {return x + 1;}
}
(使用IDEA插件查看class文件)
下载插件:
对编译好的class文件右击, 然后选择打开于:
选择刚才下载的插件:
字节码:
- 二进制格式
- 16进制格式:
magic 数
首先对于上图的16进制的文件, 不难看出开头的四个字节(2^4可以描述一位16进制的数, 因此两位16进制数需要两个2^4的二进制, 一个字节是8个bit位, 2^4为四个bit位因此2个16进制的数需要1个字节)被标记为天蓝色.
其内容为:"CAFEBABE", 这个也不难理解, 其实你可能已经猜出来了这个是干嘛用的, 它的唯一作用就是检查这个文件是否是一个可以被JVM识别接受的Class文件, 参考其他的文件格式, 有很多也是使用模式来判断身份标识, 例如GIF等文件格式. 不使用扩展名的原因是扩展名可以随意修改, 为了安全起见, 没有采用扩展名的方式.
你能找出开头不为:cafebabe的class文件吗.
文件版本
紧接着magic的四个字节是次版本号(minor_version)和主版本号(major_version), 先来看看这四个字节的16进制数是多少:
其中地址偏移量(offset) 为0x04 ~ 0x05的是次版本号, 0x06~0x07的是主版本号, 可以看到次版本号为0, 主版本号为0x0034, 对应的10进制就是52, 这个52到底是什么意思, 为什么次版本号为0呢?
首先需要知道的是java的版本号是从45开始的, JDK1.1之后每一个大版本发布都将主版本号+1, 高版本的JDK可以兼容低版本的Class文件, 但是不能兼容高于当前JDK版本的JDK编译生成的Class文件, 即使文件格式没有发生任何改变, 如果版本号高于当前的JDK版本, 虚拟机会需要拒绝此文件(来自java虚拟机规范)
例如JDK1.1支持的版本号是45.0~45.65535, 但是无法执行版本号为4.6以及以上的Class文件. (上图中我自己的版本号为52, 刚好对应的是JDK1.8版本).
下图是对应版本号对应的JDK版本:
关于次版本号为0, 存在一些历史因素, 其中在JDK1.1支持的版本是45.0~45.65535, 但是从1.2之后次版本号均没有被使用, 都是为0, 到了JDK12以及后期, JDK本身集成了非常多的功能, 其中可能不乏一些不稳定的新特性, 这些新特性在还没有全面的测试使用和分析数据的情况下, 是不能直接进入商业应用阶段的, 因此为了区分, 如果CLass文件中使用了该JDK版本未正式列入特性清单中的功能, 那么就会将次版本号的值改为: 65535(二进制全1), 因此你可能会在偏移量为0x04和0x05这两个偏移(相对) 看见 FFFF(HEX)的值.
为了更好的理解常量池是怎么设计的, 我们先使用一些工具来查看字节码的结构
使用JClassLib观察class文件
首先我们需要下载JclassLib:
GitHub - ingokegel/jclasslibjclasslib bytecode editor is a tool that visualizes all aspects of compiled Java class files and the contained bytecode. - ingokegel/jclasslibhttps://github.com/ingokegel/jclasslib jclasslib(现在简称jcl) 字节码编辑器是一个可以分析java的Class文件结构的工具, 它将Class文件解析成我们可以看懂的结构特征. 目前已经支持到了JDK21版本.
下载之后, 进行安装, 然后我们编写如下代码:
public class Test implements Runnable{private int x;private static final String a = "abc";public int inc() {return x + 1;}@Overridepublic void run() {}
}
我们使用javac来编译当前的代码:
javac Test.java
当前编译的JDK:
然后将编译成的Test.class使用jclassLib打开:
可以看到jclassLib解析出了6个模块分别是:
- 一般信息
- 常量池
- 接口
- 字段
- 方法
- 属性
一般信息
一般信息是类的统计信息, 例如当前javaclass字节码文件中的主次版本号, 常量池中常量项的个数, 访问标志(是否是public), 父类的全限定类名, Test类的名称的常量, 还有当前这个类的实现的接口的数量, 字段数量, 自身的属性等.
首先主次版本号中, 次版本号为0 , 说明当前的JDK版本中没有集成哪些不稳定的需要公测的特性. 主版本号为52, 正好对应着JDK1.8版本, 然后常量池中常量计数器的数值(常量项的个数)
访问标志是两个字节存储的, 也就是u2, 例如0x000021, 它的二进制表示为:
00000000 00000000 00000000 00100001, 其中第0位表示公共性,其他位可以指示是否是抽象等等. 后面细说.
然后就是文本索引, 其它的值为cp info #3, 不难猜出其实cp就是类似于linux中的cp指令, 意思就是它引用了#3的常量项(常量池中的), 我们在jclasslib'中点击它, 可以看见它跳转到了如下页面:
可以看到它来自这个名为[03]CONSTANT_Class_info的常量项, 并且这个常量项引用了另外一个#24的常量项, 我们继续点击这个#24, 跳转如下:
我们注意到这个页面有两个常量池项的类型:
- CONSTANT_String_info : 用于表示一个字符串常量
- CONSTANT_Utf8_info
CONSTANT_String_info更接近于Java语法中数据类型的东西, 它包含一个指向CONSTANT_Utf8_info 常量类型的引用, 可以用作字符串的符号引用,在编译时用于表示字符串常量, 而CONSTANT_Utf8_info 专门用于存储UTF-8编码的字符串数据, 也就是真是存储数值的常量项, 这是一个通用的条目类型,可以被其他常量类型引用,例如CONSTANT_Utf8_info 和CONSTANT_String_info, CONSTANT_Utf8_info本身不提供上下文,只是存储字符串的原始内容,
总结来说, CONSTANT_String_info是一个引用, 用于在常量池中指定一个具体的常量, CONSTANT_Utf8_info则是世界存储该字符串的内容的条目
这里提一嘴, CONSTANT_Class_info直接引用CONSTANT_Utf8_info, 而没有通过CONSTANT_String_info简介去引用CONSTANT_Utf8_info常量的原因是, 类名信息室不需要区分数据类型的, 它只需要表示可读的文本即可, 但是例如你在内存中定义了一个常量String a = "abc", 此时你引用这个a, 就需要引用一个CONSTANT_String_info来表示它是一个String类型的常量, 然后再引用其真实的值. 对于类信息, 例如类名, 它使用Class_info表名其类型, 然后再引用这个CONSTANT_Utf8_info类型的常量.
父类引用为#04号常量, 从图中可以简略看出来它是继承自Object.
我们查看这个引用, 信息如下 :
可以看到这个#04的引用是一个Class_info类型的, 说明它描述的是类的信息(毕竟是继承, 继承是表达类与类之间关系的一种方式). 这个Class_info引用了另外一个类型(#27), 通过上面的讲述, 其实你也能猜到引用的是一个CONSTANT_Utf8_info类型的常量如下:
它的字面量是一个全限定类名: java/lang/Object.
接口
还有一个接口数, 可以看见接口数量为1 :
但是貌似没有任何引用. 虽然在一般信息里面不能直接看到, 但是可以从接口栏目查看, 如下:
可以看到它引用了 #5 这个常量, 如下:
可见, 接口信息也被归属到 类信息中, 并且引用了另外一个CONSTANT_Utf8_info的字面量.
常量池
从上面的分析不难看出, 常量池中主要存储的是CONSTANT_Utf8_info的类型的字面量, 还存放着CONSTANT_Class_info的类信息, 除此之外还有CONSTANT_String_info的类型的信息. 具体还有哪些就不一一讲解, 大致总结如下(随着jdk版本的变更或多或少为了支持新特性会增加一些其他类型的信息) :
从上图不难看出每种常量池的类型都有不同的标志位, 从1开始往后. 字节码指令中, 往往就是存在着一些对常量池中数据的引用, 如下:
这种通过编号引用到常量池中数据的过程成为符号引用.
字段
如下:
我们可以看出来字段拥有着这几类的描述信息:
- 字段名 : 名称
- 描述符 : 描述符号, 可以理解为类型
- 访问标志: 权限, 是public 还是Private等
我们首先看名字, 它(字段a)引用了第八项常量, 如下(字段的名称直接引用的是字面量) , 以此来说明这个字段的命名为"a":
其次它还引用了第九项常量, 如下:
可以看出来描述也是一个CONSTANT_Utf8_info的字面量, 它描述的是类型的信息. 除此之外它的访问标志为
除此之外, 除了这些描述性的信息(字段名, 类型, 访问标志), 他还有自己的值, 我们双击这个常量a, 就可以返现它有一个CONSTANT的常量值:
具体信息如下:
其中特有信息引用的是第11项常量值, 如下:
它是一个String类型的CONSTANT引用, 因此表面此数据类型是String类型的字面量, 然后引用了第二十九项常量(Class_info类型), 其值为"abc".
其次, 一般信息中它还引用了一个10号常量, 如下:
可以发现 , 里面的字面量为ConstantValue, 这个 ConstantValue有什么用呢? 我不是已经通过一个访问标志来查看它的类型了吗, 而且还有访问标志符号来判定它是否是一个静态常量, 为什么还需要一个一般信息的constantValue来描述它?
ConstantValue属性是专门用于存储静态字段(特别是static final字段)的常量值的, ConstantValue属性是专门用于存储静态字段(特别是static final字段)的常量值的. 这个属性会指向常量池中的一个CONSTANT_Utf8_info(对于字符串)或者CONSTANT_Integer_info(对于整数)等,从而指定静态字段的值. ConstantValue属性的存在是为了在类加载阶段能够直接获取到这些静态常量的值,而不需要在运行时通过方法调用等方式去计算或获取.
在进行类加载的时候, 仅仅只是知道这些常量在常量池中的位置是不够的, 还需要知道哪些字段是静态常量, 并且在类加载的时候, 就加载上正确的值, 这个时候, 就需要看ConstantValue的脸色了, ConstantValue是类文件中字段表的一个可选的属性, 通过解析类文件中的字段表,查找具有ConstantValue属性的字段,并读取该属性所指向的常量值来识别和加载静态常量.
方法
需要解析的东西还不少 ... ...
回顾一下这个class文件的源代码:
public class Test implements Runnable{private int x;private static final String a = "abc";public int inc() {return x + 1;}@Overridepublic void run() {}
}
通过对比源码可知, 0号方法为init, 我们暂时猜测它是构造方法, inc为我们自己定义的方法, run是Runnable的实现方法, 我们首先来看自己定义的inc方法, 如下:
同样的拥有两样信息:
- 方法名称, 描述方法的名称, 引用了第19个常量, 该常量是一个utf8_info的数据, 值为"inc", 正式我们的方法名
- 描述符 : 描述该方法的参数和返回值, 该例子中它引用了第20个常量, [20] 常量是一个utf8_info类型的数据, 是一个字面量.
双击这个inc就可以发现, 它里面的方法体如下:
我们首先看这个Code, 如下:
同样拥有一般信息和特有信息(包括方法体的字节码, 异常表,和杂项), 首先是一般信息中, 引用了[14] 号常量, 它是一个utf8_info类型的字面量, 值为Code, 然后字节码为:
0 aload_0
1 getfield #2 <Test.x : I>
4 iconst_1
5 iadd
6 ireturn
对此字节码指令的说明:
- aload_0(操作码:26,局部变量索引:0)
这条指令从局部变量表的第0个位置加载一个引用类型的值到操作数栈顶。在Java方法中,局部变量表的前几个位置通常用于存储方法的参数和this引用(对于非静态方法). 因此,aload_0通常用于加载当前对象的引用(即this)
- getfield #2 <Test.x : I>(操作码:179,字段引用索引:2)
这条指令从操作数栈顶取出一个对象引用(由aload_0加载的),并根据常量池中的索引2找到对应的字段描述符(这里是<Test.x : I>),然后从该对象中获取名为x的字段的值,并将该值(类型为int,由I表示)压入操作数栈顶。这里假设Test是一个类,x是Test类的一个int类型的实例字段。
- iconst_1(操作码:8)
这条指令将整型常量1压入操作数栈顶
- iadd(操作码:96)
这条指令从操作数栈顶弹出两个整型值(第二个弹出的是操作数,第一个弹出的是被加数),将它们相加,然后将结果压回操作数栈顶. 在这里,它将getfield指令获取的x的值与iconst_1指令压入的1相加
- ireturn(操作码:172)
这条指令从操作数栈顶弹出一个整型值,并将其作为方法的返回值。这意味着方法的执行将结束,并且该整型值将被返回给方法的调用者
杂项:
至于说下面的这两项:
在Java虚拟机(JVM)中,LineNumberTable和LocalVariableTable都是类文件中方法属性表(attribute_info)的一部分,它们提供了关于方法的附加信息,这些信息对于调试和代码分析非常有用 .
LineNumberTable属性用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。它不是运行时必需的属性,但默认会生成到Class文件中,可以使用javac编译器的选项来取消或要求生成这项信息。
- 作用:它帮助开发者在调试时,将字节码指令映射回Java源码中的具体行号,从而更容易地定位和理解代码的执行流程。
- 结构:
LineNumberTable
属性包含一个表,表中每一项都包含一个源码行号和对应的字节码偏移量。
LocalVariableTable
localVariableTable属性用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系。它同样不是运行时必需的属性,默认也不会生成到Class文件之中,但可以使用javac
编译器的选项来要求生成这项信息。
- 作用:它帮助开发者在调试时,了解局部变量在方法执行过程中的值和作用域,从而更容易地跟踪和理解代码的执行状态。
- 结构:
LocalVariableTable
属性包含一个表,表中每一项都包含变量的名称、类型、作用域起始和结束位置(以字节码偏移量表示)等信息。
其他的一些都是class文件的一些属性, 待读者自行研究.
常量池计数器
紧接着版本号的就是常量池(红框), 它是Class文件结构中与其他项目关联最多的数据, 通常也是一个CLass文件占用最多的部分, 但是由于常量池的长度不固定, 因此需要有一个标识来记录其常量池的容量的计数值:
名为constant_pool_count, 大小为2个字节, 是一个无符号数. 能表示的范围为0~65535. 我之前的截图文件中的constant_pool_count 的数值如下:
1A(HEX)即 26(DEC), 这就代表常量池中存在着25项常量. 但是与其他的结构不同的是, 这个值的计数是从1开始, 就例如你数数量, 从来都是从1开始数起.
这样做的目的在于,如果后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,可以把索引值设置为0来表示, 你暂时只需要记住这两个字节是表示常量池常量项个数的标识即可.
常量池
类型
通过我们上面的jclassLib观察的结果, 发现常量池中的很多种类型, 我们已经熟悉了:
- CONSTANT_Class_info , 类信息
- CONSTANT_Utf8_info , 字面量
- CONSTANT_String_info , 数据类型
说完常量池计数器, 紧接着的就是常量池. 常量池中主要存放两大类:
- 字面量 : 比较接近java语言中的常量的概念(CONSTANT_Utf8_info)
- 符号引用 , 主要包括下面几类常量:
- 被模块导出或者开放的包
- 类和接口的全限定名 (CONSTANT_Class_info)
- 字段的名称和描述符
- 方法的名称和描述符
- 方法句柄和方法类型
- 动态调用点和动态常量
具体符号引用是什么, 我们需要先理解一下, java的编译原理.
在java的Class文件中, 是不会存储文件中各个字段, 方法等最终在内存中的布局信息, 这些字段方法的符号引用不经过java虚拟机在运行期间的转换, 是无法真正得到内存中的入口地址的. 也就无法被虚拟机使用,
常量池中的每一项都是一个表, 每个表最初具有11种各不相同的表结构数据, 后来为了更好的支持动态语言调用, 额外增加了4中动态语言相关的常量 ...
后续增加到17种, 如下:
每一种类型都有自己的标志位来表示自己是哪种类型的, 为了表示它, 每一个类型的最前面都会有一个u1类型的标志, 来表示这个标志位.
为了演示, 我们编写如下代码:
public class Test{private int m;public int inc() {return m + 1;}
}
然后使用HxD查看其字节码文件如下:
我们回顾一下之前的讲解:
- 00 ~ 03 是 java字节码文件的标识(魔数)
- 04~05 是次版本号
- 06 ~ 07 是主版本号 34(hex) 对应的10进制是52, 正好对应着JDK1.8
- 08 ~ 09 对应着常量池中常量项的个数. 在上图的Test.class中对应的16进制是16, 转换成10进制就是22, 也就是说常量池中存在着22个常量项, 通过jclasslib查看如下:
虽然是22个, 但是这里只显示了21个, 也就是说索引的范围为1~21, 那还少了一个, 这是因为常量池的容器计数是从1开始的, 第0项有着特殊的含义, 也就是将其他常量项的值设置为索引0来表示它不索引任何一个值.
我们回过头来看看这个常量池中的第一个常量的标志位:
CONSTANT_Methodref_info
可以看到第一个常量是(地址偏移为0x0000000A) 0x0A, 也就是标志位值为10, 对应的类型是CONSTANT_Methodref_info, 其实我们也可以在jclassLib中查看:
CONSTANT_Methodref_info 说明它是类中方法的引用, 它引用了类名的描述和方法名以及其描述符, 类名引用的是CONSTANT_Class_info类型的类名(此类名引用了CONSTANT_Utf8_info类型的字面量), 名字和描述符引用了CONSTANT_NameAndType_info类型的符号引用, 这个符号引用分别引用了名字的符号引用和符号的引用:
描述符描述的就是返回类型和参数, 是一个utf8字面量. 名字也是如此.
它的结构如下:
除了tag(标志位), 下面两个字节是u2类型, 来表示这个方法的类描述符(CONSTANT_Class_info), 紧接着这个u2的也是一个u2类型, 指向名称以及类型描述符(CONSTANT_NameAndType_info)
可以看到本例子中的第一个常量就是一个CONSTANT_Methodref_info 类型, 第一个index表示了[04]位的常量, 第二个index位0x12, 标识了[18]位常量, 如下:
在字节码层面上, 我们对上图的字节码文件的结构进行解析, 一直解析到第一个常量项: 如下
可以看到, 第一个常量的类型的地址偏移从0x0000000A~0x0000000E, 也就是:
- 0A: 为一个u1类型, 表示常量类型的标志位, 这里是0A表示CONSTANT_Methodref_info
- 0B ~ 0C: 为一个u2类型, 表示 指向声明此方法的类描述符的索引
- 0D ~ 0E: 为一个u2类型, 表示方法名称和类型描述符
然后下一个常量开始的地址偏移就是0x0000000F, 其值为9, 表示字段的引用符号:
然后以此类推, 就可以查出所有的常量池常量所在的位置
CONSTANT_Class_info
这个其实就是类的信息, 也使用常量来存储. 它的存储结构如下:
第一个u1表示标志类型, 其值与上述的表对应(0x07), name_index 是常量池的索引值, 它使用2个字节来指向常量池中的CONTANT_Class_info类型的常量
还有很多类型, 这里就不一一列举例子.
类型结构总表
避免遗忘回顾一下这张图:
访问标志
可以看出来, 在常量池之后的内容就是一个u2字节的访问标志(access_flags), 用于标识一些类或者接口层次的访问信息, 包括这个Class是类还是接口 , 是否是public类型, 是否是abstract等, 如果是类的话, 又是否是被声明为final等等之类的, 具体的含义如下:
注意是16进制 ...
访问标志位占用2个字节, 也就是16个bit位, 也就是可以使用16位来表示其中的各种含义, 现在不同的标志位对应的含义已经标明, 我们只需要找出访问标志位, 然后解析其值, 并与其上图进行分析即可.
我们当前使用的代码:
public class Test{private int m;public int inc() {return m + 1;}
}
对应的class字节码文件(16进制)
由于我们再常量池那一章节中使用的是一个普通的java类, 不是接口, 注解, 也不是枚举, 或者模块, 被public修饰但是没有被final修饰, 并且使用JDK1.8标识, java虚拟机规范要求, 如果没有被使用到的一律需要置为0
从上述的代码中可以看出Test类是一个普通的java类, 因此 它应该将public标志位置为真:
但是我们发现0x0020也被激活, 这是为什么? 从上图中可以看出, 0x0020(ACC_SUPER)是否允许使用invokespecial字节码指令的新语义,invokespecial指令的语义在JDK1.0.2发生过改变,为了区别这条指令使用哪种语义,JDK1.0.2之后编译出来的类的这个标志都必须为真.
其余项目都应该置为假, 因此就形成了0x0021的值.
类索引, 父类索引与接口索引
access_flags下面的四个字节就是this索引和super索引了, 他们分别占用两个字节, 也就是分别为u2类型的数据, 紧接着access_flags的是this_class, 也就是类索引. 除了this_class和super_class, 接下来还有两个两个字节的u2表示接口相关的信息
他们四个来确定Class文件中类的集成关系, 接下来分别讲述其真正的意义:
- this_class: 用于确定类的全限定类名, 指向一个CONSTANT_Class_info的常量类型
- super_class: 用于确定父类的全限定类名, 指向一个CONSTANT_Class_info的常量类型
我们还是拿这个代码为案例:
public class Test{private int m;public int inc() {return m + 1;}
}
其字节码文件中的类继承相关的字节码偏移量如下:
java语言不支持多继承, 但是支持多接口实现, 因此父类索引只能存在一个, 除了Object之外, 所有的Java类都有父类, 因此除了Object其他所有的类的父索引都不为0, 接口索引数据描述实现了哪些接口, 并且这些接口将按照implements的顺序从左到右以此排列在字节码文件中.
我们分析其值, 可知:
- this_class : 0x0003
可以看到它引用了一个Utf8_info的字面量, 值为Test
- super_class: 0x0004
可以看到 其引用了一个Utf8_info的字面量, 值为java/lang/Object, 也就是Object类的全限定类名.
- interfaces_count: 这个是接口的入口, 是一个计数器, 用来表示接口的数量, 你可以将其类比常量池中常量的排列前面总是要加一个constant_pool_count.
其值为0, 说明没有接口实现, 这里有个细节, 因为接口计数器值为0 那么它下面的表示接口细节的u2类型的接口索引表也就不会占用任何字节.
字段表
首先字段表用于描述接口或者类中声明的变量, Java语言中的字段包括了类变量和实例变量, 不包括方法内部局部变量.
既然是java的字段, 就应该有以下的描述:
- 访问限定修饰符(public, private 等)
- 是否是类变量(static)
- 可变性(final)
- 可见性(volatile, 强制主内存读写)
- 字段类型
- 字段名
- 等
从上图中可以看出, 字段表是紧接着interfaces接口索引集合后面的 (请注意interfaces_count为0的时候, interfaces是不占用任何字节的). 使用了n个字节来表示, 首先需要一个u2类型来描述字段的个数, 然后这两个字节后面就是真正的字段的相关信息
我们使用如下代码:
public class Test{private int m;public int inc() {return m + 1;}
}
字节码还是如下, 并且我已经标出来了字段相关信息的部分:
从图中可以看出来fields_count的值为1表示有一个字段, 并且这个字段刚好就应该是我们上面定义的m字段.
这个字段后面就是具体的字段表(这个依然可以跟常量池比较, 他们前面都有一个记录数量的标识), 然后每个字段里面的符号引用或者字面量都是大小都是固定的(除了info), 如下:
第一个access_flags是不是很眼熟, 这个跟类的访问标识符很像, 但是他们的值有不同的含义, 如下:
显然我们再编写代码的时候, ACC_PUBLIC, PRIVATE, PROTECTED这三个是不能同时选择的, 只能选择一个, 同时VOLATILE和FINAL之间也只能选择一个, 我们根据上述的字段表, 可以看到, access_flags后面的两个字节是name_index, 见名知意, 其实就是名称的索引, descriptor_index就是一种类似于描述符的
他们都是对常量池项目的引用, 我们可以查看jclassLib中的演示:
该字段的值如下:
- name_index: 0x0005, 指向索引值为5的常量
- descriptor_index: 0x0006, 指向常量值为6的常量
索引值为5的常量为CONSTANT_Utf8_info, 内容为m:
索引值为6的同样为 CONSTANT_Utf8_info, 值为I (大写的i, 至于为什么为i, 后面会讲解), 表示该字段的类型为int类型.
我们在使用的时候, 导入包的时候, 都是使用"import com.mybatis.cn.*"之类的, 可以看出这里面都是使用的全限定的包名, 但是对于JVM来说, 类的全限定名仅仅只是把里面的"."号替换成为了"/" 而已, 例如我们的Object类 :
对于类名和方法名, 字段名这些不需要参数类型, 就只是人能看懂的符号即可, 也就是常见的CONSTANT_Utf8_info类型, 例如上述代码中的inc()方法, 它的方法名(inc)表示如下:
但是描述符可能就要复杂一点, 因为描述符需要描述方法和字段的返回值, 数据类型等等之类的, 就基本数据的类型而言(byte、char、double、float、int、long、short、boolean), 以及void类型, 都是用其数据类型命名的第一个字母的大写表示,
如下:
这里的void只是为了统一让读者了解而列出来的, 在java虚拟机中为VoidDescriptor. 数组类型需要在前面加上"[", 例如int[] 就被描述为"[I".
因此下图中(类字段m) :
它的描述符为一个引用了Utf8_info类型的, 值为大写i(I)的引用, 此处的大写的i就是表示的基本数据类型int :
public class Test{private int m;public int inc() {return m + 1;}
}
对于方法inc的描述, 可以看到其描述符的值为:
描述方法的时候, 按照如下顺序来描述:
- 参数列表 , 严格按照编码的顺序放在(). 例如方法int func(int[] arrInt, char ch)的描述符就为([IC)I
- 返回值, 紧跟在()后面, 例如方法char func(), 描述符为()C
字段表的固定描述到了descriptor_index就算结束了, 后面的attributes_count和attributes用于存储一些额外的属性信息, 本例中, attributes_count为0, 也就是没有额外的信息存储.
那什么时候需要使用到这个, 如果你将m字段声明为static final, 那么可能就需要额外存储一段ConstantValue的属性
方法表
紧接着字段表的就是方法表, 第一个跟表相关的仍然是count数值, 也就是methods_count, 表示方法的个数.
方法表和字段表几乎采用了一摸一样的描述方法. 方法表的结构如下:
在access_flags中, 因为方法的访问标志中, 不存在volatile等关键词, 因此在访问标志的描述上,, 存在些许差异:
唯一需要谈谈的就是, 方法里面的代码去哪了?
java方法里面的code经过Javac编译器, 编译成字节码指令, 然后存放在方法属性表集合中一个名为code的属性之中去了 :
以如下代码为例子:
public class Test{private int m;public int inc() {return m + 1;}
}
字节码的方法表如下:
methods_count的值为2, 代表有两个方法, java基础好的肯定知道, 虽然只写了一个inc方法, 但是还有一个方法肯定是跟构造方法之类的有关, 没错他就是<init>方法(编译器添加的实例构造器), 第一个方法的访问标志位为0x0001, 对比{方法访问标志表}得知, 表示name_index为7:
可知它为init的方法名称.
描述符(0x0008) 不再赘述, 自行研究: 对应常量为 -- ()V.
init方法的属性计数器为1, 因此表示此方法的属性表集合有1项属性,属性名称的索引值为0x0009:
查看常量池, 值为code, 说明此属性是描述的方法的字节码.
属性表
属性表紧跟着方法表之后, 开头仍然是一个计数器, 其次才是表
属性表用来存放什么信息?
Class文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景专有的信息, 我们之前所说的方法的code就是存储在此
例如我们之前写的代码:
public class Test{private int m;public int inc() {return m + 1;}
}
使用JClassLib来观察其方法inc的方法表如下:
JDK12规范中的属性KV如下:
属性表的结构如下 :
- attribute_name_index自然是引用的一个CONSTANT_Utf8_info类型的字面量,
- attribute_length来说该属性所占用的位数
- info来表示即为值
接下来我们来聊聊方法表中属性中存储的code属性.
经过编译器编译之后, 方法体被编译成字节码指令序列, 被存储在code属性中, 它的结构如下:
我们逐项解析:
- attribute_name_index自然就是引用的Utf8_Info(CONSTANT_Utf8_info), 表示属性名称的字面量. 此处为"Code"
- attribute_length指示了属性值的长度
- max_stack 表示操作数栈的最大深度(可控制的值)
- max_locals 表示局部变量表所需的空间单位是变量槽. 变量槽是虚拟机分配内存的基本单位, 对于byte、char、float、int、short、boolean和returnAddress等长度不超过32位的数据类型,每个局部变量占用一个变量槽,而double和long这两种64位的数据类型则需要两个变量槽来存放
- code_length代表字节码长度的长度,
- code用于存放字节码指令, 每一个字节码指令的大小为u1, 个数为code_length.
继续使用如下代码作为案例分析:
public class Test{private int m;public int inc() {return m + 1;}
}
字节码:
通过上几节的分析, 我们可以找到属性code所在位置:
我们使用javap反编译这个文件:
C:\Program Files\Java\jdk-17\bin>javap -verbose "you\path\Test.class"
Classfile /C:/TestData/ThreadTest/out/production/ThreadTest/Test.classLast modified 2024年9月30日; size 338 bytesSHA-256 checksum 03b1362989e1f14e5cc4311d6fbca80c2caf91724b4d5ee8eb27a94bfffaa76dCompiled from "Test.java"
public class Testminor version: 0major version: 52flags: (0x0021) ACC_PUBLIC, ACC_SUPERthis_class: #3 // Testsuper_class: #4 // java/lang/Objectinterfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:#1 = Methodref #4.#18 // java/lang/Object."<init>":()V#2 = Fieldref #3.#19 // Test.m:I#3 = Class #20 // Test#4 = Class #21 // java/lang/Object#5 = Utf8 m#6 = Utf8 I#7 = Utf8 <init>#8 = Utf8 ()V#9 = Utf8 Code#10 = Utf8 LineNumberTable#11 = Utf8 LocalVariableTable#12 = Utf8 this#13 = Utf8 LTest;#14 = Utf8 inc#15 = Utf8 ()I#16 = Utf8 SourceFile#17 = Utf8 Test.java#18 = NameAndType #7:#8 // "<init>":()V#19 = NameAndType #5:#6 // m:I#20 = Utf8 Test#21 = Utf8 java/lang/Object
{public Test();descriptor: ()Vflags: (0x0001) ACC_PUBLICCode:stack=1, locals=1, args_size=10: aload_01: invokespecial #1 // Method java/lang/Object."<init>":()V4: returnLineNumberTable:line 1: 0LocalVariableTable:Start Length Slot Name Signature0 5 0 this LTest;public int inc();descriptor: ()Iflags: (0x0001) ACC_PUBLICCode:stack=2, locals=1, args_size=10: aload_01: getfield #2 // Field m:I4: iconst_15: iadd6: ireturnLineNumberTable:line 4: 0LocalVariableTable:Start Length Slot Name Signature0 7 0 this LTest;
}
SourceFile: "Test.java"
下面是<init>方法的字节码指令:
public Test();descriptor: ()Vflags: (0x0001) ACC_PUBLICCode:stack=1, locals=1, args_size=10: aload_01: invokespecial #1 // Method java/lang/Object."<init>":()V4: returnLineNumberTable:line 1: 0LocalVariableTable:Start Length Slot Name Signature0 5 0 this LTest;
可以看到args_size为1, 也就是参数为1, 但是构造方法明明没有任何参数, 同样的, 里面的locals为什么为1? 这是因为在类中的实例方法 无论有无参数, 都会有一个this参数指向自身.
仅仅是在编译的时候, 把this关键字作为转变为一个普通的方法参数. 因此在该方法的局部变量表中, 至少会存在一个指向当前实例的局部变量(占用局部变量表中第一个变量槽, 只对实例方法有效)
参考资料
- 深入理解JAVA虚拟机 3版
- oracle 官网 JVM规范:
Java SE Specificationshttps://docs.oracle.com/javase/specs/index.html
- Java虚拟机规范 Java SE 8版