GC垃圾回收       

垃圾回收

哪些垃圾可以回收

引用计数

循环引用的垃圾无法回收。

根搜索算法(可达性分析)

GC roots可以是虚拟机栈、方法区中静态属性引用的对象、方法区常量引用的对象、native方法引用的对象,已启动且未停止的 Java 线程

  1. Java 方法栈桢中的局部变量;
  2. 已加载类的静态变量;
  3. JNI handles;
  4. 已启动且未停止的 Java 线程。
Class - 由系统类加载器(system class loader)加载的对象,这些类不可以被回收,他们可以以静态字段的方式持有其它对象。我们需要注意的一点就是,通过用户自定义的类加载器加载的类,除非相应的java.lang.Class实例以其它的某种(或多种)方式成为roots,否则它们并不是roots
Thread - 活着的线程
Stack Local - Java方法的local变量或参数(存在于所有Java线程当前活跃的栈帧里,它们会指向堆里的对象)
【Java类的运行时常量池里的引用类型常量(String或Class类型)】(先不考虑)
【String常量池(StringTable)里的引用】(先不考虑)
JNI Local - JNI方法的local变量或参数
JNI Global - 全局JNI引用
Monitor Used - Monitor被持有,用于同步互斥的对象
Held by JVM - 用于JVM特殊目的由GC保留的对象,但实际上这个与JVM的实现是有关的。可能已知的一些类型是:系统类加载器、一些JVM知道的重要的异常类、一些用于处理异常的预分配对象以及一些自定义的类加载器等。JVM的一些静态数据成员会指向堆里的对象
缺点

在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为 null)或者漏报(将引用设置为未被访问过的对象)

解决方法

Stop-the-world 以及安全点

停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)

Java 虚拟机中的 Stop-the-world 是通过安全点(safepoint)机制来实现的。当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程进行独占的工作。

  1. 对于解释执行来说,字节码与字节码之间皆可作为安全点。Java 虚拟机采取的做法是,当有安全点请求时,执行一条字节码便进行一次安全点检测。
  2. 即时编译器需要插入安全点检测,以避免机器码长时间没有安全点检测的情况。HotSpot 虚拟机的做法便是在生成代码的方法出口以及非计数循环的循环回边(back-edge)处插入安全点检测
安全点检测
  1. HotSpot 虚拟机已经将机器码中安全点检测简化为一个内存访问操作,在有安全点请求的情况下,Java 虚拟机会将安全点检测访问的内存所在的页设置为不可读,并且定义一个 segfault 处理器,来截获因访问该不可读内存而触发 segfault 的线程,并将它们挂起
  2. 即时编译器生成的机器码打乱了原本栈桢上的对象分布状况。在进入安全点时,机器码还需提供一些额外的信息,来表明哪些寄存器,或者当前栈帧上的哪些内存空间存放着指向对象的引用,以便垃圾回收器能够枚举 GC Roots

垃圾收集

基于根搜索算法,

  1. 标记-删除(造成内存碎片)
  2. 复制(堆使用效率较低)

image-20191102102702213

  1. 标记-整理(性能开销较大)

  2. 分代收集算法来回收垃圾。

实验表明大部分的 Java 对象只存活一小段时间,而存活下来的小部分 Java 对象则会存活很长一段时间,所以引入分代回收垃圾思想

新生代用来存储新建的对象。当对象存活时间够长时,则将其移动到老年代,给不同代使用不同的回收算法

其中分代搜集算法利用前三种算法收集不同类型的垃圾。

分代收集:对于新生代采用复制算法,对于老年代采用标记-整理或标记-清除

python:采用引用计数和分代搜集算法

java分代收集

image-20210403155411672

Serial和parallel(1.8默认)、ParNew

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

CMS新生代的Young GC、G1和ZGC都基于标记-复制算法,但算法具体实现的不同就导致了巨大的性能差异。

主要是CMS收集器(concurrent mark sweep),主要用于老年代,新生代使用ParNew

G1收集器(整体是标记整理,局部是复制)

java server模式下,默认使用Parallel Scavenge + Serial Old收集器组合进行回收

1.新生代收集器 serial收集器:是新生代的一个单线程的GC,,进行GC时,停掉所有用户线程,直至回收结束,“stop-the-world”。但是其单线程的简单高效,没有线程交互的开销,常被JVM运行在client模式下的默认新生代收集器。

