你写的每一行Java代码,从保存文件到真正跑起来,中间隔着7个精密环节。理解它们,才能解释为什么你的服务启动慢、为什么内存会炸、为什么同样的代码第二次运行更快。

编译:从人话到字节码

打开网易新闻 查看精彩图片

javac 做的不是"翻译",而是"降维"。

它把你的Java源码变成字节(bytecode)——一种平台无关的中间格式,存成 .class 文件。这些文件可以打包进JAR,或者按Java 9之后的模块系统组织。

关键认知:字节码不是机器码。它跑不了,只能被JVM"解释"或"编译"后才能执行。这是Java"一次编写,到处运行"的根基,也是性能争议的源头。

加载:类加载器的层级博弈

类加载器子系统采用双亲委派模型(parent delegation)。不是"先自己找",而是"先问爸爸"。

三层分工:

启动类加载器(Bootstrap):加载JDK核心类,java.lang.* 这些。用C++实现,Java代码里看不见。

平台类加载器(Platform):加载扩展类,JDK的 lib/ext 目录或模块路径。

系统类加载器(System):加载你的应用代码,CLASSPATH指定的那些。

为什么要"先问爸爸"?安全。防止你写一个 java.lang.String 把JDK的替换掉。也防止重复加载——父加载器加载过的,子加载器不再加载。

但打破双亲委派也有场景。Tomcat给每个Web应用独立类加载器,就是为了让同名的Servlet类可以隔离共存。OSGi、SPI机制(Service Provider Interface)也是破坏者。

链接:验证、准备、解析

类加载进来后,进入链接阶段,三步走:

验证(Verify):JVM检查字节码是否合法。操作数栈不会溢出、类型转换安全、跳转指令不越界。这是安全沙箱的第一道防线。

准备(Prepare):为类的静态变量分配内存,并赋予默认值static int count = 10,这时候 count = 0。真正的10还没来。

解析(Resolve):把符号引用变成直接引用。你的代码里写 new UserService(),编译后是个字符串符号。解析阶段找到UserService类在内存里的实际地址,把指针填进去。

解析可以延迟。用到的时候才解析,节省启动时间。

初始化:静态世界的诞生

这是类加载的最后一步,也是程序员最能感知的一步。

静态变量被赋予真正的初始值,static 代码块按顺序执行。这个过程是线程安全的——JVM保证一个类的初始化只会发生一次,多线程竞争时只有一个能执行,其他的阻塞等待。

触发初始化的时机:new实例、访问静态变量/方法、反射调用、子类初始化触发父类初始化、main方法所在类。被动引用不会触发,比如只定义父类的静态字段引用。

很多"诡异"的类加载问题,根源在这里。循环依赖的静态初始化、静态块里的耗时操作,都会在这一步暴露。

内存布局:线程共享与隔离

JVM内存不是一块大平地,而是严格分区的建筑。

线程共享区:

堆(Heap):所有对象实例和数组。GC的主战场。新生代、老年代、永久代/元空间,都在这里。

方法区(Method Area):类信息、常量池、静态变量、JIT编译后的代码缓存。JDK 8之前叫永久代(PermGen),之后叫元空间(Metaspace),移到了本地内存。

线程私有区:

JVM栈(Stack):每个线程一个栈,栈帧对应方法调用。局部变量表、操作数栈、动态链接、方法返回地址。

程序计数器(PC Register):当前线程执行的字节码行号指示器。线程切换时靠它恢复位置。

本地方法栈(Native Method Stack):给JNI调用的C/C++方法用的栈。

直接内存(Direct Memory)不在JVM规范里,但NIO的 ByteBuffer.allocateDirect 用它。绕过堆,减少一次拷贝,但不受GC直接管理,容易内存泄漏。

执行引擎:解释与编译的拉锯战

字节码终于要被"执行"了。JVM有两种武器:

解释器(Interpreter):逐条读字节码,边解释边执行。启动快,执行慢。适合跑一两次的代码。

JIT编译器(Just-In-Time Compiler):把热点代码编译成本地机器码,存到代码缓存(Code Cache)。后续直接执行机器码,速度接近C++。但编译本身耗时,触发需要"预热"。

热点检测基于方法调用计数器回边计数器。方法被调用多少次,循环执行多少轮,达到阈值就进编译队列。

