> 文档中心 > 详述JVM垃圾收集器(一)垃圾收集算法

详述JVM垃圾收集器(一)垃圾收集算法


一、关于垃圾收集器

我们在聊具体的垃圾收集器前,我们先来聊聊几个问题。
1.哪些内存需要被回收?
我们知道,Java内存运行时数据区的各个部分,如图。
在这里插入图片描述

其中程序计数器、虚拟机栈、本地方法栈随着线程而生,线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊的执行着入栈和出栈的操作,所以这几个区域的内存分配和回收都具有确定性,在这几个区域内就不需要考虑如何回收的问题。因为当方法结束或者线程结束的时候,内存自然而然的就跟着回收了。
Java堆和方法区这两个区域则有着很显著的不确定性,垃圾收集器所关注的正是这部分内存该如何管理。
方法区的回收:
方法区的垃圾收集主要回收两部分的内容:
  1.废弃的常量
  2.不再使用的类
判定一个常量是否“废弃”还是相对简单,但是要判定一个类型是否属于“不再使用的类”的条件就非常苛刻了。需要同时满足以下三个条件:
  1.该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
  2.加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  3.该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

2.如何判断哪些对象可以被回收?
堆中存放了几乎所有对象的实例,垃圾回收器对堆进行回收前,第一步得判定哪些对象可以被回收,也就是哪些对象不可能被任何途径使用了。实现判断的方法有几种常见的

  • 引用计数法
      实现原理:即,在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一,任何时刻计数器为零的对象就是不可能再使用的。
      看起来这种计数器简单,判断效率也高,只是占用了一些额外的内存空间来进行计数。但是在java领域主流的Java虚拟机中都没有采用引用计数法来管理内存。因为,这种方法虽然看起来简单,但是有很多额外的情景需要考虑,必须要配合大量额外处理才能保证正确的工作。
    详述JVM垃圾收集器(一)垃圾收集算法
      比如一个非常简单的循环引用的问题,对象A引用对象B,对象B引用对象A,这两个对象没有和其他任何对象有引用关系,在引用计数法看来,其引用关系都不为0,所以都不会被回收。但实际上这两个对象都已经不可能再被访问了。
  • 可达性分析法
      这个是最常用的判断对象是否存活的方法,当前主流的商用程序语言Java、C#等内存管理子系统都是通过可达性分析算法来判定对象是否存活的。
      实现原理:即,通过一系列被称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走的路径被称为“引用链”(reference Chain),如果某个对象到GC Root间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象是不可达的,因此他们会被判定为可回收的对象。
    如图所示:
    在这里插入图片描述
    对象Object A、Object B、Object C、Object D最终都是和GC Root相关联的,所以标记为存活的对象,不会被回收。但是对象Object E、Object F、Object G虽然互相有引用,但是与GC Roots之间没有任何的引用,所以被标记为可回的对象。
     那么对于java来说,哪些可以做为GC Roots的对象呢?
     (1)在虚拟机栈(栈帧中的本地变量表)中引用的对象,例如当前正在运行的方法所使用到的参数、局部变量、临时变量等。
     (2)在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
     (3)在方法区中常量引用的对象,例如字符串常量池里的引用。
     (4)在本地方法栈中JNI引用的对象
     (5)Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象等,还有系统类加载器。
     (6)所有被同步锁(synchronized关键字)持有的对象
      …

  我们再来聊一聊引用
  无论是上面的可达性分析法,还是引用计数法,判定对象的存活,都与“引用”离不开关系。jdk1.2之前,对于引用的定义是“如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用”。jdk1.2之后,Java对引用的概念进行了扩充,将引用分为了以下四种引用类型:
  (1)强引用
    这种引用是最传统“引用”的定义,是指在程序代码中普遍存在的引用赋值,即类似Object A= new Object(),这样的引用关系。在任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  (2)软引用
    这种引用是用来描述一些还有用,但是非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK中,提供了SoftReference类来实现软引用,在垃圾回收器回收前,和强引用一样,可以被程序正常访问,但是需要通过软引用对象间接访问,需要的话也能重新使用强引用将其关联。这中引用适合用来做内存敏感的高速缓存。
  注意:SoftReference对象是用来保存软引用的,但它同时也是一个Java对象。所以,当软可及对象被回收之后,虽然这个SoftReference对象的get()方法返回null,但SoftReference对象本身并不是null,而此时这个SoftReference对象已经不再具有存在的价值,需要一个适当的清除机制,避免大量SoftReference对象带来的内存泄漏。
  (3)弱引用
    这种引用比软引用更弱一些,这种引用遇到GC无论有没有被使用,都会被回收。在JDK中,提供了WeakReference类来实现弱引用。
  (4)虚引用
    这种引用是最弱的一种引用,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个对象的实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK中,提供了PhantomReference类来实现虚引用

