读书笔记 | Java 的内存模型

一、概述

本篇博客是基于《深入理解Java虚拟机》一书的读书笔记,是对笔者所学知识点的一个记录,本篇博客所包含的知识点如下:

  • JVM 内存区域的划分
  • 划分区域各自的功能和职责
  • 对象的创建、内存布局和访问定位
  • JDK1.8 中的虚拟机

二、内存区域的划分

JVM 在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域,如下图所示:

读书笔记 | Java 的内存模型
其中方法区(Method Area)和堆(Heap)是线程共享的,而虚拟机栈(VM Stack)、本地方法栈(Native Method Area)和程序计数器(Program Counter Register)则都是线程私有的。

1. 程序计数器

  • 当前线程所执行的字节码行号指示器。
  • 可以通过改变计数器的值来选取下一条需要被执行的字节码指令。
  • 和线程是一对一的关系,即线程私有。
  • 只对 Java 方法进行计数,如果是 Native 方法则计数器值为 Undefined。
  • 不会发生内存泄漏。

2. 虚拟机栈

  • Java 方法执行的内存模型。
  • 生命周期与线程相同,为线程私有。
  • 每个方法的执行都会创建栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
  • 每个方法从调用到执行结束的过程,对应一个栈帧从入栈到出栈的过程。
  • 如果线程所请求的栈深度超过虚拟机所允许的深度,会抛出 *Error 异常。
  • 虚拟机栈可进行动态扩展,如果扩展时无法申请到足够的内存,就会抛出 OOM。

3. 本地方法栈

  • 与虚拟机栈类似,主要作用于标注了 native 的方法。
  • 与虚拟机栈一样,本地方法栈会抛出 *Error 以及 OOM。

4. Java 堆

  • 所有线程共享的一块内存区域,它的唯一目的就是存放对象实例。
  • 是对象实例的分配区域,也是 GC 管理的主要区域。
  • 从内存回收的角度看,堆可以细分为新生代和老年代,新生代还可以继续细分为 Eden 空间、To Survivor 空间和 From Survivor 空间等。
  • 从内存分配的角度看,线程共享的 Java 堆可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。
  • 无论如何划分,都与存放内容无关,无论哪个区域存放的都是对象实例,进一步划分的目的是为了更好地回收内存或更快地分配内存。

5. 方法区

  • 线程共享的内存部分。
  • 用于存储以被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码数据等。
  • 在 JDK1.7 之前,这个区域经常被成为永久代(Permanent Generation),但永久代其实是 HotSpot 这款虚拟机才存在的概念。
  • 方法区可以选择不进行垃圾收集,这个区域内存回收的主要目标是针对常量池的回收和对类型的卸载。
  • 类型卸载的条件非常苛刻,但是对它的回收却是必要的。
  • 当方法区无法满足内存分配的需求时,将会抛出 OOM。

6. 运行时常量池

  • 运行时常量池(Runtime Constant Pool)是方法区的一部分。
  • 一般来说,运行时常量池除了保存 Class 文件中描述的符号引用外,还会把翻译出来的直接引用进行储存。
  • 相对于Class文件常量池的一个重要特性是具备动态性,即并非预置入Class文件常量池的内存才能进入运行时常量池中,运行期间也可能将新的常量放入池中。

Java 常量池还有以下 2 种类型:

  • 类文件常量池(Constant Pool Table): Class文件中除了有类的版本、字段、方法和接口等描述信息之外,还有一项信息是常量池,用于存放编译器产生的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
  • 字符串常量池:字符串常量池是全局的,因此也称其为全局字符串常量池。在 jdk1.6(含)之前也是方法区的一部分,并且其中存放的是字符串的实例;在 jdk1.7(含)之后是在堆内存之中,存储的是字符串对象的引用,字符串实例是在堆中。

三、对象

关于对象的介绍将按下列顺序进行展开:

  • 在 JVM 中创建一个对象
  • 对象的内存布局
  • 对象的访问定位

1. 创建一个对象

当 JVM 遇到一条 new 指令时,分为以下 3 步进行对象的创建:

  • 类加载检查:首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号。并且检查这个类是否被加载过、解析过和初始化过。如果没有的话就先进行类加载过程,否则的话直接进行第 2 步操作。
  • 为新生对象分配内存:对象所需内存在类加载完成后便可完全确定,为对象分配空间的任务等同于将一块确定大小的内存从堆中划分出来。
    分配方式有如下两种:“指针碰撞”(Bump the Pointer)和“空闲列表”(Free List),分配方式由 Java 堆是否规整决定,规整时使用指针碰撞,不规整则采用空闲列表。
    • 指针碰撞:将用过的和空闲的内存置于两边,中间用一个指针作为分界指示器,分配内存时将指针往空闲空间处移动一段与对象大小相等的距离。应用有 Serial、Parnew 等带 Compact 功能的收集器。
    • 空闲列表:在内存不规整时,虚拟机通过维护一个列表记录记录哪些内存块是可用的,然后在分配时分配一块足够大的内存划分给对象实例,并更新该列表。应用有使用 CMS 这种基于 Mark-Sweep 算法的收集器。

