【JVM核心体系02】JVM 运行时内存区域:堆、栈、元空间、直接内存一次讲清

前言

上一篇我们从整体上建立了 JVM 的知识地图:

text 复制代码
JVM
│
├── 类加载
├── 运行时数据区
├── 执行引擎
├── GC
└── 生产诊断

这一篇开始深入其中最核心的一部分:

JVM 运行时内存。

很多 JVM 问题最终都会回到内存。

例如:

text 复制代码
为什么会 Full GC?

对象到底存在哪里?

为什么递归会 StackOverflow?

-Xmx 明明只配置了 1.5G,
为什么 Docker 2G 还是 OOMKilled?

Heap 没满,
为什么还能发生 OOM?

Metaspace 到底是不是 Heap?

Direct Memory 又是什么?

如果 JVM 内存区域没有真正理解清楚,后面学习:

text 复制代码
GC
G1
OOM
Heap Dump
JVM 调优

都会比较吃力。

所以这一篇的目标很明确:

把 JVM 内存的整体结构、每块区域存什么、什么时候创建、是否线程共享、可能出现什么故障,一次串起来。


一、先纠正一个常见叫法:JVM 内存结构 ≠ JMM

很多文章或者面试中会问:

JVM 内存模型是什么?

这个说法其实容易产生歧义。

我们需要区分两个概念。


1.1 JVM 运行时数据区

也就是本文主要讨论的:

text 复制代码
Heap
Java 虚拟机栈
本地方法栈
程序计数器
方法区

它解决的问题是:

Java 程序运行过程中,数据分别存在哪里。


1.2 Java Memory Model

也就是:

text 复制代码
JMM
Java Memory Model
Java 内存模型

它主要讨论:

text 复制代码
多线程之间如何共享数据

线程什么时候能看到其他线程修改的数据

指令是否允许重排序

volatile 为什么能保证可见性

synchronized 为什么既能互斥又能保证可见性

happens-before 是什么

所以:

text 复制代码
JVM 运行时数据区
≠
JMM

可以简单记成:

text 复制代码
JVM 内存区域
→ 数据放在哪里


JMM
→ 多线程之间怎么正确访问共享数据

我的 JMM 内容会放在并发编程专题中。

本文只讨论:

JVM 运行时内存结构。


二、JVM 运行时数据区整体结构

按照 JVM 规范,可以先把运行时数据区分成:

text 复制代码
JVM Runtime Data Area
│
├── 线程共享
│   │
│   ├── Heap
│   │
│   └── Method Area
│
└── 线程私有
    │
    ├── Program Counter Register
    │
    ├── Java Virtual Machine Stack
    │
    └── Native Method Stack

翻译成中文:

text 复制代码
JVM 运行时数据区
│
├── 线程共享
│   ├── Java 堆
│   └── 方法区
│
└── 线程私有
    ├── 程序计数器
    ├── Java 虚拟机栈
    └── 本地方法栈

除此之外,在实际 HotSpot JVM 和生产排查中,我们还经常会关注:

text 复制代码
Metaspace
Direct Memory
Code Cache
Native Memory
Thread Stack

因此从生产视角来看,一个 Java 进程占用的内存远远不只是:

text 复制代码
Heap

而更接近:

text 复制代码
Java Process
│
├── Heap
│
├── Metaspace
│
├── Thread Stack
│
├── Direct Memory
│
├── Code Cache
│
└── JVM Native Memory

这张图非常重要。

后面很多 OOM 问题都要从这里出发。


三、为什么要区分"线程共享"和"线程私有"?

假设一个 Java 服务同时有:

text 复制代码
Thread A
Thread B
Thread C

它们都会在同一个 JVM 进程中运行。

但是不同内存区域的生命周期是不一样的。


3.1 线程共享区域

例如:

text 复制代码
Heap
Method Area

所有线程都可以共同访问。

例如:

java 复制代码
User user = new User();

这个 User 对象通常位于 Heap。

不同线程只要拿到它的引用,就都可能访问这个对象。

所以:

text 复制代码
Heap
→ 多线程共享

这也是为什么共享对象会产生线程安全问题。


3.2 线程私有区域

例如:

text 复制代码
Java 虚拟机栈
程序计数器
本地方法栈

每创建一个线程,就会拥有自己独立的一套。

可以理解为:

text 复制代码
Thread A
├── PC Register A
├── Java Stack A
└── Native Stack A


Thread B
├── PC Register B
├── Java Stack B
└── Native Stack B

Thread A 不会直接使用 Thread B 的 Java 栈。

所以:

线程创建得越多,除了线程调度成本增加,也会消耗更多线程栈内存。

这一点后面排查:

text 复制代码
unable to create new native thread

时非常重要。


四、程序计数器

程序计数器英文:

text 复制代码
Program Counter Register

简称:

text 复制代码
PC Register

它是 JVM 中非常小的一块内存。

但是它非常重要。


4.1 程序计数器是干什么的?

Java 是多线程执行的。

