1.1 JVM — 我的学习记录
路书在
1-Java核心.md#第三部分:JVM这是动手实践记录,每个 Session 完成后更新
Session 1:内存区域 + 对象的一生 ⏳
日期:2026-06-09
目标:能用自己的话说清楚"一个 new Object() 从创建到回收经过了哪些内存区域"
一、JVM 内存布局全景
把 JVM 想象成一台虚拟计算机,它的"内存"就是这台计算机的 RAM。
按是否被线程共享分为两类:
graph TD
subgraph 线程共享
Heap[堆 Heap]
Meta[方法区 Metaspace]
end
subgraph 线程私有
PC[程序计数器]
JStack[虚拟机栈]
NStack[本地方法栈]
end
style Heap fill:#4a9eff,color:#fff
style Meta fill:#4a9eff,color:#fff
style PC fill:#ff9f4a,color:#fff
style JStack fill:#ff9f4a,color:#fff
style NStack fill:#ff9f4a,color:#fff
逐个拆解:
① 程序计数器(线程私有)
- 存当前线程执行到哪一行字节码
- 线程切换后能恢复执行位置
- 唯一不会 OOM 的区域
② 虚拟机栈 / Java 栈(线程私有)
- 每个方法调用→一个栈帧入栈,方法结束→栈帧出栈
- 栈帧里装:局部变量表(int、对象引用等)、操作数栈(计算临时结果)、方法返回地址
- 如果方法递归太深 → StackOverflowError
③ 本地方法栈(线程私有)
- 跟虚拟机栈一样,但服务于 native 方法(C/C++ 写的底层方法,比如
System.currentTimeMillis()底层调的就是 C)
④ 堆(线程共享)
- 对象的家:所有
new出来的对象都在这 - 堆是一个怎样的模型? 想象一块巨大的空地,JVM 在上面划区管理:
┌─────────────────────────────────────────┐
│ 堆 │
│ ┌───────┬──────┬──────┬──────────────┐ │
│ │ Eden │ S0 │ S1 │ 老年代 │ │
│ │ │ │ │ │ │
│ │ 对象 │ 存活 │ 存活 │ 熬过N次GC的 │ │
│ │ 出生地 │ 对象 │ 对象 │ 老油条 │ │
│ └───────┴──────┴──────┴──────────────┘ │
│ 新生代 │
└─────────────────────────────────────────┘
堆不是一块连续的铁板,它是按对象的年龄分区管理的:
- Eden(伊甸园):对象刚出生都在这里,像流水线入口
- Survivor 0 / 1(存活区):从 GC 手里活下来的对象搬到这里,两个区互相交换
- 老年代:熬过了十几轮 GC 还没死的老对象挪到这里
这种分代模型是因为 98% 的对象很快会死。如果把整个堆当一块管,每次 GC 要扫描全部 → 太慢。分代 = 把老人和小孩分开管理,效率高得多。
- GC 主要工作区域
- 最常见 OOM 来源:
java.lang.OutOfMemoryError: Java heap space(堆满了又没东西可回收)
⑤ 方法区 / 元空间(线程共享)
- 存类信息(类名、方法字节码、字段信息)、静态变量、常量池
- Java 8 之前叫"永久代"(在堆里),Java 8 改成"元空间"(在本地内存里)
- 为什么要改?永久代大小固定,容易 OOM;元空间用本地内存,只受操作系统限制
二、new 一个对象,它经历了什么?
Person p = new Person();
一步步拆:
graph TD
A[① main调用<br/>栈帧入栈] --> B[② new指令<br/>堆Eden分配内存]
B --> C[③ 对象头写入<br/>Mark Word 标签]
C --> D[④ 构造方法<br/>操作数栈传参]
D --> E[⑤ 堆地址→栈<br/>p指向堆对象]
style A fill:#ff9f4a,color:#fff
style B fill:#4a9eff,color:#fff
style C fill:#4a9eff,color:#fff
style D fill:#ff9f4a,color:#fff
style E fill:#ff9f4a,color:#fff
展开讲 ③:Mark Word 是什么?
每个对象在堆里不只有业务数据,JVM 还给它贴了个"标签":
Mark Word(8字节) = 多功能瑞士军刀,不同状态下存不同信息:
- 无锁时:哈希码 + GC 年龄
- 有锁时:线程 ID / 锁指针
Klass Pointer(4/8字节) → 指向 Person 类的定义位置
Mark Word = 快递盒上的物流标签:写着发件地、目的地、是否运输中。不同运输阶段标签内容不同,但始终是那 8 个字节。
展开讲 ④:操作数栈是什么?
操作数栈就是 JVM 的草稿纸——所有计算、传参、返回值都要经过它。
new Person("张三", 25) 实际在操作数栈上:
① 压入 "张三" → [ "张三" ]
② 压入 25 → [ "张三", 25 ]
③ 调用构造方法 → 弹出 25 和 "张三"
④ 初始化字段
⑤ 堆地址压回栈 → [地址]
⑥ 弹出 → 存到 p
操作数栈 = 你算账时手边的计算器,按一个数字放进去,再按一个,最后按等号组合出结果。
关键理解:
p(引用)在栈里new Person()(对象本体)在堆里- 方法结束 → 栈帧弹出 →
p消失 → 堆里的对象没人引用了 → 变成"垃圾" → 等 GC 来收
这就是"栈管运行,堆管存储"。
追问:为什么 ArrayList 必须在堆里?
因为它活多久不由方法决定:
public List<String> createList() {
List<String> list = new ArrayList<>(); // 引用在栈,对象在堆
list.add("hello");
return list; // list 要返回给调用者!
}
// createList 结束 → 栈帧弹出 → list 引用消失
// 但堆里的 ArrayList 还在,因为有调用者引用它
如果 new ArrayList<>() 分配在栈上,方法结束栈帧弹出,ArrayList 跟着销毁,调用者拿到空指针。
规则:所有 new 出来的东西都在堆里。 理由很简单:
- 栈上数据随方法结束自动销毁
- 堆上数据可以跨方法存活(只要有人引用它)
- 对象生死由 GC 决定,不需要程序员操心
有一个例外:逃逸分析。如果 JVM 确定对象没逃出方法(不返回、不赋值给静态变量、不被其他线程访问),它会优化到栈上分配,省掉 GC 开销。但这只是 JVM 的优化,不是语言规范。
三、为什么堆要分代?为什么有新生代、老年代?
因为 98% 的对象朝生夕死。
如果不分代,每次 GC 都要扫描全堆 → 太慢。 分代后:
graph TD
subgraph 新生代
Eden[Eden<br/>对象出生地]
S0[Survivor 0]
S1[Survivor 1]
end
Old[老年代<br/>熬过N次GC的对象]
Eden -->|Minor GC存活| S0
S0 -->|下次GC| S1
S1 -->|年龄+1| S0
S1 -->|年龄≥15| Old
style Eden fill:#ffcccc,color:#333
style S0 fill:#ffe5cc,color:#333
style S1 fill:#ffe5cc,color:#333
style Old fill:#cce5ff,color:#333
- 新生代:专门对付"活不过一次 GC"的短命对象(大部分对象都在 Eden 出生后很快死去)
- 老年代:存放熬过了多次 GC 的"老油条"对象
打个比方:
新生代是急诊室 — 大部分患者(对象)进来就出去了。 老年代是住院部 — 只有病情稳定的才留在这。 每次 GC 只查急诊室就很高效,不用查整个医院。
对象晋升老年代的条件:
- 熬过了 N 次 Minor GC(默认 N=15,每次 GC 存活下来年龄 +1)
- 大对象直接进入老年代(
-XX:PretenureSizeThreshold)
四、动手验证
1. 查看本机 JVM 默认堆大小
java -XX:+PrintFlagsFinal -version | findstr HeapSize
把输出填这里:
InitialHeapSize = 232783872 ← 约 222MB
MaxHeapSize = 3720347648 ← 约 3.5GB
这些值怎么来的?
你的物理内存:16 GB(16 × 1024 × 1024 × 1024 = 17,179,869,184 字节)
InitialHeapSize = 17,179,869,184 ÷ 64 ≈ 268MB ... 但是 JVM 有一个下限阈值,
实际取的是"1/64 和 某个最小值之间取较大值",你的机器实际得到 ~222MB
MaxHeapSize = 17,179,869,184 ÷ 4 ≈ 4.3GB ... 实际得到 ~3.5GB
JVM 的规则:
- 初始堆 ≈ 物理内存 / 64(但不超过 1GB,不低于 5MB)
- 最大堆 ≈ 物理内存 / 4(但不超过 25GB,不低于 16MB)
- 这两个值可以通过
-Xms和-Xmx覆盖
为什么要这么设计?
- 初始堆设小一点:启动快(不用一次性申请大块内存)
- 最大堆设个上限:防止 Java 吃完所有内存,给操作系统和其他程序留空间
为什么是 1/64 和 1/4?
经验值,不是数学推导。
| 参数 | 比例 | 逻辑 |
|---|---|---|
| 初始堆 1/64 | ≈1.5% | 先少拿点,不够再扩容。大部分 Java 程序启动时用不了多少内存 |
| 最大堆 1/4 | 25% | 最多拿四分之一,别把家底吃光。给操作系统文件缓存、其他进程留空间 |
如果最大堆设太大(比如 100%),当 Java Full GC 时内存暴增,系统会用硬盘当内存(Swap),直接卡死。1/4 是个"不会因为 Java 而把机器搞崩"的安全上限。
对你实际的影响:
- 一个 JVM 程序最多吃 3.5GB(你的 16GB 内存 × 1/4)
- 如果同时跑多个 Java 程序(比如 IDE + 后端服务 + 本地数据库),总和可能超过 16GB → 系统变慢
- 这时需要手动
-Xmx给每个程序分配更小的上限
注意:这个值取决于你电脑内存。JVM 默认初始堆 ≈ 物理内存的 1/64,最大堆 ≈ 1/4。
2. 跑这段代码,逐行对应内存区域
JvmMemoryDemo.java(写进笔记,不用切文件):
public class JvmMemoryDemo {
// ====== 方法区(元空间)======
private static String staticField = "静态变量 → 方法区";
private static final int CONSTANT = 42;
// ====== 堆 ======
private String instanceField;
public JvmMemoryDemo(String value) {
this.instanceField = value;
}
public static void main(String[] args) throws Exception {
System.out.println("=== Step 1: 局部变量都在栈里 ===");
int x = 10;
int y = 20;
int sum = x + y;
System.out.println("x + y = " + sum);
System.in.read();
System.out.println("=== Step 2: new → 对象在堆 ===");
JvmMemoryDemo obj = new JvmMemoryDemo("hello");
obj.doSomething();
System.out.println("=== Step 3: 堆分配 50MB ===");
java.util.ArrayList<byte[]> list = new java.util.ArrayList<>();
for (int i = 0; i < 50; i++) {
list.add(new byte[1024 * 1024]);
System.out.println("已分配 " + (i + 1) + " MB");
Thread.sleep(100);
}
System.out.println("按回车释放...");
System.in.read();
list = null;
System.out.println("list=null, 等GC...");
Thread.sleep(2000);
System.gc();
Thread.sleep(3000);
System.out.println("GC 完成, 堆应该降了");
}
public void doSomething() {
String localVar = "局部变量 → doSomething 栈帧";
System.out.println(localVar + "\n方法结束 → 栈帧弹出");
}
}
跑起来:
cd Backend\基础知识
javac JvmMemoryDemo.java
java JvmMemoryDemo
每步按回车,看输出。另外再试试调堆参数跑:
java -Xms32m -Xmx128m JvmMemoryDemo
四(续)、用 jvisualvm 肉眼观察
JDK 自带 jvisualvm(在 JDK bin 目录下),启动后能看到:
- 堆曲线:分配 50MB 时直线上升,GC 后下降
- 线程栈:main 线程的调用栈
jvisualvm
五、检验理解
看完上面这些,试试用自己的话回答:
- 一个
new Object()存在哪个区域? - 方法里的局部变量存在哪?
- 静态变量存在哪?
- 为什么堆不分代会很慢?
- Java 8 为什么把永久代改成元空间?
- 递归调用太深会报什么错?为什么?
我的理解(用自己的话写在这里,后面复习看):
疑问/待深入: