查看原文
其他

关于Java字节码,了解这些就够了

fundroid AndroidPub 2022-07-13

Java 之所以能做到 “一次编写,到处运行(Write Once, Run Anywhere)”,正是得益于虚拟机和字节码的存在。多少了解一点字节码知识对日常的开发工作会有所帮助。

1. Java虚拟机

虚拟机是操作系统上层的高度抽象,可以进行物理机的模拟,一个虚拟机可以在多个操作系统(Windows/Mac/Linux)和硬件体系(x86/arm)运行。Java虚拟机就是其中最经典的代表,Java代码会都被编译成特定的.class字节码供虚拟机执行。

我们常说的 JVM 实际上只是一套规范,或者也可以理解为一个接口。具体的实现有很多,例如 DVM、ART、Hotspot。JVM 包含了一堆运行时服务,包括 JIT 编译器,还有 GC、线程支持、元数据管理(例如类加载)等功能,外加配套的 Java 标准库,这些全部打包在一起就是一个 Java Runtime Environment。

1.1 虚拟机与字节码的关系

“同一份输入,不同的输出”,我们只需要生成一份字节码文件,然后同一份.class字节码文件在不同的操作系统中,由不同的虚拟机生成对应机器码。虚拟机和字节码是Java的两个最底层的原理。最简单的编译运行流程,实际情况比这个复杂的多

1.2 HelloWorld

按照惯例,我们使用 HelloWorld 来入门一下字节码这门“低级“的语言

  • HelloWorld.java
  • HelloWorld.class

HelloWorld.java编译后,生成HelloWorld.class字节码文件。这里不仔细分析这份字节码文件的内容,先感受一下其大概的样子:使用javap -v命令反编译后:

2. 字节码文件

2.1 基础知识

字节码文件是二进制文件,只不过我们一般打开都是16进制形式的。众所周知在二进制中,8位为一个字节,4位二进制数表示一个16进制数,也就是说两个16进制数表示一个字节,所以上图中一个字符占了半个字节长度。在下文中,u1表示占用一个字节长度,u2为两个字节长度,以此类推。

2.2 文件结构

字节码文件在结构一次包含以下内容:

  • 魔数(Magic Number)
  • 版本号(Minor&Major Version)
  • 常量池(Constant Pool)
  • 类访问标记(Access Flags)
  • 类索引(This Class)
  • 超类索引(Super Class)
  • 接口表索引(Interfaces)
  • 字段表(Fields)
  • 方法表(Methods)
  • 属性表(Attributes)
在这里插入图片描述

2.2.1 魔数

统一的CAFE BABE,其作用与文件名后缀.java.png等是一样的作用,标示一种类型的文件,不同的文件以二进制的形式打开都能看到对应的魔数。虚拟机在加载字节码文件时会首先检查魔数。

CAFE BABE的来由:http://mishadoff.com/blog/java-magic-part-2-0xcafebabe/

2.2.2 版本号

第二部分是由副版本号与主版本号组成的版本号,在1.2中的HelloWorld.class中的版本号[0000 003a]能看出来我电脑中用的JDK版本是316 + 101 = 58,由于Java版本是从45开始的,所以算下来是Java14,用java -version 确认下:

在这里插入图片描述

一个大版本下还会有多个小版本,根据字节码文件留出来的长度可知,一个大版本下最多可以有[0xffff] + 1 = 16^4 = 65536个小版本。

由于每个新版本都会有新特性,所以老版本的虚拟机不能够兼容新版本的字节码文件,所以进行类加载的时候也会检查版本号是否兼容,假如不兼容会抛出java.lang.UnsupportedClassVersionError的错误。

2.2.3 常量池

常量池算是字节码中最复杂的数据结构,常量池中会存放一些字符串常量与较大的整数,比较小的与常用的整数则是内嵌在字节码指令中,如iconst_1表示整数1入栈。

常量池的结构由常量池大小与其内容组成,常量池大小的字节码长度为两个字节,所有从这里也可以得知一个类文件中最多只能有65536个常量。

在这里插入图片描述

假设常量池大小为N,其中有效索引为1~N-1,0为保留索引,其中常量池则最多有N-1项,为何是最多呢?因为LongDouble的类型的常量占用两个索引位置,所以实际常量项会比N少。

一个常量项的数据结构分为类型tag 与 内容,如下:

在这里插入图片描述

目前Java有14种常量类型,这里不展开细讲,有兴趣的同学可以自行了解:

constant typetag
CONSTANT_Utf8_info1
CONSTANT_Integer_info3
CONSTANT_Float_info4
CONSTANT_Long_info5
CONSTANT_Double_info6
CONSTANT_Class_info7
CONSTANT_String_info8
CONSTANT_Fieldref_info9
CONSTANT_Methodref_info10
CONSTANT_InterfaceMethodref_info11
CONSTANT_NameAndType_info12
CONSTANT_MethodHandle_info15
CONSTANT_MethodType_info16
CONSTANT_InvokeDynamic_info18

2.2.4 类访问标记

类访问标记用来标识一个类是否为finalabstract等,大小为两个字节,用一个位标记一种类型,目前只用了8个位

在这里插入图片描述