假设线程 A 正在执行:

text 复制代码
第 100 条字节码

这时操作系统进行线程切换:

text 复制代码
Thread A
    ↓
暂停

Thread B
    ↓
运行

Thread B
    ↓
暂停

Thread A
    ↓
继续

线程 A 再次获得 CPU 后,必须知道:

我刚才执行到哪里了?

程序计数器就负责记录类似这样的执行位置信息。

可以简单理解:

text 复制代码
Thread A
PC → bytecode 100


Thread B
PC → bytecode 260

所以:

程序计数器是线程私有的。

每个线程都有自己的程序计数器。


4.2 为什么程序计数器通常不会 OOM?

程序计数器需要保存的信息很少。

它不像 Heap 一样不断创建大量对象。

所以在 JVM 规范定义的运行时区域中:

程序计数器通常也是唯一没有规定 OutOfMemoryError 场景的区域。

生产排查中,我们基本不会把:

text 复制代码
OOM

首先定位到程序计数器。


五、Java 虚拟机栈

Java 虚拟机栈:

text 复制代码
Java Virtual Machine Stack

是 JVM 面试和生产排查中非常重要的一部分。

它和:

text 复制代码
Java 方法调用

密切相关。


六、每个线程都有自己的 Java 栈

假设程序有三个线程:

text 复制代码
Thread A
Thread B
Thread C

那么可以简单理解为:

text 复制代码
Thread A
└── Java Stack A


Thread B
└── Java Stack B


Thread C
└── Java Stack C

所以 Java 栈:

线程私有。

它的生命周期基本与线程一致。

线程创建:

text 复制代码
创建 Stack

线程结束:

text 复制代码
Stack 随之释放

七、什么是栈帧?

线程每调用一个 Java 方法,就会创建一个:

text 复制代码
Stack Frame
栈帧

例如:

java 复制代码
public void methodA() {
    methodB();
}

public void methodB() {
    methodC();
}

public void methodC() {
}

执行:

text 复制代码
methodA()

以后:

text 复制代码
methodA()
 ↓
methodB()
 ↓
methodC()

对应 Java Stack:

text 复制代码
Java Stack
│
├── methodC Stack Frame
├── methodB Stack Frame
└── methodA Stack Frame

当:

text 复制代码
methodC()

执行完成:

text 复制代码
methodC 栈帧出栈

然后继续执行:

text 复制代码
methodB

所以方法调用本质上存在:

text 复制代码
入栈
↓
执行
↓
出栈

的过程。


八、栈帧里面放什么?

一个栈帧主要包含:

text 复制代码
局部变量表
操作数栈
动态链接
方法返回信息

其中最容易理解的是:

text 复制代码
局部变量表

例如:

java 复制代码
public void test() {

    int age = 18;

    User user = new User();

}

可以粗略理解:

text 复制代码
Java Stack
│
└── test Stack Frame
    │
    ├── age = 18
    │
    └── user = reference

而:

text 复制代码
User 对象

通常存在 Heap。

所以我们经常看到:

text 复制代码
Stack
└── Reference
       │
       ↓
Heap
└── Object

九、为什么无限递归会 StackOverflow?

例如:

java 复制代码
public void test() {
    test();
}

执行后:

text 复制代码
test()
 ↓
test()
 ↓
test()
 ↓
test()
 ↓
test()
 ↓
...

每调用一次:

text 复制代码
test()

都会创建新的栈帧。

Java Stack 会不断增长:

text 复制代码
Java Stack

[test]
[test]
[test]
[test]
[test]
[test]
...

最终栈空间不足:

text 复制代码
java.lang.StackOverflowError

这就是经典的:

text 复制代码
StackOverflowError

十、StackOverflow 常见原因

生产中比较常见的原因包括:

text 复制代码
无限递归

递归深度过大

循环调用

A → B → C → A

例如:

java 复制代码
public void methodA() {
    methodB();
}

public void methodB() {
    methodA();
}

最终:

text 复制代码
A
↓
B
↓
A
↓
B
↓
A
↓
...

同样会不断创建栈帧。


十一、怎么排查 StackOverflow?

StackOverflow 一般比较容易排查。

异常通常直接显示:

text 复制代码
java.lang.StackOverflowError

然后看异常堆栈。

如果看到:

text 复制代码
UserService.test()
UserService.test()
UserService.test()
UserService.test()
UserService.test()
UserService.test()

同一个方法重复成百上千次:

基本就可以判断存在递归问题。

所以 StackOverflow 的排查重点不是:

text 复制代码
Heap Dump

而是:

text 复制代码
Exception Stack Trace

十二、线程栈大小和 -Xss

Java 线程栈大小可以通过:

text 复制代码
-Xss

控制。

例如:

text 复制代码
-Xss1m

可以简单理解为:

每个 Java 线程允许使用的栈空间上限大约是 1MB。

因此如果创建:

text 复制代码
1000 个线程

理论上光线程栈就可能需要非常可观的内存。

