前言
日常业务开发中,我遇到了一个很有意思的现象,先看这段极简代码:
ini
Integer i1 = 128;
Integer i2 = 128;
System.out.println(i1 == i2);
运行输出结果为:false。
初次看到这个结果时其实挺疑惑的,明明都是赋值 128,为什么用 == 判断却不相等?翻阅资料后才发现,这背后本质是 Integer 底层机制在起作用。
顺着这个问题往下深挖,就绕不开 Class 文件、字节码指令与包装类缓存的核心原理。下面我们就从根源入手,一步步拆解底层逻辑。
一、Class文件结构
JVM 只遵守一条规则:只认符合标准的 Class 文件 。只要你能生成符合该规范的 Class 文件,无论其来源如何,都能在任意兼容的 JVM 上运行。JVM 本身并不关心 Class 文件是由 Java、Kotlin 还是其他语言编译而来 ------ 这正是 JVM 能够跨语言支持的底层基础。
1、 🌰🌰举个例子
ini
package com.nl;
public class ClassDemo {
public static void main(String[] args) {
Integer i3 = 128;
Integer i4 = 128;
System.out.println(i3 == i4);
}
}
2、Class 文件
2.1、Class 文件本质上是一个纯二进制文件。
它无法直接用普通文本编辑器阅读,但可以用十六进制工具(如 Sublime Text 等)打开查看

2.2、jclasslib 插件
不过,盯着这一串十六进制数字看,理解起来还是太抽象了。我们可以借助 IDEA 里的 jclasslib 插件,它能把 Class 文件的二进制内容,转换成清晰的结构化视图,效果大概是这样的:

2.3、LineNumberTable字节码对应代码表

