资讯动态

JVM架构详解:从类加载到内存管理,彻底搞懂Java虚拟机

发布时间:2026/10/24 7:04:28 来源:尧图企业网站定制
第一次见JVM总览图的人往往不是被它的复杂吓到而是被它的层次感吸引一边是类加载器在搬运字节码一边是运行时数据区在分门别类地存放数据另一边是执行引擎忙着把字节码翻译成机器指令。我最早学Java时把这些部件背得滚瓜烂熟但直到线上出过几次内存问题才真正把这些图变成自己的知识。这篇文章不写复杂源码分析只做一件事把JVM架构这条主线彻底捋清楚——它由哪些部件组成、每个部件解决什么问题、这些部件之间如何协作以及当线上OOM或栈溢出时这些架构知识能给出什么线索。适合刚学完Java基础的同学、准备面试的开发者以及会写代码但不太清楚JVM内部情况的业务后端。1. 先建立整体框架JVM在Java程序里扮演的三个角色1.1 跨平台翻译官把“一次编写”变成“到处运行”Java源码不会直接编译成机器码而是先编译成一种中间产物——字节码也就是 .class 文件。你的电脑、别人的服务器、某个嵌入式设备CPU架构可能完全不同但它们都装着一套“能读懂字节码的虚拟机”也就是JVM。启动Java程序时JVM负责把字节码逐条翻译成当前操作系统和CPU能认识的机器指令。所以“一次编写到处运行”的真正底气不是Java语言本身而是JVM这套翻译机制。开发者面向字节码编程平台差异被挡在虚拟机之下。你不需要在Windows上写一份代码、在Linux上再改一份只要对应平台有JVM实现同一份字节码就能跑。理解这一点之后你就明白为什么Java程序更换部署环境时第一件事永远是确认目标机器有没有正确安装对应版本的JVM。1.2 内存管家与运行调度器让开发者从释放内存里解脱写C/C的时候你申请了一块内存用完必须自己释放忘记释放就泄漏释放早了就悬空。Java把这块职责收走了JVM作为内存管家自己申请内存、自己划分区域、自己追踪对象存活情况并通过垃圾回收器把不再使用的对象清理掉。开发者没有了手动释放内存的负担但也付出了代价——GC什么时候发生、停顿多久成了一种需要重新适应的运行特性。JVM同时还承担一部分调度职责。Java里的多线程最终会映射到操作系统线程去执行线程怎么创建、怎么切换、怎么和其他线程协作JVM会在中间做一层协调。这也是为什么很多并发工具源码里会看到“由JVM保证”这样的字眼。你写的业务线程本质上运行在JVM这个“运行环境”里。1.3 四块核心部件先入脑类加载、数据区、执行引擎、本地接口JVM架构可以大致切成四块类加载子系统负责找到 .class 文件或者 jar 包里的字节码把它读进内存并做一系列校验和准备工作。运行时数据区JVM在运行期间使用的内存区域分为程序计数器、虚拟机栈、本地方法栈、堆、方法区。执行引擎把字节码翻译成机器指令并执行热点代码的编译优化。本地方法接口允许Java代码调用非Java写成的原生方法比如操作系统API、C/C库。打个比方JVM像一家餐厅。类加载岗是采购和验菜把食材运进后厨并检查是否变质运行时数据区是冷库、灶台和出菜口各区域各管一摊执行引擎是大厨把半成品加工成能上桌的菜本地方法接口则是外聘的供应商某些特殊食材直接由专业厂商供货。四条线各司其职又环环相扣。2. 类加载子系统一段class文件是如何被“验收入库”的2.1 五个阶段里最容易忽略的是“验证”和“准备”类加载不是一次简单的“读文件”它要经历五个阶段加载、验证、准备、解析、初始化。加载阶段类加载器根据类的全限定名找到对应的二进制字节流把这个字节流转成方法区的运行时数据结构并在堆中生成一个代表这个类的Class对象作为后面所有实例对象的模板。验证阶段经常被忽略但它极其重要。JVM会检查字节码文件格式是否合法、语义是否自洽、符号引用是否能解析到位。为什么需要这一步因为 .class 文件不一定来自编译器也可能是手工构造或传输中被篡改过的。验证就是安全防线防止恶意字节码把JVM搞崩。准备阶段是为类的静态变量分配内存并设置默认零值的阶段。注意这里赋的是默认值比如int类型给0boolean给false引用类型给null还不是你在代码里写的那个初始值。唯一的例外是static final常量它会在准备阶段直接写入ConstantValue。解析阶段把常量池里的符号引用替换为直接引用。所谓符号引用可以理解成“类的全名字段名方法名”这样的文本描述而直接引用则是指向内存地址或句柄的指针。解析可以发生在类加载时也可能延迟到某个字段或方法第一次被使用时。初始化阶段才是真正执行静态变量赋值和静态代码块的时候。JVM保证一个类的初始化是线程安全的多个线程同时触发初始化时只有一个线程能执行其余线程会等待。2.2 双亲委派机制为什么父亲先吃第一口饭类加载器并不是只有一套常用的有三层从底到顶分别是引导类加载器、扩展类加载器在较新版本中演变为平台类加载器、应用类加载器。还有用户自定义类加载器可以挂到应用类加载器下面。class文件长得都差不多字节码本身不会说“我是标准库还是业务代码”。怎么避免开发者写一个java.lang.String就悄悄替换掉标准库靠双亲委派机制。双亲委派的核心逻辑是每个类加载器收到加载请求后先把请求扔给父加载器父加载器处理不了子加载器才自己动手。加载链走到了最顶层的引导类加载器它只加载JDK的核心类库业务类它看不上于是请求原路返回给下一级最终由应用类加载器加载你的业务代码。这样做的结果有两个第一核心类只能由引导类加载器加载你想写一个同名同包名java.lang.String的类去冒充标准库引导类加载器会发现已经加载过直接返回已有的Class对象你的“冒牌货”根本没机会登场第二同一个类在JVM里由同一个加载器加载类型身份是唯一的不会出现“两个String类互相不认”的混乱局面。2.3 打破双亲委派的真实现场SPI为什么一定绕不开双亲委派是默认行为但有些场景必须打破它。最典型的是JDK的SPI机制比如数据库驱动。数据库驱动接口定义在核心类库里由引导类加载器加载但真正的驱动实现是一个第三方jar包必须由应用类加载器加载。按照双亲委派引导类加载器看到“需要加载驱动实现类”的请求时会先派给父加载器——而它没有父加载器自己又不负责加载业务jar包结果就会找不到实现类。解决思路是反过来核心接口在启动过程中通过线程上下文类加载器去加载实现类。线程上下文类加载器通常是应用类加载器它有能力加载项目lib目录下的驱动jar包。这样父加载器就可以明确指定“用这个子类加载器去加载”实现了对双亲委派方向的倒置。排查类加载问题时常见报错就两类ClassNotFoundException表示类在指定路径下不存在NoClassDefFoundError则表示类第一次加载失败后后续代码再次引用它时抛出的错误。出现后一类情况时不要急着加依赖先看看是哪个类由哪个加载器加载有可能是因为父加载器或同名前置加载器已经加载了另一个版本导致你的类永远轮不上。3. 运行时数据区不是所有内存都归堆管3.1 两片私有区域和一片共享区域线程之间怎么分家JVM把运行时内存划分成信息用途不同的区域。先说线程私有的部分。程序计数器是每线程一个的小空间记录当前线程正在执行的字节码行号。它特别小但作用极大线程被切走再切回来时靠它才能恢复到原来的执行位置。它是规范里唯一不会出现OOM的区域。虚拟机栈也是每线程一个。每个方法被调用时虚拟机栈会压入一个栈帧方法执行完再弹出去。栈帧里装着局部变量表、操作数栈、动态链接、方法出口等信息。递归过深时栈帧不断入栈最终把栈空间耗尽抛出StackOverflowError。本地方法栈和虚拟机栈功能类似区别在于它是为调用native方法服务的。很多JVM实现会直接把两者合并不必刻意区分。然后是线程共享的两大区域。堆存放几乎所有的对象实例和数组是垃圾回收的主战场。方法区存放类的元信息、常量、静态变量等。为了清晰我把主要区域整理成一张表区域线程关系存放内容常见异常程序计数器线程私有当前字节码行号无虚拟机栈线程私有方法栈帧、局部变量StackOverflowError本地方法栈线程私有native方法调用信息StackOverflowError堆线程共享对象实例、数组OutOfMemoryError: Java heap space方法区线程共享类信息、常量、静态变量OutOfMemoryError: Metaspace3.2 堆与分代对象住哪回收器才好干活堆是整个JVM里最大的一块内存也是理解“对象分配”的关键。为了配合垃圾回收堆被分成新生代和老年代。新生代里又细分出一个Eden区和两个Survivor区常用称呼是S0和S1。绝大多数新对象先被分配到Eden区。Eden区满了之后触发一次Minor GC存活下来的对象被复制到S0或S1GC过程会是两个Survivor区来回倒腾每经过一轮回收存活对象的年龄加一年龄达到阈值后晋升到老年代。还有一些大对象比如超大数组或超大字符串在新生代里反复复制代价太高JVM会让它直接进入老年代。为什么要分代因为JVM观察到一条经验规律大部分对象都是朝生夕死的比如循环体里的临时对象用完就没人引用了。新生代存活率低用复制算法只需把少量存活对象搬走剩下的整片空间直接标记为可复用效率很高。老年代对象存活率高复制代价大所以用标记-清除或标记-整理GC频率也不能太高。还有一点容易忽略JVM里所有线程都能往堆里分配对象如果每次分配都去争抢同一把分配锁并发性能会很差。所以JVM给每个线程留了一块本地线程分配缓冲也就是TLAB线程先在各自的TLAB里分配小块内存TLAB满了还想要大对象才走共享内存的分配路径。这也是为什么在很多监控工具里能看到“线程本地分配”这个概念。3.3 方法区的进化从永久代到元空间为什么非要改方法区在逻辑上属于JVM规范的一部分但在物理实现上经历了一次重要变迁。早期版本把方法区实现成永久代永久代和堆挤在同一块区域里有固定上限。一旦加载的类太多或者动态代理生成类过多就会抛出OutOfMemoryError: PermGen space。后来标准实现把永久代去掉了改为元空间。元空间不再占用JVM堆内存而是直接使用本地内存上限默认受操作系统物理内存限制。这也解释了为什么很多生产环境升级之后“PermGen space”消失了取而代之的是Metaspace相关的OOM。类元信息、运行时常量池、静态变量等依然属于方法区的语义范畴。JDK 7开始字符串常量池移到了堆中JDK 8开始永久代的概念在标准实现里被彻底淡出。这些变化对普通开发者最大的影响是以前“永久代不够用”要靠调-XX:MaxPermSize现在则要关注-XX:MaxMetaspaceSize或监听Metaspace的使用率。4. 执行引擎解释器先跑JIT随后跟上4.1 解释执行和编译执行各自的账本类被加载到内存数据有了安放区域接下来真正的问题来了字节码不是机器码CPU根本不认识怎么执行执行引擎登场。最基础的方式是解释执行一条字节码一条字节码地翻译成机器指令并立即执行。优点是从启动到运行几乎不需要等待缺点是同一段代码每次执行都要重新翻译一遍整体偏慢。另一种方式是提前把字节码编译成机器码再执行这就是即时编译习惯上叫JIT。JIT会把一段代码整体编译成平台相关的机器码并缓存起来后续再走到这段代码直接执行缓存好的机器码速度飞快。缺点也很明显编译本身耗时如果启动时把所有代码都编译一遍启动等待时间会让人崩溃。所以标准做法是两者共存。程序启动初期解释器先跑起来让业务代码尽快可用与此同时JVM不断收集运行数据找出哪些代码是明显的热点再针对热点代码启动JIT编译。这个“解释器先跑、JIT随后跟上”的模式本质上是启动速度和峰值性能之间的妥协。4.2 热点代码怎么被识别和编译JIT不会好心到把所有代码都编译一遍。它只关心热点代码靠计数器来识别。典型的两类计数器是方法调用计数器和回边计数器。方法调用计数器统计一个方法被调用了多少次达到阈值就认为是热点方法触发编译。回边计数器则统计循环体的回边次数一个方法里的某个循环体反复执行同样会被视作热点只编译这个循环部分而不用等整个方法都被调够次数。阈值在标准实现里是可以调整的比如通过参数修改触发编译的调用次数具体数值不同版本有差异。编译器和解释器不是“你干完我接手”的关系而是各管一段JIT编译完成后解释器就不再负责这段热点的解析执行直接切换过去。再往下还有分层编译的概念。基础编译器C1编译速度快、优化程度有限适合快速产出可用代码高级编译器C2编译慢、优化激进适合长时间运行的服务端程序在峰值性能上做文章。实际运行中JVM会先用C1把代码编译出来同时继续收集运行数据等到数据积累足够、判定某个方法值得更极端的优化时再用C2重新编译。理解“C1是快车、C2是重甲”这个关系后再看各种JIT参数就不会一头雾水。4.3 逃逸分析为什么说“几乎”所有对象都在堆上教科书里常说“JVM堆存放所有对象实例”到了执行引擎这里要修正一下几乎所有但不是绝对。逃逸分析是JIT的一项重要优化技术。JVM会分析对象的作用域看它有没有“逃逸”出当前方法。假如一个对象只在一个方法内部创建和使用没有作为返回值传出也没有被赋值给某个外部可见的字段很多实现会把该对象做标量替换甚至直接分配到栈上。分配在栈上的对象方法执行完随栈帧一起弹出连垃圾回收都不用等。这也是为什么有些代码里明明频繁new对象GC压力却没有想象中那么大。不过要提醒一句不要为了蹭这个优化故意把对象设计成“看起来只在方法内使用”逃逸分析是否生效受太多因素影响业务代码只需要按照正常思路写优化交给JVM就好。5. 对象的一生从new到回收的完整时间线5.1 一个对象的诞生七步走完分配流程new关键字背后不是简单一记“malloc”而是一条完整链路。第一步类加载检查。JVM执行new指令时先确认这个类有没有被加载、解析、初始化过。如果没加载过会先触发类加载流程。第二步分配内存。对象需要多大空间在类加载完成后已经能算出来JVM会在堆上划出一块区域。这块区域的划分方式有两种堆内存规整时用指针碰撞直接移动指针堆内存碎片化时用空闲列表找一块足够大的空闲区域。第三步处理并发安全。有TLAB可以先在TLAB里分配没有TLAB空间了再通过CAS或加锁保证线程安全。第四步内存空间初始化为零值。这一步保证了对象字段即使还没有赋上业务值也存在默认零值比如int是0boolean是false。第五步设置对象头。对象头里记录着哈希码、GC分代年龄、锁状态标志等信息。第六步执行构造方法。也就是你写的实例初始化逻辑。第七步返回引用给调用方。这七步里最容易被忽略的是“类加载检查”和“零值初始化”。前者解释了为什么第一次new一个类会比后续new同类对象慢得多后者则说明为什么JVM里未赋值字段不会是随机的垃圾数据。5.2 内存布局对象比你想象中更“占地方”一个Java对象在内存中由三部分组成对象头、实例数据、对齐填充。对象头里至少包含两部分一部分是Mark Word记录哈希码、GC年龄、锁标记另一部分是类型指针指向它的类元数据。开启压缩指针后对象头通常有12字节左右不开启则可能更大。实例数据是你声明的字段所占的空间字段排列顺序受到JVM重排的影响但这不是你能精确控制的。关键点在于对齐填充。JVM要求对象大小必须是8字节的整数倍比如对象实际用到30字节会补齐到32字节。很多初学JVM的朋友会震惊一个只含几个int字段的小对象消耗往往不是你以为的那个数值。拿一个有两个int字段两个引用字段的对象举例开启压缩指针时对象头约12字节两个int占8字节两个引用占8字节合计约28字节再对齐到32字节。这些空间开销在分析系统内存占用时非常关键。所以老话“Java里一个对象比C里相同结构的struct占更多内存”不是比喻是事实。5.3 回收的判定与分代策略谁会被清走垃圾回收首先要回答一个问题怎么判断一个对象死了主流做法是可达性分析。JVM从一组称为GC Roots的根对象出发沿着引用关系遍历能被遍历到的对象就是活着的遍历不到的就是不可达对象可以被回收。GC Roots包括栈帧里的局部变量引用的对象、静态变量引用的对象、常量引用的对象、JNI引用的对象等。两个对象互相引用但整条引用链上没有任何GC Roots指向它们它们依然是不可达的会被回收。网上流传的“循环引用导致内存泄漏”说法在很多现代JVM里并不成立。真正的内存泄漏是对象被某个长期存活的对象持有引用比如不用的对象被堆到静态集合里一直有GC Roots能沿着引用链找到它GC就拿它没办法。回收策略上新生代用复制算法因为存活率低复制代价小老年代用标记-清除或标记-整理因为存活率高大面积复制不划算。想粗略观察GC情况可以用JDK自带的命令行工具定时输出GC利用率看一眼里面的Eden、Old、FGC数据就能对应用的健康程度有个大致判断。6. 架构认知落地用总览知识快速定位运行时问题6.1 事前摸底先看看当前JVM的默认配置很多JVM调优教程给了一堆参数但不同版本JDK的默认值差异很大。与其死记硬背不如直接问虚拟机。标准实现里可以利用PrintFlagsFinal这类参数打印出所有可调参数的默认值。如果想确认堆大小、元空间上限这类基础配置在启动命令里加上相关打印参数把输出管道接到grep上过滤关键字就行。看到当前JDK实际生效的值之后再决定要不要调整而不是拿网上搜来的“推荐值”硬套。6.2 排障路径面对StackOverflowError和OOM该查什么JVM报错自带关键字这些关键字就是排查线索。StackOverflowError指向虚拟机栈。最常见原因是递归不收敛或极深的方法调用链。可以先看异常堆栈顶部基本能定位到最后一个还在递归调用的方法。有人遇到栈溢出第一反应是调大-Xss但栈变大了只是把崩溃点往后推递归写错逻辑的问题依然存在。优先检查代码再考虑调参。java.lang.OutOfMemoryError: Java heap space指向堆。带着“堆内存不够”的信号先分辨是内存泄漏还是内存分配峰值太高。泄漏的话对象只进不出堆使用率会持续单调上升需要抓取堆转储排查哪些对象被不该有的强引用一直持有峰值太高的话可能只是某一瞬间并发请求暴涨看看业务流量和对象分配速度更有效。OutOfMemoryError: Metaspace指向元空间。反射、动态代理生成大量类、加载过多类库都可能导致类元数据超标。定位思路上查谁在运行时不断生成新类并对元空间做大小限制。还有一条容易被忽略的GC overhead limit exceeded它表示JVM在垃圾回收上花费了大量时间却只回收出一丁点空间。看到这个异常基本是堆内存已经满得快要炸了根源还是堆里的对象太多了。6.3 把架构当线索而不是死记硬背我自己学JVM架构的过程中最有效的办法是把四个部件串成一条时间线类加载器先把类送进方法区运行时常量池记住了各种引用线程创建后有了私有栈和程序计数器业务代码new对象时对象在堆里落地执行引擎一边解释一边编译让热点代码跑得更快回收器则一直在堆后方盯着把不可达对象清走。遇到任何运行时问题先看报错关键字对应哪个区域再顺着区域去找原因。我的习惯是每学一个概念就问自己一句如果这里出问题日志里会出现哪些关键字栈溢出、堆OOM、元空间OOM三个关键字对应三种完全不同的排查动作。把这张对应关系记在心里比背任何参数列表都实用。架构不是一张挂在墙上的图而是排查问题时能随时调用的地图。

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

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

免费获取报价 →
↑