深入理解 Java —— GC 机制

时间:2023-07-27 23:28:20

1. 基础知识

1.1 什么是垃圾回收?

程序的运行必然需要申请内存资源,无效的对象资源如果不及时处理就会一直占有内存资源,最终将导致内存溢出,所以对内存资源的管理非常重要。

垃圾回收就是对这些无效资源的处理,是对内存资源的管理。

1.2 为什么要了解 GC?

在你排查内存溢出、内存泄漏等问题时,以及程序性能调优、解决并发场景下垃圾回收造成的性能瓶颈时,就需要对GC机制进行必要的监控和调节。

1.3 什么时候进行垃圾回收

  1. 会在cpu空闲的时候自动进行回收  

  1. 在堆内存存储满了之后  

  2. 主动调用 System.gc() 后尝试进行回收

补充:System.gc() 用于调用垃圾收集器,在调用时,垃圾收集器将运行以回收未使用的内存空间。它将尝试释放被丢弃对象占用的内存。 然而System.gc()调用附带一个免责声明,无法保证对垃圾收集器的调用。所以 System.gc() 并不能说是完美主动进了垃圾回收。

1.4 GC 发生在 JVM 哪部分?

在回答这个问题之前,有必要先了解 JVM 的体系结构图:

深入理解 Java —— GC 机制

对于 JVM 各部分的解读,这里不在做过多说明,具体可以参考文章:Java 虚拟机结构

GC主要发生在堆中,堆区由所有线程共享,在虚拟机启动时创建。堆区主要用于存放对象实例及数组,所有new出来的对象都存储在该区域。
JVM 虚拟栈,本地方法栈,程序计数器不需要进行垃圾回收,因为他们的生命周期是和线程同步的,随着线程的销毁,他们占用的内存会自动释放。所以,只有方法区和堆区需要进行垃圾回收,回收的对象就是那些不存在任何引用的对象。

2. 如何判断对象已死(或能够被回收)

既然名叫垃圾回收,那么哪些对象成为“垃圾”呢?已经不再被使用的对象便视为“已死”,就应该被回收。在Java中,GC只针对于堆内存,Java语言中不存在指针说法,而是叫引用,在堆内存中没有被任何栈内存引用的对象应该被回收

2.1 引用记数法

既然没有引用就可以回收,引用计数法应运而生。简单的来说就是判断对象的引用数量。

实现方式:给对象共添加一个引用计数器,每当有引用对他进行引用时,计数器的值就加1,当引用失效,也就是不在执行此对象,它的计数器的值随之减1,若某一个对象的计数器的值为0,那么表示这个对象没有被其他对象引用,也就是意味着是一个失效的垃圾对象,就会被GC进行回收。

缺点:无法解决对象减互相循环引用的问题。即当两个对象循环引用时,引用计数器都为1,当对象周期结束后应该被回收却无法回收,造成内存泄漏。

2.2 可达性分析算法

目前主流使用的都是可达性分析算法来判断对象是否存活。算法基本思路:以“GC Roots”作为对象的起点,从此节点开始向下搜索,搜索所走过的路径成为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

深入理解 Java —— GC 机制

哪些对象可作为GC Roots?

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象;

  • 方法区中类静态属性引用的对象;

  • 方法区中常量引用的对象;

  • 本地方法栈中JNI(Native方法)引用的对象;

  • 活跃线程的引用对象。

在可达性分析法中,判定一个对象 objA 是否可回收,至少要经历两次标记过程:
  1. 如果对象 objA 到 GC Roots没有引用链,则进行第一次标记。

  2. 如果对象 objA 重写了 finalize() 方法,且还未执行过,那么 objA 会被插入到 F-Queue 队列中,由一个虚拟机自动创建的、低优先级的 Finalizer 线程触发其 finalize() 方法。finalize() 方法是对象逃脱死亡的最后机会,GC 会对队列中的对象进行第二次标记,如果 objA 在 finalize() 方法中与引用链上的任何一个对象建立联系,那么在第二次标记时,objA 会被移出“即将回收”集合。

