本文部分内容节选自Java Guide和《深入理解Java虚拟机》, Java Guide地址: https://javaguide.cn/java/jvm/memory-area.html

🚀 基础(上) → 🚀 基础(中) → 🚀基础(下) → 🤩集合(上) → 🤩集合(下) → 🤗JVM专题1 → 🤗JVM专题2 → 🤗JVM专题3 → 🤗JVM专题4

运行时数据区域

JVM 在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域. 这些区域有各自的用途, 以及创建和销毁的时间, 有的区域随着虚拟机进程的启动而一直存在, 有些区域则是依赖于用户线程的启动和结束而建立和销毁. 根据 Java 虚拟机规范 的规定, JVM 所管理的内存将会包括以下几个运行时区域

Java运行时数据区域(JDK1.7)

Java运行时数据区域(JDK1.8)

线程私有的 :

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享的 :

  • 方法区
  • 直接内存(非运行时数据区的一部分)

程序计数器

程序计数器是一块较小的内存空间, 它可以看作是当前线程所执行的字节码的行号指示器. 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令, 它是程序控制流的指示器, 分支, 循环, 跳转, 异常处理, 线程恢复都需要依赖这个计数器来完成

由于 JVM 的多线程是通过线程轮流切换, 分配处理器执行实际的方式来实现的, 在任何一个确定的时刻, 一个处理器都只会执行一条线程中的指令. 因此, 为了线程切换之后能恢复到正确的执行位置, 每条线程都需要有一个独立的程序计数器, 各条线程之间计数器互不影响, 独立存储, 所以这类内存区域为线程私有的内存

注意: 此内存区域是唯一一个在 Java虚拟机规范 中没有规定任何 OutOfMemoryError 情况的区域

虚拟机栈

和程序计数器一样, 虚拟机栈也是线程私有的, 它的生命周期和线程相同. 虚拟机栈描述的是 Java 方法执行的线程内存模型: 每个方法被执行的时候, JVM 都会同步创建一个栈帧用于存储局部变量表, 操作数栈, 动态链接, 方法出口等信息. 每一个方法被调用到执行完毕的过程, 就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程

局部变量表存放了编译期可知的各种 JVM 基本数据类型(boolean, byte, char , short, int, float, long, double), 对象引用和returnAddress类型. 这些数据类型在局部变量表中的存储空间以局部变量槽来表示, 其中64位的数据会占据两个槽, 其余的只占据一个. 局部变量表所需的内存空间在编译期间就完成分配, 当进入一个方法时, 这个方法需要在栈帧中分配多大的局部变量空间是完全确定的, 在方法运行期间不会改变局部变量表的大小

操作数栈主要用于方法调用的中转站, 用于存放方法执行过程中产生的中间计算结果. 此外, 计算过程中产生的临时变量也会放在操作数栈中

动态链接主要服务一个方法需要调用其他方法的场景. Class 文件的常量池中保存有大量的符号引用比如方法引用的符号引用. 当一个方法要调用其他方法, 需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用. 动态链接的作用就是为了将符号引用转换为调用方法的直接引用, 这个过程也被叫做动态连接

栈空间不是无限的, 如果函数调用陷入无限循环, 就会导致栈压入过多栈帧, 导致栈空间过深. 当线程请求栈的深度超过 JVM 虚拟机栈的最大深度时, 就会抛出 StackOverflowError

栈帧随着方法调用而创建, 随着方法结束而销毁, 无论是正常完成还是异常完成都算方法结束

除了 StackOverflowError , 栈还可能出现 OutOfMemoryError 错误, 这是因为如果栈的内存大小可以动态扩展, 虚拟机在动态扩展栈时无法申请到足够的内存空间, 则会抛出 OutOfMemoryError

本地方法栈

本地方法栈和虚拟机栈非常相似, 差别仅在于虚拟机栈为虚拟机执行 Java 方法服务, 本地方法栈则为虚拟机使用到的本地方法服务

和虚拟机栈一样, 本地方法栈也会抛出 StackOverflowErrorOutOfMemoryError

