Java 垃圾回收机制整理

垃圾回收的意义

如果不进行垃圾回收,内存迟早会被消耗空。
垃圾回收机制的引入可以有效的防止内存泄露、保证内存的有效使用,也减轻了 Java 程序员的对内存管理的工作量。

内存泄露:指该内存空间使用完毕后未回收,在不涉及复杂数据结构的一般情况下,Java 的内存泄露表现为一个内存对象的生命周期超出了程序需要它的时间长度,我们有是也将其称为“对象游离”。

垃圾回收机制中的算法

垃圾回收算法需要做的基本事情:

  • 发现无用对象
  • 回收被无用对象占用的内存空间,使该空间可被程序再次使用

1. 可达性检测算法

1.1 引用计数法(Reference Counting Collector

引用计数是垃圾收集器中的早期策略。
此方法中,堆中的每个对象都会添加一个引用计数器。每当一个地方引用这个对象时,计数器值 +1;当引用失效时,计数器值 -1。任何时刻计数值为 0 的对象就是不可能再被使用的。

这种算法无法解决对象之间相互引用的情况
比如对象有一个对子对象的引用,子对象反过来引用父对象,它们的引用计数永远不可能为 0

public class Main {
    public static void main(String[] args) {
        MyObject object1 = new MyObject();
        MyObject object2 = new MyObject();
          
        object1.object = object2;
        object2.object = object1;
          
        object1 = null;
        object2 = null;
    }
}

最后面两句将 object1object2 赋值为 null,也就是说 object1object2 指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为 0,那么垃圾收集器就永远不会回收它们。

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

由于引用计数法存在缺陷,所有现在一般使用根搜索算法。

根搜索算法图解

根搜索算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点 GC ROOT 开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点。
如上图中的 ObjFObjDObjE通过 GC Root 是无法找到的,所以它们是无用节点。

Java 中可作为 GC Root 的对象:

  • 虚拟机栈中引用的对象(本地变量表)
  • 方法区中静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中引用的对象(Native对象)

可参考:《JVM 内存模型概述》

小结:无论是引用计数法还是跟搜索法,都是为了找到可回收的对象(内存块)。

2. 垃圾收集算法

在确定了哪些垃圾可以被回收后,垃圾收集器要做的就是进行垃圾的回收,有下面的几中算法:

2.1 标记-清除(Mark-Sweep)算法

标记-清除算法分为两个阶段:

  • 标记阶段:标记出需要被回收的对象。
  • 清除阶段:回收被标记的可回收对象的内部空间。
标记-清除算法图

标记-清除算法实现较容易,不需要移动对象,但是存在较严重的问题:

  • 算法过程需要暂停整个应用,效率不高。
  • 标记清除后会产生大量不连续的内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。

2.2 复制(Copying)算法

为了解决标志-清除算法的缺陷,由此有了复制算法。
复制算法将可用内存分为两块,每次只用其中一块,当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的内存空间一次性清理掉。

复制算法图

小结:

  • 优点:实现简单,不易产生内存碎片,每次只需要对半个区进行内存回收。
  • 缺点:内存空间缩减为原来的一半;算法的效率和存活对象的数目有关,存活对象越多,效率越低。

2.3 标记-整理(Mark-Compact)算法

为了更充分利用内存空间,提出了标记-整理算法。
此算法结合了“标记-清除”和“复制”两个算法的优点。
该算法标记阶段和“标志-清除”算法一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。

标志-整理算法图

2.4 分代收集(Generational Collection)算法

分代收集算法是目前大部分 JVM 的垃圾收集器采用的算法。
核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

分代算法图

区域划分:

年轻代(Young Generation

  1. 所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。
  2. 新生代内存按照8:1:1的比例分为一个 eden 区和两个 survivor(survivor0,survivor1) 区。一个 Eden 区,两个 Survivor 区(一般而言)。大部分对象在 Eden 区中生成。回收时先将 eden 区存活对象复制到一个 survivor0 区,然后清空 eden 区,当这个 survivor0 区也存放满了时,则将 eden 区和 survivor0 区存活对象复制到另一个 survivor1 区,然后清空 eden 和这个 survivor0 区,此时 survivor0 区是空的,然后将 survivor0 区和 survivor1 区交换,即保持 survivor1 区为空, 如此往复。
  3. survivor1区不足以存放 edensurvivor0 的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次 Full GC ,也就是新生代、老年代都进行回收。
    4.新生代发生的 GC 也叫做 Minor GCMinor GC 发生频率比较高(不一定等 Eden 区满了才触发)。

年老代(Old Generation

  1. 在年轻代中经历了 N 次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
  2. 内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发 Major GCFull GCFull GC 发生频率比较低,老年代对象存活时间比较长,存活率标记高。

持久代(Permanent Generation
用于存放静态文件,如 Java 类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些 class ,例如 Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。

GC 类型:

  1. Minor GC(新生代 GC):
    新生代 GC,指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生熄灭的特点,所以 Minor GC 十分频繁,回收速度也较快。
  2. Major GC(老年代 GC):
    老年代 GC,指发生在老年代的垃圾收集动作,当出现 Major GC 时,一般也会伴有至少一次的 Minor GC(并非绝对,例如 Parallel Scavenge 收集器会单独直接触发 Major GC 的机制)。 Major GC 的速度一般会比 Minor GC 慢十倍以上。
  3. Full GC:
    清理整个堆空间—包括年轻代和老年代。Major GC == Full GC参考:聊聊JVM(四)深入理解Major GC, Full GC, CMS

产生 Full GC 可能的原因:

  1. 年老代被写满。
  2. 持久代被写满。
  3. System.gc() 被显示调用。
  4. 上一次 GC 之后 Heap 的各域分配策略动态变化。

垃圾收集器(GC

不同虚拟机所提供的垃圾收集器可能会有很大差别,下面的例子是 HotSpot

新生代收集器使用的收集器:SerialPraNewParallel Scavenge
老年代收集器使用的收集器:Serial OldParallel OldCMS

垃圾收集器图
  1. Serial 收集器(复制算法)
    新生代单线程收集器,标记和清理都是单线程,优点是简单高效。

  2. Serial Old收集器(标记-整理算法)
    老年代单线程收集器,Serial 收集器的老年代版本。

  3. ParNew 收集器(停止-复制算法)  
    新生代收集器,可以认为是 Serial 收集器的多线程版本,在多核 CPU 环境下有着比 Serial 更好的表现。

  4. Parallel Scavenge 收集器(停止-复制算法)
    并行收集器,追求高吞吐量,高效利用 CPU。吞吐量一般为 99%, 吞吐量 = 用户线程时间 / (用户线程时间 + GC线程时间)。适合后台应用等对交互相应要求不高的场景。

  5. Parallel Old 收集器(停止-复制算法)
    Parallel Scavenge 收集器的老年代版本,并行收集器,吞吐量优先。

  6. CMS(Concurrent Mark Sweep) 收集器(标记-清理算法)
    高并发、低停顿,追求最短 GC 回收停顿时间,cpu 占用比较高,响应时间快,停顿时间短,多核 cpu 追求高响应时间的选择。

根据对象的生命周期的不同将内存划分为几块,然后根据各块的特点采用最适当的收集算法。大批对象死去、少量对象存活的(新生代),使用复制算法,复制成本低;对象存活率高、没有额外空间进行分配担保的(老年代),采用标记-清理算法或者标记-整理算法。

四种引用状态

在实际开发中,我们对 new 出来的对象也会根据重要程度,有个等级划分。有些必须用到的对象,我们希望它在其被引用的周期内能一直存在;有些对象可能没那么重要,当内存空间还足够时,可以保留在内存中,如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。
由此,Java 对引用划分为四种:强引用、软引用、弱引用、虚引用,四种引用强度依次减弱。

  1. 强引用
    代码中普遍存在的类似"Object obj = new Object()"这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  2. 软引用
    描述有些还有用但并非必需的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。Java 中的类 SoftReference 表示软引用。
  3. 弱引用
    描述非必需对象。被弱引用关联的对象只能生存到下一次垃圾回收之前,垃圾收集器工作之后,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。Java 中的类 WeakReference 表示弱引用。
  4. 虚引用
    这个引用存在的唯一目的就是在这个对象被收集器回收时收到一个系统通知,被虚引用关联的对象,和其生存时间完全没关系。Java 中的类 PhantomReference 表示虚引用。

参考资料:
深入理解 Java 垃圾回收机制
Java垃圾回收(GC)机制详解
深入浅出Java垃圾回收机制
深入理解Java虚拟机---(6)触发Full GC的条件