写点什么

一图详解 java-class 类文件原理

  • 2022 年 5 月 17 日
  • 本文字数:5777 字

    阅读完需:约 19 分钟

本文分享自华为云社区《【读书会第十二期】这可能是全网“最大“、“最细“、“最深”的一份java-class类文件原理图解了!》,作者: breakDawn。

 

借着华为云读书会的活动,重读了一遍《深入理解 java 虚拟机》。在阅读中, 用 processorOn 做了一副超大的类文件解析图,方便自己通过浏览这个图能马上回忆起 class 文件的结构以及内部的指令。


下面的内容是拆分后的内容,对于每块拆分的内容,会有详细的解释。

魔数、版本号

  • 每类文件都有一个魔数,用于快速校验文件类型。

  • 对于高低版本号,只要明确 java11\java8 这种版本是主版本号

  • 永远向下兼容, 即高版本 jvm 可以读取低版本的 class 文件, 但是低版本的 jvm 无法读取高版本的 class 文件

常量池(常量池个数、多个常量项)

大部分文件协议格式中,都会先给定一个某项的数量长度,再决定某项的个数,方便确认遍历几次才结束。常量池的设置也是这个原理。


因此学习 java 的 class 格式,对我们设计某些文件格式或者协议都是一种不错的借鉴。


Q:常量池中的常量到底是干嘛的?和我们理解的 static final String xxx 常量是一个意思吗?

A:不对!代码中定义的 final 类型字符串常量只是一种用途。更重要的一种用途是符号引用。而对符号引用的理解,是对 java 类文件原理最难也最重要的地方。直接去解释符号引用的话,还是很难理解的,因此我们按下不表,在第 4 部分“类索引”部分会给出详细解释。


Q:常量池的索引计数为什么从 1 开始(即其他地方要使用常量池的第一个常量时,必须写成 1 而不是 0)?A:因为要留一个 0,表示不引用任何常量

  • 举例:匿名类就是没有名字的,但是类文件结构中,类名那边总需要填入类名常量索引,因此可以填入 0,表示“没有类名”的意思。

  • 再来一个例子:object 类,是没有父类的,所以他的父类那一栏填的常量索引也是 0

  • 对于常量池的作用,后面会有更详细的体现和解释。

类定义的第一行(类访问标志、本类、父类、实现接口)

为什么叫类定义的第一行,因为这就来自我们写每个类时的第一行内容。


例如

public abstract class A extend B implement C,D

这句话对应的所有信息就包含在了上图中,因此我叫他“类定义的第一行”

CONSTANT_class_info 这个类常量到底是干嘛的?


从图上可以看到,他其实就是指向了一个表示类名的字符串常量。这里也可以看到,java 文件中的所有名称例如类名、方法名、字段名,都会以 Utf_info 的形式,存储在常量池中。

Q:为什么要这样多走一层?为什么不能直接指向一个字符串常量?A:这个问题我没找到解释,但可以理解为这是最基础的一层封装。

字段表(字段数量,各字段(修饰符、名、类型、属性))

可以看到,字段名、字段类型分别对应了 2 个字符串常量。特别注意字段类型使用一个字符串来表示的,而不是一个 constant_field_info。


那么 constant_field_info 是干嘛的呢?


Q:字段修饰符中的 synchetics 指的是编译器自动生成的字段,怎么理解呢?什么情况下会用到?A:找到一个简单的例子(代码出处:知乎-不凋花),用枚举做 switch:

enum Foobar {    FOO,    BAR;}class Test {    static int test(Foobar var0) {        switch (var0) {            case FOO:                return 1;            case BAR:                return 2;            default:                return 0;        }    }}
复制代码

switch 的原理,我们应该很容易想到,就是做一次顺序检查,那么检查时,肯定程序里需要有一个列表吧,因此上面 switch 的背后逻辑代码是长这样的:

class Test$1 {    static final int[] $SwitchMap$Foobar;    static {        $SwitchMap$Foobar = new int[Foobar.values().length];        try {            $SwitchMap$Foobar[Foobar.FOO.ordinal()] = 1;        } catch (NoSuchFieldError e) {            ;        }        try {            $SwitchMap$Foobar[Foobar.BAR.ordinal()] = 2;        } catch (NoSuchFieldError e) {            ;        }    }}
复制代码

