JVM垃圾回收

概念

垃圾回收(Garbage Collection)是JVM提供的一种用于在空闲时间不定时回收无任何对象引用的对象占据的内存空间的一种机制。

注意:垃圾回收器回收的是无任何引用的对象占据的内存空间而不是对象本身。换言之,垃圾回收只会负责释放那些对象占有的内存。对象是个抽象的词,包括引用和其占据的内存空间。当对象没有任何引用时其占据的内存空间随即被收回备用,此时对象也就被销毁。

引用介绍

如果Reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。引用又分为强引用,软引用,弱引用,虚引用,引用强度依次逐渐减弱。

  • 强引用(Strong Reference):如“Object obj = new Object()”,这类引用是Java程序中最普遍的。只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用(Soft Reference):它用来描述一些可能还有用,但并非必须的对象。在系统内存不够用时,这类引用关联的对象将被垃圾收集器回收。JDK1.2之后提供了SoftReference类来实现软引用。
  • 弱引用(Weak Reference):它也是用来描述非须对象的,但它的强度比软引用更弱些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。
  • 虚引用(Phantom Reference):最弱的一种引用关系,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的是希望能在这个对象被收集器回收时收到一个系统通知。JDK1.2之后提供了PhantomReference类来实现虚引用。

垃圾对象的确定

任何一种垃圾回收算法一般要做以下2件基本的事情:

  • 找到所有存活对象
  • 回收被无用对象占用的内存空间,使该空间可被程序再次使用

那么如何找到堆中哪些是存活对象,哪些是无用对象,通常有以下几种算法来确定。

引用计数算法(Reference Counting Collector)

堆中每个对象都有一个引用计数器,当一个对象被创建并初始化赋值后,该计数器数值设置为1。每当有一个地方引用它时,计数器就加1,如a=b,b被引用,则b的引用计数器加1。当引用失效时,如一个对象的某个引用超过了生命周期或者被设置为一个新值时,则计数器值减1。任何引用计数器为0的对象都可以被当作垃圾回收。当一个对象被垃圾回收时,它引用的任何对象计数器减1。

  • 优点:执行简单,判定效率高,与程序并发运行
  • 缺点:难以检测出对象之间的循环引用。引用计数器增加了程序执行的开销。现大多数JVM采用下面的根搜索算法

根搜索算法(Tracing Collector)

这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下一级一级地搜索,并沿途标记路径上的每个节点,搜索所走过的路径称为引用链(Reference Chain),这时引用链上的节点即是存活对象。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
根搜索算法
图中灰色标记的为存活对象,黑色标记的垃圾对象,即GC Roots不可达的对象。
哪些对象可以作为GC Roots对象,主要有以下几种:

  • 虚拟机栈中引用的对象(栈帧中的本地变量表)
  • 方法区中的常量引用的对象
  • 方法区中的类静态属性引用的对象
  • 本地方法栈中JNI(Native方法)的引用对象

由于这些GC Roots对象不能被其他对象引用,所以也就不会出现引用计数算法中循环引用的问题了。在标记阶段有几个地方是需要注意的:

  • 开始标记前,需要暂停应用线程(STW),以便JVM可以准确地进行标记工作
  • 暂停时间地长短取决于存活对象的多少,而不是堆内对象的多少或堆的大小。因此,调高堆的大小并不会影响到标记阶段的时间长短。
  • 在根搜索算法中,要真正判定一个对象是否存活,至少要经历两次标记过程:
  • 如果对象在进行根搜索后,发现没有与GC Roots相连接的引用链,那么它会被第一次标记并且进行一次筛选。筛选的条件是此对象是否有必要执行finalize()方法。虚拟机会将以下两种情况是为没有必要执行finalize()方法:1、对象没有覆盖finalize()方法;2、finalize()方法已被虚拟机调用过。
  • 如果对象被判定为没有必要执行finalize()方法,则直接将其回收。反之,如果该对象被判定为有必要执行finalize()方法,那么这个对象会被放置在一个名为F-Queue队列中,并由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行该对象的finalize()方法。然后,GC将堆F-Queue中的对象进行第二次小规模标记,再次判断该对象是否可达。如果想要让该对象存活下来,就需要在finalize()方法中让该对象重新引用GC Roots链上的任何一个对象,建立起关联即可。但如果对象还没有关联到任何GC Roots链上的引用,即该对象不可达,那么它就会被回收。finalize()方法是该对象判定为存活的最后一次机会,它最多只会被系统自动调用一次。

