前言
垃圾收集,在JVM的世界中,是属于非常重要的一环。
为了实现控制反转设计原则,java通过一种方式,依赖注入,将java对象的生成和销毁都交给了我们的java程序自己解决,也就是说,java程序不会控制自己对象的生命周期,那么如果我们的JVM没有管理对象的生成和销毁,那么就会导致我们的程序生成的java对象堆满了我们的JVM内存,导致内存不断的出现内存溢出的情况。
于是,JVM有了垃圾收集这一环节,目的自然就是为了保证我们的JVM不出现内存溢出等问题。
在JVM中,程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。
每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。
而Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。
垃圾收集器所关注的正是这部分内存该如何管理。
因为我们的垃圾收集器主要是为了对java对象的回收而出现的,当然,java类的类对象也属于我们回收的范畴。那么,既然是对java对象来进行回收,那么我们首先要知道一件事,那就是要先确定java对象是否存活,如果java对象还存活,那自然就不能进行回收了。
一. 判断java对象是否存活
不同的虚拟机可能会有不同的方案来判断他的对象存活与否,而现在业界用的比较多的计算对象是否存活的算法主要如下:
- 引用计数法。
- 可达性分析算法。
具体讲解如下。
1. 引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为0的对象就是不可能再被使用的。
<table><tr><td>
优点:
- 可即刻回收垃圾:在引用计数法中,每个对象始终都知道自己的被引用数(就是计数器的值)。当被引用数的值为 0 时,对象马上就会把自己作为空闲空间连接到空闲链表。也就是说,各个对象在变成垃圾的同时就会立刻被回收,所以内存空间不会被垃圾占领。
- 最大暂停时间短:在引用计数法中,只有当通过mutator(应用程序)更新指针时程序才会执行垃圾回收。也就是说,每次通过执行mutator(应用程序)生成垃圾时这部分垃圾都会被回收,因而大幅度地削减了 mutator(应用程序)的最大暂停时间。
</td></tr></table>
<table><tr><td>
缺点:
- 计数器值的增减处理繁重:在引用计数法中,每当指针更新时,计数器的值都会随之更新,因此值的增减处理必然会变得繁重。这个问题可以使用 延迟引用计数法 来解决,但是延迟计数器值的增减,垃圾不能马上得到回收,这样一来垃圾就会压迫堆,我们也就失去了引用计数法的一大优点——可即刻回收垃圾
- 计数器需要占用很多位:用于引用计数的计数器最大必须能数完堆中所有对象的引用数。打个比方,假如我们用的是32位机器,那么就有可能要让2的32次方个对象同时引用一个对象。考虑到这种情况,就有必要确保各对象的计数器有32位大小。也就是说,对于所有对象,必须留有32位的空间。这个问题可以使用 Sticky引用计数法 来解决,或者使用 1位引用计数法 来解决,这两个办法都是减少计数器位宽的思路来解决问题,但是可能会导致计数器溢出的情况。
-
循环引用无法回收:无法回收环状引用数据结构,包括自引用结构。这会造成一个问题,即如果这个整体在程序中是不可达的时候,但是回收器依然无法回收这部分内存,因为它们的引用计数将永远不会为0。
</td></tr></table>
但是在java中,我们主要还是把方向对准了循环引用无法解决的问题,那么,接下来我们来研究一下这个问题。
1.1 环状引用数据结构
这个环状引用数据结构,也就是我们经常讲的循环引用,这是在使用引用计数法的时候,经常会出现的一种情况。
通过我们的java代码,可以更简单的了解这个情况。
public static void main(String[]args){
Object object1 = new Object();
Object object2 = new Object();
object1 = object2;
object2 = object1;
object1 = null;
object2 = null;
}
这样就能看出来,当我们的两个对象object1和object2互相引用的时候,如果是使用引用计数法的来判断对象的存活与否,那么就会看到两个对象到程序执行完后,它们的计数器的值还是1,这也就意味着垃圾回收器是无法回收这两个对象的。
不论是在应用程序还是运行时系统中,循环引用而导致出现的环状数据结构都是非常普遍的,如双向链表或环状缓冲区。
1.2 自引用结构
这种结构,一般都是在c语言中出现的多,主要是在当实现树结构以及树的子节点的时候,就需要该结构体包含自身类型的成员变量。
这种情况,就会导致自引用结构的出现,由于这种结构在java中涉及的几乎没有,因此不多做赘述。
当然,自引用结构其实也就是环状数据结构的一种。
1.3 如何解决循环引用问题
当出现环状引用结构或者自引用结构的时候,导致垃圾回收器无法回收这部分内存,为了解决这个问题,业界出现的最普遍的最广泛认可的是试验删除算法。
该算法无需使用后备的追踪式回收器来进行整个存活对象图的扫描,相反它将注意力集中在可能会因删除引用而产生环状垃圾的局部对象图上。
<table><tr><td>
试验删除算法的思想是:
- 在环状垃圾指针结构内部,所有对象的引用计数都由其内部对象之间的指针产生。
- 只有在删除某一对象的某个引用之后,该对象的引用计数扔大于零时,才有可能出现环状垃圾。
有了这两条,可以使用部分追踪(partial tracing)从一个可能是垃圾的对象开始进行子图追踪。对于每一个候选垃圾对象,算法将对其进行试验删除,从而移除由内部指针产生的引用计数。追踪完成后,如果某个对象的引用计数仍然不为0,那么肯定存在一个外部对象引用了该对象。
</td></tr></table>
使用recycler算法可以实现这个思想,它主要分为以下三个阶段:
- 回收器从某个可能是环状垃圾成员的对象出发进行子图追踪,同时减少由内部指针产生的引用计数。算法通过 三色标记法(后面会讲到) 将遍历到的对象设为灰色。
- 对子图中所有对象进行检测,如果某一对象的引用计数不是零,则该对象必然被子图外的其他对象引用。此时需要对第一阶段的试验删除操作进行修正,算法将存活的灰色对象重新设为黑色,同时将其他灰色对象设为白色。
- 子图中依然为白色的对象必然是垃圾,算法可以将其回收。
当然,除了使用recycler算法实现的试验删除算法以外,还有其他的情况解决问题:
- Python则是使用了追踪式回收器实现了部分标记-清除算法来解决循环引用问题。
- Android的通过强引用和弱引用来实现的智能指针方法来解决循环引用问题。
这些方法和理论,我这里就不多讲了,感兴趣的大家可以自行去了解一下。
2. 可达性分析算法
通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的,那么垃圾回收器则可以回收这些垃圾对象。
当前主流的商用程序语言(Java、C#,上溯至古老的Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。
2.1 哪些对象算的上是GC Roots呢?
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
- 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
- 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized关键字)持有的对象。
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
- 根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。
2.2 “引用”的类型
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。
在JDK 1.2版之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。
这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有“被引用”或者“未被引用”两种状态,对于描述一些“食之无味,弃之可惜”的对象就显得无能为力。
譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象,很多系统的缓存功能都符合这样的应用场景。
在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。
- 强引用: 是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
- 软引用: 是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2版之后提供了SoftReference类来实现软引用。
- 弱引用: 也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2版之后提供了WeakReference类来实现弱引用。
- 虚引用: 也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK1.2版之后提供了PhantomReference类来实现虚引用。
2.3 对象的死亡
在前面我讲到,当一个对象,被可达性分析算法判定为不可达的时候,垃圾回收器就可以回收这个对象,但实际上,这种对象,也不是非死不可的。
要真正宣告一个对象死亡,至少要经历两次标记过程。
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。
假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”,那么垃圾回收器会直接回收这个对象。
但是如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。
这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。
这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。
finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,譬如把自己 (this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
但是要记住的是,finalize()方法,一个对象只能执行一次,就是说如果上面那个逃过一劫的对象,在后续的可达性分析中又被判定为不可达,那么这个对象就不能再去执行finalize()方法了,会被垃圾回收器直接回收掉。
二. 垃圾收集器的设计思想
在当代商业虚拟机中,涌现了许多的关于垃圾收集器的设计理论,如果通过判断对象消亡的角度来讲,垃圾收集算法可以划分为两种:
- 引用计数式垃圾收集(Reference Counting GC,也叫直接回收)
- 追踪式垃圾收集(Tracing GC,也叫间接回收)
引用计数式垃圾收集我在前面稍微讲解了一下,但是在java中我们使用的基本都是追踪式垃圾回收,因此我们主要集中去了解追踪式垃圾回收。
什么是追踪式垃圾回收?
追踪式垃圾回收都是采用的间接式的回收策略,也就是这种策略并非直接寻找垃圾本身,而是先寻找哪些对象存活,然后反过来判断其余所有的对象为垃圾对象,并释放其余的不可访问对象的内存空间。
在这种追踪式垃圾回收的基础上,市面上的大部分虚拟机基本上都采取了两种策略来回收垃圾:
- 分代收集理论
- 分区收集理论
这两种策略,主要是用来划分我们的虚拟机的内存空间,如果是在java中,那么就是通过这两种策略中的一种或者两者,将我们JVM中的堆空间和方法区细分为不同的内存区域,然后通过我们的 内存回收算法 来进行垃圾回收。
1. 分代收集理论
其实在上一篇文章《从头开始学习JVM(八):运行时数据区(下)》关于java堆的讲解中,我已经稍微讲述了一下分代收集理论,但是没有讲多清除,而在这里,我重新详细叙述一遍。
人们从众多程序案例中总结出了一个经验:“大部分的对象在生成后马上就变成了垃圾, 很少有对象能活得很久。”而分代收集理论则是利用该经验,在对象中导入了“年龄”的概念。
那么,当对象有年龄以后,分代收集理论在有年龄的基础上,设置了两个假说:
- 弱分代假说:绝大多数对象都是朝生夕灭的。
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
在这两个假说的基础上,分代垃圾回收中把对象分类成几代,针对不同的代使用不同的 GC 算法。我们把刚生成 的对象称为新生代对象,到达一定年龄的对象则称为老年代对象。
不同的对象,存储在java堆的不同区域,比如在HotSpot中,但是这就引申出了另外一个问题,新生代中存在对老年代对象的引用,或者老年代中存在对新生代的引用。
这会造成什么问题呢?
当gc回收新生代对象的时候,根据可达性分析算法,gc可能不得不遍历所有的老年代对象,如果gc回收老年代对象,那么就也有可能不得不区遍历所有的新生代对象,这种问题如果特别多的话,那么会严重损耗虚拟机的性能。
于是,人们又从众多程序案例中总结出了一个经验,得出一个假说:
- 跨代引用假说:跨代引用相对于同代引用来说占比极少。
依据这条假说,gc在回收新生代对象的时候,就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。
此后当发生GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
因此,分代收集算法是基于3个假说基础上的。
2. 分区收集理论
分区算法则将整个堆空间划分为连续的不同小区间, 每个小区间独立使用, 独立回收。
这样做的好处是可以控制一次回收多少个小区间在相同条件下,堆空间越大,一次GC耗时就越长,从而产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割为多个小块,根据目标停顿时间,每次合理地回收若干个小区间(而不是整个堆),从而减少一次GC所产生的停顿。
3. 内存回收算法
由于有着分代或者是分区收集算法的存在,我们JVM的内存区域被划分为了不同性质的对象存储的空间,那么如果所有的空间都使用相同的算法来进行垃圾回收,那么我们进行分代或者分区的意义就不存在了。
因此针对不同的区域,我们的JVM会匹配不同的垃圾收集算法来对垃圾进行回收。这些垃圾算法主要要以下几个:
- 标记-清除算法
- 标记-复制算法
- 标记-整理算法
这些针对性的算法,是针对了java堆的不同内存区域的,因此我们接下来了解一下这些算法。
3.1 标记-清除算法
最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法。自其问世以来,一直到半个世纪后的今天,它依然是各种处理程序所用的伟大的算法。
算法分为 标记 和 清除 两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
标记过程就是对象是否属于垃圾的判定过程,在JVM中,使用就是可达性分析判断的。
优点:
- 实现简单:这个算法既是所有算法的基础,其余的垃圾回收算法都是在其基础上发展起来的,所以算法上来实现的话,是相对简单的。
- 和保守式GC兼容:与那些不能识别指针和非指针的GC,这个算法非常兼容。
缺点:
- 执行效率不稳定,如果Java堆中包含大量对 象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
- 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中,如果需要分配较大对象的时候,可能无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
- 分配速度太慢:算法中内存区域不是连续的,因此内存碎片必须用链表记录,每次分配都必须遍历空闲链表,找到足够大的内存。
标记-清除算法作为所有算法的基础,但是这不代表着这个算法就落后了,在很多的gc上,都使用这个算法来清除垃圾,比如我们的Major GC,就是使用这个算法。
3.2 标记-复制算法
标记-复制算法是Marvin L. Minsky 在 1963 年研究出来的算法,目的是为了解决标记-清除算法面对大量可回收对象时执行效率低的问题。
这个算法说得简单点,就是只把某个空间里的活动对象复制到其他空间,把原空间里的所有对象都回收掉。
在我们的java堆中,新生代中Survivor区域就是使用了这个算法。这个算法将Survivor区域平分为两个区域,一个叫From区域,一个叫To区域,当From空间被完全占满时,gc会对这个From区域还存活着的对象进行标记处理,然后会将标记还存活着的对象全部复制到 To 空间。当复制完成后,该算法会把 From 空间和 To 空间互换,GC 也就结束了。
优点:
- 优秀的吞吐量:因为GC复制算法只搜索并复制活动对象,所以跟一般的GC标记-清除算法相比,它能在较短时间内完成GC。也就是说,其吞吐量优秀。
- 可实现高速分配:GC复制算法不使用空闲链表。这是因为分块是一个连续的内存空间。因此,调查这个分块的大小,只要这个分块大小不小于所申请的大小,那么移动指针就可以进行分配了。
- 不会发生碎片化:因为存活着的对象会被整体移动到一块空的内存中,而留下来的内存空间会将所有的对象清除掉,形成一个完整的空白内存。
- 与缓存兼容:有引用关系的对象在复制的时候会被安排在堆里离彼此较近的位置,这样CPU读取需要的对象的时候,速度会变快。
缺点:
- 堆使用效率低下:GC复制算法把堆二等分,通常只能利用其中的一半来安排对象。也就是说,只有一半堆能被使用。相比其他能使用整个堆的 GC 算法而言,可以说这是标记-复制算法的一个重大的缺陷。
- 不兼容保守式GC算法:因为标记-复制算法需要移动对象,所以无法和保守式GC兼容。
- 复制带来的性能损耗:复制某个对象时要递归复制它的子对象。因此在每次进行复制的时候都要调用递归函数,由此带来的额外负担不容忽视。而且在每次递归调用时都会消耗栈,所以还有栈溢出的可能。
3.2 标记-整理算法
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的“标记-整理”(Mark-Compact)算法,其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象,通过Lisp2算法计算后,将对象向内存空间一端移动,然后直接清理掉边界以外的内存中的对象。
优点:
- 可有效利用堆: 标记-整理算法不会出现标记-复制算法那样只能利用半个堆的情况。标记-整理算法可以在整个堆中安排对象,堆使用效率几乎是标记-复制算法的2倍。用“几乎”这个词,是因为要留出用于指针的空间,所以严格来说不到2倍。另一方面,尽管标记-清除算法也能利用整个堆,但因为没有整理的过程,所以会产生碎片化,不能充分有效地利用堆。
缺点:
- 整理对象耗费性能:在整理移动对象的时候,无疑是耗费非常大的性能的。如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”。
- 吞吐量低:在执行Lisp2算法的时候,会对整个堆进行3次搜索,也就是说,使用标记-整理算法的gc,gc的执行时间和堆大小是成正比的。但是要注意的是,吞吐量相比于标记-清除算法来说要高。
总结
本文主要讲述了垃圾收集的理论基础,并没有讲述到具体的gc实现上去,具体的gc实现将会在下一篇文章讲述。
写本文耗费了很长的时间,一直没有什么思路,都是一点一点磨出来的,希望诸位看客能从本文中收获到东西,这样我就感觉到非常快乐了。