堆是 JVM 所管理的内存中最大的一块, Java 堆是所有线程共享的一块内存区域, 在虚拟机启动时创建. 该内存区域的唯一目的就是对象实例, Java中几乎所有的对象实例都在这里分配内存.

Java 堆是垃圾收集器管理的内存区域, 因此也被称为 “GC堆”. 从回收内存的角度看, 由于现代垃圾收集器大部分都是基于分代收集理论设计的, 所以 Java 堆还可以细分为: 新生代和老年代, 再细致一点: Eden, Survivor, Old空间. 进一步划分的目的是为了更好的回收内存或更快的分配内存

在JDK7及以前的版本, 堆分为下面三个部分:

  1. 新生代
  2. 老生代
  3. 永久代

JDK8 之后永久代已被元空间取代, 元空间使用的是本地内存

大部分情况下, 对象首先在 Eden区分配, 在一次新生代垃圾回收之后, 如果对象还存活, 则会进入 S0或S1 , 并且对象的年龄会+1, 当年了增加到一定程度(默认为15岁), 就会晋升到老年代.

堆中最经常出现的就是 OutOfMemoryError 错误, 并且这种错误的表现方式有几种:

  1. java.lang.OutOfMemoryError: GC Overhead Limit Exceeded : 当JVM 花太多时间执行垃圾回收且只能回收很少的堆空间时, 就会发生此错误
  2. java.lang.OutOfMemoryError: Java Heap Space : 假如创建新对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误

方法区

方法区和堆一样, 是线程共享的内存区域, 它用于存储已被虚拟机加载的类型信息, 常量, 静态变量, 即时编译器编译后的代码缓存等数据

方法区和永久代, 元空间是什么关系? 方法区和永久代, 元空间类似于Java中接口和类的关系, 这里类可以看作是永久代和元空间, 接口可以看作是方法区, 也就是说永久代和元空间其实是方法区的两种实现. 永久代是JDK1.8之前对方法区的实现, 元空间是JDK1.8之后对方法区的实现

为什么要把永久代替换成元空间?

  1. 整个永久代有 JVM 本身设置的固定大小上限, 无法进行调整; 而元空间使用的是本地内存, 受本机可用内存的限制, 虽然元空间可能会溢出,但比原来出现溢出的概率要小
  2. 元空间中存放的是类的元数据, 这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间控制, 这样能加载的类就更多了
  3. 在 JDK8, 合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫做永久代的地方, 合并之后也没必要额外设置一个永久代
  4. 永久代会为GC带来不必要的复杂度, 且回收效率偏低

根据 Java虚拟机规范 , 如果方法区无法满足新的内存分配需求, 将抛出 OutOfMemoryError 异常

运行时常量池

运行时常量池是方法区的一部分, Class文件除了有类的版本, 字段, 方法, 接口等描述信息外, 还有一项信息就是常量池表, 用于存放编译期生成的各种字面量与符号引用, 这部分内容将在类加载后存放到方法区的运行时常量池中

运行时常量池相较于 Class 文件常量池的另外一个重要特征就是具备动态性, Java语言并不要求常量一定只有编译期才能产生, 也就是说, 并非预置入Class文件中常量池的内容才能进入方法去运行时常量池, 运行期间也可以将新的常量放入池中

当常量池无法再申请到内存时就会抛出 OutOfMemoryError 异常

字符串常量池

字符串常量池是 JVM 为了提高性能和减少内存消耗针对字符串专门开辟的一块区域, 主要目的是为了避免字符串的重复创建

JDK1.7之前, 字符串常量池存放在永久代, JDK1.7后字符串常量池移动到堆

为什么要把字符串常量池移动到堆?

主要是因为永久代的GC效率太低, 只有整堆收集(Full GC)的时候才会执行GC. Java程序在通常有大量的被创建的字符串等待回收, 将字符串常量池放到堆中, 可以更高效地回收字符串内存

直接内存

直接内存是特殊的内存缓冲区, 它并不在 Java 堆或方法区分配, 而是通过 JNI 的方式在本地内存上分配

