运行时数据区

Java虚拟机运行时数据区

程序计数区

这一部分和计算机结构中的程序计数器原理相同, 用于指示当前程序执行的指令位置. 程序计数器是线程私有的, 每个线程都具有一个独立的程序计数区.

虚拟机栈与本地方法栈

与C语言一样, Java内存也可以大致分为, 但Java的内存划分实际上更为细致. 对于栈空间, 处理有传统意义上的虚拟机栈以外, 还包括一个本地方法栈. 虚拟机栈保存了虚拟机在执行某个方法时需要的局部变量表, 操作数栈, 动态链接和方法出口等信息. 具体的栈帧接口在后面会有详细介绍.

虚拟机栈中的局部变量表保存了编译期可知的各种基本信息, 包括各个变量的类型, 引用的对象的地址(可能是指针, 也可能是句柄)和方法的返回地址. 这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示, long和double类型的数据占用两个Slot, 其他类型的数据占用一个Slot(每个Slot对应多少内存空间是JVM实现自己决定的)

对于虚拟机栈, 定义了两种内存异常. 如果线程请求的栈深度大于虚拟机运行的最大深度, 就会抛出StackOverflowError异常. 如果Java虚拟机栈容量可以动态扩展, 但内存耗尽, 则抛出OutOfMemoryError异常.

HotSpot虚拟机的栈容量是不可以动态扩展, 因此只要栈空间申请成功就不会抛出OutOfMemoryError异常.

本地方法栈与虚拟机栈类似, 也定义了就会抛出StackOverflowError异常和OutOfMemoryError异常.

Java堆

Java堆主要存放创建的对象和数据, 几乎所有对象都在堆上分配. 但由于逃逸分析技术的日渐强大, 也存在栈上分配和标量替换等技术, 使得对象没有在堆上分配.

多年以前, Java的垃圾收集器都采用了分代回收算法, 因此Java堆可以细分为新生代和老年代. 但现在垃圾收集器已经有了很大的变化, 已经存在不使用分代算法的垃圾收集器. 因此堆内存不再可以简单的划分为新生代和老年代了.

Java虚拟机的堆可以是固定大小的, 也可以是可扩展的. 不过目前主流的Java虚拟机都是按照可扩展实现的. 如果需要分配新的对象且堆无法扩展, 则虚拟机抛出OutOfMemoryError异常.

在内存分配方面, Java堆是所有线程共享的区域, 但为了提高分配效率, Java堆中也存在线程私有的分配缓冲区.

方法区

方法区是所有线程共享的区域, 存储了已经被虚拟机加载的类型信息, 常量, 静态变量, JIT编译的代码等数据. 根据Java虚拟机规范, 方法区在逻辑上应该是堆的一部分, 但它却有一个别名叫作”非堆”(Non-Heap), 其实质上与堆是不同的内存空间.

在以前, Java程序员习惯将方法区称为永久代, 因为HotSpot虚拟机将垃圾回收扩展到方法区, 并使用永久代来管理方法区的内存. 但这种设计存在一些问题, 使得Java应用更容易遇到内存溢出的问题. 因此在JDK8之后, 就放弃了永久代的概念, 改用使用本地内存中实现的元空间(Metaspace)来代替.

如果方法区无法满足新的内存分配要求, 则会抛出utOfMemoryError异常.


运行时常量池是方法区的一部分, 对应了Class文件中的常量池. 但Java语言中并非在编译时确定值的变量才是常量, 在运行时也可以产生常量, 因此运行时常量池可以在运行时动态的添加常量的特性使得Java语言的这一特性得以实现. 常见的产生运行时常量的方法是String.intern方法.

直接内存

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

在JDK1.4中引入了NIO类, 引入了基于通道与缓冲区的I/O方式, 可以使用Native方法直接分配的堆外内存, 然后通过存储在Java堆中的对象对这块内存进行操作. 这样能在一些场景中避免在Java堆和Native堆中来回复制数据.

这一部分内存不会受到虚拟机内存总量的限制, 但会受到物理机器的总内存限制. 设置内存参数时, 如果忽略了直接内存, 则可能导致总内存操作物理内存的限制, 从而导致OutOfMemoryError.

HotSpot对象

对象的创建

Java虚拟机在遇到一个new指令时, 首先需要检查需要创建的类是否能在常量池找到定义, 对应的类是否被加载. 如果没有没加载就先执行类加载过程. 完成类加载过程后, 虚拟机为对象分配内存空间. 根据不同的内存分配算法, 分配内存空间的过程可能是简单的移动一个指针, 也可能是根据空闲列表选择一个合适的区域.

