JVM垃圾回收机制详解


1. 堆内存的基本结构

堆内存是垃圾收集器管理的主要区域,这是因为对象会随着进程的运行而不断产生和被弃用,因此在这个堆内存的区域中就需要执行大量的GC操作

堆内存通常分为三部分

  • 新生代内存区域,主要分为伊甸园区,S0区和S1区
  • 老年代区域
  • 永久代/元空间区域

永久代和元空间的最大区别在于:永久代使用的是进程的堆内内存,而元空间使用的是直接内存

2. 内存分配与回收遵循什么原则?

要理解这个问题,我们可以开启Java程序运行过程中的GC日志,可以通过参数

-XX:+PrintGCDetails

  • 第一个原则,对象优先在伊甸园区进行分配,在大多数的情况下,对象在Eden区进行分配,当Eden中的内存空间已满时,就会触发一次Minor GC,在GC的期间如果无法将对象转移到Survivor中的话,那么就会通过分配担保的机制,将新生代的对象提前转移到老年代中去,如果老年代中的空间足以存放这个对象,那么就不会触发Full GC
  • 第二个原则,大对象直接进入到老年代,大对象就是需要大量连续内存空间的对象,比如说字符串或者数组,大对象直接进入老年代是为了避免在对象晋级的时候产生频繁的内存拷贝,以及分配担保机制所带来的对象提前晋级的问题
  • 第三个原则,长期存活的对象将进入到老年代,这个过程实际上就规定了对象的晋升规则

对象的年龄是在运行的过程中不断变化的,因此年龄这个字段实际上存在对象头中的,具体的晋升规则如下:

首先,对象被创建时,如果Eden区有足够的空间,那么就会将对象的内存分配到Eden中,此时对象的年龄就是0,当Eden区空间已满,需要执行GC的时候,那么此时就会将所有存活的对象的age+1,当age == 1的时候,就会将这个对象的内存空间转移到S0或者S1区,此时,每经过一次MinorGC,那么这个对象的年龄就会++,直到达到一个阈值,这个阈值可以-XX:MaxTenuringThreadshold来设置,默认是15岁,当到达这个阈值的时候,这个对象就会晋升到老年代了。

主要进行GC的区域

新生代收集MinorGC/YoungGC:只对新生代进行垃圾收集

老年代收集MajorGC/OldGC:只对老年代进行GC,需要注意的是MajorGC在某些语境下也是说的整堆GC

混合收集Mixed:对整个新生代和部分老年代进行垃圾收集

整堆收集Full GC:收集整个Java堆和方法区

什么叫空间分配担保?

空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。

简单来说,空间分配担保是指在GC的过程中,预想了一种最坏的情况,在下一次MinorGC之后,所有的Survivor对象都达到了默认的阈值,然后晋升到了老年代,由于MinorGC是针对于新生代的对象的,因此不能够确保老年代的空间足以存放下所有将要晋升上去的新生代的对象

因此,在执行MinorGC之前,如果开启了jvm参数:-XX:HandlePromotionFailure,那么也就是说不允许担保失败,那么就会强制检查,如果目前老年代可分配的连续空间是小于当前新生代的所有对象的总空间的,那么就会执行fullGC

否则的话就是说允许担保失败,那么就会按照一个规则:检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,根据过去预测未来,如果还是小于的,那么就还是会执行FullGC

其他的情况都被认为是安全的,执行minorGC

3. 什么是引用计数法?

引用计数法的大致思路是:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就+1,当引用失效的时候,计数器值就-1,任何时刻计数器为零的对象就是不可能再被使用的了。

这个算法十分简单,但是存在一个关键的问题:循环引用的问题

public class ReferenceCountingGC {
	public Object instance = null;
	private static final int _1MB = 1024 * 1024;
	/**
		* 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否有回收过
	*/
	private byte[] bigSize = new byte[2 * _1MB];
    public static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
        // 假设在这行发生GC,objA和objB是否能被回收?
        System.gc();
    }
}

