JVM之垃圾收集器

前言

《Java虚拟机规范》中并没有关于垃圾收集器的相关章节,所以本篇文章的内容将完全参考周志明老师的书籍,目的同样是归纳总结形成自己的理解。虚拟机的运行时数据区可划分为程序计数器、Java虚拟机栈、本地方法栈、堆、方法区,其中前三者随着线程的创建而建立,后两者随着虚拟机的启动而创建,所以在线程结束时,这部分的内容也会随之回收,而对于方法区来说,有多少类需要加载都只能在运行时才能确定,这部分的内存需要动态分配与回收,所以垃圾收集器关注的正是这部分的内存区域该如何管理。

哪些对象该被回收

考虑回收之前应该明确哪些对象该被回收,简单来说就是哪些对象不在使用,书中介绍了引用计数法可达性分析法

引用计数法:在对象中添加一个引用计数器,有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能在被使用的。这种方式看似很简单,实际上隐藏着很多缺点,比如它很难解决对象之间相互循环引用的问题。

可达性分析法:通过GC Roots作为根对象开始,根据引用关系向下搜索,搜索过程所走过的路径称为"引用链",如果对象到GC Roots间没有任何引用链相连或者说GC Roots到对象不可达时,则证明此对象是不可能在被使用。其中可作为GC Roots对象的Java虚拟机栈中引用的对象、静态属性引用的对象、常量引用的对象、本地方法栈中引用的对象、Java虚拟机内部的引用、所有持有同步锁的对象等。

现在已经知道了哪些对象要被回收,下一个问题就是该考虑什么时候回收、如何回收的问题了,关于这些问题应该具体到某一种垃圾收集器来详细说明,在这之前先介绍垃圾回收的算法。

垃圾回收算法

  • 标记清除

通过上面介绍的方式进行标记,在标记完成后回收所有被标记的对象。看着很简单实际上存在问题,第一个方面是:执行效率不稳定,当堆中包含大量对象,且这些对象是要被回收了,那么就必须要进行大量地标记和清除,这两个动作的执行效率会随着对象数量的增长而逐渐降低;第二个方面是:内存空间的碎片化,在执行标记清除动作后会产生不连续的内存碎片,当以后想为大对象分配连续的内存时不得不提前触发下一次垃圾收集,以便腾出更大的内存空间来使用。如图所示:

标记清除

  • 标记复制

将原有的内存空间分为大小相等的两块,每次只使用其中一块,发生垃圾收集时,将正在使用的一块内存中的存活对象复制到另一块内存中(存活对象依次排列),接着清除正在使用的内存块中的所有对象。说说优缺点,一方面是:如果正在使用的内存块中有多数对象是存活的,这种算法在内存间上的复制上将会有一定的开销,相反,对于有少数存活对象来说,这种算法就显得简单高效;另一方面是:由于每次垃圾收集时都是对其中一块内存区域进行操作,所以在分配内存时就不用考虑内存的碎片化问题,只需要按顺序分配即可;第三方面是:既然将内存划分成两块,那么能使用的内存就变得少了。优秀的设计者总会想方设法的规避缺点,提出了更优化的复制策略,将新生代划分为一块较大的Eden空间、两块较小的Survivor空间(HotSpot虚拟机默认Eden和Survivor的大小比例是8:1)。其中两块Survivor空间又被称为from、to空间,且大小相等,可进行角色互换,用于存放存活的对象。接下来介绍下这几个区域是如何相互协作的,关于这部分内容其实我很纠结,周志明老师基本上都是点到为止,没有提及重要的点,比如两个Survivor空间是如何协作的,为了能吃透这部分知识还专门看了《实战Java虚拟机》,至少提到了相关的内容,但仍然还是不够精准,比如from区与to区是如何进行角色转换的,后面又去搜了相关视频,所以下面阐述的理论更多的是结合以上三方加上自己的理解得出来的。

