Java 与 C++ 一个很不一样的地方就在于,C++ 需要开发人员手动释放内存空间,而 Java 不需要,虚拟机会帮忙做这件事情。

Java 的内存区域中,虚拟机栈、本地方法栈和程序计数器都是线程相关的,会随着线程的消失而释放。而堆和方法区是线程无关的,所以需要虚拟机来管理内存。

# 垃圾判断方法

既然要回收垃圾,首先就需要辨别哪些是垃圾,垃圾肯定是再也不会被使用的对象。

# 引用计数算法

最简单的方式就是引用计数法,如果有其他地方引用到它,就给他计数加一,如果引用计数是 0,就代表它已经不再被使用了。这样子有一个弊端,如果两个对象互相引用,但是其他地方都没有引用他们,那么这种互相引用的两个对象无法被标记为垃圾。

# 可达性分析算法

可达性分析就是,选用一批作为根节点,从根节点开始,对他们的引用进行标记,所有未被标记的对象就是垃圾。那么比较讲究的就是如果选出来这批根节点,这些根节点要涉及到 JVM 中所有有用的引用,我们称之为 GC Roots。

可作为 GC Roots 的对象有以下几种:

  1. 虚拟机栈中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中引用的对象
  5. 虚拟机内部的引用
  6. 同步锁持有的对象

# 垃圾收集算法

能找到哪些是垃圾,然后就需要用相应的策略进行回收。

GC 有几个名词:部分收集(Partial GC)/ 年轻代收集(Young GC)/ 老年代收集(Old GC)/ 年轻代收集(Minor GC)/ 老年代收集(Major GC)/ 混合收集(Mixed GC)/ 整堆收集(Full GC)

# 标记 - 清除算法

标记之后,直接将垃圾清理掉。

简单的清理掉垃圾,会产生很多的内存碎片。即使下次会被新的对象占用,但是这样会产生更多的内存小碎片,不利于内存的管理,浪费内存空间。

# 标记 - 复制算法

标记之后,将存活的对象复制到一块儿新的区域,然后将这个区域全部清理掉。

好处是不会产生内存碎片,坏处是需要进行一次复制,还要更新对旧对象的引用,这个过程会消耗时间。其次是,需要确保内存中有另外一块儿新的区域用来复制存活的对象,要预留空间,这也是浪费内存的。对于那些朝生夕死的对象,一个区域中存活的对象很少,大部分都是要回收的话,用复制算法比较划算,需要复制并更新引用的对象较少,需要预留的空间也少。

# 标记 - 整理算法

标记之后,清理掉垃圾,然后将存活的对象重新整理下,使它们的物理存储变成连续的空间,这样不需要占用额外的空间。对于老年代的对象,由于存活的对象比例更高,预留额外空间会导致很浪费内存空间,采用这种方式,更划算。

标记整理消耗的时间很长,因为有很多大对象需要进行移动并更新引用。看起来很消耗时间,其实是在增大吞吐量,在整体看来,垃圾收集的频率会降低,因为整理后内存的空间都被剩余出来使用。当然,如果每次清理完都要整理,也是很消耗时间的,可以设定一个次数,比如 n 次使用标记 - 清理之后,使用一次标记 - 整理。

# 垃圾收集器

基于垃圾收集算法,有几种不同的垃圾收集器,我们可以根据具体的应用场景,选择不同的垃圾收集器。

# Serial 收集器

这是一款采用单线程进行垃圾收集的收集器,收集算法采用标记 - 复制,所以是一个年轻代的垃圾收集器。单线程进行垃圾回收,需要 STW,它的吞吐量和延迟都不会很好。

# Serial Old 收集器

它也是单线程,收集算法采用标记 - 整理,所以是一个老年代的垃圾收集器。和 Serial 问题一样,单线程进行垃圾回收,吞吐量低,延迟高。

# ParNew 收集器

单线程效率低,就有了多线程的收集器,同时也需要 STW,采用标记 - 复制算法,在多线程的场景下,性能比着 Serial 收集器还是强了几倍的。

# Parallel Scavenge 收集器

它着重优化的点在吞吐量,采用标记 - 复制算法,适合于新生代垃圾收集,同样采用多线程。它的一个弊端就是无法与 CMS 搭配使用,而 Serial 和 ParNew 都可以和 CMS 进行搭配。可以手动设置期望的吞吐量,然后会进行自适应式调整。

# Parallel Old 收集器

同样这款是为了老年代设计的,目标也在提高吞吐量,采用标记 - 整理算法,采用多线程进行垃圾回收,回收期间需要 STW。

# CMS 收集器