这也是为什么:

线程不是免费的。


十三、线程太多为什么也可能导致内存问题?

假设容器限制:

text 复制代码
2GB

Java Heap:

text 复制代码
-Xmx1200m

看起来似乎还有:

text 复制代码
800MB

但如果:

text 复制代码
-Xss1m

同时创建:

text 复制代码
500 个线程

那么线程栈本身就可能消耗数百 MB 级别的地址空间和实际内存。

还没有计算:

text 复制代码
Metaspace

Direct Memory

Code Cache

JVM Native Memory

所以:

text 复制代码
Heap 没满

并不意味着:

text 复制代码
Java 进程整体内存安全

十四、Unable to create new native thread

如果系统无法继续创建线程,还可能看到:

text 复制代码
java.lang.OutOfMemoryError:
unable to create new native thread

这和:

text 复制代码
Java heap space

不是一个问题。

常见原因可能包括:

text 复制代码
线程创建过多

进程线程数达到系统限制

Native Memory 不足

容器内存不足

所以遇到这种错误,重点应该看:

text 复制代码
线程数量

-Xss

容器内存

系统线程限制

而不是一看到 OOM 就去分析 Heap Dump。


十五、本地方法栈

本地方法栈:

text 复制代码
Native Method Stack

和 Java 虚拟机栈类似。

区别主要在于:

text 复制代码
Java Virtual Machine Stack
→ Java 方法


Native Method Stack
→ Native 方法

Java 可以通过:

text 复制代码
JNI
Java Native Interface

调用底层本地代码。

例如:

text 复制代码
C
C++

实现的方法。

这些 Native Method 的执行就可能涉及本地方法栈。

在 HotSpot 的具体实现中:

Java 栈和本地方法栈的实现细节可能不像规范概念划分得那么完全独立。

但是面试中首先理解逻辑区别即可。


十六、Java Heap

Java Heap:

text 复制代码
Java 堆

是 JVM 内存中最重要的一部分。

也是 GC 最主要管理的区域。

它最大的特点:

text 复制代码
线程共享

所有线程都可能访问 Heap 中的对象。


十七、Heap 主要放什么?

最常见的是:

text 复制代码
对象实例
数组

例如:

java 复制代码
User user = new User();

通常可以理解:

text 复制代码
Stack
│
└── user Reference
       │
       ↓
Heap
└── User Object

再例如:

java 复制代码
int[] array = new int[100];

数组对象也通常位于:

text 复制代码
Heap

十八、对象一定在 Heap 吗?

初学 JVM 时通常会说:

Java 对象都创建在 Heap。

这个说法作为入门理解没有问题。

但如果严格来说:

不应该机械理解成"所有对象在任何情况下都必须真的在 Heap 中分配完整对象"。

现代 JVM 会进行很多优化,例如:

text 复制代码
逃逸分析
Escape Analysis

如果 JVM 能证明某个对象:

text 复制代码
不会逃逸当前方法

理论上可以进一步进行:

text 复制代码
标量替换
锁消除

等优化。

所以比较严谨的说法是:

从 Java 内存语义和常规理解上,对象主要位于 Heap;但 JIT 优化后,某些对象可能被优化掉,不能简单理解成所有 new 都必然对应 Heap 上一个完整对象。

面试中通常说到这里已经足够。


十九、Heap 为什么需要 GC?

假设程序不断:

java 复制代码
new User();
new User();
new User();
new User();

如果对象永远不释放:

text 复制代码
Heap
↓
越来越满
↓
没有空间
↓
OOM

所以 JVM 需要:

text 复制代码
Garbage Collector

找出:

text 复制代码
已经没有业务需要的对象

然后释放它们占用的空间。

所以:

text 复制代码
Heap

也是 GC 的核心管理区域。


二十、年轻代和老年代

很多 GC 使用:

text 复制代码
Generational Collection
分代收集

的思想。

最经典的理解:

text 复制代码
Heap
│
├── Young Generation
│   │
│   ├── Eden
│   ├── Survivor 0
│   └── Survivor 1
│
└── Old Generation

为什么需要分代?

因为大量 Java 对象都有这样的特点:

生命周期非常短。

例如:

java 复制代码
List<UserDTO> result = new ArrayList<>();

一个接口执行过程中可能产生大量:

text 复制代码
DTO
String
List
Map
JSON 对象
临时对象

请求结束以后,很快就没有引用了。

所以:

text 复制代码
大量对象
→ 朝生夕死

这是分代 GC 的重要依据。


二十一、对象通常怎么流转?

可以先建立这样的理解:

text 复制代码
new Object()
     ↓
Eden
     ↓
Young GC
     ↓
对象还活着
     ↓
Survivor
     ↓
继续经历 GC
     ↓
长期存活
     ↓
Old Generation

但是要注意:

这是一张帮助理解的简化图。

不同垃圾收集器的具体实现存在差异。

尤其是:

text 复制代码
G1