垃圾回收算法

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

标记-清除算法是现代垃圾回收算法的思想基础。CMS垃圾回收器中的old gc就是基于标记-清除算法实现的。标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段

  • 标记阶段:首先通过根节点(GC Roots),标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象
  • 清除阶段:清除所有未被标记的对象

标记-清除算法
在标记清除期间内应用程序会暂停运行,发生STW事件。

缺点:

  • 效率比较低,需要遍历全堆的存活对象,导致STW事件比较长
  • 清除后的空闲内存不是连续的,难以为大对象分配内存空间。同时JVM不得不维护一个内存的空闲列表。

复制算法(Copying)

将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中。之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。
复制算法

注意:

  • 复制算法不适用于存活对象较多的场合,如老年代。相反,适合于新生代的GC。

缺点:

  • 空间浪费。复制算法使得每次都只对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况。只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

该收集算法适用于来回收新生代内存空间。新生代中的对象大多数的都是存活率较低的,所以并不需要按照1:1比例来划分内存空间,而是将内存分为一块比较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。当Survivor空间不够用时,需要依赖老年代进行分配担保,所以大对象直接进入老年代。
老年代不适合用复制算法进行垃圾回收的原因有2个:

  • 当对象的存活率较高时就要进行较多的复制操作,效率就会降低
  • JVM不会对老年代的空间进行划分,此时就需要额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况

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

该算法适用于存活对象较多的场合,如老年代。它在标记-清除的基础上做了一些优化。和标记-清除算法一样,标记-整理算法也需要从根节点开始,对所有可达对象做一次标记,然后将所有存活对象压缩到内存的一端,接着再清理边界外的所有空间。
标记整理算法

具体过程:

  • 标记:这个阶段与标记-清除算法的第一个阶段是一样的,均是遍历GC Roots,然后将存活的对象标记。
  • 整理:移动所有存活对象,且按照内存地址次序依次排列,然后将末端内存地址后的内存全部回收。

优点:

  • 弥补了标记-清除算法当中,内存区域分散的缺点。当我们需要给新对象分配内存时,JVM只要持有一个内存的起始地址即可。相比维护一个空闲列表,少了很多开销。
  • 消除了复制算法当中,内存减半的高额代价。

缺点:

  • 效率不高,低于复制算法。不仅要标记所有存活对象,还要整理所有存活对象的引用地址。

分代收集算法(Generational Collection)

分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般是把Java堆分为新生代和老年代:短命对象归为新生代,长命对象归为老年代。

  • 少量对象存活,适合复制算法:在新生代中,每次GC时都发现有大批对象被回收,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成GC。
  • 大量对象存活,适合用标记-清理或标记-整理算法:在老年代中,因为对象存活率高、没有额外空间对他进行分配担保,就必须使用标记-清理或标记-整理算法进行GC。

注意:老年代的对象中,有一小部分是因为在新生代回收时,老年代做担保,进来的对象;绝大部分对象是因为很多次GC都没有被回收掉而进入老年代。

总结

标记-清除算法、复制算法、标记-整理算法相同点:

  • 都是基于根搜索算法去判断一个对象是否应该被回收。支撑根搜索算法可以正常工作的理论依据是语法中变量作用域的相关内容。因此,要想防止内存泄露,最根本的办法就是掌握好变量作用区域。
  • 在GC过程中,它们都要暂停应用程序(STW)。

那么这三个算法之间的区别如下:

  • 效率:复制算法 > 标记-整理算法 > 标记-清除算法
  • 内存整齐度:复制算法 = 标记-整理算法 > 标记-清除算法
  • 内存利用率:标记-整理算法 = 标记-清除算法 > 复制算法

老年代和新生代的区别(题外话)

  • 新生代对象的生命周期短,老年代对象的生命周期长
  • 新生代对象回收采用复制算法,老年代对象回收采用标记-清除或标记-整理算法
  • 新生代空间满了就执行Minor GC,一般采用复制算法;老年代空间就执行Full GC,一般采用Mark-Compact算法清理