上面介绍说有一块Eden区、一块from区、一块to区,按照我的理解,Eden区和from区是可以用来分配对象的,to区则是用于存放存活的对象,也就是说将eden区和from区的存活对象复制到to区,然后在将这两片区域清空,而此时的to区将变成from区,from区变成to区,下次回收仍然按照上面的步骤,这样子的优化措施提高了内存空间的使用率。对象每经过一次垃圾回收后其年龄都会加1,当其年龄达到15岁时就会进入到老年代中,周志明老师还提出并不是所有的对象都会按照指定年龄进入到老年代中,当survivior区中相同年龄的对象大小总和大于survivor区的一半时,其大于或等于该年龄的对象会直接进入到老年代中,还有一种情况就是当to区不足以容纳存活的对象时,这些对象将通过分配担保机制进入到老年代中,如果说老年代的空间也不足以存放对象时,将会触发Full GC,新生代的垃圾回收叫做Minjor GC。如图所示:

标记复制-1

标记复制-2

标记复制-3

  • 标记整理

与标记清除不同的是,在标记完成后不是直接清除所有被标记的对象,而是让所有存活的对象往内存空间一侧移动,然后在清除掉边界以外的内存。说下优缺点,一方面是:避免了内存碎片化,相比标记复制算法来说内存使用率增大了;另外一方面是:在对存活对象进行移动时,为了更新存活对象的引用位置,同时尽可能地减小存活对象之间的引用关系发生变化,在发生回收时需要全程暂停用户线程,这将导致用户线程的响应时间被拉长,整体的吞吐量就会呈现下降的趋势。如图所示:

标记整理

概念

  • 空间分配担保

从上面的知识点可以知道每次发生Minor GC可能有会对象转移到老年代中,需要考虑的是老年代中是否有足够的空间可以存放,也就是说在执行Minor GC之前要先判断老年代是否有足够的空间,由于还未执行Minor GC所以并不知道存活的对象有多少个,不过可以判断老年代的可用空间是否大于新生代所有对象总空间,如果大于,那么执行Minor GC后将存活的对象转移到老年代中自然也就没问题了;如果小于,虚拟机会先查看HandlePromotionFailure参数值是否允许担保失败;如果允许,会继续判断老年代的可用空间是否大于之前晋升到老年代的存活对象的平均大小,也就是拿过往的经验值来判断,这种做法具有风险性,就跟赌博一样...为什么说有风险呢,经验只能拿来参考,不具有实际意义,在实际发生Minor GC后仍然有可能造成存活的对象比之前多了多,导致老年代存不下,最终还是回到了要执行Full GC的结局,绕了一圈又绕回来了;如果大于,将执行Minor GC,有可能因为存活对象的增加导致结果执行了Full GC;如果小于或者说HandlePromotionFailure不允许担保失败,那就只能老老实实执行Full GC。其实设置HandlePromotionFailure参数值是为了尽可能地避免执行Full GC,因为它会增大用户线程的执行时间,Java 6之后的规则变成了老年代的可用空间大于新生代所有对象总大小或之前晋升的平均大小,则执行Minor GC,否则执行Full GC,也就是说直接默认HandlePromotionFailure参数值为true了,不在判断是否允许担保失败了。

  • Stop The World

如果老年代满了则触发Full GC,同时回收新生代和老年代,它会造成Stop The World,造成很大的开销。Stop The World指的是在执行GC期间,只有垃圾收集线程在工作,其他用户线程会被挂起。

  • Safe Point

我们知道判断对象是否存活是通过GC Roots,而在程序的运行过程中其引用关系可能会发生变化,总不可能一旦发生变化就记录,而我们知道只有垃圾收集器会使用到它,那为什么不在垃圾收集前在更新呢,因为垃圾收集会影响性能,所以不能在任意位置触发GC,而是要求必须在一个适合的位置才被允许,这个合适的位置称为安全点,也就是在此位置更新了GC Roots,

  • Safe Region

还有一种场景,当程序没有被分配处理器时间片的话,也就是说当用户线程处于Sleep状态或Blocked状态,这时候线程无法响应虚拟机的中断请求,不能走到安全点去中断挂起自己,虚拟机显然也无法等待线程被激活,针对这种情况引入了安全区域,指能够确保在某一段代码片段之中,引用关系不会发生变化,也就是说GC Roots是不会发生变化的,所以在这块区域的任意地方开始垃圾收集都是安全的。当用户线程执行到安全区域里的代码时,首先会标识自己已经进入了安全区域,在这段时间里虚拟机可以直接发起垃圾收集;当线程要离开安全区域时,它要检查虚拟机是否已经完成GC Roots扫描,如果已经完成则线程继续执行,当作什么都没有发生过,否则就要一直等待着,直到收到可以离开安全区域的信号为止。

  • 记忆集与卡表

