JVM 核心技术 100 讲 (尊享版)
kimmking
Apache Dubbo/ShardingSphere PMC,前某集团高级技术总监 / 阿里资深架构师 / 商业银行北京研发中心负责人 / 某国有大行分布式核心技术平台架构负责人
1 人已学习
立即订阅
课程目录
已更新 21 讲/共 100 讲
JVM 核心技术 100 讲 (尊享版)
15
15
1.0x
00:00/00:00
登录|注册

02. Java字节码技术:不积细流,无以成江河(2)

4.5 查看方法信息

javap 命令中使用 -verbose 选项时, 还显示了其他的一些信息。例如, 关于 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
可以看到方法描述: ([Ljava/lang/String;)V
其中小括号内是入参信息 / 形参信息,
左方括号表述数组,
L 表示对象,
后面的 java/lang/String 就是类名称
小括号后面的 V 则表示这个方法的返回值是 void
方法的访问标志也很容易理解  flags: ACC_PUBLIC, ACC_STATIC,表示 public 和 static
还可以看到执行该方法时需要的栈 (stack) 深度是多少,需要在局部变量表中保留多少个槽位, 还有方法的参数个数: stack=2, locals=2, args_size=1。把上面这些整合起来其实就是一个方法:
public static void main(java.lang.String[]);
注:实际上我们一般把一个方法的修饰符 + 名称 + 参数类型清单 + 返回值类型,合在一起叫“方法签名”,即这些信息可以完整的表示一个方法。
稍微往回一点点,看编译器自动生成的无参构造函数字节码:
public demo.jvm0104.HelloByteCode();
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
你会发现一个奇怪的地方, 无参构造函数的参数个数居然不是 0: stack=1, locals=1, args_size=1。这是因为在 Java 中, 如果是静态方法则没有 this 引用。 对于非静态方法, this 将被分配到局部变量表的第 0 号槽位中, 关于局部变量表的细节, 下面再进行介绍。
有反射编程经验的同学可能比较容易理解: Method#invoke(Object obj, Object... args); 有 JavaScript 编程经验的同学也可以类比: fn.apply(obj, args) && fn.call(obj, arg1, arg2);

4.6 线程栈与字节码执行模型

想要深入了解字节码技术,我们需要先对字节码的执行模型有所了解。
JVM 是一台基于栈的计算机器。每个线程都有一个独属于自己的线程栈 (JVM stack),用于存储栈帧(Frame)。每一次方法调用,JVM 都会自动创建一个栈帧。栈帧操作数栈局部变量数组 以及一个class引用组成。class引用 指向当前方法在运行时常量池中对应的 class)。
我们在前面反编译的代码中已经看到过这些内容。
局部变量数组 也称为 局部变量表(LocalVariableTable), 其中包含了方法的参数,以及局部变量。 局部变量数组的大小在编译时就已经确定: 和局部变量 + 形参的个数有关,还要看每个变量 / 参数占用多少个字节。操作数栈是一个 LIFO 结构的栈, 用于压入和弹出值。 它的大小也在编译时确定。
有一些操作码 / 指令可以将值压入“操作数栈”; 还有一些操作码 / 指令则是从栈中获取操作数,并进行处理,再将结果压入栈。操作数栈还用于接收调用其他方法时返回的结果值。

4.7 方法体中的字节码解读

看过前面的示例,细心的同学可能会猜测,方法体中那些字节码指令前面的数字是什么意思,说是序号吧但又不太像,因为他们之间的间隔不相等。看看 main 方法体对应的字节码:
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
间隔不相等的原因是, 有一部分操作码会附带有操作数, 也会占用字节码数组中的空间。例如, new 就会占用三个槽位: 一个用于存放操作码指令自身,两个用于存放操作数。因此,下一条指令 dup 的索引从 3 开始。
如果将这个方法体变成可视化数组,那么看起来应该是这样的:
每个操作码 / 指令都有对应的十六进制 (HEX) 表示形式, 如果换成十六进制来表示,则方法体可表示为 HEX 字符串。例如上面的方法体百世成十六进制如下所示:
甚至我们还可以在支持十六进制的编辑器中打开 class 文件,可以在其中找到对应的字符串:
(此图由开源文本编辑软件 Atom 的 hex-view 插件生成)
粗暴一点,我们可以通过 HEX 编辑器直接修改字节码,尽管这样做会有风险, 但如果只修改一个数值的话应该会很有趣。
其实要使用编程的方式,方便和安全地实现字节码编辑和修改还有更好的办法,那就是使用 ASM 和 Javassist 之类的字节码操作工具,也可以在类加载器和 Agent 上面做文章,下一节课程会讨论 类加载器,其他主题则留待以后探讨。

4.8 对象初始化指令:new 指令, init 以及 clinit 简介

