垃圾回收机制 —— 整理介绍

垃圾回收机制的意义

在 C++ 开发中管理内存是一个很麻烦的问题,而 Java 引入了垃圾回收机制,开发者不需要手动去管理内存的分配和回收问题,一切都交给 JVM 通过垃圾回收机制处理,同时有效的防止了内存泄漏的问题。

Java 语言规范中并没有明确的指定 JVM 使用哪种回收算法,但通常回收算法主要做 2 件事情:

  • 发现无用的对象
  • 回收被无用对象占用的内存空间

如何发现无用的对象

Reference Counting(引用计数)

早期的 JVM 利用的策略是引用计数。一般来说,堆中的每一个对象对应一个引用计数器。

  • 当创建一个对象并分配给一个引用变量时,对象的引用计数器置为 1。
  • 当任何其他引用变量被赋值为这个对象的引用时,引用计数器加 1。
  • 但当一个对象的某个引用变量超过了生命周期或者被设置为一个新值时,该对象的引用计数器减 1。
  • 当一个对象被回收时,它引用的任何对象的引用计数器都减 1。
  • 任何引用计数器为 0 的对象可以被当作无用的对象。

利用这种方法判断无用的对象,实现简单高效,对程序需要不被长时间打断的环境比较有利。但这种方法无法解决循环引用的问题:

Object o1 = new Object();
Object o2 = new Object();

o1.object = o2;
o2.object = o1;

o1 = null;
o2 = null;

o1,o2 最后都被赋值为 null,也就是说之前 o1,o2 所引用的对象都无法被访问。但是由于两个对象互相引用对方,所以它们的引用计数器都不为 0,所以垃圾收集器无法回收它们。

Tracing(追踪)算法

现在垃圾回收机制都使用根搜索算法,把所有的引用关系看作一张图,根集(root set)作为图的起点,所谓根集就是正在执行的 Java 程序可以访问的引用变量的集合(包括局部变量、参数、类变量)。从根集 开始,寻找可达的对象,找到可达的对象后继续寻找这个对象的引用对象,当所有的可达的或间接可达的对象寻找完毕,剩余的则被认为是不可达的游离对象,即无用的对象。

image

典型的垃圾收集算法

1. Mark - Sweep(标记 - 清除)算法

这是最基础的垃圾收集算法,标记 - 清除算法是从根集进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收。该方法不移动对象,仅仅回收未标记的对象,在存活对象较多的情况下效率极高。但是这个算法也容易产生内存碎片,过多的内存碎片会导致为大对象分配空间时无法找到足够的空间,而触发新一次的垃圾收集动作。

image

2. Mark - Compact(标记 - 整理)算法

标记 - 整理算法在标记阶段与标记 - 清除算法一致,但是为了解决内存碎片的问题,在完成标记后,并不直接清理未标记的对象,而是将存活的对象都向一端移动,然后清理掉存活对象端边界以外的内存。一般在这种算法的实现中,都增加了句柄和句柄表,也造成了一定的开销。

image

3. Copying(复制)算法

复制算法会将堆内存分为使用区和空闲区两部分。每次只使用其中的使用区,当使用区用完,就进行一次扫描标记,将还存活的对象复制到空闲区上,然后再将使存区进行一次清理。这样,空闲区成为了使用区,原来的使用区变成了空闲区。这钟也解决了内存碎片的问题。一种典型的基于 Copying 算法的垃圾回收是 Stop - Copy 算法,它在使用区和空闲区的切换过程中,程序暂停执行。

这种算法虽然简单高效,且不易产生内存碎片,却对内存空间的利用付出了高昂的代价,内存使用率只有一半。而且,存活的对象如果数量居多,那么算法效率将大大降低。

image

4. Generational(分代)算法

分代收集算法是目前大部分 JVM 的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期不同将内存划分为若干个不同的区域。一般情况下将堆区划分为新生代(Young Generation)和老年代(Tenured Generation)。不同生命周期的对象可以采取不同的回收算法,以提高回收效率。

image

新生代(Young Generation)

所有新生成的对象首先都是放在新生代中,在新生代的目标是尽可能快的收集生命周期短暂的对象。

