从零开始学JVM系列(八):畅聊JVM垃圾收集算法

1,946 阅读18分钟

前言

这篇文章开始就到了大家最感兴趣的垃圾收集器的环节,在之前的文章中,我们知道了对象的创建过程,有就有,在java的世界里也逃不过这个轮回,在创建的对立面就是回收,那么从这篇开篇就开始说说对象的回收

分代收集理论

如今的市面上,有各种形形色色的垃圾收集器,比如:CMS、ParNew、G1以及后面出来的ZGC等等,这些五花八门的垃圾收集器使用的垃圾收集算法大致上可以分为三类复制算法、标记整理算法、标记清除算法,这三个算法都借助了分代收集思想,这种思想没有什么特殊之处,就是根据对象存活周期的不同,将内存分为几块

分代思想的特点就是将分为新生代老年代,然后根据各个年代的特点选择合适的垃圾收集算法

新生代中,每次收集都会有大量的对象(近99%)死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次收集

老年代中,对象的存活几率较高,复制成本太高,而且没有额外的空间给它分配担保,所以必须使用标记整理算法或标记清除算法进行垃圾收集

接下来就来看看这三类垃圾收集算法

垃圾收集算法

标记-清除算法

最早出现也是最基础的垃圾收集算法是标记-清除算法,标记-清除算法共有两个阶段,分为标记清除阶段:标记存活的对象,统一回收所有未被标记的对象 (一般选择这种,也可以反过来,标记所有需要回收的对象),在标记完成后统一回收未被标记的对象,它是最基础的垃圾收集算法,比较简单,但是会带来两个明显的问题:

  1. 效率问题:如果标记的对象太多,那么效率不高
  2. 空间问题:标记清除后,会产生大量不连续的碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找 到足够的连续内存而不得不提前触发另一次垃圾收集动作

image.png

标记-复制算法

为了解决标记-清除算法的效率问题,1969年Fenichel提出了标记-复制算法,它可以将内存分为大小相同的两块,每次使用其中的一块,当这一块内存使用完后,就会将存活的对象复制到另外一块去,然后把使用过的空间一次性清理掉,这样就使每次的内存回收都是对内存区间的一半进行回收,如下图所示

image.png

在1989年,Andrew Appel针对具备朝生夕灭特点的对象,提出了一种更优化的半区复制分代策 略,现在称为Appel式回收。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。

具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。

HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会 被浪费的。

当然,任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此Appel式回收还有一个充当罕见情况的逃生门的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域 (实际上大多就是老年代) 进行分配担保。这就是在上几篇文章中讲过的每一次MinorGC,要去判断老年担保分配担保机制

标记-复制算法的局限性

标记-复制最大的弊端就是浪费内存空间

根据上图所示,我们知道标记-复制算法是把一块内存分成两块,存储的话只会用到其中的一块,也就是说,假设你有500M的内存空间,最多只有250M是能使用的,这很大程度上浪费了内存空间

那既然浪费了大量的内存空间,为什么新生代还要采用标记-复制算法呢?

这就和新生代的特性有关了,存在新生代的对象基本上都是朝生夕死的,也就是说,生命周期很短,经过一次垃圾收集以后,存活着的对象很少,但是老年代不同呀,老年代的对象大多都是存活的对象,通过标记-复制的算法需要复制大量的非垃圾对象,一方面是,复制的开销就会很大,另一方面是老年代的对象基本都很稳定,没必要挪来挪去

所以老年代采用的垃圾回收算法是标记-清除算法或者标记-整理算法,接下来来看标记-整理算法

标记-整理算法

针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的标记-整理算法,其中的标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,如下图所示:

image.png

标记-清除算法标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策:

标记-整理算法的优缺点

缺点

  1. 标记-整理算法的最后是要移动存活对象,但是如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行

优点

  1. 但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过空闲列表来解决内存分配问题 (计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)

所以,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂

从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。即使不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的

HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的,这也从侧面印证这点。

折中方案

还有一种折中方案可以不在内存分配和访问上增加太大额外负担,做法是让JVM平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。

前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。

垃圾收集算法实现的关键点:怎么找出存活的对象?

上面说的三种垃圾收集算法无论是哪种,首先要做的都是要找出存活对象,市面上常见的判断存活对象的方法有两种:

  • 引用计数法
  • 可达性分析算法

