1. 运行时数据区

1.1 程序计数器

程序计数器是一块较小的内存空间,它可以看作是当前线程执行的字节码的行号指示器。

  • 如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址
  • 如果线程正在执行的是 Native 方法,这个计数器的值应为空

程序计数器是唯一一个在《Java 虚拟机规范》中没有规定任何 OOM 情况的区域

1.2 虚拟机栈

虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧用于存储局部变量表操作数栈动态连接方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈从入栈到出栈的过程。

局部变量表存放了在编译期可知的各种 Java 虚拟机数据类型:

  • 基本数据类型:bit、short、int、long、float、double、char、boolean
  • 对象引用:reference 类型,它不等同于对象本身。它可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置
  • returnAddress 类型:指向了一条字节码指令的地址
这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)表示,其中 64 位长度的 long 和 double 占用两个变量槽,其余的数据类型只占用一个。

局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

这里的大小指变量槽(Slot)的数量,而不是具体的字节数。虚拟机使用多大的内存空间来表示一个变量槽完全由具体的虚拟机实现自行决定。

该区域会抛出的异常:

  • StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度
  • OutOfMemoryError:栈扩展时无法申请到足够的内存

1.3 本地方法栈

本地方法栈与虚拟机栈的作用非常相似,其区别是虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则是为虚拟机执行本地方法服务。

该区域会抛出的异常:

  • StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度
  • OutOfMemoryError:栈扩展时无法申请到足够的内存

1.4 堆

堆用于存放对象实例。

从分配内存的角度看,所有线程共享的堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率。

《 Java 虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。

堆可能抛出的异常:

OutOfMemoryError:堆中没有足够的内存来完成实例分配,并且堆也无法再扩展时。

JVM 参数:

  • -Xmn

    格式:-Xmn

    设置堆内存的年轻代的初始大小和最大大小(单位:字节)。添加字母 k 或 K 表示千字节,m 或 M 表示兆字节,g 或 G 表示千兆字节。

    堆内存的年轻代区域用于新对象。这个区域的 GC 比其他区域的 GC 执行得更频繁。如果年轻代的大小太小,那么将执行大量的小垃圾收集。如果大小太大,那么只会执行完整的垃圾收集,这可能需要很长时间才能完成。Oracle 建议将年轻带的大小保持在整个堆大小的一半到四分之一之间。

    示例. 设置堆内存的年轻代大小为 256 MB:

    • -Xmn256m
    • -Xmn262144k
    • -Xmn268435456

该参数等同于-XX:NewSize

  • -Xms

    格式:-Xms

    设置堆的初始大小(单位:字节)。该值必须是 1024 的倍数,且大于 1 MB。添加字母 k 或 K 表示千字节,m 或 M 表示兆字节,g 或 G 表示千兆字节。

    示例. 设置堆内存初始大小为 6 MB:

    • -Xms6291456
    • -Xms6144k
    • -Xms6m

如果不设置这个选项,那么初始大小将被设置为分配给老年代和年轻代的大小之和。年轻代的堆的初始大小可以使用-Xmn选项或-XX:NewSize选项来设置。