并不是把年轻代和老年代永久划成两块固定连续空间。

G1 使用:

text 复制代码
Region

动态承担:

text 复制代码
Eden
Survivor
Old
Humongous

等角色。

所以:

text 复制代码
年轻代 / 老年代

是重要的逻辑概念,但不要把它机械理解成所有 GC 都有完全相同的物理布局。


二十二、Heap OOM

Heap 无法继续满足对象分配时,最典型的异常就是:

text 复制代码
java.lang.OutOfMemoryError:
Java heap space

常见原因包括:

text 复制代码
内存泄漏

Heap 配置太小

一次查询太多数据

缓存无限增长

大集合

大量大对象

并发量突然增大

对象分配速度远高于回收速度

二十三、Heap OOM 不等于内存泄漏

这是生产排查非常重要的一点。

假设出现:

text 复制代码
Java heap space

不能直接说:

内存泄漏了。

因为 OOM 可能只是:

text 复制代码
-Xmx512m

但是业务本身正常运行就需要:

text 复制代码
800MB

这种属于:

text 复制代码
Heap 容量不足

而不是:

text 复制代码
对象错误地无法释放

只有当:

text 复制代码
本来应该释放的对象
↓
因为错误引用关系
↓
长期无法被 GC 回收

时,才属于真正意义上的:

text 复制代码
Memory Leak

二十四、方法区

方法区:

text 复制代码
Method Area

是 JVM 规范中的逻辑概念。

它用于存储和:

text 复制代码
Class

相关的数据。

例如:

text 复制代码
类型信息
字段信息
方法信息
运行时常量池
方法字节码等

它和 Heap 一样:

text 复制代码
线程共享

二十五、方法区 ≠ 永久代

这是 JVM 学习中一个非常常见的混淆。

正确关系应该是:

text 复制代码
方法区
→ JVM 规范定义的逻辑概念

而:

text 复制代码
PermGen
Metaspace

属于:

HotSpot 对方法区相关数据的具体实现方式。

所以不能简单说:

text 复制代码
方法区就是永久代

或者:

text 复制代码
方法区就是 Metaspace

更准确的说法:

方法区是 JVM 规范定义的区域,而永久代和 Metaspace 是 HotSpot 不同时期对相关功能的具体实现。


二十六、PermGen 永久代

早期 HotSpot JVM 使用:

text 复制代码
PermGen
Permanent Generation
永久代

保存大量和 Class 相关的数据。

但是永久代存在一些问题。

例如:

text 复制代码
容量需要额外配置

动态类加载容易撑满

ClassLoader 泄漏容易造成 OOM

最终 Java 8 中:

text 复制代码
PermGen 被移除

二十七、Metaspace 元空间

Java 8 以后,HotSpot 使用:

text 复制代码
Metaspace

存储类元数据。

最大的变化之一:

Metaspace 使用 Native Memory,而不是 Java Heap。

所以:

text 复制代码
-Xmx

并不能直接限制 Metaspace。


二十八、Metaspace 存什么?

可以简单理解为主要保存:

text 复制代码
Class Metadata

也就是:

text 复制代码
类的元数据

例如:

text 复制代码
类结构
方法元数据
字段信息
继承关系

等 Class 相关信息。


二十九、Metaspace OOM

如果加载的 Class 数量持续增长:

text 复制代码
Class
Class
Class
Class
Class
...

Metaspace 也会越来越大。

最终可能出现:

text 复制代码
java.lang.OutOfMemoryError:
Metaspace

常见原因:

text 复制代码
大量动态生成 Class

ClassLoader 泄漏

大量动态代理

字节码增强

频繁热部署

脚本引擎动态生成类

例如一些框架可能使用:

text 复制代码
CGLIB
ByteBuddy
动态代理

生成 Class。

正常情况下问题不大。

但是如果:

text 复制代码
不断创建新的 ClassLoader
+
旧 ClassLoader 一直不能释放

就可能导致:

text 复制代码
Class 无法卸载
↓
Metaspace 不断增长
↓
Metaspace OOM

三十、怎么观察 Metaspace?

后面 JVM 排障专题会详细讲工具。

这里先知道两个方向。

例如:

bash 复制代码
jstat -class PID 1000

可以观察:

text 复制代码
Loaded
Unloaded

等类加载情况。

还可以:

bash 复制代码
jcmd PID VM.native_memory summary

查看 JVM Native Memory。

不过:

text 复制代码
VM.native_memory

通常要求 JVM 启动时开启:

text 复制代码
Native Memory Tracking

所以生产环境需要提前考虑。


三十一、运行时常量池

运行时常量池:

text 复制代码
Runtime Constant Pool

属于方法区的逻辑组成部分之一。

Class 文件编译以后会存在:

text 复制代码
Constant Pool
常量池

类被加载进 JVM 后,会形成:

text 复制代码
Runtime Constant Pool

它里面可能包含:

text 复制代码
字面量
符号引用
方法引用
字段引用

等信息。


三十二、字符串常量池在哪里?