引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1,当引用失效,计数器就减1,任何时候计数器为0的对象就是不可能再被使用的

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,最主要的原因是它很难解决对象之间的循环依赖问题

引用计数法例子
public class ReferenceCountingGC {

    Object instance = null;

    public static void main(String[] args) {
        ReferenceCountingGC referenceCountingGC_A = new ReferenceCountingGC();
        ReferenceCountingGC referenceCountingGC_B = new ReferenceCountingGC();

        referenceCountingGC_A.instance = referenceCountingGC_B;
        referenceCountingGC_B.instance = referenceCountingGC_A;

        referenceCountingGC_A = null;
        referenceCountingGC_B = null;
    }
}

案例中:referenceCountingGC_AreferenceCountingGC_B指向的对象中instance属性指向着对方的对象,着就是相互引用,后面把外部引用referenceCountingGC_A、referenceCountingGC_B置为空,也就是没有外部引用了,但是相互引用,导致这两个计数器值不为0,GC就无法回收

为了解决循环依赖问题,提出了第二种判断对象存活的方法,同时也是目前主流的判断方法可达性算法

可达性算法

GC Roots对象作为起点,从这些节点开启向下搜索引用的对象,找到的对象都标记成非垃圾对象,其余未标记的对象都是垃圾对象

image.png

GC Roots对象判断标准

在Java语言中,可作为GC Roots的对象包含以下几种:

  • 虚拟机栈 (栈帧中的本地变量表) 中引用的对象局部变量表引用的所有对象
  • 方法区中静态属性引用的对象引用方法区该静态属性的所有对象
  • 方法区中常量引用的对象引用方法区中常量的所有对象
  • 本地方法栈中(Native方法)引用的对象引用Native方法的所有对象
  • 所有被同步锁(synchronized关键字)持有的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
GC Roots对象的理解
  1. 第一种是局部变量表中引用的对象:,我们正常创建一个对象,对象会在上开辟一块空间,同时会将这块内存空间的地址作为引用保存到栈帧中,如果对象生命周期结束了,那么引用就会随着栈帧出栈,因此如果在虚拟机栈中有引用,就说明这个对象还是有用的,这种情况是最常见的。

  2. 第二种是在类中定义了全局的静态的对象,也就是使用了static关键字:,由于虚拟机栈是线程私有的,所以这种对象的引用会保存在共有的方法区中,显然将方法区中的静态引用作为GC Roots是必须的。

  3. 第三种是常量引用,就是使用了static final关键字,由于这种引用初始化之后不会修改,所以方法区常量池里的引用的对象也应该作为GC Roots

  4. 第四种是在使用JNI技术时,有时候单纯的Java代码并不能满足我们的需求,我们可能需要在Java中调用C或C++的代码,因此会使用native方法,JVM内存中专门有一块本地方法栈,用来保存这些对象的引用,所以本地方法栈中引用的对象也会被作为GC Roots

常见引用类型

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可 达,判定对象是否存活都和引用离不开关系。所以借着可达性分析算法,再来看看Java里面的引用是怎么一回事:

在JDK1.2以前,Java中引用的定义很传统: 如果引用类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。 这种定义有些狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态

在JDK1.2之后,Java对引用的概念做了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)四种,这四种引用的强度依次递减

强引用:普通的变量引用

强引用是最传统的引用的定义,是指在程序代码之中普遍存在的引用赋值,无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

public static User user = new User();

软引用:描述一些还有用,但非必须的对象

将对象用SoftReference软引用类型的对象包裹,正常情况下不会被回收,但是GC完之后发现释放不出空间存放新的对象,则会把这些软引用的对象回收掉

软引用可用来实现内存敏感的高速缓存,例如:浏览器页面的回退操作,按后退按钮时,这个后退时显示的网页内容是重新请求还是从缓存中取出来?

1、如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览的页面就需要重新请求 2、如果将浏览过的页面存储到内存中,就会造成内存的大量浪费,导致OOM

基于以上两点,我们可以把当前网页内容用软引用存储,当做缓存,如果发生GC后发现空间还是不够,就把软引用指向的内存空间释放,如果够,那么不回收。

public static SoftReference<User> user = new SoftReference<User>(new User());

弱引用:描述那些非必须对象

