文章目录
概述
如果说收集算法是内存回收的方法论,那么收集器就是内存回收的实践者。
垃圾收集器没有在Java虚拟机规范中进行过多的规定,可以由不同的厂商、不同版本的JVM来实现。
由于JDK的版本处于高速迭代的过程中,因此JAVA发展至今已经衍生了众多的GC版本。
从不同的角度来分析垃圾收集器,是可以将GC分为不同的类型。
垃圾回收器分类
- 按线程数分,可以分为串行垃圾回收器和并行垃圾回收器。
串行垃圾回收: 指的是在同一个时间段内只允许有一个CPU用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。
并行垃圾回收: 是和串行垃圾回收相反的,并行运算可以拥有多个CPU同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用“Stop The World”的机制。在回收的时候,需要暂停所有的线程。
- 按照工作模式分,可以分为并发式垃圾回收器和独占式回收器
并发式垃圾回收器: 与应用程序交替工作,以尽可能减少应用程序的停顿时间。
独占式垃圾回收器: 就是Stop - The - World。一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。
- 按照工作的内存区间,又可分为年轻代垃圾回收器和老年代垃圾回收器
年轻代垃圾回收器: 效率高,采用复制算法,但对内存的占用控制不精确,容易造成内存溢出。
老年代垃圾回收器: 效率低,在执行的时候,会STW,但一般启动次数少。
GC的性能指标
-
吞吐量:运行用户代码的时间栈总运行时间的比例。
(总运行时间:程序的运行时间+内存回收时间) -
垃圾收集开销:垃圾收集所用时间与总运行时间的比例。
-
暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。
-
收集频率:相对于应用程序的执行,收集操作发生的频率。
-
内存占用:Java堆区所占的内存大小。
-
快速:一个对象从诞生到被回收所经历的时间。
HotSpot虚拟机中的垃圾收集器
因为HotSpot的虚拟机是现在主流的商用虚拟机,也是非常经典的一款虚拟机所以在这我就自己学了一下这款虚拟机的垃圾收集。
这些经典的收集器尽管已经算不上最先进的技术,但它们曾在实践中千锤百炼,足够成熟,基本上可认为是现在能够在商用生产环境中放心使用的全部垃圾收集器了。
各款经典收集器之间的关系如图。
如果两个收集器中间存在连线,就说明他们可以搭配使用。图中收集器所处的区域,则表示它是属于新生代收集器或者老年代收集器。
串行回收器:Serial,Serial old;
并行回收器:ParNew,Parallel scavenge,Parallel old;
并发回收器:CMS、G1;
新生代收集器:Serial,ParNew,Parallel scavenge;
老年代收集器:Serial old,Parallel old.cMS;
整堆收集器:G1;
虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来,虽然垃圾收集器的技术在不断进步,但是直到现在还没有最好的收集器出来,更加不存在“万能”的收集器,所以我们选择的只是对具体应用最合适的收集器。
如果有一种放之四海,任何场景都使用的完美收集器存在,那么HotSpot虚拟机就完全没有必要实现那么多种不同的收集器了。
在说收集器的时候,会接触到“并发”和“并行”
并行: (Parallel)描述的是多条垃圾收集器线程之间的关系。
说明同一时间有多条这样的线程协同工作,通常默认此时用户线程是处于等待状态。
并发: (Concurrent): 并发描述的是垃圾收集器线程与用户线程之间的关系。
说明在同一时间垃圾收集器线程与用户线程都在运行。由于用户线程并非被冻结,所以程序仍然是能够响应服务请求的,但由于垃圾收集器线程占有了一部分的系统资源,此时应用程序的处理的吞吐量将会受到一定的影响。
Serial 垃圾收集器(单线程)
Serial收集器是最基础、历史最悠久的收集器,曾经(在JDK1.3.1之前)是HotSpot虚拟机新生代收集器的唯一选择。它是一个“单线程”的收集器,但是它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集的时候,必须停止其他所有的工作线程,直到结束。
运行示意图:
特点:单线程,简单高效,采用的是复制算法,对于单CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集器自然可以获得最高的单线程收集效率。收集器进行垃圾回收时,必须暂停其他所有的线程(Stop The World),直到它结束。
虽然它是最早产生的,但是它与其他收集器的单线程相比是最简单而高效的。所以在用户桌面的应用场景以及近几年来流行的部分微服务器应用中是一个很好的选择
应用场景:适用Client模式下的虚拟机。
Serial Old 垃圾收集器(单线程)
Serial Old是Serial收集器的老年代的版本,单线程,使用标记 - 整理算法。
应用场景:主要也是使用在 Client 模式下的虚拟机中。也可在 Server 模式下使用。
ParNew 垃圾收集器
ParNew收集器实质上是Serial收集器的多线程版本,除了同时使用多线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有控制参数(例如: -XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、STW、对象分配规则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。
特点:多线程、ParNew收集器默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境中,可用使用 -XX:ParallelGCThreads参数来限制垃圾收集的线程。和Serial收集器一样存在Stop The World 问题。
应用场景:ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,因为它是除了Serial收集器外,唯一一个能与CMS收集器配合工作的。
Parallel Scavenge 垃圾收集器
Parallel Scavenge 收集器也是一款新生代的收集器,它同样是基于 标记 - 复制算法实现的收集器,也是能够并行收集的多线程收集器
特点: 它的关注点与其他收集器是不同的,CMS等收集器的关注点是尽肯地缩短垃圾收集时用户线程的停顿使劲,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。
所谓吞吐量就是处理器用于远程用户代码的时间与处理器总消耗时间的比值,即:
高吞吐量则可以最高效率地利用处理器资源,尽快地完成程序的运算任务,主要适合在后台运算而不需要太多交互的分许任务。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的 -XX:GCTimeRatio参数。
-XX:MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定的值。(但是设置太小的话,停顿时间的确在下降,但吞吐量也降下来了)
-XX:GCTimeRatio参数的值为一个正整数,表示用户期望虚拟机消耗在GC上的时间不超过程序运行时间的 1/(1+N)。默认值为99.含义是尽可能保证应用程序执行的时间为收集器执行时间的99倍,也即收集器的时间消耗不超过总运行时间的1%。
由于与吞吐量关系密切,Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”。
Parallel Old垃圾收集器
是Parallel Scavenge收集器的老年版本。
特点:支持多线程并发,采用标记-整理算法。
这个收集器是在JDK 6时才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直都处于一个相当尴尬的场面,原因是如果新生代选择了Parallel Acavenge收集器,老年代除了Serial Old收集器以外别无选择,其他表现良好的老年代收集器,比如CMS是无法配合它工作的。由于老年代Serial Old的“拖累”,使得Sarallel Scavenge收集器是没有办法在整体上获得吞吐量最大化的效果。
直到Parallel Old 收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的搭配组合。
应用场景:在注重吞吐量或者处理器资源较为稀缺的场合。都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。
CMS垃圾收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。
它的运作过程相对于前面几种收集器来说要更为复杂一些,整个过程分为四个步骤:
-
初始标记(CMS initial mark)
这个阶段仅仅只是标记一下GC Roots能直接关联到的对象,速度快。 -
并发标记(CMS concurrent mark)
这个阶段就是从GC Roots的直接关系对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行 -
重新标记(CMS remark)
这个阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发阶段的时间短。 -
并发清除(CMS concurrent sweep)
这个阶段,就是清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
由于在整个过程中耗时最长的并发标记和并行清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以在总体上来看,CMS收集器的内存回收过程是与线程一起并行执行的。
优点:并发收集、低停顿。
缺点:
-
对处理器资源非常敏感。
在并发阶段虽然不会导致用户线程停顿,但却会因为占用一部分线程而导致应用程序变慢,降低总吞吐量。 -
无法处理“浮动垃圾”。
因为无法处理“浮动垃圾”,就有可能出现“Concurrent Mode Failure”失败而导致另一次完全“Stop The Stop”的Full GC的产生。
在CMS的并发标记和并发清理阶段,用户线程还是继续运行的,程序在运行自然就还有伴随着有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程以后的,CMS无法在当次收集中处理掉它们,就只好期待下一次垃圾收集时再清理掉。这就是“浮动垃圾”。 -
会产生内存碎片。
因为CMS是基于“标记 - 清除”算法来实现的收集器。从垃圾回收中就了解到垃圾回收中的“标记 - 清除”算法就会产生内存碎片。内存碎片过多的话,就会导致给大对象分配带来了很大的麻烦。就会不得不提前触发Full GC。
三色标记算法
为了提高JVM的垃圾回收性能,从CMS垃圾收集器开始,引入了并发标记的感念。引入并发标记的过程就会带来一个问题,在业务执行的过程中,会对现有的引用关系链出现改变。
三色标记中的三色:
黑色:表示该对象已经被标记过了,且该对象下的属性也全部都被标记过了,例如:GCRoots对象
灰色:对象已经被垃圾收集器扫描过了,但是对象中还存在没有扫描的引用(GC需要从此对象中去寻找垃圾)
白色:表示该对象没有被垃圾收集器访问过,即表示不可达。
三色标记的过程:
- 初始时,全部对象都是白色的
- GC Roots直接引用的对象变为灰色
- 从灰色集合中获取元素;将本对象直接引用的对象标记为灰色;然后将当前的对象标记为黑色。
- 重复步骤3,直到灰色的对象集合全部变为空
- 结束后,仍然被标记为白色的对象就是不可达对象,就视为垃圾对象。
当Stop The Word时,对象间的引用是不会发生变化的,因为此时用户线程是中断的,可以轻松完成标记。但是在并发标记的时候,标记期间用户线程还在跑,对象间的引用可能发生变化,多标和漏标的情况就可能会发生。
多标(又叫浮动垃圾)
看图,A为GC Roots的对象,然后向下进行遍历。把A标记为黑色,将B和C标记为灰色。
此时A取消了对B的引用。 A->B = null
这个时候A->B之间的引用没有了,B应该为白色,但是因为之前在对A的时候,已经把D标为灰色了了,所以B对象任然会被当做存活对象遍历下去。
最终结果:这部分对象仍然会被标记为存活对象,本轮GC是不会回收他们的内存。这部分因为并发而造成的本应该回收但是没有回收的对象就称为“浮动垃圾”,我们稍微一想,浮动垃圾是不会影响应用程序的重要性,只需要等到下一轮GC到来就会被回收了。
另外一点: 针对并发标记开始后产生的新对象,通常做法是直接标记为黑色,本轮不进行清除。即使这些对象会变成垃圾对象,这也算浮动垃圾的一部分。
漏标(错杀)
假设GC线程遍历到了B,发生了以下操作:
断开B和D之间的引用关系,新增了A和D之间的引用关系了。
此时B到D之间的引用消失,A生成了新的对D的引用。但是此时GC线程已经走过了A,因为B已经没有了对D的引用,所以不会遍历到D,D也就不会标志为灰色,同时A已经标志为黑色了,不会再被遍历,那么也就导致D一直是白色的,最后被当成垃圾处理,这显然与事实不符。这是绝对不被允许的。
这个问题是比较致命的,如果错杀了,就会出现运行结果不符合预期的情况。这个是绝对不能发生的。
解决漏标(错杀)问题
先分析一下发生漏标的具体原因有二:
● 灰色指向白色的引用全部断开(在这就行一个原始快照)
● 黑色指向白色的引用被建立(增量更新)
那么仔细想一想,只要打破上面任何一个问题,就可以解决漏标(错杀)的问题。
写屏障:
-
写屏障 + SATB
当对象B的引用发生变化时,利用写屏障,将B原来的引用对象记录下来,这样可以尝试保留开始时的对象图,保证标记依然按照原本的路线走 -
写屏障 + 增量更新
●当对象A的引用发生变化时,利用写屏障,将A新的引用对象D记录下来
即当有新的引用插入进来时,记录下新的引用
●这种思路不要求保留原始对象图,而是针对新的引用记录下来等待遍历,即增量更新
读屏障:
读屏障针对第一步,当读取引用对象的时候,一律记录下来,显然这种方法非常保守,但是安全。
将记录下的再引用遍历就是了。
在现代的垃圾回收器当中可达性分析算法的垃圾回收器几乎都借鉴了三色标记法的思想
在Java HotSpot VM中
CMS采用的是:写屏障 + 增量更新
G1采用的是:写屏障 + SATB
G1(Garbage First)收集器
Garbage First(简称G1)收集器是垃圾收集器计数发展历史上的里程碑式的成果。G1垃圾回收器是在Java7 update 4 之后引入的一个新的垃圾回收器,是当今收集器计数发展的最前沿成果。
它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。是一款面向服务端应用的垃圾收集器。
因为在后世发展中,所对应的业务越来越庞大、复杂、用户越来越多,没有GC就不能保证应用程序正常进行,而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。
与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时为了兼顾良好的吞吐量。
官方给G1设定的目标就是在延迟可控的情况下获得尽可能高的吞吐量,所以才会担当起“全功能收集器”的重任与期望。
虽然G1任然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单词回收的最小单位,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。
在Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为大小超过一个Region容量的一半的对象即可以判定为大对象。而对于那些超过整个Region容量的超级大对象,就会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。
这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。
因为 G1 是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)、(物理上不连续的)。使用不同的 Region 来表示 Eden、幸存者0区,幸存者1区,老年代等。
G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个 Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给G1还有一个名字:垃圾优先(Garbage First)。
G1(Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备现在的多核 CPU 及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。
G1收集器可以 “ 建立可预测的停顿时间模型 ”,它维护了一个列表用于记录每个 Region 回收的价值大小(回收后获得的空间大小以及回收所需时间的经验值),这样可以保证G1收集器在有限的时间内可以获得最大的回收效率。
如果我们不去计算用户线程运行过程中的动作(比如使用写屏障维护记忆集的操作),G1收集器的运行过程大致可划分为以及4个步骤。
-
初始标记(Initial Marking)
仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一个阶段用户线程并发运行的时候,能正确地在可用的Region中分配新对象。
这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成时,所以G1收集器在这个阶段实际并没有额外的停顿。 -
并发标记(Concurrent Marking)
从GC Root开始对堆中的对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。
当对象图扫描完成之后,还有重新处理SATB记录下的并发时有引用变动的对象。 -
最终标记(Final Marking)
对用户线程做另一个短暂的暂停,用于处理并发阶段结束后人遗留下的最后那少量的SATE记录。 -
筛选回收(Live Data Counting and Evacuation)
负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收那一部分Region的存活对象复制到空的Region中,再清理掉整个旧的Region的全部空间。这里的操作涉及存回对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
从上述阶段完全可以看出,G1收集器除了并发标记以外,其余阶段也要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设立的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才坦当器“全功能收集器”的重任和期望。
适用场景:
要求尽可能可控GC停顿时间;
内存占用较大的应用。可以用 -XX:+UseG1GC 使用 G1 收集器
jdk9 默认使用 G1 收集器。