深入理解JVM-笔记3-垃圾回收

181 阅读12分钟

内存模型中, 虚拟机栈, 本地方法栈, 程序计数器的大小在编译器可确定; 且他们是线程私有, 随方法返回和线程结束回收

方法区的大小难以确定, 在运行时才能知道会创建多少对象, 执行哪段代码. 因此GC主要针对这两个区域

垃圾回收触发条件

MinorGC/YoungGC

对young的回收

当Eden区满时触发

将Eden和FromSurvivor去中的存活对象复制到ToSurvivor中, FromSurvivor中对象的年领+1, Eden区的初始年领为1. 再将ToSurvivor中的对象复制到FromSurvivor.

MajorGC/OldGC

通常认为是对oldG的回收

FullGC

对整个堆和方法区的回收. 与上面两种(PartialGC)相对

一般出现fullGC, 说明可能有没有及时释放的变量, 此时需要对堆进行检查, 用jmap, jstack等指令定位哪些变量没有及时释放

在准备要触发一次 youngGC时,需先对oldG的剩余空间进行检查. 如果oldG的剩余空间比新生代所有对象总空间大, 则直接MinorGC

-XX:HandlePromotionFailure 决定了是否在oldG的剩余空间比新生代所有对象总空间小时冒险:

如果发现统计数据说之前 youngGC的平均晋升大小比目前的old gen剩余的空间大,则不会触发young GC而是转为触发FullGC(回收整个堆); 如果平均晋升大小比老年代对象的平均大小小, 则进行MinorGC, 但平均值可能担保失败, 则依然需要fullGC

ParallelScavengeGC

默认在要触发 FullGC前先执行一次 youngGC,并且两次GC之间能让应用程序稍微运行一小下,以期降低FullGC的暂停时间 (因为youngGC会尽量清理了young gen的死对象,减少了FullGC的工作量)。控制这个行为的VM参数是: -XX:+ScavengeBeforeFullGC

CMS GC

定时去检查old gen的使用量,但使用量超过了触发比例就会启动一次 CMS GC,对old gen做并发收集

请求垃圾回收

JVM垃圾回收是自动的,

System.gc()

Runtime.getRuntime().gc()

主动请求垃圾回收

判断对象是否已经死亡

引用计数法

无法判断是否循环引用

可达性分析

对象是否有从GC Roots的引用链

  • 虚拟机栈中栈帧的本地变量表的引用
  • 本地方法栈中(JNI)的引用
  • 方法区静态变量的引用
  • 方法区常量池中的引用
  • JVM内部的引用,如:
    • 基本类型对应的Class对象
    • 常驻的异常对象(NullPointerExecption, OOMError...)
    • 系统类加载器
  • 被同步锁(synchronized)持有的对象
  • 反映JVM内部情况的JMXBean, JVMTI中注册的回调,本地代码缓存等

如果是回收部分区域, 这个区域中的对象可能被其他区域中的对象引用, 则需要将关联区域的对象也加入GCRoots中(可以在老年代中分块, 标记哪块有跨代引用, 只将这部分加入GCRoots即可, 将有引用的放到新生代的记忆集中)

引用类型

  • 强引用
  • 软引用(内存不足时OOM之前, 二次回收)
  • 弱引用(被发现即回收,无论是否内存不足)
  • 虚引用(仅为了在被回收时收到系统通知. 必须与ReferenceQueue联合使用)

对象回收过程

  1. 可达性分析

判断是否有与GCRoots相连的引用链, 若无, 会被第一次标记

  1. 筛选是否有必要调用其finalize()方法

    • 若未覆盖finalize(), 则不调用
    • 若已调用, 则不调用
  2. 将有必要调用finalize()方法的对象放入F-Queue, 由低优先级的Finalize线程去调用

    注意: 可能不会等待finalize()方法执行结束, 避免后续线程等待; 对象可以在自己的finalize()方法中将this赋值给引用链上的对象来建立关联, 则第二次收集时, 它GCRoots可达, 可避免被回收(但自救只会发生一次, 因为下次被标记不可达, 但finalize已经被调用过, 则不可再被调用

  3. 将不需调用finalize()方法的对象直接回收; 将已经调用完finalize()方法的对象重新检查可达性, 若存活不回收, 若不存活回收

判断常量是否可回收

类似对象的回收过程

在HotSpotVM JDK7之前,方法区作为永久代

JDK7将方法区中的常量池和静态变量放到堆中

JDK8后用元空间(MetaSpace)代替永久代.

判断类是否可被回收

类也位于方法区中, 类被回收需同时满足3个条件:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类即派生类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

是否需要JVM对类进行回收可以通过-Xnocalssgc等参数配置

垃圾回收算法

程序运行情况的经验:

  • 弱分代假说: 绝大部分对象都是朝生夕灭的(98%)
  • 强分代假说: 熬过越多次垃圾收集过程的对象越难消灭
  • 跨代引用相对于同代引用仅占极少数(可以在老年代中分块, 标记哪块有跨代引用)

标记-清除

标记: 判定是否属于垃圾(可以标记存活/死亡)

清除: 统一回收标记/未标记的对象

缺点:

  • 大部分对象需要清除, 需要大量标记和清除工作, 执行效率低
  • 产生大量不连续的内存碎片(需要复杂的内存分配器和内存访问器解决)

标记-复制

半区复制, 当半区空间用完, 将存活对象复制到另外半区

特点:

  • 不会产生内存碎片, 分配空间时只需移动堆顶指针, 顺序分配
  • 内存复制消耗高, 需要全程暂停用户应用程序
  • 内存使用率低, 只能使用一半->可通过Appel提升, 但无法全部利用
Appel式回收

因为弱分代假说, 不必按1:1划分新生代 可分为较大的Eden空间和两个较小的Survivor空间 HotSpot默认 -XX: SurvivorRatio=8, 即90%空间用于分配

标记-整理

移动式的回收算法

标记: 过程同标记-清除

整理: 将存活对象向内存空间的一端移动, 清除边界外的内存

缺点:

  • 内存复制消耗高, 需要全程暂停用户应用程序

垃圾回收过程详解

根节点枚举

必须暂停用户线程, 在一个能保障一致性的快照中才得以进行, 否则分析结果不准确. 但并不一定一点不漏地从GCRoots开始找

现在的主流虚拟机都可以准确式内存管理(即虚拟机可以知道内存中的数据具体是引用类型还是某种基本类型, 因此在垃圾回收时能够知道堆上哪些数据是仍被使用的), 因此能够得到哪些地方存放着对象引用.

HotSpotVM用一组OopMap(Oop=Ordinary Object Pointers)保存对象引用的位置. 类加载完成后, HotSpot就会计算出对象内哪个位置上是引用类型; 即时编译时, HotSpot也会在特定位置(安全点)记录下栈(和寄存器?)中哪些位置是引用

安全点

不可能为每条指令生成OopMap, 因此需要设置安全点, 只有达到安全点才能暂停用户进程, 进行垃圾回收

通常安全点选在指令序列复用的位置, 这些地方需要"长时间执行", 例如方法调用, 循环跳转, 异常跳转等

让线程在安全点中断的方式:

  • 抢先式中断

    在垃圾收集发生时, 系统首先把所有用户线程中断, 如果有的用户线程不在安全点上, 则恢复线程运行, 直至跑到安全点再中断

  • 主动式中断

    当垃圾收集需要中断线程时, 不直接对线程操作, 而是简单地设置一个标志位. 各个线程在正常执行时会不断查询该标志位, 如果为真就在最近的安全点(通常轮询标志的地方和安全点是重合的)上主动挂起.

    在需要创建对象或在堆上分配内存的位置也需要查询中断标志位, 防止无足够内存分配对象

    HotSpot的轮询中断标志位通过内存包含陷阱的方式实现. 垃圾收集程序将指定的内存页设为不可读, 则线程之次那个test指令读该内存页时, 会出现异常, 陷入异常处理, 在预先注册的异常处理器中挂起等待.

安全区域

即确保在一段代码中, 引用关系不会发生变化的区域.

对于正在Sleep或Blocked线程, 暂时未执行, 就无法响应中断请求进入安全区. 如果这些线程被唤醒, 就会破坏系统的一致性

当线程执行到安全区域中, 会标识自己进入安全区域, JVM进行根节点枚举时就可以忽略它们; 当线程要离开安全区时, 需要坚持JVM是否完成根节点枚举, 如果完成可以继续执行, 否则需要等待完成的信号

记忆集

记录从非收集区指向收集区域的指针集合的数据结构. 可以分为以下三个精度:

  • 字长精度: 每个记录一个机器字长(即处理器的寻址位数), 记录跨代指针
  • 对象精度: 每个记录为一个跨代对象, 包含跨代指针
  • 卡精度: 每个记录为一块区域, 该区域的对象为跨代对象, 含有跨代指针, 即卡表(卡表中每条记录一个byte, 对应一个卡页的位置)
写屏障(非乱序的写屏障)

JVM的字节码执行分为解释执行和编译执行, 若为编译执行, 指令变为机器指令, 将跨代引用记录到卡表中成为一个问题

HotSpot针对引用类型字段赋值做了一个类似AOP的around处理, 除G1外, 都是在post-write barrier做卡表状态更新

可达性分析

从GCRoots遍历引用链, 对对象进行标记, 同样需要stop the world, 且时间与堆大小成正比. 因此使用并发扫描. 而并发扫描可能带来将存活对象标记为死亡的问题, 因此应采用以下两种方式之一:

  • 增量更新 CMS
  • 原始快照 G1, Shenandoah

HotSpot垃圾回收器

HotSpot垃圾回收器

Serial

单线程, 内存消耗小, 客户端模式的默认收集器

  • 新生代: 标记-复制
  • 老年代: 标记-整理

Serial Old

Serial的老年代版本

常与Parallel Scavenge搭配, 或作为CMS并发收集错误后的预案

ParNew

Serial的多线程版本, 新生代收集可以多线程

通常与老年代收集器CMS搭配使用

Parallel Scavenge

类似ParNew, 同样是标记-复制的新生代垃圾收集器

可精确控制吞吐量(非垃圾回收占总运行时间百分比)

-XX:MaxGCPauseMills 垃圾回收时间不超过设定值, 但可能收集次数上升, 吞吐量下降/ 或可能新生代空间变小

-XX:GCTimeRatio, 默认值99, 即收集时间占 1/(GCTimeRatio+1)

Parellel Old

Parallel Scavenge的老年代版本, 常与Parallel Scavenge配套使用

老年代采用多线程的标记复制算法

CMS(Concurrent Mark Sweep)

基于标记-清除算法, 分为以下阶段

  • 初始标记

    需Stop-the-world, 只标记GCRoots直接关联到的对象, 使用单线程

  • 并发标记

    不需停顿用户线程, 从GCRoots的直接关联对象开始遍历整个堆, 与用户线程并发的一个线程

  • 重新标记

    需Stop-the-world, 并发地修正上一阶段用户操作导致的变动

  • 并发清除

    与用户线程并发地删除标记阶段判断的已死亡对象

特点:

  • 与用户线程并发运行, 占用CPU资源
  • 浮动垃圾, 并发标记和并发清除阶段的垃圾下次才能处理
  • 回收时需要用户线程运行, 不能等老年代快满才收集
  • 标记清除产生内存碎片, 可能未满就无法分配, 导致fullGC. 可以设置参数在fullGC时整理内存(stop-the-world), 或若干次fullGC后整理一次

G1(Garbage First)

全功能垃圾收集器, JDK9成为服务端的默认垃圾收集器

将java堆分为大小相等的Region, 根据垃圾数量回收收益决定回收哪里. Region可扮演Eden/Survivor/老年代, 自动采取不同策略处理.

大小超过Region一半的对象视为大对象, 放在连续的Humongous Region中, 一般视为老年代

跨Region对象采用记忆集避免整堆GCRoots

步骤:

  • 初始标记

标记GCRoots的直接关联对象, 借用MinorGC时同步完成, 需要Stop-the-world但没有额外停顿

  • 并发标记

    从GCRoots开始可达性分析, 与用户线程并发

  • 最终标记

    处理上一阶段遗留的对象, 需stop-the-world

  • 筛选回收

    跟新Region统计数据, 根据用户期望的停顿时间决定回收多少Region(标记复制, 将存活对象复制到空Region, 清理旧Region), 需stop-the-world

特点:

  • 每个Region可能有不同角色, 因此卡表占内存空间大
  • 需要写前屏障和写后屏障维护卡表, 消耗运算资源多
  • (CMS只需要写后屏障, 且卡表小)

垃圾回收的相关参数

-XX: Use... 设置使用哪些垃圾回收器 -XX:PringGC ... 打印垃圾回收日志(JDK9后Log类均用-XLog配置)

大对象直接进入老年代

  • Eden还有空间但无法分配导致GC
  • 新生代复制开销大

-XX: PreTenureSizeThreshold 大于该值直接在老年代分配

动态年龄判定

-XX: MaxTennuringThreshold设定晋升年龄, 但如果某年龄的对象占了Survivor一半以上空间, 则大于等于该年龄的对象会直接晋升