java备忘录之垃圾回收

垃圾收集

java的垃圾回收主要对java堆和方法区进行垃圾回收

判断对象是否存活

引用计数法

给对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。
两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。

1
2
3
4
5
6
7
8
9
10
public class ReferenceCountingGC {
public Object instance = null;

public static void main(String[] args) {
ReferenceCountingGC objectA = new ReferenceCountingGC();
ReferenceCountingGC objectB = new ReferenceCountingGC();
objectA.instance = objectB;
objectB.instance = objectA;
}
}

正因为循环引用的存在,因此 Java 虚拟机不适用引用计数算法。

可达性分析算法

通过 GC Roots 作为起始点进行搜索,能够到达到的对象都是存活的,不可达的对象可被回收。

Java 虚拟机使用该算法来判断对象是否可被回收,在 Java 中 GC Roots 一般包含以下内容:
虚拟机栈中引用的对象
方法区中类静态属性引用的对象
方法区中的常量引用的对象
本地方法栈JNI(即Native方法)中引用的对象

引用类型

Java 具有四种强度不同的引用类型。

  • 强引用
    被强引用关联的对象不会被垃圾收集器回收。
    使用 new 一个新对象的方式来创建强引用。
    1
    Object obj = new Object();
  • 软引用
    被软引用关联的对象,只有在内存不够的情况下才会被回收。
    使用 SoftReference 类来创建软引用。
    1
    2
    3
    Object obj = new Object();
    SoftReference<Object> sf = new SoftReference<Object>(obj);
    obj = null; // 使对象只被软引用关联
  • 弱引用
    被弱引用关联的对象一定会被垃圾收集器回收,也就是说它只能存活到下一次垃圾收集发生之前。
    使用 WeakReference 类来实现弱引用。
    1
    2
    3
    Object obj = new Object();
    WeakReference<Object> wf = new WeakReference<Object>(obj);
    obj = null;
    WeakHashMap 的 Entry 继承自 WeakReference,主要用来实现缓存。
    1
    private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V>
    Tomcat 中的 ConcurrentCache 就使用了 WeakHashMap 来实现缓存功能。ConcurrentCache 采取的是分代缓存,经常使用的对象放入 eden 中,而不常用的对象放入 longterm。eden 使用 ConcurrentHashMap 实现,longterm 使用 WeakHashMap,保证了不常使用的对象容易被回收。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    public final class ConcurrentCache<K, V> {

    private final int size;

    private final Map<K, V> eden;

    private final Map<K, V> longterm;

    public ConcurrentCache(int size) {
    this.size = size;
    this.eden = new ConcurrentHashMap<>(size);
    this.longterm = new WeakHashMap<>(size);
    }

    public V get(K k) {
    V v = this.eden.get(k);
    if (v == null) {
    v = this.longterm.get(k);
    if (v != null)
    this.eden.put(k, v);
    }
    return v;
    }

    public void put(K k, V v) {
    if (this.eden.size() >= size) {
    this.longterm.putAll(this.eden);
    this.eden.clear();
    }
    this.eden.put(k, v);
    }
    }
  • 虚引用
    又称为幽灵引用或者幻影引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例。
    为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。
    使用 PhantomReference 来实现虚引用。
    1
    2
    3
    Object obj = new Object();
    PhantomReference<Object> pf = new PhantomReference<Object>(obj);
    obj = null;

方法区的回收

因为方法区主要存放永久代对象,而永久代对象的回收率比新生代差很多,因此在方法区上进行回收性价比不高。
永久代的垃圾收集主要回收两部分的内容:废弃常量和无用的类
主要是对常量池的回收和对类的卸载。
类的卸载条件很多,需要满足以下三个条件,并且满足了也不一定会被卸载:

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

    在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载功能,以保证不会出现内存溢出。

finalize()

finalize() 类似 C++ 的析构函数,用来做关闭外部资源等工作。但是 try-finally 等方式可以做的更好,并且该方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。
当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能通过在该方法中让对象重新被引用,从而实现自救。

垃圾收集算法

标记-清除算法

首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,它的标记过程就是上面的可达性分析算法。
不足:

  • 标记和清除过程效率都不高
  • 会产生大量不连续的内存碎片,导致无法给大对象分配内存。

复制算法

img

将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
优点是实现简单,运行高效,主要不足是只使用了内存的一半。
复制算法在对象存活率较高时就要就要进行较多的复制操作,效率将会降低。
现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将内存划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survior 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90 %。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。

标记-整理算法

让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。(老年代使用)

分代收集算法

在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。
一般将 Java 堆分为新生代和老年代。
新生代使用:复制算法
老年代使用:标记-清理 或者 标记-整理 算法

HotSpot的算法实现

枚举根节点

可作为GC Roots的节点主要在全局性引用(例如常量或静态属性)与执行上下文(例如栈帧中的本地变量表),如果要逐个检查这里面的引用,那么必然会消耗很多时间。
可达性分析对执行时间的敏感还体现在GC停顿上。
GC停顿是指GC进行时必须停顿所有java执行线程(Sun将这件事称为“Stop The World”),即使号称(几乎)不会发生停顿的CMS收集器中,枚举根节点也是必须要停顿的。
在HotSpot的实现中,使用一组称为OopMap的数据结构来知道哪些地方存放着对象引用。

安全点

在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举,但是有一个问题:
可能导致引用关系变化或者说OopMap内容变化的指令非常多,如果为每一条执行都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很高。
实际上,HotSpot没有为每条指令都生成OopMap,只是在特定的位置记录了这些信息,这些位置称为安全点,即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。
安全点的选定是以程序是否具有让程序长时间执行的特征为标准进行选定的,例如方法调用、循环跳转、异常跳转等。

安全区域

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

垃圾收集器

以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

先理解一下垃圾收集器并行和并发的概念

  • 并行指的是多条垃圾收集器线程并行工作,但此时用户线程处于等待状态。
  • 并发指的是用户线程和 GC 线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行在另一个CPU上。

Serial 收集器

Serial/Serial Old收集器的运行过程

Serial 翻译为串行,可以理解为垃圾收集和用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序,直到它收集结束。除了 CMS 和 G1 之外,其它收集器都是以串行的方式执行。CMS 和 G1 可以使得垃圾收集和用户程序同时执行,被称为并发执行。
它是单线程的收集器,只会使用一个线程进行垃圾收集工作。使用复制算法。
它的优点是简单高效,对于单个 CPU 环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率。
它是 Client 模式下的默认新生代收集器,因为在用户的桌面应用场景下,分配给虚拟机管理的内存一般来说不会很大。Serial 收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。

ParNew 收集器

它是 Serial 收集器的多线程版本。除了多线程之外,其余的与Serial收集器完全一样
是 Server 模式下的虚拟机首选新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。
不能与Parallel Scavenge 收集器配合工作
默认开始的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。

Parallel Scavenge 收集器

与 ParNew 一样是并行的多线程收集器。新生代收集器。使用复制算法。

其它收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户代码的时间占总时间的比值。
吞吐量 = 运行用户代码的时间 / (运行用户代码的时间 + 垃圾回收的时间)

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间 -XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的 -XX:GCTimeRatio 参数(值为大于 0 且小于 100 的整数)。缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。

还提供了一个参数 -XX:+UseAdaptiveSizePolicy,这是一个开关参数,打开参数后,就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为 GC 自适应的调节策略(GC Ergonomics)。

Serial Old 收集器

是 Serial 收集器的老年代版本,同样是一个单线程收集器。使用标记-整理算法。也是给 Client 模式下的虚拟机使用。如果用在 Server 模式下,它有两大用途:

  • 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
  • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

Parallel Old 收集器

是 Parallel Scavenge 收集器的老年代版本。使用标记-整理算法

在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

CMS 收集器

CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记-清除算法。

特点:并发收集、低停顿。

分为以下四个流程:

初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿(Stop The World)。
并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿(Stop The World)。
并发清除:不需要停顿。
在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

具有以下缺点:

  • 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
  • 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。可以使用 -XX:CMSInitiatingOccupancyFraction 来改变触发 CMS 收集器工作的内存占用百分,如果这个值设置的太大,导致预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
  • 标记-清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

G1 收集器

G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。

Java 堆被分为新生代、老年代和永久代,其它收集器进行收集的范围都是整个新生代或者老生代,而 G1 可以直接对新生代和永久代一起回收。

G1 把新生代和老年代划分成多个大小相等的独立区域(Region),新生代和永久代不再物理隔离。

通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。

通过记录每个 Region 记录垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。

每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。

如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

  • 初始标记,需要停顿(Stop The World),但耗时很短。
  • 并发标记
  • 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
  • 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿是时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

G1收集器具备如下特点:

  • 空间整合:整体来看是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
  • 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。
  • 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,来缩短Stop The World停顿的时间。
  • 可预测的停顿:能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集器上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器特征了。

比较

打赏

请我喝杯咖啡吧~

支付宝
微信