JVM-垃圾收集入门

垃圾收集概述

对Java程序员而言不需要显式地管理对象的生命周期:我们可以在需要时创建对象,对象不再被使用时,会被JVM在后台自动进行回收。那为什么我们还要去了解GC和内存分配?

答案很简单:当需要排查各种内存溢出、内存泄露问题时或者当垃圾收集成为系统达到更高并发量的瓶颈时,就需要对这些“自动化”的技术实施必要的监控和调节。

垃圾收集器所关注的内存分配和回收的区域为Java堆和方法区。一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建那些对象。

简单来说,垃圾收集由两步构成:查找不再使用的对象(垃圾对象),以及释放这些对象所管理的内存。

查找不再使用的对象

引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。

客观地说,引用计数算法的实现简单,判定效率也很高。但是,至少主流的Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。

可达性分析算法

在主流的商用程序语言(Java、C#,甚至包括前面提到的古老的Lisp)的主流实现中,都是称通过可达性分析来判定对象是否存活的。这个算法的基本思路就是 通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,将会被判定为可回收的对象。

在Java语言中,可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中国中JNI(即一般说的Native方法)引用的对象

再谈引用

无论通过哪种算法查找不再使用的对象,判定对象是否存活都与“引用”有关。在JDK 1.2以前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态。

在JDK 1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4钟,这4钟引用强度依次逐渐减弱。

  • 强引用就是指在程序代码之中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收被引用的对象。
  • 软引用是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
  • 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否虚拟引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。JDK 1.2之后,提供了PhantomReference类来实现虚引用。

回收过程

如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。

任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()不会被再次执行。

方法区回收

很多人认为方法区(或者Hotspot虚拟机中的永久代)是没有垃圾收集的,Java虚拟机规范中不要求虚拟机在方法区实现垃圾收集,而且在方法区中进行垃圾收集的“性价比”一般比较低:在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而永久代的垃圾收集效率远低于此。

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量与回收Java堆中的对象非常类似,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足3个条件才能算是“无用的类”:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法再任何通过反射访问该类的方法。

虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制。

在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

垃圾收集算法

标记-清除算法(Mark-Sweep)

最基础的收集算法是“标记-清除”算法,该算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它的主要不足有两个:

  • 效率问题:标记和清除两个过程的效率都不高

  • 空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。标记—清除算法的执行过程如下图所示:

    《JVM-垃圾收集入门》

复制算法

为了解决效率问题,一种称为“复制(Copying)”的收集算法出现了,它将可用空间按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。这种算法的代价是将内存缩小为原来的一半,未免太高了一点。复制算的执行过程如下图所示:

《JVM-垃圾收集入门》

现在的商业虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生代的对象98%是“朝生夕死”的,所以不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和用过的Survivor空间。当Survivor空间不够用时,需要依赖其他内存进行分配担保。

标记-整理算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。所以在老年代一般不能直接选用这种算法。

根据老年代的特点,提出了“标记-整理”(Mark-Compat)算法,该算法的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理端边界以外的内存。”标记 – 整理”算法的示意图如下图:

《JVM-垃圾收集入门》

分代收集算法

当前商业虚拟机的垃圾收集都采用“分代收集”算法,该算法根据对象存活周期的不同将内存划分为几块,这样就可以根据各个年代的特点采用最适当的收集算法。一般是把Java堆分为”新生代(Young Generation)和”老年代(Old Generation或Tenured Generation)”,新生代又被进一步划分为不同区域,分别称为Eden空间和Survivor空间。

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收。

所有的垃圾收集算法在新生代进行垃圾回收时都存在“时空停顿”现象。所有应用线程都停止运行所产生的停顿称为时空停顿(stop-the-world)。通常这些停顿对应用的性能影响最大,调优垃圾收集时,尽量减少这种停顿是最为关键的考量因素。

Minor GC

新生代是堆的一部分,对象首先在新生代中分配。新生代填满时,垃圾收集器会暂停所有的应用程序,回收新生代空间。不再使用的对象会被回收,仍然在使用的对象会被移动到其他地方。这种操作被称为Minor GC。

Full GC

对象不断地被移动到老年代,最终老年代也会被填满,JVM需要找到老年代中不再使用的对象,并对它们进行回收。这个过程被称为Full GC,通常导致应用程序长时间的停顿。

垃圾收集器

Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。

《JVM-垃圾收集入门》

Serial收集器

Serial收集器是最基本、发展历史最悠久的收集器,在JDK 1.3之前是虚拟机新生代收集的唯一选择。该收集器使用单线程清理堆的内容。无论是进行Minor GC还是Full GC,在进行清理堆空间时,所有的应用线程都会被暂停(Stop The World),直到它收集结束。进行Full GC时,它还会对老年代空间的对象进行压缩整理。

Serial收集器是虚拟机运行在Client模式下的默认新生代收集器。也有着优于其他收集器的地方:

  • 简单而高效(与其他收集器的单线程比)
  • 对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、“Stop The World”、对象分配规则、回收策略等都与Serial收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。

ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。CMS作为老年代的收集器,新生代只能选择ParNew或者Serial收集器中的一个。ParNew收集器也是使用-XX:+UseConcMarkSweepGC选项后的默认新生代收集器,也可以使用-XX:+UseParNewGC选项来强制指定它。

ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百地保证可以超越Serial收集器。当然,随着可以使用的CPU的数量的增加,它对于GC时系统资源的有效利用还是很有好处的。它默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。

并发(Concurrent):指用户线程与垃圾收集线程同时执行,用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。

Parallel Scavenge收集器

Parallel Scavenge收集器是一个新生代收集器,也是使用复制算法的收集器,又是并行的多线程收集器。但是Parallel Scavenge收集器的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算任务,主要适合在后台运算而不需要太多交互的任务。

Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

  • MaxGCPauseMillis:MaxGCPauseMillis参数允许的值是一个大于0的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。不要认为如果把这个参数的值设置得稍小一点就能使得系统的垃圾收集速度变得更快,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,收集300MB新生代肯定比收集500MB快,这就导致垃圾收集发生得更频繁一些,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。
  • GCTimeRatio:GCTimeRatio参数的值应当是(0,100)之间的整数,默认值为99,是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。如果把此参数设置为19,那允许的最大GC时间就占总时间的5%(即1 / (1+19))。

除了上述两个参数之外,Parallel Scavenge收集器还有一个参数-XX:+UseAdaptiveSizePolicy值得关注。这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应的调节策略。

Serial Old收集器

Serial Old是Serial收集器的老年代版本,同样是一个单线程收集器,使用“标记-整理”算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下 ,那么它主要还有两大用途:

  • 在JDK 1.5以及之前的版本中与Parallel Scavenge收集器搭配使用。
  • 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供的。在此之前,新生代的Parallel Scavenge收集器一直处于比较尴尬的状态。原因是如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器外别无选择。

Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤:

  • 初始标记(CMS initial mark):标记一下GC Roots能直接关联到的对象,速度很快。
  • 并发标记(CMS concurrent mark):进行GC Roots Tracing的过程
  • 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
  • 并发清除(CMS concurrent sweep)

其中,初始标记、重新标记这两个步骤仍然需要“Stop The World”。整个过程中耗时最长的并发标记和并发清楚过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS收集器也存在如下3个明显的缺点:

  1. 对CPU资源非常敏感

    面向并发设计的程序都对CPU资源比较敏感。CMS收集器在并发阶段,虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS默认启动的回收线程数是(CPU数量 + 3)/ 4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不小于25%的CPU资源,并且随着CPU数量的增加而下降。当CPU不足4个(譬如2个)时,CMS对用户程序的影响就可能变得很大,如果本来CPU负载就比较大,还分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了50%,其实也让人无法接受。

  2. 无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。

    CMS在并发清理阶段用户线程还需要运行着,伴随程序运行自然就会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。由于在垃圾收集阶段用户线程还需要运行,那就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能其他收集器那样等到老年代几乎完全被填满了在进行收集,需要预留一部分空间提供并发收集时的程序运作使用。

    如果CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

    老年代使用了多少内存时才会触发CMS收集器,是由参数:-XX:CMSInitiatingOccupancyFraction的值决定的。如果参数CMSInitiatingOccupancyFraction的值设置过高很容易导致大量“Concurrent Mode Failure”失败,性能反而降低。

  3. 产生空间碎片

    CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。

    为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开发参数(默认开启),用于在CMS收集器顶不住要进行FullGC时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间变长了。另外一个参数-XX:CMSFullGCsBeforeCompaction,这个参数用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,表示每次进入Full GC时都进行碎片整理)。

