你写下人生第一行 Java 代码的时候,大概是这样的:
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, World");
}
}
你按下运行键,控制台跳出 Hello, World。这一刻发生了什么?编译器把 .java 变成 .class,然后……然后呢?这个 .class 文件是怎么变成你看到的输出的?
答案是:有一台「虚拟的电脑」在替你执行它。这台虚拟电脑就是 JVM(Java Virtual Machine)。理解 JVM,本质上就是搞清楚 这台虚拟电脑长什么样、怎么干活、什么时候会出问题。
这一篇(入门篇)先把地基打牢:JVM 的内存怎么分区、字节码长什么样、一个对象是怎么被 new 出来的、类又是怎么被加载进来的。搞懂这些,第二篇的垃圾回收、第三篇的 JIT 才有地方安放。
TL;DR
- JVM 是一台跑在操作系统上、逐条执行字节码的「口袋电脑」;「Write Once, Run Anywhere」靠的是每个平台都有一个 JVM 替你翻译,而不是代码本身能到处跑。
- 内存分五区:堆(
new出来的对象)、虚拟机栈(方法工作台 = 局部变量表 + 操作数栈)、方法区 / 元空间(类的图纸)、程序计数器、本地方法栈。 - 字节码是一套「基于操作数栈」的指令集,
javap -c能亲眼看到它压栈、弹栈、算数 —— 看得懂这层,才谈得上看懂后面 JIT 在优化什么。 - 一行
new= 类加载检查 → 分配内存(走 TLAB)→ 赋零值 → 设对象头 → 执行构造器;HotSpot 用「直接指针」访问对象。 - 类经「加载 → 验证 → 准备 → 解析 → 初始化」进入 JVM,加载遵循双亲委派(但 SPI / Tomcat 等框架常打破它)。
Table of contents
Open Table of contents
一、为什么需要 JVM:一个「翻译官」的故事
1.1 一个最容易理解的类比
假设你要在中国、美国、日本三个国家做同一场演讲。最笨的办法是学三门语言,每个国家都重新写一遍稿子。聪明一点的办法是:只写一份稿子(中文),每个国家配一个翻译。
JVM 干的就是翻译这件事。你的 Java 代码只写一份,编译成统一的「中间语言」(字节码),然后:
| 平台 | JVM 做的事 |
|---|---|
| Windows | 把字节码翻译成 Windows 能听懂的指令 |
| Linux | 把字节码翻译成 Linux 能听懂的指令 |
| macOS | 把字节码翻译成 macOS 能听懂的指令 |
一份源码编译成统一字节码,各平台的 JVM 各自把它翻译成本地机器指令。
一句话本质
Java 那句口号 「Write Once, Run Anywhere」 的真相 —— 不是 Java 代码本身能到处跑,而是 每个平台都有一个 JVM 在替它跑。
1.2 JVM 到底是什么
剥开所有花哨的修饰,JVM 就是一个 运行在你操作系统上的普通程序。它的工作是读取 .class 文件,按文件里的字节码指令一条一条执行。
你可以把它想象成一台「口袋电脑」:它有自己的内存(不是物理内存,而是从操作系统申请来的一块)、自己的 CPU 模拟器、自己的「硬盘」(用来存类信息)。你的 Java 程序就在这台口袋电脑里运行。
搞清楚这一点之后,下面所有的内容都好理解了 —— 我们其实就是在研究这台口袋电脑的内部构造。
二、口袋电脑长什么样:JVM 的内存分区
既然 JVM 是一台口袋电脑,它就得有内存。但 JVM 的内存不像你电脑里那一整条内存条那样混在一起用,它被 划分成了好几个区域,每个区域有自己的用途。
为什么要分区?
打个比方:你家有客厅、厨房、卧室、书房,不是说一个大房间不能住,而是分开之后每个区域专心干一件事,效率更高。JVM 的分区也是这个思路。
运行时数据区分成线程共享(堆、方法区)与线程私有(虚拟机栈、本地方法栈、程序计数器)两大类。
2.1 一行代码经过的所有「房间」
我们看一段代码,跟着它走一遍:
public void greet() {
String name = "Alice";
User user = new User(name);
System.out.println(user);
}
这段代码运行起来时,JVM 内存里会发生这些事:
greet方法本身的信息(字节码、参数表等)—— 存在 方法区;- 方法被调用,JVM 为它分配一个「工作台」(栈帧),栈帧放在 虚拟机栈;
- 局部变量
name、user这两个引用 —— 存在栈帧里的局部变量表; - 真正的
User对象 —— 存在 堆 里; - JVM 当前执行到哪一行 —— 存在 程序计数器 里。
一次方法调用会同时用到方法区、虚拟机栈、堆与程序计数器;栈里的引用指向堆里的真实对象。
是不是有点晕?没关系,我们一个一个看。
2.2 堆(Heap):对象的家
核心结论
所有用
new创建出来的对象,都住在堆里。
记住这一句就够了。new User()、new ArrayList<>()、new int[100],统统都在堆里。
为什么把对象单独放一个地方?因为对象是 JVM 里 最大的群体,数量多、生命周期不一、占用空间不同。给它们分配专门的区域,方便统一管理(特别是第二篇要讲的垃圾回收,专门跟堆里的对象打交道)。
堆是 所有线程共享的 —— 你在 A 线程里创建的对象,B 线程也能访问到。
堆里的对象长什么样:对象头
每个对象在堆里都由三部分拼成:
- 对象头(Object Header):存对象的「身份信息」—— 哈希码、GC 年龄(第二篇讲晋升要用的那个年龄计数器,就存在这)、锁状态等,这部分叫 Mark Word;外加一个 类型指针,指向方法区里的类元信息,告诉 JVM「我是哪个类的实例」;
- 实例数据:你在类里定义的那些字段的真实值;
- 对齐填充:把对象大小凑成 8 字节的整数倍,纯粹为了 CPU 读取效率,可有可无。
所以一个
new Object()哪怕一个字段都没有,也要占 16 字节(对象头 + 对齐填充)。
眼见为实:用 OpenJDK 的 jol(Java Object Layout)把 new Object() 的内存布局打印出来(64 位 + 开启压缩指针):
java.lang.Object object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0x00001000
12 4 (object alignment gap)
Instance size: 16 bytes
一眼验证:Mark Word 8 字节 + 类型指针 4 字节(压缩后)+ 对齐填充 4 字节 = 正好 16 字节,和上面说的一模一样。顺便留意那个 age: 0 —— 它就是第二篇讲对象晋升要用的 GC 年龄计数器。
2.3 栈(Stack):方法的工作台
每调用一个方法,JVM 就给它发一张「工作台」,叫做 栈帧(Stack Frame)。这张工作台上放着:
- 局部变量表:方法的参数和局部变量;
- 操作数栈:计算过程中的临时数据(下一节马上细讲);
- 返回地址:这个方法执行完了要回到哪里继续。
方法调用结束,工作台被收走(栈帧出栈)。
栈是 每个线程独有的 —— 你的栈和我的栈互不干扰,这也是为什么局部变量天然就是线程安全的。
两个经典异常(附一处常见误解的澄清)
StackOverflowError:方法套方法套太深了,栈帧堆不下。最经典的场景 —— 递归忘了写终止条件。OutOfMemoryError:注意,HotSpot 的线程栈大小是固定的(由-Xss决定,不会动态扩容),所以「栈太深」只会抛StackOverflowError,不会是 OOM。真正跟栈有关的 OOM 是 线程开太多、系统没内存再分配新线程栈,报的是unable to create new native thread。(很多老教材说「栈动态扩容时内存不足抛 OOM」,那是 JVM 规范允许、但 HotSpot 并没这么实现的情况,别被带偏。)
2.4 一眼看懂字节码:操作数栈是怎么算数的
前面反复提到「字节码」,但它到底长什么样?我们把最开头那句口号落到实处。写一个最简单的方法:
int add() {
int a = 1;
int b = 2;
return a + b;
}
javac 编译成 .class 后,用 javap -c 反编译,就能看到 JVM 真正执行的字节码:
int add();
Code:
0: iconst_1 // 把常量 1 压入操作数栈
1: istore_1 // 弹出栈顶,存入局部变量表 slot 1(就是 a)
2: iconst_2 // 把常量 2 压入操作数栈
3: istore_2 // 弹出栈顶,存入 slot 2(就是 b)
4: iload_1 // 把 a 读回操作数栈
5: iload_2 // 把 b 读回操作数栈
6: iadd // 弹出栈顶两个数相加,结果压回栈
7: ireturn // 返回栈顶的 int
关键在于:JVM 是一台「基于栈」的虚拟机(不是基于寄存器)。它没有 CPU 那种 eax、rbx 寄存器,所有运算都靠 操作数栈 来回压弹。跟着上面 8 条指令走一遍,栈帧里两块区域的变化一目了然:
指令 局部变量表 [slot1, slot2] 操作数栈(自底向上)
iconst_1 [ - , - ] [ 1 ]
istore_1 [ 1 , - ] [ ]
iconst_2 [ 1 , - ] [ 2 ]
istore_2 [ 1 , 2 ] [ ]
iload_1 [ 1 , 2 ] [ 1 ]
iload_2 [ 1 , 2 ] [ 1 , 2 ]
iadd [ 1 , 2 ] [ 3 ] ← 弹出 1、2,压回 3
ireturn [ 1 , 2 ] [ ] → 把 3 返回给调用者
为什么要懂这一层
- 局部变量表 就是 2.3 说的「工作台上放变量的格子」,操作数栈 就是「工作台上摊开算草稿的地方」——现在它们从抽象名词变成了看得见的东西。
- 后面很多概念都建立在字节码之上:JIT(第三篇)优化的输入是字节码、方法内联省的是「建栈帧 + 传参」的开销、
synchronized对应的是monitorenter/monitorexit两条字节码。看得懂字节码,才谈得上看懂 JVM 在优化什么。
2.5 方法区:类的说明书柜
你的 User 类长什么样?有哪些字段?哪些方法?方法里的字节码是啥?这些 关于类本身的信息,都存在方法区里。
注意区分:
new User()创建出来的具体对象 → 堆;User这个类本身的定义 → 方法区。
一个生动的比方:方法区是「图纸」,堆是「按图纸造出来的产品」。
永久代 → 元空间
JDK 8 之前方法区由「永久代(PermGen)」实现;JDK 8 之后改名叫「元空间(Metaspace)」,并且搬出了 JVM 堆,直接用操作系统的内存。 原因是永久代大小固定,反射、动态代理一多就
OOM: PermGen space,元空间默认不设上限,省心多了。
还有个特别值得一提的居民 —— 字符串常量池,它正是面试里那道经典 == 题的根源:
String a = "hello";
String b = "hello";
String c = new String("hello");
System.out.println(a == b); // true
System.out.println(a == c); // false
为什么一个
true一个false
"hello"这种 字面量 会进字符串常量池,相同内容只存一份,所以a、b指向 同一个对象 →a == b为true;new String("hello")强制在 堆 里另建一个新对象,地址不同 →a == c为false(要比内容得用a.equals(c))。补充:JDK 7 起字符串常量池已经从永久代 挪进了堆,这样不再被永久代大小卡住,长字符串也能正常被 GC 回收。
顺带理解「堆」与「非堆」
方法区是典型的 非堆(Non-Heap) 内存。很多人困惑「为什么除了堆还有非堆」,一句话就能拎清:
堆 = 你
new出来的对象住的地方;非堆 = JVM 自己运转所需的「基础设施」住的地方。
为什么要把它们分开?因为这两类东西的 生命周期和管理方式截然不同:堆和它的分代 GC 是专门为「对象大量创建、朝生夕灭」优化的;而一个类的元信息会一直存在到类加载器卸载、一段 JIT 机器码只要方法还热就一直有用 —— 它们根本不符合「短命对象」的规律,塞进堆只会把 GC 搞乱,所以单独拎出来管理。
非堆里主要住着:
| 非堆区域 | 装什么 |
|---|---|
| Metaspace(元空间) | 类的元信息:字段、方法、字节码 —— 就是这里讲的方法区 |
| Code Cache(代码缓存) | JIT 把热点代码编译出的本地机器码(见第三篇) |
| 其他 | 压缩类空间、符号表、字符串表等 JVM 内部结构 |
工厂类比
把 JVM 想成一家工厂:堆 是流水线上不断生产又出库的 产品(对象),进进出出,所以内存曲线是锯齿;非堆 是工厂的 图纸库(方法区)+ 机器设备(Code Cache 的机器码),一次装好就长期不变,所以曲线接近水平。你不会把图纸和机器跟产品堆在同一个仓库 —— JVM 也一样。(第三篇会把「堆 / 堆外 / 各自会抛什么 OOM」画成一张完整地图。)
2.6 程序计数器和本地方法栈
剩下两个区域比较小众,简单理解就行:
- 程序计数器:记录当前线程执行到字节码的哪一行了(就是 2.4 那些
0/1/2…偏移量)。线程切换后还能找回原位置,靠的就是它。它也是 唯一不会 OOM 的区域; - 本地方法栈:服务于
native方法(用 C/C++ 实现的方法),结构和虚拟机栈差不多。
2.7 一张表记住所有分区
| 区域 | 谁住在这里 | 谁能访问 | 会不会 OOM |
|---|---|---|---|
| 堆 | new 出来的对象 | 所有线程 | 会(Java heap space) |
| 虚拟机栈 | 栈帧、局部变量表、操作数栈 | 当前线程 | 栈太深是 StackOverflowError;线程太多才 OOM |
| 方法区 / 元空间 | 类的定义、静态变量、常量池 | 所有线程 | 会(Metaspace) |
| 程序计数器 | 当前执行位置 | 当前线程 | 不会 |
| 本地方法栈 | native 方法 | 当前线程 | 会 |
三、对象的一生:new 到底做了几件事
知道了对象住在堆里,我们把镜头拉近,看一行 new User() 在字节码层面到底触发了哪些步骤。
一行 new 背后是「类加载检查 → 分配内存 → 赋零值 → 设对象头 → 执行构造器」五步。
- 类加载检查:JVM 先看这个类有没有被加载过。没有,就先走第四章的类加载流程。
- 分配内存:在堆里划一块地。如果堆是规整的(有整理型 GC),就用「指针碰撞」——指针往后挪一段;如果堆有碎片,就查「空闲列表」找一块够大的。为了避免多线程抢地盘加锁,绝大多数分配走的是每个线程私有的 TLAB(第二篇细讲)。
- 赋零值:所有实例字段先统一置零(
int→ 0、引用 →null)。这就是为什么 Java 字段不赋值也有默认值。 - 设置对象头:把 Mark Word、类型指针填好(就是 2.2 说的对象头)。
- 执行构造器:最后才执行
<init>,按你写的代码给字段赋真实值、跑构造逻辑。
对象访问定位:句柄 vs 直接指针
栈里的引用怎么找到堆里的对象?有两种设计:
- 句柄:栈里的引用指向一个「句柄池」,句柄再分别记「对象实例地址」和「类型地址」。好处是对象被 GC 移动时只改句柄、引用不动;坏处是多一次跳转。
- 直接指针:栈里的引用直接指向对象,对象头里再存类型指针。少一次跳转、访问更快 —— HotSpot 用的就是这种。代价是对象被移动时要回来改引用(这正是第二篇 GC「移动对象成本高」的由来之一)。
四、类是怎么进 JVM 的:类加载机制
上一章第 1 步就卡在「类加载」。你写的 User.class 文件躺在硬盘上,JVM 怎么知道要去找它?什么时候找?找到之后怎么处理?这就是 类加载机制 要回答的。
4.1 类加载的五个步骤
JVM 加载一个类,要走五步,可以用一句话记住:「加验准解初」 —— 加载、验证、准备、解析、初始化。
类加载五步:加载 → 验证 → 准备 → 解析 → 初始化。
我们用一个生活化的类比,假设你要把一本新书放进图书馆:
| 步骤 | 图书馆类比 | JVM 实际做的事 |
|---|---|---|
| ① 加载 | 把书从仓库搬到门口 | 把 .class 文件读进来,变成内存里的数据 |
| ② 验证 | 检查书有没有破损 | 检查字节码合不合法,防止恶意代码搞崩虚拟机 |
| ③ 准备 | 在书架上预留位置 | 给静态变量分配内存,赋默认零值(int 给 0,引用给 null) |
| ④ 解析 | 把「参见第 X 页」换成真实页码 | 把字节码里的「符号引用」换成「直接引用」 |
| ⑤ 初始化 | 真正摆上书架、开放借阅 | 执行 <clinit>,把静态变量赋成程序员写的值,执行 static {} |
「符号引用」和「直接引用」是什么
- 符号引用:用 名字 来指代目标,比如「调用
com.example.User的getName方法」。此时还不知道这个类、方法在内存的哪个位置;- 直接引用:换成 真实的内存地址 / 偏移量,直接就能定位到目标。
「解析」做的事,就是把「按名字找」翻译成「按地址找」—— 类似把书里的「参见《JVM》一书」换成「参见本馆 3 层 B12 书架」。
4.2 一个容易踩坑的点
注意「准备」和「初始化」都会给静态变量赋值,但意思完全不一样:
public class Demo {
static int a = 10;
}
面试高频陷阱
- 准备阶段:
a = 0(默认零值)- 初始化阶段:
a = 10(程序员写的值)理解了原理就不会记混。
4.3 类加载器与双亲委派
JVM 不是只有一个加载器,而是有 三个层级:
| 加载器 | 负责加载 |
|---|---|
| 启动类加载器(Bootstrap) | java.lang.* 等核心类库 |
| 扩展类加载器(Extension) | JDK 扩展库 |
| 应用类加载器(Application) | 你自己写的类(classpath 上的类) |
术语提示(JDK 9+)
自 JDK 9 引入模块系统后,「扩展类加载器」被 平台类加载器(Platform ClassLoader) 取代,负责加载 JDK 中除核心类库外的平台模块;名字变了,但双亲委派的层级关系不变。本文沿用更广为人知的「扩展类加载器」称呼。
它们之间是 父子关系。当应用类加载器收到「加载 java.lang.String」的请求时,它不会自己加载,而是 先问父加载器:你能加载吗?父加载器再问自己的父加载器,一路问到顶。顶层加载器(启动类加载器)说「我能」,于是由它加载完返回。这就是 双亲委派模型。
双亲委派:请求先一路上交到顶层,顶层能加载就加载,不能才逐层下放。
为什么搞得这么麻烦?想象一下,如果你自己写了一个 java.lang.String 类放在 classpath 里:
- 没有双亲委派:应用类加载器看到你的
String就加载它,系统瞬间崩溃 —— 各种核心代码都依赖String,结果加载的是你写的山寨版; - 有了双亲委派:请求一路上交到启动类加载器,它一看「我这里就有正版
String」,直接返回,你写的山寨版永远没机会上场。
双亲委派的两个核心目的
- 保证核心类不被篡改
- 避免类被重复加载
现实中双亲委派经常被「打破」
双亲委派只是「推荐」而非「强制」,很多框架反而需要打破它:
- JDBC / SPI:
DriverManager(核心库,由启动类加载器加载)要去用你引入的 MySQL 驱动(在应用 classpath 上),但父加载器够不着子加载器的类,只好借助「线程上下文类加载器」反向委派给应用加载器;- Tomcat:每个 Web 应用有自己的类加载器,且 优先加载自己的类(打破了「先问父亲」),这样不同应用才能各用同一个库的不同版本而互不冲突;
- OSGi / 热部署:为了能动态卸载、替换类,采用了更灵活的网状加载结构。
到这里,JVM 这台「口袋电脑」的静态构造就摸清了:内存怎么分区、字节码怎么跑、一行 new 背后的五步、类经「加验准解初」如何进来。对象已经在堆里安了家 —— 可堆空间有限,对象终将死去。谁来清理它们、怎么清理、又怎么清得既快又不卡顿? 这正是下一篇 核心篇 · 垃圾回收全解 要回答的问题。
延伸阅读
- 周志明.《深入理解 Java 虚拟机(第 3 版)》—— 第 2 章(内存区域)、第 7 章(类加载机制)是本篇的权威母本。
- Oracle. The Java® Virtual Machine Specification (Java SE 21) —— 第 2 章讲运行时数据区与字节码指令集,第 5 章讲类加载。