下面给个示例代码,具体结论,大家可以自己验证。

@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("method finalize is running");
object = this;
} public static void main(String[] args) throws Exception {
object = new FinalizerTest(); // 第一次执行,finalize方法会自救
object = null;
System.gc(); Thread.sleep(500);
if (object != null) {
object.isAlive();
} else {
System.out.println("I'm dead");
} // 第二次执行,finalize方法已经执行过
object = null;
System.gc(); Thread.sleep(500);
if (object != null) {
object.isAlive();
} else {
System.out.println("I'm dead");
}
}

2.2.1 回收方法区

假如一个字符串 “abc” 已经进入了常量池中,但是当前系统没有任何一个String对象是“abc”,那么这个对象就应该回收。方法去(HotSpot虚拟机中的永久代)的垃圾收集主要回收两部分内容:废弃常量和无用的类。比如上述的“abc”就是属于废弃常量,那么哪些类是无用的类呢?

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例;

  • 加载该类的 ClassLoader 已经被回收;

  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

3. Java中四种引用

在 JDK1.2 之前,Java 中的引用定义很单一:如果 reference 类型的数据中储存的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。但是这种定义太过狭隘,如果某个对象介于被引用和未被引用两种状态之间,那么这种定义就显得无能为力。在 JDK1.2 后 Java 对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference),这四种引用强度依次逐渐减弱。

  • 强引用(Strong Reference)

强引用就是值在程序代码中普遍存在的,用 new 关键字创建的对象都是强引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象

  • 软引用(Soft Reference)

软引用是用来描述一些还有用但并非必需的对象,在系统将要发生内存溢出之前,将会吧这些对象列入回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。可用来实现高速缓存。软引用对象在回收时会被放入引用队列(ReferenceQueue)。

//  软引用
SoftReference<String> softReference = new SoftReference<>("test");
  • 弱引用(Weak Reference)

弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次 GC 发生之前,当垃圾收集器工作时,无论当前内存是否足够,都会回收掉该类对象。弱引用对象在回收时会被放入引用队列(ReferenceQueue)。

//  弱引用
WeakReference<String> weakReference = new WeakReference<>("test");
  • 虚引用(Phantom Reference)

虚引用被称为幽灵引用或幻象引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得对象实例。任何时候都可能被回收,一般用来跟踪对象被垃圾收集器回收的活动,起哨兵作用。必须和引用队列(ReferenceQueue)联合使用。

//  虚引用,必须配合引用队列使用
ReferenceQueue<String> referenceQueue = new ReferenceQueue<>();
PhantomReference<String> phantomReference = new PhantomReference<>("test",referenceQueue);

4. 垃圾收集算法

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

这是最基础的算法,标记-清除算法就如同它的名字样,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象。这种算法的不足主要体现在效率和空间,从效率的角度讲,标记和清除两个过程的效率都不高;从空间的角度讲,标记清除后会产生大量不连续的内存碎片, 内存碎片太多可能会导致以后程序运行过程中在需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。标记-清除算法执行过程如图:

深入理解 Java —— GC 机制

4.2 复制(Copying)算法

复制算法是为了解决效率问题而出现的,它将可用的内存分为两块,每次只用其中一块,当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的内存空间一次性清理掉。这样每次只需要对整个半区进行内存回收,内存分配时也不需要考虑内存碎片等复杂情况,只需要移动指针,按照顺序分配即可。复制算法的执行过程如图:

深入理解 Java —— GC 机制

不过这种算法有个缺点,内存缩小为了原来的一半,这样代价太高了。现在的商用虚拟机都采用这种算法来回收新生代,不过研究表明 1:1 的比例非常不科学,因此新生代的内存被划分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。每次回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。

HotSpot 虚拟机默认 Eden 区和 Survivor 区的比例为 8:1,意思是每次新生代中可用内存空间为整个新生代容量的 90%。当然,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够用时,需要依赖老年代进行分配担保(Handle Promotion)。

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

