闲谈JVM中的垃圾收集机制

前面我们已经聊过了Java的内存区域构成,今天再来看下Java的垃圾收集机制。整体的框架图如下

整个垃圾收集主要分为两个部分,一个是方法论,也就是垃圾收集算法思想;一个是具体实现,也就是七个垃圾收集器。我们先来看下算法思想部分。垃圾收集是针对那些已死的对象,也就是说这一类对象以后不会再被使用。那么我们如何去判定一个对象是否是已死对象呢?一般有两种方法。一是引用计数算法。每当有一个其它对象引用了当前的对象,那么当前对象的引用计数值就加1,那么每次垃圾收集的时候,引用计数为0的对象就是已死对象,可以对其进行回收。但这种方法有一个很严重的缺陷,那就是它无法解决对象之间相互引用的问题。举个简单的例子,假设对象A引用了对象B,对象B也引用了对象A,并且除此之外,再无其他对象引用了A、B这两个对象。按理来说这两个对象都是要进行回收的,因为它们不会再被使用到。但是由于它们之间互相引用,引用计数值不为0,因此,基于引用计数算法实现的垃圾收集器是不会对其进行回收的。另一种判定方法则是可达性分析,这种方法可以有效地解决对象之间相互引用的问题。它的基本思想是:从根节点进行回溯,如果某个对象没有与任何一个跟节点直接或间接相连,那么这个对象就是已死对象。而根节点的选定主要有以下几个:栈帧中本地变量表引用的对象、类静态属性和常量引用的对象,native方法引用的对象。

为了提高收集的效率,基本所有的虚拟机都采用了分代收集的算法思想。也就是把堆分成了老年代和新生代。老年代中存放的都是存活时间较长的对象,这些对象有很大概率会继续长时间存活下去,因此这个区域的对象相对而言是比较稳定的。而新生代中存放的都是刚创建不久的对象,这些对象往往都是“朝生夕死”,存活下来的对象较少。一个新生对象在它的年龄达到设定的阈值后就会被转移到老年代中。针对这两个区域不同的特点,我们就可以采用不同的垃圾收集算法对其进行垃圾收集处理。

垃圾收集算法主要有以下三种:标记清除算法、标记复制算法和标记整理算法。首先我们来看下标记清除算法。它分为两个步骤,先是扫描区域中的对象,标记其中的已死对象,接着清除这些被标记的对象,回收相应的内存空间。它的优点是实现简单,缺点则是每一次收集都会产生大量的空间碎片。这些空间碎片会导致一些大对象由于找不到连续的空闲内存而无法存放的问题,但是实际上该区域的空闲内存可能还很多。为了解决空间碎片的问题,在标记清除的算法上出现了另外两种算法。一个是标记复制算法。它将当前的内存区域分为大小相同的两块,每次只使用其中的一块。当需要进行垃圾收集的时候。就把当前所使用内存区域中的存活对象依次复制到另一个区域中。这种算法虽然很好地解决了空间碎片的问题,但是也带来了另一个问题,那就是内存的浪费。内存资源是非常宝贵的,如果采用这种算法就意味着我们我们只能使用其中的一半内存,这个代价有点高。当然这个问题是有解决方案,待着我们会讲到。另外一种算法是标记整理算法。它也是先标记所有的已死对象,但是在清除已死对象时它不是简单地将其清除,而是将仍存活的对象不断地左移,填充前面已死对象的内存空间,由此消除了空间碎片。但是这种方法效率会比较低。

