资讯动态

JVM架构拆解:从类加载到垃圾回收的底层机制全解析

发布时间:2026/9/20 18:42:54 来源:尧图企业网站定制
上周在群里看到有人晒一个报错截图error invoking method. failed to launch jvm下面有人回复“你是不是装了OpenJDK又装了Oracle JDK冲突了吧”。我当时挺想接一句“这报错跟版本混装关系不大但那台机器上JVM和JDK的环境变量八成是乱了”。这个小场景其实暴露了一个很普遍的问题很多写了好几年Java的人看到“OpenJDK”“JVM”“JRE”“HotSpot”“字节码”这些词凑在一起依然是模糊的。这篇是我给团队做内部分享时的第1章讲义我把它整理出来适合刚入行Java后端半年到两年、想系统理解JVM底层的人如果你正在准备面试也可以把它当一份速查地图。面对大厂面试题里那些“JVM内存模型”“类加载双亲委派”“GC Roots有哪些”如果你脑子里只有几个孤立名词没有一张整体架构图回答一定散。这一章的目的就是把OpenJDK和JVM相关术语全部摆到准确的位置上再顺着一条主线把JVM架构拆开类加载子系统、运行时数据区、执行引擎、垃圾回收。概念之间怎么协作、为什么这么设计、参数在哪个区域生效我会尽量用大白话讲透。1. 先把概念理干净JDK、JRE、JVM与OpenJDK的身份边界这里说的“概念理干净”不是让你背八股定义而是真的能在心里画出一张包含关系图。很多问题的根源都是这三层东西被坛成了一锅粥。1.1 JDK不是JVM是一整套“开发工具链”最基础的结论先说JVMJava Virtual MachineJava虚拟机只是整个Java技术栈当中的一个运行时组件它的职责是执行字节码。而JDKJava Development Kit是给开发者使用的完整工具包里面至少包含三部分。javac编译器把.java源码编译成.class字节码文件。JRE运行环境JVM加上核心类库负责把编译好的字节码跑起来。诊断与调优工具jcmd、jstat、jmap、jstack、jinfo这些排查线上问题时全靠它们。我习惯打一个比方JVM是汽车发动机JRE是装好发动机、变速箱、电气系统的整车JDK则是整车再加维修工具、说明书和你自己的改装配件。你日常用IntelliJ IDEA写代码IDEA之所以能找到java命令是因为机器上装了JDK但程序运行真正需要的是JRE。这里顺带回答热搜里那个“jre和jvm之间的关系”JRE JVM 核心类库JVM是进程实体JRE是安装包形态。JDK 9之后官方把JRE从独立安装包里拿掉了不再提供单独的jre目录取而代之的是jlink这种模块化工具你可以按需生成一个精简运行时镜像。第一次被jdk目录结构吓到的人不在少数看到bin下没有jre文件夹很正常不是装错了。1.2 OpenJDK与Oracle JDK同一份代码的不同“发行渠道”先给一个硬核结论OpenJDK是Java SE的官方开源参考实现代码仓库由Oracle主导但开放给社区Oracle JDK是基于OpenJDK代码再加少量内部测试和工具的商业构建。Java 11之后两者在功能和性能上几乎完全一致区别主要在于更新策略和商业授权。很多人问“OpenJDK到底是不是阉割版”答案是否定的。你在Linux服务器上通过apt、yum装的openjdk-8-jdk和官网下载的Oracle JDK 8运行的是同一套HotSpot虚拟机。不同发行版的差异不在于JVM本身而在于带不带商业功能、加密库打包方式不同、有没有免费的长期支持承诺。下载OpenJDK时要注意别搜到杂牌网站。现在主流发行版包括Eclipse TemurinEclipse基金会主导社区热度最高Microsoft Build of OpenJDKAmazon CorrettoAzul ZuluLiberica JDK选哪个都行关键看团队维护习惯。有些云厂商会对自家发行版做深度适配比如优化容器内CPU识别这类差异在你做JVM调优时才会体现出来。还有两个名字容易混淆HotSpot和OpenJ9。HotSpot是OpenJDK默认的JVM实现市面上绝大多数Java进程跑的都是它。OpenJ9是IBM贡献给Eclipse的开源JVM主打低内存占用在云原生场景有存在感。所以“JVM”是一个抽象规范“HotSpot”是具体实现“OpenJDK”是包含HotSpot在内的完整产品线。1.3 JLS、JSR、JCP规范层面的术语补全除了产品层面的名称还有一组规范缩写需要认识面试偶尔会问日常看技术文档也经常碰到。JLSJava Language Specification《Java语言规范》定义Java语法、词法、类型系统。JVMSThe Java Virtual Machine Specification《Java虚拟机规范》定义class文件结构、字节码指令集、类加载过程、运行时数据区规则。JSRJava Specification Request某项新特性的技术提案。JCPJava Community Process审批JSR的组织和流程。举个例子JDK 8引入Lambda表达式背后是JSR 335JDK 9引入模块系统背后是JSR 376。你要深挖某个版本特性去搜“JSR编号”比在技术博客里看二手解读要准确得多。搞清楚这一层之后你就有了一个判断框架日常遇到的绝大多数“JVM问题”其实不是虚拟机本身有bug要么是配置参数没生效要么是代码层面触发了规范允许的行为。带着这个心态去排查问题不会一上来就怀疑JDK。2. 类加载子系统class文件是怎么变成“活”的Java对象的架构图上JVM内部三大块是类加载子系统、运行时数据区、执行引擎。很多人上来就去啃运行时数据区结果越看越懵因为不知道对象从哪来。所以我建议先看类加载它回答的核心问题是你写的一个.java文件凭什么能变成内存里能调用的Class对象。2.1 javac之后发生了什么class文件的二进制骨架javac Hello.java之后磁盘上会多出Hello.class文件。用xxd或Hex Fiend打开最前面四个字节是CAFEBABE这就是class文件的魔数JVM用它来识别文件是不是合法的class文件。整个class文件内部结构大致是魔数与版本号常量池符号引用、字符串字面量访问标志public/final等字段表、方法表属性表比如Code属性里面放的就是字节码指令常听到的“字节码”就藏在方法表对应的Code属性里。比如int a 1对应字节码可能是iconst_1加istore_0。你不需要背指令集但至少要建立“class文件不是机器码是JVM的中间汇编”这个观念。2.2 类加载的五个阶段加载、验证、准备、解析、初始化一个class文件要真正成为JVM运行时能用的Class对象要经历五个阶段。加载把class文件二进制读入内存生成java.lang.Class对象。验证检查文件格式、字节码语义防止恶意或损坏代码破坏JVM。准备为类的静态变量分配内存并赋默认值。注意这里是零值比如static int x 100准备阶段x先设为0真正的100要等初始化阶段。解析把常量池中的符号引用替换为直接引用也就是“从名字找到实际内存地址”。初始化执行静态变量赋值和静态代码块。这个顺序就是面试官爱问的“类加载过程”。最容易被绕进去的坑是“准备阶段到底赋值了吗”以及“解析一定在初始化之前吗”。HotSpot里为了支持动态绑定部分解析动作可以延迟到初始化之后这就是为什么规范里写“解析阶段可以被JVM推迟”。2.3 双亲委派Java安全体系里被误解最深的概念双亲委派模型的规则不复杂当一个类加载器收到加载请求时先不自己加载而是交给父类加载器父类加载不了再下放给子类。JVM内置的加载器层级从上到下是启动类加载器Bootstrap加载java.base等核心类、平台类加载器PlatformJDK 9后取代了扩展类加载器、应用类加载器Application加载classpath下的类。这个机制的核心价值是为了安全防止你写一个java.lang.String把自己的类塞进核心类库。因为加载java.lang下的类时请求最终会传到启动类加载器它看到要加载的是已有的String就直接用了你的“李鬼”类根本没机会执行。但工作中真正有坑的是“怎么打破双亲委派”。典型场景有两个。第一个是Tomcat它要为每个Web应用隔离不同版本的类库所以自己实现了一个加载器优先加载WEB-INF/classes下的类打破了向上委托。第二个是JDBCDriverManager在启动类加载器层级而MySQL驱动在classpath里按照双亲委派根本加载不到于是JDBC 4之后通过ServiceLoader配合线程上下文类加载器来完成驱动发现。面试答“双亲委派”能主动抛出“为什么要打破”“哪些场景打破”的深度印象分会明显不同。3. 运行时数据区JVM内存布局的完整地图上一节讲了类是怎么被装进来的这一节回答“装进来之后放在哪”。运行时数据区就是JVM规范定义的内存划分也是面试、“JVM内存模型”热搜和OOM排查的焦点。这里先纠正一个高频误解面试官说“聊聊JVM内存模型”如果只回答堆、栈、方法区其实答偏了。严谨的“JVM内存模型”Java Memory ModelJMM指的是多线程下共享变量的可见性规则和运行时数据区是两回事。但很多人自己也混着用所以你先按运行时数据区答再主动区分一下体现出的专业度就高一个档次。3.1 六个区域先记一张总表区域线程共享/私有存放内容异常情况程序计数器线程私有当前线程执行的字节码行号无Java虚拟机栈线程私有栈帧局部变量表、操作数栈等StackOverflowError / OutOfMemoryError本地方法栈线程私有native方法调用信息StackOverflowError / OutOfMemoryErrorJava堆线程共享对象实例、数组OutOfMemoryError: Java heap space方法区线程共享类型信息、常量、静态变量OutOfMemoryError: Metaspace运行时常量池归方法区管类文件常量池的运行时表示OutOfMemoryError这里要特别强调方法区在JDK 8之前叫永久代PermGenJDK 8之后改为元空间Metaspace且元空间使用本地内存Native Memory而不是JVM堆内存。很多早期调优文章里的-XX:PermSize、-XX:MaxPermSize在JDK 8之后已经失效了再用只会报“Unrecognized VM option”。3.2 Java堆绝大多数对象的老家堆是JVM内存中最大的一块所有通过new创建的对象实例都在这分配。为了优化GC效率堆通常被分成新生代和老年代新生代Eden区 两个Survivor区S0、S1默认比例是8:1:1。老年代存放长期存活的对象或者大对象直接晋升的对象。为什么分代因为绝大多数对象“朝生夕死”在Eden区分配出去很快变成垃圾最后被Young GC一次清理掉代价极小。只有熬过多次GC的对象才会晋升到老年代老年代满了才会触发Full GC。和堆相关的三个最常用参数-Xms堆初始大小。-Xmx堆最大大小。-Xmn新生代大小。调优没有万能数值但我习惯先保证-Xms和-Xmx一致避免JVM运行中动态扩容带来的停顿。3.3 Java虚拟机栈每次方法调用都在这留下一帧虚拟机栈是线程私有的每个线程启动时会分配一块栈内存。每调用一个方法JVM就会压入一个栈帧方法返回栈帧弹出。栈帧内部包含局部变量表、操作数栈、动态链接、方法出口等。面试里最经典的追问是“int a 1; int b 2; int c a b;执行时发生了什么”在栈帧视角下a和b先被存进局部变量表然后指令把它们压入操作数栈执行加法指令再把结果写回局部变量表。局部变量表是“储物柜”操作数栈是“临时计算台”。这里有个常见的OOM场景线程数开太多每个线程默认栈大小-Xss通常在1MB左右1000个线程就是近1GB内存。很多微服务进程在容器里内存暴涨不一定是堆设大了有可能是线程数打爆了栈内存。排查时要用jstack看线程数量而不是盯着堆大小。3.4 方法区与元空间类型信息、常量和静态变量的家方法区存的是每个类的结构信息类名、方法签名、字段描述、运行时常量池、静态变量、JIT编译产物缓存等。这里体现的是“类元数据”的存储成本有多大所以每次增加类数量时元空间占用就会上涨。比如热部署插件加载了大量ClassLoader就会导致Metaspace膨胀。字符串常量池的位置是一个经典大坑。JDK 7之前字符串常量池在PermGenJDK 7之后移到了堆里目的是让字符串对象能被常规GC回收减少PermGen OOM。所以你会看到abc.intern()的返回值在JDK版本变化后行为有差异。还有一块“堆外”内存叫直接内存Direct Memory底层由NIO的DirectByteBuffer使用目的是减少从内核态到用户态的一次拷贝实现更高的IO吞吐。它不受-Xmx限制但会占用系统物理内存对应异常是OutOfMemoryError: Direct buffer memory。Netty默认就用了这部分内存如果你不做限制容器内存会超出预期。4. 执行引擎让Java“越跑越快”的JIT编译机制类加载子系统把class文件装进来运行时数据区画好了内存地盘现在轮到执行引擎干活。JVM的执行引擎主要由解释器、C1编译器、C2编译器和垃圾收集器组成这一节先说前三个GC单独放下一节。4.1 为什么要“先解释、后编译”而不是一步到位解释执行是边翻译边执行优点是启动快、没有编译等待缺点是每条指令翻译一遍运行慢。JITJust In Time编译则把热点代码直接编译成机器码执行效率高但编译过程本身耗时间。JVM的聪明之处在于“分层”启动阶段用解释器点火程序跑起来后统计哪些方法频繁被调用再让JIT编译器介入把热点代码变成机器码。所以同一个JVM进程刚启动时和稳定运行一段时间后性能差异巨大这正是HotSpot名字的由来——它能找到“热点”。热点检测靠的是两个计数器方法调用计数器默认阈值在10000次附近实际受分层编译影响和回边计数器统计循环体执行次数。4.2 C1、C2与Graal的分工JIT编译器不是只有一种。C1也常称为Client Compiler编译速度快、优化激进程度低适合对启动速度敏感的场景C2也常称为Server Compiler会做更深入的分析和优化比如循环展开、分支预测优化等适合追求峰值性能。JDK 6 u25之后默认开启分层编译常见的策略是第0层解释器。第1层C1简单编译。第2层C1、C2边界过渡。第3层C1开启完整性能统计。第4层C2编译。日常运行在Server模式、长时间跑任务的Java进程最后都会落到C2。Graal是Oracle主导的新一代JIT编译器在GraalVM中更常见可以与C2并列。调优时关注“分层编译”是否被误关闭。有些团队为了缩短启动时间把-XX:TieredStopAtLevel1写成固定配置短时间内启动确实快但系统吞吐会下降业务峰值时性能明显不够。这个参数只适合临时调试不适合生产环境。4.3 常见的JIT优化案例方法内联、锁消除、标量替换JIT优化里最有名的三个方向方法内联把短方法体的指令直接“搬”进调用方避免方法调用开销。锁消除JVM发现某段代码中的锁不可能被多线程竞争会把synchronized直接去掉。逃逸分析 标量替换对象只在方法内部使用、没有“逃逸”出去JVM就不在堆上分配完整对象而是把它的字段拆成局部变量。所以它放在“栈上分配”的方向上但严格说HotSpot主要做的是标量替换不是真的在栈上开一个对象。理解这些优化后你会发现一个现象从源码角度看着“多余”的代码JIT执行时可能早就不存在了。比如短方法加private final内联概率更高局部对象放循环里JIT可能会自动做栈上分配。反过来如果你写代码时让对象进入了不安全的逃逸路径JIT就无法优化明明一样的逻辑性能差出几倍并不奇怪。5. 垃圾回收判断存活、回收算法与收集器选择的底层逻辑谈到JVM架构垃圾回收是绕不开的重头戏也是“JVM调优”热搜里占比最大的一块。GC设计的本质是回答三个问题哪些对象是垃圾用什么方式清理垃圾用什么收集器来执行5.1 判断对象是否存活可达性分析取代引用计数最直觉的垃圾判断方式是“引用计数”给对象加计数器被引用一次加一引用失效减一归零就回收。但它有个硬伤两个对象互相引用外部没有引用指向它们时计数器永远不为零就泄漏了。所以主流JVM改用可达性分析。可达性分析的思路是以GC Roots为起点沿着引用链往下走。能被链条触达的对象存活触达不到的就是垃圾。GC Roots包括栈帧中局部变量表引用的对象。方法区中静态属性、常量引用的对象。JNI方法引用的对象。活跃线程本身。这个知识点经常成为面试深挖点比如“ThreadLocal为什么内存泄漏”根因就是Thread只强引用ThreadLocalMapkey是弱引用而value是强引用GC时若ThreadLocal实例被回收value还挂在Map里等着被清理。5.2 三种基本垃圾回收算法各有各的适用场景标记-清除先标记存活对象再清除垃圾。缺点是没有整理空间产生大量碎片。复制算法把存活对象复制到另一半内存剩下的整块清空。优点是高效、无碎片缺点是浪费一块空间适合存活率低的新生代。标记-整理标记存活对象后把对象往一端移动再清理末端。适合存活率高的老年代避免复制开销同时解决碎片问题。JDK 8默认的Parallel Scavenge负责新生代用复制算法Parallel Old负责老年代用标记-整理。它们的特点是追求吞吐量但在响应时延上不做保证。5.3 主流收集器对应关系与选择逻辑收集器适用代策略特征Serial新生代复制单线程适合客户端、内存极小场景Serial Old老年代标记-整理Serial的老年代版本Parallel Scavenge新生代复制多线程追求吞吐量Parallel Old老年代标记-整理与Parallel搭配JDK 8默认组合CMS老年代标记-清除低停顿JDK 9后废弃JDK 14移除G1不区分逻辑分Region分区化复制整理JDK 9起默认面向服务器兼顾停顿ZGC不区分代染色指针读屏障超低停顿百GB大堆场景Shenandoah不区分代并发整理与ZGC思路类似Red Hat主导JDK 8默认GC是Parallel Scavenge Parallel OldJDK 9开始G1成为默认目前JDK 17、21上G1依然是默认ZGC已经能跑出极低延迟但没有成为默认。CMS被废弃的直接原因是它用“标记-清除”算法碎片严重最终会退化成Serial Old导致长停顿G1则通过把堆划分成多个Region在部分Region之间做复制做到“可控的停顿时间”。选收集器没有银弹。我的经验是如果服务要求高吞吐检测指标主要看TPS用Parallel如果服务是网关、交易中心这类对延迟敏感的优先G1和ZGC堆内存在数十GB以上且GC停顿无法接受ZGC的价值立刻体现。先用默认配置跑半个月再用GC日志做决策绝对不要上来就抄网上“最强参数”改一堆。5.4 GC日志从哪里看JVM调优的第一步做任何GC调优第一件事是打开GC日志观察基线数据。JDK 8和JDK 11的日志参数差异很大别搞混JDK 8常用-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/path/to/gc.logJDK 11统一用-Xlog-Xlog:gc*:file/path/to/gc.log:time,uptime,level,tags看GC日志时重点关注几个指标Young GC频率是不是每秒都在GC、Full GC频率正常应该极低、GC平均耗时和最大耗时、以及GC前后的堆使用率。如果Full GC频繁且堆内存未回收出空间基本可以判定是内存泄漏或堆设置过小下一步配合jmap -histo找大对象来源。6. 概念落地面试问答、OOM排查与调优的正确打开方式把术语和架构图啃完最终要落到实际产出上面试里怎么组织语言线上OOM怎么定位调优从哪个入口进入。6.1 面试被问“JVM内存模型”分清两种答法面试官问法不同答题侧重完全不同如果问“JVM运行时数据区有哪些”你要按线程私有/共享分类把程序计数器、虚拟机栈、本地方法栈、堆、方法区的职责说清最好能提到元空间替代永久代。如果问“Java内存模型”重点是volatile的可见性、happens-before规则、指令重排、以及主内存与工作内存的交互。面试中能主动区分“这是JMM不是JVM内存结构”会给面试官留下“这人概念是成体系的”印象。6.2 线上OOM用架构知识做“由外向内”排查OOM不是一类异常至少要能区分几种典型表现Java heap space堆空间不足多半是对象泄漏、大集合未释放。Metaspace元空间不足ClassLoader泄漏或动态生成类过多。unable to create new native thread线程数打满操作系统不允许再创建线程。Direct buffer memory直接内存耗尽NIO/Netty未正确释放。排查链路我一般固定为先用free -h看进程内存与容器内存差距再用jcmd PID GC.heap_info看当前堆使用分布并用jstat -gcutil PID观察GC频率最后用-XX:HeapDumpOnOutOfMemoryError把堆转储文件留下来再用MAT或JProfiler分析泄漏对象。这套流程不需要一次学会但方向对了问题就能收敛。6.3 不照抄参数的JVM调优先定指标再看日志强烈不建议连上服务器就改-Xmx和GC。合理的调优流程应该是定义问题是CPU高、GC停顿长、还是内存占用超预期收集数据GC日志、线程快照、堆转储、监控曲线缺一不可。复现或压测在灰度环境模拟业务高峰确保改动有对比。小步调参一次只改一个参数观察两周。回滚准备记录原参数异常时快速回滚。“看完第1章就想做调优”是不现实的但至少你应该能听懂别人嘴里的“堆”“元空间”“老年代”“G1”“ZGC”“逃逸分析”到底指什么知道问题该去哪个文档里查、该看哪份日志这就比90%只会背八股的人已经强了。我自己带过不少新人发现最快建立JVM体系感的方式不是啃完一本书而是拿到一个真实乱写的程序亲眼看他OOM、调参、看GC日志然后再回头对照架构图。概念和实战来回印证几次这些名词就长在脑子里了。后面如果有机会我会接着写第2章把JMM与并发机制的完整脉络理一遍继续用这种“先全景、再细节”的方式往下走。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价