前言

对于程序计数器、虚拟机栈、本地方法栈这三个部分而言,其生命周期与相关线程有关,随线程而生,随线程而灭。并且这三个区域的内存分配与回收具有确定性,因为当方法结束或者线程结束时,内存就自然跟着线程回收了。因此本篇文章所讲的有关内存分配和回收关注的是Java堆方法区这两个区域。

如何判断对象已死?

1. 引用计数法

简介

给对象增加一个引用计数器,每当有一个地方引用它时,计数器就+1;当引用失效时,计数器就-1;任何时刻计数器为0的对象就是不能再被使用的,即对象已“死”。 引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个比较好的算法。

但是,在主流的JVM中没有选用引用计数法来管理内存,最主要的原因是引用计数法无法解决对象的循环引用问题。

2. 可达性分析算法

Java并不采用引用计数法来判断对象是否已“死”,而采用“可达性分析”来判断对象是否存活。

简介

此算法的核心思想:通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为“引用链”,当一个对象到 GC Roots 没有任何的引用链相连时(从 GC Roots 到这个对象不可达)时,证明此对象不可用。如图所示:

对象Object5 —Object7之间虽然彼此还有联系,但是它们到 GC Roots 是不可达的,因此它们会被判定为可回收对象。

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

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  2. 方法区中静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中(Native方法)引用的对象

对象在堆上创建之后所持有的引用其实是一种变量类型,引用之间通过赋值构成一条引用链。从GC Roots开始遍历,判断引用是否可达。引用的可达性是判断能否被垃圾回收的基本条件。JVM会据此自动管理内存的分配与回收,不需要开发工程师干预。但在某些场景下,即使引用可达,也希望能够根据语义的强弱来进行有选择的回收,以保证系统的正常运行。

根据引用类型语义的强弱来决定垃圾回收的阶段,我们可以把引用分为强引用、弱引用、软引用和虚引用四类。后三类引用,本质上是可以让开发工程师通过代码方式来决定对象的垃圾回收时机。

下面我们来看下这四种引用类型。

引用类型

1. 强引用(Strong Reference)

最为常见。如Object object=new Object();这样的变量声明和定义就会产生对该对象的强引用。只要对象有强引用指向,并且GC Roots可达,那么java内存回收时,即使濒临内存耗尽,也不会回收该对象。

2. 软引用(Soft Reference)

引用力度弱于”强引用“,用在非必需对象的场景。在即将OOM(内存溢出)之前,垃圾回收器会把这些软引用指向的对象加入回收范围,以获得更多的内存空间,让程序能够继续健康运行。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。

主要用于缓存服务器中间计算结果及不需要实时保存的用户行为等。

在JDK1.2之后,提供了SoftReference类来实现软引用。

3. 弱引用(Weak Reference)

引用强度比前面两者更弱。也是用来描述非必需对象的场景的。被弱引用关联的对象只能生存到下一次垃圾回收之前。当垃圾回收器开始进行工作时,不论当前内存空间是否够用,都会回收掉只被弱引用关联的对象。

在JDK1.2之后提供了WeakReference类来实现弱引用。

4. 虚引用(Phantom Reference)

虚引用也被称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。虚引用必须与引用队列联合使用,当垃圾回收时,如果发现存在虚引用,就会在回收对象 内存前,把这个虚引用加入与之关联的引用队列当中。

在JDK1.2之后,提供了PhantomReference类来实现虚引用。

4种引用类型如图所示:

描述:在房地产交易市场中,某个卖家有一套房子,成功出售给某个买家后引用置为null。这里有4个买家使用4种不同的引用关系指向这套房子。买家buyer1是强引用,如果把seller引用赋值给她,则永久有效,系统不会因为seller=null就触发对这套房子的回收,这是房屋交易市场最常见的交付方式。买家buyer2是软引用,只要不产生OOM,buyer2.get()就可以获取房子对象,就像房子是租来的一样。买家buyer3是弱引用,一旦过户后,seller置为null,buyer3的房子持有时间估计只有几秒钟,卖家只是给买家做了一张假的房产证,买家高兴几秒钟后,发现房子已经不是自己的了。buyer4是虚引用,定义完成后就无法访问到房子对象,卖家只是虚构的房源,是空手套白狼的诈骗术。

强引用是最常用的,而虚引用在业务中几乎很难用到

回收方法区

方法区(永久代)的垃圾回收主要收集两部分内容:废弃常量和无用类。 回收废弃常量和回收Java堆中的对象十分类似。以常量池中字面量(直接量)的回收为例,假如一个字符串"abc"已经进入了常量池中,但是当前系统没有任何一个String对象引用常量池中的"abc"常量,也没有其他地方引用这个字面量,如果此时发生GC并且有必要的话,这个"abc"常量会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。

判定一个类是否是"无用类"则相对复杂很多。类需要同时满足下面三个条件才会被算是"无用的类":

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

JVM可以对同时满足上述3个条件的无用类进行回收,也仅仅是“可以”而不是必然。在大量使用反射、动态代理等场景都需要JVM具备类卸载的功能来防止永久代的溢出。

垃圾回收算法

1. 标记-清除算法

“标记-清除”算法是最基础的收集算法。算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。

“标记-清除”算法的不足主要有两个:

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

2. 复制算法

“复制”算法是为了解决“标记-清除”的效率问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这块内存需要进行垃圾回收时,会将此区域还存活着的对象复制到另一块上面,然后再把已经使用过的内存区域一次清理掉。这样做的好处是每次都是对整个半区进行内存回收,内存分配时也就不需要考虑内存碎片等的复杂情况,只需要移动堆顶指针,按顺序分配即可。此算法实现简单,运行高效。算法的执行流程如下图:

现在的商用虚拟机(包括HotSpot)都是采用这种收集算法来回收新生代。

3. 标记-整理算法

复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。 针对老年代的特点,提出了一种称之为“标记-整理算法”。标记过程仍与“标记-清除”过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象向一端移动,然后直接清理掉端边界以外的内存。流程图如下:

4. 分代收集算法

当前JVM垃圾收集都采用的是"分代收集(Generational Collection)"算法,这个算法并没有新思想,只是根据对象存活周期的不同将内存划分为几块。

一般是把Java堆分为新生代和老年代。在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此我们采用复制算法;而老年代中对象存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清理"或者"标记-整理"算法。

参考资料

JVM GC算法 垃圾收集器

深入理解JVM的垃圾回收机制