概述

总所周知,Java 代码都是运行在虚拟机上的,而虚拟机在执行 Java 程序的过程中会把所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途。这些区域的统称就是运行时数据区域。
也正是因为 Java 的自动内存管理机制,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。这篇文章就是介绍一下 Java 内存结构即运行时数据区域。

Java 内存结构

《Java 虚拟机规范(Java SE 8)》中描述了 JVM 运行时内存区域结构如下:

20210224164319

图片来自JVM 内存结构 VS Java 内存模型 VS Java 对象模型

线程共享 线程私有
Java 堆 PC 寄存器(程序计数器)
方法区 Java 虚拟机栈
本地方法栈

JDK1.8 后,方法区的实现由永久代改为了元空间,这里按下不表,后面再说。

PC 寄存器(程序计数器)

程序计数器是一块较小的内存空间,几乎可以忽略不记,也是运行速度最快的存储区域(因为只存放了指向下一条指令的地址)。可以看作是当前线程所执行的字节码的行号指示器。

字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。

  • PC 寄存器用来存储指向下一条指令的地址,也就是即将要执行的指令代码。由执行引擎读取下一条指令。
  • 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。
  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

从上面的介绍中我们知道程序计数器主要有两个作用:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

总结一下,PC 寄存器有两个问题:

  1. Question: 使用 PC 寄存器存储字节码指令地址有什么用呢? 为什么要使用 PC 寄存器记录当前线程的执行地址呢?
    Answer: 因为 CPU 需要不停的切换各个线程,在切换回来后,需要知道接着从哪开始继续执行。而 JVM 的字节码解释器就可以通过 PC 寄存器的值来明确下一条应该执行什么样的字节码指令。
  2. Question: PC 寄存器为什么会被设定为线程私有?
    Answer: 为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个 PC 寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。

虚拟机栈

虚拟机栈也称为 Java 栈,每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧(Stack Frame)。

  1. Java 虚拟机栈是线程私有的,它的生命周期与线程相同(随线程而生,随线程而灭)。
  2. 栈帧包括局部变量表、操作数栈、动态链接、方法返回地址和一些附加信息。
  3. 每一个方法被调用直至执行完毕的过程,就对应这一个栈帧在虚拟机栈中从入栈到出栈的过程

Java 栈的大小

Java 虚拟机规范允许虚拟机栈的大小固定不变或者动态扩展。

  • 固定情况下:如果线程请求分配的栈容量超过 Java 虚拟机允许的最大容量,则抛出StackOverflowError异常;
  • 可动态扩展情况下:尝试扩展的时候无法申请到足够的内存;或者在创建新的线程的时候没有足够的内存去创建对应的虚拟机栈,则会抛出OutOfMemoryError异常。

可以通过 java -Xss<size> 设置 Java 线程堆栈大小,或者在 idea 中 help -> edit vm option中改变大小

运行时栈帧结构

每个栈帧包含 5 个组成部分:局部变量表、操作数栈(Operand Stack)、动态链接(Dynamic Linking)、方法出口(Return Address)和一些附加信息

局部变量表
  1. 局部变量表(Local Variables)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。
    在 Java 程序被编译为 Class 文件时,就在方法的 Code 属性的 max_locals 数据项中确定了方法所需的分配的局部变量表的最大容量。
  2. 局部变量表存放了编译期可知的各种基本数据类型 (boolean、byte、char、short、int、float、long、double),对象引用 (reference 类型) 和 returnAddress 类型(它指向了一条字节码指令的地址)。

如果执行没有被 static 修饰的方法,即非静态方法。那么局部变量表中第 0 位索引的变量槽,默认是用于传递方法所属对象实例的引用,也就是 this(当前实例对象的引用)。这也是静态方法内无法使用 this 的原因(因为静态方法的局部变量表中没有 this 对象)。

注意:基本数据和对象引用存储在栈中这种说法虽然是正确的,但是很不严谨,只能说这种说法针对的是局部变量。

  • 局部变量存储在局部变量表中,随着线程而生,线程而灭。并且线程间数据不共享。
  • 如果是成员变量则存储在堆中。类的成员变量在不同对象中各不相同但又需要在不同线程间共享,基本数据类型和引用数据类型的成员变量都存储在这个对象中,作为一个整体存储在堆中。因为线程通过堆共享数据,而虚拟机栈是私有的。并且栈帧里的命名就已经清楚的划分了界限 : 局部变量表!

