一个方法是怎么从Java代码变成机器指令的?
众所周知,Java程序的执行要经历从编译期到运行期的一系列阶段,包括将源代码编译为字节码、类加载和链接、字节码解释执行、JIT即时编译优化,最终生成机器码由CPU执行。
接下来我们一起看看这些阶段都具体做了什么。
一、Java源代码编译成字节码
Java的方法(以及整个类)首先经过编译期的处理,由javac编译器将.java源文件编译成.class字节码文件。
这个过程中编译器会执行词法分析、语法分析、语义分析等步骤,将源代码转换成抽象语法树并进行检查和优化,最后生成和平台无关的字节码指令。
简而言之,Javac会将源代码的字符流分解成标记(词法分析),根据语言文法规则组装成语法树(语法分析),在语义分析阶段检查类型一致性、变量是否正确初始化等逻辑,并处理语法糖(如泛型、自动装箱等)的转换,最后根据语法树和符号表生成等价的字节码指令,写到.class文件。
字节码生成阶段还会隐式添加一些构造方法和类初始化代码等细节,使得生成的.class文件包含运行所需的完整信息。
词法分析、语法分析、语义分析可以去看下这些类:
语法糖处理阶段也做了很多事情,我们经常议论的Java的泛型擦除,其实就是在TransTypes完成的。
自动装箱/拆箱、增强for循环、内部类这些是在Lower中完成的。
最后的字节码生成:
通过上面的一系列操作,下面的这个方法会变成字节码:
javac编译器把源代码里的加法运算编译为四条字节码指令(iload_1, iload_2, iadd, ireturn),JVM执行这个方法的时候就是逐条执行这四条指令完成计算的。
二、字节码的结构和在JVM中的作用
2.1Class文件结构:
.class字节码文件本质上是一系列定格式的二进制数据,描述了一个Java类/接口的完整信息,包括常量池、类元数据、字段和方法描述以及方法字节码等部分。
根据JVM规范,一个Class文件包含如下主要结构:
魔数(标识文件类型,固定为0xCAFEBABE)、版本号、常量池(保存类中用到的常量和符号引用,如文本字符串、类名、方法名等)、类的访问标志、类名及父类名、实现的接口列表、字段表、方法表以及属性表等。
其中方法表的每个方法入口都包含该方法的访问权限、签名,以及一系列属性——最重要的属性是方法的Code属性,其中存放了方法的字节码指令序列及辅助信息(如此方法执行所需的最大操作数栈深度、局部变量表大小、异常表等)。
这些字节码指令是针对栈式虚拟机设计的指令集,它们不直接对应任何特定机器的指令集,而是在运行时由 JVM 来解释或编译执行。
2.2字节码在JVM中的作用:
JVM执行引擎以Class文件中的字节码指令作为输入。
字节码提供了"一次编译,到处运行"的中间表示,跟具体的平台无关。
当一个方法首次执行时,JVM通常通过解释器逐条读取并执行其中的字节码指令,将每条字节码翻译成对应的平台相关操作来完成程序语义。
例如,对于上节中的add方法,JVM会依次取出iload_1(把局部变量表索引1的整数压入操作数栈)、iload_2(压入索引2整数)、iadd(弹出两个整数相加再将结果入栈)、ireturn(从当前方法返回栈顶整数作为返回值)这几条指令并执行,从而完成加法计算。
所以,字节码是Java程序运行的基础指令序列,JVM通过执行字节码来实现程序的逻辑。
在解释执行的过程中,字节码指令的执行会涉及到维护操作数栈、局部变量表、程序计数器(PC寄存器)等运行时数据结构,以跟踪指令执行的位置和操作数状态。
总之,字节码作为中间代码既携带了程序的语义信息,又屏蔽了底层硬件差异,由JVM来负责将其转化成具体平台上的动作。
三、JVM的类加载、验证、准备
当编译生成的.class 文件准备好之后,Java虚拟机(JVM)就会在运行时加载这些类,并为执行做好链接和初始化工作。
类加载器机制负责找到和加载.class文件的二进制数据到内存。
JVM的类加载遵循的是双亲委派模型:
类加载请求会先交给父类加载器处理,层层往上,直到顶层的引导类加载器;
只有当父加载器没办法找到对应类的时候,子加载器才尝试加载。
这个机制保证了核心类优先由引导加载器加载,避免了重复加载。
同一时间,不同的类加载器可以隔离命名空间,这样就可以运行存在重名的类。
3.1加载(Loading):
在加载阶段,类加载器根据类名(及其包名)定位.class文件(通常根据classpath路径查找),读取字节序列并转换成内部的数据结构,并做初步的格式校验。
比如,加载器会校验Class文件的起始魔数是不是0xCAFEBABE,主次版本号是不是受支持,常量池是不是完整等,以此确保这个字节码文件基本上是有效的。
然后,JVM会在方法区为这个类创建一个java.lang.Class对象,表示这个类的运行时元数据,使程序可以通过这个Class对象访问类的信息。
到此类加载阶段就完成了,类的二进制数据已经进入了内存。
3.2链接(Linking):
加载之后,JVM会对类进行链接,链接包括验证(Verification)、准备(Preparation)和解析(Resolution)几个子阶段:
验证:
验证阶段是为了确保字节码的安全性和正确性。JVM将检查类的字节码是不是符合规范,比如类的结构是不是符合要求,字段和方法描述是不是合法,指令序列是不是会违反访问控制或造成栈映射不匹配等。还会检查像final字段有没有被非法修改、方法返回值类型是不是正确、局部变量是不是正确初始化后才能使用等等。验证通过才能保证字节码不会把虚拟机搞崩溃(避免非法的内存访问等)。
准备:
在准备阶段,JVM为类的静态变量分配内存并设置默认初始值。这一步发生在真正执行程序代码之前:比如,我们用static修饰的静态常量等,静态int字段在准备阶段会被分配并初始化成0(默认值),静态引用则初始化成null。这个时候只是分配内存和默认赋值,还没有执行任何Java代码,也不包括我们在源代码里写的初始化值,我们自己写的初始化赋值在初始化阶段才执行。
解析:
解析阶段通常就是把常量池里面的符号引用转换成直接引用。也就是说,把类、方法、字段的符号名解析成实际对应的内存地址。比如,解析一个方法调用的时候,JVM会查找目标方法的实际Method对象或本地代码地址,并将符号引用更新成直接引用。解析可以在初始化之前或者之后执行,HotSpot默认在初始化阶段延迟解析,首次使用时才解析符号引用,来减少不必要的解析开销。
完成上面这几步的链接过程后,类的结构已经被JVM认可并在内存中布局好了,静态变量也有了内存占位。
3.3初始化(Initialization):
初始化阶段就是执行类构造器<clinit>方法的过程。
这个方法由编译器收集类中的所有静态变量赋值语句和静态代码块合并产生。
初始化阶段会按照程序定义的顺序执行这些赋值和静态块,从而把静态变量初始化成我们设定的值,并执行所有静态初始化逻辑。
比如,如果类里面有句代码:static int x = 5;,在初始化时x就会从准备阶段的0被赋值成5。
如果一个静态变量初始化依赖于其他类(比如调用另一个类的静态方法),那么在执行时会触发那其他类的加载和初始化。
初始化阶段确保类的运行环境就绪。
字节码的解释执行:
上一节讲字节码的时候已经提过一嘴字节码解释器。
类初始化完成之后,方法就可以由JVM的执行引擎调用。
默认情况下,HotSpot JVM使用字节码解释器来执行方法:
每当一个方法被调用的时候,如果没有本地机器码可以用,执行引擎就进入解释模式,读取这个方法的字节码序列,一条条解释执行。
解释器根据字节码操作码(OPCODE)查找对应的执行逻辑,比如遇到iload_1指令时,从当前栈帧的局部变量表加载整数到操作数栈;
遇到iadd就弹出栈顶两个整数相加再把结果入栈,等等。
解释执行需要在运行时逐条解释字节码指令并执行,相比直接运行本地机器指令肯定会有额外的开销。
所以,经常都会听到说,解释执行的性能不如静态提前编译的语言(如C/C++)直接运行机器码快。
但是解释执行也有自己的好处,解释器的好处是即时性:程序一启动就可以执行,不需要需等待所有代码编译优化。
JVM通过解释器能够快速启动程序,然后再逐渐把热点的代码切换成高效的本地机器码执行,这个其实就是Java 边解释边编译体系的基础。
在解释执行的过程中,JVM会收集一些运行期信息(profiling),比如方法调用次数、循环执行次数等。
这些信息将用于后续即时编译(JIT)阶段,指导哪些代码需要优化,以及如何优化。
这是源码中字节码解释器:
每一条字节码指令都有对应的解释执行执行逻辑。
四、即时编译
上面提到为了提升性能,在代码执行的过程中,JVM会把某些热点代码编译成本地代码,这个就是即时编译。
HotSpot JVM内置了即时编译器(Just-In-Time Compiler)。
JVM通过计数器来监控代码的热度:比如,每个方法调用和循环跳转都累积计数。
当某段代码的执行次数超过设置的阈值的时候,JIT编译器就会介入,对这段代码启动即时编译。
HotSpot默认把方法调用次数和循环回边次数都计入热度阈值,当方法或代码块在短时间内调用次数超过阈值,就认为其是"热点"需要编译。
比如,在服务器模式下,方法调用次数阈值默认约为10000次(可以通过 -XX:CompileThreshold 调节),超过这个次数就会触发JIT编译,这个方法的字节码就会编译成机器码并存入Code Cache(代码缓存区)。
下次这个方法再被调用,JVM就可以直接执行已经编译好的本地机器指令,不需要再解释执行了,极大提高执行效率。
举个极端点的例子,看下开启了JIT(默认)和关闭JIT,一个简单的计算斐波那契数列耗费的时间:
package com.lazy.snail.jvm;
/**
* @ClassName JITDemo
* @Description TODO
* @Author lazysnail
* @Date 2025/6/9 14:33
* @Version 1.0
*/
public class JITDemo {
public static long fibonacci(int n) {
if (n <= 1) {
return n;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
public static void main(String[] args) {
int n = 40;
long startTime = System.nanoTime();
fibonacci(n);
long endTim = System.nanoTime();
double durationMs = (endTim - startTime) / 1_000_000.0;
System.out.printf("耗费时间: %.3f ms", durationMs);
}
}
默认情况下:耗费时间: 535.949 ms
关闭JIT:耗费时间: 6365.919 ms
通过-Xint强制JVM在解释器模式下运行,结果差不多差了一个数量级。
对于长时间运行的循环,JVM还支持栈上替换(OSR,On-Stack Replacement)技术:
HotSpot实现了分层编译策略:
它同时包含了两个JIT编译器,就是我们熟知的C1和C2。
C1编译器注重编译速度和轻量级优化,适合于提升应用的启动性能;
C2编译器着重深度优化,采用更复杂的全局优化算法,生成更高质量的代码,但编译耗时更长。
默认情况下JVM会先使用C1快速编译热点代码来获得一定的优化,同时收集更详细的性能分析数据,然后对于极热点代码再由C2进行进一步优化编译,来平衡响应速度和最终性能。
在JDK 8之后,分层编译是默认开启的,两个编译器协同工作。这样一来,Java既可以在早期通过C1取得较快的响应,又能在程序运行一段时间后通过C2获得接近C/C++的高执行效率。
JIT还有个反优化的机制,一开始JIT基于运行假设做出了一些优化,但是后来发现假设不成立,JIT就会通过反优化,把代码重新降级成解释执行或者触发重新编译。
这总操作就是为了确保在JIT优化的过程中不会影响最初的字节码语义,但是还是牺牲了一点性能。
五、机器码执行
JIT把热点代码编译为机器码之后,这段代码就直接以本地指令形式在CPU上运行了。
Java程序运行在操作系统的进程里面,JVM只是这个进程里管理执行的一个虚拟机。
每个Java线程通常对应一个本地操作系统线程,操作系统负责线程的调度和在CPU核上的分配执行。
机器码开始执行的时候,实际上跟普通的C/C++本地代码差不多。
CPU会读取指令寄存器里的地址,从Code Cache里取出机器指令并执行。
这些指令可以直接操作寄存器、内存,就像原生程序一样完成计算任务。
int addOne(int x) {
return x + 1;
}
int computeSum(int n) {
int sum = 0;
for (int i = 0; i < n; i++) {
sum += addOne(i);
}
return sum;
}
这段代码里的addOne方法可能会被直接通过方法内联,嵌入到computeSum里面。
循环里的addOne(i)直接替换成i+1大操作,字节码层面的调用指令(invokevirtual/invokestatic)就没了。
这就节省了方法调用的开销。
编译后的机器代码可能就长这个样子:
0x7fffd000: mov eax, [rbp-0x8]
0x7fffd003: add eax, 1
0x7fffd006: mov [rbp-0x4], eax
0x7fffd009: inc DWORD PTR [rbp-0x8]
0x7fffd00C: cmp [rbp-0x8], 0x186A0
0x7fffd013: jl 0x7fffd000
...
这个指令片段使用CPU寄存器和本地内存完成累加和循环控制逻辑。
最终由CPU执行单元处理,使用CPU的高速缓存、流水线、分支预测等硬件功能来加速执行。
总体来说,最终的机器码执行是在操作系统管理下在处理器上完成的。
操作系统为Java进程分配CPU时间和内存资源,JVM产生的机器码作为进程的一部分被CPU执行。
每当需要更底层的服务,机器码就通过调用操作系统提供的功能完成,跟其他本地应用没有区别。
Java线程被操作系统调度,Java进程遇到IO时挂起等待内核响应,这些机制都跟其他语言运行的程序一致。
唯一不同的是,这些机器码大部分是由JVM在运行时动态生成和管理的。
另外,为了保证安全性和稳定性,JVM对动态生成的代码和调用进行了严格的校验和限制,并且受操作系统的保护模式控制(不能越权访问不属于本进程的内存等)。
结语
大致的梳理一下Java方法从源代码到最终机器指令的转换执行过程:
编译期把高级源码转成中间字节码表示;
类加载和链接把字节码加载到内存并做好执行前的准备;
解释执行先行运行并且监控字节码;
即时编译识别热点并把热点代码编译成机器代码;
运行的时候机器码由CPU执行并通过操作系统完成和硬件、外部环境的交互。
参考文献
《深入理解Java虚拟机》
《Java编程思想》