内存回收与垃圾收集器在很多时候都是影响系统性能、并发能力的主要因素之一
垃圾收集算法
由于垃圾收集算法中涉及到大量的程序细节,而且每个平台的虚拟机操作内存的方法又不同,因此关于算法不会讨论其具体实现,而是介绍算法的思想和发展过程。
标记-清除算法(Mark-Sweep)
(1)定义
垃圾收集算法中最基础的算法 —— 标记-清除算法,顾名思义,算法分成“标记”、“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
(2)缺陷
它是最基础垃圾收集算法的原因是:因为后续的收集算法都是基于这种思路并对其不足进行改进而产生的。标记-清除算法的主要不足有两点:
效率问题,标记和清除两个过程的效率都不高;
空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不触发另一次垃圾收集动作。
(3)图解执行过程
标记-清除算法的执行过程如下:
复制算法(Copying)
(1)定义
为了解决标记-清除算法的效率问题,一种称为“复制”的收集算法出现了,思想为:它将可用内存按容量分成大小相等的两块,每次只使用其中的一块。当这一块内存用完,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。
(2)优点与缺陷
优点:这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
缺陷:只是这种算法的代价是将内存缩小为原来的一半,代价过高。
(3)图解执行过程
复制算法的执行过程如下:
(4)升级版复制算法
现在商业虚拟机都采用复制啊来回收新生代,而IBM公司的专门研究表明:新生代中的对象98%是“朝生夕死”的,所以并不需要按照1:1 的比例来划分控件。升级版算法主要思想为:将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚使用的Survivor空间。
(5)HotSpot虚拟机中的升级版复制算法
HotSpot虚拟机默认Eden和Survivor的大小比例是 8:1 ,也就是每次新生代中可用内存控件为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。
(6)内存的分配担保(Handle Promotion)
举个例子来理解这一概念,类似于银行借款,如果我们信誉好,在98%的情况下都能按时偿还,这样银行会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人,当我们没有还款时就从他账户扣前,银行会觉得万无一失了。而内存分配担保也是如此,如果另外一块Survivor上没有足够空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。
标记-整理算法(Mark-Compact)
(1)定义
按照惯例, 标记-整理算法的出现肯定又是为了解决复制算法的不足,这就是一次次迭代过程,为不同的服务对象找到最适合的垃圾收集算法。确实如此,复制算法在对象存活率较高时要进行较多的复制操作,效率将会变低。更关键的是:如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用复制算法。
根据老年代的特点,标记-整理算法被提出来,主要思想为:此算法的标记过程与 标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
(2)图解执行过程
标记-整理算法的执行过程如下:
分代收集算法(Generational Collection)
(1)定义
当前商业虚拟机的垃圾收集都采用分代收集算法,此算法相较于前几种没有什么新的特征,主要思想为:根据对象存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法。
(2)算法建议
适于“新生代”的算法建议
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。适于“老年代”的算法建议
在老年代中,因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。
垃圾收集器算法实现
此篇文章在讲解垃圾收集算法的开始有介绍说:
每个平台的虚拟机操作内存的方法又不同,因此关于算法不会讨论其具体实现,只讨论其主要思想。
确实如此,Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现,此小节来讲解各个收集器的算法实现。
Java堆内存结构
接下来讨论的收集器基于JDK1.7 Update 14 之后的HotSpot虚拟机(在此版本中正式提供了商用的G1收集器,之前G1仍处于实验状态),但是在正式讨论收集器之前,需要介绍一下Java堆内存结构—— 适用于非G1收集器外的垃圾收集器:
查看上图,可得知该区域划分是根据Java对象的生命周期长短把Java堆内存分为老年代和年轻代,然后年轻代又被划分为:一个Eden区和两个大小同等的Survivor区。
垃圾收集器
之所以需要了解Java堆内存中的大致划分,是因为随后讲解HotSpot虚拟机中的各个收集器就是存在于此内存中,接下来以图示介绍此虚拟机中所包含所有的收集器:
如上图所示,展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。
在开始介绍这7个收集器之前需要明确一个观点:虽然在对各个收集器进行比较,但并非是为了挑出一个最好的收集器。因为目前并无完美的收集器出现,只是选择对具体应用最适合的收集器。若真有完美收集器的存在,HotSpot虚拟机就没必要如上图所示实现那么多的收集器了 。
在后续几个收集器的介绍中,涉及到了并发和并行的收集器,有必要解释这两个名词,它们属于并发编程中的概念,在谈论垃圾收集器的上下文语境中,可解释如下:
并行(Parallel)
指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。并发(Concurrent)
指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续执行,而垃圾收集程序运行在另一个CPU上。
Serial 收集器
(1)特征
Serial收集器是最基本、发展历史最悠久的收集器,曾经(JDK1.3之前)是虚拟机新生代收集的唯一选择。它是一个单线程收集器,但“单线程”并非指该收集器只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程,直至Serial收集器收集结束为止。
“Stop The World”用这个名字来形容Serial收集器的工作是再适合不过了,实际上这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,试想一下这独裁般的过程,对多数应用来说是难以接收的。
(2)图解运行过程
以下是Serial / Serial Old 收集器的运行过程:
(3)优势
也许你认为Serial收集器这种工作方式根本没有存在的必要,其实不尽然,它依然是虚拟机运行在Client模式下的默认新生代收集器。它也有着优于其他收集器的地方:简单而高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得更高的单线程收集效率。需要注意:从Serial收集器到Parallel收集器,再到最前沿成功的G1收集器,用户线程的停顿时间越来越少,但是仍无法完全消除。
(4)适用场景
其实实际情况并没有那么糟糕,在用户的桌面应用场景中,分配给虚拟机管理的内存一般不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本不会再大了),停顿时间完全可以控制在几十毫秒最多一百毫秒以内,只要不频繁发生,这点停顿时间可以接收。所以,Serial收集器对于运行在Client模式下的虚拟机来说是一个很好的选择。
Serial Old 收集器
(1)特征
Serial Old 是 Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。
(该收集器的运行过程同Serial收集器的相同,可查看上图,在此不再列出)
(2)适用场景
此收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,它还有两大用途:
在JDK1.5 以及之前版本中与Parallel Scavenge收集器搭配使用。
作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。
ParNew 收集器
(1)特征
在理解完Serial收集器后,得知了它的一个明显的特征——单线程,而接下来介绍的ParNew收集器就是它的多线程版本,除了此特征其余地方与Serial收集器相同,两者共用了相当多的代码。
(2)图解运行过程
以下是ParNew 收集器的运行过程:
(3)不同使用场景中的优势与缺陷
单CPU的环境
ParNew 收集器在单CPU的环境中绝对不会有比Serial收集器有更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百地保证可以超越。多CPU下的环境
随着CPU的数量增加,它对于GC时系统资源的有效利用是很有好处的。它默认开启的收集线程数与CPU的数量相同,在CPU非常多的情况下可使用参数设置。
Parallel Scavenge 收集器
(1)特征
由图示构造可知,Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法,并且是一个并行的多线程收集器,看上去与ParNew收集器类似,其实此收集器的特点是它的关注点与其他收集器不同:在讲述第一个Serial Old收集器时已经提过,后续产生的收集器它们都是为了尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。
(2)吞吐量(Throughput)
吞吐量就是CPU用于运行用户代码的时间与CPU总消耗的比值,运算公式为:
吞吐量 = 运行时间 / (运行用户代码时间 + 垃圾收集时间)
举个例子说明:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
(3)图解运行过程
以下是Parallel Scavenge / Parallel Old 收集器的运行过程:
(4)适用场景
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
Parallel Old 收集器
(1)特征
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。
(此收集器的运行过程与Parallel Scavenge收集器相同,可查看上图,此处不重复粘贴)
(2)Parallel Old 产生之前的收集器组合问题
此小点是一个可能较陌生的知识点,在学习了几个收集器后,希望大家除了结构图中的收集器不要忘记这些收集器之间的连线,代表这些收集器可以互相搭配使用,也是很重要的一点。
接下来要讲的也与这个收集器组合有关。此收集器在JDK1.6 中才出现,在此之前,Parallel Scavenge收集器处于一个很尴尬的位置,原因如下:
如果新生代选择了Parallel Scavenge收集器,那么老年代除了Serial Old收集器外别无选择,可是Serial Old收集器在服务端应用性能上的效果不太理想,而导致Parallel Scavenge收集器在整体应用上无法获得吞吐量最大化的效果。由于单线程的老年代收集中无法充分利用服务器多CPU的处理能力,在老年代很大而且硬件比较高级的环境中,此组合还不如Parallel Scavenge搭配CMS收集器。
(3)Parallel Old的产生解决组合问题
可是在Parallel Old收集器 出现后,“吞吐量优先”收集器终于有了比较理想的应用组合,在注重吞吐量及CPU资源敏感的场合,可以有限考虑Parallel Scavenge搭配Parallel Old收集器。
CMS(Concurrent Mark Swep) 收集器
(1)特征
CMS收集器是一种以获取最短回收停顿时间为目标的收集器。该收集器名字中的“Mark Sweep”已经体现出它是基于“标记-清除”算法实现的。
(2)解析运行过程
此收集器的运作过程相对于前面几种较为复制,整个过程分为4个步骤:
初始标记 (CMS initial mark)
仅仅只是标记一下GC Roots 能直接关联到的对象,速度很快。并发标记(CMS concurrent mark)
该阶段进行 GC Root Tracing的过程。重新标记(CMS remark)
修改并发标记期间因用户程序继续运作而导致标记产生变动的那部分对象的标记记录。此阶段停顿时间会比初始标记阶段稍长一些,但远比并发标记时间短。并发清除(CMS concurrent sweep)
这4个步骤中的 初始标记、重新标记 过程仍需要“Stop The World”,即停止所有工作线程。由于整个过程中耗时最长的并发标记 和 并发清除过程收集器线程都可以与用户线程一起工作,所以总体而言,CMS收集器的内存回收过程是与用户线程一起并发执行的。
(3)图解运行过程
以下是CMS 收集器的运行过程:
(4)优点与缺点
CMS 收集器确实是一款优秀的收集器,主要优点已经体现在名字上:并发收集、低停顿,但是远远不到完美程度,不可避免的有以下3个缺点:
并发导致总吞吐量降低
CMS收集器对CPU资源非常敏感,实际上,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说是CPU资源)而导致应用程序变慢,总吞吐量会降低。无法处理浮动垃圾导致 Full GC
由于CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理 阶段用户线程还在运行,伴随程序运行自然会有新的垃圾不断生成,此部分垃圾出现在标记之后,CMS无法再当次收集中处理,只好留给下一次GC时再清理,此部分的垃圾称为“浮动垃圾”。本质算法导致大量空间碎片
CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象。
(5)适用场景
目前很大一部分的Java应用集中在互联网站或B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,来给用户带来较好体验,而CMS收集器最适合这类需求。
G1(Garbage-First) 收集器
(1)特征
G1收集器是当今收集器技术发展最前沿的成果之一,HotSpot开发团队更是寄期望于它未来可以替换JDK 1.5中发布的CMS收集器,特点如下:
并行与并发: G1 能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短“Stop The World”停顿时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
分代收集: 虽然G1不可不需要其他收集器配合就能独立管理整个 GC堆,但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次GC的旧对象来获取更好的收集效果。
空间整合: 与CMS收集器采用的“标记-清除”算法不同,G1采用的是“标记-整理”算法,意味着G1运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
可预测的停顿: 这是G1相对CMS的一大优势,G1除了降低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内。
在G1之前的收集器进行收集的范围都是整个新生代或老年代,而G1使用时,Java堆的内存布局与其他收集器有很大区别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但新生代和老年代不再是物理隔离,而都是一部分Region(不需要连续)的集合。
(2)解析运行过程
G1收集器的运作大致可划分以下几个步骤:
初始标记 (Initial Marking)
仅仅只是标记一下GC Roots 能直接关联到的对象,并且修改TAMS(Nest Top Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可以的Region中创建对象,此阶段需要停止线程,但耗时很短。并发标记(Concurrent Marking)
从GC Root 开始对堆中对象进行可达性分析,找到存活对象,此阶段耗时较长,但可与用户程序并发执行。最终标记(Final Marking)
修改并发标记期间因用户程序继续运作而导致标记产生变动的那部分对象的标记记录。此阶段需要停顿线程,但是可并行执行。筛选回收(Live Data Counting and Evacuation)
首先对各个Region中的回收价值和成本进行排序,根据用户所期望的GC 停顿是时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
(3)图解运行过程
以下是G1 收集器的运行过程:
(4)适用场景
根据G1收集器的特征,最适用于面向服务端应用来进行垃圾收集。
总结
此篇博文分成两大块,第一部分介绍了垃圾收集算法,第二部分介绍了7大垃圾收集器的特点、运行原理、适应场景等相关知识点。其实内存回收与垃圾收集器在很多时候都是影响系统性能、并发能力的主要因素之一,虚拟机之所以提供多种不同的收集器,也是因为只有根据实际应用需求、实现方式来选择最优的收集方式才能获取最高的性能。
垃圾收集算法绝对是面试必备,需要多加理解学习,在理解好算法后,后续的垃圾收集器是基于算法之上实现的,融汇贯通起来效果更好。
本文分享自微信公众号 - 业余草(yyucao)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。