G1收集器

G1 GC,全称Garbage-First Garbage Collector,通过-XX:+UseG1GC参数来启用,在JDK 7u4版本发行时被正式推出。在JDK 9中,G1被提议设置为默认垃圾收集器(JEP 248)。

G1是一种服务器端的垃圾收集器,G1收集器的设计目标是取代CMS收集器。与CMS相比,在以下方面表现的更出色:

  • G1是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片。
  • G1的Stop The World(STW)更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间。

Region

使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的,它们都是一部分Region(不需要连续)的集合。

《JVM-垃圾收集入门》

在上图中,注意到有一些Region标明了H,它代表Humongous,这表示这些Region存储的是巨大对象(humongous object,H-obj),即大小大于等于region一半的对象。

可预测的停顿时间模型

G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大Region(这也就是Garbage-First名称的来由)。

这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以获得尽可能高的收集效率。

Remembered Set

Java堆分为多个Region后,垃圾收集是否就真的能以Region为单位进行了?Region不可能是孤立的,一个对象分配在某个Region中,它并非只能被本Region中的其他对象引用,而是可以与整个Java堆任意的对象发生引用关系。那在做可达性判定确定对象是否存活的时候,岂不是还得扫描整个Java堆才能保证准备性。这个问题其实并非在G1中才有,只是在G1中更加突出而已。

在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中,如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即保证不对全堆扫描也不会有遗漏。

    原文作者:JVM
    原文地址: https://juejin.im/post/5af6ee2ff265da0b9a6a0a47
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