Skip to content
Charles Shao
Go back

JVM 深入(一)· 入门篇:内存架构与类加载 —— 从字节码到对象的一生

views

你写下人生第一行 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

Table of contents

Open Table of contents

一、为什么需要 JVM:一个「翻译官」的故事

1.1 一个最容易理解的类比

假设你要在中国、美国、日本三个国家做同一场演讲。最笨的办法是学三门语言,每个国家都重新写一遍稿子。聪明一点的办法是:只写一份稿子(中文),每个国家配一个翻译

JVM 干的就是翻译这件事。你的 Java 代码只写一份,编译成统一的「中间语言」(字节码),然后:

平台JVM 做的事
Windows把字节码翻译成 Windows 能听懂的指令
Linux把字节码翻译成 Linux 能听懂的指令
macOS把字节码翻译成 macOS 能听懂的指令

跨平台翻译示意图:一份 Hello.java 源码经 javac 编译成统一的 Hello.class 字节码,再分别交给 Windows / Linux / macOS 三个平台上的 JVM,各自翻译成对应平台的机器指令 一份源码编译成统一字节码,各平台的 JVM 各自把它翻译成本地机器指令。

一句话本质

Java 那句口号 「Write Once, Run Anywhere」 的真相 —— 不是 Java 代码本身能到处跑,而是 每个平台都有一个 JVM 在替它跑

1.2 JVM 到底是什么

剥开所有花哨的修饰,JVM 就是一个 运行在你操作系统上的普通程序。它的工作是读取 .class 文件,按文件里的字节码指令一条一条执行。

你可以把它想象成一台「口袋电脑」:它有自己的内存(不是物理内存,而是从操作系统申请来的一块)、自己的 CPU 模拟器、自己的「硬盘」(用来存类信息)。你的 Java 程序就在这台口袋电脑里运行。

搞清楚这一点之后,下面所有的内容都好理解了 —— 我们其实就是在研究这台口袋电脑的内部构造。

二、口袋电脑长什么样:JVM 的内存分区

既然 JVM 是一台口袋电脑,它就得有内存。但 JVM 的内存不像你电脑里那一整条内存条那样混在一起用,它被 划分成了好几个区域,每个区域有自己的用途

为什么要分区?

打个比方:你家有客厅、厨房、卧室、书房,不是说一个大房间不能住,而是分开之后每个区域专心干一件事,效率更高。JVM 的分区也是这个思路。

JVM 运行时数据区分区图:分为线程共享区(堆 Heap 存放 new 出来的对象、方法区 / 元空间存放类定义 · 静态变量 · 常量池)与线程私有区(虚拟机栈存放栈帧与局部变量、本地方法栈服务 native 方法、程序计数器记录当前执行位置) 运行时数据区分成线程共享(堆、方法区)与线程私有(虚拟机栈、本地方法栈、程序计数器)两大类。

2.1 一行代码经过的所有「房间」

我们看一段代码,跟着它走一遍:

public void greet() {
    String name = "Alice";
    User user = new User(name);
    System.out.println(user);
}

这段代码运行起来时,JVM 内存里会发生这些事:

  1. greet 方法本身的信息(字节码、参数表等)—— 存在 方法区
  2. 方法被调用,JVM 为它分配一个「工作台」(栈帧),栈帧放在 虚拟机栈
  3. 局部变量 nameuser 这两个引用 —— 存在栈帧里的局部变量表;
  4. 真正的 User 对象 —— 存在 里;
  5. JVM 当前执行到哪一行 —— 存在 程序计数器 里。

一行代码经过的内存区域:greet() 方法的字节码与参数表存在方法区,方法调用的栈帧与局部变量 name / user 引用存在虚拟机栈,new User(name) 创建的真实对象存在堆里(栈帧里的引用指向堆中的对象),当前执行的字节码行号存在程序计数器 一次方法调用会同时用到方法区、虚拟机栈、堆与程序计数器;栈里的引用指向堆里的真实对象。

是不是有点晕?没关系,我们一个一个看。

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 那种 eaxrbx 寄存器,所有运算都靠 操作数栈 来回压弹。跟着上面 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 类长什么样?有哪些字段?哪些方法?方法里的字节码是啥?这些 关于类本身的信息,都存在方法区里。

注意区分:

一个生动的比方:方法区是「图纸」,堆是「按图纸造出来的产品」。