由于创建对象是一个高频操作, 因此虚拟机需要考虑创建对象的线程安全问题. 这也有两种方案. 第一种方案是使用CAS操作保证更新是一个原子操作, 第二种方案是给每个线程在Java堆中预先分配一部分内存, 只有预先分配的内存用完之后才进行同步锁定.

内存分配完成后, Java虚拟机需要将分配的内存空间全部置零, 从而使得实例的字段即使没有初始化也有正确的零值. 接下来Java虚拟机填充对象头, 包括这个对象是哪个类的实例, 如何找到类的元数据, 对象的哈希吗以及GC年龄信息等内容.

完成上面的工作后, 从虚拟机的角度看, 一个对象已经创建了. 但从Java语言的角度看, 这才是刚刚开始. 接下来虚拟机需要调用对象的构造函数即Class文件中的<init>()方法. 执行完此方法后, 才算是从Java语言的角度完成了对象的创建工作.

对象的内存布局

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

对象头中包含两类信息, 第一类是存储对象自身的运行时数据, 包括哈希吗, GC分代年龄, 锁状态标志, 线程持有的锁等, 这部分数据的长度在32位虚拟机和64位虚拟机上的长度分别为32bit和64bit. 但因为需要存储的数据很多, 已经超过了能够记录的最大数据量, 因此实际上这部分内存会根据不同的状态存储不同的值.

对象头中的另一部分是类型指针, 此指针指向了此对象的类型元数据, 从而Java虚拟机能够得知这块内存对应的实例是那种类型. 但并非所有的虚拟机都以这种方案获取对象的元数据.

此外, 如果是数组类型, 对象头中还包括了数组的长度信息.


实例数据部分存储的就是在Java语言层面上定义的各种成员变量. 通常按照先父类变量再子类变量的方式排布成员变量, 但如果开启压缩字段功能, 那么也运行将子类中较窄的变量插入到父类变量的空隙之间.


最后是对齐填充, HotSpot虚拟机对象的内存必须是8字节的整数倍, 因此如果不满足要求时, 将会填充一些额外的字节.

对象的访问定位

Java虚拟机规范只规定了通过引用获得对象, 但如何实现引用并没有规定. 因此有句柄和直接指针两种实现方式. 如果使用句柄, 那么内存中可能会划分出一部分空间作为句柄池, 句柄中存储对象的具体地址和类型信息, Java栈通过引用句柄来间接引用对象. 如果使用直接指针, 那么所有的引用就都直接是对象的具体地址, 此时就要考虑如何存储对象的类型信息.

使用句柄的好处是所有的引用都通过句柄间接引用, 因此可以直接修改对象的地址. 移动对象在垃圾回收过程中很常见. 但使用句柄导致访问对象要经过多次寻址, 这对于程序的性能可能有较多的影响.

垃圾收集概述

引用类型

Java中定义了四种不同的应用类型, 各种类型和相应的含义如下表所示

引用类型 含义
强引用 传统的引用, 只要存在强引用就不会被回收
软引用 当内存不足时, 虚拟机回收只有软引用对象
弱引用 无论是否内存不足, 虚拟机都会回收只有弱引用的对象
虚引用 虚引用的存在不影响对象的生命周期, 仅用于对象被回收是收到系统通知

分代收集理论

当代的商业虚拟机垃圾收集器大都遵守分代收集理论, 即

  1. 弱分代假说: 绝大多数对象都是朝生夕灭的
  2. 强分代假说: 熬过越多次垃圾收集过程的对象就越难以消亡

根据上面的分代假设, Java虚拟机可以将内存划分为新生代和老年代. 其中新生代的对象大部分都是新创建的, 而经过足够多次垃圾回收后仍存活的对象进入老年代. Java虚拟机可以以不同的频率扫描新生代和老年代, 从而获得更好的收集效果.

虽然分代可以使虚拟机针对某一个区域进行垃圾收集, 但这里存在一个跨代引用的问题, 即老年代可能对新生代有引用, 而如果仅扫描新生代来判断是否有引用则会导致错误. 针对这一问题, 可以引入第三个假设, 即

  1. 跨代引用假说: 跨代引用相对于同代引用来说仅占极少数

一般情况下, 相互引用的对象应该具有相似的GC年龄, 从而一同进入老年代, 只有少量的对象存在跨代引用. 因此针对少量的跨代引用, 可以通过引入额外的数据结构(记忆集, Remembered Set)来避免对整个老年代的扫描.

记忆集将老年代划分为若干小区域, 每个区域有一个标志位指示此区域内的对象有没有跨代引用. 从而在后续的扫描时, 只需要扫描少量有标记的区域中的对象.

标记清除算法

标记清除算法首先标记对象的可达性, 然后直接原地清除不可达对象. 整个过程不需要移动任何对象, 但会产生空间碎片. 空间碎片过多可能导致大对象无法分配, 进而触发又一次的垃圾回收动作.

标记复制算法

标记复制算法首先将内存分割为两个相等大小的区域, 每次只使用其中的一个区域. 在进行垃圾回收时, 标记复制算法首先标记对象的可达性, 然后将存活的对象直接复制到另一个区域之中, 最后直接清空原区域的内存.

由于存在一个复制过程, 因此可以保证垃圾收集以后的内存是规整的, 但复制过程需要改变对象的地址, 因此可能需要调整对象引用的值. 根据弱分代假说, 如果收集时大部分对象都是死亡的, 那么复制导致的影响可以接受.

在具体的实现时, 不一定需要划分为两个等大的区域, 而是可以划分一个较大的Eden区域和两个较小的Survivor区域. 每次只使用Eden区域和其中的一个Survivor区域. 在清理的时候直接把存活对象都复制到另外一个Survivor区域. HotSpot虚拟机默认Eden和Survivor的大小比例是8:1, 即Eden区域占80%的空间, 两个Survivor区域各占10%的空间.


如果按照Eden和Survivor的模式进行划分, 存在一定概率出现存活的对象较多, 一个Survivor区域无法存放的情况. 这种情况下就会出现分配担保, 即让一部分存活对象直接进入老年代.

标记整理方法

标记复制算法虽然可以避免空间碎片, 但当大部分对象都是存活状态时, 复制操作的代价较大, 而且如果不使用等大的两块内存空间, 就可能出现需要分配担保的情况. 因此老年代一般不使用标记复制算法.

标记整理方法的初始步骤与标记清除方法一致, 但后续步骤不是直接对可回收对象进行清理, 而是让所有存活的对象都向内存空间一端移动, 然后直接清理掉边界以外的内存. 这个过程有点类似磁盘的碎片整理过程.

虽然移动存活对象可以避免空间碎片, 但也导致虚拟机需要更新引用值. 老年代中的对象大部分都是存活的, 因此更新地址值的代价也很大, 垃圾回收器需要仔细的衡量空间碎片和更新地址的代价.

HotSpot算法实现细节

根节点枚举

垃圾回收的第一步是获得GC Root, 从而后续可以通过GC Root遍历引用关系来寻找所有的可达对象.

安全点与安全区域

垃圾收集器并不能随意的暂停用户的进程. 用户进程可以被垃圾收集器暂停的地方称为安全点. 设置安全点有性能开销, 因此虚拟机只会在方法调用, 循环跳转, 异常跳转等位置设置安全点.

当垃圾收集器希望用户进程暂停时, 一般会设置一个标志位, 然后等待用户线程自己检查是否需要暂停, 并自己主动挂起.


安全区域是一段代码区域, 其中引用关系不会发生变化, 从而在这个区域的任何位置开始垃圾收集都是安全的. 当线程进入安全区域时, 设置一个标志位, 从而当垃圾收集器进行根节点枚举时, 直接忽略这些线程. 当这些线程离开安全区时, 会检查是否完成了根节点枚举, 如果没有完成就等待根节点枚举完成后再继续执行. 否则就可以当做什么事情都没有发生, 继续正常执行.

记忆集与卡表

记忆集从逻辑上只需要能够记录是否有跨代引用即可, 在实现上可以以不同的粒度进行实现. 其中以内存块为粒度的实现称为卡表. 这一实现类似操作系统的分页机制, 将内存分成不同的内存块, 每一块称为一个卡页. 记忆集中保存每个卡页是否有跨代引用.

写屏障

虽然记忆集可以缩小搜索范围, 但是维护记忆集又需要引入额外的计算. 针对维护问题, HotSpot虚拟机引入了写屏障技术. 写屏障可以理解为对引用类型字段赋值的AOP切面. 虚拟机能够在赋值操作的前后执行需要的代码. 对卡表的维护就可以通过写屏障在每次赋值的时候进行维护.