根据对象的存活时间长短来将其分配到新生代或老年代,但有可能存在新生代与老年代的对象互相引用,为了避免把关联区域(某个区域的对象被其他区域的对象所引用,其他区域就可以称作关联区域)的所有对象都加入到GC Roots扫描范围(为了保证可达性的准确性,通常会将关联区域的对象也一并加入到GC Roots),引出来了记忆集:一种用于记录非收集区域指向收集区域的指针集合的抽象数据结构,简单来说是非收集区域是否存在有指向了收集区域的指针。个人理解关联区域指的就是非收集区域(不一定正确),而卡表就是记忆集的具体实现,就跟HashMap与Map的关系一样。卡表是一个字节数组,数组中的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作卡页,只要卡页内有一个对象的字段存在着跨代引用,那就将卡表的数组元素的值标识为1,称这个元素便脏了,没有则标识为0,就不需要在将关联区域中的所有对象都加入到GC Roots扫描范围中了,只需要筛选出卡表中变脏的元素,就能知道哪些内存块包含跨代引用,将其加入到GC Roots。关于记忆集与卡表的描述其实很模糊,书中阐述的知识点其实也就这么多。

垃圾收集器

  • Serial/Serial Old收集器

Serial收集器工作在新生代,通过单线程的方式,也就是说只有使用一个处理器或者说一个收集线程去完成垃圾收集工作,不仅如此,在它进行垃圾收集时,必须暂停其他用户线程(STW),直到它收集结束。Serial Old收集器则是工作在老年代,同时也可以作为CMS收集器(另一款收集器)的替代品。Serial/Serial Old虽然是历史最悠久的收集器,但即使到了现在仍然有它的一席之地。如图所示:

serial/serialOld收集器

优点:依然是HotSpot虚拟机运行在客户端模式下的默认收集器,相比于其他收集器来说,简单高效是其一大特点;对于内存资源受限或者处理器核心数较少的环境来说,由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

缺点:暂停用户线程导致响应时间变长,很是影响体验。

使用场景:只要适当降低停顿时间让用户不可知仍然是一款优秀的收集器,适用于虚拟机占用内存小的应用,比如用户桌面的应用场景、微服务,只要给虚拟机分配较少的内存,其垃圾收集器造成的停顿时间完全可以控制在几十、最多一百毫秒内,所以适用于客户端模式下的虚拟机。

  • ParNew收集器

ParNew收集器工作在新生代,本质上是Serial收集器的多线程并行版本,除此之外,其余的行为包括垃圾算法、STW、对象分配规则、回收策略等都与Serial收集器完全一致。除了Serial收集器之外,目前就只有它能与CMS(另一款收集器)配合工作。如图所示:

parNew收集器

优点:由于其多线程的特性加快了垃圾收集,降低了用户线程的停顿时间。

缺点:仍然需要停顿时间。

使用场景:其多线程特性注定了它只能在多核心处理器的环境下使用,倘若在单核心处理器下使用,由于存在线程交互的开销并不一定会比Serial优秀,很大程度上降低了停顿时间,说明提升了响应时间,所以适合于服务端模式下的虚拟机。

  • Parallel Scavenge收集器

Parallel Scavenge收集器工作在新生代,也是一款能够并行收集的多线程收集器,与ParNew或者其他收集器不同的是其关注点,ParNew等收集器关注的是尽可能地缩短用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量,所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即吞吐量 = 运行用户代码时间/(运行用户代码时间 + 运行垃圾收集时间)。Parallel Scavenge收集器提供了一个参数,激活该参数后就不需要人工指定新生代的大小、Eden与Survivor区的比例、晋升老年代对象的大小等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式被称为自适应的调节策略。如图所示:

parallel scavenge收集器

优点:由于其多线程的特性加快了垃圾收集,降低了用户线程的停顿时间;自适应调节策略。

缺点:仍然需要停顿时间。

使用场景:高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的分析任务。

  • Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,两者的组合实现了真正的吞吐量优先。如图所示:

parallel old收集器

优点:由于其多线程的特性加快了垃圾收集,降低了用户线程的停顿时间。

缺点:仍然需要停顿时间。

使用场景:在注重吞吐量的场合,组合Parallel Scanvenge收集器一起使用。

  • CMS收集器

