2、垃圾回收

297 阅读15分钟

如何判断一个类是无用的类

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

虚拟机可以对满足上述3个条件的无用类进行回收,这里仅仅是”可以“,而并不是和对象一样不适用了就必然会被回收。

判断对象是否还存活

引用计数算法

为每一个对象创建一个引用计数器,有对象引用时计数器+1,释放-1,如果计数器为0时就可以被回收,但是不能解决循环引用也就是相互引用的问题(Java虚拟机没有采用)

可达性分析算法

从GC Roots开始向下搜索,搜索过的路径称为引用链,当一个对象到 GC Roots没有任何引用链是这个对象可以被回收。在JVM进行垃圾回收的工作时,会从GCRoot节点找引用链,这时需要逐个检查引用,所以在GC时必须停掉所有Java执行线程的一个重要原因,这个事件称为Stop The World

可以作为GC Roots的对象:

  • 虚拟机栈(局部变量表)中引用的对象
  • 方法区中常量和类的静态属性引用的对象
  • 本地方法栈中 native方法引用的对象

以上判断对象是否存活都与引用有关,而引用又分为以下几种:

  • 强引用:发生gc的时候不会被回收
  • 软引用:有用但不是必须的对象,在发生内存溢出之前会被回收
  • 弱引用:有用但不是必须的对象,在下一次gc会被回收
  • 虚引用:无法通过虚引用获得对象

内存分配策略

  • 对象优先在 eden分配,新生代(eden+survivor),老年代(survivor)
  • 大对象(需要连续内存的对象)直接进入老年代;比如数组和字符串
  • 长期存活的对象进入老年代。动态年龄超过15岁进入老年代,或者在survivor区中相同年龄对象大小总和大于survivor空间的一半直接进入老年代

垃圾收集算法

  • 标记-清除算法:标记无用对象,然后进行清除回收,但是效率不高,无法清除垃圾碎片
  • 复制算法:把容量划分2个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。优点就是实现简单,运行高效,缺点:内存使用率不高,只有原来的一半。现在常用虚拟机的年轻代就是这样: eden和survivor比例8:1
  • 标记-整理算法:标记无用对象,让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存,缺点是要进行局部的数据移动,效率低
  • 分代收集算法:根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记整理或者标记清除算法。

细节概念

安全点

安全点是指代码中一些特定的位置,当线程运行到这些位置时它的状态时确定的,这样虚拟机就可以安全的进行垃圾回收,所以GC不是想什么时候触发就立刻会触发的,是需要等待所有线程运行到安全点后暂停这些线程的工作,才能触发,这些安全的位置主要有一下几种:

  • 方法返回之前
  • 调用某个方法之后
  • 抛出异常的位置
  • 循环的末尾

让线程跑到最近的安全点停下来有两种方案:

  • 抢占式中断(几乎没有虚拟机实现)
  • 主动式中断
安全区域

安全区域:安全点是对针对正在执行的线程设定的。 如果一个线程处于 Sleep 或中断状态,它就不能响应 JVM 的中断请求。因此 JVM 引入了安全区域。安全区域是指在一段代码片段中,引用关系不会发生变化。在这个区域内的任意地方开始 GC 都是安全的。线程在进入安全区域的时候先标记自己已进入了安全区域,等到被唤醒时准备离开安全区域时,先检查能否离开,如果 GC 完成了,那么线程可以离开,否则它必须等待直到收到安全离开的信号为止。

记忆集

记忆集是一种记录从非收集区域指向收集区域的指针集合的抽象数据结构,可以使用记忆集来缩减 GC Root 扫描范围,避免把整个老年代加进扫描范围。

卡表

在HotSpot中,卡表是将堆划分成一个个大小为 512字节的卡页,然后通过数组来维护。用来存储每张卡页的一个标识位,这个标识位可以用来知道当前卡页有没有存在执行年轻代对象的引用,如果有,那么就认为这个卡页是脏卡。比如:如果卡页中有一个对象存在跨代指针,就将卡页的值标记位1,称为变脏。

在MinorGC时,如果新生代的对象晋升到老年代之后它可能会引用新生代的对象,所以在标记存活对象的时候,还需要扫描老年代中的对象,这样就做了一次全堆扫描。如果引用卡表的话,在进行minor GC时,可以不用扫描整个老年代,而是在卡表中寻找脏卡,将脏卡中的对象加入到minor GC 的GC Roots中,当完成所有脏卡的扫描之后,Java虚拟机便会将所有的脏卡标识位清零。