ParNew:并行收集器,是serial收集器的多线程的版本。是运行在server模式下的首先的新生代的收集器。

parallel scavenge :新生代收集器,多线程,并行收集。 此收集器与之前的收集器目的不同:(特点)达到一个可控制的吞吐量。吞吐量=运行用户代码时间/CPU总执行时间。 用于精确吞吐量的两个参数:1.控制最大垃圾收集停顿时间参数 2.直接设置吞吐量大小的参数。Parallel scavenge收集器与ParNew收集器重要区别是: 垃圾自适应调节策略。

2.老年代收集器 serial old:老年代收集器版本,单线程。 用途:1.在JDK1.5版本之前与parallel scavenge 收集器搭配使用。 2.作为CMS收集器的后备预案。

Parallel Old :使用多线程收集。吞吐量优先。

CMS:

  1. 目标是:尽量缩短垃圾回收时间和用户线程的停顿时间

  2. 严格意义上第一款并发垃圾回收器

  3. 主要场景在 互联网 B/S 架构上

  4. 使用标记清除算法

  5. 步骤 5.1 初始标记:STW、快;GC Root 能直接关联的对象 5.2 并发标记:并发;GC Root Tracing 的过程 5.3 重新标记:STW、快;修复并发标记阶段 用户线程运行时变动的对象 5.4 并发清除:并发

  6. 因为整个过程中耗时最长的 “并发标记”和“并发清除”是和用户线程并发执行的,所以可认为CMS回收器是和用户线程并发执行的

什么对象进入老年代

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold 可以设置大

对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下

有效。比如设置JVM参数:-XX:PretenureSizeThreshold=1000000 (单位是字节) -XX:+UseSerialGC ,再执行下上面的第一

个程序会发现大对象直接进了老年代

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

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在

老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor

空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度

(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代

的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

对象动态年龄判断

当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的

50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,

例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会

把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年

龄判断机制一般是在minor gc之后触发的。

老年代空间分配担保机制

年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间

如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象)

就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了

如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小

如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾,

如果回收完还是没有足够空间存放新的对象就会发生”OOM”

当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full

gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”

cms

三色标记

  1. 刚开始,所有的对象都是白色,没有被访问。
  2. 将GC Roots直接关联的对象置为灰色。
  3. 遍历灰色对象的所有引用,灰色对象本身置为黑色,引用置为灰色。
  4. 重复步骤3,直到没有灰色对象为止。
  5. 结束时,黑色对象存活,白色对象回收。

这个过程正确执行的前提是没有其他线程改变对象间的引用关系,然而,并发标记的过程中,用户线程仍在运行,因此就会产生漏标和错标的情况。

https://www.jianshu.com/p/cd591e2f7946

如何快速枚举 GC Roots?

1、笨方法:遍历栈里所有的变量,逐一进行类型判断,如果是 Reference 类型,则属于 GC Roots。

2、高效方法:从外部记录下栈里那些 Reference 类型变量的类型信息,存成一个映射表 – 这就是 OopMap 的由来

“在解释执行时/JIT时,记录下栈上某个数据对应的数据类型,比如地址1上的”12344“值是一个堆上地址引用,数据类型为com.aaaa.aaa.AAA)

现在三种主流的高性能JVM实现,HotSpot、JRockit和J9都是这样做的。其中,HotSpot把这样的数据结构叫做 OopMap,JRockit里叫做livemap,J9里叫做GC map。”

GC 时,直接根据这个 OopMap 就可以快速实现根节点枚举了。

G1

image-20210316163659500

标记-复制算法应用在CMS新生代(ParNew是CMS默认的新生代垃圾回收器)和G1垃圾回收器中。标记-复制算法可以分为三个阶段:

G1的混合回收过程可以分为标记阶段、清理阶段和复制阶段。

标记阶段停顿分析

清理阶段停顿分析

复制阶段停顿分析

四个STW过程中,初始标记因为只标记GC Roots,耗时较短。再标记因为对象数少,耗时也较短。清理阶段因为内存分区数量少,耗时也较短。转移阶段要处理所有存活的对象,耗时会较长。因此,G1停顿时间的瓶颈主要是标记-复制中的转移阶段STW。为什么转移阶段不能和标记阶段一样并发执行呢?主要是G1未能解决转移过程中准确定位对象地址的问题。

G1的Young GC和CMS的Young GC,其标记-复制全过程STW。

G1内存分配策略

