大家好,我是二哥呀。
Java 字节码指令是 JVM 体系中非常难啃的一块硬骨头,我估计有些读者会有这样的疑惑,“Java 字节码难学吗?我能不能学会啊?”
讲良心话,不是我谦虚,一开始学 Java 字节码和 Java 虚拟机方面的知识我也感觉头大!但硬着头皮学了一阵子之后,突然就开窍了,觉得好有意思,尤其是明白了 Java 代码在底层竟然是这样执行的时候,感觉既膨胀又飘飘然,浑身上下散发着自信的光芒!
我在 CSDN 共输出了 100 多篇 Java 方面的文章,总字数超过 30 万字, 内容风趣幽默、通俗易懂,收获了很多初学者的认可和支持,内容包括 Java 语法、Java 集合框架、Java 并发编程、Java 虚拟机等核心内容。
为了帮助更多的 Java 初学者,我“一怒之下”就把这些文章重新整理并开源到了 GitHub,起名《教妹学 Java》,听起来是不是就很有趣?
GitHub 开源地址(欢迎 star):https://github.com/itwanger/jmx-java
Java 官方的虚拟机 Hotspot 是基于栈的,而不是基于寄存器的。
基于栈的优点是可移植性更好、指令更短、实现起来简单,但不能随机访问栈中的元素,完成相同功能所需要的指令数也比寄存器的要多,需要频繁的入栈和出栈。
基于寄存器的优点是速度快,有利于程序运行速度的优化,但操作数需要显式指定,指令也比较长。
Java 字节码由操作码和操作数组成。
由于 Java 虚拟机是基于栈而不是寄存器的结构,所以大多数指令都只有一个操作码。比如 aload_0
(将局部变量表中下标为 0 的数据压入操作数栈中)就只有操作码没有操作数,而 invokespecial /#1
(调用成员方法或者构造方法,并传递常量池中下标为 1 的常量)就是由操作码和操作数组成的。
加载(load)和存储(store)相关的指令是使用最频繁的指令,用于将数据从栈帧的局部变量表和操作数栈之间来回传递。
1)将局部变量表中的变量压入操作数栈中
解释一下。
x 为操作码助记符,表明是哪一种数据类型。见下表所示。
像 arraylength 指令,没有操作码助记符,它没有代表数据类型的特殊字符,但操作数只能是一个数组类型的对象。
大部分的指令都不支持 byte、short 和 char,甚至没有任何指令支持 boolean 类型。编译器会将 byte 和 short 类型的数据带符号扩展(Sign-Extend)为 int 类型,将 boolean 和 char 零位扩展(Zero-Extend)为 int 类型。
举例来说。
private void load(int age, String name, long birthday, boolean sex) {
System.out.println(age + name + birthday + sex);
}
通过 jclasslib 看一下 load()
方法(4 个参数)的字节码指令。
通过查看局部变量表就能关联上了。
2)将常量池中的常量压入操作数栈中
根据数据类型和入栈内容的不同,此类又可以细分为 const 系列、push 系列和 Idc 指令。
const 系列,用于特殊的常量入栈,要入栈的常量隐含在指令本身。
push 系列,主要包括 bipush 和 sipush,前者接收 8 位整数作为参数,后者接收 16 位整数。
Idc 指令,当 const 和 push 不能满足的时候,万能的 Idc 指令就上场了,它接收一个 8 位的参数,指向常量池中的索引。
Idc_w
:接收两个 8 位数,索引范围更大。Idc2_w
指令。举例来说。
public void pushConstLdc() {
// 范围 [-1,5]
int iconst = -1;
// 范围 [-128,127]
int bipush = 127;
// 范围 [-32768,32767]
int sipush= 32767;
// 其他 int
int ldc = 32768;
String aconst = null;
String IdcString = "沉默王二";
}
通过 jclasslib 看一下 pushConstLdc()
方法的字节码指令。
3)将栈顶的数据出栈并装入局部变量表中
主要是用来给局部变量赋值,这类指令主要以 store 的形式存在。
明白了 xload_ 和 xload,再看 xstore_ 和 xstore 就会轻松得多,作用反了一下而已。
大家来想一个问题,为什么要有 xstore_ 和 xload_ 呢?它们的作用和 xstore n、xload n 不是一样的吗?
xstore_ 和 xstore n 的区别在于,前者相当于只有操作码,占用 1 个字节;后者相当于由操作码和操作数组成,操作码占 1 个字节,操作数占 2 个字节,一共占 3 个字节。
由于局部变量表中前几个位置总是非常常用,虽然 xstore_<n>
和 xload_<n>
增加了指令数量,但字节码的体积变小了!
举例来说。
public void store(int age, String name) {
int temp = age + 2;
String str = name;
}
通过 jclasslib 看一下 store()
方法的字节码指令。
通过查看局部变量表就能关联上了。
算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新压入操作数栈。可以分为两类:整型数据的运算指令和浮点数据的运算指令。
需要注意的是,数据运算可能会导致溢出,比如两个很大的正整数相加,很可能会得到一个负数。但 Java 虚拟机规范中并没有对这种情况给出具体结果,因此程序是不会显式报错的。所以,大家在开发过程中,如果涉及到较大的数据进行加法、乘法运算的时候,一定要注意!
当发生溢出时,将会使用有符号的无穷大 Infinity 来表示;如果某个操作结果没有明确的数学定义的话,将会使用 NaN 值来表示。而且所有使用 NaN 作为操作数的算术操作,结果都会返回 NaN。
举例来说。
public void infinityNaN() {
int i = 10;
double j = i / 0.0;
System.out.println(j); // Infinity
double d1 = 0.0;
double d2 = d1 / 0.0;
System.out.println(d2); // NaN
}
Java 虚拟机提供了两种运算模式:
我把所有的算术指令列一下:
举例来说。
public void calculate(int age) {
int add = age + 1;
int sub = age - 1;
int mul = age * 2;
int div = age / 3;
int rem = age % 4;
age++;
age--;
}
通过 jclasslib 看一下 calculate()
方法的字节码指令。
可以分为两种:
1)宽化,小类型向大类型转换,比如 int–>long–>float–>double
,对应的指令有:i2l、i2f、i2d、l2f、l2d、f2d。
2)窄化,大类型向小类型转换,比如从 int 类型到 byte、short 或者 char,对应的指令有:i2b、i2s、i2c;从 long 到 int,对应的指令有:l2i;从 float 到 int 或者 long,对应的指令有:f2i、f2l;从 double 到 int、long 或者 float,对应的指令有:d2i、d2l、d2f。
举例来说。
public void updown() {
int i = 10;
double d = i;
float f = 10f;
long ong = (long)f;
}
通过 jclasslib 看一下 updown()
方法的字节码指令。
Java 是一门面向对象的编程语言,那么 Java 虚拟机是如何从字节码层面进行支持的呢?
1)创建指令
数组也是一种对象,但它创建的字节码指令和普通的对象不同。创建数组的指令有三种:
普通对象的创建指令只有一个,就是 new
,它会接收一个操作数,指向常量池中的一个索引,表示要创建的类型。
举例来说。
public void newObject() {
String name = new String("沉默王二");
File file = new File("无愁河的浪荡汉子.book");
int [] ages = {};
}
通过 jclasslib 看一下 newObject()
方法的字节码指令。
new /#13 <java/lang/String>
,创建一个 String 对象。new /#15 <java/io/File>
,创建一个 File 对象。newarray 10 (int)
,创建一个 int 类型的数组。2)字段访问指令
字段可以分为两类,一类是成员变量,一类是静态变量(static 关键字修饰的),所以字段访问指令可以分为两类:
举例来说。
public class Writer {
private String name;
static String mark = "作者";
public static void main(String[] args) {
print(mark);
Writer w = new Writer();
print(w.name);
}
public static void print(String arg) {
System.out.println(arg);
}
}
通过 jclasslib 看一下 main()
方法的字节码指令。
getstatic /#2 <com/itwanger/jvm/Writer.mark>
,访问静态变量 markgetfield /#6 <com/itwanger/jvm/Writer.name>
,访问成员变量 name方法调用指令有 5 个,分别用于不同的场景:
举例来说。
public class InvokeExamples {
private void run() {
List ls = new ArrayList();
ls.add("难顶");
ArrayList als = new ArrayList();
als.add("学不动了");
}
public static void print() {
System.out.println("invokestatic");
}
public static void main(String[] args) {
print();
InvokeExamples invoke = new InvokeExamples();
invoke.run();
}
}
我们用 javap -c InvokeExamples.class
来反编译一下。
Compiled from "InvokeExamples.java"
public class com.itwanger.jvm.InvokeExamples {
public com.itwanger.jvm.InvokeExamples();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
private void run();
Code:
0: new #2 // class java/util/ArrayList
3: dup
4: invokespecial #3 // Method java/util/ArrayList."<init>":()V
7: astore_1
8: aload_1
9: ldc #4 // String 难顶
11: invokeinterface #5, 2 // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
16: pop
17: new #2 // class java/util/ArrayList
20: dup
21: invokespecial #3 // Method java/util/ArrayList."<init>":()V
24: astore_2
25: aload_2
26: ldc #6 // String 学不动了
28: invokevirtual #7 // Method java/util/ArrayList.add:(Ljava/lang/Object;)Z
31: pop
32: return
public static void print();
Code:
0: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #9 // String invokestatic
5: invokevirtual #10 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
public static void main(java.lang.String[]);
Code:
0: invokestatic #11 // Method print:()V
3: new #12 // class com/itwanger/jvm/InvokeExamples
6: dup
7: invokespecial #13 // Method "<init>":()V
10: astore_1
11: aload_1
12: invokevirtual #14 // Method run:()V
15: return
}
InvokeExamples 类有 4 个方法,包括缺省的构造方法在内。
1)InvokeExamples()
构造方法中
缺省的构造方法内部会调用超类 Object 的初始化构造方法:
`invokespecial #1 // Method java/lang/Object."<init>":()V`
2)成员方法 run()
中
invokeinterface #5, 2 // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
由于 ls 变量的引用类型为接口 List,所以 ls.add()
调用的是 invokeinterface
指令,等运行时再确定是不是接口 List 的实现对象 ArrayList 的 add()
方法。
invokevirtual #7 // Method java/util/ArrayList.add:(Ljava/lang/Object;)Z
由于 als 变量的引用类型已经确定为 ArrayList,所以 als.add()
方法调用的是 invokevirtual
指令。
3)main()
方法中
invokestatic #11 // Method print:()V
print()
方法是静态的,所以调用的是 invokestatic
指令。
方法返回指令根据方法的返回值类型进行区分,常见的返回指令见下图。
常见的操作数栈管理指令有 pop、dup 和 swap。
这些指令不需要指明数据类型,因为是按照位置压入和弹出的。
举例来说。
public class Dup {
int age;
public int incAndGet() {
return ++age;
}
}
通过 jclasslib 看一下 incAndGet()
方法的字节码指令。
控制转移指令包括:
1)比较指令
比较指令有:dcmpg,dcmpl、fcmpg、fcmpl、lcmp,指令的第一个字母代表的含义分别是 double、float、long。注意,没有 int 类型。
对于 double 和 float 来说,由于 NaN 的存在,有两个版本的比较指令。拿 float 来说,有 fcmpg 和 fcmpl,区别在于,如果遇到 NaN,fcmpg 会将 1 压入栈,fcmpl 会将 -1 压入栈。
举例来说。
public void lcmp(long a, long b) {
if(a > b){}
}
通过 jclasslib 看一下 lcmp()
方法的字节码指令。
lcmp 用于两个 long 型的数据进行比较。
2)条件跳转指令
这些指令都会接收两个字节的操作数,它们的统一含义是,弹出栈顶元素,测试它是否满足某一条件,满足的话,跳转到对应位置。
对于 long、float 和 double 类型的条件分支比较,会先执行比较指令返回一个整形值到操作数栈中后再执行 int 类型的条件跳转指令。
对于 boolean、byte、char、short,以及 int,则直接使用条件跳转指令来完成。
举例来说。
public void fi() {
int a = 0;
if (a == 0) {
a = 10;
} else {
a = 20;
}
}
通过 jclasslib 看一下 fi()
方法的字节码指令。
3 ifne 12 (+9)
的意思是,如果栈顶的元素不等于 0,跳转到第 12(3+9)行 12 bipush 20
。
3)比较条件转指令
前缀“if_”后,以字符“i”开头的指令针对 int 型整数进行操作,以字符“a”开头的指令表示对象的比较。
举例来说。
public void compare() {
int i = 10;
int j = 20;
System.out.println(i > j);
}
通过 jclasslib 看一下 compare()
方法的字节码指令。
11 if_icmple 18 (+7)
的意思是,如果栈顶的两个 int 类型的数值比较的话,如果前者小于后者时跳转到第 18 行(11+7)。
4)多条件分支跳转指令
主要有 tableswitch 和 lookupswitch,前者要求多个条件分支值是连续的,它内部只存放起始值和终止值,以及若干个跳转偏移量,通过给定的操作数 index,可以立即定位到跳转偏移量位置,因此效率比较高;后者内部存放着各个离散的 case-offset 对,每次执行都要搜索全部的 case-offset 对,找到匹配的 case 值,并根据对应的 offset 计算跳转地址,因此效率较低。
举例来说。
public void switchTest(int select) {
int num;
switch (select) {
case 1:
num = 10;
break;
case 2:
case 3:
num = 30;
break;
default:
num = 40;
}
}
通过 jclasslib 看一下 switchTest()
方法的字节码指令。
case 2 的时候没有 break,所以 case 2 和 case 3 是连续的,用的是 tableswitch。如果等于 1,跳转到 28 行;如果等于 2 和 3,跳转到 34 行,如果是 default,跳转到 40 行。
5)无条件跳转指令
goto 指令接收两个字节的操作数,共同组成一个带符号的整数,用于指定指令的偏移量,指令执行的目的就是跳转到偏移量给定的位置处。
前面的例子里都出现了 goto 的身影,也很好理解。如果指令的偏移量特别大,超出了两个字节的范围,可以使用指令 goto_w,接收 4 个字节的操作数。
巨人的肩膀:
https://segmentfault.com/a/1190000037628881
除了以上这些指令,还有异常处理指令和同步控制指令,我打算吊一吊大家的胃口,大家可以期待一波~~
(骚操作)
路漫漫其修远兮,吾将上下而求索
想要走得更远,Java 字节码这块就必须得硬碰硬地吃透,希望二哥的这些分享可以帮助到大家~
二哥在 CSDN 上写了很多 Java 方面的系列文章,有 Java 核心语法、Java 集合框架、Java IO、Java 并发编程、Java 虚拟机等,也算是体系完整了。
为了能帮助到更多的 Java 初学者,二哥把自己连载的《教妹学Java》开源到了 GitHub,尽管只整理了 50 篇,发现字数已经来到了 10 万+,内容更是没得说,通俗易懂、风趣幽默、图文并茂。
GitHub 开源地址(欢迎 star):https://github.com/itwanger/jmx-java
如果有帮助的话,还请给二哥点个赞,这将是我继续分享下去的最强动力!
版权说明 : 本文为转载文章, 版权归原作者所有 版权申明
原文链接 : https://blog.csdn.net/qing_gee/article/details/119320646
内容来源于网络,如有侵权,请联系作者删除!