复制算法在对象存活率较高的场景下要进行大量的复制操作,效率很低。万一对象100%存活,那么需要有额外的空间进行分配担保。老年代都是不易被回收的对象,对象存活率高,因此一般不能直接选用复制算法。根据老年代的特点,有人提出了另外一种标记-整理算法,过程与标记-清除算法一样,不过不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。标记-整理算法的工作过程如图:

深入理解 Java —— GC 机制

4.4 分代收集算法(Generational Collection)

当前大多数垃圾收集都采用的分代收集算法,这种算法并没有什么新的思路,只是根据对象存活周期的不同将内存划分为几块,每一块使用不同的上述算法去收集。在 jdk8 以前分为三代:年轻代、老年代、永久代。在 jdk8 以后取消了永久代的说法,而是元空间取而代之。一般年轻代使用复制算法(对象存活率低),老年代使用标记整理算法(对象存活率高)。

4.4.1 新生代(复制算法为主)

新生代的目标就是尽可能快速的收集掉那些生命周期较短的对象,一般情况下新生成的或者朝生夕亡的对象一般都是首先存放在新生代里面。

深入理解 Java —— GC 机制

整个新生代占1/3的堆空间,新生代分为三个区,Eden、Survivor-from(s0)、Survivor-to(s1),其内存大小默认比例为8:1:1(可调整),大部分新创建的对象都是在 Eden 区创建。

当回收时,先将 Eden 区存活对象复制到一个 s0 区,然后清空 Eden 区,存活的对象年龄+1;当这个 s0 区也存放满了时,则将 Eden 区和 s0 区存活对象复制到另一个s1 区,然后清空 Eden 和这个 s0 区,存活的对象年龄+1;此时 s0 区是空的,然后将 s0 区和 s1 区交换,即保持 s0 区为空(此时的 s0 是原来的 s1 区), 如此往复。年轻代执行的 GC 是 Minor GC。

新生代的迭代更新很快,大多数对象的存活时间都比较短,所以对 GC 的效率和性能要求较高,因此使用复制算法,同时这样划分为三个区域,保证了每次 GC 仅浪费 10% 的内存,内存利用率也有所提高。

s0 和 s1 设置的参数主要有两个:

-XX:SurvivorRatio=8
-XX:InitialSurvivorRatio=8

第一个参数(-XX:SurvivorRatio)是 Eden 和 Survivous 区域比重(注意 Survivous 一般包含两个区域 s0 和 s1,这里是一个 Survivous 的大小)。如果将 -XX:SurvivorRatio=8 设置为 8,则说明 Eden 区域是一个 Survivous 区的 8 倍,换句话说 S0 或 S1 空间是整个新生代空间的 1/10,剩余的 8/10 由 Eden 区域来使用。

第二个参数(-XX:InitialSurvivorRatio)是 Young/s0 的比值,当其设置为8时,表示 s0 或 s1 占整个新生代空间的 1/8(或12.5%)。

一个对象每次Minor GC时,活着的对象都会在 s0 和 s1 区域转移,讲过 MInor GC 多少次后,会进入 Old 区域呢?

默认是15次,参数设置:

--XX:MaxTenuringThreshold=15

计数器会在对象的头部记录它的交换次数。

4.4.2 老年代(标记-整理算法为主)

在新生代经过很多次垃圾回收之后仍然存活的对象(默认15岁),就会被放入老年代中,因为老年代中的对象大多数是存活的,所以使用算法是 标记-整理 算法。老年代执行的 GC 是Full GC。

深入理解 Java —— GC 机制

4.4.3 永久代/元空间

jdk8 以前:

永久代用于存放静态文件,如 Java 类、方法等。该区域回收与上述“方法区内存回收”一致。但是永久代是使用的堆内存,如果创建对象太多容易造成内存溢出OOM(OutOfMemory)。

深入理解 Java —— GC 机制

jdk8 以后:

