ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

JVM 虚拟机图文详解:真香!秒懂!一点都不难!

JVM 虚拟机图文详解:真香!秒懂!一点都不难! 摘要本文用生活化类比、流程图和大量可运行 Java 代码把 JVM 的整体架构、类加载机制、运行时数据区、对象创建、垃圾回收、字节码执行引擎与调优实战完整串起来。读完你会发现JVM 真的没有想象中那么难。1. 为什么你总被 JVM 劝退一提到 JVM很多人的第一反应是面试八股文、晦涩的 GC 日志、看不懂的调优参数还有那句魔咒般的“你了解 JVM 内存模型吗”。于是 JVM 渐渐成了一门“背面试题才看、平时不碰”的知识。但真相是JVM 是每一个 Java 程序员写在简历上、跑在服务器里、却很少真正搞懂的东西。线上突然 OOM你只会重启CPU 飙到 100%你只会看运气内存一年比一年吃紧你只会加机器。这背后真正的原因是你把 JVM 当成了黑盒。本文的目标只有一个用“图纸 生活类比 真实代码”的方式帮你把这台虚拟机器拆开、看懂、再装回去。看完之后你会发现——JVM 真香秒懂一点都不难。flowchart LR A[看不懂 JVM] -- B[只会重启服务] B -- C[线上问题反复出现] C -- D[面试被问懵] D -- A X[读完本文] -- Y[看懂内存布局] Y -- Z[读懂 GC 日志] Z -- W[独立定位问题]2. 从一个 Java 程序的一生说起想理解 JVM先看一个最普通的 Java 程序从“写出来”到“跑起来”经历了什么。public class HelloWorld { public static void main(String[] args) { String msg Hello JVM; System.out.println(msg); } }这段代码在磁盘上只是一个.java文本文件。它的完整生命周期可以分成三步编译期Compile Timejavac把HelloWorld.java编译成HelloWorld.class。这里面不是机器指令而是一种平台无关的字节码Bytecode。加载期Load TimeJVM 启动后类加载器把.class文件读进内存并根据它生成一个Class对象。运行期RuntimeJVM 的执行引擎逐条解释或编译这些字节码真正把Hello JVM打印出来。flowchart TD A[HelloWorld.java 源代码] --|javac 编译| B[HelloWorld.class 字节码] B --|类加载器加载| C[JVM 内存中的 Class 对象] C --|执行引擎运行| D[输出 Hello JVM]这里有一个关键结论Java 的“一次编译到处运行”靠的不是.java而是.class字节码。你在 Windows 上编译出的.class文件可以原封不动丢到 Linux 服务器上运行只要那台机器装了对应版本的 JVM。这就是 JVM 最核心的价值——屏蔽底层操作系统差异。3. JVM、JRE、JDK 到底什么关系很多教程一上来就甩出三个缩写让人越看越晕。这里用一张表一次讲清名称全称包含什么通俗理解JVMJava Virtual Machine类加载器、运行时数据区、执行引擎虚拟的“发动机”真正干活的核心JREJava Runtime EnvironmentJVM 核心类库rt.jar 等能运行 Java 程序的“整车”JDKJava Development KitJRE 编译工具javac 等能开发又能运行的“整车 工具箱”flowchart LR subgraph JDK[JDK 开发工具包] direction LR subgraph JRE[JRE 运行环境] direction LR SUB1[核心类库] subgraph JVM[JVM 虚拟机] SUB2[类加载器] SUB3[运行时数据区] SUB4[执行引擎] end end SUB5[javac 等开发工具] end一句话记住JDK 包含 JREJRE 包含 JVM。你开发用 JDK部署线上只需 JRE而真正执行字节码的是 JVM。4. JVM 整体架构全景图把 JVM 拆开它由三大部分组成这就是本文的地图建议先记住这张图类加载子系统Class Loader Subsystem负责把字节码文件加载进内存。运行时数据区Runtime Data AreasJVM 在运行过程中使用的各种内存区域。执行引擎Execution Engine负责执行字节码包括解释器、即时编译器JIT和垃圾回收器。flowchart TD A[Class 文件] -- B[类加载子系统] B --|加载 链接 初始化| C[运行时数据区] C -- D[执行引擎] D -- E[本地方法接口] E -- F[本地方法库] C --|对象读写| D G[字节码指令流] -- D生活类比把 JVM 想成一家餐厅。类加载子系统是采购部负责把食材class 文件搬进后厨仓库。运行时数据区是厨房里的操作台、冰箱、储物柜不同区域放不同东西。执行引擎是厨师把食材加工成一道道菜执行结果。垃圾回收器是保洁员及时清理不要的边角料让厨房一直有空间。脑子里有了这张图后面每一章都是对其中某一块的放大特写。5. 类加载机制字节码是怎么“进门”的5.1 类的生命周期一个类从被加载到卸载一共要经历七个阶段加载、验证、准备、解析、初始化、使用、卸载。其中“加载、验证、准备、解析、初始化”这五个阶段统称为类加载过程。flowchart LR A[加载] -- B[验证] -- C[准备] -- D[解析] -- E[初始化] -- F[使用] -- G[卸载]阶段核心动作生活类比加载读取字节码在堆中生成 Class 对象把书从仓库搬到书架上验证检查字节码是否合法安全检查这本书是不是正版、没有缺页准备为静态变量分配内存并给默认值给每个书架格子贴上标签解析符号引用转直接引用把“找张三”变成“找 3 楼 302 室”初始化执行clinit真正给静态变量赋代码写的值按规则把书摆放到位一个经典面试题下面两个代码count1和count2分别输出多少public class InitOrder { public static int count1 1; private static InitOrder instance new InitOrder(); public static int count2 2; private InitOrder() { count1; count2; } public static void main(String[] args) { System.out.println(count1 count1); // 2 System.out.println(count2 count2); // 2 } }答案都是 2。因为准备阶段给count1、count2赋默认值 0初始化时从上到下执行count1先赋 1接着创建instance触发构造器此时count1变 2、count2变 1最后执行count2 2于是两者都是 2。静态变量赋值的执行顺序就是代码书写顺序这是理解初始化阶段的关键。5.2 类加载器分类JVM 自带三种主要类加载器类加载器加载内容实现语言启动类加载器 Bootstrap ClassLoader核心类库如java.lang.*、rt.jarC/C 实现Java 里拿到的是 null扩展类加载器 Extension ClassLoaderjre/lib/ext目录下的扩展类库Java 实现应用程序类加载器 App ClassLoader自己写的类、classpath 下的类Java 实现更重要的是它们之间的双亲委派模型Parent Delegation Model。5.3 双亲委派模型当一个类加载器收到加载请求时它不会立刻自己加载而是先把请求委派给“父加载器”。如果父加载器能加载就由父加载器完成如果父加载器加载不了子加载器再自己尝试加载。flowchart TD A[AppClassLoader 收到请求] --|向上委派| B[ExtClassLoader] B --|向上委派| C[BootstrapClassLoader] C --|能加载?| D{是否加载成功} D --|是| E[返回 Class 对象] D --|否| F[ExtClassLoader 自己加载] F --|失败| G[AppClassLoader 自己加载]为什么这么设计最主要的原因是保证核心类库的安全。试想如果有人自己写了一个java.lang.String类里面藏着恶意代码。因为双亲委派的存在加载java.lang.String时永远优先交给启动类加载器加载的是 JDK 原生的那个类恶意版本根本无法被加载从而避免了核心类库被篡改。想验证这一点一行代码就够了public class LoaderTest { public static void main(String[] args) { ClassLoader loader String.class.getClassLoader(); System.out.println(loader); // null说明是启动类加载器加载的 System.out.println(LoaderTest.class.getClassLoader()); // sun.misc.Launcher$AppClassLoader... } }这就是“为什么String.class.getClassLoader()是 null”的由来。6. 运行时数据区JVM 的内存地图这是全文最核心的一章也是面试和线上问题的高发区。运行时数据区可以先用一张图建立全局认知flowchart TD subgraph JVM内存[JVM 运行时数据区] subgraph 线程私有[线程私有] A[程序计数器] B[Java 虚拟机栈] C[本地方法栈] end subgraph 线程共享[线程共享] D[堆 Heap] E[方法区 元空间] end end记忆口诀“三个私有两个共PC、栈、本地栈靠线程堆和方法区大家抢OOM 最爱这两块。”6.1 程序计数器Program Counter Register程序计数器是一块很小的内存空间可以理解为“当前线程执行到哪一行字节码”的行号指示器。每个线程都有自己独立的程序计数器所以它是线程私有的。为什么需要它因为 CPU 在多线程之间来回切换每次切回来都需要知道“我上次执行到哪了”程序计数器就负责记住这个位置。它是 JVM 规范中唯一没有规定任何 OutOfMemoryError 情况的区域也是所有内存区域里最简单、最不容易出问题的一块。6.2 Java 虚拟机栈Java Virtual Machine Stack虚拟机栈描述的是 Java 方法的执行过程每个线程在创建时都会分配一个虚拟机栈。方法被调用时JVM 会创建一个栈帧Stack Frame入栈方法执行完栈帧就出栈。flowchart TD A[main 方法栈帧] -- B[methodA 栈帧] B -- C[methodB 栈帧] C -- D[栈顶 当前执行]每个栈帧里主要装着四样东西局部变量表、操作数栈、动态链接、方法返回地址。局部变量表保存方法的参数和局部变量基础类型直接存值引用类型存指向堆中对象的指针。操作数栈字节码指令在这里做计算比如1 2先把 1 和 2 压栈再执行加法。动态链接指向运行时常量池中该方法的引用。方法返回地址方法执行完要回到哪里继续执行。虚拟机栈最容易出的问题是栈溢出StackOverflowError。递归没有终止条件栈帧不断入栈栈深度超出限制就会爆掉public class StackOverflowDemo { static int count 0; static void recursion() { System.out.println(depth: count); recursion(); // 没有退出条件无限递归 } public static void main(String[] args) { recursion(); // 最终抛出 StackOverflowError } }还有一种栈相关错误叫OutOfMemoryError出现在“栈空间允许动态扩展但扩展时申请不到足够内存”的情况相对少见。6.3 本地方法栈Native Method Stack本地方法栈和虚拟机栈非常像区别只有一个虚拟机栈服务于 Java 方法本地方法栈服务于native 方法。比如Thread.start()底层最终会调用 native 方法这些方法用的是 C/C 写的本地代码它们执行时的栈结构就在本地方法栈里。Java 8 之后HotSpot 虚拟机已经把本地方法栈和虚拟机栈合并在一起管理了。因此我们在日常开发中几乎感知不到它的存在只需要记住凡是带native关键字的方法底层执行位置就在本地方法栈。6.4 堆Heap堆是 JVM 中最大的一块内存也是所有线程共享的区域。JVM 启动时就会创建堆几乎所有的对象实例和数组都在这里分配内存。生活类比堆就像仓库里的“大货架区”所有新生产出来的商品都先摆到这里等不再有人需要时再由保洁员统一清走。flowchart TD subgraph Heap[堆 Heap] subgraph Young[新生代 1/3] Eden[Eden 区 8/10] S0[Survivor 0 1/10] S1[Survivor 1 1/10] end Old[老年代 2/3] end堆主要分为新生代和老年代新生代Young Generation由 Eden、Survivor 0S0、Survivor 1S1组成默认比例约为 8:1:1。新对象通常先分配在 Eden 区。老年代Old Generation经过多次垃圾回收仍然存活的对象会从新生代晋升到这里。堆大小使用-Xms初始和-Xmx最大控制。堆空间不足时会抛出java.lang.OutOfMemoryError: Java heap space。6.5 方法区与元空间Method Area / Metaspace方法区同样是线程共享的区域用来存放类元信息、静态变量、常量、即时编译器编译后的代码缓存等数据。在 Java 8 之前这块区域叫“永久代PermGen”位于堆内Java 8 开始被元空间Metaspace取代改用本地内存不再受堆大小限制。版本名称位置特点Java 7 及以前永久代 PermGen堆内容量小容易 OutOfMemoryErrorJava 8 及以后元空间 Metaspace本地内存默认几乎无限建议设置上限防止吃满物理内存元空间中还有一个重要结构叫运行时常量池Runtime Constant Pool它保存.class文件常量池表的内容包括字面量和符号引用是方法调用和字段访问的重要入口。可以这样直观理解字符串常量池的复用public class MetaSpaceDemo { public static void main(String[] args) { String a jvm; String b jvm; System.out.println(a b); // true字符串常量池复用同一个对象 System.out.println(MetaSpaceDemo.class.getClassLoader()); } }元空间常见的错误是java.lang.OutOfMemoryError: Metaspace通常是动态生成大量类、加载太多类却没有及时回收导致的。7. 一个对象是怎么被 new 出来的面试高频题来了Object obj new Object();到底发生了什么完整流程如下类加载检查判断Object类是否已加载。如果没有先走类加载过程。分配内存根据对象大小在堆中划出一块空间。内存是否规整决定使用“指针碰撞”还是“空闲列表”。初始化零值把分配到的内存初始化为默认值保证实例字段不赋值也能直接使用。设置对象头写入类元信息指针、哈希码、GC 分代年龄、锁状态等信息。执行 init 方法执行init也就是构造器和实例代码块完成真实赋值。flowchart TD A[new Object] -- B[类加载检查] B -- C[在堆中分配内存] C -- D[内存初始化为零值] D -- E[设置对象头 Mark Word 类型指针] E -- F[执行 init 方法]有个关键优化叫TLABThread Local Allocation BufferJVM 在 Eden 区为每个线程预留一小块私有缓冲区线程创建对象时优先在这里分配不用每次加锁抢共享资源减少并发分配冲突。再看对象在内存中的布局由三部分组成对象头Header包含 Mark Word 和类型指针。Mark Word 存储哈希码、GC 年龄、锁标志等运行时数据。实例数据Instance Data类里定义的各种字段内容。对齐填充PaddingHotSpot 要求对象大小是 8 字节的整数倍不够就补空白。8. 垃圾回收JVM 最怕被问的一章垃圾回收Garbage CollectionGC的核心只有三件事哪些是垃圾什么时候收怎么收8.1 怎么判断对象该被回收主流 JVM 使用可达性分析算法Reachability Analysis。从一组叫GC Roots的根对象出发沿引用链向下搜索搜索不到的死亡对象就会被回收。常见的 GC Roots 包括虚拟机栈中引用的对象方法区中静态变量引用的对象方法区中常量引用的对象本地方法栈中 Native 方法引用的对象flowchart TD Root[GC Roots] -- A[对象 A] Root -- B[对象 B] A -- C[对象 C] B -- D[对象 D] E[对象 E 无引用 可回收]8.2 常见垃圾回收算法算法思路优点缺点标记-清除 Mark-Sweep先标记活对象再清理垃圾对象实现简单产生内存碎片复制 Copying把存活对象复制到另一块空间无碎片效率高浪费一半空间标记-整理 Mark-Compact标记后把存活对象向一端移动无碎片移动对象成本高实际 JVM 不会只用一种算法而是使用分代收集理论新生代对象大多“朝生夕死”用复制算法效率最高老年代对象存活率高用标记-清除或标记-整理更合适。8.3 垃圾回收器演进Serial单线程回收客户端小应用可用。Parallel多线程回收JDK 8 默认追求吞吐量。CMS并发标记清除追求低停顿但会产生内存碎片。G1JDK 9 后默认按 Region 管理兼顾吞吐和停顿适合大堆。查看 GC 日志是定位内存问题的基本功。开启参数可以写成-Xms512m -Xmx512m -Xlog:gc*:filegc.log:time,uptime日志里如果反复出现Full GC且耗时很长通常说明对象晋升过快、内存不够或者存在泄漏。9. 字节码执行引擎解释器和 JIT 怎么配合JVM 拿到字节码后有两种执行方式解释执行像翻译员一样读一条翻译一条执行一条启动快但重复执行效率低。即时编译JIT把频繁执行的“热点代码”编译成机器码后续直接执行接近原生速度。JVM 通常会混合使用两者。对于执行次数很多的热点代码使用 JIT 编译器如 C1、C2进行优化对于冷门代码继续解释执行以减少编译开销。这种机制叫分层编译Tiered Compilation。flowchart LR A[字节码] -- B{热点代码?} B --|否| C[解释器执行] B --|是| D[JIT 编译为机器码] D -- E[直接执行机器码]我们也可以手动触发编译观察效果public class JitDemo { public static void main(String[] args) { long sum 0; for (int i 0; i 100_000; i) { sum compute(sum); } System.out.println(sum); } static long compute(long n) { return n 1; } }在这个方法被大量调用后JIT 会把它当作热点进行编译运行效率明显提升。10. JVM 调优实战从参数到排障学再多的理论最终都要落到“怎么用”。下面是几个最常用的 JVM 参数参数作用-Xms512m设置堆初始大小-Xmx2g设置堆最大大小-Xss1m设置单个线程栈大小-XX:MetaspaceSize256m触发元空间初始大小-XX:MaxMetaspaceSize512m限制元空间最大大小-XX:PrintGCDetails打印 GC 详情一套入门级排障组合拳jps查看 Java 进程 PID。jstat -gcutil pid 1000实时观察各区使用率和 GC 情况。jmap -heap pid查看堆配置和各区使用情况。jstack pid查看线程栈定位死锁、阻塞或 CPU 高占用。线上遇到 OOM 时最常见的动作是配置自动 dump-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/tmp/heapdump.hprof然后用 MAT 或 JProfiler 分析 dump 文件优先找占用最高的对象结合引用链判断是正常增长还是内存泄漏。11. 总结把 JVM 装回你的工具箱本文从 JVM 的整体架构开始沿着类加载、运行时数据区、对象创建、垃圾回收、字节码执行到调优这条主线把 JVM 这台“虚拟机器”完整拆了一遍。最后用一句话串起来JVM 类加载子系统 运行时数据区 执行引擎。其中执行引擎里又藏着 JIT 和垃圾回收内存区域里最重要的是堆、虚拟机栈和方法区。只要记住这张地图遇到问题时就知道该去哪个区域找线索。建议把这篇文章保存为笔记下次线上再遇到 OOM、CPU 飙高或者 Full GC 频繁时打开这张地图先定位区域再分析原因最后用参数和工具验证。这样JVM 就不再是玄学而是你能掌控的工具。真香
返回列表