JVM 运行时数据区详解
JVM 运行时数据区详解
JVM 主要由三个子系统构成,分别是:类加载子系统、运行时数据区(内存结构)和 字节码执行引擎。

JVM 的体系结构全貌:

当我们开发 Java 程序时,首先会编写 .java 文件,之后,会将 .java 文件编译成 .class 文件。
在 JVM 中,会通过类加载子系统将 .class 文件的内容装载到 JVM 的运行时数据区,而 JVM 的运行时数据区又会分为:方法区、堆、栈、本地方法栈和程序计数器几个部分。
在装载 class 文件的内容时,会将 class 文件的内容拆分为几个部分,分别装载到 JVM 运行时数据区的几个部分。
方法区也叫作元空间,主要包含了:运行时常量池、类型信息、字段信息、方法信息、类加载器的引用、对应的 Class 实例的引用等信息。
在 JVM 中,程序的执行是通过执行引擎进行的,执行引擎会调用本地方法的接口来执行本地方法库,进而完成整个程序逻辑的执行。
我们常说的垃圾收集器是包含在执行引擎中的,在程序的运行过程中,执行引擎会开启垃圾收集器,并在后台运行,垃圾收集器会不断监控程序运行过程中产生的内存垃圾信息,并根据相应的策略对垃圾信息进行清理。
这里,大家需要注意的是:栈、本地方法栈和程序计数器是每个线程运行时独占的,而方法区和堆是所有线程共享的。所以,栈、本地方法栈和程序计数器不会涉及线程安全问题,而方法区和堆会涉及线程安全问题。
方法区(元空间)
元空间这个区域是 JDK1.8 中划分出来的,主要包含:运行时常量池、类型信息、字段信息、方法信息、类加载器的引用、对应的 Class 实例的引用等信息。方法区中的信息能够被多个线程共享。
例如,在程序中声明的常量、静态变量和有关于类的信息等内容的引用,都会存放在方法区,而这些引用所指向的具体对象一般都会在堆中开辟单独的空间进行存储,也可能会在直接内存中进行存储。

堆
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。
堆一般会被分成年轻代和老年代。而年轻代又会被进一步分为1个 Eden 区和2个 Survivor 区。在内存分配上,如果保持默认配置的话,年轻代和老年代的内存大小比例为 1 : 2,年轻代中的1个 Eden 区和2个 Survivor 区的内存大小比例为:8 : 1 : 1。

栈
用于存储方法执行时的局部变量表、操作数栈、动态链接、方法出口等信息,也是线程私有的。每个方法在执行时都会创建一个栈帧,栈帧包含了方法的局部变量表、操作数栈等信息。
[!note] 堆和栈的区别
- 数据结构:堆是一种动态分配的内存区域,用于存储对象实例和数组。它是所有线程共享的,对象在堆中通过引用进行访问。栈是为每个线程分配的内存区域,用于存储方法调用和局部变量等信息;栈的数据结构类似于栈(先进后出),每个方法的调用会创建一个栈帧,方法的参数、局部变量和返回值都存储在栈帧中。
- 分配方式:堆是由 Java 虚拟机动态分配和管理的,通过垃圾回收机制来自动释放不再使用的对象内存;栈的内存分配是自动的,随着方法的进入和退出而自动分配和释放。当方法被调用时,栈会为方法的参数和局部变量分配内存,当方法执行完毕时,栈会自动释放这些内存。
- 内存管理:堆的内存管理由 Java 虚拟机负责,它使用垃圾回收机制自动回收不再使用的对象内存,从而避免了内存泄漏和手动释放内存的问题;栈的内存管理是自动的,当方法调用结束时,栈会自动释放该方法所使用的内存,不需要额外的操作。
- 空间大小:堆的大小通常比栈大得多,因为堆需要存储大量的对象实例和数组;栈的大小通常比较小,因为它仅用于存储方法的调用和局部变量等信息。
- 生命周期:堆中的对象生命周期可以比较长,可以在程序的不同部分进行引用和使用;栈中的数据生命周期较短,当方法调用结束时,栈中的数据就会被自动释放。
本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。
程序计数器
程序计数器主要有两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
⚠️ 注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
运行时常量池
Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的 常量池表(Constant Pool Table) 。
字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数、浮点数和字符串字面量。常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法符号。
常量池表会在类加载后存放到方法区的运行时常量池中。
运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比典型符号表更广泛的数据。
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。
字符串常量池
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
1 | // 在堆中创建字符串对象”ab“ |
HotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp ,StringTable 可以简单理解为一个固定大小的HashTable ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置),保存的是字符串(key)和 字符串对象的引用(value)的映射关系,字符串对象的引用指向堆中的字符串对象。
JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。


[!question] JDK 为什么要将字符串常量池移动到堆中
主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。
直接内存
直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。
JDK1.4 中新加入的 NIO(Non-Blocking I/O,也被称为 New I/O),引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
类似的概念还有 堆外内存 。在一些文章中将直接内存等价于堆外内存,个人觉得不是特别准确。
堆外内存就是把内存对象分配在堆外的内存,这些内存直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响。