可以看到有一个“static final int[] SwitchMapSwitchMapFoobar;”, 这个静态数组字段,就是编译器帮忙生成的字段,他会被标记成 synchetics。


Q:上面可以看到每个字段项的最后包含属性数量和属性长度,那么 class 中的属性和上面的“字段名”、“字段类型”有什么区别呢?A:属性是可有可无的,而且提供了高度的“jvm 可扩展性”。


换言之,在 jvm 虚拟机规范中,“字段修饰符”、“字段名”、“字段类型”都是必备的,而属性则没有限制。因此我们甚至可以自己实现一个虚拟机,定义新的属性,在 class 中加上属性项然后自己使用

对于属性作用的更详细理解,可以看后面的方法章节,方法中的属性是比较重要且用得最多的。从字段属性可以看到, 类似于 static final int a =10 这种常量,就是通过属性里的 constant 属性来设置的。有个泛型签名的属性,可能不太好马上理解,后面在方法章节中会一并提到这个属性的作用!

方法表(方法数量、方法项(修饰符、名、描述、属性))

class 文件中,最值得学习的就是常量池和方法表了!

方法修饰符中的桥接

对于方法修饰符,大部分都很好理解,有 2 个修饰符需要关注:“bridge”和“synthetic”。

其实很多 bridge 桥接方法本身也是 synthetics 系统生成的,所以我不太想去区分二者,只要关注他们 2 个用来做什么。


思考下面这个问题:

1. 假设有个非公开的类 A,A 中有个 public 方法 f(),有个继承自 A 的公开类 B,没有重写 f(),那么外部是否可以调用 b.f()?