jdk8 以后便取消了永久代的说法,而是用元空间代替,所存内容没有变化,只是存储的地址有所改变,元空间使用的是主机内存,而不是堆内存,元空间的大小限制受主机内存限制,这样有效的避免了创建大量对象时发生内存溢出的情况。

深入理解 Java —— GC 机制

4.5 对象标记过程

在可达性分析过程中,为了准确找出与 GC Roots 相关联的对象,必须要求整个执行引擎看起来像是被冻结在某个时间点上,即暂停所有运行中的线程,不可以出现对象的引用关系还在不断变化的情况。

如何快速枚举GC Roots?

GC Roots 主要在全局性的引用(常量或类静态属性)与执行上下文(本地变量表中的引用)中,很多应用仅仅方法区就上百兆,如果进行遍历查找,效率会非常低下。

在 HotSpot 中,使用一组称为OopMap的数据结构进行实现。类加载完成时,HotSpot 把对象内什么偏移量上是什么类型的数据计算出来存储到 OopMap 中,通过 JIT 编译出来的本地代码,也会记录下栈和寄存器中哪些位置是引用。GC 发生时,通过扫描 OopMap 的数据就可以快速标识出存活的对象。

如何安全的 GC?

线程运行时,只有在到达安全点(Safe Point)才能停顿下来进行GC。

基于 OopMap 数据结构,HotSpot 可以快速完成 GC Roots 的遍历,不过 HotSpot 并不会为每条指令都生成对应的 OopMap,只会在 Safe Point 处记录这些信息。

所以 Safe Point 的选择很重要,如果太少可能导致 GC 等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会选择一些执行时间较长的指令作为 Safe Point,如方法调用、循环跳转和异常跳转等。

关于 Safe Point 更多的信息,可以看看这篇文章 JVM的Stop The World,安全点,黑暗的地底世界

发生 GC 时,如何让所有线程跑到最近的 Safe Point 再暂停?

当发生 GC 时,不直接对线程进行中断操作,而是简单的设置一个中断标志,每个线程运行到Safe Point的时候,主动去轮询这个中断标志,如果中断标志为真,则将自己进行中断挂起。

这里忽略了一个问题,当发生 GC 时,运行中的线程可以跑到Safe Point后进行挂起,而那些处于 Sleep 或 Blocked状态的线程在此时无法响应 JVM 的中断请求,无法到 Safe Point 处进行挂起,针对这种情况,可以使用安全区域(Safe Region)进行解决。

Safe Region是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始 GC 都是安全的。

  1. 当线程运行到 Safe Region 的代码时,首先标识已经进入了 Safe Region,如果这段时间内发生 GC,JVM 会忽略标识为 Safe Region 状态的线程;

  2. 当线程即将离开 Safe Region 时,会检查JVM是否已经完成 GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开 Safe Region 的信号为止;

6. Minor GC和Full GC

之前多次提到 Minor GC和 Full GC,那么它们有什么区别呢?

  • Minor GC 即新生代 GC:发生在新生代的垃圾收集动作,因为 Java 有朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。

  • Major GC / Full GC:发生在老年代,经常会伴随至少一次 Minor GC。Major GC 的速度一般会比 Minor GC 慢倍以上。

Minor GC发生条件:

  • 当新对象生成,并且在Eden申请空间失败时;

Full GC发生条件:

  • 老年代空间不足

  • 永久带空间不足(jdk8以前)

  • System.gc() 被显示调用

  • Minor GC 晋升到老年代的平均大小大于老年代的剩余空间

  • 使用 RMI 来进行 RPC 或管理的 JDK 应用,每小时执行 1 次 Full GC

在发生 FULL GC 的时候,意味着 JVM 会安全的暂停所有正在执行的线程(Stop The World),来回收内存空间,在这个时间内,所有除了回收垃圾的线程外,其他有关 JAVA 的程序,代码都会静止,反映到系统上,就会出现系统响应大幅度变慢,卡机等状态。

7. 常见的垃圾收集器(jdk8及以前)

