作为一个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.Main
103
这次变成了 1 + 2 + 100 = 103。可以看到,我们并没有重新编译,所以你只要找对地方修改正确,就可以直接改变代码逻辑了。
5、结尾
当然,我并不鼓励你去修改线上的class。你也看到了,十六进制的代码是非常容易改错的。上面这个例子更多的是帮你理解java的字节码。
看了上面的内容,我不知道你是否联想到了Spring中的AOP。事实上Cglib就是通过直接修改字节码的方式来实现切面的。这点我相信你准备面试的时候肯定已经知道了,但是通过上面的内容,我相信你肯定有更深的理解了。
但事实上,更重要的话题是,要如何方便地修改字节码(不直接修改十六进制文件),这是我们下面一篇JVM系列文章会给大家介绍的内容。
此外,这些都只是编译过程中“耍的小花招”,如何重载一个运行时的类是更有意思的话题。虽然现在很多热部署方案存在各种各样的问题,从而大家都还是信赖静态编译后重新部署,但是了解对运行时类的修改和重载,是极有意义的,尤其是开发过程中。
本文转载自微信公众号「 CodingBetterLife」,作者「 赵志强 」,可以通过以下二维码关注。
转载本文请联系「 CodingBetterLife」公众号。