
Java 虚拟机Java Virtual MachineJVM是 Java 生态中最基础、也最核心的一层基础设施。无论是面试、性能调优、线上故障排查还是日常写出更高质量的代码深入理解 JVM 都是绕不开的一步。本文以一张 JVM 思维脑图为索引从类加载、运行时数据区、垃圾回收到执行引擎与调优实战带你系统拆解 JVM 的核心原理力争做到 2 万字的深度详解。1. JVM 全景思维脑图下面这张脑图是全文的知识地图建议先整体浏览一遍再带着问题进入后续章节。JVM 可以分为三大子系统类加载子系统、运行时数据区、执行引擎。mindmap root((JVM 核心原理)) 类加载子系统 加载 Loading 通过类全限定名获取二进制字节流 字节流转化为方法区运行时结构 在堆中生成 Class 对象入口 连接 Linking 验证 Verify 文件格式验证 元数据验证 字节码验证 符号引用验证 准备 Prepare 为静态变量分配内存并赋零值 static final 属性直接赋常量值 解析 Resolve 符号引用替换为直接引用 初始化 Initialize 执行 clinit 方法 主动引用与被动引用 运行时数据区 程序计数器 线程私有 当前线程执行字节码的行号指示器 唯一无 OutOfMemoryError 的区域 虚拟机栈 栈帧 Stack Frame 局部变量表 操作数栈 动态链接 方法返回地址 本地方法栈 native 方法服务 堆 Heap 几乎所有的对象实例 新生代与老年代 对象分配过程 方法区 Method Area 类型信息、常量、静态变量 运行时常量池 元空间 MetaSpace 执行引擎 解释器 Interpreter 逐条解释字节码执行 即时编译器 JIT 热点探测 分层编译 逃逸分析 垃圾回收 GC 可达性分析算法 标记-清除 标记-复制 标记-整理 分代收集 常见垃圾收集器 内存模型 JMM 主内存与工作内存 volatile happens-before有了这张全景图下面我们逐层深入。建议阅读过程中随时回看这张脑图确认自己当前处在 JVM 知识体系的哪个分支上。2. JVM 是什么从“一次编写到处运行”说起Java 官方那句著名的口号“Write Once, Run Anywhere”翻译过来就是“一次编写到处运行”。这句话赖以成立的技术基础正是 Java 虚拟机。2.1 为什么需要虚拟机C、C 这类语言在编译后会直接生成依赖特定操作系统和 CPU 指令集的机器码。这意味着同一份源代码在 Windows、Linux、macOS 上往往需要分别编译甚至需要针对不同的 CPU 架构产出不同版本。Java 则采用了完全不同的思路源代码先被编译成一种与平台无关的中间表示也就是字节码Bytecode再由各个平台上的 JVM 负责解释执行或编译执行这些字节码。换句话说Java 程序真正的“运行环境”不是操作系统而是 JVM。只要目标平台上有对应实现的 JVM同一份字节码就能运行。平台差异由 JVM 去适配和屏蔽开发者只需要面对一套统一的字节码指令集。2.2 JVM、JRE、JDK 三者的关系这三个概念经常被混用但它们的边界其实非常清晰JVMJava 虚拟机负责执行字节码是整套体系的运行核心。JREJava Runtime EnvironmentJava 运行环境等于 JVM 加上 Java 核心类库例如 java.lang、java.util 等。JDKJava Development KitJava 开发工具包等于 JRE 再加上编译工具 javac、调试工具、监控工具等。可以用一句简单的话来概括JDK 包含 JREJRE 包含 JVM。开发时用 JDK运行时用 JRE真正干活的是 JVM。2.3 JVM 的跨语言平台属性JVM 并不只服务于 Java 语言。任何一门语言只要能够编译成符合规范的 Class 字节码文件就可以运行在 JVM 上。这催生了一批运行在 JVM 上的语言例如 Kotlin、Scala、Groovy、Clojure 等。从这个角度看JVM 更像一个通用的“字节码运行平台”Java 只是它最主流的使用者。理解这一点对后面理解类加载器、字节码结构等内容非常有帮助。3. JVM 整体架构三大子系统的分工虽然不同版本的 HotSpot 在实现细节上不断演进但 JVM 的宏观架构一直保持着高度稳定。整体可以分为三条主线类加载子系统负责把 Class 文件加载到内存中转化成可以被虚拟机使用的数据结构。运行时数据区JVM 运行时使用的各种内存空间的统称包括堆、栈、方法区等是对象和数据的实际存放地。执行引擎负责真正执行字节码包括解释器、即时编译器以及垃圾回收器等。此外JVM 还通过本地方法接口JNI与操作系统交互底层的本地方法库提供操作系统能力。整个过程可以描述为类加载子系统把字节码搬运到运行时数据区执行引擎读取字节码指令并执行执行过程中不断与运行时数据区交互产生或回收对象。理解这套“搬运、存放、执行”的分工是后续所有章节的框架。接下来我们首先进入类加载子系统。4. 类加载子系统Class 文件如何进入内存类加载子系统是 JVM 的“入口”。它的职责非常明确将描述类的数据从 Class 文件加载到内存并对数据进行校验、转换解析和初始化最终形成可以被虚拟机直接使用的 Java 类型。4.1 类的生命周期一个类从被加载到虚拟机内存开始到卸载出内存为止整个生命周期包括以下阶段加载Loading验证Verification准备Preparation解析Resolution初始化Initialization使用Using卸载Unloading其中加载、验证、准备、初始化、卸载这五个阶段的顺序是确定的但解析阶段比较特殊。解析通常发生在初始化之前但也可能在初始化之后开始这是为了支持 Java 语言的运行时绑定特性即动态绑定。4.2 加载阶段做了什么加载阶段是“类加载”这个动作的起点。JVM 规范对这一阶段的要求很宽松只规定了三件必须完成的事情通过类的全限定名获取定义此类的二进制字节流。将这个字节流所代表的静态存储结构转化为方法区中的运行时数据结构。在 Java 堆中生成一个代表该类的 java.lang.Class 对象作为方法区中该类数据的外部访问入口。这三点中最值得关注的是第一条“通过类的全限定名获取二进制字节流”。规范没有限定字节流必须来自本地 Class 文件这就给类加载的灵活性留下了巨大空间可以从 ZIP 包中读取JAR、WAR可以从网络中获取可以在运行时动态生成甚至可以从数据库中读取。动态代理、各种 AOP 框架之所以能实现正是利用了这一点。4.3 验证阶段保证安全的第一道防线验证阶段是连接阶段的第一步目的是确保 Class 文件中的字节流符合 JVM 规范的要求不会危害虚拟机自身的安全。毕竟 Class 文件未必都是由 javac 编译产生的也可能是经过篡改的甚至是手工构造的。验证阶段大致包括四个动作文件格式验证检查魔数、版本号、常量池类型、索引指向等确保字节流是合法的 Class 文件。只有通过文件格式验证字节流才会进入方法区存储。元数据验证对字节码描述的语义进行校验比如这个类是否有父类、父类是否允许被继承、是否继承了 final 类、抽象方法是否被实现等。字节码验证这是整个验证中最复杂的一步通过数据流和控制流分析确定程序语义是合法的不会出现跳转到非法指令等情况。符号引用验证发生在解析阶段之前检查常量池中的各种符号引用是否能够被正确解析比如符号引用中的类、字段、方法是否存在访问权限是否合法。验证阶段虽然耗费一定资源但它是保证 JVM 安全性的重要屏障。如果跳过验证恶意字节码可能破坏虚拟机内部结构。4.4 准备阶段默认值先行准备阶段是为类的静态变量分配内存并设置初始零值Default Zero Value的阶段。这里“初始零值”指的并不是开发者代码里的初始化值而是类型对应的默认值整型为 0浮点型为 0.0布尔型为 false引用类型为 null。需要特别注意的是有一种例外情况。对于被 static final 修饰的常量也就是程序中的常量字段如果它的值在编译期就能确定那么在准备阶段就会直接被赋值为代码中指定的字面量。看下面的例子public class Demo { // 准备阶段 value 被赋值为 0 static int value 123; // 准备阶段 CONSTANT 直接被赋值为 固定值 static final String CONSTANT 固定值; }第一个变量 value 在准备阶段是 0直到初始化阶段才会变成 123而 CONSTANT 因为编译期就能确定其值准备阶段就直接得到了字符串字面量。4.5 解析阶段符号引用到直接引用解析阶段是 JVM 将常量池中的符号引用替换为直接引用的过程。所谓符号引用可以理解为一种用符号来描述所引用目标的引用与虚拟机内存布局无关而直接引用则可以是直接指向目标的指针、相对偏移量或能间接定位到目标的句柄。通俗地讲符号引用像是“某个地址上住着张三”而直接引用就是“张三具体住在 3 栋 502 室”。解析动作主要针对类或接口、字段、方法、接口方法、方法类型、方法句柄和调用点限定符等几类符号引用。解析的结果是把这些符号引用映射到方法区中真实的内存位置。4.6 初始化阶段真正执行代码初始化阶段是类加载过程的最后一步也是真正开始执行类中定义的 Java 程序代码的阶段。在这个阶段JVM 会执行类的构造器方法 clinit 方法。clinit 方法是由编译器自动收集类中所有静态变量的赋值动作和静态代码块中的语句合并产生的。收集的顺序由语句在源文件中的出现顺序决定public class Demo { static { value 2; // 先执行 } static int value 1; // 后执行最终 value 等于 1 }这段代码最终 value 的值是 1而不是 2因为静态变量赋值语句出现在静态代码块之后。这个顺序问题在阅读框架源码时非常常见需要格外注意。与实例构造器不同clinit 方法不需要显式调用父类的 clinitJVM 会保证在子类 clinit 执行之前父类的 clinit 已经执行完毕。因此JVM 中第一个被执行的 clinit 方法一定是 java.lang.Object 的 clinit。4.7 类加载器与双亲委派机制类加载器是“加载”阶段的具体执行者。JVM 提供了三类主要的类加载器启动类加载器Bootstrap ClassLoader使用 C 实现负责加载 JAVA_HOME 下 lib 目录中的核心类库比如 rt.jar、resources.jar。它不是 java.lang.ClassLoader 的子类所以在 Java 代码中无法直接获取其引用。扩展类加载器Extension ClassLoader负责加载 lib/ext 目录或系统变量指定的目录中的类库Java 9 之后逐步被平台类加载器所取代。应用程序类加载器Application ClassLoader负责加载用户类路径ClassPath上的类库是程序中默认的类加载器。这三者之间存在一种层次关系也就是著名的双亲委派模型。一个类加载器收到类加载请求后并不会自己先去加载而是把这个请求委派给父类加载器去完成只有父加载器无法完成时子加载器才会尝试自己加载。可以用一段简化的代码来描述这个逻辑protected Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 先检查是否已经加载过 Class? c findLoadedClass(name); if (c null) { try { if (parent ! null) { c parent.loadClass(name, false); } else { c findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 父加载器无法加载继续向下走 } if (c null) { // 父加载器无法完成自己尝试加载 c findClass(name); } } if (resolve) { resolveClass(c); } return c; } }双亲委派模型的核心价值在于保证 Java 核心类库的稳定与安全。比如有人恶意编写了一个名为 java.lang.String 的类按照双亲委派机制这个请求会一直向上委派到启动类加载器而启动类加载器会优先加载真正的 java.lang.String恶意类不会生效。同时双亲委派也保证了同一个类不会被多次重复加载保证了 Java 类型体系中类型的一致性。4.8 破坏双亲委派的典型案例双亲委派机制并非绝对不可打破在 Java 的历史上出现过几个著名的“破坏”场景历史兼容原因JDK 1.2 之前类加载器的抽象设计存在缺陷后来引入双亲委派时为了兼容旧代码ClassLoader 中保留了子类可以覆盖 loadClass 方法的可能。如今一些自定义类加载器仍然选择重写 loadClass 来实现自己的加载逻辑。SPI 机制JDBC、JNDI 等 SPI 接口由核心类库定义但具体实现由第三方厂商提供。核心类库由启动类加载器加载但它无法加载用户类路径下的实现类因此引入了线程上下文类加载器来解决反向委托的问题。JDBC 的驱动加载就是典型例子。追求热部署OSGi、Tomcat 等场景需要每个模块有独立的类加载器各自加载自己的类实现模块隔离和热替换。Tomcat 会为每个 Web 应用创建独立的 WebAppClassLoader让不同应用的同名类互不干扰。理解了双亲委派的设计初衷和它的例外场景才能真正理解 Java 应用中复杂的类冲突问题比如常见的 NoClassDefFoundError、ClassCastException 等背后的原因。5. 运行时数据区JVM 的内存版图类加载完成之后字节码所要操作的数据都被放到了运行时数据区。这是 JVM 最重要的内存结构也是面试和故障排查的重中之重。根据是否线程共享运行时数据区可以分为线程私有程序计数器、虚拟机栈、本地方法栈。线程共享堆、方法区元空间。5.1 程序计数器程序计数器Program Counter Register是一块较小的内存空间可以看作当前线程所执行字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等功能都依赖它。在 JVM 的规范中程序计数器是唯一一个不会抛出 OutOfMemoryError 的内存区域。如果线程正在执行的是 Java 方法计数器记录的是虚拟机字节码指令的地址如果执行的是 native 方法计数器的值为空Undefined。由于每个线程都有自己的程序计数器它们之间互不影响这是线程切换后能够恢复到正确执行位置的基础。5.2 虚拟机栈虚拟机栈Java Virtual Machine Stack也是线程私有的它的生命周期和线程一致。虚拟机栈描述的是 Java 方法执行的内存模型每个方法在执行的同时都会创建一个栈帧Stack Frame用于存储局部变量表、操作数栈、动态链接、方法返回地址等信息。一个方法从调用到执行完成的过程就对应一个栈帧在虚拟机栈中从入栈到出栈的过程。在平时开发中最常见的内存异常有两种一种是线程请求的栈深度大于虚拟机允许的最大深度时会抛出 StackOverflowError典型场景是无终止条件的递归另一种是虚拟机栈允许动态扩展时如果扩展时无法申请到足够内存会抛出 OutOfMemoryError。单线程场景下无论是栈帧过大还是栈容量过小最终往往表现为 StackOverflowError。局部变量表是栈帧中最重要的部分之一用于存放方法参数和方法内部定义的局部变量。局部变量表以变量槽Slot为最小单位基本类型在大多数情况下占用 1 个 Slotlong 和 double 占用 2 个 Slot引用类型占用 1 个 Slot。正因为局部变量表在编译期就能确定大小并且只有在方法执行期间才存在所以这些变量不需要像堆中的对象那样参与垃圾回收。5.3 本地方法栈本地方法栈Native Method Stack与虚拟机栈的作用非常相似两者的区别在于虚拟机栈为 JVM 执行 Java 方法服务而本地方法栈为虚拟机使用到的 native 方法服务。native 方法是指用 Java 之外的语言编写、通过 JNI 调用的方法。不少虚拟机已经把本地方法栈和虚拟机栈合并实现HotSpot 就是如此。与虚拟机栈类似本地方法栈也会在栈深度溢出或扩展失败时抛出 StackOverflowError 和 OutOfMemoryError。只是对多数 Java 开发者来说直接和本地方法栈打交道的机会并不多。5.4 堆JVM 中最大的内存区域堆Heap是 JVM 管理的最大一块内存区域被所有线程共享在虚拟机启动时创建。堆的唯一目的就是存放对象实例几乎所有的对象实例以及数组都在这里分配内存。随着逃逸分析、标量替换等优化技术的发展栈上分配已经变得可能但堆仍然是对象最主要的内存归宿。堆也是垃圾收集器管理的主要区域因此很多时候又被称为 GC 堆。从垃圾回收的角度堆通常被划分为新生代和老年代新生代又分为 Eden 区和两块 Survivor 区From、To。大多数新创建的对象首先在 Eden 区分配经历多次 Minor GC 后仍然存活的对象会逐步晋升到老年代。老年代存放生命周期较长的大对象、晋升对象以及大对象直接分配的情况。堆中对象分配的基本过程可以简化为优先在 Eden 区分配对象较大时可能直接进入老年代Minor GC 发生时Eden 区中存活的对象被复制到 Survivor 区并增加年龄当年龄达到阈值后对象进入老年代。堆可以通过 -Xmx 和 -Xms 等参数控制当堆中没有内存可以完成实例分配且无法再扩展时会抛出 OutOfMemoryError。5.5 方法区与元空间方法区Method Area和堆一样是线程共享的内存区域用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。方法区在逻辑上属于堆的一部分但很多人习惯把它称为“非堆”以和真正存放对象的堆区分开。HotSpot 在 JDK 8 之前使用永久代PermGen实现方法区但从 JDK 8 开始永久代被元空间Metaspace取代。元空间使用本地内存而不是虚拟机内存因此其可用空间不再受 -XX:MaxPermSize 这样针对永久代的参数限制而是受到操作系统可用内存的限制。元空间在一定程度上避免了永久代更容易出现的 OutOfMemoryError但仍然可能因为加载大量动态生成的类而耗尽本地内存。运行时常量池Runtime Constant Pool是方法区的重要组成部分。Class 文件中除了类的版本、字段、方法、接口等信息外还有一项常量池表用于存放编译期生成的各种字面量和符号引用。类加载后常量池表的内容会进入方法区的运行时常量池。运行时常量池相对于 Class 文件常量池的一个重要特性是具备动态性例如 String 的 intern 方法就可以在运行期将新的字符串放入常量池。6. 执行引擎字节码如何被执行执行引擎是 JVM 的核心组成部分之一它的任务就是负责执行编译后产生的字节码指令。执行引擎执行指令的最小单位是字节码指令这些指令类似于汇编指令但针对 JVM 抽象指令集设计。执行引擎在执行过程中需要完成对栈帧的操作、对象的创建与回收、方法调用与返回等动作。6.1 解释器与即时编译器早期的 JVM 只有解释器逐条取指、解析并执行字节码这种方式启动快但重复执行的代码解释效率较低。随着技术的发展HotSpot 引入了即时编译器JIT把热点代码编译成本地机器码后直接执行从而大幅提升运行效率。HotSpot 中同时存在解释器和编译器并根据热点探测的结果决定哪些代码值得编译这就是“混合模式”。所谓热点探测通常采用“回边计数器”和“方法调用计数器”来判断某段代码是否多次执行。当执行次数超过阈值时会触发即时编译。编译后的代码会被缓存后续调用直接执行本地代码。为了在启动速度和峰值性能之间取得平衡HotSpot 还引入了分层编译低层级用解释执行或简单的即时编译快速响应随着代码热度的提升慢慢切换到更深层次、优化更充分的编译策略例如 C2 编译器。6.2 逃逸分析逃逸分析是 JIT 的一项重要优化。它分析对象的作用域判断对象是否会逃逸出方法或线程。如果对象不会逃逸就可以进行更激进的优化例如栈上分配、标量替换和同步消除。栈上分配让部分对象不再必须进入堆从而减轻 GC 压力标量替换把对象拆成基本成员变量进一步减少对象创建。逃逸分析让“堆中分配对象”这条经验规则出现了一些例外。7. 垃圾回收从“要不要回收”到“怎么回收”垃圾回收Garbage CollectionGC是 JVM 内存管理的核心能力。Java 区别于 C、C 的重要一点就是开发者不再需要手动释放内存而是由 GC 自动完成。理解 GC 的原理是进行 JVM 调优和排查内存问题的基础。7.1 如何判断对象已经不再使用垃圾回收的第一步是找出哪些对象已经“死亡”。最主流的方法是可达性分析算法从一组被称为 GC Roots 的根对象出发根据引用关系向下搜索搜索路径称为引用链。如果一个对象与 GC Roots 之间不存在任何引用链就认为这个对象是不可达的可以判定为可回收对象。常见的 GC Roots 包括虚拟机栈中引用的对象、方法区静态属性引用的对象、常量池中引用的对象、本地方法栈中引用的对象以及 JVM 内部引用。对象死亡之前还要经过两次标记过程第一次是没有与 GC Roots 建立引用链而被标记并进入一个筛选过程如果对象覆盖了 finalize 方法且尚未被调用它会被放入队列等待低优先级线程执行此时对象还有机会通过重新建立引用链“自我救赎”但这种方法已经被普遍认为不可靠不推荐使用。7.2 三种基础垃圾回收算法标记-清除、标记-复制和标记-整理是三种最基础的垃圾回收算法。标记-清除先标记所有需要回收的对象再统一回收。优点是简单缺点是会产生大量内存碎片。标记-复制把可用内存划分为两个等量区域每次只使用其中一块回收时把存活对象复制到另一块再整体清理使用过的那块。优点是吞吐量高、无碎片缺点是内存利用率只有一半主要适用于对象存活率低的新生代。标记-整理标记存活对象后让所有存活对象向一端移动再清理边界以外的内存。优点是解决了碎片问题缺点是移动对象需要成本适用于老年代。7.3 分代收集理论JVM 之所以把堆分为新生代和老年代正是为了针对不同区域的特性采用不同算法。新生代对象大多朝生夕灭适合标记-复制老年代对象存活时间长适合标记-清除或标记-整理。分代收集的前提是“弱分代假说”和“强分代假说”绝大多数对象都是朝生夕灭的经历越多次 GC 依然存活的对象越难回收因此需要在不同区域采用不同策略。7.4 常见垃圾收集器HotSpot 提供了一组垃圾收集器常见的有面向新生代的 Serial、ParNew、Parallel Scavenge面向老年代的 Serial Old、Parallel Old、CMS以及具有里程碑意义的 G1。JDK 11 之后ZGC 等低延迟收集器也开始逐步成熟。Serial / Serial Old单线程收集适合客户端模式和单核环境简单高效。Parallel Scavenge / Parallel Old吞吐量优先适合在后台计算型应用中追求最大吞吐。CMS老年代收集器以最短停顿时间为目标采用标记-清除算法会产生碎片。G1把堆划分为多个 Region在延迟和吞吐之间取得较好平衡逐步替代 CMS 成为主流。选择收集器没有绝对的标准答案关键要看业务对吞吐、延迟、内存占用等指标的具体要求。8. Java 内存模型 JMM并发视角JVM 不仅管理内存分配和回收还定义了多线程环境下的内存可见性规则。Java 内存模型Java Memory ModelJMM是一种抽象模型规定了线程、主内存和工作内存之间的交互规则。每个线程都有自己的工作内存所有操作先在工作内存中完成再同步回主内存这就会导致不同线程之间看到的变量值不一致。volatile 是 JMM 中最常用的轻量级同步机制。被 volatile 修饰的变量在每次被线程访问时都会强制从主内存重新读取修改后也会立即写回主内存从而保证可见性。它还能禁止指令重排序但并不能保证复合操作的原子性。happens-before 规则则用来描述两个操作之间的偏序关系例如程序次序规则、锁规则、volatile 规则和传递性规则等。理解 JMM是写出正确并发代码、理解 synchronized 和 volatile 差异的前提。9. 调优实战与常用工具JVM 知识最终要落到定位问题和调优上。常用的 JDK 工具可以帮助我们观察运行时状态jps 查看 Java 进程jstat 查看垃圾回收和类加载统计jmap 导出堆快照jstack 查看线程状态jinfo 查看和修改参数。结合 VisualVM、Arthas、MAT 等工具可以更直观地分析内存泄漏、死锁、频繁 Young GC 等问题。调优时通常遵循“先观察、再定位、后调整”的思路而不是盲目堆砌参数。常见关注点包括新生代和老年代比例、对象晋升年龄、GC 频率与耗时、堆大小与元空间大小。一次合理的调优应当以监控数据为依据并对改动做充分验证。10. 总结JVM 是一个非常庞大的体系但核心脉络并不复杂类加载子系统负责把 Class 文件转化为内存中的类型数据运行时数据区为程序提供存放对象和执行方法的物理内存执行引擎解释或编译字节码并让程序真正跑起来垃圾回收自动管理堆内存而 JMM 则从并发视角约束了多线程之间的可见性与有序性。把这五条主线串起来就等于掌握了一张完整的 JVM 知识地图。学习 JVM 不必一开始就陷入每个参数的细节更好的路径是先用一张脑图建立全局认知再沿着类加载、内存结构、执行引擎、垃圾回收和并发模型逐层深入最后通过实际调优和线上问题把知识内化成能力。本文的内容可以作为这套学习路径的起点后续还可以进一步阅读《深入理解 Java 虚拟机》以及 OpenJDK 源码做更深入的研究。