上一篇我们介绍了对象在堆内的内存布局已经占用空间的大小,同时分析了堆内可以分为Young区和Old区,而且Young区可以分为Eden区和Survivor区,Survivor区又拆分成了两个大小一样的区S0和S1区域,其实这么拆分的理由和GC是密切相关的,那么这一篇文章就让我们深入了解一下Java中的垃圾收集机制。
在垃圾收集的时候第一件事就是怎么确定一个对象是垃圾,那么该如何确定一个对象已经可以被回收了呢?主流的算法有两种:引用计数法和可达性分析算法
这个算法很简单,效率也非常高。就是给每个对象添加一个引用计数器,每当有一个地方引用它时,计数器的值就加1,当引用失效时,计数器的值就减1,当计数器的值减为0时就表名这个对象不会再被使用,成为了无用对象,可以被回收。
这种算法虽然实现简单,效率也高,但是存在一个问题,我们看下面一个场景:
上图中4个对象相互引用,但是并没有其他对象去引用他们,这种对象实际上也是无效对象,但是他们的引用计数器都是1而不是0,所以引用计数法没办法解决这种“一坨垃圾”的场景。
可达性分析算法就是选择一些对象作为起始点,这些对象称之为:GC Root。然后从GC Root开始向下搜索,搜索路径称之为引用链(Reference Chain),当一个对象不在任何一条引用链上时,就说明此对象是无效对象,可以被回收。
比如说下面这幅图,右边那一串互相引用的对象因为没有不在GC Root的引用链上,所以就是无效对象,可达性分析算法有效的解决了互相引用对象无法回收问题。
在Java中,可以作为GC Root对象的包括下面几种:
注意:在分析对象的过程中,为了确保结果的准确性,需要保证分析过程中对象引用关系不会发生变化,而为了达到这个目的,就需要暂停用户线程,这种操作也叫:Stop The World(STW)。
上面两种算法其实都是一个目的,判断对象有没有被引用,而引用也不仅仅都是一样的引用,JDK1.2开始,Java中将引用进行了分类,划分成了四种引用,分别是:强引用,软引用,弱引用,虚引用。这四种引用关系的强度为:强引用>软引用>弱引用>虚引用。
我们写的代码中一般都是用的强引用,如:Object obj = new Object()这种就属于强引用,强引用只要还存在,一定不会被回收,空间不够就直接抛出OOM异常
软引用是通过SoftReference类来实现的。软引用可以用来表示一些还有用但又是非必需的对象,系统在即将溢出之前,如果发现有软引用的对象存在,会对其进行二次回收,回收之后内存还是不够,就会抛出OOM异常。
弱引用是通过WeakReference类来实现的。弱引用也是用来表示非必需对象的,但是相比较软引用,弱引用的对象会在第一次垃圾回收的时候就被回收掉。
虚引用是通过PhantomReference类来实现的,也被成为幽灵引用或者幻影引用。这是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不对其生存时间构成影响。也无法通过虚引用来取得一个对象实例。设置为虚引用的唯一用处可能就是当这个对象被回收的时候可以收到一个系统通知。
上面分析了如何确定一个对象属于可回收对象的两种算法,那么当一个对象被确定为垃圾之后,就需要对其进行回收,回收也有不同的算法,下面就来看一下常用的垃圾收集算法
标记-清除算法主要分为两步,标记(Mark)和清除(Sweep)。
比如说有下面一块内存区域(白色-未使用,灰色-无引用,蓝色-有引用):
然后标记-清除算法会进行如下两个步骤:
标记清除之后得到如下图所示:
可以很明显看到,回收之后内存空间是不连续的,产生了大量的内存空间碎片。过多内存碎片最直接的就是可以导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
1、标记和清除两个过程都比较耗时,效率不高
2、会产生大量不连续的内存碎片。
为了解决这两个问题,所以就有了复制算法。
复制算法的思想就是把内存区域一分为二,两块内存保持一样的大小,每次只使用其中的一块,当其中一块内存使用完了之后,将仍然存活的对象复制到另一块内存区域,然后把已使用的一半内存全部一次性清理掉。
如下图(绿色表示暂时不放对象的一半空间):
回收之后:
复制算法的缺点就是牺牲了一半的内存空间,有点过于浪费。
Java堆内存中做了好几次划分,最后是将Survivor区分成了2个区域S0和S1来进行复制算法,这种做法就是为了弥补原始复制算法直接将一半的空间作为空闲空间方式的弥补。
IBM公司的研究表明,Young区(新生代)中98%的对象都是“朝生夕死”的,生命周期极短,所以说在一次GC之后能存活下来的对象很少,完全没必要划分一半的区间来进行复制算法。Hot Spot虚拟机中Eden区和Survivor区域的比例为:Eden:S0:S1=8:1:1,也就是说其实只有10%的空间被浪费掉,完全是可以接受的。
我们想一下,假如Young区(新生代)的对象在一次GC之后,基本所有对象都存活下来了,那就需要复制大量的对象,效率也会变低。而堆中的old区(老年代)的特点就是对象生命周期极为顽强,因为默认要进行第16次垃圾回收的时候还能存活下来的对象才会放到老年代,所以对老年代中对象的回收一般不会选择标记-复制算法。
标记-整理算法就是为了老年代而设计的一种算法,标记-整理算法和标记清除算法的区别就是在最后一步,标记-整理算法不会对对象进行清理,而是进行移动,将存活的对象全部向一端移动,然后清理掉端边界以外的对象。如下图所示:
回收前:
回收后:
目前主流的商业虚拟机都是采用的分代收集算法,这种算法本质上就是上面介绍的算法的结合体。新生代采用标记-清除算法,老年代采用标记-清除或者标记-整理算法。
上面介绍了确定对象的算法以及回收对象的算法,然后具体要怎么落地却并没有一个规定,而垃圾收集器就是实现了对算法的落地,而因为落地形式不同,自然也产生了很多不同的收集器。下面是一张收集器的汇总图:
上面一半表示新生代收集器,下面一半表示老年代收集器,横跨中间的表示都可以用。
根据这个图形有了整体认知之后,我们再来一个个看看这些垃圾收集器的工作原理吧。
Serial收集器是基本、发展历史悠久的收集器,在JDK1.3.1之前是虚拟机新生代收集的唯 一选择。
Serial收集器是一种单线程收集器,而且是在进行垃圾收集的时候需要暂停所有其他线程,也就是说触发了GC的时候,用户线程是暂停的,如果GC时间过长,用户是可以明显感知到卡顿的。
Serial Old是Serial的一个老年代版本,也是一种单线程收集器。
可以用下面一个图形来表示一下Serial和Serial Old收集器的工作原理:
优点:简单高效,拥有很高的单线程收集效率
缺点:收集过程需要暂停所有线程
算法:Serial采用复制算法,Serial Old采用标记-整理算法
适用范围:Serial用于新生代,Serial Old用于老年代
应用:Client模式下的默认的收集器
ParNew收集器是Serial收集器的多线程版本,实现了并行执行,其余工作原理都和Serial一致。可以使用参数:-XX:+UseParNewGC来指定使用。
注意:这里的并行指的是多个GC线程并行,但是其他线程还是暂停,而并发指的是用户线程和GC线程同时执行。
ParNew收集器默认开启和CPU个数相同的线程数来进行回收,可以使用参数:-XX:ParallelGCThreads来限制线程数
ParNew收集器工作原理如下图:
优点:在多CPU时,比Serial效率高。
缺点:收集过程暂停所有应用程序线程,单CPU时比Serial效率差
算法:复制算法
适用范围:新生代
应用:运行在Server模式下的虚拟机中首选的新生代收集器
Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,和ParNew一样也是一个并行的多线程收集器,Parallel Scanvenge收集器相比较于ParNew收集器,更关注与提升系统的吞吐量。
吞吐量指的是CPU用于运行用户代码的而时间于CPU总消耗时间的比值。
即:吞吐量=运行用户代码时间/(运行用户代码时间+GC时间)
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量:
`-XX:MaxGCPauseMillis//GC最大停顿毫秒数,必须大于0 -XX:GCTimeRatio//设置吞吐量大小,大于0小于100,默认值为99` * 1 * 2
我们思考一个问题,假如我们通过参数把允许最大停顿毫秒数设置的相对较小会怎么样?是不是GC速度就会变快了?
答案是否定的。如果设置的时间过短,Parallel Scavenge收集器会牺牲吞吐量和新生代空间来交换。
比如新生代400Mb需要GC时间为100ms,然后手动设置为50ms,那么就会把新生代调小为200Mb,这样肯定时间就降下来了,然而这种操作可能会降低吞吐量,假如说原先是10s触发一次GC,每次100ms,修改时间后编程5s触发一次GC,每次70ms,那么10s触发两次GC时间就变成了140ms,吞吐量反而降低。
如果不知道如何设置,那么还可以通过参数:-XX:+UseAdaptiveSizePolicy开启自适应策略(GC Ergonomics),这样我们就不需要手动设置吞吐量和GC停顿时间了,虚拟机会根据运行情况手机监控信息来动态调整。
Paralled Old收集器是Parallel Scavenge收集器的老年代版本,但是这个收集器是jdk1.6之后才出现的,所以导致了在Paralled Old收集器出现之前Parallel Scavenge收集器一直找不到合适的“搭档”。因为Parallel Scavenge收集器没办法和CMS收集器配合使用(后面会介绍原因),所以在Paralled Old收集器出现之前,如果新生代选择了Parallel Scavenge收集器,那么老年代就只能选择Serial Old收集器,而Serial Old收集器是单线程的,所以单单只是新生代替换成了多线程的吞吐量收集器Parallel Scavenge,在性能上并不一定有多少提升。
在注重吞吐量的业务系统中,可以考虑Parallel Scavenge+Paralled Old收集器配合使用,结合使用后的工作原理如下图所示:
PS:在jdk1.8中,默认收集器就是Parallel Scavenge+Parallel Old组合
这是一种以实现GC时最短停顿时间为目标的收集器,也是一款真正实现了并发回收的收集器。当然,虽然是并发的,但是仍然需要Stop The World,只是尽可能将这个时间缩到最短。
对于任何暂停时间要求较低的应用程序,都应该考虑使用此收集器。CMS收集器可以通过参数:-XX:+UseConcMarkSweepGC启用。
CMS收集器是基于算法标记-清除来实现的,整个过程分为4步:
CMS收集过程如下图所示:
上面的步骤中,步骤2是并发标记,所以在标记过程中,可能会有新的垃圾产生而没有被标记到。比如说对象A,刚扫描的时候是有效对象,然后继续扫描的时候,对象A又变成不可用了,然后还有并发清除的阶段,也可能会有新的垃圾产生,这种就称之为浮动垃圾(Floating Garbage)。CMS并不能收集浮动垃圾,只能等到下一次GC时再回收。
CMS收集器不能和其他收集器一样等到空间满了才开始触发GC,因为CMS收集的时候是并发的,并发的过程肯定会持续产生对象,如果因为在垃圾收集期间内存不足而导致了GC失败,就称之为Concurrent Mode Failure。出现这种情况之后,Java虚拟机就会启动预备方案,启用Serial Old收集器替换CMS收集器,这时候整个GC过程都会Stop The World。
CMS收集器的触发阈值可以通过参数:-XX:CMSInitiatingOccupancyFraction=来进行设置,N为(0,100)之间,在jdk1.6中默认是92,即老年代空间使用率达到92%就会触发CMS收集器开始进行垃圾回收。
G1也是以实现GC时最短停顿时间为目标并发回收的收集器,它尝试以高概率满足垃圾收集(GC)暂停时间目标,同时实现高吞吐量。
在G1之前的其他收集器都是属于分代收集器,也就是说一个收集器要不然用于新生代,要不然就是用于老年代,而G1中,将堆的整个内存布局做了很大的修改,在G1中,将整个Java堆划分为多个大小相等的独立区域(Region),虽然在逻辑上还保留了新生代和老年代的概念,但是物理上已经没有隔离了。
G1收集器中堆内布局如下图所示:
上图中堆被划分为一组大小相同的Region,每个Region都是连续的虚拟内存范围。
G1可以知道哪个Region区域内大部分都是空的,这样就可以在每次允许的收集时间内去优先回收价值最大的Region区域(根据回收所获得的空间大小以及回收所需要的时间综合考虑),所以这也就是G1为什么叫做Garbage-First的原因。
PS:G1是JDK1.9的默认垃圾收集器
经过上面的简单介绍,可以得出G1主要有以下特点:
G1收集器在工作流程上和CMS比较相似,只是在最后的步骤有所区别,主要经过了如下4个步骤:
工作流程图如下所示:
G1的第一个重点是为运行需要大堆且GC延迟有限的应用程序的用户提供解决方案。这意味着堆大小大约为6GB或更大,并且稳定且可预测的暂停时间低于0.5秒。
如果我们的应用程序具有以下一个或多个特性,那么可以考虑切换到G1收集器。
垃圾收集器主要可以分为如下三大类:
本文主要介绍了确定无效对象的两种算法,并且结合垃圾收集算法介绍了不同类型的落地形式而产生的不同垃圾收集器,本文将对比较偏向于理论,下一篇开始,JVM系列文章将会结合JVM系列前5篇文章来进一步结合实际场景以及相关监控工具的使用来进行实际场景分析。