将对象用WeakReference软引用类型的对象包裹,弱引用强度比软引用更弱一些,被弱引用关联的对象只 能生存到下一次垃圾收集发生为止。GC会直接回收掉,几乎不用,但是在ThreadLocal中有使用

public static WeakReference<User> user = new WeakReference<User>(new User());

虚引用

虚引用也称之为幽灵引用或者幻影引用,他是最弱的一种引用关系,几乎不用,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

public static PhantomReference<User> user = new PhantomReference<User>(new User());

逃脱回收的手段-finalize方法

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

根据可达性算法进行第一次标记并进行一次筛选,当对象没有覆盖finalize方法时,直接被回收

如果对象在finalize方法中重新与引用链上的任意对象建立联系。比如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出即将回收的集合,如果这个时候它还没逃脱,就真的要被回收了。

注意:一个对象的finalize方法只会执行一次,也就是拯救自我的机会只有一次

案例:

public class OOMTest {
    public static void main(String[] args) {
        List<Object> list = new ArrayList<Object>();
        int i = 0;
        int j = 0;
        while (true) {
            list.add(new User(i++, UUID.randomUUID().toString()));
            new User(j--, UUID.randomUUID().toString());
        }
    }
}

public class User {

    private int age;
    private String name;
    User() {
    }
    User(int age, String name) {
        this.age = age;
        this.name = name;
    }
    @Override
    protected void finalize() throws Throwable {
        System.out.println("关闭资源: userId = " + age + "即将被回收");
    }
}

首先在OOMTest类的main方法中不断的创建User对象,其中一部分对象是被list指向,也就是有引用的对象,特点是age是正数,另外一部分对象是无用对象,也就是没有引用指向的对象,特点是age是负数,我们来看下输出结果:

image.png

首先输出的内容是在finalize方法中,验证了上文说的一点,被回收的对象在回收之前会调用finalize方法,根据我们说的,对象还可以在finalize方法中自救,如下图所示:

在User类的finalize()方法中加上引用:
@Override
    protected void finalize() throws Throwable {
        OOMTest.CLASS_List.add(this);
        System.out.println("关闭资源: userId = " + age + "即将被回收");
}

在OOMTest类中加上类变量
public static List<Object> CLASS_List = new ArrayList<Object>();

回收方法区

Full GC不仅会回收堆中的垃圾对象还会回收方法区中的类元信息,那如何判断一个类的类元信息是无用的呢?需要同时满足下面三个条件才能算是无用的类

  1. 该类所有的实例已经被回收,也就是Java堆中不存在该类的任何实例
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

第一点很好理解:就是关于这个类的实例不存在,都已经被回收

第二点得稍微提一下:我们程序中的类大多数都是由Java自带的三个类加载器加载,这三个类加载器几乎不会被回收,也就是说,由这三个类加载器加载的类几乎不会回收,所以我们可以发现,经过Full GC之后,堆内存发生了很大的变化,但是方法区的内存空间变化很小

第三点:像下方TestJDKClassLoader类对应的java.lang.Class对象被引用了,所以也无法回收该类的类元信息

image.png

根据以上几点综合下来,可以确定,回收类元信息是很非常不容易的一件事情,所以平常在Full GC之后,发现方法区对应的空闲内存不会发生什么大的变化

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是被允许,而并不是和对象一样,没有引用了就必然会回收。

关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClass-Loading、-XX: +TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版的虚拟机支持。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,保证不会对方法区造成大的内存压力

本文总结

好啦,以上就是这篇文章的全部内容,这篇文章篇幅较短,重点讲了以下几块内容

  1. 垃圾收集算法的支撑-分代收集理论
  2. 三种垃圾回收算法以及各种算法的优缺点
  3. 介绍各种各样的引用,从引用强度看依次是强引用、软引用、弱引用、虚引用
  4. 介绍对象逃脱回收的一种手段-finalize方法
  5. 满足回收方法区中的类元信息的条件有哪些

絮叨

最后,如果感到文章有哪里困惑的,请第一时间留下评论,如果各位看官觉得小沙弥我有点东西的话 求点赞👍 求关注❤️ 求分享👥 对我来说真的 非常有用!!!如果想获取海量Java资源好用的idea插件、简历模板、设计模式、多线程、架构、编程风格、中间件......,可以关注微信公众号Java百科全书,最后的最后,感谢各位看官的支持!!!