C1编译器(Client Compiler):快速编译,优化少,适合桌面应用快速启动。C2编译器(Server Compiler):深度优化,激进编译,适合服务端长期运行。Java 7引入的分层编译(Tiered Compilation),先C1后C2,平衡启动和峰值性能。

Graal编译器是C2的替代者,用Java写编译器,支持AOT(Ahead-Of-Time)编译,让Java也能像Go一样快速冷启动。

垃圾回收:自动化的代价与选择

堆内存的回收是JVM最复杂的子系统。对象存活判定、回收算法、收集器实现,决定了你的服务是丝滑还是卡顿。

判定算法:

引用计数:Python用,JVM不用。循环引用解决不了。

可达性分析:从GC Roots(栈帧局部变量、静态变量、常量、JNI引用等)出发,能走到的对象存活,走不到的回收。

回收算法:

标记-清除(Mark-Sweep):先标记存活,再清除死亡。碎片多。

复制(Copying):内存分两半,存活对象复制到另一半,整片清空。无碎片,但内存利用率50%。新生代用这个,因为大部分对象朝生夕死,复制成本低。

标记-整理(Mark-Compact):标记后把存活对象往一端移动,清理边界外。老年代用这个,对象存活率高,复制不划算。

收集器演进:

Serial/Serial Old:单线程,STW(Stop-The-World)长。现在只配做客户端默认。

Parallel/Parallel Old:多线程并行,吞吐优先。JDK 8默认。

CMS(Concurrent Mark Sweep):并发低停顿,但碎片严重,JDK 14已移除。

G1(Garbage First):Region化内存,预测停顿时间,平衡吞吐和延迟。JDK 9+默认。

ZGC/Shenandoah:亚毫秒级停顿,TB级堆,染色指针、读屏障。JDK 15+生产可用。

选收集器不是越新越好。ZGC的吞吐量比G1低10%-20%,如果你的服务是批处理而非在线服务,可能反而更慢。

本地接口:JNI的双刃剑

Java代码调用C/C++库,走Java本地接口(JNI,Java Native Interface)。

流程:Java声明native方法 → 生成头文件 → C/C++实现 → 编译成动态链接库 → System.loadLibrary加载。

JNI打破了JVM的安全边界。C代码的内存泄漏、段错误,会直接搞崩整个JVM进程。字符串、数组在JNI里要手动管理内存,Get/Release配对。局部引用表有限,大量对象要及时DeleteLocalRef。

Netty的零拷贝、TensorFlow Java API、各种数据库驱动,底层都依赖JNI。用得好是性能利器,用不好是稳定性的噩梦。

实战:从启动慢到诊断工具链

理解JVM结构后,常见的性能问题有了排查路径。

启动慢:检查类加载数量(-verbose:class),看是不是加载了太多jar。用AppCDS(Application Class-Data Sharing)把类元数据缓存到文件,下次直接映射,省掉解析验证。

内存溢出:堆溢出看对象存活周期,用MAT(Memory Analyzer Tool)分析dump文件。元空间溢出检查动态生成类(CGLIB、反射)是否泄漏。直接内存溢出,看NIO的Buffer有没有手动释放。

卡顿:打印GC日志(-Xlog:gc*),看STW时间。用async-profiler做火焰图,定位热点方法。JIT编译本身也会暂停,C2编译复杂方法时的去优化(deoptimization)可能引发抖动。

工具链:

jps:看Java进程

jstat:监控GC、类加载、编译统计

jmap:生成堆dump

jstack:打印线程栈

arthas:阿里巴巴开源,在线诊断,反编译、热更新、trace方法耗时

JFR(Java Flight Recorder):低开销持续记录,JDK商业特性开源后的利器

为什么这7步值得刻进脑子

云原生时代,JVM的"重"被反复诟病。启动慢、内存大、容器里表现诡异。

但理解这7步,你会发现优化空间:

• GraalVM的AOT编译,把初始化阶段提前到构建时,冷启动进到毫秒级

• CRIU(Checkpoint/Restore In Userspace)冻结预热后的JVM状态,恢复时跳过前面6步

• Quarkus、Micronaut框架,编译期做依赖注入,减少运行时反射和类加载

JVM不是黑箱。从字节码到机器码的旅程,每一步都有设计取舍,也都有调优杠杆。下次面试被问到"对象怎么创建的"、线上遇到"Metaspace OOM"、争论"Java到底慢不慢",这7步就是你的底层操作系统。