起因
这周例会上,在进行 code review 时,有同事对其中一个方法变量的定义位置提出疑问
// 为了方便,对代码进行了简化
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
int a = 1;
int b = 2;
System.out.println(a + b);
}
}
复制代码
他认为 for 循环中 a,b 提到方法外部定义会好一些,但是具体缘由也没说清楚,有些同事还扯上运行性能,垃圾回收。
但站在我的角度,这两者的区别,除了程序层面变量的作用域变了,在方法运行层面是没有明显差异的,这边也正好回顾一下 jvm 方法运行相关的机制。
虚拟机栈
jvm 运行时内存规划中有一块区域叫做虚拟机栈,被划分为线程私有的空间,线程中方法的运行则对应该区域栈帧的入栈,出栈,一个方法对应一个栈帧。
从上图中可以看到,栈帧单元分为四部分:局部变量表,操作数栈,动态连接,返回地址。
局部变量表
存放方法运行所需的局部变量:方法参数,内部定义的局部变量。
最小存放单元为 Slot(变量槽),空间大小为 4 个字节,因此像boolean,byte,char,short,int,float,reference 只需要占用一个变量槽,而 long,double 则需要占用两个变量槽。
因此局部变量表的大小在编译阶段就能确定,与程序运行与否没有太大关系。
举个简单的例子
public static void main(String[] args) {
int a = 1;
int b = 2;
int c = a + b;
}
复制代码
对应的局部变量表为:这边重点关注一下 Slot 的值,它是根据变量定义顺序分配的索引值(例如 args 是方法参数,认为是第一个定义的局部变量,因此 args 的 slot = 0)
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
2 7 1 a I
4 5 2 b I
8 1 3 c I
复制代码
关于局部变量表还有两个有趣的点可以探索,本文先不涉及这块内容。
- 栈帧之间存储空间复用(操作数栈与局部变量表,用于方法调用参数的传递)
- 局部变量表 Slot 的复用(可能会影响对象的垃圾回收)
操作数栈
用于存放操作指令执行所需要的操作数,延续上一个简单的例子
public static void main(String[] args) {
int a = 1;
int b = 2;
int c = a + b;
}
复制代码
对应的方法字节码指令集:
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
8: return
复制代码
简单解释一下几个指令
- iconst 将对应常量放入操作数栈,例 iconst_1 是将 1 放入操作数栈
- istore 将操作数栈顶元素的值写入局部变量表指定槽位,例如 istore_1 是将操作数栈顶元素的值写入局部变量表 slot = 1 的变量
- iload 将指定槽位的局部变量值放入操作数栈顶,例 iload_1 是将局部变量表 slot = 1 的变量值放入操作数栈顶
- iadd 弹出操作数栈顶的两个元素,进行 + 操作,将结果再压回操作数栈
上述例子代码的执行过程大致如下:
- 常量 1 入栈,将栈顶元素赋值给局部变量表的变量 a =》 iconst_1,istore_1
- 常量 2 入栈,将栈顶元素赋值给局部变量表的变量 b =》 iconst_2,istore_2
- 将局部变量表中变量 a ,b 的值入栈 =》 iload_1,iload_2
- 弹出栈顶的前两个元素,进行 + 的操作,将结果入栈 =》iadd
- 将栈顶元素,也就是计算结果赋值给局部变量表的变量 c =》 istore_3
可以看出,操作数栈会将运算结果即时刷新到局部变量表对应的变量。
这边对于动态连接,还有返回地址暂时不作讨论,不是本文的重点。
字节码分析
讲到这里,相信大家对于 jvm 方法的解释执行机制有了一定的认识,下面我们回到开头的例子,探索一下这个局部变量定义位置的问题。
使用 javap 命令看一下程序对应的字节码文件内容,首先关注局部变量表,该表用来存放方法定义的局部变量,按照定义的顺序,可以看到 a,b 被分配在 2,3 槽位(slot),操作数栈运算需要用到这个 slot 索引(istore_2,iload_2 指令后面的数字就是指这个 slot 索引)
LocalVariableTable:
Start Length Slot Name Signature
10 11 2 a I
12 9 3 b I
2 25 1 i I
0 28 0 args [Ljava/lang/String;
复制代码
如果我们把局部变量定义在 for 循环外面
// 为了方便,对代码进行了简化
public static void main(String[] args) {
int a;
int b;
for (int i = 0; i < 100; i++) {
a = 1;
b = 2;
System.out.println(a + b);
}
}
复制代码
对应字节码的局部变量表,可以看到 a,b 除了变量槽 slot 的索引变了(因为定义顺序发生变化),其它都相同,在方法执行层面,可以认为它们是一致的。
LocalVariableTable:
Start Length Slot Name Signature
10 17 1 a I
12 15 2 b I
2 25 3 i I
0 28 0 args [Ljava/lang/String;
复制代码
现在我们来看一下方法对应的字节码指令,可以看到内容几乎是一样的,除了 istore,iload 指令存在差异,是因为 a,b 在变量槽中的索引发生了变化,在方法执行层面,也可以认为它们是一致的。
// 局部变量定义在方法外部
Code:
stack=3, locals=4, args_size=1
0: iconst_0
1: istore_3
2: iload_3
3: bipush 100
5: if_icmpge 27
8: iconst_1
9: istore_1
10: iconst_2
11: istore_2
12: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
15: iload_1
16: iload_2
17: iadd
18: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
21: iinc 3, 1
24: goto 2
27: return
// 局部变量定义在方法内部
Code:
stack=3, locals=4, args_size=1
0: iconst_0
1: istore_1
2: iload_1
3: bipush 100
5: if_icmpge 27
8: iconst_1
9: istore_2
10: iconst_2
11: istore_3
12: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
15: iload_2
16: iload_3
17: iadd
18: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
21: iinc 1, 1
24: goto 2
27: return
复制代码
结论
从实验现象来看,局部变量定义在循环内部 vs 局部变量定义在循环外部
- 二者 字节码中方法的局部变量表是一致的
- 二者 字节码中方法对应的执行指令是一致的
从这两个纬度基本就可以确认无疑,二者在程序运行层面上是没有明显差异的。