假设objA和objB都有一个实例成员instance,赋值的操作是objA.instance = objB以及objB.instance = objA,这造成了一种循环引用的局面,A引用了B,B的引用计数++,B引用了A,A的引用计数++

此时A和B的引用计数都是1,不满足为0的条件,如果按照引用计数法的话那么A和B都不会被GC

4. 可达性分析

这个算法的基本思想就是通过一系列的GC Roots的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连的话,那么就证明此对象是不可用的,需要被回收。

如图所示,obj5-obj7虽然具有一定的引用关系,但是因为和GC Roots不是连通的,因此最终将会被回收

那么GC Roots的选取就非常关键了,在Java中通常有这些对象可以作为GC Roots

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

  • 本地方法栈中所引用的对象

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

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

  • 所有被同步锁持有的对象

对象可以被回收,那么就一定会被回收吗?

即使在可达性分析法中不可达的对象,也并非是非死不可的,这时候它们暂时处于缓刑的阶段,要真正宣告一个对象死亡,至少要经历两次的标记过程

  • 可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize方法,当对象没有覆盖finalize方法或者finalize方法已经被虚拟机调用过的时候,虚拟机将这两种情况视为没有必要执行

被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

当第二次被标记的时候,就会被回收了,注意,当第二次被标记的时候,是不需要执行finalize的方法的

final、finally、finalize有什么不同?

final可以用来修饰类、方法、变量,final修饰的类代表不可以扩展和继承,final的变量是不可以被修改的,final的方法是不可以被重写的

finally则是Java保证代码一定要被执行的一个关键字

finalize它的作用是保证对象在被垃圾收集前完成特定资源的回收

5. 方法区的回收

方法区回收的内容主要包括有:废弃的常量和不再使用的类型

假如一个字面量”java”曾经进入过常量池,但是当前系统中又没有任何一个字符串对象的值是”java”,而且也没有其他地方引用这个字面量,如果在这时候发生内存回收,如果GC认为有必要的话,那么就会将这个”java”清理出常量池

其他类型,比如类,接口,方法的符号引用也与此类似,类写在需要满足三个条件

  • 该类的所有实例都已经被回收了
  • 加载该类的类加载器已经被回收了
  • 该类对应的Class 对象没有被任何地方所引用

6. 什么是强引用?软引用?弱引用?虚引用?

可达性分析是基于对象的引用链来实现的,因此搞清楚引用是很有必要的

如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,那么就说这个reference数据是代表某块内存、某个对象的引用

有一类对象:在内存空间还足够的时候,能够保留在内存之中,如果内存空间在进行GC之后依然非常紧缺,那么就可以抛弃这些对象,那么就可以抛弃这些对象,很多缓存功能都符合这样的场景

强引用

强引用是最传统的引用,是指程序代码之中普遍存在的引用赋值,最典型的强引用是:

Object obj = new Object();

无论任何情况下,GC器都不会轻易回收这个obj,即使内存空间不足,JVM会抛出OOM也不会回收

软引用

软引用的特性是该对象类似于可有可无的,它在内存充足的时候可以位于内存之中,但是在内存不足的时候就会被果断回收

一个常见的例子就是设计一个缓存系统,当希望将一部分数据库中的数据加载内存中来缓存上一次的结果的时候,就可以使用软引用,这实际上是说这个缓存实际上就是可有可无的,因为它可以通过走数据库得到新的数据,而且代价并不高,但是强引用的对象一般来说在程序中具有不可替代,一旦丢失,可能造成非常严重的后果

User user = new User();//强引用,地址为0x0000001
SoftReference ref = new SoftReference(user);//对user设置软引用
user = null;//取消强引用

此时user就变为了软引用的状态,当内存中的0x0000001这一块区域还没有被回收的时候,可以通过ref.get()来获取相应的对象

如果在创建SoftReference对象时,使用了一个ReferenceQueue作为参数提供给SoftReference的构造方法,那么当SoftReference软引用的u被垃圾回收器回收的同时,ref所强引用的SoftReference对象被放入ReferenceQueue。

**弱引用 **

弱引用的特性是该对象处于一种可有可无的状态,但是它比软引用更加弱,在GC的时候,无论当前内存是否充足,都会将该具有弱引用对象给GC掉

虚引用

虚引用是强度最低的一种引用,它的存在就和没有引用是一样的,它并不会决定对象的生命周期,在任何时候都可能被垃圾回收

那么它和弱引用有什么区别呢?

该对象在回收的前后都无法通过虚引用获取到这个对象,在该对象被回收掉的时候会将对象的部分信息存储到虚引用回收队列中,仅仅是拿到一个对象被回收掉的通知

同时,虚引用必须和引用队列联合使用,它的意义也就在这里,通过这种方式能够跟踪对象被垃圾回收的活动,当垃圾回收期准备回收一个对象的时候,如果发现它还有虚引用,就会在回收对象的内存之前,把这根虚引用加入到与之关联的引用队列中,程序可以判断引用队列中是否加入了虚引用,来了解引用对象是否将要被回收,程序如果发现某个虚引用已经被加入到了引用队列中,那么就可以在所引用的对象的内存被回收之前采取必要的行动

7. 什么是标记-清除算法?

标记-清除算法是JVM中GC算法的基础算法,它的工作原理和名字描述的一样

先标记,再清除,首先标记出所有需要回收的对象,标记的过程就是对象是否属于垃圾的判定过程。

这个过程就是判断对象是否死亡,通常有两种思路:引用计数法,这种方法在对象之间存在循环引用的时候会产生内存泄漏的问题,另一种是通过可达性分析判断对象是否死亡。

这种算法的优点是简单,算法容易实现

缺点则是

  • 执行的效率不稳定,如果Java堆中包含大量的对象,而且大部分都是需要被回收的,这时候必须进行大量的标记和清除,这时候GC的过程就会非常耗时了,但是如果对象很少,触发GC的时候可能又不会很耗时
  • 如图所示,在GC的时候可能导致产生大量的外碎片,当一个大对象想要分配内存的时候,这时候可能导致又触发一次GC

8. 什么是标记-复制算法?

为了解决标记-清除算法的效率问题,它将内存分为大小相同的两块,每次都只使用其中的一块,当这一块的内存使用完了之后,就将还存活的对象复制到另一块去了,然后再把使用的空间一次性清理掉

这种方式的优点:

  • 执行效率稳定,每次都是操作一半的内存,这种方式有个术语叫做半区复制
  • 空间效率:由于每次执行复制时,都默认进行内存空间的整理,没有外碎片
  • 分配效率:由于只需要移动堆顶指针,按顺序分配内存,因此分配内存速度快

缺点:

  • 在极端情况下还是会产生效率不一致的问题,假设目前有大量的对象需要回收,那么需要复制的就是少量的对象,但是如果只有少量的对象需要回收,那么需要复制的就是大量的对象了,十分耗时
  • 内存的利用率低:这样的算法无疑是对内存的利用率低的,因为每次都只能够在一半的内存空间中执行内存的分配

Appel式回收

将新生代分为EdenSurvivor区(S0和S1),每次分配都是使用Eden+S0/Eden+S1,那么在回收的时候是这样的:扫描Eden和使用的Sx区,然后将存活的对象放到另一块Sy区域,然后一次性清理掉Eden+Sx

9. 什么是标记-整理算法?

标记-整理算法的核心思路是将存活对象进行一个移动操作,将所有的存活对象移动到内存的一端,然后直接清除掉边界以外的对象。

由于这个过程中涉及到对象的移动,因此还需要修改常量池中符号引号对应的内存地址,如果对象太多,那么就会产生STW问题,因此这是一把双刃剑