局部变量表的容量以变量槽为最小单位,每个变量槽都可以存储 32 位长度的内存空间,例如 boolean、byte、char、short、int、float、reference。
对于 64 位长度的数据类型(long,double),虚拟机会以高位对齐方式为其分配两个连续的 Slot 空间,也就是相当于把一次 long 和 double 数据类型读写分割成为两次 32 位读写。

操作数栈

操作数栈(Operand Stack)也常被称为操作栈,是一个后入先出栈,用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间。其最大深度在编译时就被写到了 Code 属性的 max_stacks 中。

栈中的任何一个元素都可以是任意的 Java 数据类型,32bit 的类型占用一个栈深度,64bit 的类型占用两个栈单位深度.

如果被调用的方法带有返回值的话,其返回值会被压入当前栈帧的操作数栈中。

动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。Class 文件的常量池中存在大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数,这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化成为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

方法出口

Java 方法有两种返回方式:

  • return 语句。这种退出方法的方式称为正常完成出口
  • 抛出异常。这种称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给上层调用者产生任何返回值的。

无论采用何种退出方式,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上就等同于把当前栈帧出栈,而不管哪种返回方式都会导致栈帧被弹出。因此退出时可能执行的操作有:恢复上次方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。

附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,成为栈帧信息。

本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。

Java 堆是 Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。java 堆的大小是可扩展的, 通过-Xmx 和-Xms 控制。

Java 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 jdk 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。

堆内存大小

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常被分为下面三部分:

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永生代(Permanent Generation)

20210224180735

JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是本地内存,永久代和元空间后面介绍。

20210224180808

上图所示的 Eden 区、两个 Survivor 区都属于新生代(为了区分,这两个 Survivor 区域按照顺序被命名为 from 和 to),中间一层属于老年代。

大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄为 1),当它的年龄增加到一定程度,就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

堆大小 = 新生代 + 老年代。堆的大小可通过参数–Xms(堆的初始容量)、-Xmx(堆的最大容量) 来指定。

其中,新生代 ( Young ) 被细分为 Eden 和 两个 Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to,以示区分。默认的,Eden : from : to = 8 : 1 : 1 。(可以通过参数 –XX:SurvivorRatio 来设定 。
即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小。
JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。
新生代实际可用的内存空间为 9/10 ( 即 90% )的新生代空间。

堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:

  1. OutOfMemoryError: GC Overhead Limit Exceeded : 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
  2. java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发 java.lang.OutOfMemoryError: Java heap space 错误。(和本机物理内存无关,和你配置的内存大小有关!)
  3. ……

方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆)。

方法区和永久代的关系

《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。同时大多数 JVM 用的都是 Sun 公司的 HotSpot。在 HotSpot 上把 GC 分代收集扩展至方法区,或者说使用永久代来实现方法区。因此,我们得到了结论,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现。其他的虚拟机实现并没有永久带这一说法。

常用参数

在 1.7 之前在(JDK1.2 ~ JDK6)的实现中,HotSpot 使用永久代实现方法区,HotSpot 使用 GC 分代来实现方法区内存回收,可以使用如下参数来调节方法区的大小:

1
2
3
4
5
-XX:PermSize
方法区初始大小
-XX:MaxPermSize
方法区最大大小
超过这个值将会抛出OutOfMemoryError异常:java.lang.OutOfMemoryError: PermGen

相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。

JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是本地内存。

1
2
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。

元空间

对于 Java8, HotSpots 取消了永久代,那么是不是也就没有方法区了呢?当然不是,方法区是一个规范,规范没变,它就一直在。那么取代永久代的就是元空间。它和永久代有什么不同?

  • 存储位置不同,永久代物理上是堆的一部分,和新生代,老年代地址是连续的,而元空间属于本地内存;
  • 存储内容不同,元空间存储的是类的元信息,而字符串常量池和静态变量存入堆中。
为什么要使用元空间替代永久代呢?
  1. 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。

    当你元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace

    你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。
    使用-XX:MetaspaceSize 调整元空间的初始大小。如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

  2. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。

  3. 在 JDK 1.8 中,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

直接内存

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

JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

元空间和直接内存虽然都在堆外内,但是元空间存储类的元信息,而直接内存作用于 NIO 的缓存

参考博客

程序计数器(pc 寄存器) >Java 虚拟机栈(Java 栈) >深入理解 JVM-java 虚拟机栈 >java 堆内存详解 >Java 工程师成神之路