其中ACC_SUPER已经弃用,但是为了兼容旧版本的字节码文件,所以还占着这个位置。

2.2.5 类索引、超类索引、接口表索引

这三个部分是用以确认一个类的继承关系,索引指向常量池中的项。例如1.2中,对应的类索引为[0x00015] = 21 索引对应的常量也是一个索引,指向的是一个字符串常量——真正的类名:超类索引与接口表索引也是类似,这里不多赘述。

2.2.6 字段表

类中定义的静态与非静态字段都会存储在这个集合中,不包括方法中定义的字段。

字段表的结构与常量池相似,由长度与内容组成,如下所示:每个字段项结构如下所示,由四部分组成:

  • access_flags:表示字段的访问标记,是否public、private、static、final 等。
  • name_index:字段名的索引值,指向常量池的的字符串常量。
  • descriptor_index:字段描述符的索引,指向常量池的字符串常量。
  • attributes_countattribute_info:表示属性的个数和属性集合。

2.2.6.1 字段描述符

描述符类型
Bbyte 类型
Cchar 类型
Ddouble 类型
Ffloat 类型
Iint 类型
Jlong 类型
Sshort 类型
Zbool 类型
L ClassName ;引用类型,"L" + 对象类型的全限定名 + ";"
[一维数组

其中引用类型描述符比较特殊,如String类型表示为:“Ljava/lang/String;” ,由于long类型的L被引用类型占了,所以long类型用了J

2.2.7 方法表

字段表后面的就是方法表,类中定义的方法都会存放在这里,其结构与字段表结构相似:每个方法项的结构也与字段长得一摸一样,如下:

  • access_flags:表示方法的访问标记,是否public、private、static、final 等。
  • name_index:方法名的索引值,指向常量池的的字符串常量。
  • descriptor_index:方法描述符的索引,指向常量池的字符串常量。
  • attributes_countattribute_info:表示方法相关属性的个数和属性集合,包含了很多有用的信息,比如方法内部的字节码就是存放在 Code 属性中。

方法描述符

表示一个方法所需的参数与返回值,格式为“(参数1类型 参数2类型 参数3类型 ...)返回值类型”。例如方法Object foo(int i, double d, Thread t)的方法描述符为:(IDLjava/lang/Thread;)Ljava/lang/Object;

2.2.8 属性表

属性表不只是在顶层的class文件中出现,从上文中可以得知,字段表与方法表中也有对应的属性表。属性表的结构如下:

在这里插入图片描述

各种不同属性有不同的结构,具体属性要具体分析,由于篇幅有限这里只介绍最重要的ConstantValue属性与Code属性。

ConstantValue

该属性只会出现在字段的属性表中,其结构如下:attribute_name_index 是指向常量池中值为 ConstantValue"的常量项,ConstantValue属性的attribute_length值恒定为 2,constantvalue_index指向常量池中具体的常量值索引,根据变量的类型不同constantvalue_index` 指向不同的常量项。

Code

方法中最重要的就是方法字节码,其实都包含在Code属性中,Code的属性结构如下所示:Code 属性表的字段含义如下:

  • attribute_name_index:属性名索引,占两个字节,指向常量池中字符串常量,表示属性的名字,比如这里对应的常量池的字符串常量"Code"。

  • attribute_length: 属性长度,占用两个字节,表示属性值大小

  • max_stack 表示操作数栈的最大深度,方法执行的任意期间操作数栈的深度都不会超过这个值。它的计算规则是有入栈的指令 stack 增加,有出栈的指令 stack 减少,在整个过程中 stack 的最大值就是 max_stack 的值,增加和减少的值一般都是 1,但也有例外:LONG 和 DOUBLE 相关的指令入栈 stack 会增加 2,VOID 相关的指令则为 0。

  • max_locals 表示局部变量表的大小,它的值并不是等于方法中所有局部变量的数量之和。

  • code_lengthcode 用来表示字节码相关的信息,其中 code_length 表示字节码指令的长度,占用 4 个字节。code 是一个长度为 code_length 的字节数组,存储真正的字节码指令。

  • exception_table_lengthexception_table 用来表示代码内部的异常表信息,如我们熟知的 try-catch 语法就会生成对应的异常表。

  • attributes_countattributes[] 用来表示 Code 属性相关的附属属性,Java 虚拟机规定 Code 属性只能包含这四种可选属性:LineNumberTableLocalVariableTableLocalVariableTypeTableStackMapTable。以LineNumberTable 为例,LineNumberTable 用来存放源码行号和字节码偏移量之间的对应关系,这 LineNumberTable 属于调试信息,不是类文件运行的必需的属性,默认情况下都会生成。如果没有这个属性,那么在调试时没有办法在源码中设置断点,也没有办法在代码抛出异常的时候在错误堆栈中显示出错的行号信息。

3.javap

最后再看一下将.class二进制文件反编译的命令javap:


~ FIN ~



推荐阅读
【Kotlin协程】Channel 与 Flow 深入解析
FragmentFactory 在 Koin 中的应用
打造一个 Kotlin Flow 版的 EventBus
kotlin协程:并发 & 线程安全



加好友拉你进群,技术干货聊不停


↓关注公众号↓↓添加微信交流↓


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存