**关于标记-清除算法和标记-整理算法的比较 **

首先标记-清除算法是不需要移动对象的,因此它的GC速率将会非常高,但是在内存分配方面的表现欠佳,最显著的就是当内存中有大量的外碎片的时候,这时候就会导致没有连续的大内存空间而可能导致频繁地触发GC

标记-整理算法是需要移动对象的,因此它的GC速率相对欠佳,但是在内存分配方面有更好的表现,因为当进行了标记-整理算法后,内存空间就规整了,分配时就能够更加迅速

那么如何选择呢?从Java程序的普遍使用来看,内存分配的频率是远远高于GC的频率的,因此提高内存分配的效率能够提升整个系统的吞吐量,从这个角度上来看 ,使用标记-整理算法更好

目前常见的虚拟机的实现有采用一种混合式的GC方式,也就是实时监控内存碎片状态,在内存碎片可以接受的程度下实现标记-清除算法,而当内存碎片状态过于严重的时候,就会触发一次标记-整理算法。

10. 什么是分代收集算法

分代收集算法的理论基础是分代假说,这个假说的理论是这样的:

  • 弱分代假说:绝大多数对象都是朝生夕灭的
  • 强分代假说:活得越久也就是熬过越多次的垃圾收集过程的对象就越难以消亡

这两个假说点奠定了一个设计原则:垃圾收集器应该要将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过的垃圾收集的次数)分配到不同的区域之间存储

根据对象的年龄,把这些对象归类到不同的区域,然后在这些不同的区域执行最合适的GC算法

比如说,在新生代,由于朝生夕灭,那么就意味着存活的对象可能很少,因此采用标记-复制算法能够取得更低的成本

那么在老年代,由于这些对象经过了长期的GC还没有消亡,因此存活的对象将会很多,为了尽量避免STW现象,应该尽量在老年代中采取轻量级的GC操作

11. 说说Serial(串行)收集器

串行收集器是基本的垃圾收集器,它有这样的特点:它是单线程的,它只会使用一条垃圾收集线程去完成垃圾的收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有工作线程

它在GC时也是基于分代收集算法实现的

对于新生代执行标记-复制算法,对于老年代执行标记-整理算法

12. 说说ParNew收集器

ParNewSerial的多线程优化版,这里解释一下多线程到底是哪多线程

  • 不是指用户线程和GC线程同时运行,而是指在GC时,开启了多个线程同时GC

13. Parallel Scavenge 收集器

Parallel Scavenge 收集器关注点是吞吐量(高效率的利用 CPU)。CMS 等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。 Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。

新生代采用标记-复制算法,老年代采用标记-整理算法。

14. Serial Old 收集器

Serial 收集器的老年代版本,它同样是一个单线程收集器。它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案

15. Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本。使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器。

16. CMS收集器

CMS收集器旨在降低STW的时间,希望以尽量小的代价降低用户线程的停顿,它的GC过程一共有四步

第一步:初始标记,初始标记就是从GCRoots出发,标记出那些与GCRoots直接关联的第一个对象,这一步与整个堆中的对象数量对比,是比较少的,因此耗时相对比较短

第二步:并发标记,假设这一步中,采取的是STW机制的话,那么将会占据大部分的数据,因此为了做出优化,因此采用的是并发标记的思路,这个并发是真正意义上的并发,标记线程与用户线程同时执行

这一步中有一个权衡,就是并发标记线程的数量,默认启动的线程数量是(处理器核心数量+3)/4,这个线程数量将会占据一部分的处理器性能,因此,当启动GC的时候,用户还是会感到明显的卡顿

卡顿有两种形式:一种是短时间内的强烈卡顿,这种情况是出现在CPU的大部分时间片被标记线程所占用,导致用户线程无法使用CPU,但是由于标记线程的吞吐量得到了提高,因此时间较短

