advanced java (十一) GC

advanced java (十一) GC

一般来说,垃圾收集(Garbage Collection ,GC)是一种自动的内存管理机制。目的是减轻程序员负担,最早的 GC 起源于 Lisp 语言,很多语言都有直接的垃圾回收器。

在判断对象存活的算法主要有两个,分别是 引用计数算法可达性分析算法 。 前者实现简单,后者是主流商用程序主流实现。

GC算法主要有以下几种思想,有的时候是几种思想的混用来实现的算法。分别是 标记清除算法复制算法标记整理算法 ,组合起来的 分代收集算法

在这里,主要讨论 HotSpot 中的算法实现,其中需要注意的是 GC停顿,也被称作 STW ( stop the world ) ,也就是从GC Roots 节点找应用链的操作。其中还有线程模型的 安全点安全区域

而垃圾收集器也是很重要的。包括早期的单线程新生代 Serial 收集器 ,一般和老年代 Serial Old 收集器 一起使用, 也就是 CMS 收集器 。CMS收集器还可以选择多线程新生代 ParNew 收集器 替代 Serial 。

CMS 收集器 追求的是短停顿时间,如果对停顿时间不介意,而是追求 GC 时的吞吐量。那么可以选择 新生代 Parallel Scavenge 收集器 ,而老年代可以选择 Parallel Old 收集器 替代 Serial Old 。老年代不能和 Serial 收集器搭配。

G1收集器 是 java7 中加入的,替代CMS 收集器。G1 收集器将内存分为多个 Region 。生代和老年代不再是直接区分,而是分别由一系列 Region 组成。

虽然说 Shenandoah 收集器 是 G1收集器的继承者,但是由于是 Redhat 开发的收集器,而非 Java 亲爹 Oracle 公司。只存在于 OpenJdk 中。而 Epsilon 收集器 是一种没有回收行为的收集器。一旦java的堆被耗尽,jvm就直接关闭。这两种收集器暂不讨论,有兴趣可以查阅相关资料。

在 java11 中实验性质的 ZGC 带来了低延迟的体验,并尽量不影响吞吐量。

不用的语言也有不同的 GC 策略。golang 里面的 GC 可以说基于CMS的思想,是一种 低延迟GC ,也是 三色标记算法 思想的一种体现,也有 hybird write barrier 的优化。

对象存活算法

引用计数算法

引用计数算法给对象提娜佳一个引用计数,每当有一个对象引用时,计数器加一,当引用失效时,计数器减一。当计数器为零时,回收对象。

当然引用计数会有两个问题

可达性分析算法

可达性分析算法通过一系列称为 GC Roots 的对象作为初始点,根据引用链向下搜索,当对象不可达时,可以回收。

GC Root 包括以下几种

对新生代的 GC ,老年代的对象中的引用也应该成为 GC Roots 。弱引用在新生代 GC 不被回收,因此属于 GC Root 。而 Full GC 时老年代的对象和弱引用就不是 GC Roots 了。

对于方法区,虽然垃圾回收少,但是现代框架大量使用动态生成, GC 也是必要的。满足以下条件就会触发 GC

垃圾收集算法

标记清除算法 表示标记需要回收的对象,然后同一回收标记的对象。存在两个问题:标记、清除效率不高;会产生不连续的内存碎片。

复制算法 表示将可用内存划分为相等的两块,每次只使用其中一块。如果存在对这个对象的引用,就把它复制到另一块,新的内存空间是连续完整的。但是在存活对象较高时复制效率不高。

标记整理算法 在标记清除算法的基础上,会整理到内存区域的头部。

分代收集算法 中将java 堆分为 新生代和老年代。 新生代分为一块 Eden 和两块 Survivor 区域,默认比例是 8:1:1 。 对于大多数朝生夕死的对象(弱分代假说),Survivor 空间中选用复制算法。对于老年代中熬过多次垃圾收集的对象 (强分代假说),使用标记清除或者标记整理算法。

HotSpot

GC Root

枚举 GC Root 过程的主要问题是 Stop the World 。即使是几乎不停顿的 CMS 收集器,也是必须在枚举根节点上停顿。

准确式 GC 中,收集器明确地知道内存中每一块数据的实际类型和偏移量。使用的一组 OopMap 数据结构来达到这个目的。实际数据类型指的是整数还是引用地址。同样在对象移动后可以修改偏移量。

安全点 解决的是如果对每个对象都生成对应的 OopMap ,那么需要额外内存空间,频繁修改的开销大的问题。只有在安全点才会发生 OopMap 的修改,也才能进行 GC 。安全点太少 GC等待时间会长,太多会造成频繁修改。GC需要所有的线程都在最近的安全点停下来,有两种方式

安全区域 指的是在一段代码之中,引用关系不会发生变化。也就是说,在这个区域任意地方开始 GC 都是安全的。

记忆集 是为了解决引用相对同代引用占极少数(跨代引用假说)的问题。建立一种数据结构,记录非收集区域指向收集区域的指针集合,避免将老年代加入 GC Root 的扫描范围。

写屏障 维护了记忆集的卡表状态。(ZGC为了更好的并发性能,采取了读屏障)

经典垃圾收集器

Serial / Serial Old / ParNew

