1. 为什么学JVM要先弄懂“组成”
我刚接触Java那会儿,对JVM的理解就是一串能背下来的名词:“类加载器、运行时数据区、执行引擎、垃圾回收”。面试前背得滚瓜烂熟,可真到线上服务内存溢出(OOM)、启动直接报错、或者同事问我“为什么这里改成接口调用就变慢了”,脑子里的那套名词根本派不上用场。后来我才意识到,问题不在于记没记住名词,而在于我没有把JVM当成一台“机器”去理解——不知道它的零件有哪些、每个零件干什么、零件之间怎么配合,自然也就谈不上排查故障。
“JVM的组成”这个话题,看起来是入门第一课,其实是一张地图。你后面遇到的所有问题——堆内存溢出、栈溢出、类加载冲突、JIT编译异常、GC停顿——几乎都能在这张地图上找到对应的位置。理解了组成,你才能知道OOM该去看哪块区域、类为什么加载不进来该查哪个环节、线程卡住该抓哪里的信息。这也是为什么很多公司面试Java岗位,第一问就是JVM由哪几部分构成,它不是简单考察背诵,而是想看看你有没有建立这张地图。
这篇是我“JVM笔记”系列的第一篇,重点就放在组成上:JVM整体分哪几个子系统、运行时数据区里各块区域是干什么的、类加载机制是怎么把.class变成可运行对象的、执行引擎和垃圾回收在JVM里扮演什么角色。为了让内容不只是停留在概念层,我会穿插一些实际排查时用到的参数、命令和场景,毕竟能解决问题的笔记才算有价值。
顺带说一句,如果你分不清JVM、JRE、JDK三者的关系,这篇也会顺带讲清楚。JDK是Java开发工具包,里面包含了编译器javac和一系列开发工具;JRE是Java运行环境,只负责跑Java程序;而JVM是JRE的核心,真正执行Java字节码的那个“虚拟机”。简单说,JVM在JRE里,JRE在JDK里,而JVM本身又由下面要讲的几个子系统组成。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JVM整体架构:三个子系统加一块内存区域
先抛开细节,从上往下看JVM的整体结构。一台真实的计算机有CPU、内存、磁盘、操作系统,JVM作为一台“虚拟计算机”,它的结构也对应着一台完整计算机该有的东西:能加载程序的装置、能存放运行数据的空间、能执行指令的处理单元,以及跟外部打交道的通道。
2.1 类加载子系统:Java类的“安检通道”
类加载子系统负责把.class文件从磁盘、网络或者其他地方读进来,经过一系列校验和准备,最终生成JVM能够使用的Class对象。这个过程包括加载、链接(验证、准备、解析)、初始化三个阶段,后面我会专门用一整章来拆。
你可以把它理解为机场安检:旅客(字节码)经过身份核验(验证)、行李称重(准备)、确认航班信息(解析)、最后登机(初始化),每一步都不能少。没有类加载子系统,你在代码里写的任何一个类都进不了JVM。
这里有一个新手容易忽略的点:类加载是“按需”的,不是程序一启动就把所有类全部加载进内存。只有当一个类被真正使用到的时候,才会触发它的加载和初始化。这也是为什么一个大型应用启动时类加载会比较慢,但启动之后运行就流畅了——大部分类已经在运行过程中被陆续加载完了。
2.2 运行时数据区:程序运行时的“工作现场”
类加载进来之后,程序要开始跑,就得有地方存放数据。运行时数据区就是JVM在运行期间管理内存的各个区域,下面几个区域是它的主体:
- 程序计数器(Program Counter Register)
- 虚拟机栈(JVM Stack)
- 本地方法栈(Native Method Stack)
- 堆(Heap)
- 方法区(Method Area,JDK 8之后叫元空间Metaspace)
前三个是线程私有的,跟着线程生而生、线程灭而灭;堆和方法区是所有线程共享的,这两块内存的管理直接影响整个应用的稳定性。堆是GC(垃圾回收)的主战场,方法区存放类信息、常量、静态变量等数据。具体每一块是什么、怎么调参,下一章展开说。
2.3 执行引擎与本地接口:翻译字节码的“CPU”和对外通道
类加载好、数据也放进内存了,接下来要真正“执行”。执行引擎就是JVM的CPU,它负责解释执行或者编译执行字节码指令。JVM支持两种执行方式,一种是解释执行,相当于一句一句翻译;另一种是JIT编译,把热点代码编译成本地机器码,执行效率更高。现代JVM通常两者结合,先解释执行,运行过程中发现某段代码经常执行(热点代码),就触发了JIT编译。
垃圾回收器也是执行引擎的一部分——内存中那些不会再被引用的对象,需要有人清理掉,这就是GC做的事。你听过的Serial、Parallel、CMS、G1、ZGC,这些都是JVM里负责回收内存的收集器实现。
本地方法接口和本地方法库,则是JVM跟操作系统底层打交道的通道。Java不是所有功能都能自己实现,比如获取系统时间、操作文件、网络通信这些能力,最终都是通过native方法调用到C/C++写的本地库去完成的。你在代码里看到的所有方法,只要是以native关键字声明的,它的实现就在JVM之外。
2.4 为什么“类加载子系统 + 运行时数据区 + 执行引擎”是完整的机器
把这几个子系统合在一起,JVM就构成了一台完整的“虚拟计算机”:
- 类加载子系统负责“装系统”——把程序需要的类从外部装进来
- 运行时数据区负责“提供场地”——数据有地方放,指令有地方执行
- 执行引擎负责“跑任务”——真正逐条解释、编译和回收
所以,回答“JVM的组成”这个问题,不能只说“运行时数据区包含堆、栈、方法区”,那只是其中一块。完整的答案应该有三条主线:类加载子系统、运行时数据区、执行引擎,再加上与操作系统交互的本地接口。面试官问组成时,如果你能按这条主线回答,同时把每块的职责说清楚,基本就已经超过大多数背名词的候选人了。
3. 运行时数据区逐块拆解:堆、栈、方法区怎么分工
运行时数据区是JVM组成里最核心也最容易考的一部分,面试题里翻来覆去问的就是它。这一章我们把每一块区域单独拎出来,讲清楚它存什么、谁来用、大小怎么配、出问题是什么现象。
3.1 堆:所有对象实例的“家”
堆(Heap)是JVM内存中最大的一块,所有通过new创建的对象和数组都存放在这里。它是线程共享的,所以多个线程同时new对象时,JVM需要做线程安全处理。堆也是垃圾回收的主要区域,所以它又被细分为新生代、老年代,新生代里还有Eden区和两个Survivor区(S0、S1)。大多数对象先在Eden区出生,经过几次Minor GC还活着,就会被移动到Survivor区,再活得久一点最终进入老年代。
通过启动参数可以控制堆的大小:
bash复制# 堆初始大小和最大大小,通常建议两者设置相同,避免运行时动态扩容
java -Xms512m -Xmx1024m -jar myapp.jar
# 新生代大小
java -Xmn256m -jar myapp.jar
# Eden与Survivor的比例,默认是8:1:1
java -XX:SurvivorRatio=8 -jar myapp.jar
实际排障时,如果报了java.lang.OutOfMemoryError: Java heap space,意思就是堆内存满了,对象太多、回收不掉或者堆大小设置不够。这时候用jmap -dump把堆快照导出来,配合MAT或者VisualVM分析,就能看到是哪个类的对象占满了堆。
3.2 虚拟机栈:每个线程的“工作台”
虚拟机栈(JVM Stack)是线程私有的,生命周期和线程一致。你每调用一个方法,JVM就会为这个方法创建一个栈帧(Stack Frame),栈帧里有局部变量表、操作数栈、动态链接、方法出口等信息。方法开始执行,栈帧入栈;方法执行结束,栈帧出栈。
局部变量表存放方法里的基本类型变量和对象引用,操作数栈是方法执行过程中真正做计算的地方。比如你执行int c = a + b,JVM会先把a和b加载到操作数栈,执行加法指令,再把结果存回局部变量表。
一个线程能有多深的方法调用,取决于栈的大小。默认情况下,大部分JVM的实现里栈大小是512KB到1MB左右。如果递归调用过深或者方法调用层级过多,就会报StackOverflowError。可以用-Xss参数调整:
bash复制java -Xss256k -jar myapp.jar
注意,栈大小设得太小容易栈溢出,设得太大又可能因为线程数过多而耗尽内存。实际项目中如果你要开大量线程,可以把-Xss调小一点,省内存;如果是递归算法或者复杂调用链,就保持默认或者稍调大。
3.3 方法区与元空间:存放类的“户口本”
方法区存放的是JVM加载进来的类元数据:类的结构信息、运行时常量池、静态变量、方法字节码等。在JDK 8之前,方法区由堆中的“永久代”(PermGen)实现,JDK 8开始,永久代被移除,方法区改由“元空间”(Metaspace)实现。
元空间的存储不在JVM堆内,而是使用本地内存,默认情况下它可以根据系统可用内存自动增长。但你仍然可以手动设置上限,防止元空间无限膨胀:
bash复制# 设置元空间初始大小和最大大小
java -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -jar myapp.jar
如果你遇到java.lang.OutOfMemoryError: Metaspace,通常是因为加载了大量类(比如动态生成代理类、热部署反复加载类),或者元空间上限设得太小。与堆不同,堆满了是对象太多,元空间满了是类太多。
3.4 程序计数器与本地方法栈:两个容易被忽略的小角色
程序计数器(PC Register)是所有内存区域里最小的一块,也是线程私有的。它记录当前线程正在执行的字节码指令地址。因为线程切换后要恢复执行现场,所以每个线程必须有自己的程序计数器。注意,程序计数器是唯一不会出现OutOfMemoryError的区域。
本地方法栈(Native Method Stack)也是线程私有的,它服务于native方法。当Java代码调用本地方法时,本地方法的执行需要独立的栈空间。在HotSpot JVM中,本地方法栈和虚拟机栈合二为一,所以你在参数层面通常不需要单独配置它。
3.5 直接内存:JVM“看得到管不着”的区域
严格来说,直接内存(Direct Memory)不是运行时数据区的一部分,但在NIO编程里频繁出现,值得提一句。它是通过ByteBuffer.allocateDirect()分配的一块堆外内存,由操作系统直接管理,不受堆大小限制,但会受到机器物理内存和-XX:MaxDirectMemorySize参数的限制。
直接内存的好处是减少了数据在JVM堆和操作系统之间的拷贝次数,提升了IO性能;坏处是它不在堆上,堆里能看到的内存统计看不到它,一旦泄漏很难排查。如果你用了Netty、Kafka这类高IO框架,一定要关注直接内存的占用。
4. 类加载机制的本质:双亲委派与初始化时机
理解了运行时数据区,再看类加载子系统会顺畅很多。类加载不是简单地把.class文件“读进来”,它分三个阶段:加载、链接、初始化。链接又细分为验证、准备、解析。
4.1 加载、验证、准备、解析、初始化五步走
- 加载:通过类的全限定名获取该类的二进制字节流,把字节流中的静态存储结构转化为方法区的运行时数据结构,并在堆中生成一个Class对象作为访问入口。
- 验证:确保字节流符合JVM规范,不会危害JVM自身安全。比如检查魔数(前四个字节是CAFEBABE)、检查字节码指令是否合法。这一步是JVM自我保护的一道防火墙。
- 准备:为类的静态变量分配内存,并设置初始值。比如
static int count = 10,在准备阶段count会被设置为0,真正的10要在初始化阶段赋值。 - 解析:把常量池中的符号引用替换为直接引用。符号引用是类、方法、字段的名字和描述符,直接引用是指向实际内存地址的指针或偏移量。这一步通常发生在类初始化之后,但JVM规范允许在运行期间延迟解析,这就是动态绑定的基础。
- 初始化:执行类构造器
<clinit>方法,为静态变量赋真正的值,执行静态代码块。
4.2 双亲委派模型:为什么要“先让父亲加载”
JVM加载类时,默认采用双亲委派模型。当一个类加载器收到加载请求时,它先不自己加载,而是把请求委派给父加载器,父加载器再往上委派,直到最高层的启动类加载器(Bootstrap ClassLoader)。只有父加载器反馈自己无法完成加载时,子加载器才尝试自己加载。
为什么要这么设计?核心是安全。比如你定义一个类叫java.lang.String,双亲委派机制保证这个类一定是由启动类加载器去加载JDK自带的String,你自定义的String不会被加载,也就无法伪造核心类库。如果每个加载器都先自己加载,这种“李鬼”类就可能混进来。
JVM里的三层类加载器是这样的:
| 加载器 | 加载路径 | 说明 |
|---|---|---|
| Bootstrap ClassLoader | JAVA_HOME/lib核心类库 |
用C++实现,不是ClassLoader子类 |
| Extension ClassLoader | JAVA_HOME/lib/ext扩展类库 |
JDK 9之后改为平台类加载器 |
| Application ClassLoader | classpath(即-cp指定的路径) |
加载你项目里的类 |
实际工作中遇到的类冲突问题,比如引入了不同版本的同一个库导致NoSuchMethodError,本质上就是类加载层面出了问题,多个类加载器加载了同一个类的不同版本,或者父加载器加载了A版本、子加载器加载了B版本。排查时用-verbose:class启动参数可以看到每个类具体是由哪个加载器加载的。
4.3 什么时候会触发类的初始化
类加载的“加载”和“初始化”是有区别的,一个类被加载了不代表被初始化了。JVM规范规定了六种主动使用场景会触发初始化:
- 遇到
new、getstatic、putstatic、invokestatic字节码指令 - 使用
java.lang.reflect进行反射调用时 - 初始化子类时,如果父类还没初始化,先触发父类初始化
- 程序启动时指定了要执行的main类
- 使用JDK 7的动态语言支持时
- 接口定义了默认方法,接口实现类初始化时
而被动使用不会触发初始化,比如通过子类访问父类的静态字段、定义类数组、引用类的静态常量(编译期常量在编译阶段就写入了常量池)。面试常问的“某个类会不会被初始化”,就是在考这组边界。
5. 执行引擎与垃圾回收:JVM如何真正“跑起来”
类加载好了,数据也放好了,执行引擎开始干活。这一章讲两块:字节码是怎么执行的,以及JVM怎么自动管理内存。
5.1 解释执行与JIT编译
JVM执行字节码有两种方式。一种是解释执行(Interpreter),逐条把字节码翻译成机器码执行,启动快但运行效率低;另一种是JIT编译执行,把整个方法体编译成机器码,执行快但编译本身需要耗时。
HotSpot JVM采用“解释器 + JIT编译器”混合模式。程序启动时先由解释器解释执行,同时JVM会统计哪些代码是热点代码(比如循环次数多、被频繁调用的方法),一旦判定为热点,就交给JIT编译器编译成本地代码,后续直接执行编译结果。这就是“热点探测”技术的由来。
JIT编译器又分C1和C2(JDK 10之后是两个独立编译器,还在演进)。C1编译速度快、优化程度低,适合对启动速度敏感且不需要重度优化的场景;C2编译速度慢、优化程度高,适合服务端长期运行的应用。分层编译(Tiered Compilation)的思路是:先用C1快速编译让程序跑起来,再在后台用C2做深度优化,两者结合达到“启动快”和“峰值性能高”的平衡。
实际调优时需要注意,如果线上流量有明显的“预热”现象——刚发布完的实例性能不如运行一段时间后的实例——多半就是JIT还在累积编译热点,这属于正常现象。压测时一定要给足预热时间,否则测出来的数据会偏低。
5.2 垃圾回收:回收哪块内存、用什么思路
JVM的垃圾回收主要针对堆内存。哪些对象可以回收?核心是“可达性分析”:从GC Roots出发,沿着引用链找不到的对象,就是可回收对象。GC Roots包括栈中的局部变量引用、静态变量引用、JNI引用等。
回收算法有几种基础思路:
- 标记-清除:先标记出可回收对象,再统一回收。优点是简单,缺点是产生大量内存碎片。
- 标记-复制:把内存分成两块,回收时把存活对象复制到另一块,整块清空。适合对象存活率低的新生代场景。
- 标记-整理:标记后可回收对象,存活对象向一端移动,直接清理边界外的内存。适合老年代场景。
现代JVM的堆分代设计——新生代、老年代——配合了这些算法。新生代对象朝生夕灭,适合用复制算法,所以Eden区和两个Survivor区的结构本质上就是复制的具体实现;老年代对象存活时间长,空间也大,用标记-整理更合适。
垃圾回收器方面,从最早的Serial、Parallel,到CMS,再到G1,以及JDK 11以后的ZGC,每一代收集器的核心诉求都是在“停顿时间”和“吞吐量”之间找平衡。G1是当前JVM的默认收集器(JDK 9之后),它把堆划分成多个大小相同的Region,用可预测的停顿时间模型替代了原来物理分代的设计思想。ZGC则用染色指针和读屏障等技术,把GC停顿时间降到了几毫秒甚至更短。
关于垃圾回收,后面我会单独写一篇展开。在这个“组成”的总览篇里,你只需要理解:垃圾回收器属于执行引擎的一部分,它的职责是自动管理堆内存,你写Java代码时不需要手动释放对象,JVM会在后台替你处理。这也正是Java相对C/C++在开发效率上的巨大优势之一。
6. 从组成出发排查两类高发报错
理解了组成,最大的好处就是排查问题时有了方向感。我拿两个实际遇到过的报错来演示:如何从“JVM组成”的视角去定位问题。
6.1 “Error invoking method. Failed to launch JVM”排查链路
这个报错最常见于Eclipse、IDEA这类IDE启动时,或者在代码里调用Java扩展工具链时。它的字面意思是“调用方法失败,无法启动JVM”。很多人看到这个报错会懵,但回到JVM组成的层面,无非是下面几个环节出了问题:
- 启动指定了不合理的JVM参数,比如
-Xmx设置的大小超过了机器可用物理内存,JVM启动时分配不了这么多内存,直接启动失败。 - 32位JDK在Windows下最多只能分配不到1.5GB的堆内存,如果
-Xmx设成了2GB甚至更大,启动就会失败。 - 多个JDK版本共存导致启动器(java.exe)与库文件版本不匹配。比如PATH里指向的是JDK 8的java.exe,但IDE配置要用JDK 17的库,就会出现JVM版本不一致。
- IDE的配置文件(如eclipse.ini)里参数写错了,比如
-Xmx和-Xms写在同一行、或者值格式不对。 - 系统磁盘空间不足,JVM启动需要创建临时文件和日志文件,写不进去就会启动失败。
- 杀毒软件或安全策略阻止了JVM访问临时目录。
排查步骤我建议按这个顺序来:
- 先用
java -version确认当前命令行使用的Java版本和位数,排除多版本混乱的问题。 - 用
java -Xmx2g -version这类命令手工测试JVM能否正常启动,能启动说明命令行没问题,问题在配置层。 - 检查IDE配置文件(比如eclipse.ini里
-Xmx和-Xms的写法),对照官方文档确认参数格式。 - 查看系统磁盘剩余空间、临时目录权限,排除环境层面的干扰。
- 把JVM参数调保守,
-Xmx从512m开始逐步往上加,找到能让JVM启动的上限。
这类报错能直观说明“JVM的参数配置”和“运行环境状态”之间的关系——你写的每一个启动参数,都会被JVM启动流程严格执行,任何一个环节承载不了,它就会直接拒绝启动,而不是给你降级处理。
6.2 “无法编译为JVM目标 17 配置的模块”怎么解决
另一个高频报错是在IDEA或Maven编译时出现“无法编译为JVM目标 17 配置的模块”或“指定的回退 s”这类信息。这不是运行时问题,而是编译期问题,根源在于编译器的目标字节码版本和当前环境支持的最高版本不匹配。
简单说,你项目里配置的编译目标(比如设置为17),但你机器上安装的JDK版本低于17,javac编译器根本生成不了目标版本为17的字节码;或者项目用到的依赖模块、插件版本太老,不支持Java 17的字节码格式。
排查和解决思路:
- 确认你安装的JDK版本:
java -version,至少要达到17或更高。 - 在Maven的
pom.xml中配置maven-compiler-plugin,明确source和target:
xml复制<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
或者用--release参数,它同时约束source、target和系统API版本,避免出现“SDK版本比target新但API用了更新的”这种隐蔽问题:
xml复制<properties>
<maven.compiler.release>17</maven.compiler.release>
</properties>
- 在IDEA里,打开
Project Structure(快捷键Ctrl+Alt+Shift+S),检查Project SDK和Project language level,确保SDK是JDK 17、语言级别选17。还要检查Settings -> Build Tools -> Maven -> Importing里的JDK设置。
这个报错背后的原理,正好落在“类加载”和“字节码版本”这两个概念上:Java的每个版本都有自己的字节码版本号(class file major version,JDK 17对应61),高版本的JVM可以运行低版本的class文件,但低版本的JVM无法加载高版本的class。所以不仅编译时要版本匹配,部署运行环境的JDK版本也不能低于编译版本。这也是生产环境里常见的坑——本机编译用JDK 17,服务器装的是JDK 8,上去直接报UnsupportedClassVersionError。
7. 用命令和参数把JVM组成“看”明白
纸上谈兵没意思,这一章给实际可用的观察手段。理解JVM组成之后,怎么验证你的理解?用命令直接“看”JVM的内部。
7.1 三组最常用的JDK自带命令
JDK自带的命令行工具是排查JVM问题的基础设施,都放在JAVA_HOME/bin目录下:
| 命令 | 作用 | 使用场景 |
|---|---|---|
jps |
列出当前机器上正在运行的Java进程PID | 先拿到进程号,后续命令都要用 |
jstat |
查看JVM统计信息,包括堆各分区使用量、GC次数和耗时 | 观察堆内存变化、GC频率,评估是否要调堆大小 |
jmap |
导出堆快照、查看堆内存分布 | 分析OOM问题时最常用的工具 |
jstack |
导出线程快照,查看线程状态和堆栈信息 | CPU飙高、线程死锁、卡顿问题排查 |
jcmd |
综合诊断命令,替代了部分jmap和jstat的功能 | JDK 8之后很好用的多功能工具 |
实操里最经典的一条链路:
bash复制# 1. 找到Java进程PID
jps -l
# 2. 每隔1秒打印一次堆内存使用情况,共打印10次
jstat -gcutil 12345 1000 10
# 3. 导出堆快照,后续用MAT分析
jmap -dump:format=b,file=heap.hprof 12345
# 4. 导出线程快照,排查卡顿
jstack 12345 > thread-dump.txt
看到命令输出后,结合上面讲的堆分代结构,你能立刻知道Eden区是不是满了、Minor GC每秒触发多少次、老年代占用是不是在持续上涨。这些信息直接对应JVM组成里的那些区域,知道它们是什么,就知道下一步该调什么参数。
7.2 记住这几个最实用的JVM参数
不推荐背一大堆参数,但下面这些是日常必然用到的,建议记牢:
-Xms/-Xmx:堆初始大小和最大大小。生产环境建议设为相同值,省去扩容开销。-Xmn:新生代大小,通常设为堆总大小的三分之一到四分之一。-Xss:每个线程的栈大小,需要大量线程时调小,递归深度大时调大。-XX:MetaspaceSize/-XX:MaxMetaspaceSize:元空间初始大小和上限。-XX:+HeapDumpOnOutOfMemoryError:OOM时自动导出堆快照。这是压箱底的好参数,线上出了问题至少能把罪证留下来。-XX:SurvivorRatio:Eden和Survivor区的比例,默认8。-XX:MaxDirectMemorySize:直接内存上限。
举个例子,一个典型的服务端Java进程启动参数可以这样配:
bash复制java -Xms2g -Xmx2g -Xmn768m -Xss512k \
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/data/logs/heap.hprof \
-jar myapp.jar
这样配的意思是:堆固定2GB,其中新生代用768MB,线程栈512KB,元空间最多512MB,一旦OOM就把现场快照存到指定路径。你完全可以根据机器的实际内存和业务类型去调整——先理解每项参数管的是哪块区域,再动手调,就不会两眼一抹黑地乱改。
7.3 建议的学习路径
JVM的组成是后续一切知识的基础,学到这里,可以给自己安排一个验证小实验来检验理解程度:
- 写一个简单的Java程序,分别创建大量对象、抛出异常、写一个无限递归方法,看看报错和堆栈输出的内容是否与前面讲的区域一一对应。
- 用
jps观察程序进程,再用jstat看堆内存变化,理解Eden区、Survivor区、老年代的数据流转。 - 手动指定不同的
-Xms、-Xmx、-Xss参数,体会参数变化对内存使用和报错行为的影响。
这套实验做完,你对JVM组成就不是背出来的知识,而是自己验证过的事实。遇到面试题也好、线上故障也好,脑子里画得出那张架构图,心里就不慌。
写在最后:一个博主的小建议
我当年学JVM走过一段弯路:一上来就啃《深入理解Java虚拟机》,每个字都认识,但读完之后好像什么都没抓住。后来我换了个笨办法——先跑起来一个Java程序,然后用命令去“看”它里面的堆、栈、方法区,看到一个现象就回去翻书对应一个概念。就这样一个循环一个循环地过,JVM组成才算真正在脑子里扎下了根。
如果你也是刚开始学JVM,这篇笔记的定位就是一张地图。有了地图上的各个地名,后面讲内存分配、讲GC算法、讲性能调优,你才知道那些内容挂在哪个位置。下一篇笔记我会继续写JVM的内存分配策略和对象逃逸,到时候会大量用到这一篇里的基础概念。先把组成弄扎实,后面提升起来会快很多。
