Zhu.Yang

朱阳的个人博客 (公众号:think123)

0%

从 Java 字节码分析中我学到了什么

实参和形参

1
2
3
4
5
public int sum(int x,int y) {
return x+y;
}

sum(2,3);

上面的代码中 sum () 方法中的 x,y 就是形参,而调用方法 sum (2,3) 中的 2 与 3 就是实参。形参是在方法定义阶段,而实参实在方法调用阶段。

查看字节码

基本类型参数调用

1
2
3
4
5
6
7
8
9
10
11
private static int intStatic = 222;

public static void main(String[] args) {
method(intStatic);
System.out.println(intStatic);
}

public static void method(int intStatic) {
intStatic = 777;
}

上面的 method () 方法的字节码 (javap -verbose XXX.class) 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void method(int);
descriptor: (I)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: sipush 777
3: istore_0
4: return
LineNumberTable:
line 13: 0
line 14: 4
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 intStatic I

sipush: 将一个短整形常量值推送至栈顶

iconst 将 int 形式 - 1-5 推送到栈顶
bipush 表示将单字节的常量值 (-128-127) 推送至栈顶
sipush 表示一个短整形常量值 (-32768-32767) 推送至栈顶

istore_0: 将栈顶 int 型数值存入第一个本地变量。

上面字节码的意思就是将 777 推送到栈顶,然后将其赋值给 intStatic 这个本地变量。所以我们输出的结果是 222, 因为 method () 中的赋值是对本地变量进行赋值的,并没有改变 static 变量的值,这也是 Java 的变量就近原则,当然可以使用 Class.intStatic 这样的方式显示声明。

不可变对象参数调用

1
2
3
4
5
6
7
8
9
10
private static String stringStatic = "old string";

public static void main(String[] args) {
method(stringStatic);
System.out.println(stringStatic);
}
public static void method(String stringStatic) {
stringStatic = "new string";
}

上面输出的结果是 old string, 同样反编译看下字节码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Constant pool:
#6 = String #35 // new string
#7 = String #36 // old string
#10 = Utf8 stringStatic
#35 = Utf8 new string
#36 = Utf8 old string