卡表和记忆集关系:卡表是记忆集的具体表现

写屏障

当有其他分代区域的对象引用了本区域对象时,其对应的卡表元素就应该变脏。变脏事件点发生在引用类型字段赋值的那一刻。hotspot虚拟机里是通过写屏障技术维护卡表状态。写屏障可以看做在虚拟机层面堆引用类型字段赋值动作的AOP切面。每次只要对引用进行更新,就会产生额外的开销,但对比扫描整个老年代的代价要低很多。

垃圾收集器

Serial收集器

最早的单线程串行垃圾回收器,运进行垃圾收集是必须暂停其他所有的工作线程,直到它收集完成(这样造成STW),优点就是简单高效,是虚拟机在Client模式下的默认新生代收集器

ParNew收集器

复制算法,是上面的多线程版本,虚拟机server的首选新生代收集器,是唯一一个可以和CMS收集器配合工作的收集器

Serial Old收集器

标记整理算法,serial的老年代版本

Parallel Scavenge收集器

新生代收集器,采用复制算法,目的是达到一个可控制的吞吐量,可以牺牲等待时间换取系统的吞吐量,与自适应调节策略配合使用,可以把内存管理的调优任务交给虚拟机

parallel Old收集器

是上面的老生代版本,使用标记整理算法,在注重吞吐量和cpu资源敏感的场合,可以采用 parallel scavenge+parallel old

CMS收集器

CMS 是英文 Concurrent Mark-Sweep 的简称,以获取最短回收停顿时间为目标的收集器。采用标记清除算法实现。它是HotSpot虚拟机第一款真正意义上的并发收集器,第一次实现了垃圾收集线程与用户线程同时工作。在gc的时候会产生大量的碎片。一种以获得最短停顿时间为目标的收集器。用户线程和垃圾收集线程同时执行

工作过程
  • 初始标记(标记GC Roots能够直接关联的对象)

    • 单线程执行、暂停所有的其他线程,会造成STW、但仅仅把GC Root的直接关联对象标记,所以这里的速度非常快
  • 并发标记(进行GC Roots 追踪的过程)

    • 对于初始标记过程所标记的初始标记对象,进行并发追踪标记。此时其他线程可以继续工作。此处的时间长,但是不停顿。不能保证可以标记出所有活着的对象。
  • 重新标记(修正并发标记期间因为用户程序继续运作而导致标记产生变动的那一部分对象的标记记录)

    • 在并发标记的过程中,可能还会产生新的垃圾,所以要重新标记新产生的垃圾
    • 此处执行并行标记,与用户线程不并发,所以依然是STW,而且停顿时间比初始标记长。
  • 并发清除

    • 并发清除之前标记的垃圾、其他用户线程可以工作,不需要停顿
缺点
  • 对cpu资源非常敏感,它虽然不会导致用户线程停顿,但是会使应用变慢,虽然提供了一种增量式并发收集器(在并发标记、清理时让gc线程和用户线程交替运行,减少gc线程的占用时间,但是这样会使整个垃圾回收过程边长),但是效果不明显。
  • 垃圾收集线程值占用不超过25%的处理器资源。但处理器核心数量不足4个的时候,CMS对程序的影响变得很大。
  • 无法处理浮动垃圾(在并发清理时用户线程还在运行,这个过程会产生新的垃圾,这些垃圾在标记过程之后所以无法清理,只能等到下一次)。采用标记清除算法,会有空间碎片,但是cms提供了一个开关参数,在无法找到足够大的空间时会提前触发一次 Full GC(开启碎片的合并整理过程),但是这个过程使停顿时间变长了
G1 收集器

Garbage First,是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。它把堆内存划分为多个大小相等的region区域,每个region都维护自己的记忆集,双向卡表结构。然后跟踪每个region里面垃圾堆积的价值大小,在后台维护一个优先列表,每次根据允许的收集时间优先回收价值最大的region,每个region可能是eden、survivor、old、Humongous区,当对象大于region的一半就会直接区Humongous区。

在G1中,大对象的判定规则:一个大对象超过了一个Region大小的50%就会被放入Humongous中,而且一个大对象如果太大,可能会横跨多个Region来存放。Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销。Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。

JVM最多可以有2048个Region。一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以用参数 -XX:G1HeapRegionSize 手动指定Region大小,但是推荐默认的计算方式。

