你有没有遇到过这样的场景——线上服务突然变慢,CPU 飙到 100%,你却只能重启了事?面试官问你"讲讲 JVM 内存结构",你只记得堆和栈两个词,然后陷入尴尬的沉默?
别焦虑,你不是一个人!大多数 Java 开发者的日常都是在和 Spring Boot、MyBatis 打交道,JVM 就像每天呼吸的空气——看不见摸不着,但一旦出了问题,你就知道它有多重要。更微妙的是,面试官也清楚初级开发者不可能精通 JVM 调优,他们真正想考察的,是你对这门语言底层机制的好奇心和学习潜力。
本文是 JVM 系列的上篇,我们会从最基础的概念讲起,不要求你有任何 JVM 前置知识。当你读完本文,你将能自信地画出 JVM 内存结构图、解释类加载过程、讲清楚垃圾回收的核心原理——这些都是面试中最基础也最高频的考点。
分层导读:不同阶段的 Java 开发者需要学什么?
在正式进入内容之前,我们先做一个"自我定位"。不同阶段对 JVM 的要求差异很大,你不必一次性掌握全部内容:
| 阶段 | 核心关注点 | 学完本文能做什么 |
|---|---|---|
| 初级(0-2 年) | JVM 是什么、类加载、运行时数据区 | 面试能画内存结构图,能解释堆、栈、方法区 |
| 中级(2-5 年) | GC 原理、GC 日志、JVM 参数调优 | OOM 不再只会重启,能定位问题并说清原因 |
| 高级(5 年+) | JIT 编译、字节码、JMM、性能诊断 | 主导线上故障排查和架构级性能优化 |
| 面试冲刺 | 高频考点 + 典型面试题 | 有条理地回答 JVM 问题,不再支支吾吾 |
本文重点覆盖初级到中级前半段的内容。关于垃圾收集器实战、诊断工具、JIT 编译和面试题精讲,请阅读本系列下篇。
第一章:JVM 到底是什么?——先让你心里有张地图
1.1 "一次编写,到处运行"的秘密
几乎所有 Java 程序员的第一课都会听到这句话:Write Once, Run Anywhere。
Windows 的 .exe 只能跑在 Windows 上,Linux 的二进制文件只能跑在 Linux 下,凭什么 Java 的 .class 文件能到处跑?答案就在 JVM。
JVM(Java Virtual Machine,Java 虚拟机)本质上是一个"翻译官"——把 .class 文件中的字节码翻译成操作系统能理解的机器指令。不同操作系统有不同的 JVM 实现,但它们面对同一份 .class 文件时,执行逻辑完全一致。
1.2 JDK、JRE、JVM 到底什么关系?
这是面试高频考点,但很多工作了两三年的人也说不清楚。三者的关系其实很简单:
┌─────────────────────────────────────┐ │ JDK │ │ ┌─────────────────────────────┐ │ │ │ JRE │ │ │ │ ┌──────────────────────┐ │ │ │ │ │ JVM │ │ │ │ │ │ (HotSpot / OpenJ9) │ │ │ │ │ └──────────────────────┘ │ │ │ │ 核心类库 (rt.jar / lib) │ │ │ └─────────────────────────────┘ │ │ 开发工具 (javac, javap, jstack...) │ └─────────────────────────────────────┘- JVM:最底层,负责执行字节码。你写的 HelloWorld 能跑起来靠的就是它。
- JRE(Java Runtime Environment)= JVM + 核心类库。光有 JVM 不够,
String、HashMap这些基础类都在核心类库里。 - JDK(Java Development Kit)= JRE + 开发工具。javac(编译器)、javap(反编译)、jstack(线程分析)都在这。
打个比方:JVM 是发动机,JRE 是能跑的车,JDK 是带全套修车工具的车。普通用户只需要车(JRE),而我们开发者需要能造车也能修车的工具(JDK)。
1.3 HotSpot 是什么?常见的 JVM 实现有哪些?
我们通常说的"JVM",绝大多数时候指的是HotSpot VM——Oracle 和 OpenJDK 默认使用的虚拟机,市场份额超过 90%。
| JVM 实现 | 特点 | 使用场景 |
|---|---|---|
| HotSpot | Oracle/OpenJDK 默认 | 通用,99% 的 Java 应用 |
| OpenJ9 | IBM 开源,启动快、内存占用低 | 云原生、微服务容器化场景 |
| GraalVM | Oracle 出品,支持多语言混编 | 高性能计算、多语言项目 |
| Zing/Azul | C4 算法,超低延迟 GC(付费) | 金融交易系统等 |
对于绝大多数开发者来说,掌握 HotSpot 就足够了,本文所有内容也基于 HotSpot 展开。
第二章:类加载机制——你的代码是怎么"活"起来的?
搞清楚了 JVM 的定位,接下来问一个具体问题:你写的User user = new User(),这个User类到底是怎么被 JVM 认识的?
2.1 类加载三部曲:加载 → 链接 → 初始化
JVM 把类从 .class 文件变成可用的"活对象",经过三个大阶段:
关键细节——准备阶段 vs 初始化阶段,这几乎是面试必问的点:
// 假设类中有这个静态变量 public static int value = 123;- 准备阶段:JVM 给
value分配内存,并设为默认值0(还不是123!) - 初始化阶段:执行
<clinit>()方法,value才被真正设置为123
如果是public static final int value = 123;,则编译期就会将常量值写入字段——这就是编译时常量优化。
2.2 双亲委派模型:为什么你写的 java.lang.String 不会生效?
你有没有好奇过:如果自己写了一个java.lang.String类放到 classpath 下,能替换掉 JDK 自带的 String 吗?
答案是不能。这背后的保护机制,就是双亲委派模型。
工作流程:当一个类需要被加载时,类加载器不会自己先动手,而是先把请求向上委托给父加载器。只有父加载器反馈"我找不到这个类"时,子加载器才会自己去加载。
就像审批链条:科长收到请求 → 找处长 → 处长找局长 → 局长说他不管 → 处长处理 → 处长也不管 → 科长自己处理。
这样做的好处:
- 避免核心类被篡改:你写的
java.lang.String会先被启动类加载器拦截——它发现这个全限定名属于自己管辖范围,直接加载了官方的 String,你的版本永远不会被使用。 - 避免重复加载:同一个类只会被加载一次。
面试官可能会追问:"如何打破双亲委派?"答案是自定义 ClassLoader 并重写
loadClass()(JDK 1.2 之后更推荐重写findClass())。Tomcat 的 WebappClassLoader 就是典型案例——不同 Web 应用之间需要隔离,所以打破了双亲委派。
2.3 不得不打破的时候:SPI 机制怎么绕过去的?
实际开发中,双亲委派并不是铁板一块。JDK 自身的SPI(Service Provider Interface)机制就需要打破它。以 JDBC 为例:
// 这行代码为什么能加载到 MySQL 的驱动类? Connection conn = DriverManager.getConnection(url, user, password);DriverManager由启动类加载器加载,但 MySQL 驱动在 classpath 下,由应用类加载器加载。按双亲委派,启动类加载器根本看不到 classpath 下的类,怎么办?
JDK 的解决方案是线程上下文类加载器(Thread Context ClassLoader):
// DriverManager 内部实际上这样获取了驱动 ClassLoader cl = Thread.currentThread().getContextClassLoader(); // cl 就是应用类加载器,可以加载 classpath 下的 MySQL 驱动这告诉我们:没有银弹。双亲委派解决了核心类安全问题,但在需要"父加载器访问子加载器的类"时(SPI 是典型),就必须绕过了。
第三章:运行时数据区——JVM 的"五脏六腑"
类加载让代码进入了 JVM,接下来代码运行时,数据放在哪里?这就是面试中最核心的知识点之一:JVM 运行时数据区(Runtime Data Area)。
3.1 先给你一张全景图
不要被术语吓到,我们先整体看一眼,然后逐个拆解:
记忆口诀:"三私两公一堆外"——程序计数器、虚拟机栈、本地方法栈是线程私有的;堆和方法区是线程共享的;直接内存是堆外面的。
3.2 程序计数器(PC Register):最简单也最不起眼
它可能是 JVM 中最低调的一块内存——只存一条信息:当前线程正在执行的字节码指令行号。
但它不可或缺。CPU 在多线程之间切换时,必须知道每个线程"执行到哪了"。程序计数器就是干这个的——记录执行位置,方便切换回来后接着执行。
两个特点:它是 JVM 中唯一不会抛出 OutOfMemoryError的区域;执行 native 方法时值为 undefined。
3.3 Java 虚拟机栈(JVM Stack):方法调用的幕后玩家
每个 Java 方法被调用时,JVM 都会同步创建一个栈帧(Stack Frame),压入虚拟机栈:
方法执行完毕,栈帧出栈。如果方法里调了另一个方法,就把新栈帧压在上面——这正是"栈"的含义(后进先出)。
你遇到过
StackOverflowError吗?它就是因为方法调用层次太深(比如无限递归),虚拟机栈的容量不够了。设置栈大小的参数是-Xss,如-Xss1m。
3.4 Java 堆(Heap):所有对象的故乡
User user = new User(); // 这个 new User() 创建的对象,就放在堆里堆是 JVM 中最大的一块内存,也是 GC(垃圾回收)的主战场。几乎所有对象都在堆上分配。
堆的内存结构(分代视角):
为什么新生代要分 Eden 和 Survivor?如果只有 Eden,第一次 Minor GC 能回收的对象还好说,但存活下来的对象去哪?直接放老年代会导致老年代被快速填满、触发代价高昂的 Full GC。Survivor 区就是给那些"刚出生还死不掉"的对象一个缓冲地带。
3.5 方法区(Method Area):类的"档案馆"
方法区存放的是类信息(类名、字段、方法、接口版本)、常量、静态变量、JIT 编译后的代码缓存。
一个重要版本变更:
| 版本 | 方法区实现 | 所在位置 |
|---|---|---|
| JDK 7 及以前 | 永久代(PermGen) | 堆的一部分 |
| JDK 8 及以后 | 元空间(Metaspace) | 本地内存(堆外) |
# JDK 7:永久代大小固定 -XX:MaxPermSize=256m # JDK 8+:元空间默认无上限,但强烈建议设置上限 -XX:MaxMetaspaceSize=256m这个改动非常实际——以前部署应用时经常因为加载的类太多导致java.lang.OutOfMemoryError: PermGen space,换成元空间后好多了。
3.6 运行时常量池 vs 字符串常量池
这也是个容易混淆的点:
- 运行时常量池:每个类都有一个常量池,类加载后成为运行时常量池,属于方法区的一部分。
- 字符串常量池:JDK 7 开始被移到了堆中。
String s1 = "hello"; // 字面量,在字符串常量池中 String s2 = new String("hello"); // 在堆中创建新对象 String s3 = s2.intern(); // 将 s2 的值放入字符串常量池并返回该引用 System.out.println(s1 == s2); // false(常量池 vs 堆中不同对象) System.out.println(s1 == s3); // true (都指向常量池中的同一对象)这段代码是面试常客。理解它的关键是:
new一定在堆中创建新对象,intern()操作的归宿是字符串常量池。
第四章:垃圾回收基础——JVM 最精彩的篇章
理解了内存是怎么划分的,下一个自然的问题就是:内存是有限的,不用的对象谁来清理?这一章我们聚焦在 **"怎么找到垃圾"**和"怎么清理垃圾"这两个核心问题上。
4.1 怎么判断一个对象"死了"?
回收垃圾的第一步是找到垃圾。
(1)引用计数法——最直观但被淘汰的方案
给每个对象加一个引用计数器,有人引用就 +1,引用失效就 -1,计数器归零就回收。听起来很合理,但有一个致命缺陷:循环引用。
class Node { Node next; } Node a = new Node(); Node b = new Node(); a.next = b; b.next = a; // a 和 b 互相引用 a = null; b = null; // 外部引用都没了,但 a 和 b 的计数器都不是 0 —— 永远无法回收!(2)可达性分析——JVM 实际采用的方法![]()
GC Roots 包括哪些?(面试必考)
虚拟机栈中引用的对象(局部变量指向的对象)
方法区中静态属性引用的对象
方法区中常量引用的对象
本地方法栈中引用的对象
被 synchronized 锁持有的对象
4.2 四种引用类型:强、软、弱、虚
Java 提供了四种引用,让开发者能更精细地控制对象的生命周期:
引用类型 | 回收时机 | 典型用途 |
|---|---|---|
强引用 | 永不回收(除非不可达) | 99% 的场景 |
软引用 | 内存不足时回收 | 图片缓存、网页缓存 |
弱引用 | 下次 GC 必定回收 | ThreadLocal 的 Key |
虚引用 | 无法通过它获取对象 | NIO DirectByteBuffer 清理 |
// 软引用示例——内存敏感的缓存 SoftReference<byte[]> cache = new SoftReference<>(new byte[1024 * 1024 * 10]); byte[] data = cache.get(); if (data == null) { // 缓存已被回收,重新加载 data = loadFromDisk(); cache = new SoftReference<>(data); }ThreadLocal 内存泄漏的根因就藏在这里:ThreadLocalMap 的 Key 是弱引用,但 Value 是强引用。Key 被 GC 后变成 null,而 Value 仍然被 Map 持有、无法回收——除非显式调用
remove()。
4.3 垃圾回收算法:从理论到实践
找到垃圾之后,怎么清理?有三种经典算法:
(1)标记-清除(Mark-Sweep)——最基础
缺点:效率不高,且产生内存碎片。
(2)标记-复制(Mark-Copy)——新生代主力![]()
没有碎片,但可用内存减半。为什么适合新生代?因为新生代 98% 的对象"朝生夕死",需要复制的存活对象很少,复制成本极低。
这也解释了为什么新生代有两个 Survivor:Eden 和一个 Survivor 作为"使用中"的内存,另一个 Survivor 是"闲置空间"来接收存活对象。两个 Survivor 交替使用,永远有一块是空的。
(3)标记-整理(Mark-Compact)——老年代主力![]()
老年代存活率高,不适合复制算法(复制成本太高)。整理算法没有碎片,但需要移动对象,耗时较长。
4.4 分代收集:为什么要"分代"?
三种算法各有优劣,JVM 的策略是因地制宜——把一个堆分成不同区域,对不同区域用不同算法:
弱分代假说:绝大多数对象是朝生夕死的,活得越久的对象越不容易死。
基于这个经验事实:
- 新生代:"死亡率"极高 → 用标记-复制(复制成本低,回收效率高)
- 老年代:"存活率"高 → 用标记-清除或标记-整理(避免频繁复制)
这就是分代收集的核心思想。理解了它,你就理解了 JVM 垃圾回收的一大半。
下篇预告
本文带你走完了 JVM 基础知识的"主干道":JVM 是什么 → 类怎么加载进来的 → 运行时数据放在哪里 → 垃圾怎么回收。
在下篇中,我们会进入动手实战环节——认识 HotSpot 中真正干活的垃圾收集器(G1、CMS、ZGC 等),学习 JVM 核心参数和 GC 日志,掌握线上排障的诊断工具用法,并最终用十道面试精讲帮你把知识转化为面试中的得分点。
建议:在阅读下篇之前,先把本文中的内存结构图和回收算法画一到两遍——肌肉记忆会帮你在面试时更自信。
【下一篇:JVM 征服手册:从 CRUD 到性能调优的完整指南(二)- 进阶实战】