目前大部分垃圾收集器对于新生代都采用 Copying 算法,因为新生代中每次都要回收大部分对象,存活的对象较少,所以复制操作较少。一般来说,新生代的内存按照 8:1:1 的比例划分为一个 Eden 区和两个较小的 Survivor0,Survivor1 区。大部分对象在 Eden 区生成,回收时先将 Eden 区中存活的对象复制到一个 Survivor0 区中,然后清空 Eden 区。当这个 Survivor0 区也存放满时,则将 Eden 区和 Survivor0 区的存活对象复制到另一个 Survivor1 区,然后清空 Eden 和 Survivor0 区,此时 Survivor0 区是空的,然后将 Survivor0 区和 Survivor1 区交换,即保持 Survivor1 区为空,如此往复。

当 Survivor1 区不足以存放 Eden 和 Survivor0 的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次 Full GC,也就是新生代、老年代都进行回收。

新生代发生的 GC 也叫做 Minor GC,MinorGC 发生频率比较高(不一定等 Eden 区满了才触发)。

老年代(Tenured Generation)

在年轻代中经历了多次垃圾回收后仍然存活的对象,到达一定次数后就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。所以每次回收都只回收少量对象,一般使用的是 Mark - Compact 算法。

一般来说,大对象会被直接分配到老年代,所谓的大对象是指需要大量连续存储空间的对象,最常见的一种大对象就是大数组。

老年代内存比新生代也大很多,当老年代内存满时触发 Major GC 即 Full GC,发生的频率比较低。

持久代(Permanent Generation)

在堆区之外还有一个就是持久代(Permanent Generation),它用于存放静态文件,如 class 类、常量、方法描述等。持久代的回收主要回收两部分内容:废弃的常量和无用的类。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些 class,例如 Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。

典型的垃圾收集器

垃圾收集算法是内存回收的理论基础,而垃圾收集器就是内存回收的具体实现。下面 JVM 提供的几种垃圾收集器,用户可以根据自己的需求组合出各个年代使用的收集器。

image

1. Serial/Serial Old

它们都是一个单线程的收集器,在进行垃圾收集时,必须暂停所有的用户线程。它的优点是实现简单高效,但是缺点是会给用户带来停顿。Serial 是针对新生代的收集器,采用的是 Copying 算法。Serial Old 是针对老年代的收集器,采用的是 Mark - Compact 算法。

2. ParNew

ParNew 收集器是 Serial 收集器的多线程版本,使用多个线程进行垃圾收集,是针对新生代的收集器,采用的是 Stop - Copy 算法。

3. Parallel Scavenge / Parallel Old

Parallel Scavenge 收集器是一个针对新生代的多线程收集器(并行收集器),它在回收期间不需要暂停其他用户线程,其采用的是 Copying 算法,该收集器与前两个收集器有所不同,它主要是为了达到一个可控的吞吐量。Parallel Old 是 Parallel Scavenge 收集器的老年代版本(并行收集器),使用多线程和 Mark - Compact 算法。

4. CMS(Concurrent Mark Sweep)

它是一种以获取最短回收停顿时间为目标的收集器,它是一种针对老年代的并发收集器,采用的是 Mark - Sweep 算法。

5. G1

G1 收集器是当今收集器技术发展最前沿的成果,它是一款面向服务端应用的收集器,它能充分利用多 CPU、多核环境。因此它是一款并行与并发收集器,并且它能建立可预测的停顿时间模型。

垃圾回收执行机制的分类

由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC 有两种类型:Scavenge GC 和 Full GC。

Scavenge GC

一般情况下,当新对象生成,并且在 Eden 申请空间失败时,就会触发 Scavenge GC,对 Eden 区域进行 GC,清除非存活对象,并且把尚且存活的对象移动到 Survivor 区,然后整理 Survivor 的两个区。这种方式的 GC 是对年轻代的 Eden 区进行,不会影响到老年代。因为大部分对象都是从 Eden 区开始的,同时 Eden 区不会分配的很大,所以 Eden 区的 GC 会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使 Eden 去能尽快空闲出来。

Full GC

对所有年代进行整理,包括新生代、老年代和持久代。Full GC 因为需要对整个内存进行回收,所以比 Scavenge GC 要慢,因此应该尽可能减少 Full GC 的次数。在对 JVM 调优的过程中,很大一部分工作就是对于 Full GC 的调节。有如下原因可能导致 Full GC:

  • 年老代被写满
  • 持久代被写满
  • System.gc() 被显示调用
  • 上一次 GC 之后堆中的各域分配策略动态变化
posted @ 2017-02-26 16:49  银色子弹  阅读(2269)  评论(0编辑  收藏  举报