正式分享之前,先回忆一下作者年少时的一次真实的面试囧途。
经验老道的面试官:
先问个简单的问题,i++ 与 ++i 有啥区别?
年少懵懂的攻城狮:
i++ 先把操作数加 1,然后把操作数放入表达式中运算;
++i 先把操作数放入表达式运算,然后把操作数加 1。
经验老道的面试官:
略微点点头,虽然面带微笑,不过感觉不太满意的样子... ...
时隔多年,回想起那个面试场景,忍不住要感叹:年少不懂i++(爱家家),如今懂了却已是老码农(双鬓白)。
相信大部分人都会这么教科书式的回答,但是能否从字节码角度再深入一点点呢?
1
准备:拿到字节码指令集文件
首先具备 Java 环境(能打开此文章,说明你肯定具备此环境)。
java version "1.8.0_251"
Java(TM) SE Runtime Environment (build 1.8.0_251-b08)
Java HotSpot(TM) 64-Bit Server VM (build 25.251-b08, mixed mode)
能开发代码的工具(不强求IntelliJ IDEA),然后写出如下图 IPlus.java 就可以。
public class IPlus {
public static void main(String[] args) {
int i = 0;
i = i++;
System.out.println(i);
}
}
编译 IPlus.java 源文件,生成对应的字节码文件。
接下来对 IPlus.class 文件进行反编译,当然推荐可以使用工具 ClassPy、JavaClassViewer、jclasslib 查看 class 文件结构,本次就用 jdk 自带的命令 javap 来查看 class 文件的结构,并把字节码指令集重定向输出到文件 iplus_javap.txt 中。
javap -v IPlus.class >> iplus_javap.txt
javap 是 Java class 文件分解器,可以反编译,也可以查看 java 编译器生成的字节码,用于分解 class 文件,可以解析出当前类对应的 code 区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等等信息。
Classfile /Users/yiyuanxiaojiangV5/IdeaProjects/IPlus.class
Last modified 2020-8-27; size 516 bytes
MD5 checksum a5279417c4e7cce8408ce9bb53c44205
Compiled from "IPlus.java"
public class IPlus
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #5.#21 // java/lang/Object."<init>":()V
#2 = Fieldref #22.#23 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Methodref #24.#25 // java/io/PrintStream.println:(I)V
#4 = Class #26 // IPlus
#5 = Class #27 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 LocalVariableTable
#11 = Utf8 this
#12 = Utf8 LIPlus;
#13 = Utf8 main
#14 = Utf8 ([Ljava/lang/String;)V
#15 = Utf8 args
#16 = Utf8 [Ljava/lang/String;
#17 = Utf8 i
#18 = Utf8 I
#19 = Utf8 SourceFile
#20 = Utf8 IPlus.java
#21 = NameAndType #6:#7 // "<init>":()V
#22 = Class #28 // java/lang/System
#23 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(I)V
#26 = Utf8 IPlus
#27 = Utf8 java/lang/Object
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream;
#31 = Utf8 java/io/PrintStream
#32 = Utf8 println
#33 = Utf8 (I)V
{
public IPlus();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LIPlus;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: iconst_0
1: istore_1
2: iload_1
3: iinc 1, 1
6: istore_1
7: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
10: iload_1
11: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
14: return
LineNumberTable:
line 3: 0
line 4: 2
line 5: 7
line 6: 14
LocalVariableTable:
Start Length Slot Name Signature
0 15 0 args [Ljava/lang/String;
2 13 1 i I
}
SourceFile: "IPlus.java"
接下来跟随小猿的脚步,一起去分析字节码指令集文件 iplus_javap.txt,尝试彻底搞懂 i++。
2
解剖:字节码全局了解
一:Classfile 文件信息
Classfile /Users/yiyuanxiaojiangV5/IdeaProjects/IPlus.class //class文件的路径
Last modified 2020-8-27; size 516 bytes //最后一次修改时间以及该class文件的大小
MD5 checksum a5279417c4e7cce8408ce9bb53c44205 //该类的MD5值
Compiled from "IPlus.java" //编译自源文件
这块感觉不用详细解释,仔细去看,应该都能懂。
第 1 行:class 文件的路径
第 2 行:最后一次修改时间;该 class 文件的大小。
第 3 行:MD5 checksum 值,例如下载文件的场景下会用于检查文件完整性,检测文件是否被恶意篡改。
第 4 行:编译自 IPlus.java 源文件。
二:类主体部分定义信息
public class IPlus //类名
minor version: 0 //次版本号
major version: 52 //主版本号,52 对应 JDK 1.8
flags: ACC_PUBLIC, ACC_SUPER //该类的权限修饰符(访问标志)
重点关注第 2、3 两行,为什么要重点关注呢?业务开发中估计多数都遇到过 Unsupported major.minor version 的错误。其实就是通过高版本的 JDK 进行编译(例如 JDK 1.8),然后跑在低版本的 JDK 上(JDK 1.5),就会报版本不支持。
为了使用方便,特意整理一 JDK 各版本图,请拿走不谢。
三:常量池信息
Constant pool: // 常量池,#数字相当于是常量池里的一个索引
#1 = Methodref #5.#21 // java/lang/Object."<init>":()V // 方法引用
#2 = Fieldref #22.#23 // java/lang/System.out:Ljava/io/PrintStream; // 属性引用
#3 = Methodref #24.#25 // java/io/PrintStream.println:(I)V
#4 = Class #26 // IPlus // 类引用
#5 = Class #27 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 LocalVariableTable
#11 = Utf8 this
#12 = Utf8 LIPlus;
#13 = Utf8 main
#14 = Utf8 ([Ljava/lang/String;)V
#15 = Utf8 args
#16 = Utf8 [Ljava/lang/String;
#17 = Utf8 i
#18 = Utf8 I
#19 = Utf8 SourceFile
#20 = Utf8 IPlus.java
#21 = NameAndType #6:#7 // "<init>":()V //返回值
#22 = Class #28 // java/lang/System
#23 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(I)V
#26 = Utf8 IPlus
#27 = Utf8 java/lang/Object
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream;
#31 = Utf8 java/io/PrintStream
#32 = Utf8 println
#33 = Utf8 (I)V
#数字相当于是常量池里的一个索引,例如上面代码段里 #1 代表的是一个方法引用,并且该引用由 #5.#21 构成。
在 JVM 规范中定义了很多常量类型,汇总本次的遇到的几个。
四:构造方法信息
public IPlus();
descriptor: ()V //方法描述符,这里的V表示void
flags: ACC_PUBLIC //权限修饰符
Code:
stack=1, locals=1, args_size=1
0: aload_0 // aload_0 把this装载到了操作数栈中
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LIPlus;
descriptor:方法入参和返回描述;
flags:访问权限控制符为 public;
stack:方法对应栈帧中的操作数栈的深度为 1;
locals:本地变量数量为 1;
args_size:参数数量为 1;
aload:从局部变量表的相应位置装载一个对象引用到操作数栈的栈顶;
invokespecial:调用一个初始化方法;
LineNumberTable、LocalVariableTable:前者代表行号表,是为调试器提供源码行号与字节码的映射关系;后者代码本地变量表,存放方法的局部变量信息,属于调试信息。
思考一:通过这段字节码信息,印证了一个准则:在没有显示声明构造的情形下,Java 会默认提供无参构造方法。
思考二:虽然是无参构造器,为什么 args_size 的值是 1 呢?是因为无参构造器和非静态方法调用会默认传入 this 变量参数,其中 aload_0 即表示的 this。
五:main 方法的信息
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: iconst_0
1: istore_1
2: iload_1
3: iinc 1, 1
6: istore_1
7: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
10: iload_1
11: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
14: return
LineNumberTable:
line 3: 0
line 4: 2
line 5: 7
line 6: 14
LocalVariableTable:
Start Length Slot Name Signature
0 15 0 args [Ljava/lang/String;
2 13 1 i I
通过 descriptor 、flags 能直观的能够读懂 main 方法的入参,返回值以及访问修饰符;通过 LocalVariableTable 运行时候的局部变量表,能够看到 main 函数的 args 参数保存在了 LocalVariableTable 中。
3
解剖:字节码指令看看 i++ 的执行
为了便于理解,把本次用的字节码指令先列一下,大家结合着去读。
重点关注 main 方法中的如下指令(红色圈住部分)
结合字节码指令列表,把上面红色圈住部分解读一下,主要分两部分。
//第一步:int i = 0;
0: iconst_0 // 将常量 0 推送至栈顶。
1: istore_1 // 将栈顶的值保存到局部变量 1 中,i = 0。
//第二步:i = i++;
2: iload_1 // 从局部变量 1 中装载 int 类型值入栈,此时栈顶的值为 0。
3: iinc 1, 1 // 将第 1 个局部变量进行加 1 操作,此时局部变量 i = 1。
6: istore_1 // 取出栈顶元素 0 保存到局部变量 1 中,此时的值为 0。
为了更清晰,不妨贴一个字节码里的指令与源代码的一个对应关系图。
懂了 i++ 的执行原理,再去看 ++i 的执行原理,就很容易了,本次不带着刻意去分析,放一张图你就懂了(左侧是 i++ 的指令,右侧是 ++i 的指令)。
简单做个总结:i++ 会在本地局部变量中对数字做相加,但是并没有将值推至栈,那么再次从栈中便会拿到相加前的数值,保存到本地变量中;而 ++i 也会将本地局部变量中的数字做相加,但是将数据做了入栈操作,那么再次从栈中便会拿到相加后的数值,再次压入到本地变量中。
4
寄语写最后
本次,主要让大家从字节码角度看看 i++、++i 的执行原理,希望通过本次分享,大家对 Java 字节码不再恐惧,并且希望能够学以致用,尝试分析更多场景,从骨子里理解所以然。
好了,本次就谈到这里,一起聊技术、谈业务、喷架构,少走弯路,不踩大坑。会持续输出原创精彩分享,敬请期待!
推荐阅读: