第3章 垃圾收集器与内存分配策略

第3章 垃圾收集器与内存分配策略

3.1 概述

3.2 对象已死?

3.2.1 引用计数算法

3.2.2 可达性分析算法

  • Java技术体系中固定可作为GC Roots的对象
    • 在虚拟机栈中引用的对象
    • 在方法区中类静态属性引用的对象
    • 在方法区中常量引用的对象
    • 在本地方法栈中本地方法引用的对象
    • Java虚拟机内部的引用
    • 所有被同步锁持有的对象
  • 根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象临时加入,共同构建完整的GC Roots集合
  • 如果只针对Java堆中某一块区域发起垃圾收集时,必须考虑到内存区域是虚拟机自己的实现细节,不是孤立封闭的
    • 某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时需要将这些关联区域的对象一并加入GC Roots集合中,才能保证可达性分析的正确性

3.2.3 再谈引用

  • JDK 1.2之前
    • 如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称该reference数据代表某块内存、某个对象的引用
  • JDK 1.2之后
    • 强引用
      • 只要强引用关系还在,垃圾收集器就永远不会回收掉被引用的对象
    • 软引用
      • 只被软引用关联的对象,在系统将要发生内存溢出异常前,会把这些对象列入回收范围之中进行第二次回收
      • 如果这次回收还没用足够的内存,才会抛出内存溢出异常
      • 提供了SoftReference类来实现软引用
    • 弱引用
      • 被弱引用关联的对象只能生存到下一次垃圾收集发生为止
      • 当垃圾收集器开始工作,无论当前内存是否足够,都会收掉只被弱引用关联的对象
      • 提供了WeakReference类来实现弱引用
    • 虚引用
      • 对象是否有虚引用存在,不会对其生存时间构成影响,无法通过虚引用取得对象实例
      • 为对象设置虚引用关联的唯一目的只是为了能在该对象被收集器回收时收到系统通知
      • 提供了PhantomReference类来实现虚引用

3.2.4 生存还是死亡

  • 尽量避免使用finalize()方法,因为它不等同于C语言中的析构函数,是在Java诞生时对传统C程序员做的一种妥协
  • finalize()方法的运行代价高昂,不确定性大,已被官方明确声明为不推荐使用的语法
  • finalize()方法能做的工作,使用try-finally或者其他方式都能做得更好、更及时

3.2.5 回收方法区

  • 方法区垃圾收集的性价比通常比较低

    • 在新生代中一次垃圾收集通常可以回收70%~99%的内存空间

    • 在方法区中由于苛刻的判定条件,垃圾收集成果往往很低

    • 方法区的垃圾收集主要收集两部分

      • 废弃的常量

        • 回收废弃的常量和回收Java堆中的对象类似
      • 不再使用的类型

        • 同时满足3个条件
        1. 该类的所有实例都已被回收,即Java堆中不存在该类及其任何派生子类的实例
        2. 加载该类的类加载器已经被回收(除非时经过精心设计的可替换类加载器,否则很难达成)
        3. 该类对应的java.lang.Class对象没用在任何地方被引用,无法在任何地方通过反射访问该类的方法

3.3 垃圾收集算法

  • 从如何判定对象消亡的角度出发,垃圾收集算法可以划分为
    • 引用计数式垃圾收集(直接垃圾收集)
    • 追踪式垃圾收集(间接垃圾收集)

3.3.1 分代收集理论

  • 分代假说
    • 弱分代假说:绝大多数对象都是朝生夕灭
    • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
    • 跨代引用假说
  • 根据Java堆内存划分不同的区域,垃圾收集器每次回收的区域划分为
    • Minor GC
    • Major GC
    • Full GC
  • 若只进行Minor GC,新生代中的对象是有可能被老年代引用的
    • 为了找出该区域的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有的对象来确保可达性分析结果的正确性
    • 存在相互引用关系的两个对象,更倾向于同时生存或同时消亡,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象再收集时存活,进而晋升到老年代,这时跨代引用也随机被消除
  • 新生代上建立一个全局数据结构(记忆集,Remembered Set),该结构把老年代划分成若干个小块,并标识出存在跨代引用的老年代
    • 当发生Minor GC时,只有被标记的小块老年代内存被加入到GC Roots扫描

3.3.2 标记-清除算法

  • 执行效率不稳定
    • 如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这是必须进行大量标记和清楚的动作
    • 导致标记和清除两个过程的执行效率都随对象数量而降低
  • 内存空间的碎片化问题
    • 标记、清除之后会产生大量不连续的内存碎片
    • 空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而提前出发垃圾收集

3.3.3 标记-复制算法

  • 如果内存中多数对象都存活,将会产生大量的内存间复制的开销
  • 将可用内存缩小为原来的一半

3.3.4 标记-整理算法

  • 如果移动存活对象,在老年代中每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作
  • 这种对象移动操作必须全程暂停用户应用程序才能进行(Stop The World)

3.4 HotSpot的算法细节实现

3.4.1 根节点枚举

  • 所有收集器再根节点枚举这一步骤时都是必须停止用户线程的,现在可达性分析算法耗时最长的查找引用链的过程已经可以做到和用户线程并发执行
  • 根节点枚举始终必须在一个能保障一致性的快照中才得以进行
    • 即整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况
  • 固定可作为GC Roots的节点主要在全局性的引用(如常量或类静态属性)与执行上下文(如栈帧中的本地变量表)中,查找过程要做到高效并非容易
  • HotSpot虚拟机的解决方案时使用一组称为OopMap的数据结构
    • Oop(Ordinary Object Pointer)普通对象指针
    • 类加载完成时,HotSpot会把对象内偏移量上是什么类型的数据计算出来
    • 在即时编译过程中,会在特定位置记录下栈和寄存器中哪些位置是引用
    • 收集器在扫描时直接扫描,不需要从GC Roots开始查找

3.4.2 安全点

  • 导致OopMap内容变化的指令很多,如果为每一条指令都生成对应的OopMap,将会需要大量的额外存储空间
    • 事实上HotSpot并没有为每一条指令生成OopMap,而是在特定的位置设置了,这个位置被称为安全点
  • 安全点决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来进行垃圾收集,而是强制要求必须执行到达安全点后才能够停止
    • 安全点不能太少以至于垃圾收集器等待时间过长,不能太多以至于增大运行时内存负荷
    • 安全点的选定是以“是否具有让程序长时间执行的特征”为标准
      • 每条指令的执行时间都非常短暂,长时间执行最明显的特征是指令序列的复用,即方法调用、循环跳转、异常跳转等
  • 垃圾收集发生时让所有线程跑到最近的安全点
    • 抢先式中断
      • 垃圾收集发生时,把所有用户线程全部中断,如果有线程中断的地方不在安全点,就恢复该线程直到跑到安全点(几乎不采用)
    • 主动式中断
      • 垃圾收集发生时,不直接操作线程,而是设置一个标志位,各个线程执行过程中会不停的主动去轮询该标志,一旦发现中断标志为真时就在距自己最近的安全点上主动中断挂起
        • 轮询标志的地方和安全点是重合的,同时也会设置在所有创建对象和其他需要在Java堆上分配内存的地方,是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象

3.4.3 安全区域

  • 安全点机制保证了程序在执行时,可以在较短的时间内进入垃圾收集的安全点
    • 程序中仍然存在不执行的线程,即没有被分配处理器时间的线程,这些线程无法响应虚拟机的中断请求
    • 虚拟机不可能持续等待线程重新被激活,分配处理器时间,并执行代码走到安全点
  • 安全区域指能够确保在某一段代码片段中,引用关系不会发生变化
    • 在这个区域中任意地方开始垃圾收集都是安全的,可以把安全区域看作是被扩展拉伸的安全点
    • 当线程执行到安全区域内的代码时,首先会标识自己已经进入安全区域,这段时间内发起的垃圾收集不会管已声明自己在安全区域内的线程
    • 当线程离开安全区域时,先检查虚拟机是否已完成根节点的枚举(或垃圾收集过程中其他需要暂停用户线程的阶段)
      • 如果已完成,线程则继续执行
      • 如果未完成,线程则继续等待,直到接收到可以离开的信号为止

3.4.4 记忆集与卡表

  • 为了解决对象跨代引用所带来的问题,垃圾收集器建立了名为记忆集的数据结构(RememberSet)
  • 记忆集是一种记录从非收集区域指向收集区域的指针合集的抽象数据结构
    • 如果不考虑效率和成本,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构
    • 垃圾收集时,只需要通过记忆集判断出某一块非收集区域是否存在有指向收集区域的指针即可,并不需要任何细节
    • 在实现记忆集时,提供了3中记录精度
      • 字长精度:每个记录精确到一个机器字长(即处理器的寻址位数),该字包含跨代指针
      • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针
      • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针

  • 卡精度是用一种称为“卡表”的方式实现记忆集,目前最常用的一种记忆实现形式
    • 记忆集是一种抽象的数据结构,只定义了行为意图,没有定义其行为的具体实现
    • 卡表是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等
  • HotSpot虚拟机的默认卡表形式是一个字节数组
    • 字节数组的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”,HotSpot使用的卡页大小为512字节
    • 一个卡页通常有多个对象,只要卡页内有一个或多个对象的字段存在跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏,没有则标识为0
    • 在区域垃圾收集时,就把变脏的卡页加入GC Roots中扫描

3.4.5 写屏障

  • 卡页变脏的时间点,原则上应该发生在引用类型字段赋值的时候
    • 若是解释执行的字节码中,虚拟机负责每条字节码指令的执行,有充分的介入空间
    • 若是编译执行的代码中,经过即时编译都的代码已经是机器指令流,此时需要机器码层面的手段来维护卡表的赋值操作
  • 写屏障可以看作是在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面
    • AOP切面就是共性功能与挖的位置的对应关系
    • 在引用对象赋值时会产生一个环形通知,供程序执行额外的动作,即赋值前后都在写屏障覆盖范围内
    • 赋值前的部分写屏障叫做写前屏障,赋值后的部分写屏障写后屏障
    • 应用写屏障后,虚拟机会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次都会对引用进行更新
  • 在高并发场景下还面临着“伪共享”问题,伪共享是处理并发底层细节时一种经常需要考虑的问题
    • 处理器的缓存系统是以缓存行为单位进行存储的,当多线程修改互相独立的变量时,若这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或同步)而导致性能降低
    • 缓存行的大小为64字节,由于一个卡表元素占1字节,64个卡表元素将共享一个缓存行,即这64个卡表元素对应的内存为32KB
    • 若不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能
    • 为避免伪共享问题可以采用有条件的写屏障,先检查卡标记,只有当卡表元素未被标记过时才将其标记为变脏

3.4.6 并发的可达性分析

  • 对于遍历对象图过程中遇到的对象,可以分为三种
    • 白色:表示对象尚未被垃圾处理器访问过,若在分析结束时,依然是白色的对象则代表不可达
    • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过
      • 如果有其他引用指向黑色对象,无须重新扫描
      • 黑色对象不可能不经过灰色对象,直接指向某个白色对象
    • 灰色:表示对象已经被垃圾回收器访问过,但该对象上至少存在一个引用还没有被扫描
  • 垃圾收集器在并发时工作可能会造成两种后果
    • 把原本消亡的对象错误标记为存活
    • 把原本存活的对象错误标记为消亡
  • 当满足两个条件时,会产生“对象消失”,即原本应该是黑色的对象被误标为白色
    • 赋值器插入了一条或多条从黑色对象到白色对象的新引用
    • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
  • 解决方案
    • 增量更新
      • 当黑色对象插入新的指向白色对象的引用关系时,将这个新插入的引用记录下来,在并发扫描结束后,将记录过的引用关系中的黑色对象为根,重新扫描一次
      • 即黑色对象一旦新插入了指向白色对象的引用之后,就会变成灰色对象
    • 原始快照
      • 当灰色对象要删除指向白色对象的引用关系时,将这个删除的引用记录下来,在并发扫描结束后,将记录过的引用关系中的灰色对象为根,重新扫描一次
      • 即无论引用关系删除与否,都会按照刚开始扫描那一刻的对象图快照进行搜索
    • 对引用关系记录的插入和删除,虚拟机的记录操作都是通过写屏障实现的

3.5 经典垃圾收集器

3.5.1 Serial收集器

  • 该收集器是一个单线程工作的收集器,不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,而是强调在它进行垃圾回收时,必须暂停其他所有工作线程,直到收集结束
  • 优点
    • 简单而高效(与其他收集器的单线程相比)
    • 对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的
    • 对于单核处理器或处理器较少的环境,该收集器由于没有线程交互的开销,获得最高效的单线程垃圾收集
  • Serial收集器对于运行在客户端模式下的虚拟机来说是很好的选择

3.5.2 ParNew收集器

  • ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致
  • 除了Serial收集器外,目前只有该收集器能与CMS收集器配合工作
  • ParNew收集器可以说是HotSpot虚拟机中第一款退出历史舞台的垃圾收集器

3.5.3 Parallel Scavenge收集器

  • Parallel Scavenge收集器的目标是达到一个可控制的吞吐量,能够并行收集的多线程收集器
  • Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间,设置吞吐量大小
    • 垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的
  • Parallel Scavenge收集器常被称为“吞吐量优先收集器”,提供了一个开关参数
    • 当该参数被激活后,就不需要人工指定新生代的大小、Eden与Survivor区的比例、晋升老年代对象大小等细节参数
    • 虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大吞吐量
    • 即垃圾收集的自适应调节策略,该策略是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性

3.5.4 Serial Old收集器

  • Serial Old收集器是Serisl收集器的老年代版本,同样是一个单线程收集器
    • 供客户端模式下的HotSpot虚拟机使用
    • 在服务端模式下
      • JDK 1.5及之前与Parallel Scavenge收集器搭配使用
      • 作为CMS收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用

3.5.5 Parallel Old收集器

  • Parallel Old收集器时Parallel Scavenge收集器的老年代版本,支持多线程并发收集
  • 在注重吞吐量或者处理器资源较为稀缺的场合,可以优先考虑Parallel Scavenge加Parallel Old收集器组合

3.5.6 CMS收集器

  • CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器
  • 运行过程分为4个步骤
    • 初始标记
      • 需要Stop The World
      • 仅标记一下GC Roots能直接关联到的对象,速度很快
    • 并发标记
      • 从GC Roots的直接关联对象开始遍历整个对象图的过程
      • 整个过程耗时,但可以与用户线程并发运行
    • 重新标记
      • 需要Stop The World
      • 为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(增量更新)
      • 该阶段停顿时间通常比初始标记阶段稍长,但远比并发标记阶段的时间短
    • 并发清除
      • 清理删除掉标记阶段判断的已死亡的对象
      • 不需要移动对象,可以与用户线程并发运行
  • 整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程并发运行
    • 总体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的
  • CMS收集器的优点是并发收集、低停顿
  • CMS收集器的缺点至少有3个
    1. CMS收集器对处理器资源非常敏感
      • 在并发阶段虽然不会导致用户线程停顿,但却会占用一部分线程而导致应用程序变慢,降低总吞吐量
      • CMS收集器默认启动回收线程数是(处理器核心数量+3)/4,即垃圾收集器占用处理器运算资源是随着处理器核心数量的增加而下降的,但是当处理器核心数量不足4个时,CMS对用户线程的影响就可能变得很大
      • 为了解决这种情况,虚拟机提供了一种“增量式并发收集器”(i-CMS),在并发标记、清理的时候让收集器线程与用户线程交替进行,尽量减少垃圾收集线程的独占资源的时间
      • 从JDK 1.7开始,i-CMS模式被声明为已过时不再提倡用户使用;JDK 1.9开始,i-CMS模式被完全废弃
    2. CMS收集器无法处理“浮动垃圾”
      • 由于垃圾收集阶段用户线程还需要持续运行,即还需要预留足够的空间提供给用户线程使用,CMS不能等待老年代几乎填满时再进行收集,必须预留一部分空间供并发收集时的程序运作使用
      • 若CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现“并发失败”(Concurrent Mode Failure)
      • 此时收集器会启动备用预案,冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿的时间就很长了
      • 参数设置太高很容易导致大量的并发失败产生,性能反而降低
    3. 垃圾收集结束时会有大量空间碎片产生
      • 空间碎片过多时,会出现老年代还有很多剩余空间,但是无法找到足够大的连续空间来分配大对象,而触发一次Full GC的情况
      • 收集器提供了2个参数(JDK 1.9开始废弃)
        • CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,但由于内存整理必须移动存活对象,是无法并发的,停顿时间会变长
        • CMS收集器进行若干次不整理空间的Full GC后,在下一次进入Full GC前会先进行碎片整理

3.5.7 Garbage First收集器

  • Garbage First收集器(G1)开创了收集器面向局部收集的设计思路和基于Region的内存布局形式,G1提供并发的类卸载的支持,被Oracle官方称为“全功能的垃圾收集器”

  • G1收集器被设计为可预测的“停顿时间模型”收集器

    • 即能够支持在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒
    • G1收集器出现之前,垃圾收集的目标范围分为Minor GC、Major GC、Full GC
    • G1收集器可以面向堆内存任何部分来组成回收集(Collection Set,CSet)进行回收,衡量标准是哪块内存中存放的垃圾数量多,回收收益最大,即G1收集器的Mixed GC模式
    • G1仍然遵循分代收集理论,保留新生代和老年代概念,但不再坚持固定大小以及固定数量的分代区域划分,它们是一系列不需要连续区域的动态集合
      • 把连续的Java堆分划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Syrvivor空间,或者老年代空间
    • Region中还有一类特殊的Humongous区域专门用来存储大对象
      • G1认为只要大小超过一个Region容量一半的对象即可判定为大对象
      • 对于超过整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region中,G1大多数行为都把Humongous Region作为老年代的一部分进行看待
    • G1能够建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元
      • 即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划的避免在整个Java堆中进行全区域的垃圾收集
      • G1收集器跟踪各个Region里的垃圾堆积的价值大小,价值即回收获得的空间大小以及回收所需时间的经验值
      • 在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的Region
      • 使用Region划分空间,以及具有优先级的区域回收方式保证了G1收集器在有限的时间内获取尽可能高的收集效率
      • 这就是Garbage First名字的由来
  • 花费近10年才发布的G1的细节之处

    1. Region中存在跨Region引用对象
      • 使用记忆集避免全堆作为GC Roots扫描,每个Region都维护有自己的记忆集,记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页范围之内
      • G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,存储的元素是卡表的索引号,这种“双向”卡表比原本卡表更难实现
      • 由于Region数量比传统收集器的分代数量多,因此G1收集器有着更高的内存占用负担,G1至少耗费Java堆容量10%~20%的额外内存来维持收集器工作
    2. 并发标记阶段多线程并发运行
      • 用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,G1通过原始快照算法来实现
      • 每一个Region设计了两个名为TAMS的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新创建的对象地址必须要在这两个指针位置上
      • G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围
      • 如果内存回收的速度赶不上内存分配的速度,会发出“Concurrent Mode Failure”并发失败,导致Full GC而产生长时间“Stop The World”
    3. 建立可靠的停顿预测模型
      • G1收集器的停顿预测模型是以衰减均值为理论基础实现的
      • 在垃圾回收过程中,G1收集器会记录每个Region的回收耗时、记忆集里的脏卡数量等各个可测量的步骤花费成本,并分析得出平均值、标准偏差、置信度等统计信息
      • “衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,更能准确的代表“最近的”平均状态
      • 即Region的统计状态越新越能决定其回收的价值,通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益
  • G1收集器的运作过程分为4个步骤

    • 初始标记
      • 仅是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让用户线程并发运行时能正确的在可用Region中分配新对象
      • 需要线程停顿,耗时很短,而且是借用进行Minor GC的时候同步完成,因此G1收集器在这个阶段时间并没有额外的停顿
    • 并发标记
      • 从GC Roots的直接关联对象进行可达性分析,递归扫描整个堆里的对象图,该阶段耗时较长,但用户线程可以并发执行
      • 对象图扫描完成以后,重新处理SATB记录下的在并发时有引用变动的对象
    • 最终标记
      • 对用户线程做短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后少量的SATB记录
    • 筛选回收
      • 负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来指定回收计划
      • 可以自由选择任意多个Region构成回收集,把决定回收的那一部分Region的存活对象复制到空Region中,再清理掉整个旧Region
      • 此操作涉及存活对象的移动,必须暂停用户线程,由多条收集器线程并行完成
  • CMS和G1的对比

    • 收集算法
      • CMS收集器使用“标记-清除”算法
      • G1收集器从整体来看是基于“标记-整理”算法实现的,从局部(2个Region)来看是基于“标记-复制”算法实现
        • 这意味着G1运作期间不会产生内存空间碎片,垃圾收集完后成后能提供规整的可用内存
    • 内存占用
      • CMS收集器的卡表只有一份,只需要处理老年代到新生代的引用
      • G1收集器堆中每个Region不论什么身份都必须有一份卡表
    • 执行负载
      • CMS收集器通过同步操作使用写屏障来更新维护卡表
      • G1收集器除了使用写屏障更新维护卡表外
        • 为了实现原始快照搜索(SATB)算法,使用写屏障来跟踪并发时的指针变化情况
        • 相比起增量更新算法,原始快照算法能够减少并发标记和重新标记阶段的消耗,避免在最终标记阶段停顿时间过长的缺点,但在用户线程运行过程中会产生由跟踪引用变化带来的额外负担
        • G1将写屏障实现为类似消息队列的结构,把写前屏障和写后屏障中要做的事情放到队列中,然后异步处理

3.6 低延迟垃圾收集器

  • 衡量垃圾收集器的三项最重要指标
    • 内存占用、吞吐量、延迟

3.6.1 Shenandoah收集器(谢南多厄)

  • OracleJDK 12中不支持Shenandoah收集器,而OpenJDK中支持

  • Shenandoah收集器更像是G1的继承者,两者拥有着相似的内存布局,在初始标记、并发标记等阶段的处理思路上高度一致,甚至共享了一部分代价

  • Shenandoah收集器相对于G1收集器堆内存方面的改进

    • G1回收阶段可以多线程并行,但不能与用户线程并发,Shenandoah收集器支持并发的整理算法
    • 默认不使用分代收集,没有实现分代
    • 摒弃了记忆集,改用名为“连接矩阵”的全局数据结构来记录跨Region的引用关系,降低了维护消耗,减少了伪共享问题
      • 连接矩阵可以简单理解为一张二维表格,若Region5中的对象引用了Region3的对象,则在连接矩阵中5行3列进行标记,在回收时通过此表格就可以得出哪些Region之间存在跨代引用
  • 在新版本Shenandoah收集器中,工作过程大致分为12个阶段,但前三个阶段是对初始标记前进行了部分收集的强化,可以不严谨的理解为分代收集中的Minor GC,因此大致划分为9个阶段

    • 初始标记(与G1相同)
      • 短暂停顿,标记与GC Roots直接关联的对象
      • 停顿时间与堆大小无关,只与GC Roots的数量有关
    • 并发标记(与G1相同)
      • 遍历对象图,标记出全部可达对象
      • 与用户线程并发执行,时间取决于堆中存活对象的数量以及对象图复杂结构
    • 最终标记(与G1相同)
      • 短暂停顿,处理剩余的SATB扫描
      • 计算出回收价值最高的Region,并构成一组回收集
    • 并发清理
      • 清理整个区域内没有存活对象的Region(该类Region称为Immediate Garbage Region)
    • 并发回收
      • Shenandoah收集器会把回收集中存活对象复制一份到其他未被使用的Region中
      • 在移动对象的同时,用户线程会不停的对被移动的对象进行读写,移动对象是一次性的,但移动之后所有指向该对象的引用还是旧对象的地址,很难一瞬间全部改变过来
      • Shenandoah收集器采用“读屏障”和转发指针(Brooks Pointers)来解决这个问题
      • 运行时间长短取决于回收集大小
    • 初始引用更新
      • 堆中所有指向旧对象的引用修正为复制后的新地址的操作称为引用更新
      • 该阶段建立了一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成对象移动任务
      • 该阶段时间很短,会产生短暂的停顿
    • 并发引用更新
      • 按照内存物理地址的顺序,线性搜索出引用类型,把旧值改为新值即可
      • 该阶段与用户线程并发执行,时间长短取决于内存中引用数量的多少
    • 最终引用更新
      • 修正存在于GC Roots的引用
      • Shenandoah收集器最后一次停顿,停顿时间与GC Roots数量有关
    • 并发清理
      • 经过并发回收和引用更新后,整个回收集中的Region已再无存活对象,变成Immediate Garbage Region,调用一次并发清理来回收这些Region
  • 转发指针是实现对象移动与用户程序并发的一种解决方案

    • 通常是在被移动对象原有的内存上设置保护陷阱
      • 一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中段,进入预设好的异常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上
      • 如果没有操作系统层面的直接支持,该方案将导致用户态频繁切换到核心态,代价较大
    • Brooks Pointer在原有对象布局结构的最前端统一增加一个新的引用字段
      • 这种方案与句柄定位有相似之处,都是一种间接性的对象访问方式,差别是句柄通常会统一存储在专门的句柄池,转发指针是分散存放在每一个对象头前面
      • 每次对象访问会带来一次额外的转向开销,但是当对象拥有一份新的副本时,只需要修改一处指针的值,即修改旧对象上转发指针的引用位置使其指向新对象
      • 只要旧对象的内存依然存在,虚拟机内存中所有通过旧引用地址访问的代码仍然可用,都会被转发到新对象上继续工作
  • 转发指针读写并发问题

    • 如果收集器线程与用户线程发生的只是并发读取,无论读取到旧对象还是新对象上的字段,返回的结果都是一样的
    • 如果发生的是并发写入,就必须保证写操作只能发生在新复制的对象上,而不能写入旧对象内存中,假设以下情况
      • 收集器线程复制了新的对象副本
      • 用户线程更新对象的某个字段
      • 收集器线程更新转发指针的引用值为新副本地址
    • 该操作将导致的结果是用户线程对对象的变更发生在旧对象上,因此必须针对转发指针的访问操作采取同步措施,Shenandoah收集器是通过CAS来保证并发时对象的访问正确性
  • 转发指针的执行频率问题

    • 虽然使用转发指针保证并发时原对象与复制对象的访问一致性,但是对于面向对象的编程语言来说,对象的读取、写入,对象的比较,对象哈希值计算,用对象加锁等,若要覆盖全部对象访问的操作,Shenandoah收集器需要设置读、写屏障去拦截
    • 为了实现Brooks Pointer,Shenandoah收集器在读、写屏障中都加入了额外的转发处理,其中使用读屏障的代价高于写屏障,代码中读取的频率远高于写入的频率,即读屏障数量远高于写屏障数量
    • 计划在JDK 13中将Shenandoah收集器的内存屏障模型改进为基于引用访问屏障的实现,即内存屏障只拦截对象中数据类型为引用类型的读写操作,而不管原生数据类型等其他非引用字段的读写
    • 此方案能够省去对原生对象、对象比较、对象加锁等场景中设置内存屏障所带来的消耗

3.6.2 ZGC收集器

  • ZGC收集器(Z Garbage)是一款基于Region内存布局的,不设分代的,使用读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的垃圾收集器
  • ZGC的Region具有动态性——动态创建和销毁,以及动态的区域容量大小,具有三类容量
    • 小型Region
      • 容量固定为2MB,放置小于256KB的小对象
    • 中型Region
      • 容量固定为32MB,放置大于等于256KB且小于4MB的对象
    • 大型Region
      • 容量不固定,可以动态变化,但必须是2MB的整数倍,用于放置大于4MB的对象
      • 每个大型Region只会存放一个大对象,因此实际容量可能小于中型Region
      • 大型Region在ZGC的实现中是不会被重分配的,复制一个大对象的代价极大
  • ZGC收集器有一个标志性的设计是采用了染色指针技术
    • 某对象只有它的引用关系能决定它存活与否,对象上其他所有的属性都不能够影响它的存活判定结果
    • 标记实现的方案
      • Serial收集器:标记直接记录在对象头上
      • G1、shenandoah收集器:标记记录在与对象相独立的BitMap上
      • ZGC收集器:标记记录在引用对象的指针上
    • 与其说,可达性分析算法遍历的是对象图来标记对象,不如说是遍历“引用图”来标记“引用”
  • 染色指针是一种直接将少量额外的信息存储在指针上的技术
    • ZGC的染色指针将指针中除去不能寻址后的高4位提取出来存储4个标志信息
    • 通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入重分配集、是否只能通过finalize()方法才能被访问到
    • 由于这些标志位进一步压缩了地址空间,导致ZGC能够管理的内存不可以超过4TB
  • 染色指针的三大优势
    • 染色指针的自愈特性
      • 染色指针可以使得某个Region的存活对象被移走之后,能够立即被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理
      • 理论上只要还有一个空闲Region,ZGC就能完成收集,而shenandoah需要等到引用更新阶段结束以后才能释放回收集中的Region
    • 少量内存屏障
      • 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用,写屏障的目的通常是为了记录对象引用的变动情况,若这些信息直接维护在指针中,则可以省去专门的记录操作
      • 目前为止,ZGC都未使用任何写屏障,只使用了读屏障(染色指针,ZGC不支持分代)
    • 扩展性能
      • 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据
  • 染色指针的实现
    • 程序代码最终都会转换为机器指令流交付给处理器执行,处理器只会把整个指针都视作一个内存地址来对待
      • Solaris/SPARC平台的硬件层面支持虚拟地址掩码,设置之后其机器指令可以忽略掉染色指针中的标志位
      • x86-64平台则没有此类功能,解决方法是虚拟内存映射技术
        • 老代x86计算机系统中所有进程是共同一块物理内存空间,会导致不同进程之间的内存无法相互隔离,当一个进程污染别的进程内存后,整个系统只能进行复位才能得以恢复
        • “保护模式”不同于之前的物理内存寻址,处理器会使用分页管理机制把线性地址空间和物理地址空间分别划分为大小相同的块(页),通过在线性虚拟空间的页与物理地址空间的页之间建立映射表,分页管理机制会进行线性地址到物理地址空间的映射,完成线性地址到物理地址的转换
    • ZGC使用多重映射将多个不同的虚拟内存映射到同一个物理内存地址上,即多对一映射,意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内存容量更大
    • 把染色指针中的标志位看作是地址分段符,将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常寻址
    • 从根源上讲,ZGC的多重映射是它采用染色指针技术的伴生产物,并不是为了实现其他某种特性需求去做的
  • ZGC运作的4个阶段都是可以并发执行的,仅两个阶段存在短暂的停顿
    • 并发标记
      • 遍历对象图做可达性分析阶段,有短暂停顿
      • 标记阶段会更新染色指针中Marked0、Marked1标志位
    • 并发预备重分配
      • 根据特定的查询条件统计得出要清理的Region,并组成重分配集
      • ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1记忆集的维护成本
      • ZGC的重分配决定了存活对象会被重新复制到其他Region中,里面的Region会被释放
      • JDK 12中ZGC开始支持类卸载以及弱引用的处理,也是在该阶段完成
    • 并发重分配
      • 把重分配集中的存活对象复制到新Region上,并为重分配集中每一个Region维护一个转发表,记录从旧对象到新对象的转向关系
      • 用户线程并发访问位于重分配集中的对象时,会被预置的内存屏障截获,然后根据Region上的转发表记录将访问转发到新复制的对象上,同时修正更新该引用的值,称为指针的“自愈”能力
      • 只有在第一次访问旧对象时会陷入转发,由于染色指针的存在,重分配集中某个Region的存活对象都复制完毕后,该Region可以立即释放用于新对象的分配(转发表不释放)
      • 即使堆中还有指向该对象的内更新指针,一旦旧指针被使用,就会自愈
    • 并发重映射
      • 修正整个堆中指向重分配集中旧对象的所有引用
      • 重映射清理这些旧引用的主要目的是为了不变慢(释放转发表带来的收益)
      • ZGC把并发重映射阶段放到下一次垃圾收集循环中的并发标记阶段完成
      • 因为都是要遍历所有对象的,所有指针被修正后,记录新旧对象关系的转发表就会释放
  • ZGC是迄今垃圾收集器最前沿成果,几乎整个收集过程都全程并发,短暂的停顿也只与GC Roots大小相关而与堆内存大小无关,实现了任何堆上停顿都小于10毫秒的目标
  • ZGC完全没有使用到写屏障,为程序带来的运行负担也小得多,同时也限制了它能承受的对象分配速率
    • 如果对象分配速率很高,将创造大量的新对象,这些新对象很难进入收集的标记范围,通常全部当作存活对象,即使很多对象是朝生夕灭,因此产生很多浮动垃圾
    • 如果这种分配持续维持,每次回收到的内存空间持续小于并发产生的浮动垃圾所占空间,堆中剩余可腾挪空间会越来越小
    • 若要从根本上提升ZGC能够应对的对象分配速率,需要引入分代收集,让新生对象都在一个区域创建,然后专门针对这个区域进行更频繁、更快的收集

