第三章:Java 内存模型(JMM)与运行时数据区
本章目标:
- 搞懂 JVM 内存结构
- 理解堆、栈、方法区到底存什么
- 理解线程共享与线程私有内存
- 理解 JMM(Java Memory Model)解决什么问题
- 为后续 GC、并发编程、JVM调优打下基础
一、为什么必须学习 JVM 内存?
很多 Java 程序员工作几年后都会遇到这些问题:
sql
OutOfMemoryError
StackOverflowError
频繁Full GC
内存泄漏
线程安全问题
CPU 100%
这些问题最终都指向:
JVM内存
例如:
csharp
List<User> list = new ArrayList<>();
while (true) {
list.add(new User());
}
运行后:
makefile
java.lang.OutOfMemoryError: Java heap space
为什么会报错?
因为:
堆内存被占满了
所以学习 JVM,首先必须搞懂内存结构。
二、JVM 运行时数据区
当 JVM 启动时,会创建运行时数据区(Runtime Data Area)。
官方结构图:
markdown
JVM Runtime Data Area
线程共享
┌─────────────────────┐
│ Heap │
├─────────────────────┤
│ Method Area │
└─────────────────────┘
线程私有
┌─────────────────────┐
│ Program Counter │
├─────────────────────┤
│ Java Stack │
├─────────────────────┤
│ Native Method Stack │
└─────────────────────┘
分为两大类:
线程共享
所有线程共同拥有:
sql
Heap(堆)
Method Area(方法区)
线程私有
每个线程独立拥有:
程序计数器
虚拟机栈
本地方法栈
三、程序计数器(Program Counter Register)
这是 JVM 最小的一块内存。
作用:
记录当前线程执行到哪条字节码指令
例如:
ini
public void test() {
int a = 1;
int b = 2;
int c = a + b;
}
JVM执行过程:
makefile
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
程序计数器记录:
当前执行位置
例如:
当前执行到第5条指令
为什么每个线程都需要程序计数器?
因为 JVM 支持多线程。
例如:
css
线程A
线程B
线程C
CPU不断切换:
css
A → B → C → A → B
切换回来时必须知道:
上次执行到哪里
因此每个线程都必须拥有独立程序计数器。
四、Java 虚拟机栈(Java Virtual Machine Stack)
这是面试最高频知识点之一。
什么是栈?
每个线程启动时:
创建一个虚拟机栈
每调用一个方法:
创建一个栈帧(Stack Frame)
例如:
typescript
public static void main(String[] args) {
test();
}
public static void test() {
int age = 18;
}
执行过程:
scss
main()
│
▼
test()
栈结构:
scss
┌──────────┐
│ test() │
├──────────┤
│ main() │
└──────────┘
五、栈帧结构
每个方法对应一个栈帧。
sql
┌────────────────────┐
│ Local Variables │
├────────────────────┤
│ Operand Stack │
├────────────────────┤
│ Dynamic Linking │
├────────────────────┤
│ Return Address │
└────────────────────┘
局部变量表
例如:
ini
public void test() {
int age = 18;
String name = "Tom";
}
存放:
age
name引用
操作数栈
JVM计算使用。
例如:
ini
int c = a + b;
执行过程:
perl
push a
push b
add
pop
类似:
diff
1
2
+
=
3
六、StackOverflowError 原理
经典面试题。
代码:
csharp
public void test() {
test();
}
执行:
scss
test()
└─ test()
└─ test()
└─ test()
...
栈不断增长:
bash
┌──────┐
│test │
├──────┤
│test │
├──────┤
│test │
├──────┤
│test │
└──────┘
最终:
java.lang.StackOverflowError
七、本地方法栈(Native Method Stack)
服务于:
java
native
关键字修饰的方法。
例如:
csharp
public native void start0();
Thread源码:
scss
start()
↓
start0()
最终进入:
C++
Linux
Windows API
执行。
八、堆(Heap)
JVM中最大的一块内存。
也是 GC 最主要工作区域。
堆存储什么?
所有对象:
scss
new User()
new ArrayList()
new HashMap()
都在堆中。
例如:
sql
User user = new User();
内存:
sql
Stack
└─ user引用
Heap
└─ User对象
九、对象创建过程
代码:
sql
User user = new User();
发生了什么?
第一步
检查类是否加载:
arduino
User.class
未加载:
ClassLoader加载
第二步
堆中分配内存:
sql
Heap
┌─────────┐
│ User对象 │
└─────────┘
第三步
初始化对象:
ini
name = null;
age = 0;
第四步
执行构造函数:
csharp
public User() {
this.age = 18;
}
第五步
返回引用:
sql
user
保存到栈。
十、方法区(Method Area)
JDK8以前:
scss
Permanent Generation(PermGen)
JDK8以后:
Metaspace(元空间)
存储内容
类元数据:
kotlin
public class User {
}
存放:
类名
字段信息
方法信息
字节码
运行时常量池
示例
typescript
public class User {
private String name;
public void hello() {
}
}
方法区保存:
sql
User类结构
hello方法信息
name字段信息
十一、运行时常量池
属于方法区的一部分。
例如:
ini
String s = "hello";
字符串常量:
arduino
"hello"
会进入:
arduino
String Constant Pool
例如:
ini
String a = "abc";
String b = "abc";
实际上:
css
a
\
---> "abc"
/
b
共享同一个对象。
十二、JMM(Java Memory Model)是什么?
很多人把 JVM 内存结构和 JMM 混淆。
实际上:
JVM Runtime Data Area
解决:
内存如何划分
问题。
JMM
解决:
线程之间如何访问内存
问题。
十三、为什么需要 JMM?
假设:
ini
private boolean flag = false;
线程A:
ini
flag = true;
线程B:
arduino
while (!flag) {
}
理论上:
css
线程B应该结束循环
但实际上:
可能永远循环
为什么?
因为 CPU 缓存。
十四、JMM 核心结构
css
Main Memory
│
┌──────────┴──────────┐
│ │
Thread A Thread B
Working Memory Working Memory
每个线程:
有自己的工作内存
不能直接访问其他线程内存。
执行流程:
主内存
↓
工作内存
↓
修改
↓
刷新主内存
十五、volatile 如何解决可见性?
代码:
arduino
private volatile boolean flag = false;
线程A:
ini
flag = true;
JMM保证:
立即刷新主内存
线程B:
读取主内存最新值
因此:
arduino
while循环结束
十六、JMM 三大特性
1 原子性(Atomicity)
例如:
ini
count++;
实际上:
diff
读取
+
1
写回
不是原子操作。
2 可见性(Visibility)
线程A修改变量:
ini
flag = true;
线程B能够立即看到。
3 有序性(Ordering)
JVM和CPU会优化:
指令重排
JMM保证:
在规则范围内有序
十七、面试高频问题
JVM内存结构有哪些?
程序计数器
虚拟机栈
本地方法栈
堆
方法区
堆和栈有什么区别?
栈:
线程私有
方法调用
局部变量
堆:
线程共享
对象实例
GC管理
为什么会 StackOverflowError?
递归过深
栈帧过多
导致栈空间耗尽。
为什么会 OOM?
堆内存不足
元空间不足
直接内存不足
JMM 和 JVM 内存结构有什么区别?
JVM内存结构:
关注内存区域划分
JMM:
关注线程间共享变量访问规则
本章总结
牢记下面这张图:
markdown
JVM Runtime Data Area
线程共享
┌─────────────────┐
│ Heap │
├─────────────────┤
│ Method Area │
└─────────────────┘
线程私有
┌─────────────────┐
│ Program Counter │
├─────────────────┤
│ VM Stack │
├─────────────────┤
│ Native Stack │
└─────────────────┘
JMM
Main Memory
│
┌─────────┴─────────┐
│ │
Working Memory Working Memory
Thread A Thread B
理解了这两张图,你就掌握了 JVM 最核心的内存基础。
下一章《垃圾收集(GC)原理》,我们将深入讲解:
- 对象什么时候变成垃圾?
- GC Root 是什么?
- 为什么 Java 不使用引用计数?
- Minor GC、Major GC、Full GC 的区别
- CMS、G1、ZGC 到底怎么选?
这一章是 JVM 面试和线上问题排查的核心内容。