这个问题面试经常问:

String Constant Pool 在哪里?

在现代 HotSpot 中,例如 Java 8 / Java 17:

字符串常量池位于 Heap 中。

例如:

java 复制代码
String s1 = "hello";
String s2 = "hello";

s1s2 通常会指向字符串常量池中的同一个:

text 复制代码
"hello"

对象。

需要注意:

字符串常量池和运行时常量池不是完全相同的概念。

不要简单把:

text 复制代码
String Pool
Runtime Constant Pool

当成一个东西。


三十三、Direct Memory

直接内存:

text 复制代码
Direct Memory

又经常被称为:

text 复制代码
堆外内存
Off-Heap Memory

它不是 JVM 规范定义的运行时数据区之一。

但是在生产环境非常重要。


三十四、为什么要使用 Direct Memory?

Java NIO 提供:

java 复制代码
ByteBuffer.allocateDirect(...)

申请直接内存。

例如:

java 复制代码
ByteBuffer buffer =
        ByteBuffer.allocateDirect(1024);

和普通 Heap Buffer 相比,Direct Buffer 在某些 IO 场景中可以减少数据在:

text 复制代码
Java Heap
↔
Native Memory

之间来回复制的开销。

所以:

text 复制代码
Netty
NIO
高性能网络框架

经常大量使用 Direct Memory。


三十五、Direct Memory OOM

如果直接内存使用过多,可能出现:

text 复制代码
java.lang.OutOfMemoryError:
Direct buffer memory

这种情况下:

text 复制代码
Heap

甚至可能还很正常。

例如:

text 复制代码
Heap 60%

但是:

Direct Memory 已耗尽

最终仍然发生 OOM。

所以看到:

text 复制代码
OutOfMemoryError

不能只盯 Heap。


三十六、Code Cache

JVM 中还有一块很容易被忽略的区域:

text 复制代码
Code Cache

前面提到过:

text 复制代码
JIT

会把热点字节码编译成:

text 复制代码
Native Machine Code

这些编译后的机器码就需要存放在:

text 复制代码
Code Cache

中。

可以简单理解:

text 复制代码
Hot Method
↓
JIT Compile
↓
Native Code
↓
Code Cache

普通 Java 后端开发不需要每天关注 Code Cache。

但如果进行完整 Java 进程内存分析,它同样属于:

text 复制代码
Native Memory

的一部分。


三十七、一个 Java 进程到底占多少内存?

这是整篇文章非常重要的一部分。

假设:

text 复制代码
-Xmx = 2GB

是否说明:

text 复制代码
Java 进程最多占 2GB?

答案:

不是。

-Xmx 主要控制的是:

text 复制代码
Maximum Java Heap Size

也就是:

text 复制代码
Java Heap 最大值

Java 进程还需要:

text 复制代码
Metaspace
Thread Stack
Direct Memory
Code Cache
GC Internal Structure
JNI
JVM Native Memory

所以更准确的模型:

text 复制代码
Java Process Memory
│
├── Heap
│
├── Metaspace
│
├── Thread Stack
│
├── Direct Memory
│
├── Code Cache
│
├── GC Native Structures
│
└── JVM Native Memory

因此:

text 复制代码
Process Memory
>
-Xmx

是完全正常的。


三十八、Docker 为什么经常把 JVM 内存问题放大?

假设:

text 复制代码
Docker Memory Limit = 2GB

然后 JVM 设置:

text 复制代码
-Xmx1800m

看起来:

text 复制代码
2GB - 1.8GB
≈ 200MB

很多人会觉得:

还有 200MB,应该够了。

但是还没有计算:

text 复制代码
Metaspace

Thread Stack

Direct Memory

Code Cache

Native Memory

最终可能:

text 复制代码
Heap = 1.6GB

Metaspace = 150MB

Thread Stack = 200MB

Direct Memory = 200MB

Other Native = 100MB

整个进程已经超过:

text 复制代码
2GB

最终:

text 复制代码
cgroup memory limit
        ↓
OOM
        ↓
Linux Kill Process
        ↓
Java Process disappears

这就是:

text 复制代码
Container OOMKilled

三十九、Container OOM 和 Heap OOM 的区别

这两种一定要区分。


Heap OOM

JVM 自己发现:

text 复制代码
Java Heap 没有足够空间

通常可以看到:

text 复制代码
java.lang.OutOfMemoryError:
Java heap space

Container OOM

操作系统 / cgroup 发现:

text 复制代码
整个 Java 进程使用内存
>
容器限制

然后直接:

text 复制代码
kill Java process

这种情况下:

JVM 甚至可能没有机会抛出 Java heap space

所以:

text 复制代码
服务突然没了

不一定是:

text 复制代码
Java Heap OOM

还要检查:

text 复制代码
Docker OOMKilled
Linux OOM Killer
cgroup Memory Limit

四十、几种常见内存问题怎么区分?

可以先建立这张表。

