垃圾收集算法:内存回收的方法论
垃圾收集器:内存回收的具体实现
在正式讨论垃圾回收算法和垃圾收集器之前,我们应该了解一下JVM是如何判断一个对象已经死亡的?
JVM主要使用了两种方法判断对象是否已经死亡:
- 引用计数法:这种方法实现起来简单,而且也好理解,就是给对象维护了一个计数器,当有一个地方引用它的时候,它就加一,当引用失效的时候计数器就减一。当这个计数器的值为0的时候,那么就可以判断该对象可以被清理。
- 可达性分析算法:该方法的基本思想就是通过一些”GC Roots” 对象作为起始点,从这些节点开始向下搜索。就像一棵树结构一样,从它的根节点开始搜索,若某些对象不属于这些树的子节点,那么就可以判定这些对象为不可达。可以被清理。(在我们下面介绍的垃圾回收算法中,主要是使用了这种方法判断对象是否需要被回收)
注:当然,实际的判断方法没有这么简单,这里只是简单的介绍一下。
OK,接下来先说垃圾回收算法
1. 标记清除算法(Mark-Sweep)
该算法就和他的名字一样,分为标记和清除两个阶段。首先标记出所有需要回收的对象,在标记完成之后统一回收所有被标记的对象。
图解:
回收前 (黑色:可回收 | 灰色:存活对象 | 白色:未使用 )
回收后:
从上面的图解中我们就可以看出:
1. 这种算法在收集结束后,内存是乱七八糟的(也就是产生了很多内存碎片),这种内存碎片太多的话,会导致我们在下一次分配较大对象的时候,无法找到足够的连续内存,不得不触发另外一次垃圾回收。
2. 还有就是其实,标记和清理的过程的效率都是不高的。该算法适合于那些对象存活率较高的内存区域的收集工作。
2. 复制算法(Copying)
该算法的出现是为了解决效率问题。它将内存划分为等大的两份,每次只是使用其中的一块。当一块用完了,就将还活着的对象复制到另外一块上,完后再将已经使用过的那一块一次性清除。
图解:
回收前 (黑色:可回收 | 浅灰色:存活对象 | 白色:未使用 | 深灰色:保留区域 )
回收后:
算法优点:这种算法不存在出现垃圾碎片的情况,再分配较大对象时候也不会有无法找到足够内存的情况,实现简单,运行高效。
算法缺点:使用该算法的代价就是直接将内存缩小一半,代价有点高。其次,再某些情况下需要进行很大规模的复制动作,会直接影响到效率。该算法适合于那些对象存活率较低的内存区域的收集工作。
3. 标记整理算法(Mark Compact)
标记整理和标记清除的非常相似,但是标记整理的过程是这样的,首先是标记要清理的对象,然后将剩下所有存活的对象都移动到一端,然后直接清理端边界以外的内存。其实也就是标记-整理-清除算法,多了一个对内存的整理的过程。
图解:
回收前回收前 (黑色:可回收 | 灰色:存活对象 | 白色:未使用 )
回收后:
大家可以在图示中很清楚的看到,该种收集算法的好处就是首先没有像复制算法那样浪费掉一半的空间,然后收集后的内存也非常的整洁,没有内存碎片。
相比标记清除,它能很好的整理内存,但同时也多了整理内存的代价。该算法适合于那些对象存活率较高的内存区域的收集工作。
4. 分代收集算法(Generational Collection)
这种算法其实并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。就是将Java堆划分为新生代和老年代,新生代中每次收集都会有大量的对象死去,所以建议采用复制算法,只需要付出较少的复制成本就能完成收集。但是老年代中因为对象的存活率高且没有额外的空间对它进行分配担保。所以虽好,使用标记-清理或者标记整理算法来进行收集。
接下来就是垃圾收集器了
上图所示的就是接下来要介绍的集中垃圾收集器,上图中用线连接起来的两个垃圾收集器之间是可以搭配使用的。
1. Serial:基本、历史悠久
该收集器是一个单线程的收集器。这里的单线程收集器并不是说明它只会使用一个CPU或者使用一条线程去完成垃圾的收集工作,更为重要的是在它进行垃圾收集的时候需要”Stop the World”直到它的工作结束。
优点:简单高效、在单个CPU环境来说,Serial没有线程交互的开销,专心做自然高效率。
缺点:Stop the World 影响用户体验!
运行图示
2. ParNew:Serial的多线程版本
该线程除了使用多条线程进行垃圾收集之外,其余的东西和行为和Serial是一模一样的。虽然说没有多大的创新,但是却是许多运行在Server模式下虚拟机目前首选的新生代垃圾收集器,其主要原因是,它可以于另外一个吊炸天的老年代的垃圾收集器CMS配合使用。
优点:同样具备Serial的优点。
缺点:Stop the World 影响用户体验。
运行图示
插入:在谈论垃圾收集器色上下文语境中,并发和并行的理解如下:
并行:多条垃圾收集线程并行工作,但此时的用户线程仍处于等待状态。
并发:用户线程和垃圾收集线程同时处理,用户线程在继续运行、而垃圾收集程序运行在另一个CPU。
3. Parallel Scavenge:作用于新生代、使用复制算法、多线程
从表面看,该收集器和ParNew是非常相似的,但其实是有所区别的。他们主要的区别在于他们的关注点不同,CMS或者ParNew等收集器的关注点主要在于缩短垃圾收集时用户线程的停顿时间(缩短 Stop The World的时间),但是Parallel Scavenge的关注点是去达到一个可以控制的吞吐量。(吞吐量=运行用户代码的时间/(运行用户代码的时间+垃圾收集时间))。
分析:停顿的时间越短就越适合需要与用户交互的程序,但是吞吐量高则可以高效的利用CPU时间,尽快完成程序的任务,主要适合在后台运算而不需要太多交互的任务。
4. Serial Old:Serial的老年代版本、单线程、标记-整理算法
工作原理与Serial基本一致。主要作用于Client端。在server端主要有两种用途:
1. JDK1.5之前,与Parallel Scavenge搭配使用
2. 作为CMS收集器的后备预案,并发收集发生Concurrent Mode Failure时使用。
运行图示
5. Parallel Old收集器:Parallel Scavenge的老年版、多线程、标记-整理、JDK1.6中开始加入
主要是搭配Parallel Scavenge使用,可以使得整个系统的吞吐量达到最大化的优化,在注重吞吐量和CPU资源敏感的场合,可以优先考虑Parallel套件。
运行图示
6. CMS收集器(Concurrent Mark Sweap):并发收集、低停顿、标记-清除、JDK1.6中加入
该收集器意在获取最短的停留时间,多应用在B/S结构的服务端上,该收集器的运作过程。
- 初始标记(CMS initial mark)
- 并发标记(CMS concurrent mark)
- 重新标记(CMS remark)
- 并发清除(CMS concurrent sweap)
在上述的四个过程中:并发标记和重新标记两个步骤还是需要”Stop The World”。初始标记只是标记一下 GCRoots 不能直接关联到的对象,速度很快,并发标记阶段就是进行 GC Roots Tracing 的过程。并发标记就相当于再次去判断该对象是否已经死亡的过程,因为对象是非常可能”复活”的。
其中的并发标记和并发清除步骤:从总体上来说是和用户一起来工作的。所以,这也就是它最大的优点。
但是它并不完美,它主要有以下三个缺点:
1.对CPU资源敏感
CMS默认启动的回收线程数是 (CPU数量+3)/4,也就是CPU在4个以上时,并发回收时垃圾线程不少于25%的CPU资源,并且随着CPU数的上升而下降。但是,当CPU数不足4个时,比如2个,CMS对用户程序的影响就比较大了,CPU需要分出一半以上的资源供给垃圾回收,用户的程序执行速度会降低50%以上。为了应对这种情况发生,虚拟机提供了一种称为”增量式并发收集器”(Incremental Concurrent Mark Sweap / i-CMS)的变种CMS收集器,但在实际应用中效果不理想,现在已经不提倡使用。
2.无法处理浮动垃圾,可能会出现(Concurrent Mode Failure)失败而导致的另一次Full GC产生。
由于CMS在并发清理阶段用户的线程仍在运行。伴随着程序的运行指定会产生新的垃圾,这种垃圾出现在标记过程之后,CMS无法在当此处理中处理掉他们,这些就是浮动垃圾。同样因为,在清理过程中用户线程也得跑,就需要预留有足够的内存空间给用户使用。如果运行的过程中的预留的空间不够使用,那么就会发生(Concurrent Mode Failure),触发一次Full GC,临时启用 Serial Old收集器重新对老年代进行收集。这样的停顿时间过长。
3.会产生大量的内存碎片。
由于该收集器使用的标记-清除收集算法,就会不可避免的产生大量的内存碎片。给较大的对象分配带来了很大的麻烦。
运行图示:
7. G1收集器:当今收集器技术发展最前沿的成果之一
它具有如下特点:
1.并发与并行:G1充分利用多CPU,多核环境下的硬件优势,使用多个CPU来缩短 Stop-The-World停顿的时间,其它收集器需要停顿Java线程执行的GC动作,G1仍然可以通过并发的方式让Java程序继续执行。
2.分代收集:分代的概念在G1中仍然得以保留。虽然G1可以不需要其它收集器的配合就能独立管理整个GC堆。
3.空间整合:与CMS的标记清除不同,G1从整体上看是基于“标记清理”算法实现的收集器。从局部(两个Region)之间上来看是基于“复制”算法实现的。无论如何,结论就是G1运行期间不会产生内存碎片。
4.可预测的停顿:G1除了追求低停顿之外,还能建立可预测的停顿时间模型,可以让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不能超过N毫秒。
内存布局的变化:
G1之前的其它收集器进行收集的范围都是整个新生代或者老年代,但是G1不再这样。—->他将整个Java堆划分为多个大小相等的独立区域(Region),虽然还有新生代和老年代的概念,但是新生代和老年代不再是物理隔离,他们都是一部分Region的集合。
G1做了什么?让它具有了能建立可预测的停顿时间模型?
首先,G2会跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需的时间的经验值),在后台维护一个优先列表,每侧根据允许的收集时间,优先收集经验值较大的Region。这种具有一定的优先级的回收策略,使得G1在有效的时间里可以获得尽可能大的回收效率。
Java堆分为多个Region之后,那么在处理对象间依赖这个问题就自然而然的出现了。在G1中,Region之间的对象引用还有其它版本收集器中的新生代和老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。
具体使用 Remembered Set 怎么实现避免全堆扫描?
G1中每个Region中都有一个与之对应的Remembered Set ,虚拟机发现程序在对 Reference类型的数据进行写操作时。会产生一个 Write Barrier 暂时中断操作,检查 Reference 引用的对象是否处于不同的Region中,如果是,便通过 CardTable 吧相关的引用信息记录到被引用对象的Region的 Remembered Set 中。当有GC操作时,在GC Roots 的枚举范围中加入 Remembered Set 即可保证不对全堆扫描,且不会有遗漏。
G1收集器运行的大概过程(忽略维护 Remembered Set)
- 初始标记(Initial Marking)
- 并发标记(Concurrent Marking)
- 最终标记(Final Marking)
- 筛选回收(Live Data Counting and Evacuation)
· 初始标记仅仅只能标记一下 GC Roots节点能直接关联到的对象。
· 并发标记是从 GC Roots 开始对堆中的对象进行可达性分析,找出存活的对象,这阶段耗时较长,但是可以用户程序并发执行。
· 最终标记是为了修正在并发标记过程中因为用户程序继续运作而导致标记产生变动的那一部分记录。JVM将变动记录在 Remembered Set Logs中,最终将 Remembered Set Logs 和 Remembered Set 合并。虽需停顿,但是可并行执行。
· 筛选回收,首先对各个Rgion的回收价值和成本进行排序,根据用户所期望的GC停顿时间制定回收计划。
运行图示