此外在内存分配的过程中还需要考虑线程安全的问题,解决这个问题有两种方案:

  • 对分配空间的动作进行同步处理,实际上虚拟机是通过CAS + 失败重试的方式保证操作的原子性。
  • 将每个线程在 Java 堆中预先分配一块本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。线程分配内存时先在 TLAB 上进行分配,直到 TLAB 用完并分配行的 TLAB 时,才进行同步锁定。虚拟机是否使用 TLAB 可以通过 -XX:+/-UseTLAB 参数进行设定。
  • 设置对象头(Object Header):将实例所属类,类的元数据信息、对象的哈希码、对象的GC分代年龄信息等的寻找方式存储在对象头中。从虚拟机的角度看,此时一个新的对象已经产生。但从Java 程序的角度来看,此时对象还未执行——< init > 方法,所有字段都还为 0。在接着执行 < init > 方法之后,对象按照程序员的意愿进行初始化,此时一个真正可用的对象才算完全生产出来了。

2. 对象的内存布局

上面提到的对象头其实只是对象在内存存储布局的其中一块,对象的完整布局可分为如下 3 块:

  • 对象头(Object Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)‘

2.1 对象头(Object Header)

在 HotSpot 中对象头可分为 2 部分:

  • 第一部分用于存储对象自身的运行时数据,如哈希码(hash code)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分数据官方称之为“Mark Word”,根据虚拟机的位数(32位还是64位)会对其分配 32bit 或 64bit 的空间。

    • 以 32 位的 HotSpot 虚拟机为例,对它的分配如下表所示:
      读书笔记 | Java 的内存模型
  • 第二部分是类型指针,即对象指向它的类元数据的指针,JVM 通过这个指针来确定这个对象是哪个类的实例。

2.2 实例数据(Instance Data)& 对其填充(Padding)

实例数据部分是对象存储的真正有效的信息,是程序代码中所定义的各种类型的字段内容。这部分的内容会受到 JVM 分配策略参数和字段在 Java 源码中定义顺序的影响。

对其填充部分不是必须的,无任何特殊含义,仅仅起占位符的作用。在 HotSpot 虚拟机中,其内存自动内存管理要求对象地址的起始地址必须是 8 字节的整数倍,而对象头部分刚好是 8 的整数倍,所以当实例数据部分内存没有对其时,就需要通过对其填充补全。

3. 对象的访问定位

主流的访问方式有以下 2 种:

  • 使用句柄
  • 直接指针

3.1 使用句柄

使用句柄的访问方式时,Java 堆中将会划分出一块内存作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象的实例数据与数据类型各自的具体地址信息。它的最大优点是在对象被移动时(这在GC的时候非常普遍)只需要改变句柄中的实例数据指针,而 reference 本身存储的就是稳定的句柄地址,所以它并不需要修改。

读书笔记 | Java 的内存模型

3.2 直接指针

使用直接指针进行访问时,Java 堆对象的布局必须考虑如何放置访问类型数据的相关信息,而 reference 中所存储的值直接就是对象的内存地址。它的最大优点是速度更快,因为它节省了一次指针定位的时间开销。HotSpot 就是使用直接指针的方式进行对象的访问定位的。
读书笔记 | Java 的内存模型

四、JDK1.8 中的虚拟机

关于 JDK1.8 中虚拟机运行时数据区域的最大改变,就是去掉了永久代,使用元空间取而代之。并且字符串常量池也从方法区移动到了堆区(在 jdk1.7 时其实就已经完成了字符串常量池的转移)。需要注意一点的是:元空间是直接运行在本地内存上的,所以本质上它并不属于虚拟机运行时数据区域。图示如下:
读书笔记 | Java 的内存模型
在这里笔者有一个疑问:从上图中可以很明显的看到在 Runtime Data Area 中,可以看到方法区消失了。虽然在 HotSpot 中,永久代(PermGen)就等于方法区,而在 jdk1.8 中永久代被移除,但是这是否就可以等同于方法区从 JVM 中被移除了(对于 HotSpot 来说),读者并不确定,在翻阅他人博客的时候看到了如下的讲述:

方法区是 JVM 的一种规范,永久代和元空间均是方法区的实现方式

这里希望有懂的读者告诉下我是怎么回事,谢谢~

而元数据区和永久代的区别体现在 使用元数据区的 JVM 不会出现永久代空间分配不足而抛出的 OOM 异常,理论上,只要本地内存还有空间,那么元数据区就还能继续申请内存。

所以元数据区(MetaSpace)相比永久代(PermGen)有如下优势:

  • 字符串常量池存在于永久代中,容易出现性能问题和内存溢出,而 jdk1.8 中字符串常量池也并非位于元数据区中,而是位于堆中。
  • 类和方法的信息大小难以确定,会给永久代的大小指定造成困难,太小容易造成永久代溢出,太大又容易造成老年代溢出。
  • 永久代会为 GC 带来不必要的复杂性,回收效率偏低。

而 jdk1.8 中运行时数据区其他部分如程序计数器、本地方法栈、虚拟机栈和前面的版本基本上都是一样的。而堆区和之前版本的最大区别就是字符串常量池从原先的方法区移动到了堆区(这是 jdk1.7 做出的改变)。

参考

本篇博客参考自以下书籍和博客:

《深入理解Java虚拟机第二版》 第二部分 自动内存管理机制
https://www.jianshu.com/p/cd93567ed868
https://blog.csdn.net/bruce128/article/details/79357870

本篇学习笔记到这里就结束了,希望能够对您有所帮助~
有问题的话可以在下方评论区给我留言。