相关问题

  1. JVM 运行时数据区有哪些?
  2. 哪些区域是线程私有,哪些是线程共享?
  3. 程序计数器是干什么的?为什么它是线程私有的?
  4. 虚拟机栈里存什么?
  5. 一个方法调用时,栈帧里有哪些内容?
  6. 本地方法栈是什么?和虚拟机栈有什么区别?
  7. Java 堆里主要存什么?
  8. 栈和堆有什么区别?
  9. 方法区主要存什么?
  10. 元空间是什么?和永久代有什么区别?
  11. 为什么 JDK 8 把永久代换成了元空间?
  12. 运行时常量池是什么?放在哪里?
  13. 字符串常量池放在哪里?
  14. static 变量放在哪里?
  15. 一个 Java 对象创建以后通常存在哪里?
  16. 对象引用和对象本身分别可能存在哪里?
  17. Java 对象在内存中的结构是什么?
  18. 对象头里有哪些内容?Mark Word 是什么?
  19. 什么情况下会出现 StackOverflowError?
  20. 哪些内存区域可能出现 OutOfMemoryError?
  21. 线程创建太多为什么可能导致 OOM?
  22. 直接内存是什么?属于 JVM 运行时数据区吗?
  23. ByteBuffer.allocateDirect() 为什么可能导致直接内存 OOM?

内存结构:内存分哪几块、每块放什么。

GC:堆里的对象怎么分代、怎么判断垃圾、怎么回收。

JVM整体内存结构

JVM 在运行 Java 程序时,会把运行过程中需要使用的内存划分成几个不同的区域,每个区域负责保存不同类型的数据。

从整体上看,可以先分成两类:线程私有区域和线程共享区域。

线程私有区域包括程序计数器、虚拟机栈和本地方法栈。每个线程都会有自己独立的一份,线程之间不会直接共用。

线程共享区域主要包括堆和方法区,也就是后面常说的元空间相关内容,这些区域会被多个线程共同访问。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
JVM 内存

├── 线程私有
│   ├── 程序计数器
│   ├── 虚拟机栈
│   └── 本地方法栈
│
└── 线程共享
    ├── 堆
    └── 方法区 / 元空间

程序计数器用来记录当前线程执行到哪一条字节码指令。因为线程可能随时被切换出去,之后重新获得 CPU 时,需要知道应该从哪里继续执行,所以每个线程都必须有自己独立的程序计数器。

虚拟机栈主要服务于 Java 方法的执行。每调用一个 Java 方法,线程都会在自己的虚拟机栈中创建一个栈帧,用来保存这个方法执行过程中需要的数据,例如局部变量、操作数以及方法返回相关的信息。方法执行结束后,对应的栈帧就会被弹出。

本地方法栈和虚拟机栈的作用比较类似,只不过它主要服务于 native 方法。

所谓 native 方法,就是方法本身在 Java 中只有声明,真正的实现是在 C、C++ 等本地代码中完成的。例如 JVM 需要调用操作系统能力时,就可能通过这种方式执行底层代码。

所以可以把两者区分成:

1
2
3
4
5
虚拟机栈
→ Java 方法执行

本地方法栈
→ Native 方法执行

堆是 JVM 中最重要的共享内存区域之一,Java 中通过 new 创建出来的对象通常都存放在堆中。

例如:

1
User user = new User();

这里真正创建出来的 User 对象通常在堆里,而 user 这个引用如果是方法中的局部变量,则通常存在当前方法的栈帧中。

方法区主要保存和类有关的信息,例如类的结构、方法信息、运行时常量等。在 HotSpot 中,JDK 8 以后通常使用元空间来实现这部分内容。

所以刚开始可以先建立这样一张对应关系:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
程序计数器
→ 记录线程执行到哪里

虚拟机栈
→ 保存 Java 方法执行过程

本地方法栈
→ 保存 Native 方法执行过程

堆
→ 保存对象

方法区 / 元空间
→ 保存类相关信息

