ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入拆解JVM运行机制:从字节码到类加载与JIT全程图解

深入拆解JVM运行机制:从字节码到类加载与JIT全程图解 很多 Java 开发者学了几年提到 Java 虚拟机还是只记得“堆、栈、垃圾回收”几个名词一旦追问“你的代码编译完之后到底长什么样子”“JVM 是怎么一步步把你的方法跑起来的”就答不上来了。其实最扎实的入门路径不是背内存模型而是先看字节码——字节码才是 Java 虚拟机真正认识的“语言”是 Java 跨平台特性的根基也是理解类加载、JIT、异常处理一系列运行机制的总钥匙。这篇文章我会从字节码入手把 Java 虚拟机的运行机制从头到尾拆一遍javac 编译时做了什么、.class 文件里装了什么、一条条指令怎么执行、类加载和 JIT 如何接力最后再用 javap 亲手扒开几个常见的语法糖“骗局”。适合正准备啃 JVM 的开发者、面试前想摆脱死记硬背的人以及遇到过类加载、热部署、诡异异常问题想探个究竟的同行。1. 从 javac 到 .class一次编译背后的三个决定性步骤很多人的认知里“编译”就是把 .java 变成 .class然后 JVM 就能跑了。但 javac 到底干了什么几乎没人追问过。这一步如果糊弄过去后面看指令集、看类加载都会觉得像在沼泽地里赶路。1.1 词法、语法与语义分析javac 先把人话翻译成人话javac 的编译过程本质上是一条非常传统的编译流水线词法分析、语法分析、语义分析最后才是字节码生成。词法分析把源码拆成一个个 token。你的public static void main(String[] args)会被识别成 “public 是一个访问修饰符”“main 是一个标识符”“String 是类名”这样细碎的单元。语法分析根据 Java 语言规范构建抽象语法树括号不匹配、缺分号这一步就会报错。语义分析去查类型是否正确、变量是否声明、方法是否存在int a hello这类错误在这里被拦下来。真正有意思的是最后一步字节码生成。javac 和 GCC、Clang 这类“认真做优化”的编译器不太一样它做的是非常“抄写员式”的翻译把语法树变成对应的指令序列偶尔做点常量折叠。举个例子public class Simple { public int calc() { int x 1 2; return x; } }你可能会以为int x 1 2编译完会是“先压入 1再压入 2然后相加”。但 javac 在编译期就已经算出结果是 3所以字节码里根本看不到任何加法指令直接就是public int calc(); Code: 0: iconst_3 1: istore_1 2: iload_1 3: ireturn这一个细节就能解释很多现象为什么 Java 里String s abc def这种常量拼接在编译期就完成了为什么某些看起来“应该会优化”的局部变量 javac 却老老实实给你存到局部变量表又原样取出来——javac 的思路是“保证语义正确、格式规范”把优化空间留给后面的 JIT。1.2 字节码不是汇编平台无关性与栈式设计的由来字节码常被类比成“汇编语言”但这个类比有误导性。汇编指令直接操作特定 CPU 的寄存器和内存地址而字节码指令操作的是 JVM 规范里定义的抽象结构——局部变量表和操作数栈。JVM 规范里预留了 256 个操作码槽位实际实现并使用的指令有二百多个。每条指令由一个字节的操作码opcode加上若干操作数组成。不同 CPU 厂商不需要管 Java 程序长什么样他们只需要实现一个符合 JVM 规范的虚拟机——不管是 Windows 上的 HotSpot、Linux 上的 OpenJ9 还是 Android 时代的各种 JVM 变种拿到同一份 .class 文件都能跑出相同结果。这就是“一次编写处处运行”的底层真相。理解这一点特别重要你写的 Java 代码本质上不是给操作系统跑的而是给“一个抽象的机器”跑的。操作系统的差异被 JVM 挡住了CPU 指令集的差异也被 JVM 挡住了。1.3 顺手看一眼编译产物一个 .java 文件编译后多了什么编译一个最简单的类javac Simple.java ls Simple.class你会得到一个体积不大不小的二进制文件。直接cat它输出是一堆乱码——这很正常。这个文件包含了文件头、常量池、访问标志、类索引、父类索引、接口集合、字段表、方法表、属性表。这些结构会在第二章详细拆解。这里想提前说一个实操建议从现在开始你每写一个 Java 类编译完都用javap -c看一眼。坚持一个月你的 JVM 理解水平会超过大部分背了三个月面试题的人。javap 是 JDK 自带的字节码查看工具不需要装任何插件是研究 JVM 运行机制最直接的敲门砖。2. .class 文件的血肉魔数、常量池与 Code 属性逐个拆开字节码不是散落一地的指令它们是装在一个明确规格的容器里的这个容器就是 .class 文件。JVM 规范对它的每个字节都有规定十六进制打开后你看到的是一个非常严谨的表格。2.1 用十六进制打开 .classCAFEBABE 只是第一行拿xxd或者od打开任意一个 .class 文件xxd Simple.class | head -20第一行开头一定是四个字节ca fe ba be。这就是著名的魔数用来告诉 JVM“我是一个合法的 class 文件”。紧跟其后的0000 0034是次版本号和主版本号0x34是 52对应 JDK 8。不同 JDK 版本编译出来的文件主版本号不一样低版本 JDK 跑高版本编译的类报UnsupportedClassVersionError就是这个值决定的。再往后是常量池计数、访问标志、类索引、父类索引。整个文件的字节顺序规定为大端序也就是高字节在前。JVM 读取 class 文件时就是按照这些固定偏移量一项一项去解析的。手动解析 class 文件并不难网上能搜到很多十六进制对照教程。但我建议第一次接触的人不要陷入“手算偏移量”的泥潭直接用javap -verbose来看结构化输出先建立全局认知再反向对照十六进制。javap -verbose Simple你会看到一长串内容常量池里每一项都带编号从 1 开始第 0 项是保留项。字段表、方法表、属性表都有清晰的缩进。这是当前最直观的 class 文件“拆解图”。2.2 常量池是全世界最大的“符号表”常量池是 .class 文件里最核心的组成部分。你在代码里写的类名、方法名、字段名、字符串字面量、类型描述符全都被塞进了这个池子。指令本身不直接携带这些名字而是通过一个编号引用常量池里的条目标签——理解这一点很多疑惑都会解开。举个例子public class Hello { public static void main(String[] args) { System.out.println(hello); } }javap -verbose Hello常量池里至少会有java/lang/System类的常量、out字段引用、java/io/PrintStream类的常量、println方法引用、字符串hello的常量。真正执行时invokevirtual指令说白了就是“按常量池第 4 项找那个方法调用它”。这里也有人叫它“符号引用”。class 文件在编译期并不知道System.out.println指向的具体内存地址它只知道“我要调用的方法长这个样子、叫什么名字”。等到运行时类加载的解析阶段JVM 才把这些符号引用替换成可以直接调用的直接引用。另外有个细节同一个字符串字面量在代码里出现十次常量池里只会有一条CONSTANT_String_info所有引用都指向同一条。这跟new String(hello)是两码事——后者是运行时创建的堆对象常量池那个东西只是一个“字面量的符号描述”。2.3 Code 属性与三张表行号表、局部变量表、异常表方法的方法体也就是真正可执行的指令序列存放在方法的Code属性里。JVM 执行一个方法前会根据 Code 属性里的max_stack和max_locals提前分配好栈帧空间。Code 属性里还有三张重要的辅助表LineNumberTable字节码指令偏移量到源码行号的映射。异常堆栈里的at xxx(Hello.java:7)就是靠它反查出来的。没有这张表报错就只能显示Unknown Source。LocalVariableTable局部变量名和槽位的对应关系。debug 时能显示name: String、index: int靠的就是它。StackMapTable字节码验证器用来快速做类型检查的“预计算地图”后面讲类加载验证阶段时会再提到。很多人以为源码编译完就彻底跟行号无关了其实不是。只要不关掉调试信息行号表会一直存在于 class 文件里这也是线上排障能看到精确行号的真正原因。3. 方法体逐条翻译从操作数栈到 invoke 指令的执行路径这一章是全文的硬核部分。我们不再看“文件长什么样”而是进入执行视角JVM 拿到一个方法后里面的指令是怎么一条条运行的。3.1 为什么 JVM 用栈式架构而不是寄存器架构现代 CPU 几乎都是寄存器架构。Java 却选了一条反主流的栈式架构原因有三个。第一跨平台好实现。寄存器架构强依赖 CPU 的寄存器数量和命名规则栈式架构只依赖一个操作数栈任何平台只要按规范实现 push/pop 就行。第二编译器和解释器都好写。JVM 的指令集设计得很规整对 javac 这种“不那么聪明”的编译器非常友好。第三早期的 Java 面向嵌入式设备和网络代码体积越小越好基于栈的指令可以做到很短。代价也很明显栈式结构需要更多的指令来搬运数据性能上限看起来“不高”。但这不是问题——真正的 HotSpot JVM 在运行时靠 JIT 把热点代码编译成寄存器机器码运行时早就不是“逐条解释字节码”了。栈是规范给的兜底寄存器才是 JVM 高性能的秘密。3.2 一个求和方法的完整拆解iload / iadd / ireturn 的循环往复来看一个最经典的例子public class Calc { public int add(int a, int b) { int c a b; return c; } }用javap -c Calc得到public int add(int, int); Code: 0: iload_1 1: iload_2 2: iadd 3: istore_3 4: iload_3 5: ireturn逐条看iload_1把局部变量表下标 1 的 int 值压入操作数栈。这里a存在下标 1。下标 0 是什么是this——实例方法里 0 号槽位永远留给当前对象。iload_2把b压栈。iadd从栈顶弹出两个 int相加把结果压回栈。istore_3弹出栈顶值存入局部变量表下标 3也就是c。iload_3再把c压栈。ireturn返回栈顶的 int。整个过程就像两台小推车来回搬运局部变量表是仓库操作数栈是工作台。指令本身不关心数据在内存里的绝对地址它只关心“从仓库几号位取到工作台”“在工作台上做个运算”“再放回仓库几号位”。值得注意的是javac在这里并没有做“省掉局部变量 c 直接返回 ab”的优化而是老老实实存了一下又取出来。这就是所谓“抄写员式”编译。别急着骂它蠢把这种繁琐的工作交给 JIT换来的是 javac 的简单、稳定、可靠。3.3 new 后面的 dup 到底在防什么对象创建的字节码陷阱对象创建是初学字节码的人最容易懵的地方。看这段代码public class Creator { public Object create() { return new Object(); } }javap -c Creator输出public java.lang.Object create(); Code: 0: new #2 // class java/lang/Object 3: dup 4: invokespecial #3 // Method java/lang/Object.init:()V 7: areturn很多人不理解为什么要dup。new指令的执行流程是在堆上分配一块内存把对象引用压入操作数栈。但此时对象还没有调用构造器严格说还不是一个“完整可用”的对象。接下来要执行invokespecial init也就是构造方法。问题来了任何实例方法调用包括构造器调用都会从操作数栈顶部弹出一个对象引用作为接收者。如果只有一个引用调用完构造器之后栈上就空了create()拿什么返回所以 javac 插入了一条dup把引用复制一份。栈上现在有两个相同引用一个被invokespecial弹走作为构造器接收者另一个留到areturn当作返回值。这个设计不是偶然而是 JVM 调用语义和栈式架构互相匹配的必然结果。4. 类加载与 JIT 接力字节码如何一步步变成活进程字节码文件躺在磁盘上只是数据。真正让它变成可以运行的程序要经过类加载、字节码验证、初始化、执行引擎处理等一系列环节。这一章搞清楚JVM 的运行机制框架就立起来了。4.1 加载、验证、准备、解析、初始化五阶段的顺序不能乱类从被加载到被销毁生命周期大致分五步加载、验证、准备、解析、初始化。前两步常被合起来叫“加载阶段”后面三步属于“连接阶段”初始化是独立的一段。加载通过类加载器找到 .class 文件的二进制字节流解析成方法区里的运行时数据结构并在堆里生成一个java.lang.Class对象作为访问入口。验证确保字节流符合 JVM 规范不会危害虚拟机自身。第一阶段检查文件结构第二阶段检查元数据语义第三阶段做字节码验证。这里就会用到前面的StackMapTable——它与老版本那种通过数据流分析推导类型的验证方式相比验证速度高出很多。准备为静态变量分配内存并设置零值。注意这里不是赋你写的初始值而是“零值”——static int x 100在准备阶段得到的是 0100 真正赋值发生在初始化阶段。解析把常量池里的符号引用替换为直接引用。到了这一步invokevirtual #5才真正知道该跳转到目标方法的哪段内存地址。初始化执行clinit方法也就是给静态变量赋初始值、执行静态代码块的真正时机。这套顺序不能乱是有依赖关系的。验证不通过不能进入准备解析需要常量池完整初始化又依赖前面所有准备。实际开发中常见的NoClassDefFoundError、ExceptionInInitializerError几乎都落在“初始化”这一关。4.2 双亲委派模型为什么原则上不允许自己写个 java.lang.String类加载器也是分层的。HotSpot 里大致有三层启动类加载器Bootstrap负责加载 JDK 核心类平台类加载器JDK 9 之前叫扩展类加载器负责加载一些扩展库应用类加载器系统类加载器负责加载 classpath 下的类。你自己 new 的类加载器默认父加载器就是应用类加载器。双亲委派的规则很简单一个类加载器收到加载请求时先不自己加载先把请求丢给父加载器父加载器加载不了才轮到子加载器尝试。为什么要这样为了核心类不被替换。试想如果应用类加载器可以自由加载java.lang.String它在 classpath 里放一个同名同包的类系统核心 API 的安全就彻底崩了。双亲委派保证所有java.*类都只能由启动类加载器加载程序里再怎么写同名类也不会被核心 API 使用。有一个经典的反例JDBC 的DriverManager在java.sql包里它属于核心库由启动类加载器加载但具体驱动实现比如 MySQL 驱动在 classpath 上归应用类加载器管。启动类加载器没法回调应用类加载器。所以 JDK 引入了“线程上下文类加载器”打破双亲委派。理解这一点后再看各种框架里“为什么搞了个自定义类加载器”“热部署怎么实现的”思路就顺了。4.3 解释执行与即时编译同一个方法跑 10000 次之后发生了质变类加载完成对象创建好方法被调用接下来是执行引擎的活。执行引擎有两种工作方式解释执行和即时编译JIT。程序刚启动时JVM 用解释器逐条翻译字节码执行启动快但慢。当一个方法成为“热点代码”后JIT 编译器会介入。不同版本阈值不同服务端默认大概在方法调用一万次之后JVM 会把整个方法编译成本地机器码后续调用直接执行机器码不再走解释路径。HotSpot 的 JIT 分层级C1 编译器编译快、优化保守先顶上去C2 编译器优化激进编译慢用来做终极优化。典型优化有方法内联一个频繁调用的小方法可能整个被搬到调用方内部展开执行。还有逃逸分析一个对象如果在方法内部创建、不被外部引用JVM 可能把它分配到栈上甚至拆成多个标量变量干脆不经过堆内存GC 压力也随之下降。到了 JIT 阶段字节码已经只是“设计图纸”真正跑的是针对当前机器做出来的机器码。这也是为什么现代 Java 程序性能可以逼近甚至在某些场景超过 C/C 的原因。5. 拆穿常见的编译期“魔法”字符串、switch、异常与泛型最后一章来点实战。很多语法糖你在源码里看到的是优雅的写法编译成字节码后完全是另一副面孔。搞懂这些以后再遇到“源码没问题但行为诡异”的问题你会多一个排查方向。5.1 字符串拼接在字节码里的版本差异从 StringBuilder 到 invokedynamicString s a name;这种写法很多人不知道它背后不是简单的字符串相加。JDK 8 及以前字节码是这样的new 一个StringBuilder或StringBuffer然后一次次调用append最后toString。所以循环里拼字符串为什么慢看字节码就一目了然——每次循环都在 new 容器、复制数据、又 new 又复制对象分配开销全摊在头上了。JDK 9 开始javac 改成了一条invokedynamic实际拼接逻辑交给运行时策略StringConcatFactory来决定。好处是编译期不再锁死具体实现JVM 可以在运行时选择最优的拼接方案甚至避免创建中间字符串。这个优化普通人感知不到但常量池和字节码结构的变化直接反映在产品字节码版本差异里。顺便一提String s a b;属于编译期常量拼接javac 直接把它折叠成ab压根不会走上面的逻辑。5.2 tableswitch 与 lookupswitchswitch 的两种实现路线switch 语句在字节码层面有两种指令tableswitch和lookupswitch。javac 会根据 case 值的分布情况选择。int 型的 case 如果比较密集比如 1、2、3、4用tableswitch。它会在字节码里构造一张跳转表像数组一样按偏移量查找时间复杂度 O(1)。case 值很稀疏比如 1、100、1000、2000用lookupswitch。它保存一组排好序的键值对执行时做二分查找时间复杂度 O(log n)。如果稀疏值也硬做跳转表表里会有大量空槽位class 文件体积就白白膨胀了。再补充一个细节switch 支持 String 是 JDK 7 引入的底层其实编译成了两次 switch——先对字符串的 hashCode 做第一次 switch再对可能哈希冲突的字符串用equals做第二次比较。字节码长度立刻翻倍这也是务实的工程取舍。5.3 try-catch-finally 编译后的异常表finally 为什么会被复制多份异常处理在字节码里不是靠指令跳来跳去实现的而是靠一张异常表。异常表每条记录有四列.start_pc、.end_pc、.handler_pc、.catch_type。简单说就是从指令偏移 start 到 end 之间如果抛出指定类型的异常就跳到 handler 处理。try 块编译后并不是一个指令块而是一段有“保护范围”的指令区间。异常表告诉 JVM一旦在区间内发生异常该去哪里处理。catch_type 为 0 时表示“捕获所有异常”也就是 finally 的兜底逻辑。更匪夷所思的是finally 块里的代码会被 javac 复制多份插入到每个可能的退出点try 正常结束前的出口、每个 catch 处理完之后的出口、以及一个“捕获一切异常并重新抛出”的隐藏 handler 出口。这就是为什么 finally 无论如何都会执行——因为它根本不是一个统一调度的代码块而是被“物理粘贴”到了所有路径上。很早的 JDK 版本里 finally 靠jsr/ret指令实现JDK 1.6 之后这套指令被废弃复制多份的方式成为主流。5.4 泛型桥方法编译器偷偷帮你补的“中间人”泛型在 Java 里是“类型擦除”的源码里看到的ListString运行时类里其实是List。但擦除带来一个问题如果子类实现了一个泛型接口擦除之后签名对不上多态就失效了。看这个例子public class IntegerBox implements ComparableInteger { Override public int compareTo(Integer other) { return 0; } }源码里你写的compareTo(Integer)擦除后还是compareTo(Integer)但Comparable接口擦除后的方法签名是compareTo(Object)——签名不一致那通过接口调用时怎么跳到你的实现javac 的解决办法是生成一个桥方法。用javap -p看一眼这个类会发现编译器偷偷加了一个方法compareTo(Object)它会把参数强转成Integer然后调用你写的compareTo(Integer)。这个方法带有ACC_BRIDGE和ACC_SYNTHETIC标志源码里根本看不到。这也是为什么你在某些框架的反射 API、动态代理、通过字节码增强生成的类里会看到一些奇怪的合成方法——“谁往这个类里加了方法”答案往往是编译器的桥方法。最后说点我个人的实操建议。看字节码不是面试前临时磨枪而应该养成习惯遇到语法糖、遇到诡异反序列化问题、遇到“我明明重写了方法但没被调用”的困惑第一反应不再是臆想而是javap -c -p直接打开现场看一眼。这个方法几乎不花成本却能把很多玄学问题变成清清楚楚的工程细节。JDK 自带的 javap 够用了进阶之后可以再去接触 ASM、Byte Buddy 这类字节码操作框架它们就是建立在你今天看到的这些结构之上的。字节码不难难的是愿意弯下腰去看一眼。你写下的每一行 Java其实都有一份精确到字节的设计图只看你想不想翻开。
返回列表