永久代 → 元空间

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" 这种 字面量 会进字符串常量池,相同内容只存一份,所以 ab 指向 同一个对象a == btrue
  • new String("hello") 强制在 里另建一个新对象,地址不同 → a == cfalse(要比内容得用 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.7 一张表记住所有分区

区域谁住在这里谁能访问会不会 OOM
new 出来的对象所有线程会(Java heap space
虚拟机栈栈帧、局部变量表、操作数栈当前线程栈太深是 StackOverflowError;线程太多才 OOM
方法区 / 元空间类的定义、静态变量、常量池所有线程会(Metaspace
程序计数器当前执行位置当前线程不会
本地方法栈native 方法当前线程

三、对象的一生:new 到底做了几件事

知道了对象住在堆里,我们把镜头拉近,看一行 new User() 在字节码层面到底触发了哪些步骤。

对象创建流程:遇到 new 指令后先检查类是否已加载(没加载则先走类加载的加验准解初),然后分配内存(指针碰撞 / 空闲列表,优先走 TLAB),接着给实例字段赋零值、设置对象头(Mark Word 与类型指针),最后执行构造器 init() 按代码赋真实值,对象才可用 一行 new 背后是「类加载检查 → 分配内存 → 赋零值 → 设对象头 → 执行构造器」五步。

  1. 类加载检查:JVM 先看这个类有没有被加载过。没有,就先走第四章的类加载流程。
  2. 分配内存:在堆里划一块地。如果堆是规整的(有整理型 GC),就用「指针碰撞」——指针往后挪一段;如果堆有碎片,就查「空闲列表」找一块够大的。为了避免多线程抢地盘加锁,绝大多数分配走的是每个线程私有的 TLAB(第二篇细讲)。
  3. 赋零值:所有实例字段先统一置零(int → 0、引用 → null)。这就是为什么 Java 字段不赋值也有默认值。
  4. 设置对象头:把 Mark Word、类型指针填好(就是 2.2 说的对象头)。
  5. 执行构造器:最后才执行 <init>,按你写的代码给字段赋真实值、跑构造逻辑。

对象访问定位:句柄 vs 直接指针

栈里的引用怎么找到堆里的对象?有两种设计:

  • 句柄:栈里的引用指向一个「句柄池」,句柄再分别记「对象实例地址」和「类型地址」。好处是对象被 GC 移动时只改句柄、引用不动;坏处是多一次跳转。
  • 直接指针:栈里的引用直接指向对象,对象头里再存类型指针。少一次跳转、访问更快 —— HotSpot 用的就是这种。代价是对象被移动时要回来改引用(这正是第二篇 GC「移动对象成本高」的由来之一)。

四、类是怎么进 JVM 的:类加载机制

上一章第 1 步就卡在「类加载」。你写的 User.class 文件躺在硬盘上,JVM 怎么知道要去找它?什么时候找?找到之后怎么处理?这就是 类加载机制 要回答的。

4.1 类加载的五个步骤

JVM 加载一个类,要走五步,可以用一句话记住:「加验准解初」 —— 加载、验证、准备、解析、初始化。

类加载五个步骤流程:① 加载(读 .class 进内存)→ ② 验证(检查字节码合法性)→ ③ 准备(静态变量赋默认零值)→ ④ 解析(符号引用转直接引用)→ ⑤ 初始化(执行 clinit 赋真实值) 类加载五步:加载 → 验证 → 准备 → 解析 → 初始化。

我们用一个生活化的类比,假设你要把一本新书放进图书馆:

步骤图书馆类比JVM 实际做的事
① 加载把书从仓库搬到门口.class 文件读进来,变成内存里的数据
② 验证检查书有没有破损检查字节码合不合法,防止恶意代码搞崩虚拟机
③ 准备在书架上预留位置给静态变量分配内存,赋默认零值int 给 0,引用给 null
④ 解析把「参见第 X 页」换成真实页码把字节码里的「符号引用」换成「直接引用」
⑤ 初始化真正摆上书架、开放借阅执行 <clinit>把静态变量赋成程序员写的值,执行 static {}

「符号引用」和「直接引用」是什么

  • 符号引用:用 名字 来指代目标,比如「调用 com.example.UsergetName 方法」。此时还不知道这个类、方法在内存的哪个位置;
  • 直接引用:换成 真实的内存地址 / 偏移量,直接就能定位到目标。

「解析」做的事,就是把「按名字找」翻译成「按地址找」—— 类似把书里的「参见《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 就自己加载,无法加载则逐层下放回扩展、应用类加载器 双亲委派:请求先一路上交到顶层,顶层能加载就加载,不能才逐层下放。

为什么搞得这么麻烦?想象一下,如果你自己写了一个 java.lang.String 类放在 classpath 里:

双亲委派的两个核心目的

  1. 保证核心类不被篡改
  2. 避免类被重复加载

现实中双亲委派经常被「打破」

双亲委派只是「推荐」而非「强制」,很多框架反而需要打破它:

  • JDBC / SPIDriverManager(核心库,由启动类加载器加载)要去用你引入的 MySQL 驱动(在应用 classpath 上),但父加载器够不着子加载器的类,只好借助「线程上下文类加载器」反向委派给应用加载器;
  • Tomcat:每个 Web 应用有自己的类加载器,且 优先加载自己的类(打破了「先问父亲」),这样不同应用才能各用同一个库的不同版本而互不冲突;
  • OSGi / 热部署:为了能动态卸载、替换类,采用了更灵活的网状加载结构。

到这里,JVM 这台「口袋电脑」的静态构造就摸清了:内存怎么分区、字节码怎么跑、一行 new 背后的五步、类经「加验准解初」如何进来。对象已经在堆里安了家 —— 可堆空间有限,对象终将死去。谁来清理它们、怎么清理、又怎么清得既快又不卡顿? 这正是下一篇 核心篇 · 垃圾回收全解 要回答的问题。

延伸阅读


views
Share this post on:

Previous Post
JVM 深入(二)· 核心篇:垃圾回收全解 —— 从可达性分析到 ZGC
Next Post
JMeter 压测实战:组件模型、线程组、CLI 执行与 HTML 报告