问题 典型区域 典型现象
Heap OOM Heap Java heap space
Metaspace OOM Metaspace Metaspace
StackOverflow Java Stack StackOverflowError
Thread 创建失败 Native / Thread Stack unable to create new native thread
Direct Memory OOM Direct Memory Direct buffer memory
Container OOM 整个 Process Java 可能直接被系统杀掉

四十一、用一张图记住

最简单的记忆方式:

text 复制代码
对象太多
↓
Heap
↓
Java heap space

text 复制代码
Class 太多
↓
Metaspace
↓
Metaspace OOM

text 复制代码
方法调用太深
↓
Java Stack
↓
StackOverflowError

text 复制代码
线程太多
↓
Thread Stack / Native Resource
↓
unable to create new native thread

text 复制代码
DirectBuffer 太多
↓
Direct Memory
↓
Direct buffer memory

text 复制代码
整个 Java Process 太大
↓
超过 Container Limit
↓
OOMKilled

四十二、堆、栈、元空间到底有什么区别?

把最重要的几块放在一起:

内存区域 线程关系 主要存什么 典型故障
Heap 共享 对象、数组 Java heap space
Java Stack 私有 栈帧、局部变量等 StackOverflowError
PC Register 私有 当前执行位置 一般不关注 OOM
Native Method Stack 私有 Native 方法调用 栈相关问题
Method Area 共享 类相关逻辑数据 取决于具体实现
Metaspace 共享 Class Metadata Metaspace OOM
Direct Memory JVM 堆外 DirectBuffer 等 Direct buffer memory
Code Cache JVM Native JIT 编译机器码 Code Cache 压力

四十三、创建一个对象时到底发生了什么?

现在把本文知识串起来。

例如:

java 复制代码
User user = new User();

大致可以理解:

text 复制代码
执行 new 指令
      ↓
检查 User Class 是否加载
      ↓
如果没有
      ↓
ClassLoader 加载
      ↓
Class Metadata
进入 Metaspace
      ↓
Heap 分配 User 对象
      ↓
Stack Frame 中
保存 user Reference
      ↓
Reference 指向 Heap Object

于是:

text 复制代码
Metaspace
└── User Class Metadata


Java Stack
└── user reference
       │
       ↓
Heap
└── User Object

这就把:

text 复制代码
类加载

Metaspace

Stack

Heap

第一次真正串起来了。


四十四、调用方法时又发生什么?

例如:

java 复制代码
user.login();

大致:

text 复制代码
当前线程
↓
Java Stack
↓
创建 login() Stack Frame
↓
执行字节码
↓
可能访问 Heap 中的 User Object
↓
方法执行结束
↓
Stack Frame 出栈

所以:

text 复制代码
Stack

和:

text 复制代码
Method Invocation

紧密相关。

而:

text 复制代码
Heap

和:

text 复制代码
Object Lifecycle

紧密相关。


四十五、对象没有引用以后会怎样?

例如:

java 复制代码
User user = new User();

user = null;

此时原来的:

text 复制代码
User Object

如果已经无法从任何:

text 复制代码
GC Root

到达,那么它就可能成为:

text 复制代码
Garbage

之后等待:

text 复制代码
GC

回收。

所以整个过程其实是:

text 复制代码
ClassLoader
↓
Class Metadata
↓
Metaspace

new
↓
Object
↓
Heap

Method Call
↓
Stack Frame
↓
Java Stack

Object unreachable
↓
GC
↓
Heap Space Reclaimed

到这里,JVM 内存、类加载和 GC 已经连接起来了。


四十六、为什么 Full GC 和 Heap 关系这么大?

当 Heap 尤其老年代出现较高内存压力时:

text 复制代码
Old Generation
接近满

JVM 需要更积极地尝试回收对象。

如果出现:

text 复制代码
Full GC
Full GC
Full GC

就需要进一步分析:

text 复制代码
Heap 太小?

对象分配太快?

大量对象进入 Old?

内存泄漏?

大对象太多?

所以:

text 复制代码
Full GC

并不是一个独立问题。

它背后本质还是:

JVM 内存供给和对象生命周期之间失去了平衡。

后面会单独写一篇:

【JVM生产实战】为什么会频繁 Full GC?内存泄漏、堆太小还是分配太快?


四十七、生产排查时为什么不能只看 Heap?

假设:

text 复制代码
Java Process Memory = 1.95GB

Docker Limit = 2GB

这时候 Heap:

text 复制代码
Heap Used = 900MB

你如果只看 Heap:

才用了不到 1GB,没问题。

但实际上:

text 复制代码
Metaspace = 200MB

Direct Memory = 400MB

Thread Stack = 300MB

Other Native = 150MB

整个进程已经非常危险。

所以生产排查必须建立:

Memory Accounting

也就是:

text 复制代码
内存账本

不能只问:

text 复制代码
Heap 用了多少?

还要问:

text 复制代码
整个 Java Process 用了多少?

四十八、JVM 内存排查的正确思路