该参数等同于`-XX:InitialHeapSize

  • -Xmx

    格式 -Xmx

    指定堆的最大大小(单位:字节)。该值必须是 1024 的倍数且大于 2 MB。添加字母 k 或 K 表示千字节,m 或 M 表示兆字节,g 或 G 表示千兆字节。默认值是在运行时根据系统配置选择的。对于服务器部署,-Xms-Xmx通常被设置为相同的值。

    示例. 设置堆内存最大大小为 80 MB:

    • -Xmx83886080
    • -Xmx81920k
    • -Xmx80m

该参数等同于-XX:MaxHeapSize

  • -XX:NewSize=<Size>

    设置堆内存的年轻代的初始大小和最大大小(单位:字节)。添加字母 k 或 K 表示千字节,m 或 M 表示兆字节,g 或 G 表示千兆字节。

    堆内存的年轻代区域用于新对象。这个区域的 GC 比其他区域的 GC 执行得更频繁。如果年轻代的大小太小,那么将执行大量的小垃圾收集。如果大小太大,那么只会执行完整的垃圾收集,这可能需要很长时间才能完成。Oracle 建议将年轻带的大小保持在整个堆大小的一半到四分之一之间。

    示例. 设置堆内存的年轻代大小为 256 MB:

    • -XX:NewSize=256m
    • -XX:NewSize=262144k
    • -XX:NewSize=268435456
  • -XX:InitialHeapSize=<size>

    设置堆的初始大小(单位:字节)。该值要么是 0 ,要么是 1024 的倍数且大于 1 MB 。添加字母 k 或 K 表示千字节,m 或 M 表示兆字节,g 或 G 表示千兆字节。默认值是在运行时根据系统配置选择的。

  • -XX:MaxHeapSize=<size>

    设置堆的最大大小(单位:字节)。该值必须是 1024 的倍数且大于 2 MB。添加字母 k 或 K 表示千字节,m 或 M 表示兆字节,g 或 G 表示千兆字节。默认值是在运行时根据系统配置选择的。对于服务器部署,-XX:InitialHeapSize 和 -XX:MaxHeapSize 通常设置为相同的值。

    示例. 设置堆内存最大大小为 80 MB:

    • -XX:MaxHeapSize=83886080
    • -XX:MaxHeapSize=81920k
    • -XX:MaxHeapSize=80m
参考:JVM 参数文档

1.5 方法区

方法区用于存储已被虚拟机加载得类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。 方法区是堆的一个逻辑部分。

JDK 8 以前 HotSpot 虚拟机设计团队选择把垃圾收集器的分代设计扩展至方法区(使用永久代实现方法区),这样使得 HotSpot 的垃圾收集器能够像管理 Java 堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。但对于其他虚拟机来说,是不存在永久代这个概念的。

废弃永久代进程:

  • JDK 6:提出计划,放弃永久代,逐步改用本地内存来实现方法区
  • JDK 7:字符串常量池、静态变量等移出永久代
  • JDK 8:完全废弃永久代,改用在本地内存中实现的元空间来代替,把 JDK 7 中还剩余的内容(主要是类型信息)全部移到元空间中

《 Java 虚拟机规范》对方法区的约束是非常宽松的,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集在这个区域的确是比较少见的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这块区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。以前 Sun 公司的 Bug 列表中,曾出现过的若干个严重的 Bug 就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致内存泄漏。

方法区可能抛出的异常:

  • OutOfMempryError:方法区无法满足新的内存分配需求时

1.6 运行时常量池

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

运行时常量池相对于 Class 文件常量池的一个重要特征就是具备动态性,并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中。典型的例如 String 类的 intern() 方法。

运行时常量池可能抛出的异常:

  • OutOfMempryError:运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出该异常

1.7 直接内存

直接内存不是虚拟机运行时数据区的一部分,也不是《 Java 虚拟机规范》中定义的内存区域。

直接内存的分配不会受到 Java 堆大小的限制,但是会受到本机总内存(物理内存、SWAP 分区或分页文件总和)大小以及处理器寻址空间的限制。NIO 中会用到直接内存。

可能抛出的异常:

  • OutOfMempryError:当设置的最大直接内存与堆内存之和超过了物理内存时

JVM 参数:

  • -XX:MaxDirectMemorySize=<size>

    设置 NIO 直接缓冲区分配的最大总大小(单位:字节)。添加字母 k 或 K 表示千字节,m 或 M 表示兆字节,g 或 G 表示千兆字节。默认情况下,大小设置为 0 ,这意味着 JVM 自动为 NIO 直接缓冲区分配选择大小。

    示例. 设置直接内存最大值为 1024 KB:

    • -XX:MaxDirectMemorySize=1m
    • -XX:MaxDirectMemorySize=1024k
    • -XX:MaxDirectMemorySize=1048576

1.8 个人理解

方法区是一个逻辑概念,其具体的物理实现是不确定的。方法区内部存储已被虚拟机加载得类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

在 JDK 6 及以前,方法区是由堆内存的永久代实现的,此时类型信息、常量、静态变量等都存储在堆内存的永久代中。

在 JDK 7时,字符串常量池、静态变量等移出永久代,放到普通的堆内存中,永久代仍然存在。但是此时仍然可以说字符串常量池、静态变量等存储在方法区中,因为方法区是一个逻辑概念。只不过此时在实现上,方法区被“分裂”了。

在 JDK 8时,字符串常量池、静态变量等仍然在普通的堆内存中,但永久代已被废除,类型信息使用元空间存储。而元空间是存放在本地内存中的。

使用元空间的目的是因为方法区的大小不太好手动指定,因为其存储的是类型相关的信息(Class 总数等),太小容易导致 OOM,太大容易导致虚拟机内存紧张。

参考文章:https://www.cnblogs.com/duanxz/p/3520829.html

img

img

直接内存是本地内存的子集

2. HotSpot 虚拟机对象探秘

2.1 对象的创建

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

在类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存大小在类加载完成后便可完全确定,为对象分配空间的任务实际上等同于把一块儿确定大小的内存块从 Java 堆中划分出来。

内存空间分配算法

采用哪种空间分配算法与 JVM 使用的垃圾收集器类型决定。如果使用支持空间压缩整理算法的垃圾收集器(如 Serial、ParNew等),那么就可以使用指针碰撞的方式分配内存;如果使用不支持空间压缩整理算法,而是使用基于清除算法的垃圾收集器(如 CMS 等),那么理论上(CMS 就有一种基于分配缓冲区的优化手段)就只能采用较为复杂的空闲列表来分配内存。
  • 指针碰撞

    假设 Java 堆中内存时绝对规整的,所有被使用过的内存都放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那分配内存时就仅需把那个指针向空闲空间方向移动一段与对象大小相等的距离。

  • 空闲列表

    虚拟机维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录

内存分配的线程安全问题

  • 同步处理

    对分配内存空间的动作进行同步处理,JVM 采用 CAS 配上失败重试的方式保证更新操作的原子性( TLAB 关闭时)

  • TLAB(默认)

    把分配内存的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲( Thread Local Allocation Buffer,TLAB ),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓冲区时才需要同步锁定。

    • -XX:+UseTLAB

      启用在年轻代空间中使用本地线程分配缓冲(TLAB)。该选项默认为启用。如果要禁用 TLAB 的使用,请指定-XX:-UseTLAB

内存分配完成之后,JVM 必须将分配到的内存空间(不包括对象头)都初始化为零值,如果使用了 TLAB 的话,该项工作可以提前至 TLAB 分配时顺便进行。这步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。然后 JVM 需要对 对象头 的数据进行设置。

此时,从 JVM 的角度来看,一个新的对象已经产生了。但从 Java 程序的角度来看,对象创建才刚刚开始——构造函数,即 Class 文件的 <init>() 方法还没有执行,所有的字段都默认为零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。一般来说,new 关键字后面会接着执行 <init>() 方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来了。

2.2 对象的内存布局

在 HotSpot 虚拟机中,对象在内存中的存储布局可以划分为三个部分:对象头实例数据对齐填充

  • 对象头:

    • Mark Word

      在 32 位虚拟机中长度为 32 位,在 64 位虚拟机中长度为 64 位

      存储 HashCode、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等

    • 类型指针

      并非所有 JVM 实现都需要在对象数据上保留类型指针,即查找对象的元数据信息并不一定要经过对象本身

      对象指向它的类型元数据的指针,JVM 通过这个指针来确定该对象是哪个类的实例。

    • 数组长度(如果对象是数组)

      因为 JVM 无法通过元数据信息确定数组的长度,所以需要在此记录

  • 实例数据:

    实例数据部分是对象真正存储的有效信息,即代码中定义的各种类型的字段内容。无论是从父类集成下来的,还是在当前类中定义的字段都必须记录。

  • 对齐填充:

    HotSpot 要求对象起始地址必须是 8 字节的整数倍,所以当实例数据分配出现空缺时,需要通过对其填充来补全。

3.3 对象的访问定位

Java 程序会通过栈上的 reference 数据来操作堆上的具体对象。因为 JVMS( Java 虚拟机规范)没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象的访问方式也是由 JVM 实现而定的,主流的访问方式主要有以下两种:

  • 句柄

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

    优点:reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改

  • 直接指针

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

    优点:访问速度快,节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因此这类开销极少称多也是一项极为可观的执行成本。

HotSpot 主要使用直接指针的方式访问对象。

例外情况:如果使用了 Shenandoah 收集器的话也会有一次额外的转发。
Last modification:August 25th, 2020 at 10:25 am