JVM的GC策略

JVM的GC策略

1. GC简介

GC(Garbage Collection,垃圾回收)是 JVM 中非常必要的一个机制,之所以需要 GC,是因为程序在运行中,会产生很多 Garbage,而 JVM 的内存是有限制的,如果不及时对这些 Garbage 进行整理和清除,会使得内存占用越来越大,最后导致没有足够的内存生成新的对象。GC 中有一个很重要的前提:Stop-The-World,也即:JVM 由于要执行 GC 而停止了应用程序的执行。当 GC 发生时,除了 GC 所需的线程外,其他所有线程都处于等待状态直到 GC 任务完成。而通常做 GC 优化的目的,就是减少 GC 发生,以减少卡顿。

此外,GC 运行在单独的 Daemon 线程中,当 JVM 开始 GC 时,会暂停当前进程内的所有线程,然后启动 GC 线程回收资源。

每个 Java 进程都拥有独立的 JVM 实例,所以 GC 仅能触发当前进程下的资源回收。


2. GC的两种核心判定原则

2.1 引用计数

引用计数也即:为实际内存中存储的对象所具有引用记录引用数,例如:

1
2
3
4
// 省略类 A 和 B 之间的关系
A a = new A();
B b = new B();
b = a;

此时,new A() 执行后在内存中实际存储的对象数据,就具有了 ab 两个引用,则其引用计数为 2,而 new B() 生成的实际对象数据则引用计数为 0。

引用计数法的核心思想即:一个对象,每增加一个引用,则引用计数 +1,每减少一个引用,则引用计数 -1,任何时刻,计数值为 0 的对象,就是不可再使用的(例如,b = a; 后,原本 new 出来的 B 对象,就无法再通过代码获取到了),即可马上将自己当做空闲空间链接到空闲链表。

2.2 引用链

提出一个初始根节点对象的概念:GC Root,当一个对象产生时,会将其建立与 GC Root 的直接或间接连接,例如一个外部类可能直接连接 GC Root,而该类的子类则先连接到该类,再连接到 GC Root。也就是说,GC Root 本身也是对象,只是这些对象,在整个程序运行期间,都有用或不会被销毁,那么与这些对象相连接的对象,则说明是有用的,则不需要被回收。而某些对象与这些 GC Roots 没有连接,则说明这些对象不是必要的,则 GC 可以回收。这些引用关系,被称为:引用链

例如:通常一个 Java 程序,都是从 main 方法开始执行,可以将 main 方法的实际内存引用作为 GC Root,如果在 main 方法中声明两个对象:A a;B b;,则 ab 都是与 main 相连接的平行平级的对象,这时如果执行:b = a;,则将内存中 a 的实际引用(指向内存中的实际数据)赋值给了 b,则原本 b 的引用(内存中实际的一个 B 的对象)被中断,此时的引用链则是:

main 连接到平行平级的 ab,然后 ab 同时连接到内存中 A 的实际对象,而内存中 B 的实际对象则失去任何引用。

当一个对象,可以连接到 GC Root,则称之为是“可达的”,或“具有可达性”,否则为“不可达”或“不具有可达性”。


3. 常见的GC算法

常见的 GC 算法有:引用计数法、标记清除法、复制法、标记整理法、分代收集法。

3.1 引用计数法

即是通过判断引用计数是否为 0 来决定是否回收,引用计数法最致命的缺点,是当发生循环引用时,无法回收无用对象。例如:

1
2
3
4
5
A a = new A();
B b = new B();

a.friend = b;
b.friend = a;

ab 二者互相引用,执行完这段代码后,其引用计数始终不为 0,则 GC 始终无法回收。

3.2 标记清除法

标记清除法分为“标记”和“清除”两个阶段。

  • 标记阶段,从 GC Root 出发,遍历所有的子节点,并将所有可达的对象进行标记。
  • 清除阶段,遍历所有的对象,将未标记的对象回收。

标记清除法最致命的缺点是:多次 GC 会导致内存碎片化,即空闲内存在实际内存中不是连续的,导致当有大内存对象产生时,无法找到足够的连续内存而又一次触发 GC。

3.3 复制法

将内存划分为大小相等的两个区域,每次只使用其中一半,当 GC 发生时,找出其中存活的对象,按照顺序复制到另一半内存中连续的区域,然后直接把之前的一半内存清空,这样就不会出现内存碎片的情况。这种方案适用于大部分对象生命周期都比较短的情况,例如新生代中的对象,而当遇到极端情况,例如一半的内存中,在 GC 时所有的对象都存活,则直接把这一半内存全部复制到另一半,结果还是全部存活,又要复制回来···,实际上就是浪费了 50% 的空间,所以像老年代(存放经过多次 GC 后仍然存活的对象,所以这些对象被 GC 后仍然存活的几率很高)就不能用这种方案。

3.4 标记整理法

标记整理法和标记清除法类似,都是通过遍历标记所有可达的对象,但不是直接清理掉不可达对象,而是将所有含有标记的对象,向内存的一侧移动,用空闲指针来标记最后一个存活对象,然后清理掉空闲指针之后的内存空间,这样清理后的空闲内存则是连续的一段。

3.5 分代收集法

首先需要明确的是,分代收集法本质上并不是一种 GC 算法,对于一个复杂的系统,例如 JVM,只是用一种单一的 GC 算法是不足以应对所有场景的,因此需要针对多个场景制定一套算法,而分代收集法就是基于这个理念创立的。

堆区本身有 3 个区域划分:新生代(Young Generation)、老年代(Old Generation)、永久代(Permanent Generation)。

(1)新生代:所有新生成的对象都在新生代,新生代的目标就是尽可能快速的收集生命周期短的对象。新生代又分为三个区(通常是两个 Survivor 区,但也可以增加):Eden 区,Survivor 1 区,Survivor 2 区。

  • Eden 区:大部分对象在 Eden 区生成,当 Eden 区满了 GC 后存活下来的对象将随机复制到其中一个 Survivor 区。
  • Survivor区:当一个 Survivor 区满了 GC 后存活下来的对象,如果是来自 Eden 区,则复制到另一个 Survivor 区,如果是来自另一个 Survivor 区,则复制到老年代。

通俗地说,如果一个对象首先在 Eden 区中被创建,Eden 经过 GC 后存活,则被复制到其中一个 Survivor 区,如果这个 Survivor 经过 GC 后还存活,则被复制到另一个 Survivor 区,如果另一个 Survivor 区经过 GC 后仍然存活,则复制到老年代。需要注意的是:两个 Survivor 区是平等的,没有优先级高低、顺序前后之分,都有可能接收来自 Eden 或另一个 Survivor 区传来的对象。Survivor 区可以手动配置为多于 2 个,即可增加对象在新生代中的时间,减小被复制到老年代的可能性。

(2)老年代:在新生代中经过 N 次(N 即为新生代中区的数量)GC 后仍然存活的对象将进入老年代,通常都是生命周期比较长的对象。

(3)永久代:一般用来存放类的信息(包括类名、类方法、字段信息等)、静态变量、常量池等不会改变的数据,但在 JDK 1.8 开始就使用元数据区取代了了永久代。


参考文献