JVM 详解与调优
Java 虚拟机 (JVM - Java Virtual Machine) 是 Java 程序运行的基石,它是一个抽象的计算机器,负责将 Java 字节码 (
.class文件) 翻译成机器指令并执行。JVM 屏蔽了底层操作系统的差异,实现了 Java 的“一次编译,到处运行” (Write Once, Run Anywhere) 的跨平台特性。深入理解 JVM 的架构、内存管理和垃圾回收机制,对于编写高性能、稳定可靠的 Java 应用程序至关重要,也是进行系统调优的基础。
核心思想:JVM 是 Java 程序的运行时环境,通过管理内存、执行字节码和进行垃圾回收,实现跨平台运行。JVM 调优的核心在于理解内存区域、垃圾回收器行为,并根据应用特性选择合适的参数,以平衡吞吐量、延迟和内存消耗。
一、JVM 架构概述
JVM 架构主要由以下几个核心组件构成:
graph TD
A[Class Loader Subsystem - <br>类加载子系统] --> B[Runtime Data Areas - <br>运行时数据区]
B --> C[Execution Engine - 执行引擎]
C --> D[Native Method Interface - <br>本地方法接口]
D --> E[Native Method Libraries - <br>本地方法库]
subgraph User Defined Class
UserClass["Java Code (.java)"] --> Bytecode["Bytecode (.class)"]
end
Bytecode --> A
1.1 类加载子系统 (Class Loader Subsystem)
负责在程序运行时动态加载 .class 文件到 JVM 的内存中。它包括三个主要阶段:
加载 (Loading):
- 通过类的完全限定名获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构。
- 在内存中生成一个代表该类的
java.lang.Class对象,作为方法区这个类的各种数据的访问入口。 - 类加载器有:启动类加载器 (Bootstrap ClassLoader)、扩展类加载器 (Extension ClassLoader)、应用程序类加载器 (Application ClassLoader),它们遵循双亲委派模型。
链接 (Linking):
- 验证 (Verification):确保
.class文件的字节流符合 JVM 规范,不会危害 JVM 的安全。 - 准备 (Preparation):为类的静态变量分配内存,并初始化为默认值(如
int为 0,boolean为false,引用类型为null)。此时不会执行任何 Java 代码。 - 解析 (Resolution):将常量池中的符号引用(如类名、方法名、字段名)转换为直接引用(内存地址)。
- 验证 (Verification):确保
初始化 (Initialization):
- 执行类构造器
<clinit>()方法(编译器自动收集类中所有静态变量的赋值动作和静态代码块中的语句合并生成)。 - 这是类加载过程的最后一步,此时才真正开始执行类中定义的 Java 代码。
- 执行类构造器
1.2 运行时数据区 (Runtime Data Areas)
这是 JVM 内存管理的核心,分为多个区域,有的随 JVM 进程启动而存在,有的随线程创建而销毁。
graph LR
A[Runtime Data Areas] --> B[Method Area - 方法区]
A --> C[Heap - 堆]
A --> D[JVM Stacks - JVM 栈]
A --> E[Program Counter Register - 程序计数器]
A --> F[Native Method Stacks - 本地方法栈]
B & C -- Shared by All Threads --> Z[JVM Process]
D & E & F -- Private to Each Thread --> Y[Thread Instance]
方法区 (Method Area):
- 所有线程共享。
- 用于存储已被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码等。
- 在 JDK 8 之前,HotSpot JVM 中方法区通常被称为永久代 (PermGen)。
- 在 JDK 8 及之后,永久代被元空间 (Metaspace) 取代,元空间直接使用本地内存,不再在 JVM 堆中。
堆 (Heap):
- 所有线程共享。
- JVM 管理的最大一块内存区域,用于存储对象实例和数组。
- 是垃圾回收器 (Garbage Collector) 主要管理的地方,因此又被称为“GC 堆”。
- 堆通常划分为新生代 (Young Generation) 和老年代 (Old Generation)。
JVM 栈 (JVM Stacks):
- 每个线程私有。
- 生命周期与线程相同。
- 每个方法执行时会创建一个栈帧 (Stack Frame),用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 方法调用和返回的过程,就对应着栈帧的入栈和出栈。
程序计数器 (Program Counter Register):
- 每个线程私有。
- 一块较小的内存空间,用于存储当前线程执行的字节码的行号指示器。
- 在多线程环境下,当线程被切换回来时,PC 寄存器能知道上次程序执行到哪里。
- 是 JVM 中唯一一个没有规定任何
OutOfMemoryError情况的区域。
本地方法栈 (Native Method Stacks):
- 每个线程私有。
- 与 JVM 栈类似,但它为 JVM 执行 Native 方法 (即非 Java 代码,通常用 C/C++ 编写) 提供服务。
1.3 执行引擎 (Execution Engine)
负责执行字节码指令。它包含:
- 解释器 (Interpreter):逐行解释执行字节码。启动速度快,但执行效率低。
- 即时编译器 (JIT Compiler - Just-In-Time Compiler):
- 在运行时将热点代码(被频繁执行的代码)直接编译成本地机器码,然后缓存起来,下次直接执行机器码,提高执行效率。
- 分为 C1 编译器 (Client Compiler) 和 C2 编译器 (Server Compiler)。
- C1 编译器:优化程度低,编译速度快,适用于启动速度要求高的客户端应用。
- C2 编译器:优化程度高,编译速度慢,适用于需要长时间运行且对峰值性能要求高的服务器应用。
- 分层编译 (Tiered Compilation):在 JDK 7u4 之后默认启用,结合了 C1 和 C2 的优点,在程序启动初期用 C1 快速编译,随后对热点代码用 C2 深度优化。
- 垃圾回收器 (Garbage Collector):管理堆内存的自动回收。
1.4 本地方法接口 (Native Method Interface) & 本地方法库 (Native Method Libraries)
- 本地方法接口 (JNI - Java Native Interface):允许 Java 代码与用其他语言(如 C/C++)编写的本地应用程序和库进行交互。
- 本地方法库:JNI 调用本地方法时需要使用的库。
二、垃圾回收 (Garbage Collection - GC)
JVM 的核心特性之一是自动内存管理,即垃圾回收。它负责自动发现并回收不再使用的对象所占用的内存。
2.1 如何判断对象“死亡”
引用计数法 (Reference Counting):
- 给每个对象添加一个引用计数器,每当有一个地方引用它,计数器就加 1;引用失效时,计数器就减 1。任何时刻计数器为 0 的对象就是不可能再被使用的。
- 缺点:难以解决对象之间相互循环引用的问题。
- Java 虚拟机没有采用此方法。
可达性分析算法 (Reachability Analysis):
- 以一系列被称为 GC Roots 的对象作为起点,从这些节点开始向下搜索,搜索走过的路径称为引用链 (Reference Chain)。
- 当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的,可以被回收。
- GC Roots 包括:
- 虚拟机栈中(栈帧中的局部变量表)引用的对象。
- 本地方法栈中 JNI 引用的对象。
- 方法区中静态属性引用的对象。
- 方法区中常量引用的对象。
- 所有被
synchronized持有的对象。
2.2 垃圾回收算法
标记-清除算法 (Mark-Sweep):
- 标记:从 GC Roots 开始标记所有存活对象。
- 清除:遍历整个堆,回收所有未被标记的对象所占用的内存。
- 缺点:
- 效率问题:标记和清除过程效率都不高。
- 空间问题:产生大量不连续的内存碎片,可能导致后续需要较大连续内存的对象无法分配,不得不提前触发另一次 GC。
复制算法 (Copying):
- 将可用内存分为大小相等的两块,每次只使用其中一块。
- 当这块内存用完时,将存活对象复制到另一块内存上,然后清除已使用内存块的所有内容。
- 优点:
- 不会产生内存碎片。
- GC 效率高。
- 缺点:
- 内存利用率只有 50%。
- 不适用于对象存活率高的老年代。
- 应用:常用于新生代(因为新生代对象朝生夕死,存活率低)。HotSpot JVM 的新生代通常采用分代复制算法 (Generational Copying),将新生代分为一个
Eden区和两个Survivor区 (S0,S1),比例通常为 8:1:1。每次只使用Eden和一个Survivor,回收时将Eden和S0中存活的对象复制到S1,清空Eden和S0。
标记-整理算法 (Mark-Compact):
- 标记:与标记-清除算法一样,标记出所有存活对象。
- 整理:将所有存活对象向一端移动,然后直接清理掉端边界以外的内存。
- 优点:
- 解决了内存碎片问题。
- 适用于对象存活率高的老年代。
- 缺点:
- 效率比复制算法低。
- 移动对象会触发 Stop-The-World (STW),暂停所有用户线程。
分代收集算法 (Generational Collection):
- 现代 JVM 垃圾回收器普遍采用的策略。
- 基于“弱分代假说”:绝大多数对象都是朝生夕死的。
- 将堆内存划分为新生代 (Young Generation) 和老年代 (Old Generation)。
- 新生代:通常使用复制算法,回收频率高,每次回收 (Minor GC) 大部分对象都会死亡。
- 老年代:通常使用标记-整理或标记-清除(与整理组合)算法,回收频率低,每次回收 (Major GC / Full GC) 会进行全面扫描。
- 当一个对象在新生代经历多次 GC 后仍然存活(默认 15 次,可以通过
-XX:MaxTenuringThreshold设置),就会被晋升 (Promotion) 到老年代。
2.3 常见的垃圾回收器
HotSpot JVM 提供了多种垃圾回收器,各有优缺点,适用于不同的应用场景。
Serial 收集器:
- 最古老、最简单的收集器。
- 单线程工作,在进行 GC 时,必须暂停所有用户线程 (STW)。
- 适用于客户端模式下内存不大的场景。
ParNew 收集器:
- Serial 收集器的多线程版本。
- 新生代收集器,多线程并行回收,也会 STW。
- 常与 CMS 收集器配合使用。
Parallel Scavenge 收集器:
- 新生代收集器,与 ParNew 类似,也是多线程并行回收。
- 目标是达到一个可控制的吞吐量 (Throughput)。
- 提供了
-XX:MaxGCPauseMillis(最大 GC 停顿时间) 和-XX:GCTimeRatio(GC 时间与总时间的比率) 参数来控制吞吐量和停顿时间。
Serial Old 收集器:
- Serial 收集器的老年代版本。
- 单线程、标记-整理算法。
- 主要用于客户端模式下的 JVM,或作为 CMS/Parallel Scavenge 收集器的后备方案。
Parallel Old 收集器:
- Parallel Scavenge 收集器的老年代版本。
- 多线程、标记-整理算法。
- 注重吞吐量,适用于重视吞吐量和 CPU 资源敏感的场景。
CMS (Concurrent Mark Sweep) 收集器:
- 目标是获取最短回收停顿时间 (Low Pause Time)。
- 采用“标记-清除”算法。
- 工作过程:
- 初始标记 (Initial Mark):短暂停顿,标记 GC Roots 能直接关联到的对象。
- 并发标记 (Concurrent Mark):与用户线程并发执行,从初始标记的对象开始遍历整个对象图。
- 重新标记 (Remark):短暂停顿,修正并发标记期间因用户程序运行而导致标记产生变动的对象。
- 并发清除 (Concurrent Sweep):与用户线程并发执行,清除已死亡对象。
- 优点:并发收集,停顿时间短。
- 缺点:
- 对 CPU 资源敏感。
- 无法处理浮动垃圾 (Floating Garbage)。
- 基于标记-清除算法,会产生内存碎片。
- 需要预留一部分空间供并发收集时使用。
- JDK 9 废弃,JDK 14 移除。
G1 (Garbage-First) 收集器:
- 分代收集器,但不再严格划分新生代和老年代,而是将堆划分为多个大小相等的 Region (区域)。
- G1 收集器可以建立可预测的停顿时间模型。
- 工作过程:
- 初始标记 (Initial Mark):短暂停顿,标记 GC Roots 能直接关联到的对象。
- 并发标记 (Concurrent Mark):与用户线程并发执行,扫描整个堆。
- 最终标记 (Final Mark):短暂停顿,处理并发阶段结束后仍遗留下来的或在并发标记期间被修改的对象。
- 筛选回收 (Evacuation):短暂停顿,对各个 Region 的回收价值和成本进行排序,根据用户期望的停顿时间,回收一部分 Region。G1 会优先回收那些回收价值最大的 Region (Garbage First)。
- 优点:
- 可预测的停顿时间。
- 区域化管理,避免全堆扫描。
- 既能处理大对象,又能处理碎片问题。
- JDK 9 默认垃圾回收器。
ZGC (Z Garbage Collector):
- JDK 11 引入,JDK 15 正式可用。
- 目标是实现极低停顿时间 (毫秒级甚至亚毫秒级),支持 TB 级别的堆内存。
- 采用并发标记、并发回收,几乎所有 GC 阶段都与用户线程并发执行。
- 基于着色指针 (Colored Pointers) 和读屏障 (Read Barrier) 技术。
- 优点:极低停顿,支持大堆。
- 缺点:对硬件要求高,对吞吐量有一定影响,且刚推出时兼容性不如 G1。
Shenandoah 收集器:
- JDK 12 引入,与 ZGC 类似,目标也是低停顿,主要由 Red Hat 开发。
- 采用并发整理、转发指针 (Forwarding Pointers) 和读屏障技术。
2.4 GC 日志分析
通过 -Xlog:gc* 或更早版本的 -XX:+PrintGCDetails 可以打印详细的 GC 日志,有助于理解 GC 行为和问题。
1 | # JDK 9+ |
三、JVM 内存参数调优
JVM 调优主要是调整堆内存、方法区大小,以及选择合适的垃圾回收器和其相关参数,以达到最佳的性能指标 (吞吐量、响应时间等)。
3.1 堆内存参数
-Xms<size>:设置 JVM 堆的初始分配大小。默认物理内存的 1/64。-Xmx<size>:设置 JVM 堆的最大分配大小。默认物理内存的 1/4。- 建议:
Xms和Xmx设置为相同值,可以避免 JVM 在运行时动态调整堆大小带来的额外开销。
- 建议:
-Xmn<size>:设置新生代的大小。- 建议:新生代设置为整个堆的 1/4 到 1/3。
-XX:NewRatio=<ratio>:设置新生代与老年代的比值。例如-XX:NewRatio=2表示新生代占 1,老年代占 2,新生代占整个堆的 1/3。-XX:SurvivorRatio=<ratio>:设置Eden区与一个Survivor区的比值。例如-XX:SurvivorRatio=8表示Eden:S0:S1 = 8:1:1。-XX:MaxTenuringThreshold=<threshold>:设置对象在新生代晋升老年代的年龄阈值。默认 15。
3.2 方法区/元空间参数
-XX:PermSize=<size>(JDK 8 之前):设置永久代初始大小。-XX:MaxPermSize=<size>(JDK 8 之前):设置永久代最大大小。-XX:MetaspaceSize=<size>(JDK 8 及之后):设置元空间初始大小。-XX:MaxMetaspaceSize=<size>(JDK 8 及之后):设置元空间最大大小。- 建议:根据应用加载的类数量进行调整,避免频繁的 Metaspace GC 导致停顿。
3.3 垃圾回收器选择与参数
根据应用特性选择合适的 GC 策略:
吞吐量优先 (Throughput First):适用于后台批处理、数据分析等不需要太关注停顿时间的场景。
- JVM 参数:
NewRatio、SurvivorRatio等调整分代比例,-XX:+UseParallelGC(新生代) 和-XX:+UseParallelOldGC(老年代)。 - 主要参数:
-XX:MaxGCPauseMillis(控制最大 GC 停顿时间,但可能牺牲吞吐量)、-XX:GCTimeRatio(GC 时间占总时间比)。
- JVM 参数:
低延迟/短停顿优先 (Low Latency First):适用于高并发、实时响应的 Web 应用、GUI 应用。
- JVM 参数:
- G1 收集器 (JDK 9+ 默认):
-XX:+UseG1GC-XX:MaxGCPauseMillis=<millis>:设置期望的最大 GC 停顿时间 (默认 200ms)。G1 会尽力尝试达到,但不保证。-XX:G1HeapRegionSize=<size>:设置 G1 Region 大小 (默认是根据堆大小自动调整)。
- ZGC/Shenandoah 收集器 (JDK 11+):
-XX:+UseZGC/-XX:+UseShenandoahGC- 通常不需要太多调优参数,因为它们设计目标就是极低停顿。
- G1 收集器 (JDK 9+ 默认):
- JVM 参数:
其他常用 GC 参数:
-XX:+PrintGCDetails(JDK 8) /-Xlog:gc*=info(JDK 9+):打印详细 GC 日志。-XX:+DisableExplicitGC:禁止显式调用System.gc()。-XX:+HeapDumpOnOutOfMemoryError:当发生 OOM 时,生成堆转储文件,便于事后分析。-XX:HeapDumpPath=<path>:指定堆转储文件路径。
3.4 调优流程
- 监控与基线测试:使用 JMX、JVisualVM、Arthas、GCViewer 等工具监控应用运行时的内存使用、GC 情况、CPU 负载。建立性能基线。
- 分析 GC 日志:通过 GC 日志分析 GC 频率、停顿时间、内存回收量,找出 GC 热点和瓶颈。
- 调整堆大小和比例:根据 GC 情况调整
Xms、Xmx、NewRatio、SurvivorRatio,目标是减少 Full GC 的发生,并优化 Minor GC 的效率。 - 选择和调整 GC 收集器:根据应用对吞吐量和延迟的需求,选择合适的收集器 (ParallelGC、G1、ZGC 等),并调整其特定参数。
- 代码层面优化:
- 对象生命周期管理:减少不必要的对象创建,尤其是循环内部的对象创建。
- 避免内存泄漏:及时释放不再使用的对象引用,尤其是在集合类、事件监听器中。
- 并发优化:合理使用并发工具类,减少锁竞争。
- NIO/零拷贝:在 I/O 密集型应用中使用 NIO 或零拷贝技术。
- 重复测试与分析:每次参数调整后,都需要重新进行基线测试和 GC 日志分析,对比性能指标,直至达到满意的效果。
四、JVM 监控与分析工具
- JVisualVM (Java VisualVM):
- JDK 自带的 GUI 工具,提供性能分析、内存分析、线程分析、GC 监控等功能。
- 可以连接本地或远程 JVM 进程。
- JConsole (Java Monitoring and Management Console):
- JDK 自带的 GUI 工具,用于监控 JVM 的内存、线程、类加载、CPU 使用率等。
- JStack:
- JDK 自带的命令行工具,用于打印指定 Java 进程的线程栈信息,常用于分析死锁、无限循环等问题。
- JMap:
- JDK 自带的命令行工具,用于生成堆内存快照 (Heap Dump),可以分析内存泄漏。
- Arthas:
- 阿里巴巴开源的 Java 诊断工具,功能强大,支持在线排查、诊断、监控 JVM。
- GCViewer:
- GC 日志分析工具,将 GC 日志可视化,方便分析 GC 行为。
- YourKit / JProfiler:
- 商业级性能分析工具,功能更强大、更全面。
五、总结
JVM 是 Java 应用程序的运行时核心,深入理解其架构、内存模型和垃圾回收机制是 Java 工程师进阶的必经之路。JVM 调优并非一蹴而就,它是一个持续的、迭代的过程,需要结合实际应用场景、性能目标,通过监控、分析、调整的循环来进行。掌握 JVM 的原理和调优技巧,不仅能够解决生产环境中的各种性能问题,还能帮助我们编写出更高效、更健壮的 Java 应用程序。