将内存分成一个一个的region,且不要求各部分是连续的。 每个Region的大小在JVM启动时就确定,JVM通常生成2000个左右的heap区, 根据堆内存的总大小,区的size范围为1-32Mb,一般4M.

region类型

三种常见: Eden, Survivor, 或 old generation(老年代)区 巨无霸区:保存比标准region区大50%及以上的对象,存储在一组连续的区中.转移会影响GC效率,标记阶段发现巨型对象不再存活时,会被直接回收。 未使用区:未被使用的region 特别说明:某个region的类型不是固定的,比如一次ygc过后,原来的Eden的分区就会变成空闲的可用分区,随后也可能被用作分配巨型对象

ZGC

image-20210316163716189

ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:

全并发的ZGC

与CMS中的ParNew和G1类似,ZGC也采用标记-复制算法,不过ZGC对该算法做了重大改进:ZGC在标记、转移和重定位阶段几乎都是并发的,这是ZGC实现停顿时间小于10ms目标的最关键原因

ZGC关键技术

ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针

着色指针

着色指针是一种将信息存储在指针中的技术。

ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,如下图所示:

image-20210317141353023

当应用程序创建对象时,首先在堆空间申请一个虚拟地址,但该虚拟地址并不会映射到真正的物理地址。ZGC同时会为该对象在M0、M1和Remapped地址空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址,但这三个空间在同一时间有且只有一个空间有效。ZGC之所以设置三个虚拟地址空间,是因为它使用“空间换时间”思想,去降低GC停顿时间。“空间换时间”中的空间是虚拟空间,而不是真正的物理空间。后续章节将详细介绍这三个空间的切换过程。

与上述地址空间划分相对应,ZGC实际仅使用64位地址空间的第0~41位,而第42~45位存储元数据,第47~63位固定为0。

image-20210317141953808

ZGC将对象存活信息存储在42~45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。

三色标记

image-20211014084232307

从代码的角度看:

var G = objE.fieldG; // 1.读
objE.fieldG = null;  // 2.写
objD.fieldG = G;     // 3.写
  1. 读取对象 E 的成员变量 fieldG 的引用值,即对象 G;
  2. 对象 E 往其成员变量 fieldG,写入 null值。
  3. 对象 D 往其成员变量 fieldG,写入对象 G ;

我们只要在上面这三步中的任意一步中做一些“手脚”,将对象 G 记录起来,然后作为灰色对象再进行遍历即可。比如放到一个特定的集合,等初始的 GC Roots 遍历完(并发标记),该集合的对象遍历即可(重新标记)。

重新标记是需要 STW 的,因为应用程序一直在跑的话,该集合可能会一直增加新的对象,导致永远都跑不完。当然,并发标记期间也可以将该集合中的大部分先跑了,从而缩短重新标记 STW 的时间,这个是优化问题了。

写屏障用于拦截第二和第三步;而读屏障则是拦截第一步。 它们的拦截的目的很简单:就是在读写前后,将对象 G 给记录下来。

写屏障 + SATB

当对象 E 的成员变量的引用发生变化时(objE.fieldG = null;),我们可以利用写屏障,将 E 原来成员变量的引用对象 G 记录下来:

void pre_write_barrier(oop* field) {
    oop old_value = *field; // 获取旧值
    remark_set.add(old_value); // 记录 原来的引用对象
}

当原来成员变量的引用发生变化之前,记录下原来的引用对象。

这种做法的思路是:尝试保留开始时的对象图,即原始快照(Snapshot At The Beginning,SATB),当某个时刻 的 GC Roots 确定后,当时的对象图就已经确定了。 比如 当时 D 是引用着 G 的,那后续的标记也应该是按照这个时刻的对象图走(D 引用着 G)。如果期间发生变化,则可以记录起来,保证标记依然按照原本的视图来。

SATB 破坏了条件一:【灰色对象断开了白色对象的引用】,从而保证了不会漏标。

写屏障 + 增量更新

当对象 D 的成员变量的引用发生变化时(objD.fieldG = G;),我们可以利用写屏障,将 D 新的成员变量引用对象 G 记录下来:

void post_write_barrier(oop* field, oop new_value) {  
  if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
      remark_set.add(new_value); // 记录新引用的对象
  }
}

当有新引用插入进来时,记录下新的引用对象。

这种做法的思路是:不要求保留原始快照,而是针对新增的引用,将其记录下来等待遍历,即增量更新(Incremental Update)。