private static class A {	f() {..}} 
public static class B extend A{ // 不重写任何方法}public static void main(String args[]) { B b = new B(); b.f();}
复制代码

我们很容易可以得出 b.f()可以调用的结论。


但由于 B 没有重写 f(), 所以对于编译后的 B.class 而言,这意味着不会在 class 文件中包含 f 方法。那么当执行 f 时,通过多态,会定位到 A.f(),此时 A 是非公开的类,权限就会出错,因为不允许直接引用非公开的类的方法,只能间接使用。


如何解决?要修改多态的动态分派校验机制吗?


不需要,编译器为了方便,直接为我们在 B 中重写了 f()来间接调用父类方法,类似于

public void f() {	super.f()}
复制代码

这样的话就不用担心外部调用者没有权限使用 A.f()了。


2. 有个泛型基类 Base<T>,包含一个方法 f(T t), 有个子类 Sub<String>, 实现了方法 f(String s), 两个 f 方法的入参并不一致,为什么还多态的机制还能生效?

class Base<T> {	f(T t);}
class Sub extend Base<String>{ f(String t);}
复制代码

这 2 个方法的入参确实不同, 前者的方法签名是 f(Ljava/lang/Object;)V, 后者是 f(Ljava/lang/String;)V。 多态(动态分派)的规则也没有变,确实是要求入参一致。


因此编译器为 Sub 类自动生成了一个 f(Ljava/lang/Object;)V,代码如下:

public void f(Object o) {	this.f((String)o);}
复制代码

这样多态的机制也能实现了。


可以看到这一切都是为了适配多态,同时避免过多的特殊逻辑,因此使用桥接方法,来生成了我们看不到的重写方法


从下面可以看到, 方法描述符是一个**包含“入参和返回值”**的描述符

因此,java 是允许 同入参、同方法名、不同返回值的方法存在于同一个 class 文件中的。


这是不是有点反常识?这种情况我们好像编写不出来的,编译器不会通过!


其实这也是桥接+自动生成才会有这种情况。前文的泛型例子,用泛型 T 做入参,会生成一个桥接方法,和父类的匹配。


那么如果泛型 T 是一个返回值呢:

class Base<T> {	T f();}
class Sub extend Base<String>{ String f();}
复制代码

那么也是一样的道理,桥接了一个父类的 f 方法,但仅仅是返回值不同而已。所以会出现只有返回值不同的方法。


方法表的属性和字段的属性类似, 也是属性数量 + N 个属性项。但是方法表属性里的干货就更多了!

属性的结构

之前字段属性中没提到属性到底长啥样,以方法中的 throws 异常属性为例,:

从这里可以看到,每个属性都有个属性名,和常量不同,区分不同常量用的是 1 个 2 字节的数字,而属性则是用一个字符串来表示。


这样的区别就是因为常量个数有限,而属性为了扩展性,不能存在数量限制。


另外从这也可以知道, 我们在方法名上写的 f() throws IOException 都是存在于异常属性中的。

最关键的 Code 属性

Code 属性是方法属性中最最最重要的属性。他告诉我们编译器是怎样将我们的文本代码封装成一个 class 文件的。


首先,code 属性的属性名就是一个“Code”

操作数栈、局部变量表大小、指令码数量

接着会包含 3 个重要的内容:max_stack、max_local 和 code_length

从 max_stack 和 max_local 我们可以看到,操作数栈和局部变量表的大小,已经在 class 文件中计算出来了,因此当开辟一个新的栈帧时,jvm 便能够知道给这个方法开辟多大的空间,不用担心栈上分配不够的问题。

注意,是操作数栈的大小,而不是程序执行的栈的深度,程序可没法感知我们能够递归多少次。

指令码解读

code_length 代表了我们这个方法在编译后,有多少条字节码指令,而后面紧跟着的,就是对应数量的 java 字节码指令了。

指令码种类非常多,这里只列举关键的一些信息。

数据计算用的指令码

首先,每种涉及基本数据类型的计算指令,都会在指令最前方,携带一个 T,如图:

里面有句话:“不是每种数据类型和每个操作都有指令对应(否则数量太多)”。


这句话怎么理解呢,可以结果图上右侧的表格,从而得知,有些指令是不包含所有类型的,所以可能会借用一些的技巧,比如把 byte、short 都视为 int 在操作上去操作。

对象操作的指令码

另一个类指令码是和对象操作有关,例如:

可以看到,当试图获取一个类字段时,他指向的是一个 class_field_info 常量索引,这个常量会提前被放进 class 文件的常量池中。


Q: 为什么它只包含了类引用和名称呢,我怎么知道我调用的是哪个对象的字段?A: 你要调用的对象,已经通过前面提到的操作数栈相关指令,把引用放到了操作数栈的第一个,因此,jvm 只要取栈顶对象,然后根据名字进行字段操作即可,后面的方法调用也是一样的道理。


Q : 另外可以看到,new 对象和 new 数组,用的是 2 个不同的指令,为什么要有区分?不能把数组当成一个 java 对象吗 A:这要从对象的内存结构,以及类加载机制上去思考。因为数组的对象头,和普通对象的对象头是不一样的。


  • 数组的对象头中包含了数组长度,而普通对象没有

  • new 一个数组时,数组中包含的类并不会做类加载。

  • 有这么多区别,肯定是新增一个单独针对数组的指令来处理,要简单很多

操作数栈指令

其他指令好理解, 但操作数栈指令有个 dup_x 指令,例如 dup1_1 就是复制栈顶并再放入 1 个。为什么需要这么一个指令?


其实当我们调用 A a = new A()时,这一句话生成的指令中就包含了 dup 指令因为当我们 new 出 1 个 A 引用时,它有两件事要做:

  1. 调用 A 的构造函数。

  2. 把引用地址赋值给 a 这个局部变量

  3. 而每件事都会消耗一个 A 的引用!所以才需要赋值。

