java字节码

21 min read,created at 2024-07-09
java字节码classFile

1 什么是字节码

.class文件的内容就是字节码,jvm定制了一套class文件规范,只要按照这个规范的文件就可以在jvm中被加载成

img

class文件与elf这种可执行文件一样,也是一种数据结构,或者说是一个结构体,只不过相比于elf来说,class文件要简单太多了,简单讲就是把我们的java代码给压缩了。通过xxd指令可以简单的看一下,class文件中的内容,这些字节数组就是字节码,整个文件满足ClassFile的结构规范。

img

2 类文件规范

class file specification类文件规范就是字节码规范,可以从oracle Java SE Specifications的文档中找到第四章,链接,这部分是class文件的规范,url中se21是java21版本的规范,可以改成其他比如se7,你会发现class文件的整体结构变化不大,并且java有着非常变态的向前兼容性,java21能够兼容java1.0版本的class文件。

官网上来就贴出来了一张图,这张图至少从java7到21都没有变过,更早的文档不在这里维护了,不过可能也没变过,就是下面这张ClassFile的结构。

img

每个class文件都是满足这个结构体的。我们可以下载jclasslib小工具或idea插件,打开后会发现在通用信息中,其实涵盖了ClassFile结构体的大部分字段,这些字段都是单层深度,没有嵌套。

img

插件的用法是,先compile之后,通过view->Show Bytecode With Jclasslib

img

一般信息中,涵盖了一个类的基础信息,对应了上面图片中单层结构的字段,即非数组的部分。这部分比较简单,有一些内定的值,比如

  • 大版本号52对应java8,53是java9...
  • 访问标识指类是public/pravite等。
  • thisparent的类名,是用/分割的,而不是.

剩下的部分在左侧栏,依次是常量池 实现的接口列表 字段列表 方法列表 属性列表

2.1 常量池

常量池一般是ClassFile结构体中最大的部分之一(方法也很大),cp_info是每一种常量类型的结构体,如下,第一个字节是类型,后面是数据,每一种类型或者叫tag,对应的info[]的内容是不一样的。

cp_info {
    u1 tag;
    u1 info[];
}

主要的tag类型如下(这个其实不重要所有这些信息都可以从官方文档看到,只是简单列举一下)

Constant TypeValue
CONSTANT_Class7
CONSTANT_Fieldref9
CONSTANT_Methodref10
CONSTANT_InterfaceMethodref11
CONSTANT_String8
CONSTANT_Integer3
CONSTANT_Float4
CONSTANT_Long5
CONSTANT_Double6
CONSTANT_NameAndType12
CONSTANT_Utf81
CONSTANT_MethodHandle15
CONSTANT_MethodType16
CONSTANT_InvokeDynamic18

这些类型中很多都是定长的,很容易定位,比如Long Integer Float等等,甚至Class这些基本也是定长的,虽然类名长度是不确定的,但是Class_info是定长3个字节,后面2个是个下标指向类名字符串的位置,最终会指向一个CONSTANT_Utf8

img

image

CONSTANT_Utf8中有2个字节记录长度,如下,所以这样就能分割出每种结构了,要么是定长,变长的会有字段记录长度。

img

2.2 接口

这部分是当前class实现的接口列表,interfaces部分的定义是个u2[]而不是字符串,每个u2cp_info的一个下标指,即接口名也是作为字符串常量存储到常量池的。

如下,实现了java.lang.Runnable,这里存储的是下标4,指向的是常量池字符串java/lang/Runnable

img

2.3 字段

fields部分是如下结构体的数组,