虽然使用写屏障导致每次赋值都存在一些额外的开销, 但相比于Mirror GC需要扫描整个老年代的开销还是小很多了.


虽然写屏障能够更新卡表, 但由于CPU的缓存机制, 卡表存在伪共享问题. 一般情况下, CPU会一次读取一行数据(64字节)并放入缓存之中, 而卡表的一个元素只有一个字节, 因此有可能64个卡表元素在一个缓存行. 在多核情况下, 一个CPU对某一行数据进行修改会导致其他CPU中的该行缓存数据失效, 从而强制其他CPU重新获取该行数据, 这将导致多线程下对卡表的更新性能下降.

为了避免这一问题, 可以在写卡表之前先检查对应的元素是否被标记了, 只有需要更新的时候才对卡表进行写入. 不过这又会引入一次额外的判断, 因此虽然避免了伪共享的问题, 但也有性能损耗. 可以通过JVM参数控制是否需要开启写入前判断的功能.

-杂谈 什么是伪共享(false sharing)?

并发的可达性分析

垃圾收集器

image

经典的七种引用于不同的分代的垃圾收集器如上图所示.

Serial系列收集器

Serial / Serial Old收集器如同其名称这样, 是线性的垃圾收集器, 两者分别应用于新生代和老年代. 新生代采取标记-复制算法, 老年代采取标记-整理算法. 在执行垃圾收集的过程时, 都需要暂停所有的用户进程.

虽然现在已经有很多其他更为复杂的垃圾收集器, 但Serial系列的收集器具有实现简单, 内存占用少, 单核性能高的优势, 因此在资源受限的环境下还是有很好的效果.

ParNew收集器

ParNew收集器是Serial收集器的多线程版本. 收集过程中还是需要暂停用户线程, 但收集过程新生代时会使用多条GC线程同时收集.

ParNew收集器并没有太多创新, 但在JDK9以后, 就只能和CMS收集器搭配使用了, 可以将ParNew收集器视为CMS收集器的新生代部分了.

Parallel Scavenger收集器

Parallel Scavenger收集器也是基于标记复制的多线程并行收集器, 与ParNew收集的特性非常相似. 但Parallel Scavenger收集器更关注于吞吐量.

$$吞吐量=\frac{运行用户代码时间}{运行用户代码时间+运行垃圾收集时间}$$

停顿时间段可以保证服务的相应时间, 提高用户体验. 而吞吐量高能够最高效率的利用服务器资源, 因此适合在后台计算不需要太多交互的任务.

Parallel Scavenger收集器能够明确的指定最大停顿时间和吞吐量比例, 并且支持给定一个参数后由虚拟机根据运行情况决定另外一个参数的值.

Parallel Old收集器

Parallel Old收集器是Parallel Scavenger收集器的老年代版本. 采用多线程收集和标记-整理算法. 在Parallel Old收集器出现之前, Parallel Scavenger收集器只能和Serial Old收集器搭配, 在服务器端的多核环境中, Serial Old拖累了整个垃圾收集的效率, 因此吞吐量可能还不如ParNew和CMS的组合.

CMS收集器

CMS(Concurrent Mark Sweep)收集器是以最短回收停顿时间为目标的收集器. 正如名字的含义, CMS收集器使用标记-清除算法且具有并发收集的特性. CMS收集器的运行过程可以分为四个步骤

步骤名称 是否暂停用户线程 相对耗时
初始标记 较短, 暂停用户线程
并发标记 较长, 但不影响用户线程
重新标记 较短, 暂停用户线程
并发清除 较长, 但不影响用户线程

其中初始标记和重新标记阶段需要暂停用户线程, 其他阶段可以与用户线程并发执行.

初始标记节点仅标记CG Root可以直接关联的对象, 速度很快. 并发标记节点与用户线程一同并发的标记其他可达对象, 这一操作的用时较长. 重新标记阶段修正并发标记阶段用户改变的一些引用关系(采用增量更新), 这一阶段需要重新标记的对象较少, 因此耗时也与并发标记阶段耗时更短. 最后的并发清除阶段因为不需要移动对象, 因此也可以和用户进程一同运行.

CMS收集器具有低停顿, 并发收集的特点, 但也存在三个明显的缺点

  1. 对CPU资源敏感, 与用户进程并行执行时会导致用户进程执行时间变长
  2. 无法处理浮动垃圾, 即垃圾收集过程中新产生的垃圾
  3. 标记-清除算法会导致内存碎片