后面的 JVM 知识基本都会围绕这些区域展开,比如对象和 GC 主要关注堆,StackOverflowError 主要和虚拟机栈有关,而类加载过多则可能导致元空间出现内存问题。

虚拟机栈和栈帧

虚拟机栈是线程私有的,每调用一个 Java 方法,都会创建一个对应的栈帧并压入当前线程的虚拟机栈。

所以可以直接理解成:

虚拟机栈是一个栈结构,而栈帧就是每次方法调用压入栈中的一个元素。

例如:

1
2
3
methodA() {
    methodB();
}

调用 methodA() 时,会先压入 methodA 的栈帧;执行到 methodB() 时,再压入 methodB 的栈帧。

1
2
3
4
5
┌─────────────┐
│ methodB 栈帧 │ ← 当前执行
├─────────────┤
│ methodA 栈帧 │
└─────────────┘

methodB() 执行结束后,对应栈帧出栈,然后继续执行 methodA()。

一个栈帧主要包含:

1
2
3
4
局部变量表
操作数栈
动态链接
方法返回地址

局部变量表保存方法参数、局部变量和对象引用;操作数栈用于字节码执行过程中的计算。

动态链接主要用于在运行时定位当前方法需要调用的方法或访问的字段。字节码中的很多方法、字段引用最开始只是符号引用,JVM 会结合运行时常量池把它们解析成实际可以访问的目标。

方法返回地址则记录当前方法执行结束以后,应该回到调用方的哪个位置继续执行。

所以栈帧本质上就是:一次方法调用在 JVM 运行期间所需要的执行上下文。

堆

堆是 JVM 中线程共享的一块内存区域,主要用于存放 Java 对象和数组。

例如:

1
User user = new User();

如果 user 是方法中的局部变量,那么 user 这个引用通常保存在当前方法的栈帧中,而真正创建出来的 User 对象通常位于堆中:

1
2
3
虚拟机栈                     堆

user  ───────────────────→  User 对象

除了普通对象和数组之外,一些 JVM 中常见的对象结构也位于堆中。

例如字符串常量池。JDK 7 以后,HotSpot 将字符串常量池放到了 Java 堆中。

1
String s = "hello";

这里字符串常量池中会维护 "hello" 对应的字符串对象,从而让相同字符串字面量可以复用。

另外,Java 中表示一个类的 Class 对象本身也是一个 Java 对象,因此也存在堆中:

1
Class<?> clazz = User.class;

不过要区分:

1
2
3
4
5
Class 对象
→ 堆

类的元数据
→ 方法区 / HotSpot 的元空间

堆也是 GC 重点管理的区域。程序运行过程中会不断创建对象,不再被使用的对象需要由垃圾回收器进行回收。

如果堆空间已经不足,并且 GC 以后仍然无法腾出足够空间,就可能出现:

1
java.lang.OutOfMemoryError: Java heap space

所以从内存结构角度看,堆主要需要记住:

1
2
3
对象实例
字符串常量池
Class 对象

这些数据主要位于堆中,而类本身的元数据则主要存放在方法区对应的实现中。

方法区 / 元空间

方法区主要用来保存和类相关的信息,比如类的结构、方法信息、运行时常量池、静态变量等。

1
2
3
4
5
堆
→ 放对象

方法区
→ 放类相关信息

方法区本身是 JVM 规范里的一个概念,具体怎么实现由不同 JVM 决定。

在 HotSpot 里,JDK 7 及以前主要使用永久代(PermGen)来实现方法区;JDK 8 以后,永久代被移除,改为使用元空间(Metaspace)。

元空间最大的变化是:它不再主要使用 JVM 堆内存,而是使用本地内存。

所以可以理解成:

1
2
3
4
5
6
7
8
方法区
→ JVM 规范中的逻辑概念

永久代
→ 早期 HotSpot 对方法区的实现

元空间
→ JDK 8 以后 HotSpot 对方法区的主要实现

之所以用元空间替代永久代,一个重要原因是永久代容量受 JVM 内存限制,类加载过多时比较容易出现:

1
OutOfMemoryError: PermGen space