CMS 是一款经典的老年代收集器,用的比较多的就是它。它采用的算法是标记 - 清除,在 n 次之后整理一次,可以手动设置次数。它的收集过程包括 4 个阶段:

  1. 初始标记,需要 STW,标记出与 GC Roots 直接关联的对象。
  2. 并发标记,在 1 步骤的基础上,从 GC Roots 直接关联的对象开始,遍历整个图,标记出所有还存活的对象,这个过程是与用户线程并发进行的。
  3. 重新标记,在并发标记阶段,又有新的引用,这个过程就是通过增量更新的方法,标记出新增的存活对象,需要 STW。
  4. 并发回收,与用户线程并发进行垃圾回收,使用标记 - 清理算法。

虽然在步骤 2 和 4 中,可与用户线程并发运行,停顿时间有所降低,但是它也会占用机器资源,降低程序的吞吐。在并发回收的阶段,由于程序在继续运行着,所以会继续产生垃圾,这个阶段产生的垃圾只能在下次垃圾收集时清理掉,所以对垃圾收集的时机也是有要求的,必须提前预留一定的空间。

其次就是,CMS 的目标是降低延迟,所以它没有采用标记 - 整理算法,而是采用的标记 - 清理算法,这样的话会产生一些内存碎片,明明内存中还有很多剩余空间,但就是不连续无法分配给一个很大的对象,就会提前出发 Full GC,导致垃圾回收频率增高,从而降低吞吐。针对这个问题,回收器提供了一个参数,可以指定在清理多少次以后整理一次内存。

# G1 收集器

之前都是采用的分代收集的方式,而 G1 开始,开始使用更小的内存区域的方式,每一个小区域可以动态分配给年轻代或老年代。

它的收集阶段包括:

  1. 初始标记,标记与 GC Roots 直接关联的对象,STW,修改 TAMS 指针的值。
  2. 并发标记,从 1 中的对象开始搜索整个图,标记所有存活的对象,以及 TAMS 指针之后的存活对象。
  3. 最终标记,STW,标记 TAMS 指针后遗留的少量对象。
  4. 筛选回收,计算出最有回收价值的一批区域,然后进行每个区域通过复制算法,将存活的对象复制到另外一个空闲的区域,然后将整个区域清理掉,STW。

可以看出,除了步骤 2 以外,都需要 STW,步骤 1 和 3 消耗的时候可能不会很长,步骤 4 清理掉部分区域,停顿时间也没有之前的收集器长。官方对 G1 的定位是,在减少延迟的基础上,尽量增大吞吐,可以指定最大停顿时间。

由于是混合收集模式,每次收集不会清理全部区域的垃圾,而是动态计算出一批最有回收价值的区域进行回收。需要在内存中维护着每个 Region 及一些统计指标,所以 G1 是比较消耗内存的。

相较于 CMS,它具有全新的设计理念,基于 Region 维护内存空间,可以动态计算每个区域的回收价值,清理算法在 Region 区域看来是标记 - 复制算法,在整个内存看来是标记 - 整理,不会产生内存碎片。不过 G1 相比于 CMS 的内存占用和负载,是高了不少。

# Shenandaoh 收集器

Shenandaoh 也是一个基于 Region 的内存布局,它在 G1 的基础上进行改进,完全不再区分新生代或老年代。

他的收集阶段包括:

  1. 初始标记,标记出与 GC Roots 直接关联的对象, STW。
  2. 并发标记,从步骤 1 中的对象开始遍历整个图。
  3. 最终标记,STW,标记剩余存活的对象,并计算出最有回收价值的回收集。
  4. 并发清理,将回收集中没有存活对象的区域清理掉。
  5. 并发回收,将回收集中的存活对象复制到其他一个空的区域中。
  6. 初始引用更新,准备进行引用更新,相当于是做了引用更新的预操作,确保真正进行引用更新的时候,不会漏掉。
  7. 并发引用更新,这个阶段真正进行了引用更新,按照物理地址顺序,线性进行更新。
  8. 最终引用更新,更新 GC Roots 中的引用。
  9. 并发清理,清理掉回收集中所有的区域。

可以看出,Shenandoah 收集器相比于 G1 的优化点在,清理的时候可以并发,将清理分散了几个步骤,就是为了能够实现并发。

# ZGC 收集器

ZGC 也是基于 Region 分布的,它的一个改进之处在于 Region 是动态创建和销毁的,也可动态分配的大小。

他的收集过程包括:

  1. 并发标记,这个过程和 G1 的前三个过程类似。
  2. 并发预卑重分配,要统计出需要清理的重分配集,不需要计算最具回收价值的区域,统计所有的区域。
  3. 并发重分配,复制存活对象到新的区域,然后记录转向关系。
  4. 并发重映射,根据转向关系,修正引用,这个过程不迫切,可自愈。