不可不知的垃圾收集算法

在C,C++语言中,程序的内存使用空间都是靠程序员手动进行分配和回收的。但是在Java语言中,垃圾回收都是交给虚拟机自动完成。

1.理解垃圾收集

对于垃圾收集(Garbage Collection,GC),我们必须要提出灵魂三问:

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

虽然说内存的动态分配与内存回收技术已经相当成熟,一切看起来都进入了“自动化”时代,那么为什么还要去了解GC和内存分配呢?答案是:当需要排查各种内存溢出,内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,那么我们就需要对这些所谓“自动化”的技术实施必要的监控和调节。

当然我们了解到程序计数器,虚拟机栈,本地方法栈这3个区域都是跟随着线程的生命周期,线程生则生,线程死则死。这几块内存分配基本上都是在类结构确定下来时就已经知道了,所以这几个区域内存的分配和回收都具有确定性,无需过多考虑。但是Java堆和方法区就不一样,这部分内存是动态分配和回收的,后续我们讨论的垃圾收集器关注的就是这方面的内存区域。

2.如何判断GC垃圾

2.1引用计数法

算法描述:给对象添加一个引用计数器,每当有一个地方引用它的时候,计数器值+1;当引用失效时,计数器值-1;任何时刻计数器的值为0的时候,就是不能被再使用,就可以判定为垃圾。
算法优点:实现简单,判定效率高。
算法缺点:很难解决对象之间的循环引用。
算法说明:大部分情况下,还是非常nice的算法,我们熟悉的Python语言就是用这种算法。但是主流的Java虚拟机里面并没有选用引用计数算法来管理内存。

2.2可达性分析

算法描述:基本思路就是通过一系列“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时(就是从GC Roots到这个对象不可达)则认为对象是不可用的。
算法说明:目前在主流的商用语言中(Java,C#)都使用可达性分析来判定对象是否存活的。

不可不知的垃圾收集算法

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

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

3.理解四种引用

无论是引用计数器算法还是可达性分析算法,判定对象是否存活都与“引用”有关。在JDK1.2之后,Java对引用概念进行了扩充,将引用分为了以下4种:
1)强引用
程序代码中普通存在的,通过new关键词创建的对象。类似“Object obj = new Object()”这类引用,只要强引用还存在,垃圾收集器永远都不会回收掉被引用的对象。
2)软引用
用来描述一些还引用但非必须的对象。对于软引用关联的对象,在系统将要发生内存溢出异常之前,将会把这些对象列入回收范围之中进行二次回收。如果这次回收还没有足够的空间,则抛出OOM。通过SoftReference来实现软引用。
3)弱引用
也是用来描述非必需的对象的,但是它的强度比软引用更弱些。被弱引用关联的对象只能生存到下一次发生垃圾收集之前,当垃圾收集进行时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。通过WeakReference类来实现弱引用。
4)虚引用
也被称为幽灵引用或者幻影引用,是最弱的一种引用关系。一个对象有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取对象实例。为一个对象设置虚引用的目的就是在这个对象被收集器回收时收到一个系统通知。通过PhantomReference来实现虚引用。

4.方法区垃圾回收

现在这里说明一下,方法区与堆一样也是线程共享的内存区域,但是在JDK1.7中与永久代关联,在JDK1.8中永久代被废弃,改称为元空间(MetaSpace)。
方法区主要用于存储类信息、常量池、方法数据、方法代码、符号引用等。
方法区的垃圾回收主要针对两部分内容:废弃常量和无用的类
举个例子:以常量池中字面量的回收为例

假如一个字符串“abc”已经进入了常量池中,但当前系统中没有任何一个String对象叫做“abc”,也就是说没有任何String对象应用常量池中的“abc”常量,换言之,没有其他地方引用这个字面量,如果这时候发生了内存回收,而且必要的话,这个“abc”常量就会被系统清理出常量池。常量池中的其他类(接口),方法,字段的符号引用与此类似。

判定一个常量是否为“废弃常量”比较简单,但是要判断一个类是否为“无用类”则条件比较苛刻。什么条件下才能算是“无用类”呢?

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

虚拟机可以对满足上述3个条件的无用类进行回收,这里仅仅是“可以”,而不像是对象一样,不使用了就一定会被回收。

在大量使用反射,动态代理,CGLib等ByteCode框架,动态生成JSP以及OSGI这类频繁自定义ClassLoader的场景都需要虚拟机具有类卸载功能,以保证方法区不会溢出。

5垃圾收集算法

5.1标记-清除算法

标记清除(Mark-Sweep)算法可谓是最基础的垃圾手机算法。为什么说是最基础的垃圾收集算法呢?是因为后续的垃圾收集算法都是在其基础之上改进发展而来。
该算法分为两个阶段:标记清除。首先标记处所有需要回收的对象,在标记完成之后再统一回收所有标记的对象。
缺点:主要有两个不足之处,一是效率问题,标记和清除的过程效率都不高;二是空间问题,标记清除之后会产生大量不连续的内存空间,这会导致后续程序运行过程中申请不到较大内存空间而又提前触发另一次的垃圾收集动作。

标记清除算法执行过程如图所示:
不可不知的垃圾收集算法

5.2复制算法

为了解决效率问题,“复制”算法出现了。它将内存划分为大小相等的两块,每次只使用其中的一块。当这一块内存使用完了之后,就将还存在的对象统一复制到另一块内存上面,然后把使用过的内存空间进行一次清理。这样每次只对半区内存进行垃圾回收,也不用考虑内存碎片的问题。只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

复制算法的执行示意图如下:
不可不知的垃圾收集算法

咱们所知的新生代的内存回收就是采用这种算法。IBM公司专门研究表明,新生代中98%的对象都是“朝生夕死”,所以并不需要将内存划分为1:1比例的内存空间,而是将内存划分为一块较大的Eden区和两块较小的Survivor区,每次只是用Eden区和其中一块Survivor区。当进行垃圾回收时,将Eden区和刚才使用的Survivor区中存活的对象一次性复制到另一个未使用的Survivor区,最后清理掉Eden区和刚才使用过的Survivor区。

5.3标记-整理算法

我们说,复制算法一般收集“朝生夕死”的垃圾对象,但是当对象的存活率较高的时候,复制算法的回收效率就会大打折扣,而且更为关键的是,如果不想浪费50%的空间,还必须有额外的空间来提供分配担保,以应对对象都100%的存活的极端情况。所以老年代就不能使用复制算法,而是需要接下来介绍的:标记-整理算法
标记过程与与标记-清除算法一样,但后续步骤不是直接对可回收的对象进行回收而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
标记整理的算法示意图如下:

不可不知的垃圾收集算法

5.4分代收集算法

目前商用的虚拟机的垃圾收集算法都采用分代收集(Generational Collection)算法,这种算法并没有什么新鲜的思想,只是根据对象的存活周期将内存划分为不同的几块。
Java堆内存的划分新生代,老年代。而新生代中由包含了Eden区和两个大小相等的Survivor区。新生代中的对象都是高死亡率的对象,而且有一个Survivor区做分配担保,目前来说采用复制算法最适合不过;而老年代呢,对象的死亡率较低,而且没有额外的内存做分配担保,故只能采用标记-清除或者标记-整理算法。