增量更新破坏了条件二:【黑色对象重新引用了该白色对象】,从而保证了不会漏标。

读屏障

oop oop_field_load(oop* field) {
    pre_load_barrier(field); // 读屏障-读取前操作
    return *field;
}

读屏障是直接针对第一步:var G = objE.fieldG;,当读取成员变量时,一律记录下来:

void pre_load_barrier(oop* field, oop old_value) {  
  if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
      oop old_value = *field;
      remark_set.add(old_value); // 记录读取到的对象
  }
}

这种做法是保守的,但也是安全的。因为条件二中【黑色对象重新引用了该白色对象】,重新引用的前提是:得获取到该白色对象,此时已经读屏障就发挥作用了。

漏标会导致被引用的对象被当成垃圾误删除,这是严重bug,必须解决,有两种解决方案: 增量更新(Incremental

Update) 和原始快照(Snapshot At The Beginning,SATB) 。

对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:

CMS:写屏障(增量更新)

原始快照打破的是第一个条件:当灰色对象指向白色对象的引用被断开时,就将这条引用关系记录下来。当扫描结束后,再以这些灰色对象为根,重新扫描一次。相当于无论引用关系是否删除,都会按照刚开始扫描时那一瞬间的对象图快照来扫描。

增量更新打破的是第二个条件:当黑色指向白色的引用被建立时,就将这个新的引用关系记录下来,等扫描结束后,再以这些记录中的黑色对象为根,重新扫描一次。相当于黑色对象一旦建立了指向白色对象的引用,就会变为灰色对象。

从CMS垃圾回收器缺点说起

1)过于占用CPU资源,牺牲吞吐量 – 所有回收器的通病 2)并发清理阶段存在浮动垃圾; – 并发执行导致 3)fgc算法是标记清除,会产生磁盘碎片 – 标记整理算法导致 4)新生代配合ParNewGC使用,存在STW问题。 — 时间不可控,如果heap很大,可能GC时间很大,影响线上服务

G1,Shenandoah:写屏障 (SATB)

ZGC:读屏障

java内存分配

多线程同时申请内存存在并发问题,解决方案是每个线程可以向 Java 虚拟机申请一段连续的内存,比如 2048 字节,作为线程私有的 TLAB(Thread Local Allocation Buffer)。

对象优先在Eden和一个survivor0分配;

  1. 再次分配时,如果内存不够,则垃圾收集,把eden和suvivor0存活的对象放到另一个survivor1,同时对象年纪+1;如果survivor1内存不够,且老年代内存足够,则放到老年代,如果对象年纪到达老年代也直接放到老年代;如果老年代内存也不够,则需要进行老年代内存回收即full gc
  2. 然后清除eden和survivor0;
  3. survivor0和survivor1互换,原survivor1区成为下次的survivor0区

针对新生代的垃圾回收器共有三个:Serial,Parallel Scavenge 和 Parallel New。这三个采用的都是标记 - 复制算法。其中,Serial 是一个单线程的,Parallel New 可以看成 Serial 的多线程版本。Parallel Scavenge 和 Parallel New 类似,但更加注重吞吐率。此外,Parallel Scavenge 不能与 CMS 一起使用

针对老年代的垃圾回收器也有三个:刚刚提到的 Serial Old 和 Parallel Old,以及 CMS。Serial Old 和 Parallel Old 都是标记 - 压缩算法。同样,前者是单线程的,而后者可以看成前者的多线程版本

减少minor gc时间

复制对象的成本要远高于扫描成本,所以,单次Minor GC时间更多取决于GC后存活对象的数量,而非Eden区的大小。因此如果堆中短期对象很多,那么扩容新生代,单次Minor GC时间不会显著增加

评估性能

高吞吐与低延迟对比

如果你想要最小化地使用内存和并行开销,请选Serial GC; 如果你想要最大化应用程序的吞吐量,请选Parallel GC; 如果你想要最小化GC的中断或停顿时间,请选CMS GC。

参考

https://tech.meituan.com/2020/08/06/new-zgc-practice-in-meituan.html

https://zhuanlan.zhihu.com/p/340530051

https://www.cnblogs.com/yanl55555/p/13365572.html

https://blog.csdn.net/h2604396739/article/details/107957569

https://juejin.cn/post/6844903865771360264

https://www.cnblogs.com/jmcui/p/14165601.html