以后遇到 OOM,不要马上说:

text 复制代码
调大 -Xmx

应该先回答:

text 复制代码
到底是哪块内存出了问题?

排查可以先分:

text 复制代码
OOM
 │
 ├── Heap
 │   └── Java heap space
 │
 ├── Metaspace
 │   └── Metaspace
 │
 ├── Stack
 │   └── StackOverflowError
 │
 ├── Thread / Native Resource
 │   └── unable to create native thread
 │
 ├── Direct Memory
 │   └── Direct buffer memory
 │
 └── Container / Linux
     └── OOMKilled

先分类:

再使用对应工具。

而不是:

所有内存问题都拿 Heap Dump 分析。


四十九、几个常见 JVM 参数先认识一下

这一篇不深入参数调优。

先认识几个和内存相关的参数:

text 复制代码
-Xms

Java Heap 初始大小。

例如:

text 复制代码
-Xms1g

text 复制代码
-Xmx

Java Heap 最大大小。

例如:

text 复制代码
-Xmx2g

text 复制代码
-Xss

线程栈大小。

例如:

text 复制代码
-Xss1m

text 复制代码
-XX:MaxMetaspaceSize

限制 Metaspace 最大值。

例如:

text 复制代码
-XX:MaxMetaspaceSize=256m

text 复制代码
-XX:MaxDirectMemorySize

控制直接内存相关上限。

例如:

text 复制代码
-XX:MaxDirectMemorySize=512m

需要注意:

JVM 参数不能脱离 JDK 版本、GC、容器资源和业务场景机械配置。

后续调优篇会继续展开。


五十、如果线上 OOM,第一反应应该是什么?

不要先问:

-Xmx 多大?

第一句话应该是:

先确认到底是什么 OOM。

例如:

text 复制代码
Java heap space

方向:

text 复制代码
Heap

text 复制代码
Metaspace

方向:

text 复制代码
Class / ClassLoader

text 复制代码
StackOverflowError

方向:

text 复制代码
Call Stack / Recursive Call

text 复制代码
Direct buffer memory

方向:

text 复制代码
Direct Memory / NIO / Netty

Java 直接消失:

text 复制代码
No Java OOM Log

方向:

text 复制代码
Linux OOM Killer
Docker OOMKilled
cgroup Memory Limit

这个思维非常重要。


五十一、面试回答:JVM 内存区域有哪些?

如果面试官问:

JVM 内存区域有哪些?

可以这样回答:

JVM 运行时数据区可以分为线程共享和线程私有两部分。

线程共享的主要是 Heap 和 Method Area,Heap 主要存对象和数组,也是 GC 最主要管理的区域;Method Area 主要存类相关信息。

线程私有的主要有 Java 虚拟机栈、本地方法栈和程序计数器。Java 虚拟机栈和方法调用有关,每次方法调用都会创建栈帧;程序计数器记录当前线程执行到哪条字节码。

在 HotSpot 和生产排查中还需要重点关注 Metaspace 和 Direct Memory。Java 8 以后 HotSpot 使用 Metaspace 保存类元数据,它使用本地内存;NIO、Netty 等还可能大量使用 Direct Memory。

所以生产上不能认为 -Xmx 就是整个 Java 进程的最大内存。

这一段已经能够覆盖大多数 JVM 内存面试问题的第一层。


五十二、面试继续追问:Heap 和 Stack 有什么区别?

可以回答:

Heap 是线程共享的,主要存对象和数组,由 GC 统一管理;Java Stack 是线程私有的,主要保存方法调用产生的栈帧。

Heap 不足常见的是 Java heap space;栈调用层级过深常见的是 StackOverflowError

另外每创建一个线程都会有自己的线程栈,所以线程数量过多还可能消耗大量 Native Memory,甚至导致无法创建新的线程。


五十三、面试继续追问:Metaspace 和 Heap 有什么区别?

可以回答:

Heap 主要保存业务对象和数组,受 -Xmx 等参数影响;Metaspace 主要保存类元数据,并且 Java 8 之后 HotSpot 的 Metaspace 使用本地内存,不属于 Java Heap。

所以 Heap 还有空间,并不代表 Metaspace 不会发生 OOM。大量动态生成类或者 ClassLoader 泄漏都可能导致 Metaspace 持续增长。


五十四、面试继续追问:为什么 Docker 2GB,Xmx 1.8GB 仍然可能 OOM?

可以回答:

因为 -Xmx 只限制 Heap,并不等于 Java 进程的总内存。Java 进程除了 Heap,还包括 Metaspace、线程栈、Direct Memory、Code Cache 和 JVM Native Memory。

如果容器限制 2GB,却把 Heap 最大值直接给到 1.8GB,剩下的空间可能无法容纳这些非 Heap 内存,最终整个进程超过 cgroup 限制,被 Linux 直接杀掉。这种情况下甚至不一定能看到 Java heap space

这也是线上非常典型的一种问题。


五十五、整篇文章最终应该记住什么?

