Java程序的反编译工具已经十分成熟了。一般来讲,它们都是JVM(基于栈机制的Java虚拟机)的字节码(bytecode,指令流里的指令只有一个字节,故而得名。不过java指令中的操作数属于变长信息)分析工具。著名的JAD(http://varaneckas.com/jad/
)就是一款颇具代表性的JAVA反编译工具。
相对于x86平台更底层指令的反编译技术来说,面向JVM的bytecode更容易反编译。这主要是因为:
① 字节码含有更为丰富的数据类型信息。
② JVM内存模型更严格,因此字节码分析起来更为有章可循。
③ Java编译器不做任何优化工作(而JVM JIT在运行时会做优化工作),因此在反编译字节码之后,我们基本可以直接理解Java类文件里的原始指令。
什么时候JVM bytecode反编译有用呢?
① 无需重新编译反汇编的结果,而能给类文件做应急补丁。
② 分析混淆代码。
③ 需要编写自己的代码混淆器。
④ 创建面向JVM的、类似编译程序的代码生成工具(类似Scala,Clohure等等)。
让我们从简单的代码开始演示。除非特别指明,否则我们这里用到的都是JDK 1.7的自带工具。
反编译类文件的命令是:javap –c –verbose。
笔者采用的例子摘自于参考书目Jav13。
或许Java的最简函数是直接返回数值、不做其他操作的函数。当然,功能再少一点的、什么操作都没有的“闲置”函数,肯定不存在。函数必须具有某种行为,因此统称为“方法”。在Java的概念中,“类/class”是一切对象的模版,所有方法必定不能脱离“类”而单独存在。但是为了简化起见,本文还是把“方法”称为“函数”。
public class ret
{
public static int main(String[] args)
{
return 0;
}
}
我们采用命令javac 来编译它,命令行是:
javac ret.java
编译完后,我们可以用JDK自带的反汇编器-Javap来分析字节码,此时采用的命令应当是:
javap -c -verbose ret.class
反编译完成后,我们得到的代码如下所示。
指令清单54.1 JDK 1.7(摘要)
public static int main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: iconst_0
1: ireturn
Java平台的开发人员认为,0是用得最多的常量。因此他们为PUSH 0的指令单独设计了单字节的指令码,即iconst_0。此外还有iconst_1(将1入栈),iconst_2(将2入栈)……,一直到iconst_5这样的单字节字节码。而且确实有iconst_m1(将−1入栈)这类的将负数推送入栈的单字节指令。
JVM常常采用栈的方式来传递参数并从函数中返回值。因此语句iconst_0将数字0压入栈,而指令ireturn则是从栈顶返回整型数(ireturn中的字母i的意思就是“返回值为integer/整数”)。这里注意我们用TOS来代表栈顶,它是英文“Top Of Stack”的首字母缩写。
我们来重新编写一下这个例子,将返回值修改为整数1234:
public class ret
{
public static int main(String[] args)
{
return 1234;
}
}
这样的话,我们得到的结果是如下所示的代码。
指令清单54.2 JDK1.7(摘要)
public static int main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: sipush 1234
3: ireturn
指令sipush的功能是将操作数(这里是整数1234)入栈(si是short integer短型整数的缩写)。Short(短型)的意思就是针对16位的数值进行操作,而这里的整数1234正好就是一个16位的数值。
如果操作数比整型数据更大,那么字节码会是什么情况呢?让我们来看看实例:
public class ret
{
public static int main(String[] args)
{
return 12345678;
}
}
指令清单54.3 常量池
...
#2 = Integer 12345678
...
public static int main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: ldc #2 // int 12345678
2: ireturn
JVM的opcode无法直接封装32位数据,这是开发环境的局限决定的。像本例这样的32位常数将会存储到“常量池”里。常量池是一个由数组组成的表,类型为cp_info constant_pool[],用来存储程序中使用的各种常量,包括Class/String/Integer等各种基本Java数据类型,详情参见The Java Virtual Machine Specification 4.4节。
并非只有JVM如此处理常量。像MIPS、ARM以及其他的RISC型的CPU都不能在32位的opcode中封装32位常量,因此包括MIPS和ARM在内的RISC类型的CPU都得分步骤构建这些数值,或者将其保存在数据段中。有关范例可以参考本书的28.3节或者29.1节。
在MIPS的概念中也有传统意义上的常量池,不过它的名字则叫做“数据缓冲池(文字池)/literal pool”。这种文字池与可执行程序中的“.lit4/.lit8”数据段相对应。数据段.lit4用于保存32位的单精度浮点常数,而.lit8则用于保存64位的双精度浮点常数。
我们来试试其他类型的数据。
布尔型Boolean:
public class ret
{
public static boolean main(String[] args)
{
return true;
}
}
public static boolean main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: iconst_1
1: ireturn
当返回值为Ture时,JVM bytecode层面的返回值就是整数1。像C/C++一样,Java程序同样会把布尔型数值保存在32位的栈中。虽然说“逻辑真”和“整数1”的数值完全相同,但是我们不可能把布尔值当作整数值使用、也不可能把整数值当作布尔值使用。既定的类文件事先声明了数值的数据类型,而且这些数据类型会在程序运行时被实时检查。
16位的短整数型也是一样:
public class ret
{
public static short main(String[] args)
{
return 1234;
}
}
public static short main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: sipush 1234
3: ireturn
还有字符型:
public class ret
{
public static char main(String[] args)
{
return 'A';
}
}
public static char main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: bipush 65
2: ireturn
指令bipush的意思是push byte(保存字节)。Java环境中的char型数据是16位的UTF-16字符,同短整数型数据一样同属于16位short型短数据。但是大写字母A的ASCII码是十进制数65,而且我们可以用指令将一个字节的数压入栈中。
下面我们来看看byte(字节):
public class retc
{
public static byte main(String[] args)
{
return 123;
}
}
public static byte main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: bipush 123
2: ireturn
也许读者有些疑问:既然这些数据在运行的时候都是当作32位整型数据处理的,那么为什么还要不厌其烦地把它们声明为16位的数据类型呢?另外,字符型char数据与short短整数型的数值也是相同的,为什么还要刻意地把它声明为字符型char数据呢?
答案也很简单,是为了增加数据类型的控制以及增加源代码的可读性。Char字符型的限定符虽然在数值上与short短型整数相同,但是只要一看到char字符型的限定,我们立刻会联想到它是一个UTF16的字符集,而不会把它当作其他类型的方式去理解。在遇到被限定符short修饰的数据类型时,我们自然而然地就会把它理解为16位数据。同理,应当使用boolean声明的数据就不要把它声明为C语言风格的int型数据。
我们还可以通过限定符long声明JAVA的64位的整数型数据:
public class ret3
{
public static long main(String[] args)
{
return 1234567890123456789L;
}
}
指令清单54.4 常量池
...
#2 = Long 1234567890123456789l
...
public static long main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: ldc2_w #2 // long 1234567890123456789l
3: lreturn
上述64位常量同样位于程序的常量池部分。它被ldc2_w指令提取之后,再由lreturn(long return)指令回传给调用方函数。ldc2_w指令也能够从常量池里提取双精度浮点数(同样是64位常量)。
public class ret
{
public static double main(String[] args)
{
return 123.456d;
}
}
指令清单54.5 常量池
...
#2 = Double 123.456d
...
public static double main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: ldc2_w #2 // double 123.456d
3: dreturn
这里的指令dreturn代表return double,意思是返回双精度常数。
最后,我们举一个单精度浮点数的例子。单精度浮点常数的后面有一个限定符f,而双精度数的限定符则是字母d。字母f是float的缩写,而d则是double的缩写。
public class ret
{
public static float main(String[] args)
{
return 123.456f;
}
}
指令清单54.6 常量池
...
#2 = Float 123.456f
...
public static float main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: ldc #2 // float 123.456f
2: freturn
同为从常量池提取32位数据的指令,“提取整数”和“提取浮点数”的指令都是ldc。而指令freturn代表的是return float,声明了返回值为单精度浮点数。
最后,我们来看看如果什么数也不返回时情况会是怎么样的,也就是return指令后不带任何参数。
public class ret
{
public static void main(String[] args)
{
return;
}
}
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=0, locals=1, args_size=1
0: return
没有返回值的函数,最后只有一条return指令。它不返回任何值,只是把程序控制流递交给调用方函数。根据函数最后一条返回值处理指令,我们就能比较容易地推导出函数返回值的数据类型。
我们继续来看看简单的计算函数:
public class calc
{
public static int half(int a)
{
return a/2;
}
}
这里我们看到的是一个除以2的简单计算函数,用到的指令是iconst_2。我们来分析一下这几条指令:
public static int half(int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: iload_0
1: iconst_2
2: idiv
3: ireturn
首先,iload_0指令是提取外来的第0个函数参数,再把它压入栈中,而iconst_2指令则是将数值2压入栈中。这两个指令执行完后,函数栈的存储内容将如下所示:
+---+
TOS ->| 2 |
+---+
| a |
+---+
TOS是“Top Of Stack”的缩写,即栈顶。
idiv指令则是从栈顶取出这两个值并进行除非运算,然后把返回的结果保存在栈顶。
+--------+
TOS ->| result |
+--------+
ireturn指令则会提取栈顶的数据、把它作为返回值回传给调用方函数。
下面我们来看看双精度的除法的运算指令:
public class calc
{
public static double half_double(double a)
{
return a/2.0;
}
}
指令清单54.7 常量池
...
#2 = Double 2.0d
...
public static double half_double(double);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=4, locals=2, args_size=1
0: dload_0
1: ldc2_w #2 // double 2.0d
4: ddiv
5: dreturn
双精度浮点数的运算指令和单精度浮点数的指令十分相似。在提取常量时,它使用的指令时ldc2_w。此外,所有的三条运算指令(dload_0、ddiv以及dreturn)都带有前缀d,这个限定符表明操作数属于双精度浮点数double。
下面我们来看看含有两个参数的函数情况:
public class calc
{
public static int sum(int a, int b)
{
return a+b;
}
}
public static int sum(int, int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=2
0: iload_0
1: iload_1
2: iadd
3: ireturn
指令iload_0用于提取第一个函数参数a,而iload_1则用于导入第二个函数参数b。在执行完这两条指令之后,栈内数据如下图所示:
+---+
TOS ->| b |
+---+
| a |
+---+
而指令iadd的含义则是将两个参数中的数值相加,并将结果保存在栈顶TOS中。
+--------+
TOS ->| result |
+--------+
如果我们将以上函数的两个参数的数据类型更换成long类型的话:
public static long lsum(long a, long b)
{
return a+b;
}
我们看到的字节码则会变为:
public static long lsum(long, long);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=4, locals=4, args_size=2
0: lload_0
1: lload_2
2: ladd
3: lreturn
第二条lload指令会提取外来的第二个参数。指令后缀直接从0递增到2,是因为long型数据是64位的数据,它正好占有两个32位数据的存储位置(即后文介绍的“参数槽”)。
下面我们来看看一个更加复杂的例子:
public class calc
{
public static int mult_add(int a, int b, int c)
{
return a*b+c;
}
}
public static int mult_add(int, int, int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=3
0: iload_0
1: iload_1
2: imul
3: iload_2
4: iadd
5: ireturn
第一步的运算是乘法,乘积的结果存放在栈顶TOS中。
+---------+
TOS ->| product |
+---------+
指令iload_2将第三个参数压入栈中参加运算:
+---------+
TOS ->| c |
+---------+
| product |
+---------+
现在就能采用指令iadd进行加法求和运算了。
前面提到过,在x86和其他底层运行平台上,栈通常用于传递参数的参数、存储局部变量。而我们这里要提到的JVM略有不同。
JVM的内存模型可分为:
操作数栈即俗称的(java)“栈”,用于存储计算操作数,或者向被调用方函数传递参数。Java程序不能直接运行于x86那样的底层硬件环境,因此它必须通过明确的入栈、出栈指令才能访问自己的栈,不能像汇编指令那样直接对栈寻址。
堆。堆主要用于存储对象和数组。
以上3种内存模型相互独立、互相隔离。
Math.random()函数可以产生从0.0~1.0之间的任意(伪)随机数。因此,如欲生成0.0~0.5之间的随机数,就要对上述结果进行除法运算:
public class HalfRandom
{
public static double f()
{
return Math.random()/2;
}
}
指令清单54.8 常量池
...
#2 = Methodref #18.#19 // java/lang/Math.random:()D
#3 = Double 2.0d
...
#12 = Utf8 ()D
...
#18 = Class #22 // java/lang/Math
#19 = NameAndType #23:#12 // random:()D
#22 = Utf8 java/lang/Math
#23 = Utf8 random
public static double f();
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=4, locals=0, args_size=0
0: invokestatic #2 // Method java/lang/Math.random:()D
3: ldc2_w #3 // double 2.0d
6: ddiv
7: dreturn
指令invokestatic调用函数Math.random(),并将结果保存在栈顶TOS。这个值随后被除以2,最终成为函数返回值。但是这些函数的名称是如何编码的?编译器使用了Methodref的表达方法把外部函数的信息编排在常量池中。常量池里的相应数据声明了与被调用函数有关的类(Class)以及方法(NameAndType)名称。Methodref表达式的第一个字段(Fieldref里的第一个值)是Class的索引号-#18,这个索引号(实际上是指针)对应着一个Class名称(依次查询#18、#22号常量,可得到java/lang/Math)。Methodref表达式的第二个字段(Fieldref里的第二个值)是方法名称的索引号#19,这个索引号对应着方法名称NameAndType(依次查询#19、#23号常量,可得到方法名称random)。方法名称由2个索引号组成,第一个索引号对应着外部函数名称,而第二个索引号对应着函数返回值的数据类型“()D”——双精度浮点数。
综合上述信息可知:
① JVM能检查数据类型的正确性。
② JAVA的反编译器能从已经编译好的类文件中恢复出其原来的数据类型。
最后,我们来看一个经典的字符串显示例子:Hello, world!
public class HelloWorld
{
public static void main(String[] args)
{
System.out.println("Hello, World");
}
}
指令清单54.9 常量池
...
#2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #18 // Hello, World
#4 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/String;)V
...
#16 = Class #23 // java/lang/System
#17 = NameAndType #24:#25 // out:Ljava/io/PrintStream;
#18 = Utf8 Hello, World
#19 = Class #26 // java/io/PrintStream
#20 = NameAndType #27:#28 // println:(Ljava/lang/String;)V
...
#23 = Utf8 java/lang/System
#24 = Utf8 out
#25 = Utf8 Ljava/io/PrintStream;
#26 = Utf8 java/io/PrintStream
#27 = Utf8 println
#28 = Utf8 (Ljava/lang/String;)V
...
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello, World
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
偏移量为3的ldc指令从常量池中提取字符串Hello,World的指针,然后将其压入栈中。在Java中,这种二级指针称为reference(引用),但是它的本质仍然还是指针或者地址。[1]
熟悉的指令invokevirtual从常量池中提取println函数的信息,然后调用该函数。我们已经知道,标准库定义了许多版本的println()函数,每个版本都处理的数据类型都各不相同。本例调用的println()函数,肯定是专门处理string型数据的那个版本。
第一条指令getstatic的功能是什么呢?这个指令从对象System.out中提取引用指针的有关字段,再把它压入栈。这个引用指针的作用与println方法的this指针相似。因此,从内部来讲,println函数的输入参数实际上是两个指针:①this指针,也就是指向对象的指针;②字符串“Hello,World”的地址。
因此这并不矛盾:只有在System.out初始化为实例的时候,才能调用println()方法。
为了方便分析人员阅读, javap把有关信息全部追加到了字节码的注释里了。
这是一个最简单的调用(调用了无参数的两个函数),其功能就是发出蜂鸣声beep:
public static void main(String[] args)
{
java.awt.Toolkit.getDefaultToolkit().beep();
};
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: invokestatic #2 // Method java/awt/Toolkit.getDefaultToolkit:()↙
↘ Ljava/awt/Toolkit;
3: invokevirtual #3 // Method java/awt/Toolkit.beep:()V
6: return
第一条指令是偏移量为0的invokestatic指令,它调用了java.awt.Toolkit.getDefaultToolkit()函数。后者的返回值是Toolkit Class类实例的引用指针。偏移量为3的invokevirtual指令调用这个类的beep()函数。
我们再来看看PRNG(Pseudo Random Numbers Generator)随机数产生器,其实我们已经在本书的第20章介绍过它的C语言代码了。
public class LCG
{
public static int rand_state;
public void my_srand (int init)
{
rand_state=init;
}
public static int RNG_a=1664525;
public static int RNG_c=1013904223;
public int my_rand ()
{
rand_state=rand_state*RNG_a;
rand_state=rand_state+RNG_c;
return rand_state & 0x7fff;
}
}
程序在启动之初就初始化了数个成员变量(Class Fields)。这是如何进行的呢?这就得借助javap查看该类的构造函数:
static {};
flags: ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: ldc #5 // int 1664525
2: putstatic #3 // Field RNG_a:I
5: ldc #6 // int 1013904223
7: putstatic #4 // Field RNG_c:I
10: return
上述指令展示了变量的初始化过程。变量RNG_a和RNG_c分别占据参数槽的第三和第四存储单元。而putstatic函数将有关常数存放在相应地址。
函数my_rand()将输入值保存在变量rand_state中:
public void my_srand(int);
flags: ACC_PUBLIC
Code:
stack=1, locals=2, args_size=2
0: iload_1
1: putstatic #2 // Field rand_state:I
4: return
Iload_1指令提取输入变量,然后将其压入栈中。但是为什么此处是iload_1指令而不是读取第0个参数的iload_0指令?这是由于该函数调用了类的成员变量,因此要用第0个参数传递this指针。依此类推,函数的第二个参数槽用于传递成员变量、同时是隐性参数rand_state,此后的putstatic指令把栈顶的数值肤之道第二个参数槽、完成指定任务。
现在我们来看看my_rand()函数:
public int my_rand();
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field rand_state:I
3: getstatic #3 // Field RNG_a:I
6: imul
7: putstatic #2 // Field rand_state:I
10: getstatic #2 // Field rand_state:I
13: getstatic #4 // Field RNG_c:I
16: iadd
17: putstatic #2 // Field rand_state:I
20: getstatic #2 // Field rand_state:I
23: sipush 32767
26: iand
27: ireturn
这段代码分别提取类实例的各成员变量、进行各种运算,再使用putstatic指令更新rand_state的值。在偏移量为20处,rand_state的值会重新调入(此前的putstatic指令把它从栈里抛了出去)。虽然表面看来这个程序的效率很低,但是JVM肯定能够进行充分的优化、足以弥补字节码的效率缺陷。
我们来看一个简单的例子:
public class abs
{
public static int abs(int a)
{
if (a<0)
return -a;
return a;
}
}
public static int abs(int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: iload_0
1: ifge 7
4: iload_0
5: ineg
6: ireturn
7: iload_0
8: ireturn
如果栈顶/TOS的值大于或等于零,那么ifge将会跳转到偏移量为7的指令。特别需要注意的是,ifxx指令还会从栈顶抛弃一个值,否则它就不能进行比较运算。
后面的ineg指令是对整数求负的运算指令。
我们再看一个例子:
public static int min (int a, int b)
{
if (a>b)
return b;
return a;
}
这个函数的字节码如下所示:
public static int min(int, int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=2
0: iload_0
1: iload_1
2: if_icmple 7
5: iload_1
6: ireturn
7: iload_0
8: ireturn
if_icmple指令从栈中提取(pop)两个数值并将之进行比较。如果第二个操作数小于或等于第一个操作数,那么它将跳转到偏移量为7的指令,否则继续执行下一条指令。
若对上述程序进行调整,通过较大值函数max()进行比较:
public static int max (int a, int b)
{
if (a>b)
return a;
return b;
}
那么字节码则会变为:
public static int max(int, int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=2
0: iload_0
1: iload_1
2: if_icmple 7
5: iload_0
6: ireturn
7: iload_1
8: ireturn
其实与刚才取较小值的程序基本相同,但是最后两个iload指令(在偏移量为5和7的位置上)位置对换了一下。
下面再看一个更复杂一些的例子:
public class cond
{
public static void f(int i)
{
if (i<100)
System.out.print("<100");
if (i==100)
System.out.print("==100");
if (i>100)
System.out.print(">100");
if (i==0)
System.out.print("==0");
}
}
public static void f(int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: iload_0
1: bipush 100
3: if_icmpge 14
6: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
9: ldc #3 // String <100
11: invokevirtual #4 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
14: iload_0
15: bipush 100
17: if_icmpne 28
20: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
23: ldc #5 // String ==100
25: invokevirtual #4 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
28: iload_0
29: bipush 100
31: if_icmple 42
34: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
37: ldc #6 // String >100
39: invokevirtual #4 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
42: iload_0
43: ifne 54
46: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
49: ldc #7 // String ==0
51: invokevirtual #4 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
54: return
这段代码用以实现两个功能。首先,它可以判断输入的数与100的大小关系,如果是小于100的话,显示“<100”的字样;如果是等于100,则显示“=100”的字样;如果是大于100的话,则显示“>100”的字样。另外一个功能是一个特例,就是如果输入的数为0的话,则显示“==0”字样。
我们这里还是用到了前面提到的指令ifXX。还记得其功能吧?
指令if_icmpge从栈中提取(pop)出两个值,然后对它们进行比较。如果第二个数大于第一个数的话,那么就跳转到偏移量为14的位置;其实指令if_icmpne和指令if_icmple的运行机理基本相同,只是其转移的条件不相同而已。
在偏移量为43的位置,我们还可以看到一个指令ifne。我们认为这是一个措辞不当的助记符,如果把它的助记符换位ifnz似乎更加贴切一些(意思是当栈顶的值不是0时跳转),而实际的执行过程也是这样的——当输入的值不是0时,程序会跳转到偏移量为54的地方。而如果输入值为0,程序的执行流则不会发生跳转、继续执行偏移量为46的指令、显示“==0”这个字符串。
必须注意的是:JVM没有无符号数的数据类型。因此我们只会遇到比较有符号数的比较指令。
我们来将前面讲到的两个取较大值和较小值的函数混合在一起使用,也就是函数min()和函数max()。
public class minmax
{
public static int min (int a, int b)
{
if (a>b)
return b;
return a;
}
public static int max (int a, int b)
{
if (a>b)
return a;
return b;
}
public static void main(String[] args)
{
int a=123, b=456;
int max_value=max(a, b);
int min_value=min(a, b);
System.out.println(min_value);
System.out.println(max_value);
}
}
下面是主函数main()的代码:
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=5, args_size=1
0: bipush 123
2: istore_1
3: sipush 456
6: istore_2
7: iload_1
8: iload_2
9: invokestatic #2 // Method max:(II)I
12: istore_3
13: iload_1
14: iload_2
15: invokestatic #3 // Method min:(II)I
18: istore 4
20: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
23: iload 4
25: invokevirtual #5 // Method java/io/PrintStream.println:(I)V
28: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
31: iload_3
32: invokevirtual #5 // Method java/io/PrintStream.println:(I)V
35: return
调用方函数通过栈向被调用方函数传递参数,而被调用方函数通过TOS()/栈顶向调用方函数传递返回值。
JVM的位操作指令和其他指令集的工作原理基本相同。
public static int set (int a, int b)
{
return a | 1<<b;
}
public static int clear (int a, int b)
{
return a & (~(1<<b));
}
public static int set(int, int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=2
0: iload_0
1: iconst_1
2: iload_1
3: ishl
4: ior
5: ireturn
public static int clear(int, int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=2
0: iload_0
1: iconst_1
2: iload_1
3: ishl
4: iconst_m1
5: ixor
6: iand
7: ireturn
指令iconst_m1将−1这个数调入栈中,其实这个值就是0Xffffffff。将任意数与−1进行异或XOR运算,其实就是对原操作数的所有位逐位取反(这一点可以参见本书的附录A.6.2)。
而当我们将所有的数据类型扩展为64位的long类型时:
public static long lset (long a, int b)
{
return a | 1<<b;
}
public static long lclear (long a, int b)
{
return a & (~(1<<b));
}
public static long lset(long, int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=4, locals=3, args_size=2
0: lload_0
1: iconst_1
2: iload_2
3: ishl
4: i2l
5: lor
6: lreturn
public static long lclear(long, int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=4, locals=3, args_size=2
0: lload_0
1: iconst_1
2: iload_2
3: ishl
4: iconst_m1
5: ixor
6: i2l
7: land
8: lreturn
除了操作指令都具有一个表示操作数是64位值的“L”前缀之外,这个程序的字节码和上一个程序几乎相同。此外,第二个函数的参数还有一个整型数据。假如需要把int型的32位数据扩展为64位long型数据,那么编译器就会分配i2l完成这项任务。
public class Loop
{
public static void main(String[] args)
{
for (int i = 1; i <= 10; i++)
{
System.out.println(i);
}
}
}
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: iconst_1
1: istore_1
2: iload_1
3: bipush 10
5: if_icmpgt 21
8: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
11: iload_1
12: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
15: iinc 1, 1
18: goto 2
21: return
这里举一个例子,显示从1~10一共10个整型数。采用循环指令的方式。
指令iconst_1将数值1调入栈顶TOS,而istore_1指令则将局部变量阵列LVA中的这项数值存储在第一个参数槽里。为什么把它存储在1号参数槽而不是第0个参数槽呢?这是因为主函数main()有一个参数是String数组,这个字符串的引用指针会占用第0号参数槽。
因此,局部变量i必须存放于第1个参数槽。
在偏移量为3和5的地方的指令,分别将变量i与循环控制变量的上限(这里是10)比较。如果此时的变量i比10大,那么指令流就会转向偏移量为21的地方,直接退出函数;否则,程序就会调用println()函数显示当前的数值。显示完后,在偏移量为11的地方,局部变量i会重新装入新的值,继续为显示方法做准备。另外,在调用println方法的时候,我们提供的参数时integer型参数。注释中的“(I)V”分别表示数据类型为integer,函数类型为void。
当显示函数println结束时,i的数值在偏移量为15的地方递增,也就是加1。这条指令有两个操作数。第一个操作数,第一个操作数表示实际运算数存储于第一号参数槽,第二个操作数表示递增的增量是1。
goto指令的功能就是跳转/GOTO。它跳转到循环体中偏移量为2的地方。
让我们来看一个稍微复杂一些的例子:斐波那契数列,简称为Fibonacci,其实前面已经提到过了。但是这里我们来看看如何用程序来实现它。
public class Fibonacci
{
public static void main(String[] args)
{
int limit = 20, f = 0, g = 1;
for (int i = 1; i <= limit; i++)
{
f = f + g;
g = f - g;
System.out.println(f);
}
}
}
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=5, args_size=1
0: bipush 20
2: istore_1
3: iconst_0
4: istore_2
5: iconst_1
6: istore_3
7: iconst_1
8: istore 4
10: iload 4
12: iload_1
13: if_icmpgt 37
16: iload_2
17: iload_3
18: iadd
19: istore_2
20: iload_2
21: iload_3
22: isub
23: istore_3
24: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
27: iload_2
28: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
31: iinc 4, 1
34: goto 10
37: return
我们来看看本地存储数组(Local Varible Array,LVA)与各参数槽的存储关系:
① 0号参数槽存储的是主函数main()的唯一参数;
② 1号参数槽存储的是循环控制变量limit,其值固定为20;
③ 2号参数槽存储的是变量f。
④ 3号参数槽存储的是变量g。
⑤ 4号参数槽存储的是变量i。
可见,Java编译器会按照源代码声明变量的顺序,在LVA中依次分配各变量的存储空间。
当直接向第0、1、2、3号参数槽存储数据时,可使用专用的istore_n指令。然而当直接向4及更高编号的参数槽存储数据时,就没有这样便利的专用操作指令了,需要使用带有参数的istore指令。如偏移量为8的指令所示,后一种istore指令将操作数当作参数槽的编号进行存储操作。其实iload指令也是如此。本文就不再解释偏移量为10的iload指令了。
但是,像循环迭代上限limit这样的常量也占用了参数槽,难道它还经常更新数值吗?JVM JIT编译器能够充分优化这类事务,我们不必专们进行人工干预。
下列范例证明,switch()语句是由tableswitch指令实现的。
public static void f(int a)
{
switch (a)
{
case 0: System.out.println("zero"); break;
case 1: System.out.println("one\n"); break;
case 2: System.out.println("two\n"); break;
case 3: System.out.println("three\n"); break;
case 4: System.out.println("four\n"); break;
default: System.out.println("something unknown\n"); break;
};
}
上述程序的字节码与源程序几乎是逐一对应:
public static void f(int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: iload_0
1: tableswitch { // 0 to 4
0: 36
1: 47
2: 58
3: 69
4: 80
default: 91
}
36: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
39: ldc #3 // String zero
41: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
44: goto 99
47: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
50: ldc #5 // String one\n
52: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang /String;)V
55: goto 99
58: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
61: ldc #6 // String two\n
63: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
66: goto 99
69: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
72: ldc #7 // String three\n
74: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
77: goto 99
80: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
83: ldc #8 // String four\n
85: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
88: goto 99
91: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
94: ldc #9 // String something unknown\n
96: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
99: return
如果输入值是0,则显示为zero;如果输入值是1,则显示one;如果输入值是2,则显示two;如果输入值是3,则显示three;如果输入值是4,则显示four;如果不是以上的5种情况,则显示字符串something unknown。
我们首先创建一个含有10个元素的整数型数组,然后逐次填入0~9:
程序如下:
public static void main(String[] args)
{
int a[]=new int[10];
for (int i=0; i<10; i++)
a[i]=i;
dump (a);
}
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=3, args_size=1
0: bipush 10
2: newarray int
4: astore_1
5: iconst_0
6: istore_2
7: iload_2
8: bipush 10
10: if_icmpge 23
13: aload_1
14: iload_2
15: iload_2
16: iastore
17: iinc 2, 1
20: goto 7
23: aload_1
24: invokestatic #4 // Method dump:([I]V
27: return
指令newarray创建一个可容纳10个整型(int)元素的数组。这个数组的大小是由bipush设定的,它会被保存在栈顶TOS;而数组的类型则是由newarray指令的操作数定义。我们看到newarrary的操作数是int,因此它会创建整型数组。执行完指令newarray后,系统会给新建的数组分配一个引用指针(reference),并且把这个引用指针存储到栈顶TOS。其后的astore_1指令吧引用指针存储到LVA的第一个参数槽。主函数main()的第二部分是一个循环语句。这个循环执行的指令将变量 i 依次保存到相应的数值单元中。指令aload_1获取数组的引用指针,并且把它保存在栈中。而指令iastore的功能则把栈里的整型数据保存在数组中,与此同时它会通过栈顶TOS获取数组的引用指针。而主函数main()的第三部分的功能是执行函数dump()。在偏移量为23的地方,我们可以看到aload_1指令。它负责制备dump()的唯一参数。
下面我们来继续看看函数dump()的功能:
public static void dump(int a[])
{
for (int i=0; i<a.length; i++)
System.out.println(a[i]);
}
该函数执行的功能是循环显示目标数组中的值。
程序如下所示。
public static void dump(int[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
0: iconst_0
1: istore_1
2: iload_1
3: aload_0
4: arraylength
5: if_icmpge 23
8: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
11: aload_0
12: iload_1
13: iaload
14: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
17: iinc 1, 1
20: goto 2
23: return
函数会从第0号参数槽获取数组的引用指针/reference。而源代码中的a.length表达式被编译器转换成了arraylength(数组长度)指令:它通过引用指针获取数组的信息,并把数组长度保存在栈顶TOS中。在偏移量为13的指令iaload则负责加载既定的数组元素。在数组类型确定的情况下,对某个数组元素寻址需要知道数组的首地址和即定元素的索引编号。前者由偏移量为11的指令aload_0完成;后者则通过偏移量为12的指令iload_1实现。
很多人会想当然的认为,在那些带有字母前缀a的指令里,a大概是数组array的缩写。其实这种猜测并不确切。此类指令是操作数据对象引用指针的指令。数组和字符串只是对象型数据的一种特例罢了。
我们来看看另外一个例子,其功能是将一个输入的数组的各项数值相加求和。
public class ArraySum
{
public static int f (int[] a)
{
int sum=0;
for (int i=0; i<a.length; i++)
sum=sum+a[i];
return sum;
}
}
public static int f(int[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=3, args_size=1
0: iconst_0
1: istore_1
2: iconst_0
3: istore_2
4: iload_2
5: aload_0
6: arraylength
7: if_icmpge 22
10: iload_1
11: aload_0
12: iload_2
13: iaload
14: iadd
15: istore_1
16: iinc 2, 1
19: goto 4
22: iload_1
23: ireturn
在这个成员函数的存储空间里,外来数组的引用指针存储于LVA的0号存储槽里,而局部变量sum则存储在LVA的1号存储槽里。
下面展示的是一个单参数的main()函数。这个外来参数是一个字符串。
public class UseArgument
{
public static void main(String[] args)
{
System.out.print("Hi, ");
System.out.print(args[1]);
System.out.println(". How are you?");
}
}
第0个参数是程序名(就像在C/C++等中的一样),因此程序员指定的第一个参数存放于第一个参数槽。
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hi,
5: invokevirtual #4 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
8: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
11: aload_0
12: iconst_1
13: aaload
14: invokevirtual #4 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
17: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
20: ldc #5 // String . How are you?
22: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
25: return
偏移量为11的指令aload_0加载了LVA(局部变量数组)的第0个存储单元。而函数main()的唯一一个指定参数则通过偏移量为12和13的iconst_1和aaload指令的作用是获取数组的第一个元素的引用指针(也就是索引号为0的元素首地址)。偏移量为14的指令通过TOS向被调用方函数传递字符串的引用指针,这个引用指针就是此后println方法的输入变量。
class Month
{
public static String[] months =
{
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
};
public String get_month (int i)
{
return months[i];
};
}
我们在这里列举一个有预设值的数组,它的元素是字符串型的,其值是从一月到十二月的英文单词,分别是January、February、March、April、May、June、July、August、September、October、November和December。
函数get_month的功能比较简单,它是输入一个整型的数,从而能在数组的对应位置输出相应月份的字符串。
public java.lang.String get_month(int);
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: getstatic #2 // Field months:[Ljava/lang/String;
3: iload_1
4: aaload
5: areturn
aaload指令从栈里POP出数组的引用指针和元素的索引编号,并将指定元素推送入栈。在Java的概念里,字符串是对象型数据。在操作对象型数据(确切的说是引用指针)时应当使用带有a前缀的指令。同理,后面的areturn指令从侧面印证了返回指是字符串对象的引用指针。
另外一个问题是:数组months[]是如何被初始化的呢?也就是说,这个数组的初始值1到12月份的英文字符串是如何被相应地赋值到数组的相关位置的?
static {};
flags: ACC_STATIC
Code:
stack=4, locals=0, args_size=0
0: bipush 12
2: anewarray #3 // class java/lang/String
5: dup
6: iconst_0
7: ldc #4 // String January
9: aastore
10: dup
11: iconst_1
12: ldc #5 // String February
14: aastore
15: dup
16: iconst_2
17: ldc #6 // String March
19: aastore
20: dup
21: iconst_3
22: ldc #7 // String April
24: aastore
25: dup
26: iconst_4
27: ldc #8 // String May
29: aastore
30: dup
31: iconst_5
32: ldc #9 // String June
34: aastore
35: dup
36: bipush 6
38: ldc #10 // String July
40: aastore
41: dup
42: bipush 7
44: ldc #11 // String August
46: aastore
47: dup
48: bipush 8
50: ldc #12 // String September
52: aastore
53: dup
54: bipush 9
56: ldc #13 // String October
58: aastore
59: dup
60: bipush 10
62: ldc #14 // String November
64: aastore
65: dup
66: bipush 11
68: ldc #15 // String December
70: aastore
71: putstatic #2 // Field months:[Ljava/lang/String;
74: return
指令anewarray负责创建一个指定大小的数组,并将对象的引用指针推送入栈(字母a表示返回值为引用指针)。anewarry指令的操作数声明了目标数组的数据类型,在上面的指令里这个操作数是java/lang/String。在此之前的bipush 12则设置了数组的大小(这个值会被anewarrary指令pop出栈),而这个大小正好是一年的月份总数。后面出现的dup指令是在栈计算机领域非常著名的栈顶复制指令(在Forth等基于堆栈的编程语言里都有这条指令)。它将复制数组的引用指针。这是因为aastore指令会从栈顶pop出引用指针,而后面的aastore指令还需要再次读取该引用指针。显而易见的是,Java编译器认为在存储数组元素时分配dup指令比分配getstatic指令更为稳妥,否则它也不会一口气派发了12个dup指令。
aastore指令从TOS里依次提取(即POP)元素值、数组下标和数组的引用指针,并将指定值存储到指定的数组元素里。
最后的putstatic指令将栈顶的数据出栈并把它存储到常量解析池的#2号位置。因此它把新建数组的引用指针保存到了整个实例的第二个字段,也就是给months字段赋值。
可变参数函数利用了数组的数据结构。
public static void f(int... values)
{
for (int i=0; i<values.length; i++)
System.out.println(values[i]);
}
public static void main(String[] args)
{
f (1,2,3,4,5);
}
public static void f(int...);
flags: ACC_PUBLIC, ACC_STATIC, ACC_VARARGS
Code:
stack=3, locals=2, args_size=1
0: iconst_0
1: istore_1
2: iload_1
3: aload_0
4: arraylength
5: if_icmpge 23
8: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
11: aload_0
12: iload_1
13: iaload
14: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
17: iinc 1, 1
20: goto 2
23: return
在f()函数中,偏移量为3的aload_0指令提取了整数数组的指针。此后的指令依次提取数组大小等信息。
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=4, locals=1, args_size=1
0: iconst_5
1: newarray int
3: dup
4: iconst_0
5: iconst_1
6: iastore
7: dup
8: iconst_1
9: iconst_2
10: iastore
11: dup
12: iconst_2
13: iconst_3
14: iastore
15: dup
16: iconst_3
17: iconst_4
18: iastore
19: dup
20: iconst_4
21: iconst_5
22: iastore
23: invokestatic #4 // Method f:([I]V
26: return
main()函数通过newarray指令构造了一个数组,接着填充这个数组,随后调用了f()函数。
虽然newarray属于某种构造函数,但是在main()结束之后整个数组没有被析构函数释放。实际上Java没有析构函数。JVM具有自动的垃圾回收机制。
另外,当函数main()退出后,数组对象的值其实是未消失的。其实在JAVA环境中就没有清除这个指令,原因是JAVA的内存机制会自动清理不用内存的功能,当然是在其认为必要时进行。
系统自带的format()方法又是如何处理可变参数的呢?它把输入参数分为了字符串对象和数组型对象两大部分:
public PrintStream format(String format, Object... args)
参考链接:http://docs.oracle.com/javase/tutorial/java/data/numberformat.html
。
我们再来看看下面的例子:
public static void main(String[] args)
{
int i=123;
double d=123.456;
System.out.format("int: %d double: %f.%n", i, d);
}
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=7, locals=4, args_size=1
0: bipush 123
2: istore_1
3: ldc2_w #2 // double 123.456d
6: dstore_2
7: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
10: ldc #5 // String int: %d double: %f.%n
12: iconst_2
13: anewarray #6 // class java/lang/Object
16: dup
17: iconst_0
18: iload_1
19: invokestatic #7 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
22: aastore
23: dup
24: iconst_1
25: dload_2
26: invokestatic #8 // Method java/lang/Double.valueOf:(D)Ljava/lang/Double;
29: aastore
30: invokevirtual #9 // Method java/io/PrintStream.format:(Ljava/lang/↙
↘ String;[Ljava/lang/Object;]Ljava/io/PrintStream;
33: pop
34: return
可见,int型数据和double型数据首先经由各自的valueOf方法处理、返回相应的数据值。format()方法的输入值应当为Object型实例。而Integer和Double类是超类Object的子类,所以这种实例可以作为format()函数参数里的数组元素。另外一方面,所有数组都是均质的,也就是说它不能保存不同类型的数据元素,因此int和double类的数据类型不可能是超类数组以外任何类型数组的数据元素。
偏移量为13的指令构造了一个Object型的数组实例,而偏移量为22、29的指令,分别把整型Integer对象,和双精度Double型对象添加到超类对象的数组里。
整个程序的倒数第二行指令pop、即清除了栈顶TOS中的元素数值,因此在执行最后一个指令return时,该方法的数据栈已经被彻底释放(又称作平衡/balanced)。
在Java中,二维数组其实就是一个存储着另一维度数组引用指针的一维数组。
public static void main(String[] args)
{
int[][] a = new int[5][10];
a[1][2]=3;
}
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
0: iconst_5
1: bipush 10
3: multianewarray #2, 2 // class "[[I"
7: astore_1
8: aload_1
9: iconst_1
10: aaload
11: iconst_2
12: iconst_3
13: iastore
14: return
为了展示效果,我们在这里创建一个大小为10×5的整型二维数组,采用的指令是new int[5][10]。
Java采用multinewarray指令构造多维数组。本例先通过iconst_5和bipush指令将各纬度的长度值推送入栈,再使用multinewarray指令声明数据类型(常量解析池#2)和数组维度(2)。
偏移量为9、10的iconst_1和aaload指令用于加载第1行的引用指针。偏移量为11的iconst_2指令则声明了指定列。偏移量为12的指令明确该元素的取值。偏移量为13的iastore指令最终完成元素赋值。
二维数组的读取操作又是如何实现的呢?
public static int get12 (int[][] in)
{
return in[1][2];
}
public static int get12(int[][]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: iconst_1
2: aaload
3: iconst_2
4: iaload
5: ireturn
从这个程序我们可以看到:偏移量为2的aaload指令读入了指定行的引用指针,而偏移量为3的iconst_2指令声明了列编号。最终iaload指令读取了指定元素的数值。
三维数组可视为存储了二维数组引用指针的一维数组。
public static void main(String[] args)
{
int[][][] a = new int[5][10][15];
a[1][2][3]=4;
get_elem(a);
}
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
0: iconst_5
1: bipush 10
3: bipush 15
5: multianewarray #2, 3 // class "[[[I"
9: astore_1
10: aload_1
11: iconst_1
12: aaload
13: iconst_2
14: aaload
15: iconst_3
16: iconst_4
17: iastore
18: aload_1
19: invokestatic #3 // Method get_elem:([[[I]I
22: pop
23: return
我们这里举的例子中,三个维度的数值分别是5、10以及15。
它需要使用两次aaload指令才能找到最后一维数组的引用指针。
public static int get_elem (int[][][] a)
{
return a[1][2][3];
}
public static int get_elem(int[][][]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: iconst_1
2: aaload
3: iconst_2
4: aaload
5: iconst_3
6: iaload
7: ireturn
在Java中,是否可能发生缓冲区溢出的情况?不可能。Java数组的数据实例存储了数组长度的明确信息,数组操作会作边界检查。一旦发生上标/下标溢出问题,运行环境就会进行异常处理。
Java和C/C++的多维数组在底层结构上存在显著的区别,因此JAVA不太适合用进行大规模科学计算。
Java的字符串和数组都是同等对象,因此它们的构造过程没有什么区别。
public static void main(String[] args)
{
System.out.println("What is your name?");
String input = System.console().readLine();
System.out.println("Hello, "+input);
}
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String What is your name?
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: invokestatic #5 // Method java/lang/System.console:()Ljava/io/Console;
11: invokevirtual #6 // Method java/io/Console.readLine:()Ljava/lang/String;
14: astore_1
15: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
18: new #7 // class java/lang/StringBuilder
21: dup
22: invokespecial #8 // Method java/lang/StringBuilder."<init>":()V
25: ldc #9 // String Hello,
27: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/↙
↘ lang/String;)Ljava/lang/StringBuilder;
30: aload_1
31: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/↙
↘ lang/String;)Ljava/lang/StringBuilder;
34: invokevirtual #11 // Method java/lang/StringBuilder.toString:()↙
↘ Ljava/lang/String;
37: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang↙
↘ /String;)V
40: return
我们这里举的例子是交互式的,具体功能是能根据用户输入的用户名,显示一句问候,作为回显结果。
偏移量为11的指令调用了readLine函数。其返回值,即用户输入的字符串的引用指针,最后通过栈顶TOS返回。偏移量为14的指令将字符串的引用指针存储在LVA的第一个存储单元中。偏移量为30的指令再次加载了用户输入字符串的引用指针,在StringBuilder类的实例中与字符串(Hello, )连接为新的字符串。最后偏移量为37的invokevirtual指令调用了println方法,显示最终的字符串。
另外一个例子是:
public class strings
{
public static char test (String a)
{
return a.charAt(3);
};
public static String concat (String a, String b)
{
return a+b;
}
}
public static char test(java.lang.String);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: iconst_3
2: invokevirtual #2 // Method java/lang/String.charAt:(I)C
5: ireturn
编译器会利用StringBuilder类来连接字符串:
public static java.lang.String concat(java.lang.String, java.lang.String);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=2
0: new #3 // class java/lang/StringBuilder
3: dup
4: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
7: aload_0
8: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/ ↙
↘ lang/String;)Ljava/lang/StringBuilder;
11: aload_1
12: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/ ↙
↘ lang/String;)Ljava/lang/StringBuilder;
15: invokevirtual #6 // Method java/lang/StringBuilder.toString:() ↙
↘ Ljava/lang/String;
18: areturn
我们再看一个将字符串和整型数连接在一起的例子:
public static void main(String[] args)
{
String s="Hello!";
int n=123;
System.out.println("s=" + s + " n=" + n);
}
这里同样调用了StringBuilder类的append方法连接字符串,再通过println函数显示最终的字符串。
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=3, args_size=1
0: ldc #2 // String Hello!
2: astore_1
3: bipush 123
5: istore_2
6: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
9: new #4 // class java/lang/StringBuilder
12: dup
13: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
16: ldc #6 // String s=
18: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/ ↙
↘ lang/String;)Ljava/lang/StringBuilder;
21: aload_1
22: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/ ↙
↘ lang/String;)Ljava/lang/StringBuilder;
25: ldc #8 // String n=
27: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/ ↙
↘ lang/String;)Ljava/lang/StringBuilder;
30: iload_2
31: invokevirtual #9 // Method java/lang/StringBuilder.append:(I)Ljava ↙
↘ /lang/StringBuilder;
34: invokevirtual #10 // Method java/lang/StringBuilder.toString:() ↙
↘ Ljava/lang/String;
37: invokevirtual #11 // Method java/io/PrintStream.println:(Ljava/lang ↙
↘ /String;)V
40: return
让我们再来回顾一下54.13.4节中已经讲到的例子,那是一个关于月份显示的程序实例。显然如果输入数组的数值小于0或者大于11的话,都会触发异常处理函数。
指令清单54.10 IncorrectMonthException.java(不正确的月份显示例外)
public class IncorrectMonthException extends Exception
{
private int index;
public IncorrectMonthException(int index)
{
this.index = index;
}
public int getIndex()
{
return index;
}
}
指令清单54.11 Month2.java(月份2)
class Month2
{
public static String[] months =
{
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
};
public static String get_month (int i) throws IncorrectMonthException
{
if (i<0 || i>11)
throw new IncorrectMonthException(i);
return months[i];
};
public static void main (String[] args)
{
try
{
System.out.println(get_month(100));
}
catch(IncorrectMonthException e)
{
System.out.println("incorrect month index: "+ e.getIndex());
e.printStackTrace();
}
};
}
本质上讲,IncorrectMonthException.class只具备一个对象构造函数和一个访问器。
这个类由Exception继承而来,因此它首先调用Exception类的构造函数,接着声明了自己唯一的输入值字段。
public IncorrectMonthException(int);
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: invokespecial #1 // Method java/lang/Exception."<init>":()V
4: aload_0
5: iload_1
6: putfield #2 // Field index:I
9: return
而getIndex()就是一个访问器/accessor。它通过aload_0指令从LVA的第0个存储槽获取IncorrectMonthException的this指针,再通过getfield指令从对象实例里提取整数值。
public int getIndex();
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field index:I
4: ireturn
现在让我们来看看Month2.class中的get_month()。
指令清单54.12 Month2.class
public static java.lang.String get_month(int) throws IncorrectMonthException;
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=1, args_size=1
0: iload_0
1: iflt 10
4: iload_0
5: bipush 11
7: if_icmple 19
10: new #2 // class IncorrectMonthException
13: dup
14: iload_0
15: invokespecial #3 // Method IncorrectMonthException."<init>":(I)V
18: athrow
19: getstatic #4 // Field months:[Ljava/lang/String;
22: iload_0
23: aaload
24: areturn
我们来分析分析这个程序:
在偏移为1的iflt指令在栈顶值小于1的情况下触发跳转。“iflt”是英文if less than的缩写。
当index参数是无效值时,程序会跳转到偏移量为10的new指令,创建一个新的对象。而该对象的类型就是指令的操作数(常量解析池#2)IncorrectMonthException。接着偏移量为15的指令调用构造函数,并通过栈顶TOS传递局部变量index。当执行到偏移量为18的指令处时,异常处理实例已经构造完毕,athrow指令将从栈顶提取由上一条指令传递的异常处理方法的引用指针,并通知JVM系统该方法为当前类实例的异常处理函数。
此处的athrow指令并不返回控制流。此后的偏移量为19的指令开始是另外一个基本的模块,它与异常处理过程没有关系,可视为从偏移量为7的指令开始的领悟一个逻辑分支。
例外的句柄是如何工作的?我们来看看类Month2.class中的函数main()。
指令清单54.13 Month2.class
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
0: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
3: bipush 100
5: invokestatic #6 // Method get_month:(I)Ljava/lang/String;
8: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
11: goto 47
14: astore_1
15: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
18: new #8 // class java/lang/StringBuilder
21: dup
22: invokespecial #9 // Method java/lang/StringBuilder."<init>":()V
25: ldc #10 // String incorrect month index:
27: invokevirtual #11 // Method java/lang/StringBuilder.append:(Ljava/ ↙
↘ lang/String;)Ljava/lang/StringBuilder;
30: aload_1
31: invokevirtual #12 // Method IncorrectMonthException.getIndex:()I
34: invokevirtual #13 // Method java/lang/StringBuilder.append:(I)Ljava ↙
↘ /lang/StringBuilder;
37: invokevirtual #14 // Method java/lang/StringBuilder.toString:() ↙
↘ Ljava/lang/String;
40: invokevirtual #7 // Method java/io/PrintStream.println:(Ljava/lang ↙
↘ /String;)V
43: aload_1
44: invokevirtual #15 // Method IncorrectMonthException.printStackTrace ↙
:()V
47: return
Exception table:
from to target type
0 11 14 Class IncorrectMonthException
自偏移量为14的指令开始的内容就是异常表Exception table。在程序从偏移量0运行到偏移量11(含)期间,发生的全部异常状况都会交给IncorrectMonthException处理。当输入值为无效值时,程序流向导至偏移量为14的指令。实际上,主程序在偏移量为11的地方就已经结束。正常情况下,程序不会执行到偏移量为14的指令,而且也没有任何条件转移指令或者无条件转移指令会跳转到该处。只有当程序遇到例外情况时,程序才运行到偏移量大于11的指令。异常处理的第一条位于偏移量14。此处的astore_1会把外部传入的、异常处理实例的引用指针存储到LVA的第一个存储槽。在此之后,偏移量为31的指令将会通过这个引用指针调用异常处理实例的getIndex()方法。此时偏移量为30的指令把这个引用指针已经提取出来了。异常表的其他指令都是字符串处理指令:getIndex()方法返回局部变量index的整数值,这个值由toString()方法转换为字符串,再与字符串“incorrect month index:”连接,最终通过println()和printStackTrace()方法显示出来。在调用了printStackTrace()之后,整个异常处理过程宣告完毕,程序恢复正常状态。虽然本例偏移量位47的指令是结束main()函数的return指令,但是此处可以是其他的、在正常状态下需要执行的任何指令。
接下来,我们来看看IDA显示异常处理方法的具体方式。
指令清单54.14 笔者计算机里某个class文件的异常处理方法
.catch java/io/FileNotFoundException from met001_335 to met001_360\
using met001_360
.catch java/io/FileNotFoundException from met001_185 to met001_214\
using met001_214
.catch java/io/FileNotFoundException from met001_181 to met001_192\
using met001_195
.catch java/io/FileNotFoundException from met001_155 to met001_176\
using met001_176
.catch java/io/FileNotFoundException from met001_83 to met001_129 using \
met001_129
.catch java/io/FileNotFoundException from met001_42 to met001_66 using \
met001_69
.catch java/io/FileNotFoundException from met001_begin to met001_37\
using met001_37
一个简单的类如下所示。
指令清单54.15 test.java
public class test
{
public static int a;
private static int b;
public test()
{
a=0;
b=0;
}
public static void set_a (int input)
{
a=input;
}
public static int get_a ()
{
return a;
}
public static void set_b (int input)
{
b=input;
}
public static int get_b ()
{
return b;
}
}
构造函数把两个变量设置为0:
public test();
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: iconst_0
5: putstatic #2 // Field a:I
8: iconst_0
9: putstatic #3 // Field b:I
12: return
设置a:
public static void set_a(int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: iload_0
1: putstatic #2 // Field a:I
4: return
获取a:
public static int get_a();
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: getstatic #2 // Field a:I
3: ireturn
设置b:
public static void set_b(int);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: iload_0
1: putstatic #3 // Field b:I
4: return
获取b:
public static int get_b();
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: getstatic #3 // Field b:I
3: ireturn
在底层指令层面上,类中那些具有public和private属性的成员对象没有实质区别。但是.class文件级别,外部指令无法直接访问其他类里的private属性成员。
接下来,我们演示创建对象和调用方法。
指令清单54.16 ex1.java程序
public class ex1
{
public static void main(String[] args)
{
test obj=new test();
obj.set_a (1234);
System.out.println(obj.a);
}
}
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class test
3: dup
4: invokespecial #3 // Method test."<init>":()V
7: astore_1
8: aload_1
9: pop
10: sipush 1234
13: invokestatic #4 // Method test.set_a:(I)V
16: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
19: aload_1
20: pop
21: getstatic #6 // Field test.a:I
24: invokevirtual #7 // Method java/io/PrintStream.println:(I)V
27: return
new指令可以创建新的对象,但是它并没有调用构造函数(偏移量为4的指令调用了构造函数)。偏移量为13的指令调用了set_a()方法。偏移量为21的getstatic指令访问了类的一个字段。
本节通过一个简单的程序演示补丁的实现方法:
public class nag
{
public static void nag_screen()
{
System.out.println("This program is not registered");
};
public static void main(String[] args)
{
System.out.println("Greetings from the mega-software");
nag_screen();
}
}
我们可否去掉字符串“This program is not registered”?
我们使用调试工具IDA加载类文件.class,如图54.1所示。
图54.1 IDA
如图54.2所示,我们试图把该函数的第一个字节改为177,即return的字节码。
图54.2 IDA
但是如此一来程序就崩溃了(运行环境为JRE 1.7):
Exception in thread "main" java.lang.VerifyError: Expecting a stack map frame
Exception Details:
Location:
nag.nag_screen()V @1: nop
Reason:
Error exists in the bytecode
Bytecode:
0000000: b100 0212 03b6 0004 b1
at java.lang.Class.getDeclaredMethods0(Native Method)
at java.lang.Class.privateGetDeclaredMethods(Class.java:2615)
at java.lang.Class.getMethod0(Class.java:2856)
at java.lang.Class.getMethod(Class.java:1668)
at sun.launcher.LauncherHelper.getMainMethod(LauncherHelper.java:494)
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:486)
也许,JVM还存在某种与栈有关的检查机制。
好吧,我们采用一个其他的方法来“打补丁”:直接覆盖nag()的调用指令,如图54.3所示。
图54.3 IDA
0就是NOP的字节码。
经过运行检验,这次的“打补丁”是成功的。
下面我们再看看另外一个简单的例子:
public class password
{
public static void main(String[] args)
{
System.out.println("Please enter the password");
String input = System.console().readLine();
if (input.equals("secret"))
System.out.println("password is correct");
else
System.out.println("password is not correct");
}
}
其实现的基本思路是按照提示输入密码字符串,当输入的密码字符串为“secret”时,显示字符串密码正确(password is correct);否则显示字符串密码不正确(password is not correct)。
将该程序调入到调试工具IDA中,如图54.4所示。
图54.4 IDA
关键之处是比较字符串的ifeq指令。这个指令其实是英文if equal(如果相等)的缩写。实际上这个助记符不太贴切,它要是ifz(也就是如果TOS是零)就更加确切了。也就是说,如果栈顶TOS的值是零,它就进行跳转。在这个例子中,只有当输入的密码有误才会触发跳转(布尔“假”/False的对应值是0)。我们的第一个想法就是调整这个指令。在ifeq的字节码里,有两个字节专门封装转移目标地址的偏移量。要想把它强行改为“无条件不转移”,必须把第三个字节的改为3(ifeq占用3个字节,PC的偏移量加3就是执行下一条指令):
我们把相应指令改为如图54.5所示的样子 。
图54.5 IDA
结果,修改后的程序无法在JRE 1.7环境下正确执行。
Exception in thread "main" java.lang.VerifyError: Expecting a stackmap frame at branch target 24
Exception Details:
Location:
password.main([Ljava/lang/String;]V @21: ifeq
Reason:
Expected stackmap frame at this location.
Bytecode:
0000000: b200 0212 03b6 0004 b800 05b6 0006 4c2b
0000010: 1207 b600 0899 0003 b200 0212 09b6 0004
0000020: a700 0bb2 0002 120a b600 04b1
Stackmap Table:
append_frame(@35,Object[#20])
same_frame(@43)
at java.lang.Class.getDeclaredMethods0(Native Method)
at java.lang.Class.privateGetDeclaredMethods(Class.java:2615)
at java.lang.Class.getMethod0(Class.java:2856)
at java.lang.Class.getMethod(Class.java:1668)
at sun.launcher.LauncherHelper.getMainMethod(LauncherHelper.java:494)
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:486)
但是在JRE 1.6版本下,如此修改的程序的确可以正常运行。
笔者也尝试了“将这个ifeq指令的字节码直接更换成3个空指令NOP”的做法。即使是这样,修改后的程序仍然不能正常运行。大概是JRE 1.7的栈映射核查更为全面吧!
接下来,我们更换一种方法:把ifeq之前的、调用input.equals方法的全部指令全都替换为NOP,把它改为如图54.6所示的样子。
图54.6 IDA
如此修改之后,在执行到ifeq指令的时候栈顶的值永远是1,因此不会满足ifeq的跳转条件。
试验说明,这种方法果然有效。
和C/C++语言相比,Java语言少了些什么数据类型?
① 结构:采用类。
② 联合:采用类继承。
③ 无符号数据类型:这也直接导致了在JAVA下,实现密码算法比较困难。
④ 函数指针。
[1] 关于常规指针和引用指针的详细区别可以查阅本书的51.3节。