操作码介绍
我们都知在Java中我们的类会被编译成字节码然后放到虚拟机中去执行,字节码里面的内容其实我们也是可以去“阅读”的,方法就是通过 jdk自带的工具翻译成操作码。在操作码中我们能看到一些我们平时看不到的关于java的秘密。
Java虚拟机的指令由一个字节长度的的数字以及跟随其后的零至多个代表此操作所需的参数构成。即:Java指令 = 操作码 + 操作数。Java虚拟机本身是采用面向操作数栈而不是寄存器的架构,所以大多数的指令都不包含操作数,只有一个操作码。通过阅读操作码我们能直观的看到一些方法的执行过程。
查看操作码
我们随便找一个 .class 文件(我这里是Test.class),然后在命令行执行:
1 | javap -v -l -p -s -sysinfo -constants Test.class |
Test.java
源码为:
1 | public class Test { |
执行指令后可在命令行窗口看到:
1 | Classfile /G:Test.class |
有的小伙伴可能没有将 jdk的bin目录配置到环境变量,执行javap
指令的时候识别不了该指令,我们只需要指定指令的全路径就ok了,比如我的 bin 目录是 C:\jdk\bin
那我的指令就是 C:\jdk\bin\javap.exe -v -l -p -s -sy、sinfo -constants Test.class
。javap 指令的参数含义可以通过 javap -help
查看 这里不多做介绍。
操作码阅读指南
通过命令行窗口输出的内容分为以下几个部分:
- Classfile 是一些类信息,
- Constant pool 是编译时常量池,
Constant pool
中我们能看到方法信息、变量信息、关键字信息等, - {} 里面的内容是方法的一些执行指令。
我们将字节码文件翻译成了操作码,里面的信息量很大,别着急,我们一点点的去解读。Classfile部分是一些类信息,这一部分不是我们研究的重点,因此我这里不做太多介绍。
阅读操作码我们需要去查阅操作码指令表,在网上就能搜到。我在这里罗列一些比较重要的操作码。
数据类型相关
- iload指令用于从局部变量表中加载int型的数据到操作数栈中;
- fload指令则是从局部变量表中加载float类型的数据到操作数栈中;
- i代表int类型,l代表long类型,s代表short类型,b代表byte类型,a代表reference类型;以此类推
加载和存储指令相关
- 将一个局部变量加载到操作数栈,有iload、iload_
、lload、lload_ 、aload、aload_ 等 - 将一个数值从操作数栈存储到局部变量表,有istore、istore_
、lstore、lstore_ 、astore、astore_ 等 - 将一个常量加载到操作数栈,有bipush、sipush、ldc、aconst_null、iconst_等
运算指令相关
- 加法指令 iadd、ladd、fadd、dadd,
- 减法指令 isub、lsub、fsub、dsub,
- 乘法指令 imul、lmul、fmul、dmul,
- 除法指令 idiv、ldiv、fdiv、ddiv,
- 求余指令 irem、lrem、frem、drem
- 取反指令 ineg、lneg、fneg、dneg
- 位指令 ior、lor 是或运算,iand、land 是与运算 ixor、lxor 是异或运算
- 其他 iinc 是自增运算 dcmpg、dcmpl、fcmpg、fcmpl、lcmp 是比较运算
操作数栈指令
- 出栈指令 pop、pop2
- 复制压栈 dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2
- 将栈最顶端的两个数值互换 swap
- 条件分支 ifeq、iflt、ifle、ifne
- 无条件分支 goto、goto_w、jsr、jsr_w、ret
- 复合条件分支tableswitch、lookupswitch
方法调用相关
- 方法返回值类型表示方式 ()V 表示 void 方法 ()Ljava/lang/String 表示 返回 String 类型,()I 表示返回int类型,以此类推
- invokevirtual指令用于调用对象的实例方法,
- invokeinterface指令用于调用接口方法,
- invokespecial指令用于调用一些需要特殊处理的实例方法
- invokestatic指令用于调用静态方法
- invokedynamic指令用于在运行时动态解析出调用点限定符所引用的方法
- athrow指令用来实现显式抛出异常的操作
- monitorenter和monitorexit两条指令来支持synchronized关键字的语义
操作码相关源码解读
前文提到过操作码可以看到 java 的一些秘密,下面我们由难到易解读几个案例。
案例一 this 关键字的加载时机
我们思考下面一段代码:
1 | public class Test { |
这段代码相信有工作经验的朋友都研究过,但是现在我们不是来讨论代码的额执行顺序,而是讨论另外一个问题:为什么动态代码块里面可以用 this 关键字? 我们思考一下,this代指当前对象,而构造函数还没有执行我们哪来的对象?那还没有对象,我们的this又指向谁?这是一个值得思考的问题。那我们来看看这段代码的操作码吧:
1 | public Test(); |
我们仔细阅读发现,其实它的构造方法组成并不是我们在类里面看到的那样,第一步先执行 aload_0
然后 通过invokespecial
指令调用了 对象初始化方法 <init>
,然后再是正真的执行我们构造函数自己的逻辑。注意了,这里的 aload_0
就是加载this关键字,也就是其实动态代码块是直接编译在构造函数之中的,而且 this关键字的产生是对象产生的第一步;也就是说我们创建的对象从操作码的角度来讲,首先就是先加载一个 this 上来,然后再初始化对象,再实例化对象。
案例二 sychornized 关键字原理。
sychornized 从操作码的层面来观察是比较直观的,我们百度sychornized关键字原理的时候,通常是这么解释的:jvm基于进入和退出 Monitor
对象来实现方法同步和代码块同步,而这个 Monitor
是存储在Java对象头里的。
我们理解起来可能比较抽象,让我们读操作码来分析吧:
同步方法:
1 | public static void testSyn(int i){ |
对应的部分操作码:
1 | public static void testSyn(int); |
结合前文的操作码指令介绍,我们可以看到同步代码块的执行过程,先执行 monitorenter 指令获取锁,当获取锁成功,执行下面的指令,最后 monitorexit 释放锁,monitorenter 被jvm封装成一个完整指令,其执行原理就是前面所说的内容,而再往深究的话就是通过互斥原语(CPU lock 指令加 对象头锁标记位)来实现的。
案例三 对象初始化死锁。
这是一个很有意思的题,在b站上能搜到它的操作码分析视频,关键字 小马哥每日一问 2019.07.18 期 。我把题目贴出来,大佬们自己动手研究一下,阅后习题:
1 | public class Test { |
上面这个题目是很有意思的,小伙伴们仔细研究一下。