Garbage First收集器

Garbage First(G1)收集器与以往的分代收集器不同, G1收集器采取Region布局, 对象局部收集的思路. G1收集器可以对每个Region分析收集价值, 每次都先收集价值最高的Region, 并且对收集时间进行建模预测, 从而能够保证每次的收集时间都少于一个给定的值.

G1虽然保留新生代和老年代的概念, 但此时的新生代和老年代不再需要是连续的内存空间, 任何一个Region都可以是新生代或者老年代.

由于每个Region都可以是老年代, 因此G1收集器需要维护一个更复杂的记忆集, 记录所有Region之间的引用关系. 这导致G1收集器需要额外消耗大约10%到20%的内存空间来维护这些信息.

G1收集器的并发标记节点采用原始快照实现与用户进程并发执行.因此在此过程中创建的新对象都会直接分配到一个指定的区域, 并且均视为存活对象. 如果回收速度赶不上垃圾产生的速度, 那么G1收集器就要被迫暂停用户进行执行Full GC.

G1收集器的过程与CMS收集器差不多, 但最后的清理阶段G1将Region中存活的对象复制到新的Region之中. G1收集器与CMS收集器相比具有优势, 但也存在内存消耗更大, CPU性能消耗更多的问题. 一般认为在更大的Java堆上(大约6~8GB)G1收集器能获得更好的效果.

Shenandoah收集器

Shenandoah是以低延迟为目标的收集器, 几乎可以在任意的堆大小上做到固定的停顿时间. Shenandoah的布局与G1非常相似, 可以视为对G1收集器的改进. Shenandoah的改进包括

  1. 使用连接矩阵代替记忆集, 连接矩阵以Region为单位记录引用关系
  2. 引入读屏障和Brooks Pointers解决并发移动对象的问题

ZCG

ZGC也是以低延迟为目标的收集器, 也采取和G1类似的Region布局, 由于当前还处于开发阶段, 因此不支持分代. ZGC采用染色指针和转发表实现在不暂停用户进程的同时移动对象.

ZGC的主要特点是将对象的状态信息记录到引用这个对象的指针之中. ZGC可以大致分为四个阶段:

并发标记: 此阶段与G1类似, 包括初始标记, 并发标记和重新标记三个阶段, 具体操作和停顿原因都和G1一致.

并发预备重分配: 通过规则判断哪些Region要回收. ZGC目前不进行分代, 因此不需要记忆集, 但因此也需要扫描更多内存空间.

并发重分配: 移动Region中存活的对象, 并维护一个转发表. 由于染色指针的标记, ZCG可以根据指针判断一个对象是否被重分配. 如果对象被重分配, 则通过内存屏障截获请求并通过转发表返回新的对象地址并更新此指针的值. 此后再通过此指针即可直接访问最新的对象, 这一特性也称为指针的自愈

并发重映射: 此阶段修正指向就对象的指针. 由于指针可以自愈, 因此这一操作并不紧急, 被ZGC合并到了并发标记阶段, 从而节省一次遍历对象图的操作.

ZGC的主要问题是没有采取分代的设计, 导致新生对象不能单独以一个较高的速率进行处理. 由于整个垃圾回收过程是并行执行的, 一次垃圾回收可能需要一段较长的时间, 这段时间产生的浮动垃圾都无法有效的收集.

其他问题讨论

什么时候会触发Full GC

  1. 执行System.gc()可能会导致JVM执行Full GC
  2. 老年代 / MetaSapce空间不足
  3. CMS收集失败
  4. 空间分配担保

在进行Mirror GC时由于Survivor空间相比于Eden空间较小,因此可能存在放不下的情况。此时需要在老年代进行空间分配担保。

  1. 如果老年代的空间大于新生代所有对象之和,则本次收集肯定是安全的。
  2. 否则判断老年代剩余连续空间是否大于历次平均晋升对象大小,以及是否允许分配担保失败
  3. 如果上述两个条件都满足则继续进行Mirror GC
  4. 否则直接进行Full GC

最后更新: 2024年03月28日 23:43

版权声明:本文为原创文章,转载请注明出处

原始链接: https://lizec.top/2020/10/17/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3JVM%E4%B9%8B%E5%86%85%E5%AD%98%E4%B8%8E%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6/