直接内存并不是虚拟机运行时数据区的一部分, 也不是虚拟机规范中定义的内存区域, 但是这部分内存也被频繁地使用, 而且也可能导致 OutOfMemoryError 错误出现

直接内存分配不会受到 Java 堆的限制, 但是会受到本机内存大小和处理器寻址空间的限制

HotSpot虚拟机对象探秘

对象创建

Step1: 类加载检查

当虚拟机碰到一条 new 指令时, 首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用, 并且检查这个符号引用代表的类是否已被加载过, 解析过和初始化过. 如果没有, 那必须先执行相应的类加载过程

Step2: 分配内存

在类加载检查通过后, 接下来虚拟机将为新生对象分配内存. 对象所需的内存大小在类加载完成后便可确定, 为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来. 分配方式有 “指针碰撞” 和 “空闲列表”, 取决哪一种主要由 Java 堆是否规整决定

  • 指针碰撞
    • 适用场合: 堆内存规整的情况
    • 原理: 用过的内存全部整合到一边, 没有用过的内存放在另一边, 中间有一个分界指针, 只需要向着没用过的内存方向将该指针移动对象内存大小位置即可
    • 使用该分配方式的GC收集器: Serial, ParNew
  • 空闲列表
    • 适用场合: 堆内存不规整的情况
    • 原理: 虚拟机会维护一个列表, 该列表中会记录哪些内存块是可用的, 在分配的时候, 找一块足够大的内存划分给对象实例, 最后更新列表记录
    • 使用该分配方式的GC收集器: CMS

内存分配涉及到的并发问题

通常来讲, 虚拟机通过以下两种方式保证线程安全

  • CAS+失败重试 : 虚拟机采用CAS配上失败重试的方式保证更新操作的原子性
  • TLAB : 为每一个线程预先在Eden区分配一块内存, JVM在给线程中的对象分配内存时, 首先在TLAB分配, 当对象大于TLAB中的剩余内存或TLAB的内存已用尽时, 再采用上述的CAS进行内存分配

Step3: 初始化零值

内存分配完成时, 虚拟机需要将分配到的内存空间都初始化为零值, 这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用, 程序能访问到这些字段的数据类型所对应的零值

Step4: 设置对象头

设置完零值之后, 虚拟机还要对对象进行必要的设置, 例如这个对象是哪个类的实例, 如何才能找到类的元数据信息, 对象的哈希码, 对象的 GC分代年龄等信息, 这些信息存储在对象的对象头之中

Step5: 执行init方法

执行new指令后还要接着执行 <init> 方法, 这样一个真正可用的对象才算完全产生出来

对象的内存布局

在HotSpot虚拟机中, 对象在内存中的布局可以分为3块区域: 对象头 , 实例数据 , 对齐填充

HotSpot虚拟机的对象头包含两部分信息, 第一部分用于存储对象自身的运行时数据, 另一部分是类型指针

实例数据部分是对象真正存储的有效信息, 也是程序中所定义的各种类型的字段内容

对齐填充部分不是必然存在的, 也没有什么特殊的含义, 仅仅起占位左右, 由于HotSpot的内存管理系统要求对象起始地址必须是8字节的整数倍, 换句话说就是任何对象的大小都必须是8字节的整数倍. 对象头部分已经被设计成8字节的倍数, 所以如果对象实例数据部分没有对齐的话, 就需要对齐填充部分来补全

对象的访问定位

对象访问方式主要有使用句柄直接指针两种

  • 如果使用句柄访问的话, Java堆中可能会划分出一块内存作为句柄池, reference中存储的就是对象的句柄地址, 而句柄中包含对象实例数据与类型数据各自具体的地址信息

  • 如果使用直接指针访问的话, Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息, reference中存储的直接就是对象地址, 如果只是访问对象本身的话, 就不需要多一次间接访问的开销

使用指针访问的最大好处就是速度更快, 节省了一次指针定位的时间开销. 对于HotSpot而言, 主要使用第二种方式进行对象访问