CMS(Concurrent Mark Sweep)收集器工作在老年代,上面提到的老年代收集器都是采用标记整理,CMS采用的是标记清除,其运作过程相对来说更加复杂,整个过程分为四个步骤:1、初始标记;2、并发标记;3、重新标记;4、并发清除。

初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。

并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,该过程耗时较长但不需要暂停用户线程,可以与垃圾收集线程一起并发运行。

重新标记:为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,该过程的停顿时间通过会比初始标记过程稍长一些,但也远比并发标记过程的时间短。

并发清除:清除标记已经死亡的对象,由于不移动存活对象(标记清除),所以该过程也是可以与用户线程同时并发的。

如图所示:

cms收集器

优点:部分过程与用户线程并发执行,极大程度地降低停顿时间。

缺点:1、CMS收集器对处理器资源非常敏感,在并发过程,虽然不会导致用户线程停顿,但却会因为占用了一部分线程而导致应用程序变慢,降低总吞吐量,如果应用本来的处理器负载就很高,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅度降低;2、在CMS的并发标记与并发清除过程,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉,这一部分垃圾称为"浮动垃圾"。同样也是由于在垃圾收集过程中用户线程还需要持续运行,那就还需要预留足够的内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用,要是CMS运行期间预留的内存无法满足程序分配新对象,就会出现一次"并发失败",这时候虚拟机将会冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的收集,而Serial Old收集器采用的单线程进行收集,将会使停顿时间更长;3、CMS采用的是标记清除,该算法将会产生大量的内存碎片,碎片太多将会导致无法给大对象分配空间,而结果就是不得不提前触发一次Full GC。

使用场景:适用于要求低停顿时间或者响应时间的应用程序。

  • G1收集器

G1(Garbage First)收集器不存在说工作在新生代或者老年代了,它将堆分成多个大小相等的独立区域(Region),每一个Region可以根据需要扮演新生代的Eden空间、Survivor空间或者老年代空间,能够对扮演不同角色的Region采用不同的策略去处理。还有一类特殊的Humongous区域,专门用来存储大对象,G1认为只要大小超过Region容量一半的对象即可判定为大对象,对于超过整个Region容量的超级大对象,将会被存放在N个连续的Humongous区域中。如图所示:

g1 region

继续说说它是如何工作的,它不在对整个堆进行全区域的垃圾收集,而是优先回收有"价值"的Region,G1收集器会跟踪每个Region里垃圾堆积的"价值"大小,其中价值指的是回收所获得的空间大小以及回收所需要时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定的停顿时间来优先处理回收价值收益最大的Region,保证在有限的时间内尽可能高的收集效率。整个过程分为四个步骤:1、初始标记;2、并发标记;3、最终标记;4、筛选回收。前面三个同CMS收集器基本相似就不过多介绍了,说说第四个过程。

筛选回收:标记结果后就应该更新每个Region的"价值",对这些Region进行排序,并根据用户期望的停顿时间制定计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,在清理掉整个旧Region的全部空间。

如图所示:

g1收集器

优点:在有限的停顿时间内尽可能获取高吞吐量;不会产生内存碎片化;

缺点:1、我们知道通过维护记忆集来避免全局扫描GC Roots,既然分成了多个Region,那么Region中的对象自然也会存在跨代引用的情况,所以每个Region自然都要维护一个记忆集,结果就是它所需要的内存可能比传统的收集器更高;2、同样是与用户线程并发执行,自然也要像CMS那样预留空间给用户线程使用来创建新对象,如果内存的收集速度赶不上内存分配的速度,同样会出现"并发失败",结果也是要冻结用户线程执行Full GC。

使用场景:适用于大内存的应用且可控停顿时间的应用程序。

结束语

以上的几款收集器都只是停留在理论上,更多的还是应该根据不同的应用进行实际测试才能得出最合适的结论,高版本的JDK中还出现了ZGC收集器,理解起来挺费劲的,有兴趣的读者可以去了解下,毕竟JDK9还没玩的人怎么敢去碰这些呢。

参考链接

《深入Java虚拟机》
《实战Java虚拟机》
https://mp.weixin.qq.com/s/_AKQs-xXDHlk84HbwKUzOw

posted @ 2020-12-21 19:20  zliawk  阅读(80)  评论(0)    收藏  举报