通过Java反编译揭开一些问题的真相
转自:http://www.importnew.com/20280.html
收藏待研究
博主在上一篇《 Java语法糖之foreach》中采用反编译的形式进行探讨进而揭开foreach语法糖的真相。进来又遇到几个问题,通过反编译之后才了解了事实的真相,觉得有必要做一下总结,也可以给各位做一下参考。
相信很多朋友刚开始见到反编译后的内容的时候,肯定会吐槽:WTF!其实只要静下心来认真了解下,反编译也不过如此,Java字节码的长度为一个字节,顶多256条指令,目前,Java虚拟机规范已经定义了其中约200条编码值对应的指令含义。这里先用一个小例子来开始我们的征程(这里只是举例,要是在真实生活中看到这种代码,估计要骂娘了):
1
2
3
4
|
int i= 0 ; int y = i++ + ++i; i= 0 ; int z = i++ + ++i + ++i + ++i + i++ + ++i; |
问题来了:最后y和z分别是多少?
看到y估计还能看看,看到z就晕乎乎的了,大家都知道i++是先取i值运算后对i进行自加,++i是先对i进行自加再运算。那么在一串组合里(y和z)怎么运用这个规则呢。
心急的朋友估计已经打开了编译器,跑一跑答案不就出来了,看着结果再反推一下就知道这个“游戏规则”了。
在C/C++和Java语言中都有这个事实:i++是先取i值运算后对i进行自加,++i是先对i进行自加再运算。但是这两(三)种语言跑出来的结果是不一样的。
在c/c++中(vs6):
运行结果:
在java中(eclipse),运行结果:2 19。
可以看到两(三)种语言虽然遵循了同样的自增规则但是输出的结果却不一样。这里不探讨c/c++的规则,有兴趣的同学可以追根溯源。
那么java中遵循什么样的规则呢?这里就要祭出我们的必杀器了——反编译。
为了防止看晕,先对这段代码进行反编译处理(先不看变量z):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
package interview; public class TestIpp { public static void main(String[] args) { plus(); } static void plus() { int i= 0 ; int y = i++ + ++i; System.out.println(y); } } |
对其进行反编译,反编译的命令如下:
- 首先切到当前文件目录下(cd命令,window和linux相同)
- 在当前目录下输入: javac TestIpp.java (先编译),之后会看到(window下输入dir命令,linux下输入ls命令)多出来一个TestIpp.class文件
- 再输入命令:javap -verbose TestIpp(反编译,注意可以没有.class),会看到反编译结果。
上面是输入命令行的形式进行的反编译,其实Eclipse自带了这个功能,将workspace中相应的class往eclipse的workbench上一扔即可,但是javac命令生成的class文件eclipse无法识别。
下面是反编译后的代码(篇幅限制,只显示出plus()方法的反编译内容):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
// Method descriptor #6 ()V // Stack: 2, Locals: 2 static void plus(); 0 iconst_0 1 istore_0 [i] 2 iload_0 [i] 3 iinc 0 1 [i] 6 iinc 0 1 [i] 9 iload_0 [i] 10 iadd 11 istore_1 [y] 12 getstatic java.lang.System.out : java.io.PrintStream [ 21 ] 15 iload_1 [y] 16 invokevirtual java.io.PrintStream.println( int ) : void [ 27 ] 19 return Line numbers: [pc: 0 , line: 13 ] [pc: 2 , line: 14 ] [pc: 12 , line: 15 ] [pc: 19 , line: 16 ] Local variable table: [pc: 2 , pc: 20 ] local: i index: 0 type: int [pc: 12 , pc: 20 ] local: y index: 1 type: int |
这里来解析一下这些是个啥玩意儿:
1
2
3
4
5
6
7
8
9
10
11
12
|
0 iconst_0 *向栈顶压入一个 int 常量 0 *,java基于栈操作,这里首先将代码[ int i= 0 ;]中的 0 压入栈顶 1 istore_0 [i] *将栈顶元素存入本地变量 0 [这个变量 0 就是i]中*,.此时栈内无元素 2 iload_0 [i] *将本地变量 0 [i]放入栈顶中*,此时栈内有一个元素,即为 0 3 iinc 0 1 [i] *将制定的 int 型变量[i]增加指定值[ 1 ]*,这时i= 0 + 1 = 1 6 iinc 0 1 [i] *将制定的 int 型变量[i]增加指定值[ 1 ]*,这时i= 1 + 1 = 2 9 iload_0 [i] *将本地变量 0 [i]放入栈顶中*,此时栈内有两个元素, 0 和 2 ,栈顶为 2 10 iadd *将栈顶两个 int 类型数值相加*,结果压入栈顶,此时栈内一个元素为 0 + 2 = 2 11 istore_1 [y] *将栈顶元素存入本地变量 1 中*[变量 1 就是y] 12 getstatic java.lang.System.out : java.io.PrintStream [ 21 ] 15 iload_1 [y] 16 invokevirtual java.io.PrintStream.println( int ) : void [ 27 ] 19 return |
可以看到i++ + ++i的运行结果:遇到i++是先取i(初始i=0)的值(压入栈),然后进行自加(此时i=1),遇到+号标记继续(脑补一下逆波兰表达式,这里就不说明java的词法分析、语法分析、语义分析、代码生成的过程了),遇到++i,先进行自加(此时i=2),然后取i的值(压入栈),然后将栈顶两元素相加即可结果。
假如有个变量m=i++ + i++ + ++i(i初始为0)那么结果等于多少呢,我们来分析一下。
初始i=0, 遇到i++,将i的值压入栈(栈内一个元素:0),自加,此时i=1,遇到+号标记继续,遇到i++,将i值压入栈内(栈内元素:1,0),算上之前标记的+号,栈内两元素相加之后压入栈(栈内元素:1),i值自加,此时i=2,遇到+号标记继续,遇到++i,将i值自加,此时i=3压入栈内(栈内元素3,1),算上之前标记的+号,栈内两元素相加之后入栈(栈内元素为4),最后将栈顶元素存入本地变量m中,结束。整个相加过程m=0+1+3=4. 到这里,如果觉得有疑问可以打开编译器跑一下m=i++ + i++ + ++i(i初始为0)。
那么int z = i++ + ++i + ++i + ++i + i++ + ++i(初始i=0);可以得到的结果为z=0+2+3+4+4+6=19.
这个例子的讲解就此结束。这里博主不是想要讲解一下i++ + ++i之类的问题,而是希望大家可以通过这个问题认识学习反编译的重要性,能够更深刻的认识问题。就比如上小学一年级时,考试全是个位数加减,但是基本没人得满分,因为那时候个位数加减也是很难滴;后来到了三四年级学到乘除法的时候,个位数加减基本不会算错了;当你学到高等数学的时候你还会为普通的加减乘除烦恼嚒?会当凌绝顶,一览众山小。
这里博主准备再将一个例子,加深一下印象,这是前几天遇到的一个问题,首先看代码举例:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
package interview; import java.util.HashMap; import java.util.Map; public class JavapTest2 { public static Map<String,String> m = new HashMap<String, String>(){ { put( "key1" , "value1" ); } }; } |
这段代码就是定义一个静态类成员变量m,并附初始值。很多朋友应该不太习惯这种用法,一般的就是:
1
|
public static Map<String,String> m = new HashMap<String, String>(); |
要赋值就会继续m.put(“key1”,”value1”);之类的。
那么这段代码的背后到底是什么呢?同样祭出我们的反编译。
发现生成了两个class文件,分别为JavapTest2.class和JavapTest2$1.class.
JavapTest2.class:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
// Compiled from JavapTest2.java (version 1.7 : 51.0, super bit) public class interview.JavapTest2 { // Field descriptor #6 Ljava/util/Map; // Signature: Ljava/util/Map<Ljava/lang/String;Ljava/lang/String;>; public static java.util.Map m; // Method descriptor #10 ()V // Stack: 2, Locals: 0 static {}; 0 new interview.JavapTest2$ 1 [ 12 ] 3 dup 4 invokespecial interview.JavapTest2$ 1 () [ 14 ] 【博主自加:调用实例初始化方法】 7 putstatic interview.JavapTest2.m : java.util.Map [ 17 ] 【博主自加:为指定的类的静态域赋值】 10 return Line numbers: [pc: 0 , line: 8 ] [pc: 10 , line: 12 ] // Method descriptor #10 ()V // Stack: 1, Locals: 1 public JavapTest2(); 0 aload_0 [ this ] 1 invokespecial java.lang.Object() [ 21 ] 4 return Line numbers: [pc: 0 , line: 6 ] Local variable table: [pc: 0 , pc: 5 ] local: this index: 0 type: interview.JavapTest2 Inner classes: [inner class info: # 12 interview/JavapTest2$ 1 , outer class info: # 0 inner name: # 0 , accessflags: 0 default ] } |
JavapTest2$1.class:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
// Compiled from JavapTest2.java (version 1.7 : 51.0, super bit) // Signature: Ljava/util/HashMap<Ljava/lang/String;Ljava/lang/String;>; class interview.JavapTest2$ 1 extends java.util.HashMap { // Method descriptor #6 ()V // Stack: 3, Locals: 1 JavapTest2$ 1 (); 0 aload_0 [ this ] 1 invokespecial java.util.HashMap() [ 8 ] 【博主自加:invokespecial是调用父类的构造函数初始化方法】 4 aload_0 [ this ] 5 ldc <String "key1" > [ 10 ] 7 ldc <String "value1" > [ 12 ] 9 invokevirtual interview.JavapTest2$ 1 .put(java.lang.Object, java.lang.Object) : java.lang.Object [ 14 ] 【博主自加:调用接口方法】 12 pop 13 return Line numbers: [pc: 0 , line: 8 ] [pc: 4 , line: 10 ] [pc: 13 , line: 1 ] Local variable table: [pc: 0 , pc: 14 ] local: this index: 0 type: new interview.JavapTest2(){} Inner classes: [inner class info: # 1 interview/JavapTest2$ 1 , outer class info: # 0 inner name: # 0 , accessflags: 0 default ] Enclosing Method: # 27 # 0 interview/JavapTest2 } |
可以看到生成了两个class文件,很显然这里是内部类的实现,而且是匿名内部类,不然JavapTest2$1.class的1就是其它的类名了。
这里博主开始造“坑”了,稍微修改一下代码,如下(注意内部类中的m.put和put的区别):
1
2
3
4
5
6
7
8
9
10
11
12
13
|
package interview; import java.util.HashMap; import java.util.Map; public class JavapTest2 { public static Map<String,String> m = new HashMap<String, String>(){ { m.put( "key1" , "value1" ); } }; } |
这样,发现编译器也没有报错,但是这样可不可以呢?在类中加入一个main方法:public static void main(String args[]){}运行一下,报如下错误(ExceptionInInitializerError):
1
2
3
4
|
Exception in thread "main" java.lang.ExceptionInInitializerError Caused by: java.lang.NullPointerException at interview.JavapTest2$ 1 .<init>(JavapTest2.java: 10 ) at interview.JavapTest2.<clinit>(JavapTest2.java: 8 ) |
Why? 是不是一脸懵逼?反编译一下,你就知道。JavapTest2.class和之前的没有变化,有变化的是JavapTest2$1.class,贴出反编译结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
// Compiled from JavapTest2.java (version 1.7 : 51.0, super bit) // Signature: Ljava/util/HashMap<Ljava/lang/String;Ljava/lang/String;>; class interview.JavapTest2$ 1 extends java.util.HashMap { // Method descriptor #6 ()V // Stack: 3, Locals: 1 JavapTest2$ 1 (); 0 aload_0 [ this ] 1 invokespecial java.util.HashMap() [ 8 ] 4 getstatic interview.JavapTest2.m : java.util.Map [ 10 ] 7 ldc <String "key1" > [ 16 ] 9 ldc <String "value1" > [ 18 ] 11 invokeinterface java.util.Map.put(java.lang.Object, java.lang.Object) : java.lang.Object [ 20 ] [nargs: 3 ] 16 pop 17 return Line numbers: [pc: 0 , line: 8 ] [pc: 4 , line: 10 ] [pc: 17 , line: 1 ] Local variable table: [pc: 0 , pc: 18 ] local: this index: 0 type: new interview.JavapTest2(){} Inner classes: [inner class info: # 1 interview/JavapTest2$ 1 , outer class info: # 0 inner name: # 0 , accessflags: 0 default ] Enclosing Method: # 11 # 0 interview/JavapTest2 } |
上面的第4和11(不是行号,是pc号)与修改之前的第4和9一一对应。
这里详细解释一下这个运行流程:
首先JavapTest2的程序入口是main方法,这个方法什么事都没干,但是这里已经触发了对JavaTest2的类的实例化(就是上面异常中的<clinit>),那么运行的是这段:
1
2
3
4
5
6
|
static {}; 0 new interview.JavapTest2$ 1 [ 12 ] 3 dup 4 invokespecial interview.JavapTest2$ 1 () [ 14 ] 7 putstatic interview.JavapTest2.m : java.util.Map [ 17 ] 10 return |
这段指令是首先是new JavaTest2$1这个匿名内部类,然后dup(将当前栈顶元素复制一份,并压入栈中),然后调用匿名内部类的构造函数,直到这里根本没有interview.JavapTest2.m的什么事,所以执行到这一步还没有m什么鸟事。
interview.JavapTest2.m此时为null. 因为m为static类型,在类加载之后的准备阶段会为类变量分配内存并设置类变量初始值,这些变量所使用的内存都将在方法区中进行分配。这时候进行内存分配的仅包括类变量(static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:
public static int value = 123;
那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。
这里的m是引用类型,引用类型的零值是null.
接下去执行匿名内部的实例化(就是上面异常的<init>),如下:
1
2
3
4
5
6
7
8
9
|
JavapTest2$ 1 (); 0 aload_0 [ this ] 1 invokespecial java.util.HashMap() [ 8 ] 4 getstatic interview.JavapTest2.m : java.util.Map [ 10 ] 7 ldc <String "key1" > [ 16 ] 9 ldc <String "value1" > [ 18 ] 11 invokeinterface java.util.Map.put(java.lang.Object, java.lang.Object) : java.lang.Object [ 20 ] [nargs: 3 ] 16 pop 17 return |
注意到第4条getstatic interview.JavapTest2.m : java.util.Map [10]这里的getstatic是指获取指定类的静态域,但是这个m此时还是null,所以是java.lang.NullPointerException,所以这段代码会报错。
附:ExceptionInInitializerError在JVM规范中这样定义:
1. 如果JVM试图创建类ExceptionInInitializerError的新实例,但是因为出现OOM而无法创建新实例,那么就抛出OOM作为代替;
2. 如果初始化器抛出一些Exception,而且Exception类不是Error或者它的某个子类,那么就会创建ExceptionInInitializerError类的一个新实例,并用Exception作为参数,用这个实例代替Exception.
附:javap(反汇编命令)详解
javap是JDK自带的反汇编器,可以查看java编译器为我们生成的字节码。通过它,我们可以对照源代码和字节码,从而了解很多编译器内部的工作。
语法:
javap [ 命令选项 ] class…
javap 命令用于解析类文件。其输出取决于所用的选项。若没有使用选项,javap 将输出传递给它的类的 public 域及方法。javap 将其输出到标准输出设备上。
命令选项
-help 输出 javap 的帮助信息。
-l 输出行及局部变量表。
-b 确保与 JDK 1.1 javap 的向后兼容性。
-public 只显示 public 类及成员。
-protected 只显示 protected 和 public 类及成员。
-package 只显示包、protected 和 public 类及成员。这是缺省设置。
-private 显示所有类和成员。
-J[flag] 直接将 flag 传给运行时系统。
-s 输出内部类型签名。
-c 输出类中各方法的未解析的代码,即构成 Java 字节码的指令。
-verbose 输出堆栈大小、各方法的 locals 及 args 数,以及class文件的编译版本
-classpath[路径] 指定 javap 用来查找类的路径。如果设置了该选项,则它将覆盖缺省值或 CLASSPATH 环境变量。目录用冒号分隔。
-bootclasspath[路径] 指定加载自举类所用的路径。缺省情况下,自举类是实现核心 Java 平台的类,位于 jrelibt.jar 和 jrelibi18n.jar 中。
-extdirs[dirs] 覆盖搜索安装方式扩展的位置。扩展的缺省位置是 jrelibext。