field_info {
    u2             access_flags;
    u2             name_index;
    u2             descriptor_index;
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

image

access_flags与之前类的是类似的,name_index记录字段名在常量池的下标,所以是u2类型,descriptor_index是类似的,表示当前字段的类型描述。这里需要专门解释下discriptor的形式,后面方法中也会看到类似的。基础类型都是用一个字母表示,而对象类型是用Ljava/lang/String;表示,注意前面有个L,后面有个;,这是一个discriptor写法规范。基础类型的描述符如下,基本都是首字母的大写:

  • int I
  • long J (因为L给长类型用作前缀了,所以换J)
  • short S
  • byte B
  • float F
  • double D
  • boolean Z (因为B给byte了,所以换Z)
  • char C

attribute_info是属性列表,这个暂时跳过,最后会简单提一下,属性非常多,每个java版本基本都会新增一些属性。

2.4 方法

方法是method_info数组如下,也是访问级别,名称,描述和属性,与field_info是一模一样的。

method_info {
    u2             access_flags;
    u2             name_index;
    u2             descriptor_index;
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

img

field_info的描述符格式不一致,函数的描述符是由(入参)返回值组成的,入参如果有多个,是并排的列出的并不需要任何符号隔开,例如String test(int a, long b, String c)的描述符就是(IJLjava/lang/String;)Ljava/lang/String;。而返回值部分除了正常的返回值类型的描述符,还增加了一种V是对void返回类型的描述。同时还需要注意有两个方法名是比较特殊的,一个是<init>指的是构造方法的方法名,还有一个是<clinit>是静态代码块组成的类初始化时候运行的方法。

此外,方法中都会有code这个属性,该属性中放置了方法的代码字节码,我们也放到属性部分再说。

2.5 属性

ClassFile级别最后的部分是由属性attributes的,而上面的field_infoclass_info中也是有attributes信息的,属性信息会有较大可扩展性,很多java新版本的特性想要扩展,那属性是一个很好的放置位置,以便于不改变整体的结构。属性部分是最复杂,在jdk21的规范中已经支持了30种属性结构了。

img

这里我们不再对每一种属性都单独讲解了,官方文档有较为细致的解释,这里挑几个比较常见的。

2.5.1 ConstantValue

ConstantValue类型,只针对常量static final基础类型或字符串的属性,在编译器赋值,而不是运行时,提高效率。

img

2.5.2 Code

Code类型,函数体的内容,这个是非常重要的,尤其是后面学习ASM指令,一个类主要承载的功能,都反应在了methodcode里,code类型的结构体非常复杂,我们可以直接看jclasslib给我们图形化展示之后的,以构造方法<init>为例,这段代码中,我们虽然没有写构造方法,但是默认也会有构造方法,默认的实现就是super()也就是调用父类的构造方法;此外我们还对字段进行了赋值所以有如下代码。

img

我们在下一节会详细展开介绍code中的不同指令。

2.5.3 Exception

Exception类型,函数中声明的抛出的异常,可以有多个。注意这里是声明的抛出的异常,不包含一些运行时的异常。

img

img

区分Code异常表Exception属性:通过try-catch的异常会出现在code异常表

image

2.5.4 LineNumberTable

Code属性中的一个属性,记录行号的,方便debug

2.5.5 LocalVariableTable

局部变量或者叫本地变量表,也是Code中的属性,记录本地变量的名字,比如方法中int a = 100;a这个变量名字和变量索引的对应关系就会记录在局部变量表,这个也是debug方便的,与LineNumberTable一样,他俩即使删了,也不影响字节码运行。因为学asm的时候会看到这俩所以提一下。

img

2.5.6 Signature

Signature与泛型密切相关,虽然java的泛型在执行的时候会被擦除,但是这是为了兼容老版本的java,泛型信息其实还是被记录了下来,会被放置到这个属性中,例如names是个List<String>,他的字段信息中只有List没有泛型信息,但是Signature属性中,是有记录泛型信息的。

img

img

3 函数Code中的指令

ClassFile的结构介绍完毕了,其中最最核心的部分其实没有展开,那就是函数的code部分的字节码。这里我们需要了解,操作数和操作数栈的概念:

操作数就是常见的变量例如基础类型和对象引用,我们的函数就是在操作这些操作数,如果想要操作他们,那么必须先进行load加载,加载会将操作数加载到一个栈的数据结构上,这个栈就是操作数栈。例如我们想要完成a + b这个操作,需要把a加载到栈,再把b加载到栈,然后运行加法操作。

我们看一下对应的字节码:

image

通过这个图,我们有了一个大概的概念,就是我们想要执行一个操作或者说一个行为,不管是加法操作还是函数调用操作还是其他操作,都需要先准备好要操作的数,比如这里的ab要先load到栈上,然后执行iadd进行加法操作,操作会消耗掉栈顶特定个数的操作数,比如iadd是消耗两个,如果操作有返回,也会放置到栈顶。

接下来我们就需要了解一些常用的指令了,比如操作数需要load才能放置到栈顶,那么有哪些load指令呢?

3.1 load/push

load的形式有很多种,比如我们可以把本地变量load到栈顶

  • iload_{y}按照intbytecharbooleanshort类型,加载第y个变量。
  • lload_{y}按照long类型加载第y个变量。
  • fload_{y}按照float类型加载第y个变量。
  • dload_{y}按照double类型加载第y个变量。
  • aload_{y}按照对象类型加载第y个变量,aload_0加载this,默认第0个位置是this

或者常量load到栈顶

  • ldc load contant 加载常量(intbytecharbooleanshortfloat类型或字符串常量)
  • ldc_w 如果上面几种类型,因为一些不可抗力存到了宽索引,即2个栈帧中,则需要用这个指令,较少使用。
  • ldc2_w 加载longdouble类型常量

但是ldc对于一些小数字类型的性能稍差(但也可以用),于是为了性能有一些专门的指令

  • iconst_<n>如果是0-5可以优化性能
  • iconst_m1同上专门针对-1的load
  • bipush针对byte范围的int值的load
  • sipush针对short范围的int值的load
  • 上面只是int的其他类型也有专门的指令,这里不再列出。

3.2 store

上面iload_1是把本地变量1加载到栈顶,但是一开始没有存储本地变量1呢?所以是会先有一个存储的过程,这就是store指令了。

  • istore_{y}把栈顶的intbytecharbooleanshort类型消耗掉,存到本地变量y,y是数字。
  • lstore_{y}把栈顶的long消耗,存到本地变量y。!!注意long占用两个栈帧,消耗掉两个栈顶的位置。
  • fstore_{y}把栈顶的float消耗,存到本地变量y。
  • dstore_{y}把栈顶的double消耗,存到本地变量y。!!注意double占用两个栈帧,消耗掉两个栈顶的位置。
  • astore_{y}把栈顶的对象地址消耗,存到本地变量y。

3.3 return

return之后需要保证栈是空的,不然编译会验证不通过。

  • return等于代码return,不消耗栈顶
  • ireturn消耗栈顶一帧,返回一个intbytecharbooleanshort类型
  • freturn消耗栈顶一帧返回一个float
  • lreturn消耗栈顶2帧返回一个long
  • dreturn消耗栈顶2帧返回一个double
  • areturn消耗栈顶一帧返回一个地址,即返回一个对象类型的内存地址

注意:return不一定是代码结束的地方,可能有判断分支有多个return语句,而且还有可能是athrow抛出异常。

3.4 pop/dup/new

如果一个栈上的操作数,想要直接消耗掉,则直接用pop指令消耗一个栈帧,比如运行了一个函数操作后,直接忽略函数的返回值就可以pop消耗掉,如果返回值是long/double可以pop两次,或者pop2指令消耗。

如果想要复制一份操作数栈顶的数,即栈顶连续两个相同操作数则使用dup dup2这样的指令,这经常用于new一个对象。

Object obj = new Object();

对应字节码,如下new指令作用是,创建一个对象会在堆上分配内存,并将内存的地址放到操作数栈上;注意这里有个dup把地址复制了一份,这是new对象的一个固定操作,因为invokespecial #1 <java/lang/Object.<init> : ()V>这个构造方法与普通非静态方法一样,会消耗掉一个操作数作为this。所以需要提前把地址备份一下,不然new完地址就丢了,下面会说invoke相关指令。

new #4 <java/lang/Object>
dup
invokespecial #1 <java/lang/Object.<init> : ()V>

img

dup还有一些变种,例如dup_x1效果是[top-A-B] => [top-A-B-A],复制栈顶,但复制的位置是跳过一个。dup_x2同理还有什么dup2_x1 dup2_x2,当然这几个指令都可以用dup pop store load实现,只不过这个效率更高。

此外new不能创建数组对象,数组比较特殊,有专门newarray基础类型数组,anewarray创建对象类型数组,multianewarray创建数组类型数组(多维)。

3.5 invoke

invoke是函数调用的指令,他主要有5种,

  • invokevirtual普通的可访问的方法,需要依次把对象参数从左到右放到栈顶。
  • invokestatic静态方法,需要依次把参数从左到右放到栈顶。
  • invokespecial特殊方法,构造方法,私有方法,父类中的方法,接口的default实现等,根据情况参考上面的操作数顺序。
  • invokeinterface接口方法,栈顶操作数顺序参考上面。
  • invokedynamic动态方法,一般是lambda表达式,栈顶操作数顺序参考上面。

3.6 基础运算

基础运算是加减乘除位运算等,[iflda]是代表类型,下面用{t}表示

  • {t}add {t}sub {t}mul {t}div 栈顶俩数,加减乘除四则运算
  • {t}and {t}or {t}xor 栈顶俩数,与 或 异或,注意没有非门,非是通过和全1的值异或得到。
  • {t}shl {t}shr {t}shur 左右移 无符号右移<< >> >>>,没有无符号左移,左移与符号本就无关。
  • {t}rem 栈顶一个数,取余%
  • {t}neg 栈顶一个数,取反-a
  • {t}2{t} 基础类型转换
  • iinc int特有的++操作符,其他类型没有

3.7 跳转相关

当出现流程控制的时候,字节码会变得复杂。例如if(a>b) print("a>b"); else print("a<=b");最基本的判断分支,单次执行只能走到一个分支,那就需要跳转。还是用{t}表示类型,用{cond}表示条件:eq等,ne不等,lt le gt ge小于、小于等于、大于、大于等于、

  • if_{t}cmp{cond} num 比较栈顶两个数,是否满足cond,如果是则跳转到num指令。
  • if{cond} num 直接判断栈顶一个数,是否满足cond,例如ifeq代表栈顶为0则跳转,ifnq是栈顶不为0跳转
  • goto num 无脑跳转

这是条件分支的代码,满足a<=b跳转16行,否则继续往下执行,执行到goto直接跳转到24行return指令。

img

这里不得不提一些try-catch,例如对上述代码套一层,看字节码会发现两个判断分支都会走到24指令,原来的24是return现在是goto 36,而后者其实就是return,所以看上去根本执行不到24-36之间的catch处理。

img

其实try-catch是专门记录到code的异常表中的,上面提到过异常表和异常属性的区别。

imag

注意只需要记录tryStart tryEndcatchStart,不需要记录catchEnd,因为catch中可以自己goto跳走,或return/athrow结束。

感兴趣的可以自己看下,如果是try-catch-finally会是怎样的字节码,要复杂很多。