我们都知道 new是 Java 编程语言中的一个关键字, 但其实在字节码中,也有一个指令叫做 new。 当我们创建类的实例时, 编译器会生成类似下面这样的操作码:
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
当你同时看到 new, dupinvokespecial 指令在一起时,那么一定是在创建类的实例对象!
为什么是三条指令而不是一条呢?这是因为:
new 指令只是创建对象,但没有调用构造函数。
invokespecial 指令用来调用某些特殊方法的, 当然这里调用的是构造函数。
dup 指令用于复制栈顶的值。
由于构造函数调用不会返回值,所以如果没有 dup 指令, 在对象上调用方法并初始化之后,操作数栈就会是空的,在初始化之后就会出问题, 接下来的代码就无法对其进行处理。
这就是为什么要事先复制引用的原因,为的是在构造函数返回之后,可以将对象实例赋值给局部变量或某个字段。因此,接下来的那条指令一般是以下几种:
astore {N} or astore_{N} – 赋值给局部变量,其中 {N} 是局部变量表中的位置。
putfield – 将值赋给实例字段
putstatic – 将值赋给静态字段
在调用构造函数的时候,其实还会执行另一个类似的方法 <init></init> ,甚至在执行构造函数之前就执行了。还有一个可能执行的方法是该类的静态初始化方法 <clinit></clinit>, 但 <clinit></clinit> 并不能被直接调用,而是由这些指令触发的: new, getstatic, putstatic or invokestatic
也就是说,如果创建某个类的新实例, 访问静态字段或者调用静态方法,就会触发该类的静态初始化方法【如果尚未初始化】。
实际上,还有一些情况会触发静态初始化, 详情请参考 JVM 规范: [http://docs.oracle.com/javase/specs/jvms/se8/html/]

4.9 栈内存操作指令

有很多指令可以操作方法栈。 前面也提到过一些基本的栈操作指令: 他们将值压入栈,或者从栈中获取值。 除了这些基础操作之外也还有一些指令可以操作栈内存; 比如 swap 指令用来交换栈顶两个元素的值。下面是一些示例:
最基础的是 duppop 指令。
dup 指令, 复制栈顶的值, 并将复制的值压入栈。
pop 指令则从栈中删除最顶部的值。
还有复杂一点的指令:比如,swap, dup_x1dup2_x1
顾名思义,swap 指令可交换栈顶两个元素的值,例如 A 和 B 交换位置 (图中示例 4);
dup_x1 指令, 复制栈顶的值, 并将复制的值插入到最上面 2 个值的下方。(图中示例 5);
dup2_x1 指令, 复制栈顶 1 个 64 位 / 或 2 个 32 位的值, 并将复制的值按照原始顺序,插入原始值下面一个 32 位值的下方 (图中示例 6)。
dup_x1dup2_x1 指令看起来稍微有点复杂。而且为什么要设置这种指令呢? 在栈中复制最顶部的值?
请看一个实际案例:怎样交换 2 个 double 类型的值?
需要注意的是, 一个 double 值占两个槽位,也就是说如果栈中有两个 double 值,它们将占用 4 个槽位。
要执行交换,你可能想到了 swap 指令,但问题是 swap 只适用于单字 (one-word, 单字一般指 32 位 4 个字节, 64 位则是双字),所以不能处理 double 类型, 但 Java 中又没有 swap2 指令。
怎么办呢? 解决方法就是使用 dup2_x2指令, 将操作数栈顶部的 double 值, 复制到栈底 double 值的下方, 然后再使用 pop2 指令弹出栈顶的 double 值。结果就是交换了两个 double 值。示意图如下图所示:

dup, dup_x1, dup2_x1 指令补充说明

指令的详细说明可参考 JVM 规范:
dup 指令
官方说明是: 复制栈顶的值, 并将复制的值压入栈.
操作数栈的值变化情况 (方括号标识新插入的值):
..., value →
..., value [,value]
dup_x1 指令
官方说明是: 复制栈顶的值, 并将复制的值插入到最上面 2 个值的下方。
操作数栈的值变化情况 (方括号标识新插入的值):
..., value2, value1 →
..., [value1,] value2, value1
dup2_x1 指令
官方说明是: 复制栈顶 1 个 64 位 / 或 2 个 32 位的值, 并将复制的值按照原始顺序,插入原始值下面一个 32 位值的下方。
操作数栈的值变化情况 (方括号标识新插入的值):
# 情景1: value1, value2, and value3都是分组1的值(32位元素)
..., value3, value2, value1 →
..., [value2, value1,] value3, value2, value1
# 情景2: value1 是分组2的值(64位,long或double), value2 是分组1的值(32位元素)
..., value2, value1 →
..., [value1,] value2, value1
确认放弃笔记?
放弃后所记笔记将不保留。
新功能上线,你的历史笔记已初始化为私密笔记,是否一键批量公开?
批量公开的笔记不会为你同步至部落
公开
同步至部落
取消
完成
0/2000
荧光笔
直线
曲线
笔记
复制
AI
  • 深入了解
  • 翻译
    • 英语
    • 中文简体
    • 法语
    • 德语
    • 日语
    • 韩语
    • 俄语
    • 西班牙语
  • 解释
  • 总结
仅可试看部分内容,如需阅读全部内容,请付费购买文章所属专栏
《JVM 核心技术 100 讲 (尊享版)》
立即购买
登录 后留言

精选留言

由作者筛选后的优质留言将会公开显示,欢迎踊跃留言。
收起评论
大纲
固定大纲
4.5 查看方法信息
4.6 线程栈与字节码执行模型
4.7 方法体中的字节码解读
4.8 对象初始化指令:new 指令, init 以及 clinit 简介
4.9 栈内存操作指令
dup, dup_x1, dup2_x1 指令补充说明
显示
设置
留言
收藏
沉浸
阅读
分享
手机端
快捷键
回顶部