特点
  • 并发与并行:利用多核cpu,来缩短停顿时间
  • 分代收集:采用不同的方式去处理新对象和已经存活了一段时间的对象
  • 空间整合:G1 整体基于 标记整理算法,局部上(Region之间)是基于复制算法,这两个算法意味着再G1运行期间不会产生内存空间碎片
  • 可预测停顿:g1除了追求低停顿之外,还建立可预测的停顿时间模型,可以让使用者在指定时间内消耗在垃圾收集器上不得超过多少毫秒
工作过程
  • 初始标记:暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快
  • 并发标记:同CMS的并发标记
  • 最终标记:同CMS的重新标记
  • 筛选回收:筛选回收阶段首先对各个Region的回收价值和成本进行排序。根据用户所期望的GC停顿时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划。比如说老年代此时Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,那么通过计算回收成本可能只能回收100region,那么就只会回收100个Region,尽量把GC导致的停顿时间控制在指定范围内。
缺点
  • 占用较大内存
  • 写前屏障造成额外负担
ZGC

ZGC是一个并发,基于区域(region),增量式压缩、低停顿高并发的收集器。ZGC几乎在所有的地方都是并发执行,除了初始标记是STW,所以停顿时间几乎就耗费在初始标记上面。

设计目标
  • TB级别的堆内存管理
  • 最大GC Pause不高于10ms
  • 最大吞吐率损耗不高于15%
关键技术
  • 加载屏障技术
  • 有色对象指针
  • 单一分代内存管理
  • 基于区域内存管理
  • 部分内存压缩
  • 即时内存复用

与标记对象的传统算法相比,ZGC在指针上做标记,在访问指针时加入Load Barrier(读屏障),比如当对象正被GC移动,指针上颜色就不对,这个屏障就会先把指针更新位有效地址在返回,也就是,永远只有单个对象读取时有概率被减速,而不存在为了保存应用与GC一致而粗暴整体的Stop The World。

有色指针

ZGC利用指针的64位中的几位表示Finalizable、Remapped、Marked1、Marked0,用来标记对象内存的存储状态。相当于在对象的指针上标注了对象的信息。在这个被指向的内存发生变化时,颜色就会发生变化。

读屏障

由于有色指针的存在,在程序运行时访问对象的时候,可以轻易知道对象在内存的存储状态(通过指针访问对象),若请求读的内存被着色了,那么会触发读屏障,读屏障会更新指针在返回结果,在此过程中有一定的损耗,从而达到与用户线程并发的效果。

并行化处理阶段

标记(Marking); 重定位(Relocation)/压缩(Compaction); 重新分配集的选择(Relocation set selection); 引用处理(Reference processing); 弱引用的清理(WeakRefs Cleaning); 字符串常量池(String Table)和符号表(Symbol Table)的清理; 类卸载(Class unloading)。

Epsilon

内存耗尽之后,jvm直接关闭,只分配内存,都不回收内存的

Shenandoah

只有openJDK才有


新生代回收器:Serial、ParNew、Parallel Scavenge

老年代回收器:Serial Old、Parallel Old、CMS

整堆回收器:G1

查看jdk采用垃圾收集器

默认64位操作系统下都是server模式的JVM

java -XX:+PrintCommandLineFlags -version

结果如下

image-20210614232704338

Jdk1.8默认采用ParallelGC :这是 Parallel Scavenge + Serial Old 的组合

可以添加:-XX:+UseParallelOldGC,开启 Parallel Scavenge + Parallel Old 的组合

-XX:+UseG1GC:开启G1收集器

各个区的GC
  • 新生代中的eden满了后触发 MinorGC,存活的对象转移到survivor区中,当survivor中的对象经历过一定次数的MinorGC之后进入老年代
  • 老年代满了触发MajorGC,FullGC会清理整个内存堆(包括新生代MinorGC和老年代MajorGC)
  • MajorGC发生在老年代,清理老年代经常会伴随至少一次MinorGC,MajorGC比MinorGC慢10倍
新生代到老年代的晋升过程的判断条件
  • 部分对象会在 From和To区域中来回复制,默认交换15次(由JVM参数MaxTenuringThreshold决定)之后,如果该对象还存活就进入老年代,15次的原因在对象头中。
  • 如果对象的大小大于eden的二分之一(也就是大对象)会直接进入old区,如果old区分配不下,会进行一次majorGC,如果小于eden的一半但是没有足够的空间,就minorGC也就是新生代GC。大对象直接进入老年代是为了避免大对象分配内存是由于分配担保机制带来的复制而降低效率。
  • minorGC后,survivor仍然放不下则进入老年代,这种机制叫做分配担保
  • 动态年龄判断,大于等于某个年龄的对象超过了survivor空间的一半,这些对象直接进入老年代