public static void method(java.lang.String);
descriptor: (Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=1, args_size=1
0: ldc #6 // String new string
2: astore_0
3: return
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 stringStatic Ljava/lang/String;

ldc: 将 int、float 或 String 型常量值从常量池中推送至栈顶。
astore_0: 将栈顶引用型数值存入第一个本地变量。

字节码的意思是将 #6 (符号引用) 的值推送至栈顶,然后将其引用型数值赋值给第一个本地变量 (stringStatic)。

可以看到这里的 #6 表示是一个 String 类型数据,它指向常量池中一个 CONSTANT_Utf8_info (缩写 Utf8,Class 文件中方法字段等都需要引用它来描述名称) 类型,这个常量代表了类 (或者接口) 的全限定名称,在运行的时候,JVM 会根据这个全限定名称来实例化这个类,那个时候符号引用会被转换为直接引用 (就是内存中的地址)。

运行时常量池

上面一直在说常量池,那么常量池到底是个什么东东?

我们都知道方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码。
Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项是常量池 (Constant Pool Table),用于存放编译期产生的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有在编译器才能产生,也就是并非预置入 Class 文件常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,String.intern () 方法就是典型的代表。

永久代和方法区,很多人会迷惑。方法区是 JVM 规范,而永久代只是实现。
从 JDK7 开始常量池已经从方法区移动到堆中

文本字符串、声明为 final 的常量值都为字面量,而符号引用包含了下面三类常量
1. 类和接口的全限定名
2. 字段的名称和描述符
3. 方法的名称和描述符

1
2
3
4
5
Constant pool:
#1 = Methodref #9.#28 // java/lang/Object."<init>":()V
#2 = Fieldref #8.#29 // com/generalthink/kafka/ParamDemo.stringStatic:Ljava/lang/String;
#3 = Methodref #8.#30 // com/generalthink/kafka/ParamDemo.method:(Ljava/lang/String;)V
#8 = Class #37 // com/generalthink/kafka/ParamDemo

所以,编译器符号引用 stringStatic字面量 old string 会被加入到 Class 文件的常量池中,然后在类加载阶段,这两个常量会进入运行时常量池。

可变对象参数调用

上面的参数传递的是不可变对象,这里变成可变对象我们再次分析下

1
2
3
4
5
6
7
8
9
10
11
12
13
private static StringBuilder stringBuilderStatic = new StringBuilder("old stringBuilder");

public static void main(String[] args) {
method(stringBuilderStatic);
System.out.println(stringBuilderStatic);
}

public static void method(StringBuilder stringBuilderStaticParam) {
stringBuilderStaticParam.append(" first append");

stringBuilderStaticParam = new StringBuilder("new stringBuilder");
stringBuilderStaticParam.append(" new method's append");
}

查看对应的关键字节码如下:

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
35
36
Constant pool:
#6 = String #41 // first append
#8 = Class #43 // java/lang/StringBuilder
#9 = String #44 // new stringBuilder
#11 = String #46 // new method's append
#12 = String #47 // old stringBuilder
#15 = Utf8 stringBuilderStatic
#30 = Utf8 stringBuilderStaticParam
#41 = Utf8 first append
#43 = Utf8 java/lang/StringBuilder
#44 = Utf8 new stringBuilder
#46 = Utf8 new method's append
#47 = Utf8 old stringBuilder

public static void method(java.lang.StringBuilder);
descriptor: (Ljava/lang/StringBuilder;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: ldc #6 // String first append
3: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
6: pop
7: new #8 // class java/lang/StringBuilder
10: dup
11: ldc #9 // String new stringBuilder
13: invokespecial #10 // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V
16: astore_0
17: aload_0
18: ldc #11 // String new method's append
20: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
23: pop
24: return
LocalVariableTable:
Start Length Slot Name Signature
0 25 0 stringBuilderStaticParam Ljava/lang/StringBuilder;

aload_0: 将第一个引用类型本地变量推送至栈顶。
ldc : 将 int、float 或 String 型常量值从常量池中推送至栈顶。
invokevirtual:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法
pop: 将栈顶数值弹出 (不能是 long 或 double 类型)
new: 创建一个对象,并将其引用值压入栈顶
dup:复制栈顶数值并将复制值压入栈顶
astore_0 : 将栈顶引用型数值存入第一个本地变量
return : 从当前方法返回 void

需要注意的是 aload_0 中的 0 指的是 LocalVariableTable 中 slot 为 0 的参数,这里指的是 stringBuilderStaticParam。它是把静态变量的引用赋值给虚拟机栈帧中的局部变量表。

上面字节码的意思就是 stringBuilderStaticParam 推送至栈顶,然后将 first append 常量值推送到栈顶,调用 StringBuilder.append 方法得到结果,最后出栈。
接着 new 一个 StringBuilder, 将返回的地址复制一份压入栈顶,然后在将这个地址存入 stringBuilderStaticParam。然后重新 aload 到操作栈顶 (这里的值已经被进行了覆盖,所以后续对于 stringBuilderStaticParam 的 append 操作与类的静态变量 stringBuilderStatic 没有任何关系), 然后接着调用 append 方法,最后返回 void。

需要注意的是 stringBuilderStatic 仅仅只是一个指针,一个指向内存中具体地址的指针而已,它并不是这个内存地址。java spec 中声明说,java 中的所有东西都是值传递 , 从没有引用传递这个玩意儿
代码是检验整理的唯一标准,现在假设是引用传递,那么执行 method 方法之后,输出的结果就应该是 new stringBuilder new method's append, 但是输出结果并不是,所以参数传递是值传递,这个值对对象来说是指针而已。

this 是如何实现的

1
2
3
4
5
6
7
8
int m = 1;
public static void main(String[] args) {
ParamDemo demo = new ParamDemo();
System.out.println(demo.method());
}
public int method() {
return m + 1;
}

我们在 method () 方法中调用了 m, 这里隐式的使用了 this, 其实是 this.m。那么 this 式如何实现的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public int method();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field m:I
4: iconst_1
5: iadd
6: ireturn
LineNumberTable:
line 15: 0
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this Lcom/generalthink/kafka/ParamDemo;
}

我们在 LocalVariableTable 中可以发现 this 成为了一个参数,它的实现就是这样简单,Javac 编译器编译的时候把 this 关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法时自动传入此参数。

一说到 this 肯定就会想起 super, 其实 super 就是一个普通的方法调用,通过 invokespecial 指令实现。

String.intern () 原理

当使用 intern () 方法的时候你要想到方法作用是将字面量动态的加入运行时常量池。如果运行时常量池中已经存在了相同的字符串 (equals 方法决定), 则返回池中的对象,否则将其加入到常量池后返回对应的引用。

1
2
3
4
5
String s1 = "Hello";
String s2 = new String("Hello").intern();

//true
System.out.println(s1 == s2);

输出结果为 true, 编译期间”Hello” 这个字面量已经加入到了常量池,运行期间,调用了 intern () 方法,先根据 equals 方法判断两个字符串相等,然后返回常量池当中 Hello 的引用地址,所以此时 s1,s2 其实指向的是同一个地址。

我们将代码做一点修改

1
2
3
4
5
6
7
8
9
10
11
String s1 = "Hello";
String s2 = "World";

String s3 = s1 + s2;

String s4 = "Hello" + "World";

System.out.println(s3 == s4);

System.out.println(s3.intern() == s4);

输出结果分别为 false 和 true。我们注意到 s3 和 s4 最多的不同就是,s3 是由变量相加得到的,同样查看字节码

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
#2 = String     #37     // Hello
#3 = String #38 // World
#4 = Class #39 // java/lang/StringBuilder
#5 = Methodref #4.#36 // java/lang/StringBuilder."<init>":()V
#6 = Methodref #4.#40 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#7 = Methodref #4.#41 // java/lang/StringBuilder.toString:()Ljava/lang/String;
#8 = String #42 // HelloWorld

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=5, args_size=1
0: ldc #2 // String Hello
2: astore_1
3: ldc #3 // String World
5: astore_2
6: new #4 // class java/lang/StringBuilder
9: dup
10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
13: aload_1
14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
17: aload_2
18: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
24: astore_3
25: ldc #8 // String HelloWorld
27: astore 4
...

从字节码可以看出来 s3 实际上是调用了 StringBuilder.append () 方法来得到的,而 s4 的值在编译的时候就可以直接确定,它是一个准确的值,所以此时 HelloWorld 在常量池中就存在了。

欢迎关注我的其它发布渠道