3.7 选择合适的垃圾收集器

3.7.1 Epsilon收集器

3.7.2 收集器的权衡

3.7.3 虚拟机及垃圾收集器日志

3.7.4 垃圾收集器参数总结

3.8 内存分配与回收策略

  • Java技术体系的自动内存管理,最根本的目标是自动化解决问题
    • 自动给对象分配内存
    • 自动回收分配为对象内存
  • 对象的内存分配从概念上讲,都是在堆中分配,即实际上有可能经过即时编译后被拆散为标量类型并间接的在栈上分配
    • 经典分代的设计下,新生对象通常会分配在新生代中,少数情况下,当对象超过阈值时可能会直接分配在老年代
    • 《Java虚拟机规范》并未规定新对象的创建和存储细节,取决于虚拟机当前使用哪一种垃圾收集器,以及虚拟机中与内存相关的参数的设定

3.8.1 对象优先在Eden分配

  • 大多数情况下,对象在Eden区中分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC
  • HotSpot虚拟机提供了-XX:+PrintGCDetails收集器日志参数,虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况

3.8.2 大对象直接进入老年代

  • 大对象就是指需要大量连续内存空间的Java对象
  • 在Java虚拟机中要避免大对象的使用,在分配空间时,容易导致内存明明还有不少空间时就提前出发垃圾收集,以获取足够的连续空间才能安置好;在复制对象时,大对象意味着高额的内存复制开销
  • HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,可以避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作

3.8.3 长期存活的对象将进入老年代

  • 虚拟机会给每个对象定义一个对象年龄计数器,并存储在对象头中
  • 对象通常在Eden区中诞生,经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且年龄设置为1岁
    • 对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当年龄增加到一定程度(默认为15),就会被晋升到老年代中
  • HotSpot虚拟机提供了-XX:MaxTenuringThreshold参数,设置对象晋升老年代的年龄阈值

3.8.4 动态对象年龄判定

  • HotSpot虚拟机并不是永远要求对象的年龄必须到达阈值后才能晋升老年代
  • 如果在Survivor空间中所有相同年龄的对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到阈值要求的年龄

3.8.5 空间分配担保

  • 在发生Minor GC之前,虚拟机必须检查老年代最大可用的连续空间是否大于新生代所有对象总空间
    • 如果条件成立,那么本次Minor GC是确保安全的
    • 如果条件不成立,则虚拟机会查看-XX:HandlePromotionFailure参数设置是否允许担保失败
      • 如果允许,会检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
        • 如果大于,将尝试进行一次Minor GC,尽管本次GC有风险
        • 如果小于,会改为进行一次Full GC
      • 如果不允许,会改为进行一次Full GC
  • 虚拟机“冒险”担保失败
    • 即新生代使用复制收集算法,其中一个Survivor空间会作为轮换备份,当一次Minor GC后有大量存活对象,需要老年代进行分配担保,把Survivor无法容纳的对象直接存储到老年代中
    • 老年代能够进行担保,前提是老年代本身有容纳这些对象的空间,但共有多少对象能够存活下来再实际完成内存回收之前无法明确知道
    • 所以取之前每一次回收晋升到老年代对象容量的平均大小作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来释放老年代的空间

《深入理解Java虚拟机》读书笔记

周志明 著
机械工业出版社
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值