Serial 是最早的收集器,使用单线程的方式进行 GC 。Serial 在新生代使用复制算法,JVM 在安全点停止所有线程,然后单线程完成 GC,单线程的优势在于对 CPU 负担少,对于几百兆的 java 堆,几十毫秒的暂停是可以接受的。Serial Old 在老年代单线程使用标记整理算法。Serial 没有线程交互的开销,简单而高效。ParNew 收集器是 Serial 的多线程版本,目前只有它能和CMS 收集器配合工作。使用 -XX:UseParNewGC 指定。

Parallel Scavenge / Parallel Old

Parallel Scavenge 是一个新生代收集器,也是多线程地使用的复制算法。它的目标是达到一个可控制的吞吐量。也就是 吞吐量 = 运行代码的时间 /(运行代码的时间 + GC时间) ,追求尽量大的百分比。使用参数 -XX:GCTimeRatio ,控制 GC 时间在总时间中的占比,默认99,表示 99:1 。 -XX:MaxGCPauseMillis 指定允许的最长 GC 停顿。当然停顿时间越长,GC越频繁,百分比越低。打开自适应调整策略 -XX:+UseAdaptiveSizePolicy ,动态调整新生代的大小、Eden 区域比例。 Parallel Old 在老年代多线程使用标记整理算法。在注重吞吐量和CPU资源敏感的场合,使用这种组合。

CMS 收集器

CMS 基于标记清除,分为四个步骤(基于 Serial / Serial Old )

其中初始标记和重新标记需要 stop the world ,另外两个则可以在线程继续运行时进行。

CMS 优点是并发收集,低停顿,但是也有三个缺点

CMS中的年龄分代收集

对象主要分配在 Eden 区中,如果启用 TLAB,则线程优先在 TLAB 上分配。

不同分区默认比例如下

分区 大小
新生代的 Eden 区 1/3 * 8/10
新生代的 Survivor 区 1/3 * 1/10 * 2
老年代 2/3
持久代(持久代不属于 java 堆) java8 里面移除了永久代,使用元空间,大小是动态变更的

在基于基于 Serial / Serial Old 的算法中,遵循以下原则

Minor GC/Full GC/Major GC

Minor GC 作用于新生代,作用于新生代的垃圾回收,存活下来的对象,则会被送到 Survivor区。

Full GC 作用于 java 堆 和方法区的收集,开销大。

Major GC 有的指作用于老生代的垃圾回收(CMS收集器中),有的等价于 Full GC ,这是由于不同资料的混用带来的混淆。

如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。

G1收集器

G1收集器具备以下特点

在 G1 收集器中,将 java 堆分为了多个 Region,保留新生代和老年代的概念,他们是一部分 Region 的集合。G1 跟踪回收各个 Region 的空间大小和所需时间。在后台维护一个优先列表,优先回收价值最大的 Region 。保证收集效率和收集时间。

G1 收集器 “化整为零” ,但是各个 Region 之间是可能存在联系的。解决方案是为每个 Region 维护了一个 Remembered Set ,当引用关系发生变化时,将相关引用的 Region 记录到 Remembered Set 上。 Remembered Set 也是 GC Root 。

排除更新 Remembered Set 的过程,也分为四个步骤

实验垃圾收集器 ZGC

ZGC 的并发特性能够做到非常低的延迟 (小于 10 毫秒),但是目前还处于实验性质,不推荐在生产上使用(大部分还在使用 java8 ,也没法使用)。同时也支持弹性伸缩,能够将未提交的内存归还给操作系统。

它是一个标记整理 GC,GC过程中所有的阶段都设计为可以并发的,包括移动对象的阶段。除了非常短暂的safepoint,几乎可以和工作线程并发执行。

基于 Region 布局,也称作 ZPage 。 分为了小型 Region :容量为 2MB ,放置小于 256KB 的对象;中型 Region :容量为 32MB ,放置 256KB ~ 4MB 的对象;大型 Region :容量为动态的 2MB 的整数倍 ,放置大于 4MB 的对象(大型 Region 的实际情况比较复杂)。

没有使用新生代,老年代,目前 ZGC 暂时是不分代的实现,因为分代实现比较复杂。

使用load barriers 保证并发机制,即使 GC 把对象移动了,读屏障也会发现并修正指针。在重新标记阶段不再需要通过 stop-the-world 这种最粗粒度的同步方式来让 GC 与应用之间同步。

染色指针 将少量额外信息储存在指针上。 如果堆上有指针当前处于 “尚未更新” 的状态,在同一个GC周期内再次访问这个字段的话就不需要再修正了。减少吞吐量开销。

内存多重映射 将多个不同虚拟内存映射到同一个物理内存地址上,能让染色指针正常寻址。

目前 ZGC 仍处于发展阶段,还没有完全成熟。

golang GC

golang 语言也是有 GC 的,实现应该是在二进制程序中加入大量的安全点。也是基于可达性分析算法,类似 CMS ,但是不使用分代收集。采用的 三色标记算法 ,是一种标记整理算法。

三色标记算法步骤如下

其中使用 hybird write barrier, 完全消除了并发标记清除算法需要的重新扫描栈阶段,写屏障实现增量式,做到了毫秒级别的 gc pause。

同样也是准确式 GC ,能够知道内存中某个数据是数组还是指向对象的指针。

golang 的协程模型,尽可能只每个 goroutine 暂停而不全局暂停。

当自身的分配行为不容易导致碎片堆积,并且程序分配新对象的速度不太高时,golang的 GC 还是很有优势。