二、垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”和“追踪式垃圾收集”两大类。正如我们之前说的,引用计数式垃圾收集在我们现在主流的Java虚拟机中并没有涉及,所以我们讨论的都是属于追踪式垃圾收集。

  1. 标记-清除算法
      这个算法是最早出现的,也是最基础的垃圾收集算法。顾名思义,该算法分为两个阶段,标记与清除。首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记所有存活的对象,统一回收未被标记的对象。
    缺点:
    (1)执行效率不稳定,如果Java堆中包含大量的对象,而且其中大部分是需要被回收的,这是必须进行大量标记和清除操作,导致标记和清除两个过程的执行效率都随对象的数量增加而降低。
    (2)内存空间碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多会导致以后程序在运行过程中需要分配较大对象的时候无法找到足够大的连续内存空间而不得不提前触发另一次垃圾收集动作。
    在这里插入图片描述
    如图所示,当回收后产生了大量的不连续的内存碎片,当一个新的大对象需要分配内存的时候,会发现没有找不到一个足够大的连续的内存空间,虽然未使用的空间总量大,但是仍然会触发另一次垃圾回收。
  2. 标记-复制算法
      这是为了解决标记-清除算法缺点提出来的一款垃圾回收算法。最早是采用“半区域复制”这样的方式来实现。即将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存使用完了后,就将还存活着的对象复制到另一块内存中,然后再把内存空间一次性清除掉。
    在这里插入图片描述
    如图所示,“半区域复制”这样实现的垃圾回收算法缺点显而易见
    (1)内存利用效率太低,只能利用一半的内存
    (2)如果内存中出现对象大都是存活的情况,将会产生大量内存间复制的开销
      在1989年,Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更为优化的半区复制分代策略,现在称为“Appel式回收”。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。
      具体做法就是,把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生内存收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已使用过的那一块Survivor空间。HotSpot虚拟机默认的Eden和Survivor的大小默认时8:1,也即每次新生代中可用内存空间为整个新生代容量的90%,只有另一个Survivor空间,即10%的新生代是“浪费”的。如图:
    在这里插入图片描述
      当然,有些特殊的情况,比如一个大的对象存活,而survivor空间不足以存放,或者当一个对象经历多次GC依然存活的情况下,会有一个“逃生门”的设计。会依赖其他的内存空间(实际上大多就是老年代)进行分配担保。
  3. 标记-整理算法
      这种算法,是针对老年代对象的存亡特征,这种算法的标记过程和标记-清楚算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存
    在这里插入图片描述
    关于是否移动存活对象:
      如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活的区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象必须全程暂停用户引用程序才能进行-Stop The World(新的垃圾收集器ZGC和Shenandoah采用了读屏障实现了整理过程与用户线程同步进行)。
      如果不移动的话,内存的空间碎片化问题就只能依赖于更为复杂的内存分配器和内存访问器来解决。这样内存的访问将会增加额外的负担,而内存的访问是用户程序最频繁的操作,如果在这个环节上增加了额外的负担必然会直接影响应用程序的吞吐量。
      由此可见,是否移动都会各自的缺点。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至不需要停顿,但是从整个程序的吞吐量来看,移动对象会更加划算。即使不移动对象会使得收集器的效率提升一些,但是因为内存分配和访问要比垃圾收集器频率要高的多。这部分的耗时增加会将总体的吞吐量拉低。
    注:
      这里还有一种“和稀泥式”的解决方案,即:暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。(CMS收集器面临空间碎片过多时就是采用的这种处理方法)