首页
搜索 搜索
当前位置:行业聚焦 > 正文

【世界时快讯】JVM系列:几张图看懂Java字节码

2023-07-03 10:36:08 CodingBetterLife

作为一个java程序员,如果你不懂字节码的话,你只能算是初级程序员了。

这可不是耸人听闻。了解字节码你才能真正了解包括“动态代理的原理”、“类加载的细节过程”、“重载和重写是如何实现的”、“多态是如何实现的”、“泛型究竟是什么”等等。

了解这些对你的工作有实际的意义:


(资料图片仅供参考)

比如,开发时碰到问题,需要确定一个jar包是不是最新的(开发时我们经常使用一个SNAPSHOT版本反复打包)。

比如,你在工具组,要开发一个动态修改运行中的类的工具。

比如,你碰到大厂高阶一些的面试,尤其是偏底层的团队。

比如,你碰到各种诡异的包括类找不到、方法找不到、java版本错误无法解析类等问题。

了解字节码就仿佛给了你另一双眼睛和更高纬度的视角,来应对各种java相关的工作。

话不多说,我们这就开始。

1、Java代码从编写到运行

图片

如图所示,我们写好的java代码会先编译成字节码,然后JVM会加载这些字节码并执行其中的命令,也就是将字节码翻译为机器码执行。

这里有两个重点:

1)其他语言写的代码,如果可以翻译成标准的java字节码,一样可以在JVM中运行、事实上,有一些语言已经可以这么做了(包括Groovy、JRuby、Scala、Clojure、Kotlin等)。这是字节码技术面向未来的最大卖点,也是JVM的宏大愿景。

2)如果我们改变了编译出来字节码,其实就改变了逻辑。不需要去改原来的java代码。

2、字节码怎么看

下面我们用一个例子来看看字节码到底是什么,长啥样。

public class Calculator {    private static final int offset = 100;    public int add(int x, int y) {        int z = x + y;        int m = z - offset;        return m;    }}

这段逻辑很简单。我定义了一个Calculator类,它有一个add方法,就是传入两个数值参数,把他们相加后再减去一个固定值offset,然后返回。

我们使用javac命令编译后,得到如下文件(十六进制):

图片

这个class人肉读起来非常费劲。他有其固定的格式,你需要按照“说明书”一个字节一个字节翻译过来才行。

这里我不做详细的介绍,看了下图你基本上就能大致了解了。

图片

一般我们看编译后的文件,绝对不会直接看十六进制的。而是使用javap命令,将其转化为我们更容易看懂的格式。我们来执行下命令:

javap -verbose Calculator.class

得到如下这样的内容:

图片

不要着急,我来逐块分开和你讲讲。

3、字节码的含义

我们将字节码的内容分为三块来讲:类信息、常量池和方法表。

1)类信息

这里面的内容不多,也比较容易理解。

major version: 52说明这是一个使用java8编译出来的class。

flags:ACC_PUBLIC表示这个class是public的。ACC_SUPER在JDK1.2以后编译出来的类都会带上这个标志,这和JDK1.2以后invokespecial指令的变化有关。

2)常量池

常量池中保存着非常丰富的信息。包括:方法的符号引用、类的符号引用、字段或方法描述符、各种字符常量、各种基础类型字面量等等。你可以简单理解为各种你定义的类名称、属性名称、方法名称、常量以及系统自身需要的一些字面量,都会在这里定义。

下图是Calculator类的常量池数据,我做了一些标注,方便你的理解。

图片

常量池的第一列是“常量类型”,一共有十几种。每一种决定了第二列的数据格式。我这里就不详细贴图了。

常量池的第二列会有其他一些常量信息的引用,这些引用往往还是嵌套的。不过如图所示,每个复杂的常量后都有注解帮你拼接好了。

3)方法表

在Calculator类中,有两个方法。其中一个是我们定义的add方法,另一个则是默认构造函数。

【方法1 - 构造函数】

我们先来看下默认构造函数。和上面一样,我直接在图中做了说明,方便理解:

图片

【方法2 - add函数】

在介绍add方法前,我们要先简单介绍下JVM的执行引擎。

JVM的执行引擎称之为“基于栈的执行引擎”。也就是说,所有计算的中间结果都存在一个专门的栈中。与之相对应的就是历史更悠久的经典“基于寄存器的操作引擎”。

我们用一个 x + y * z 的例子,来看下两种操作引擎的差别。见下图

图片

可以看到,完全一样的代码,寄存器的每个指令都需要多个参数,而基于栈的操作指令只需要一条。这是因为寄存器需要指定数据从哪个寄存器中拿(cpu有十多个寄存器),但基于栈的操作指令只会涉及一个操作数栈,所以只需要声明出栈或者入栈即可。

下面就是 x + y * z 这条命令对应的字节码操作过程:

图片

介绍完这个后,我们就可以看下Calculator类中add方法的字节码内容了:

图片

到这里,我们就把字节码中主要的三块内容都讲完了。

4、字节码实践:直接修改字节码

既然我们了解了字节码的结构,我们就要实践一下直接定位并修改字节码,不然的话只是纸上功夫了。

我们写了个main函数来调用上面的Calculator类,这里把Main和Calculator类都贴一下:

public class Main {  public static void main(String[] args) {        System.out.println(new Calculator().add(1, 2));    }}
public class Calculator {    private static final int offset = 100;    public int add(int x, int y) {        int z = x + y;        int m = z - offset;        return m;    }}

我们编译并运行一下,得到如下结果:

$ javac com/codingbetterlife/justmylab/utils/*.java$ java -cp . com.codingbetterlife.justmylab.utils.Main-97

结果为 1 + 2 - 100 = -97

下面我来把add方法中的第7行通过字节码改成 int m = z + offset。

图片

修改后的class我们通过javap反编译后能看到,命令已经从isub变成了iadd。我们来运行下看看结果:

$ java -cp . com.codingbetterlife.justmylab.utils.Main103

这次变成了 1 + 2 + 100 = 103。可以看到,我们并没有重新编译,所以你只要找对地方修改正确,就可以直接改变代码逻辑了。

5、结尾

当然,我并不鼓励你去修改线上的class。你也看到了,十六进制的代码是非常容易改错的。上面这个例子更多的是帮你理解java的字节码。

看了上面的内容,我不知道你是否联想到了Spring中的AOP。事实上Cglib就是通过直接修改字节码的方式来实现切面的。这点我相信你准备面试的时候肯定已经知道了,但是通过上面的内容,我相信你肯定有更深的理解了。

但事实上,更重要的话题是,要如何方便地修改字节码(不直接修改十六进制文件),这是我们下面一篇JVM系列文章会给大家介绍的内容。

此外,这些都只是编译过程中“耍的小花招”,如何重载一个运行时的类是更有意思的话题。虽然现在很多热部署方案存在各种各样的问题,从而大家都还是信赖静态编译后重新部署,但是了解对运行时类的修改和重载,是极有意义的,尤其是开发过程中。

本文转载自微信公众号「CodingBetterLife」,作者「赵志强」,可以通过以下二维码关注。

转载本文请联系「CodingBetterLife」公众号。