三种垃圾收集算法我们都知道,接下来就是如何为老年代和新生代选一个合适的算法了。前面我们说过,新生代的对象具备“朝生夕死”的特点,那么使用标记清除算法显然不合适,那样会可能产生很多空间碎片。而标记整理算法显然也不太合适,可能一个对象要往前移动好多的内存位置,效率不高。那就只剩下标记复制算法了。但是我们前面说过这个方法很浪费内存空间,每次只能使用一半。那怎么办呢?根据新生代“朝生夕死”的特点,我们可以大胆地扩大使用的内存空间。把整个新生代按8:1:1划分为Eden区、from Survivor区和to Survivor区。Eden区和From Survivor区就是每次使用的内存区域,而每次垃圾收集后存活的对象就会进入To Survivor区。这里为了保证每次垃圾收集完可使用内存空间的大小保持一致,因此需要两个Survivor区。一般情况下,存活的对象比较少,To Survivor区是放得下的。但是万一真的放不下怎么办,这时就出现了“内存分配担保机制”。如果To Survivor放不下所有的存活对象,就把放不下的那部分对象直接放到老年代中。但是如果老年代也放不下怎么办,那么就触发一次Full GC对老年代进行垃圾回收。那么老年代采用哪种垃圾收集算法进行垃圾收集呢?有很多种模式,一种是直接采用标记整理算法来实现Full GC,这样子每一次Full GC都会进行空间碎片的整理。还有一种是采用标记清除+标记整理来实现,也就是平时都采用标记清除来进行Full GC,当出现进行数次标记清除之后跟着来一次标记整理。这里有一个地方需要注意一下:Full GC翻译过来就是“完整的垃圾收集”,也就是它不只会对老年代进行垃圾收集,还会对新生代也进行垃圾收集。换句话说,新生代的垃圾收集不会触发老年代进行垃圾收集,而老年代的垃圾收集则会触发新生代的垃圾收集。那这样就会出现一个问题,如果我们等待老年代放不下了再进行Full GC的话,我们就会对新生代重复执行一次垃圾收集,浪费一定的时间。因此还有一种解决方案就是,在对新生代进行垃圾收集前,先判断To Survivor区+老年代的剩余空间是否可以放下Eden区+From Survivor区的对象,如果不可以那就直接出发一次Full GC。前面一种属于冒险性的策略,后面一种属于保守型的策略。这个要根据具体情形来进行选择。

至此,垃圾收集的方法论我们已经了解得差不多了。举个例子来小结一下:如果现在我创建了一个对象A,那么首先对象A会被放入新生代中。随着新建对象越来越多,在某一刻新生代中的连续空间已经放不下刚创建的对象时,就会触发一次基于标记复制算法实现的垃圾收集。存活的对象被放入To Survivor。如果To Survivor取放不下,就会触发“内存担保机制”,把那些放不下的对象直接放到老年代中。这里我们假设虚拟机采用的是冒险型的担保机制,那么当老年代的剩余空间也放不下当前的存活对象时,就会触发一次Full GC。对象A每撑过一次垃圾收集,它的年龄就会增加1。当然前提是它没被清除掉。当它的年龄增长到一定的阈值后,就会在下一次垃圾收集中被移到老年代中。当然,并不是所有的新生对象都会被分配到新生代中,某些大对象在刚创建时就会被直接放到老年代中。因为大对象容易导致新生代中还有不少内存空间就提前触发垃圾收集(因为连续的空间放不下这个大对象)。

方法了解完,接下来我们再来看看它的具体实现——垃圾收集器。按照垃圾收集器所使用的区域不同我们可以将其划分为三类,一类是作用于新生代的垃圾收集器,比如Serial收集器、ParNew收集器和Parallel Scavenge收集器,另一类是作用于老年代的垃圾收集器,比如:Serial Old收集器、Parallel Old收集器和CMS收集器,还有一类则是作用于两个区域的垃圾收集器,G1收集器。我们大致来过一下这些垃圾收集器。

首先是Serial收集器,它是个单线程的垃圾收集器,当它执行时需要“Stop The World”,也就是暂停用户线程,它适用于资源使用较少的Server端,比如桌面应用等。如果它回收的垃圾在100M之内,它的停顿时间可以被控制在几十毫秒以内。