⚠️ LineNumberTable 就是 字节码指令与 Java 源代码行号的映射表 ,它的作用是:
✔️让 JVM 在调试、异常堆栈时,能精准把字节码位置 对应到源代码的第几行,方便定位问题。
3、字节码解析
ruby
0 sipush 128 // 将常量128压入操作数栈
3 invokestatic #2 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;> // 调用Integer.valueOf(128),返回Integer对象
6 astore_1 // 将返回的Integer对象存储到局部变量表索引1的位置(即变量a)
7 sipush 128 // 将常量128压入操作数栈
10 invokestatic #2 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;> // 调用Integer.valueOf(128),返回Integer对象
13 astore_2 // 将返回的Integer对象存储到局部变量表索引2的位置(即变量b)
14 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;> // 获取System.out静态字段(PrintStream对象)
17 aload_1 // 将局部变量a(Integer对象引用)压入栈
18 aload_2 // 将局部变量b(Integer对象引用)压入栈
19 if_acmpne 26 // 如果两个引用不相等,跳转到第26行
22 iconst_1 // 将常量1压入栈(表示true)
23 goto 27 // 跳转到第27行
26 iconst_0 // 将常量0压入栈(表示false)【跳转目标】
27 invokevirtual #4 <java/io/PrintStream.println : (Z)V> // 调用println(boolean)方法输出结果
30 return // 方法返回
⚠️注意
✔️字节码第 0~7 行,对应源码中的
Integer i3 = 128。✔️ 从字节码中可以清晰看出:代码底层会自动调用
Integer.valueOf(128)完成装箱赋值,最终返回 Integer 包装类对象。⚠️是javap 反汇编后的字节码指令,工具把二进制解析后翻译成人读:
✔️把 0x11 这种机器码 → 翻译成 sipush
✔️把 0x36 → 翻译成 astore_1
✔️把索引 #2 → 翻译成 Integer.valueOf
二、面试题
1、明明两个数的值一模一样,为什么 == 比较后返回的是 false?
在上文的 《一、Class文件结构》 的例子, 查看字节码,发现这里的赋值本质上调用了 Integer.valueOf() 方法,而它背后的对象缓存机制,才是导致结果差异的关键:
arduino
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
- 当你写
Integer i = 128;时,编译器会自动装箱成Integer.valueOf(128)。 Integer.valueOf()会对-128 ~ 127之间的整数做缓存,超出这个范围会新建对象。- 所以
127时两个对象是同一个,==为true;128时是两个不同对象,==为false。
2、静态方法为什么不能被重写 Override ?
2.1、举个例子
ClassTwoDemo
csharp
package com.nl;
public class ClassTwoDemo {
public void execute() {
test();
ClassTwoDemo.testTwo();
}
public void test() {
System.out.println("ClassTwoDemo.test()");
}
public static void testTwo() {
System.out.println("ClassTwoDemo.testTwo()");
}
}
ClassThreeDemo
scala
package com.nl;
public class ClassThreeDemo extends ClassTwoDemo {
@Override
public void test() {
System.out.println("ClassTwoDemo.testTwo()");
}
/**
* 这个是报错的,静态方法不能被重写
*/
@Override
public void testTwo() {
System.out.println("ClassTwoDemo.testTwo()");
}
}
⚠️注意
✔️java: com.nl.ClassThreeDemo中的testTwo()无法覆盖com.nl.ClassTwoDemo中的testTwo()被覆盖的方法为static
2.2、为什么会报错呢?
execute方法的字节码
bash
0 aload_0
1 invokevirtual #2 <com/nl/ClassTwoDemo.test : ()V>
4 invokestatic #3 <com/nl/ClassTwoDemo.testTwo : ()V>
7 return
⚠️注意
✔️从 JVM 字节码指令来看,
invokevirtual与invokestatic作用不同,静态方法和普通重载方法的调用指令并不一样
查阅相关资料可知,JVM 提供了多条方法调用字节码指令,各自分工不同:
invokevirtual:调用对象的虚方法,也是日常中支持重写特性的实例方法;invokespecial:依据编译时类型绑定调用实例方法,常用于构造方法<init>、私有方法、父类方法调用,并不负责静态代码块;invokestatic:专门调用类静态方法,不支持重写特性的实例方法invokeinterface:用于调用接口中的抽象方法。
2.3、结论
静态方法底层固定使用 invokestatic 指令,仅做静态绑定、不具备动态绑定能力,故而语法上不允许被重写。
3、讲讲字节码try-cache-finally的执行流程?
3.1、举个例子
csharp
package com.nl;
public class ClassFlourDemo {
public int execute() {
try {
return 1;
} catch (Exception e) {
return 2;
} finally {
end();
}
}
private void end(){
}
}
3.2、字节码
arduino
0 iconst_1 // 将常量1压入操作数栈
1 istore_1 // 存储到局部变量表索引1(暂存返回值)
2 aload_0 // 加载this引用(当前对象)
3 invokespecial #2 <com/nl/ClassFlourDemo.end : ()V> // 调用 this.end() ← finally块执行
6 iload_1 // 加载暂存的返回值1
7 ireturn // 返回1
8 astore_1 // 将异常引用存储到局部变量表索引1(即变量e)
9 iconst_2 // 将常量2压入操作数栈
10 istore_2 // 存储到局部变量表索引2(暂存返回值)
11 aload_0 // 加载this引用
12 invokespecial #2 <com/nl/ClassFlourDemo.end : ()V> // 调用 this.end() ← finally块执行
15 iload_2 // 加载暂存的返回值2
16 ireturn // 返回2
17 astore_3 // 将异常引用存储到局部变量表索引3
18 aload_0 // 加载this引用
19 invokespecial #2 <com/nl/ClassFlourDemo.end : ()V> // 调用 this.end() ← finally块执行
22 aload_3 // 加载异常引用
23 athrow // 重新抛出异常
3.3、finally块的"复制"机制
arduino
invokespecial #2 <com/nl/ClassFlourDemo.end : ()V>
⚠️注意
✔️这条指令出现了 3次(第3行、第12行、第19行),说明编译器将 finally 块复制到了每个可能的退出路径上。
4、什么是返回值的暂存机制?
csharp
// 不是直接返回,而是:
// 1. istore_2 暂存返回值(1)
// 2. 执行finally (x被改成999)
// 3. iload_2 加载暂存的返回值(1)
// 4. ireturn 真正返回(1)
public int test() {
int x = 1;
try {
return x;
} finally {
x = 999; // 不会影响返回值
}
}
⚠️为什么需要暂存?
✔️因为 finally 可能在return之前修改数据
✔️Java保证:finally中的代码不会改变已经确定的返回值(对于基本类型)
三、总结
本文通过字节码,通俗易懂拆解了三个经典Java问题。
- 借助jclasslib插件能直观看到,Integer自动装箱会调用valueOf方法,缓存机制导致判等结果出人意料;
- 静态方法采用invokestatic静态绑定,没有动态绑定能力,所以不能重写;
- 而try-finally语句,编译器会把finally代码复制到每一处退出路径,同时暂存返回值。
很多看似反常的代码现象,其实都能在字节码里找到答案。看懂字节码,不仅能轻松拿捏面试高频题,还能搞懂JVM底层运行逻辑,不用死记硬背,真正吃透Java底层知识。