元空间改用本地内存以后,容量不再直接受堆大小限制,类元数据管理也更灵活。

不过元空间也不是无限的,如果加载的类太多,同样可能出现:

1
OutOfMemoryError: Metaspace

方法区是逻辑概念,永久代和元空间是 HotSpot 的具体实现;JDK 8 以后主要使用元空间保存类元数据。

运行时常量池

Java 源码编译成 .class 文件以后,class 文件内部会包含一张常量池表。

这张表保存的内容不只是 "hello"、100 这样的字面量,还包括类名、方法名、字段名、方法描述符等符号信息。

例如:

1
user.getName();

编译成字节码以后,并不会直接把 getName() 的真实内存地址写进去,而是通过常量池中的符号引用来描述这个调用。类被 JVM 加载以后,这些信息会进入运行时常量池,供后续的方法调用、字段访问和类型解析使用。

所以运行时常量池可以理解成:

JVM 运行一个类时,用来保存和查找这个类所依赖的常量、方法、字段和类型信息的一张运行时索引表。

在 HotSpot 中,运行时常量池属于类元数据的一部分,主要位于元空间中。

static 变量

static 变量属于类本身,而不是某一个具体对象。

例如:

1
2
3
class User {
    static int count = 10;
}

无论创建多少个 User 实例,count 都只有一份。

JVM 加载 User 类时,会在类元数据中记录:这个类存在一个名为 count 的静态字段,它的类型是 int。

而 count 在程序运行过程中真正保存的值,则和 User.class 对应的 Class 对象关联。

1
2
3
4
5
6
7
元空间
└── User 类元数据
    └── 定义了 static int count

堆
└── User.class 对应的 Class 对象
    └── 保存 count 当前的值

因此,元空间负责描述类的结构,堆中的 Class 对象则承载这个类在 Java 运行时对应的数据。

直接内存

直接内存不是 Java 堆的一部分,而是 JVM 进程直接向操作系统申请的本地内存。

例如:

1
ByteBuffer.allocateDirect(1024);

这类操作会在堆外申请一块内存,用来保存真正的数据缓冲区。

直接内存经常用于网络和文件 IO,因为它可以减少 Java 堆与操作系统之间的数据复制。

可以简单理解成:

1
2
3
4
5
Java Heap
→ 保存 Java 对象

Direct Memory
→ JVM 进程向操作系统申请的堆外内存

直接内存同样不是无限的,如果申请过多,也可能出现:

1
OutOfMemoryError: Direct buffer memory

所以 JVM 进程实际占用的内存,并不只有 Java 堆,还包括元空间、线程栈、直接内存、Code Cache 等其他区域。

对象内存布局

一个 Java 对象放到堆里以后,通常可以分成三部分:

1
2
3
对象头
实例数据
对齐填充

对象头主要保存 JVM 运行时需要的信息,其中最重要的是 Mark Word 和类型指针。

Mark Word 会保存对象运行时的一些状态信息,比如 hashCode、GC 年龄以及锁相关信息。前面讲 synchronized 时提到的锁状态,就是记录在这里。

类型指针则用来告诉 JVM:

这个对象到底属于哪个类。

JVM 可以通过它找到这个对象对应的类元数据。

实例数据就是我们在类里真正定义的字段。

例如:

1
2
3
4
class User {
    int age;
    String name;
}

那么一个 User 对象里,就会有对应的 age 和 name 数据。其中基本类型字段直接保存值,引用类型字段保存的是指向其他对象的引用。

最后是对齐填充。

JVM 为了让对象在内存中的布局更规整,通常会要求对象大小按照一定字节数对齐。如果前面的对象头和实例数据加起来不满足对齐要求,就会在末尾补一些空白字节。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
User 对象

├── 对象头
│   ├── Mark Word
│   └── 类型指针
│
├── 实例数据
│   ├── age
│   └── name 引用
│
└── 对齐填充

对象头保存 JVM 管理对象需要的信息,实例数据保存业务字段,对齐填充只是为了满足内存对齐要求。