其次是ParNew收集器,它和Serial收集器基本一样,唯一的不同就在于它是一个多线程的垃圾收集器。可能有人会觉得奇怪,它既然是多线程,那为什么不能让出一条线程来执行用户程序,而把所有的线程都用于垃圾收集呢?原因就是在用户线程执行的过程中很可能会出现新的垃圾。那么就相当于当它一边在清理垃圾时,用户同样在忙着生产垃圾。这就好比你的父母在给你打扫房间时,你在地方玩泥巴。显然你的父母也会崩溃的。那么我们如何去选中这两个垃圾收集器呢?主要取决于CPU资源。如果你的CPU资源足够大,那么你就可以选择ParNew收集器,否则就选择Serial收集器。因为CPU资源是有限的,如果你的CPU资源不够大还使用ParNew收集器,那么就会因为ParNew收集器占用了过多的CPU资源而导致用户程序变慢。

最后一个新生代收集器时Parallel Scavenge收集器。它也被称为“吞吐量优先”收集器,它关注的是如何充分利用CPU时间,尽快地完成垃圾收集算法,而不在乎停顿时间。也就是它不会关注是否因为占用过多的CPU资源而导致用户程序变慢,可能其它收集器会因为用户程序变慢了而降低自己所使用的CPU资源,但是Parallel Scavenge则不管,怎么做有利于充分利用CPU资源它就怎么来。因为它使用与交互性较少的后台,也就是Server端。

了解完以上三个新生代收集器,我们来了解一下老年代的收集器。很多老年代收集的算法实现和新生代都差不多。只不过由于老年代和新生代的内存区域划分不同,所以需要开发不同的收集器。比如Serial Old 收集器就是Serial收集器的老年代版本。它也会被作为CMS的后背预案收集器,主要是为了解决CMS收集器无法处理的“浮动垃圾”问题。Parallel Old收集器就是Parallel Scavenge收集器的老年代版本。当你追求吞吐量优先时,可以选择Parallel Scavenge+Parallel Old的垃圾收集器组合。

最后一个老年代的收集器CMS,全称为“Concurrent Mark Sweep”。它的设计目标是追求最低的停顿时间。它的收集步骤分为四个:初始标记、并发标记、重新标记和并发收集。初始标记主要就是标记一下那些和GC Root直接相连的对象,这个阶段需要停止用户线程。并发标记则是进行Root Tracing,这个阶段耗时比较长,但是它可以和用户同时执行。重新标记则是根据并发标记阶段进程所做的一些操作来修正标记,这个阶段也要停止用户线程。最后是并发收集,这个阶段也可以和用户线程并发执行。因为最耗时的两个步骤都可以和用户线程同步执行,因此用户线程的停顿时间是比较短的。但是它也有一些问题。一个就是前面我们所说的“浮动垃圾”,也就是在并发清理阶段用户线程产生的垃圾。因为CMS是可以和用户线程同步执行的,那么它就必须要预留出一部分内存空间给垃圾收集过程中执行的用户线程使用。那么当CMS的预留空间已经不足时,就会临时触发备用预案,启动Serial Old收集器重新对老年代进行一次垃圾收集。

最新的一个垃圾收集器是G1收集器,它实现了单个收集器同时可以出来新生代和老年代的垃圾收集。最大的一个优点就是停顿时间更加可控。它把堆分成了许多个Region区域,每次垃圾收集时都会在停顿时间限制内,优先对回收价值最高的区域进行回收。这样子就可以在有限的时间限制内,回收到最多的可用内存空间。

最后小结一下:如果追求较小的延迟时间,可用考虑采用Serial+CMS或者ParNew+CMS。如果追求最大吞吐量,那么可以考虑采用Parallel Scavenge+Parallel Old。目前G1收集器由于还不是很成熟,使用得还是比较少。

 

该博文是本人阅读完《深入理解Java虚拟机》后做的一个知识点整合,更注重知识的关联性和完整性,因此不像其他博客一样有大小标题。没有JVM基础的建议先去看我的另外两篇博客《内存分配与回收策略(笔记)》《垃圾收集算法实现与垃圾收集器(笔记)》

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值