一张图即可清除看到不同垃圾收集器之间的关系,连线表示可以配合使用。

深入理解 Java —— GC 机制
  • Serial 收集器(复制算法)

新生代单线程收集器,标记和清理都是单线程,优点是简单高效。是 client 级别默认的 GC 方式,可以通过 -XX:+UseSerialGC 来强制指定。

  • Serial Old 收集器(标记-整理算法)

老年代单线程收集器,Serial 收集器的老年代版本。

  • ParNew 收集器 (复制算法)

新生代收集器,Serial收集器的多线程版本,在多核CPU情况时表现更好。

  • Parallel Scavenge 收集器 (复制算法)

并行收集器,追求高吞吐量,高效利用 CPU。适合后台应用等对交互相应要求不高的场景。是 server 级别默认采用的 GC 方式,可用 -XX:+UseParallelGC 来强制指定,用 -XX:ParallelGCThreads=2 来指定线程数。

  • Parallel Old 收集器(复制算法) Parallel Scavenge 收集器的老年代版本,并行收集器,吞吐量优先。
  • CMS(Concurrent Mark Sweep)收集器(标记-清理算法) 高并发、低停顿,追求最短 GC 回收停顿时间(Stop The World),cpu 占用比较高,响应时间快,停顿时间短,多核cpu追求高响应时间的选择,但是因为使用标记清理算法,容易产生内存碎片。
  • G1收集器

G1 是一款面向服务端应用的垃圾收集器,支持并行与并发、分代收集、空间整合和可预测停顿的能力,即可适用于年轻代又可适用于老年代。

深入理解 Java —— GC 机制

8. 垃圾收集器参数总结

  • UseSerialGC:虚拟机运行在Client模式下的默认值,打开此开关后,使用Serial+Serial Old的收集器组合进行内存回收

  • UseParNewGC:打开此开关后,使用ParNew+Serial Old的收集器组合进行内存回收

  • UseConcMarkSweepGC:打开此开关后,使用ParNew+CMS+Serial Old的收集器组合进行内存回收。Serial Old收集器将作为CMS收集器出现Concurrent Mode Failure失败后的后备收集器使用

  • UseParallelGC:虚拟机运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge + Serial Old(PS MarkSweep)的收集器组合进行内存回收

  • UseParallelOldGC:打开此开关后,使用Parallel Scavenge + Parallel Old的收集器组合进行内存回收

  • SurvivorRatio:新生代中Eden区域与Survivor区域的容量比值,默认值为8,代表Eden:Survivor=8:1

  • PretenureSizeThreshold:直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配

  • MaxTenuringThreshold:晋升到老年代的对象年龄,每个对象在坚持过一次Minor GC之后,年龄就增加1,当超过这个参数时就进入老年代

  • UseAdaptiveSizePolicy:动态调整Java堆中各个区域的大小以及进入老年代的年龄

  • HandlePromotionFailure:是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个Eden和Survivor区的所有对象都存活的极端情况

  • ParallelGCThreads:设置并行GC时进行内存回收的线程数

  • GCTimeRatio:GC时间占总时间的比率,默认值为99,即允许1%的GC时间。仅在使用Parallel Scavenge收集器时生效

  • MaxGCPauseMillis:设置GC的最大停顿时间,仅在使用Parallel Scavenge收集器时生效

  • CMSInitingOccupancyFraction:设置CMS收集器在老年代空间被使用多少后触发垃圾收集。默认值为68%,仅在使用CMS收集器时生效

  • UseCMSCompactAtFullCollection:设置CMS收集器在完成垃圾收集后是否要进行一次内存碎片整理,仅在使用CMS收集器时生效

  • CMSFullGCsBeforeCompaction:设置CMS收集器在进行若干次垃圾收集后再启动一次内存碎片整理。仅在使用CMS收集器时生效

参考文章

1、Java GC你不得不知的那些事

2、JVM 垃圾回收机制(GC)总结

3、JVM构架、GC垃圾回收机制的理解

4、JVM垃圾回收机制