最重要的不是死记定义。

而是记住下面这棵树:

text 复制代码
Java Process
│
├── JVM Runtime Data Area
│   │
│   ├── Thread Shared
│   │   ├── Heap
│   │   └── Method Area
│   │
│   └── Thread Private
│       ├── Program Counter
│       ├── Java Stack
│       └── Native Method Stack
│
└── HotSpot / Native Memory
    │
    ├── Metaspace
    ├── Direct Memory
    ├── Thread Stack
    ├── Code Cache
    └── Other Native Memory

然后把异常挂上去:

text 复制代码
Heap
↓
Java heap space
text 复制代码
Metaspace
↓
Metaspace OOM
text 复制代码
Java Stack
↓
StackOverflowError
text 复制代码
Thread / Native
↓
unable to create new native thread
text 复制代码
Direct Memory
↓
Direct buffer memory
text 复制代码
Whole Java Process
↓
Container Memory Limit
↓
OOMKilled

只要这两张图真正记住,JVM 内存问题就已经有了整体框架。


五十六、面试常见问题

学完这一篇以后,可以尝试回答:

  1. JVM 运行时数据区有哪些?
  2. 哪些内存区域线程共享?
  3. 哪些区域线程私有?
  4. Heap 主要存什么?
  5. Java Stack 主要存什么?
  6. 什么是栈帧?
  7. StackOverflow 为什么发生?
  8. -Xss 是什么?
  9. 为什么线程太多可能导致 OOM?
  10. unable to create new native thread 是什么意思?
  11. 方法区是什么?
  12. 方法区和永久代是什么关系?
  13. Java 8 为什么使用 Metaspace?
  14. Metaspace 在 Heap 里吗?
  15. Metaspace OOM 常见原因是什么?
  16. String 常量池在哪里?
  17. Direct Memory 是什么?
  18. Netty 为什么会使用 Direct Memory?
  19. -Xmx 是否等于 Java 进程最大内存?
  20. 为什么 Docker 2GB,-Xmx1.8g 仍然可能 OOM?
  21. Heap OOM 和 Container OOM 有什么区别?
  22. Heap OOM 一定是内存泄漏吗?
  23. Java 对象一定存在 Heap 吗?
  24. Code Cache 是什么?
  25. 一个 new User() 到底涉及哪些 JVM 内存区域?

五十七、下一篇

现在我们已经回答了:

数据到底放在哪里?

下一步就可以继续回答:

一个 Java 对象到底是怎么创建出来的?

下一篇:

【JVM核心体系03】一个 Java 对象是怎么创建出来的?从 new 指令到对象内存布局

会继续深入:

text 复制代码
new 指令
↓
类加载检查
↓
内存分配
↓
指针碰撞 / 空闲列表
↓
TLAB
↓
零值初始化
↓
对象头
↓
构造方法
↓
对象可用

并进一步讲:

text 复制代码
Object Header
Mark Word
Klass Pointer
Instance Data
Padding

把一个:

java 复制代码
new User();

背后的 JVM 行为真正拆开。


总结

JVM 内存不能只理解成:

text 复制代码
Heap
+
Stack

真实 Java 进程要复杂得多。

从 JVM 规范角度:

text 复制代码
线程共享
├── Heap
└── Method Area

线程私有
├── Program Counter
├── Java Stack
└── Native Method Stack

而从 HotSpot 和生产排查角度,还必须关注:

text 复制代码
Metaspace
Direct Memory
Thread Stack
Code Cache
Native Memory

最终需要建立一个最重要的意识:

Java Heap 只是 Java 进程内存的一部分。

所以:

text 复制代码
-Xmx
≠
Java Process Maximum Memory

同样:

text 复制代码
OOM
≠
Heap OOM

生产环境看到内存问题以后,首先应该做的不是:

text 复制代码
调大 Heap

而是:

先判断到底是哪一块内存出现了问题,再针对性获取证据。

这也是后面学习 Full GC、OOM、Heap Dump 和 JVM 调优的基础。

相关推荐
用户0942485680319 小时前
第12章:JDK 诊断工具箱——jps / jstat / jmap / jcmd / jhsdb
java·jvm
程序员阿鹏21 小时前
双亲委派机制
java·jvm·数据结构·后端
用户094248568031 天前
第11章:OpenJDK反射、动态代理与 MethodHandle 初探
java·jvm
wuminyu1 天前
纯轻量级锁体系下C2编译器锁粗化和消除机制剖析
java·linux·c语言·jvm·c++
binqian1 天前
【java】Java 两种锁机制与线程阻塞唤醒原理
jvm·spring boot·spring
香吧香1 天前
java服务异常日志只打印异常类型,没有堆栈定位分析
java·jvm·异常
程序员黎剑2 天前
JVM-元空间溢出-OutOfMemoryError-Metaspace排查与解决
jvm
11路没有终点2 天前
Java 微服务性能调优:jstack/jmap/jstat 三剑客实战指南
jvm·性能测试