本教程基于 Oracle 官方《The Java Virtual Machine Specification》(Java SE 26)第 3 章《Compiling for the Java Virtual Machine》整理编写。这一章本身不是规范性内容,而是由 Oracle 的
javac示例展示「Java 源码是怎么变成 JVM 指令的」。学完这篇,你再看到javap -c反编译出来的字节码,就不会一头雾水了。
前置知识
- 机器上装了 JDK(需要
javac和javap) - 知道「栈(后进先出)」是什么
一、先学会看「虚拟机汇编」
示例都是「Java 源码 + 编译后的字节码清单」成对出现。字节码用javap输出的「虚拟机汇编语言」表示,每行格式是:
<偏移> <操作码> [ <操作数1> [ <操作数2>... ]] [// 注释]例如:
8 bipush 100 // Push int constant 1008:这条指令在方法字节码数组里的偏移量bipush:**操作码(opcode)**助记符100:操作数- 后面是注释
运行时常量池的引用用#前缀:
10 ldc #1 // 从常量池 #1 推入 float 常量 9 invokevirtual #4 // 调用 Example.addTwo(II)I后面所有示例都遵循这个格式。先看偏移、再看 opcode、最后看注释,很快就能形成直觉。
二、常量、局部变量与控制结构:JVM 是「栈机器」
最重要的心智模型:JVM 是基于栈的。大多数操作从「当前帧的操作数栈」弹出操作数、算完再把结果压回去。方法被调用时会新建一个帧,自带一个操作数栈和一组局部变量。
2.1 一个for循环在字节码里长什么样
voidspin(){inti;for(i=0;i<100;i++){;// 循环体为空}}编译为:
0 iconst_0 // 把 int 常量 0 压栈 1 istore_1 // 存到局部变量 1(i = 0) 2 goto 8 // 第一次先跳过自增 5 iinc 1 1 // 局部变量 1 自增 1(i++) 8 iload_1 // 把局部变量 1 压栈(i) 9 bipush 100 // 把 int 常量 100 压栈 11 if_icmplt 5 // 若 i < 100,跳回 5 继续 14 return // 否则返回观察几个细节:
- 常量
0用iconst_0(带「隐式操作数」的专用指令);100用bipush(单字节立即数) i是int,存在局部变量槽 1(istore_1/iload_1)- 循环条件
i < 100被编译成「压栈 i → 压栈 100 →if_icmplt比较跳转」
2.2double循环:没有dinc
voiddspin(){doublei;for(i=0.0;i<100.0;i++){;}}关键片段:
5 dload_1 6 dconst_1 7 dadd // 用 dadd 做自增,因为【没有 dinc 指令】 8 dstore_1 ... 13 dcmpg // 浮点比较用 dcmpg 14 iflt 5 // 配合 iflt,没有 if_dcmpltdouble占两个局部变量槽- 自增不能像
int那样iinc,只能用dload+dconst_1+dadd+dstore - 浮点比较没有「带条件的比较跳转」,而是
dcmpg先比较、再配合iflt这类通用跳转
2.3short循环:i2s截断
voidsspin(){shorti;for(i=0;i<100;i++){;}}5 iload_1 6 iconst_1 7 iadd 8 i2s // 把 int 截断回 short 9 istore_1 ...规范早就说过:
byte/char/short在 JVM 里都是当int算的(之前第 2 章讲过)。所以自增在int上做完,再用i2s截断回short存回去。
三、算术:位运算的「另类写法」
intalign2grain(inti,intgrain){return((i+grain-1)&~(grain-1));}0 iload_1 // i 1 iload_2 // grain 2 iadd // i + grain 3 iconst_1 4 isub // ... - 1 5 iload_2 // grain 6 iconst_1 7 isub // grain - 1 8 iconst_m1 // -1 9 ixor // ~(grain-1) 用 (-1) XOR (grain-1) 实现 10 iand // 与上一步结果 11 ireturn注意~x(按位取反)在字节码里是用-1 XOR x(iconst_m1+ixor)实现的——这是编译器常用技巧。
四、访问运行时常量池:小常量走「快车道」
把不同大小的常量推入栈,JVM 给了不同的指令:
| 常量类型 | 指令 |
|---|---|
小int(-1、0、1、2、3、4、5) | iconst_<i>(如iconst_0) |
单字节int | bipush |
两字节int | sipush |
大int/float/String | ldc/ldc_w(从常量池取) |
long/double | lconst_<l>/dconst_<d>、ldc2_w |
示例:
voiduseManyNumeric(){inti=100;intj=1000000;longl1=1;longl2=0xffffffff;// 即 int 的 -1doubled=2.2;}0 bipush 100 // 小 int 用 bipush 2 istore_1 3 ldc #1 // 大 int 用 ldc(从常量池 #1 取 1000000) 5 istore_2 6 lconst_1 // 很小的 long 用 lconst_1 7 lstore_3 8 ldc2_w #6 // long 0xffffffff 用 ldc2_w 11 lstore 5 13 ldc2_w #8 // double 2.2 用 ldc2_w 16 dstore 7经验:编译器会「能省则省」——够小的常量用专用短指令,大了才往常量池塞。
五、更多控制流:while循环
voidwhileInt(){inti=0;while(i<100){i++;}}0 iconst_0 1 istore_1 2 goto 8 // 先跳到条件判断,避免首轮多自增一次 5 iinc 1 1 // 循环体:i++ 8 iload_1 9 bipush 100 11 if_icmplt 5 // 条件成立跳回 5 14 returnwhile和for编译出来几乎一样——测试在底部,goto先跳过首轮自增。
六、接收参数:this占的槽位
很重要的一条规则:
- 实例方法:参数从局部变量槽1开始,槽0永远是
this - 静态方法:没有
this,参数从槽0开始
intaddTwo(inti,intj){returni+j;}// 实例方法staticintaddTwoStatic(inti,intj){returni+j;}// 静态方法Method int addTwo(int,int) 0 iload_1 // 实例方法:第 1 个参数在槽 1 1 iload_2 // 第 2 个参数在槽 2 2 iadd 3 ireturn Method int addTwoStatic(int,int) 0 iload_0 // 静态方法:第 1 个参数在槽 0 1 iload_1 2 iadd 3 ireturn七、方法调用:四条invoke*指令
| 指令 | 用途 | 特点 |
|---|---|---|
invokevirtual | 实例虚方法 | 操作数指向常量池里的符号引用 |
invokestatic | 静态方法 | 不传this |
invokespecial | 父类方法、构造器<init> | 跳过虚分派 |
invokeinterface | 接口方法 | 本例未展开 |
调用实例方法(注意要先aload_0把this压栈):
intadd12and13(){returnaddTwo(12,13);}0 aload_0 // 压入 this 1 bipush 12 3 bipush 13 5 invokevirtual #4 // Method Example.addTwo(II)I 8 ireturn调用父类方法用invokespecial:
classFarextendsNear{intgetItFar(){returnsuper.getItNear();}}0 aload_0 1 invokespecial #4 // Method Near.getItNear()I(不虚分派) 4 ireturn八、对象与字段:new必须配invokespecial <init>
创建一个对象的标准三步:
Objectcreate(){returnnewObject();}0 new #1 // 1) 在堆上创建实例,引用压栈 3 dup // 2) 复制栈顶引用(一份给 <init>,一份留给返回) 4 invokespecial #4 // 3) 调用 <init> 初始化(注意不是 invokevirtual!) 7 areturn // 返回栈上那个引用
dup是关键:<init>会「消费」一份引用,但方法最终要areturn把新对象返回,所以需要两份。
字段读写用getfield/putfield(都通过常量池符号引用定位):
voidsetIt(intvalue){i=value;}intgetIt(){returni;}Method void setIt(int) 0 aload_0 // this 1 iload_1 // value 2 putfield #4 // Field Example.i I 5 return Method int getIt() 0 aload_0 1 getfield #4 4 ireturn九、数组:newarray/anewarray/multianewarray
voidcreateBuffer(){intbuffer[];intbufsz=100;intvalue=12;buffer=newint[bufsz];buffer[10]=value;value=buffer[11];}6 iload_2 // bufsz 7 newarray int // 创建 int[](基本类型数组用 newarray) 9 astore_1 10 aload_1 11 bipush 10 13 iload_3 14 iastore // buffer[10] = value 15 aload_1 16 bipush 11 18 iaload // value = buffer[11] 19 istore_3多维数组用multianewarray:
int[][][]create3DArray(){intgrid[][][];grid=newint[10][5][];returngrid;}0 bipush 10 2 iconst_5 3 multianewarray #1 dim #2 // 前两维是 10 和 5,第三维留 null 7 astore_1 8 aload_1 9 areturn十、switch:密集用tableswitch,稀疏用lookupswitch
switch只支持int(其它类型编译期会被提升为int)。
密集连续的 case →tableswitch:
intchooseNear(inti){switch(i){case0:return0;case1:return1;case2:return2;default:return-1;}}0 iload_1 1 tableswitch 0 to 2: 0: 28 1: 30 2: 32 default:34 28 iconst_0 29 ireturn 30 iconst_1 ...稀疏分散的 case →lookupswitch(表必须按 key 排序):
intchooseFar(inti){switch(i){case-100:return-1;case0:return0;case100:return1;default:return-1;}}0 iload_1 1 lookupswitch 3: -100: 36 0: 38 100: 40 default: 42直觉:
tableswitch像「用 case 值当下标直接跳」,lookupswitch像「查排序表做二分」。编译器按 case 是否密集自动选。
十一、操作数栈操作:dup2_x1等
栈操作指令把栈上的值当「无类型」处理,但不会拆开一个long/double(它们占两个单元)。
publiclongnextIndex(){returnindex++;}privatelongindex=0;0 aload_0 1 dup 2 getfield #4 // 取出当前 index 5 dup2_x1 // 复制并插入栈下方,为「返回旧值」和「自增后写回」做准备 6 lconst_1 7 ladd 8 putfield #4 // index = index + 1 写回 11 lreturn // 返回旧值十二、异常:throw与try-catch
throw new X()编译成「new+dup+invokespecial <init>+athrow」:
voidcantBeZero(inti)throwsTestExc{if(i==0){thrownewTestExc();}}0 iload_1 1 ifne 12 // 若 i != 0 跳过 4 new #1 // new TestExc 7 dup 8 invokespecial #7 // <init> 11 athrow // 抛出 12 returntry-catch不靠特殊跳转,而是靠异常表(Exception table):
voidcatchOne(){try{tryItOut();}catch(TestExce){handleExc(e);}}0 aload_0 1 invokevirtual #6 // tryItOut() 4 return 5 astore_1 // 若抛出异常,跳到这里:e = 异常对象 6 aload_0 7 aload_1 8 invokevirtual #5 // handleExc(e) 11 return Exception table: From To Target Type 0 4 5 Class TestExc异常表含义:在偏移0~4(含头不含尾)之间若抛出TestExc,就跳到偏移5开始处理。多 catch、嵌套 catch 都是用多条异常表项表达的。
十三、finally:jsr/ret子例程(旧版本)
注:这是 class 文件版本 ≤ 50.0(即 Java 7 及以前)的做法。现代 Java 用
StackMapTable+ 复制 finally 代码块的方式,不再用jsr/ret(还记得第 2 章说returnAddress是给jsr/ret用的吗?这就是它的归宿)。
voidtryFinally(){try{tryItOut();}finally{wrapItUp();}}0 aload_0 1 invokevirtual #6 // tryItOut() 4 jsr 14 // 正常路径:跳去执行 finally 7 return 8 astore_1 // 异常路径:异常存起来 9 jsr 14 // 同样去执行 finally 12 aload_1 13 athrow // finally 跑完后把异常重新抛出 14 astore_2 // finally 子例程入口 15 aload_0 16 invokevirtual #5 // wrapItUp() 19 ret 2 // 用 returnAddress 返回调用点 Exception table: From To Target Type 0 4 8 any十四、同步:monitorenter/monitorexit
synchronized块的编译:进入时monitorenter,退出时monitorexit,并且编译器会保证每条退出路径都有配对的monitorexit(通过异常表兜底)。
voidonlyMe(Foof){synchronized(f){doSomething();}}0 aload_1 1 dup 2 astore_2 // 把 f 的引用存一份,供释放时用 3 monitorenter // 加锁 4 aload_0 5 invokevirtual #5 // doSomething() 8 aload_2 9 monitorexit // 正常释放 10 goto 18 13 astore_3 // 异常路径:把异常存起来 14 aload_2 15 monitorexit // 异常时也释放锁(关键!避免死锁) 16 aload_3 17 athrow // 再抛出 18 return Exception table: From To Target Type 4 10 13 any 13 16 13 any而synchronized方法更简单——不需要显式指令,靠方法表中的ACC_SYNCHRONIZED标志在调用时隐式加锁。
十五、注解与模块(简述)
- 注解:编译进 class 文件(见规范 §4.7.16+);包级注解会编译成一个叫
package-name.package-info的接口类文件。 - 模块:模块声明编译成含
Module属性的module-info.class(设ACC_MODULE标志)。该属性列出requires/exports/opens/uses/provides;除java.base外,模块必须显式声明依赖java.base(没写编译器会自动补一个ACC_MANDATED项)。
小结
- 栈架构:运算在操作数栈上完成,
iconst/bipush/ldc等把常量送进去 - 控制流:
for/while都编译成「底部测试 +goto跳过首轮」;switch按密集度选tableswitch/lookupswitch - 方法调用:
invokevirtual(虚)/invokestatic(静态)/invokespecial(父类/构造) - 对象:
new+dup+invokespecial <init>是固定套路;字段用getfield/putfield - 数组:
newarray/anewarray/multianewarray - 异常:
throw=new+dup+<init>+athrow;catch靠异常表 - 同步:
monitorenter/monitorexit+ 异常表兜底,方法级用ACC_SYNCHRONIZED