ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Java编译到运行:javac、类加载与JIT全流程解析

Java编译到运行:javac、类加载与JIT全流程解析 做Java这么多年我面试过不少候选人也带过很多新人。一个很有意思的现象是大多数人能把Java语法背得滚瓜烂熟但你要是问他一个.java文件从保存到程序跑起来中间到底发生了什么他反而会愣住。有人以为编译完就是机器码有人以为JVM就是解释器还有人分不清javac和java两条命令的工作边界。这篇文章就把这条完整链路拆开讲透javac怎么把源码变成字节码、类加载机制如何把class文件请进JVM、字节码又是怎么被真正执行成机器指令的最后落到实际开发和面试里那些高频坑上。不管你是刚学Java的新手还是写了几年想补基础的老手这条链路都值得彻底搞明白——因为很多线上诡异问题根子都埋在这条链路里。1. javac编译阶段.java到.class之间发生了什么1.1 先明确一个误区Java的编译不是C/C那种编译很多人默认编译就是把源代码变成机器能直接跑的东西。在C语言里gcc把.c文件编译成.o目标文件再经过链接变成可执行二进制操作系统直接加载执行确实是这样。但Java的编译完全不同。javac命令把.java文件编译出的.class文件里面的字节码bytecode并不是任何CPU的机器指令。它是一种面向JVM的中间指令集类似虚拟CPU的汇编语言。这个设计是故意的——字节码不认识具体的操作系统和CPU架构它只认识JVM。这就是一次编译到处运行的真正含义你编译一次得到.class文件然后把它丢到装有对应JVM的平台上JVM负责翻译成当前平台能执行的机器码。编译产物跨平台执行过程不跨平台跨的是JVM。所以从这一刻起Java程序的生命周期就被分成了两段编译期javac干的事和运行期JVM干的事。这也是理解后面所有机制的总纲。1.2 编译四步走从字符串到字节码javac并不是一口气把源码变成字节码的它内部有严格的阶段划分。我自己早期以为javac就是个文本替换器后来翻了编译原理的书才明白它的工作流程其实非常经典大致是词法分析把.java里的字符流拆成一个一个的token记号。比如public class Hello会被拆成public、class、Hello这些最小单位每个token还带着自己的类型标记关键字、标识符、运算符等。这个阶段如果报错通常是你写了非法字符或用错了关键字。语法分析根据Java语言规范把token序列组装成抽象语法树AST。这棵树的结构反映了代码的语法层级比如方法里有变量声明、if分支、循环。这个阶段做的是句法是否正确的检查比如if (x) {少了右括号就会在这里报错。语义分析这是最容易被忽略的一步也是javac价值所在。它检查的不再是句子通不通而是意思对不对。比如类型是否匹配、方法是否存在、变量有没有重复定义、泛型擦除、自动拆装箱的插入都是在这一阶段完成的。int x hello这种错误就是语义分析阶段拦下来的。字节码生成AST经过验证和优化后javac会生成对应的字节码指令写入.class文件。有一个细节值得注意javac不会做特别深的性能优化。它只做必要的语法糖处理和简单的常量折叠比如int a 2 * 3直接算成6真正的高阶优化全留给了运行期的JIT编译器。这跟GCC那种编译期就做大量优化的思路完全不同。很多从C转Java的人一开始不理解为什么编译完还不快答案就在这里——Java的优化大头在运行期。1.3 Class文件里到底存了什么如果你用十六进制工具打开一个.class文件第一眼看到的是魔数cafebabe这是JVM识别class文件的标记。之后是版本号、常量池、访问标志、字段表、方法表、属性表等结构。我想重点说常量池。它是class文件里最核心的数据结构说白了就是一个符号引用清单。里面存了类名、方法名、字段名、字符串字面量、类型描述符等。比如你写了System.out.println(Hello)常量池里会存放System类的符号引用、out字段的符号引用、println方法的符号引用以及Hello这个字符串字面量。为什么我叫它符号引用而不是直接引用因为编译期javac并不知道System.out在内存里的具体地址它只能记录一个名字。真正的地址解析要等到运行期类加载时再做。这个机制牵连出一个重要结论后面会细说Java的链接时机发生在运行期所以Java本质上是动态链接的。1.4 javac管了哪些事又刻意不管哪些事javac负责的事情包括语法检查、类型检查、常量折叠、泛型擦除、生成语法糖代码比如foreach循环转换成迭代器调用、lambda表达式转换成invokedynamic指令、字符串拼接转换成StringBuilder操作、生成class文件结构。javac刻意不负责的事情包括不找到第三方依赖的入口地址、不进行跨类的代码优化、不校验目标平台兼容性、不生成可执行文件。这里有个非常典型的实战问题为什么有时候javac能编译过但运行时就报NoClassDefFoundError因为编译期只需要能找到类的符号引用就能通过比如你import了某个包、调用了某个方法而运行期JVM必须真的把那个类加载到内存并找到方法实现。编译时用的classpath和运行时用的classpath不一致就会造成这种编过了但跑不起来的诡异情况。2. 类加载.class文件进入JVM的完整旅程2.1 加载、验证、准备、解析、初始化五阶段.class文件是静态文件跑起来之前必须先被JVM加载进来。这个过程叫类加载包含五个阶段加载由类加载器ClassLoader根据全限定名找到class文件或jar包里的class条目读入字节流然后把它转成方法区里的运行时数据结构并在堆中生成一个java.lang.Class对象作为访问入口。这一步就是把文件变成JVM内部数据的过程。验证确保这个class文件是合法安全的不能破坏JVM。包括文件格式验证魔数是不是cafebabe、字节码语义验证指令操作数是否合法、符号引用验证等。这个设计是为了安全——毕竟class文件不一定来自javac也可能是手工构造的恶意字节码。准备为类的静态变量分配内存并设置默认初始值。注意这里说的是默认值不是代码里写的值。比如static int count 10在准备阶段count先被置为0真正的10要等初始化阶段才赋上。解析把常量池里的符号引用替换为直接引用。简单说符号引用是名字直接引用是地址。比如之前说的System.out解析阶段会真正找到它在内存中的位置并建立关联。这一步是Java动态链接的关键环节——之所以叫动态就是因为链接发生在类被加载运行的时候而不是编译成可执行文件时。初始化执行类构造器clinit方法给静态变量赋上代码里写的值执行静态代码块。这个阶段是很多乱象的温床比如静态块里抛了异常、循环依赖导致的初始化顺序问题都在这里爆发。五个阶段里验证、准备、解析统称为连接linking整个类加载就是加载连接初始化的组合。2.2 双亲委派模型为什么是安全底线JVM的类加载器不是只有一个而是分层的。标准三层结构分别是Bootstrap ClassLoader启动类加载器加载JDK核心类比如java.lang、java.util这个加载器是C写的在Java里看不到它的实例。Platform ClassLoader平台类加载器JDK9之后叫这个以前叫Extension加载一些扩展模块。Application ClassLoader应用类加载器加载classpath下的用户代码。它们的协作机制叫双亲委派当一个类加载请求到来先不自己加载而是委托给父加载器每一层都向上传直到Bootstrap如果父加载器加载不了才往下返回由子加载器尝试加载。为什么要这么复杂核心是安全。假如你自己写了一个java.lang.String放在classpath里如果没有双亲委派应用类加载器直接加载了它那整个Java基础类库的安全性就崩了——你的假String可以篡改核心逻辑。有了双亲委派java.lang.String的加载请求最终会到达Bootstrap加载到的一定是JDK自带的真String用户自定义的伪造类根本没机会被加载。这个机制也是后面排查ClassCastException、类隔离问题时的底层依据。比如Tomcat里每个Web应用有独立的类加载器就是靠破坏双亲委派模型实现应用隔离的。2.3 静态链接还是动态链接一个很多人搞错的点我在网上看到一个热搜词叫java是静态链接的。这个说法是错的但错得很典型。C程序编译完链接器会把所有依赖的函数实现地址全部确定下来生成的可执行文件可以直接运行。这叫静态链接或编译期链接。Java不是这样——Java的class文件在编译期只记录符号引用所有方法调用、字段访问的地址绑定都是在运行期解析阶段完成的。这就是动态链接。动态链接带来两个直接结果第一类可以延迟加载JVM可以按需把某个类加载进来避免启动时所有类全部就位第二多态才有实现的土壤——只有运行期才知道变量的实际对象类型方法分派invokevirtual指令才能在方法表里查到真正的调用目标。所以如果有人面试问你Java程序是静态链接还是动态链接答案是动态链接论证过程就是类加载机制里的解析阶段。3. 字节码执行解释执行与JIT编译的协同作战3.1 逐条翻译的解释执行为什么慢class文件加载完毕之后JVM就要开始执行字节码了。最简单的执行方式是解释执行——一条一条把字节码指令翻译成当前平台的机器指令然后执行。你可以把解释执行想象成一个同声传译外国人说一句翻译跟一句。优点是启动快、内存占用低不需要额外编译环节缺点是同一个方法被调用一万次它就要翻译一万次执行效率上不去。早期Java被人诟病慢就是这个原因。后来HotSpot虚拟机引入了JITJust-In-Time编译Java的性能问题才逐步解决。3.2 热点检测与JIT编译把字节码变成机器码JIT的思路很朴素一段代码如果被反复执行为什么还要每次都翻译直接把它编译成机器码缓存起来下次调用直接执行机器码不就行了关键在于哪些代码值得编译。JVM通过运行期统计信息来判定这就是热点检测。HotSpot虚拟机名字里的HotSpot就来自这里会统计方法调用次数和循环回边次数当超过阈值默认方法调用10000次Server模式是10000可以用-XX:CompileThreshold调整就会把这个方法标记为热点交给JIT编译器编译成当前平台的机器码。现代JVM普遍采用分层编译HotSpot里对应C1和C2两套编译器。C1编译速度更快但编译出的机器码优化程度一般C2编译更慢但优化更激进生成的代码质量更高。分层编译的做法是方法先被C1快速编译用低质量的机器码先跑起来如果方法还是持续热点再交给C2做深度优化。为什么不像C/C那样一次性全部编译成机器码要搞解释执行JIT混合这套原因是取舍全部编译拖慢启动时间。一个大型Spring应用几万个类全部编译要几十秒用户等不起。运行期编译能拿到解释执行阶段收集的运行时信息可以针对实际数据做优化这是静态编译做不到的。3.3 逃逸分析及其他运行期优化JIT编译器里最有代表性的优化之一是逃逸分析。名字听着玄思路其实很简单判断一个对象的作用域会不会逃逸出方法。如果一个对象只在方法内部使用、不会被其他线程访问、不会被外部引用那它就没有逃逸。没有逃逸的对象可以享受三个优化栈上分配直接在虚拟机栈上分配内存方法结束自动销毁不需要经过垃圾回收。这是很多人说JVM未必所有对象都分配在堆上的原因。标量替换把对象拆成它包含的基础类型字段省去对象头等额外开销。锁消除如果同步代码块里的锁对象不会逃逸出当前线程那就根本没竞争的锁JIT会把同步直接去掉。此外还有内联把方法体直接嵌入调用处、死代码消除、循环展开等。解释执行和JIT编译的边界清晰之后你就明白为什么Java程序常常是越跑越快——热点方法被不断优化执行效率随时间上升这在编译型语言里是见不到的现象。4. 从java命令到main方法运行期的完整画面4.1 JVM启动后先做什么你敲下java Hello那一刻开始发生的事情比你想象的要多得多JVM先启动自身进程初始化运行环境。创建引导类加载器开始加载核心类库——java.lang、java.util这些最基础的类其实在这里就已经成批加载了。创建JVM内部的一些核心数据结构比如初始化各种内存区域。找到你指定的主类启动类就是我们写的含有main方法的类调用它的main方法。这里有个高频问题为什么经常看到错误: 找不到或无法加载主类 Hello原因一般有三个classpath里没有包含当前目录导致类加载器找不到Hello.class类文件名与类名不一致main方法的签名写错了比如漏了public或void、参数不是String[]。前两种都跟类加载机制直接相关最后一种属于JVM检查方法签名失败。4.2 内存区域与一个对象诞生的全过程JVM运行期的内存布局也是这条链路的重要一环。按照Java虚拟机规范运行时数据区包括程序计数器记录当前线程执行到哪一条字节码指令是线程私有的。虚拟机栈每个线程一个栈里面是栈帧每个方法调用对应一个栈帧里面有局部变量表、操作数栈、动态链接、方法出口等。本地方法栈给native方法比如底层C/C实现的方法用的。堆几乎所有对象实例都在这里分配是垃圾回收的主战场。方法区JDK8之后叫元空间存储类元信息、常量、静态变量、JIT编译后的代码缓存等。一个对象从创建到回收的过程可以完整描述为类加载检查确认这个类已经被加载→ 在堆上分配内存如果开启了TLAB优先在TLAB里分配→ 把内存清零 → 设置对象头标记字段、类指针等→ 执行init构造方法初始化字段 → 后续被GC回收或逃逸分析优化。每次new一个对象背后都有这么一长串动作。这也是为什么推荐创建少量大对象而不是海量小对象——每一次分配都需要类加载检查、分配、初始化、释放的处理。4.3 一条Hello World的完整旅程为了把这整条链路串起来我习惯用最简单的Hello World做贯穿javac Hello.java把源码变成hello.class。词法、语法、语义都过了class文件里记下了main方法、打印语句、字符串常量。java Hello启动JVM使用应用类加载器寻找Hello.class。加载Hello类后依次走验证、准备、解析、初始化四个阶段。其中解析阶段会把Hello类里引用的System、PrintStream、out字段等符号引用解析成直接引用。JVM执行main方法对应的字节码。在解释阶段逐条翻译执行此时调用System.out.println时会真正定位到PrintStream.println的实现。如果这个方法被大量调用成为热点JIT编译器会把它编译成机器码后续直接执行机器码速度更快。程序结束时非守护线程停止JVM进程退场。一条输出语句背后是编译、类加载、符号解析、字节码执行、JIT优化一整套机制的协同。5. 基于这条链路的常见问题排查与面试区分点5.1 高频报错类找不到和主类找不到这是新手问得最多的一类问题但很多人分不清ClassNotFoundException和NoClassDefFoundError的区别。ClassNotFoundException是显式加载某个类比如用Class.forName时类加载器在classpath里找不到这个类它是受检异常属于java.lang。NoClassDefFoundError则更阴险——编译时这个类是存在的但运行时加载阶段失败了它是Error不是Exception。典型场景依赖的jar包没部署到运行环境、类的静态初始化抛了异常、某个类因为版本不兼容在验证阶段没通过。区分这两者的意义在于定位问题的方向前者说明你的classpath配置有问题或缺少依赖包后者说明你编译时的环境和运行时的环境不一致。我排查线上问题时遇到NoClassDefFoundError第一反应就是检查运行环境里有没有对应版本的jar而不是去查代码逻辑。5.2 版本不匹配问题UnsupportedClassVersionError这个报错信息里通常会带一个数字比如class file version 61.0。61.0对应Java 1755.0对应Java 1152.0对应Java 8。这个数字记录在class文件的版本号位置JVM加载时会校验如果class文件的版本号高于当前JVM支持的版本直接拒绝加载并抛UnsupportedClassVersionError。所以用JDK17编译用JDK8运行必然报这个错。常见解决办法是重新用低版本JDK编译或者换高版本JDK运行。这个坑在多人协作项目里特别常见。5.3 编译期异常与运行期异常的边界热搜词里有编译期异常这里也顺带说清楚。Java把异常分成两类受检异常checked exception和不受检异常unchecked exception。受检异常比如IOException如果方法里可能抛出它要么在方法签名里声明throws要么在方法内try-catch处理否则javac在编译期就报错根本生成不了class文件。这叫编译期强制检查。不受检异常包括RuntimeException及其子类比如NullPointerException、ArrayIndexOutOfBoundsException编译器不强制处理它们通常到运行期才会暴露。注意这里说的编译期异常有两个完全不同层面的含义一是编译器要求你处理受检异常这种编译期报错二是编译期生成的代码导致运行期异常比如泛型擦除后强转失败。踩过坑的人应该懂一个List 在编译期安全打包但类型擦除后在运行期取出来时如果强转成其他类型就会ClassCastException。5.4 面试回答Java是编译型还是解释型的框架这个问题几乎每次面试都会遇到标准回答框架是Java既有编译型的特点也有解释型的特点准确说是先编译后解释再编译的混合模式。具体展开说javac把源码编译成字节码这是编译行为JVM一开始用解释器逐条解释字节码这是解释行为方法成为热点后JIT把字节码编译成机器码这又回到了编译行为。所以不能简单归为单一一种。另外一个高频追问是一次编译到处运行为什么能实现回答落点是编译产物是平台无关的字节码运行依赖的平台相关部分全部被封装在JVM里不同平台装不同JVM但.class文件是同一份。我个人做了这么多年Java最大的体会是编译到运行这条链路不是枯燥的基础理论它是定位问题的坐标系。遇到任何诡异报错先问自己这句话发生在哪个阶段——是javac编译报错、还是类加载阶段报错、还是字节码执行阶段报错、还是JIT优化导致的问题。把这个坐标一划90%的问题都能快速找到排查方向。如果这篇文章能帮你在脑海中建立这条完整链路比记多少API都值钱。以后无论是面试讲原理还是线上排查疑难杂症这条路就是你的地图。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表