第二种则是长时间的轻微卡顿,这种情况是启用了操作系统中常见的处理器调度算法,能够使得各线程之间对CPU的竞争达到一个平衡

目前来看,第一种形式比较符合当前服务的需求

这一步中有一个非常重要的算法叫做三色标记算法

首先先来了解一下每一个对象的颜色是如何分配的:

  • 黑色:自己已经被标记了,而且与他有边的各个节点都已经被标记了那么它自己就被设置为黑色,不会被回收
  • 灰色:自己已经被标记了,但是与它有边的各个节点还有没有被标记了的,那么它自己就被设置为灰色,不会被回收
  • 白色:自己还没有被标记,会被回收

BUG:有一种情况,假设有这样的一个连接关系:A->B,B->D

线程1过来,将B标记了,然后A就被标记为了黑色,而B就被标记为了灰色

线程2过来(用户线程),它将B->D引用取消了,然后将D的引用给了A,最终导致D在内存中丢失了

CMS下的解决方案:

增量更新+写屏障

当对象D的成员变量的引用发生变化的时候,可以利用写屏障,将D新的成员变量引用对象G记录下来。不要求保留原始快照,而是针对新增的引用,将其记录下来,等待遍历

什么是写屏障呢?这里的话可以简单理解为,当有特殊事件发生的时候,它将对这个事件进行处理,执行一段特殊的代码,类似于Spring中的AOP

原始快照

当对象E的成员变量的引用发生变化的时候,可以利用写屏障,将E原来的成员变量的引用对象G记录下来,啥意思呢?就是说将这个G标记为灰色的,下一次再来扫描就好了

第三步:重新标记,为什么要重新标记?这是因为在并发标记的过程中,依然有用户线程在不断地产生垃圾,因此此时要做一个修正,还有一种情况是本来是垃圾的,然后被标记了,因为用户进程在执行,然后导致被标记为垃圾的对象将要被清楚,这样的话就要做一个修正,而且必须确保这一步不会出错

第四步:并发清理,注意,这一步依然是用户线程和GC线程同时运行,因此在这种情况下,GC的清理和垃圾的产生同步进行,那些没有被标记到的垃圾就被称为浮动垃圾。CMS自然无法清理掉它们,因此只好等待下一次GC

可以看出,GC的效率还与并发的程度有关

另外,与其他串行处理器的方式不一样,CMS由于用户线程和GC线程同时进行,因此在GC的过程中可能导致堆内存区域提前被占满,因此必须在内存中预留部分内存可以为并发时的用户线程使用

-XX:CMSInitatingOccu-pancyFraction

如果当GC标记/清理过程中,用户线程申请内存失败,那么就会启动预案,强制STW进行GC,这种情况我们叫做并发失败

17. G1收集器是什么?

G1是一款主要面向服务端应用的垃圾收集器。以极高概率满足GC停顿时间要求的同时还具备高吞吐量性能特征

  • 在G1收集器出现之前所有的收集器都是针对某一个区域进行GC的,而G1收集器的理念则是可以收集堆内存中任意区域的内存,这个概念叫做回收集,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,能够获得的收益最大
  • G1的堆内存布局与其他收集器有很大的区别,在其他的收集器中的堆内存模型中,使用的是新生代->老年代->永久代/元空间的设计,但是在G1中,它将内存空间划分为了多个Region,这些Region在内存中离散分布,他们既可以是Eden也可以是Survivor,因此在内存排布上更加灵活了,适应了第一点的改造
  • G1中还有一类新增的区域为Humongous区域,它是用来存储大对象的,G1只要认为大小超过了半个Region的大小的对象就称之为大对象,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待

至于到GC,它的核心思路是追踪每个Region里面的价值,优先处理那些回收价值最大的那些Region

初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要

停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际

并没有额外的停顿。

并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆

里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以

后,还要重新处理SATB记录下的在并发时有引用变动的对象。

最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。

筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部


文章作者: 穿山甲
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 穿山甲 !
  目录