  4. 因此可以看到,指令码很多时候都是基于操作数栈进行操作的,每操作一个数据或引用,就消耗一个

方法调用指令

对于方法调用指令,和前面的类字段调用有点像,也是一个方法常量,方法常量包含类索引和方法描述索引。对于方法究竟是如何触发调用实现多态的、invokevirtual 指令和 invokedynamic 指令有什么区别,这个内容就更多了,后面我会放到类加载的图解笔记中讲解。

异常表属性

指令码结束后,后面会紧跟着一个异常表。表中的每一行长这样:

是不是恍然大悟,原来 try-catch 代码的逻辑在这边, 它本质上就是抛异常时,根据 try 的位置和异常类型,这个异常表中进行查找到对应的 catch 代码位置,从而实现异常处理。


Q: 那 finally 的操作被放到哪了?catch 操作完了之后,它怎么知道要跳转到哪里?A: finally 模块在 java 语言中是必须执行的,在编译的时候,通过将 finally 中代码块分别在 try 模块的最后和 catch 模块的最后都复制了一份,通过这样来保证 finally 的必定执行。


Q: 有一个问题,对于 synchronized 关键字,它本质是生成了 monitorenter 和 monitorexit 两个指令(上面方法调用指令里的最后 2 个)。但如果发生了异常,那会不会无法 monitorexit 了?A: 生成 code 字节码时,jvm 会自动为 synchronized 生成 1 个默认的异常表和 throw 指令,保证中间同步块发生异常时,monitorexit 能够正确被指令(类似于放了一个自动生成的 try-catch 代码,或者在已有的 catch 操作后添加)。


Q: 前面提到方法属性中,已经有一个名叫“Exception”的属性,和这个 code 属性中的异常表有什么区别?A: 上面 code 异常表指的是代码执行时 try-catch 的逻辑部分而方法中的 exception 属性则是方法名上所声明的 throws 异常。

Code 的扩展属性

在 code 属性中,竟然还携带了属性,也就是说,是允许“属性中的属性”。毕竟属性的实现是可以完全自定义的,那么自己给自己新增额外特性完全是允许的。

里面有个属性叫“局部变量描述属性”,长这样:

从这里,你就能明白,为什么你从 IDEA 里看到反解后的 class 文件,有时候是 var1、var2 之类莫名其妙的局部变量,有时候却又能看到完整的变量名了吧?就是通过这个属性决定的。毕竟存储局部变量名的代价还是很高的。

其他的方法属性

泛型签名这个属性很迷惑,不是有泛型擦除吗,为什么还需要这个属性?


其实泛型签名属性是为了方便反射的。


我们通过前面关于桥接的原理,可以知道编译时会发生泛型擦除,方法入参都变成了 object。


但是反射 API 可能希望获取泛型信息因此可通过这个扩展属性进行获取。所以会增加这个属性,从而能感知一些泛型属性相关的信息。

类属性

既然方法和字段都有属性,那么类肯定也有属性:

其他属性都比较好理解或者不重要,重点讲一下内部类属性。


通过内部类属性,我们可以看到内部类并不是直接包含在这个 class 文件中,它其实是生成了另一个 class 文件,所以才需要一个内部类属性,来确认对应的名字,方便类加载时能找到内部类。


Q: 为什么内部类属性中,要包含宿主类的类名?难道宿主类,不就是它本身吗?

A: 因为,内部类中,还可以继续定义内部类哦!

另外,从上面的一些属性中可以看到, 很多 debug 用的调试、展示信息,都会包含在 class 中

因此,当我们希望调试一些环境上执行的程序时,如果想提供最为贴近原代码,那就需要 class 文件中能有充足的信息,如果想要 class 文件小,那就去掉,具体怎么去掉或者添加,肯定就是一些编译选项的区别了。

最后的完整图

好累,终于写完了,感觉能看到最后的人不会太多,但一通详细地分析和解决中间发现的问题,还是收获了不少。


最后贴上完整的大图,欢迎保存和收藏。

欢迎点击该链接报名参加读书会,一起成长学习和交流!


点击关注,第一时间了解华为云新鲜技术~​

发布于: 刚刚阅读数: 2
用户头像

提供全面深入的云计算技术干货 2020.07.14 加入

华为云开发者社区,提供全面深入的云计算前景分析、丰富的技术干货、程序样例,分享华为云前沿资讯动态,方便开发者快速成长与发展,欢迎提问、互动,多方位了解云计算! 传送门:https://bbs.huaweicloud.com/

评论

发布
暂无评论
一图详解java-class类文件原理_Java_华为云开发者社区_InfoQ写作社区