03-java虚拟机的垃圾收集

1.垃圾收集

  垃圾收集(简称GC)顾名思义就是把无用的对象回收,腾出更多空间给新创建的对象使用,人们在设计GC的时候就需要考虑三个问题分别,那些内存需要回收、什么时候回收、如何回收。

  目前内存的动态分配与GC技术已经相当成熟,一切看起来都进入了自动化时代,那为什么我们还需要去了解这些知识,答案很简答,当需要排查内存溢出、内存泄露问题、GC成为系统达到更高并发的瓶颈时,我们需要对这些自动化技术实施必须要的监控与调节。

  回到我们熟悉的java语言,像java虚拟机栈、程序计数器、本地方法栈3块区域都是随着线程而生,又随线程而灭,因此这几个区域的内存分配和回收都具备确定性,不需过多的考虑回收问题,因为方法结束或线程结束时内存就自然回收了,而java堆和方法区不一样,我们只有在程序的运行期间时才能知道会创建那些对象,运行期的对象创建都是动态的、不确定性的,所以GC所关注的是这部分内容。

2.如何确定对象无用

  2-1.计数算法

    给对象添加一个引用计数器,对象被引的时候就+1,引用失效的时候-1,计数器等于0的对象说明是要无用对象,要被GC的对象。

    但是java虚拟机中并没有采用这种算法来确定对象是否无用,计数算法有个很大的缺陷,它很难解决对象之间循环引用的问题。

举个例子,A对象引用B对象,B对象引用A对象,此时外部引用A对象,A对象计数器等于1,然后发现A对象中引用了B对象,B对象计数器等于1,接着外部引用B对象,B对象计数器等于2,发现B对象中引用了A,A对象计数器等于2,最后外部引用全部失效,A计数器减1、B计数器减1,它们的计数器各自等于1,实际上这两个对象都没有被引用了,是无用对象,但是此时无法GC,因为它们的计数器都没有等于0。

  2-1.可达性分析算法

    在java虚拟机中采用的是可达性分析算法,可达性分析算法通过一系列的称之为GC Roots的对象作为起始点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链接,则证明该对象不可用。

03-java虚拟机的垃圾收集

如上图所示,object1~object4对GC Root都是可达的,说明不可GC收,object5和object6对GC Root节点不可达,说明其可以被GC。

在java中GC Roots对象分为下面几种

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

    可达性分析算法中,不可达的对象也并非“非死不可”,如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,对象会被标记并进行一次筛选,筛选的条件是此对象是否有必须执行finalize(),如果对象没有覆盖finalize()或者finalize()被系统调用过一次,虚拟机将这种情况视为不必执行,对象直接被GC

    如果此时对象被判断为可执行,这个对象将会被放置到一个F-Queue队列中,稍后由虚拟机自动建立一个低优先级的线程去执行F-Queue队列里对象的finalize()方法,如果finalize()方法中把当前对象关联上了GC Roots的引用链,这个对象就得到了自救,在稍后来对F-Queue队列扫描的GC中存活下来,当然finalize()这个方法不推荐使用,大家可以把它忘记。

3.引用

什么是引用,引用就是这块内存存储的是另一块内存的地址,通过该地址访问到另外一块内存,在java中引用分为了4种,4种引用强度依此逐渐减弱。

  1. 强引用非常常见,像Objet object = new Objet() 这就是强引用,只要强引用存在,不会GC

  2. 软引用是用来描述一些还有用但是并非必须的对象,对于软引用对象,系统在发生内存溢出异常之前,会将这些对象进行二次GC,如果回收后内存还是不够,才会抛出内存溢出异常

  3. 弱引用也是用来描述非必须对象,它比软引用还要弱些,物流内存空间是否足够,弱引用对象一定会被回收,被弱引用关联的对象只能活到下次GC发生之前

  4. 虚引用是最弱的一种引用关系,一个对象是否有虚引用完全不会对其生存时间构成影响,也无法通过虚引用来访问对象实例,它唯一的作用就是对象被GC时收到一个系统通知

4.方法区的回收

  方法区一般是被GC比较少的区域,它甚至可以指定不使用GC,因为方法区的GC价比很低,主要体现在GC不了太多的空间,在堆区年轻代里面常规的一次GC可以腾出70%~%95的空,而方法区估计10%都没有。

  方法区的GC主要是针对废弃常量和无用的类,废弃常量指没有被引用的常量,比如字符串“ab”,没有任何Spring对象引用,该常量会被GC,

  判断一个废弃常量比较简单,但是判断一个无用类缺苛刻了许多,一个无用类需要满足下面三个条件才可以被回收,注意这里只是说可以,并没说一定。

  1. 该类的所有实例都被GC了
  2. 加载该类的ClassLoader被回收了
  3. 该类对应的Class对象没有在任何地方被引用,也没有被反射操作。

5.垃圾收集算法

这里介绍几种算法的思想与发展过程

  5-1.标记清除法

    最为基础的GC算法,如同他的名字一样,算法分为两个阶段,分别是标记、清除,首先标把所有需要GC的对象标记出来,接着在把标记对象全部清除。

    之所以称它为最基础的GC算法,因为后面的GC算法都是基于这种思路并对它的不足处做出改进,标记清除算法主要不足的地方有两处,第一个是效率问题,标记和清除这两个阶段效率不高,另一个是内存空间不连续问题,在运行过程中分配到了较大的对象,找不到足够大的空间来存储,不得不提前触发一次GC,标记清除法运行过程如下图

03-java虚拟机的垃圾收集

  5-2.复制算法

    它将内存容量按相等大小化为一块,每次只使用其中一块,每次GC时,把存活对象复制到另一块空间,当前空间全部清除,这样内存分配的时候不要考虑空间不连续的问题,这种算法实现简单运行高效,但是它的缺点也很明显,空间换时间,浪费了一块空间,是复制算法运行过程如下图

03-java虚拟机的垃圾收集

需要注意的是,复制算法有个分配担保机制,一般复制算法的两块空间都非常的小(8:1),当空间无法存储存活对象时会进行分配担保机制,放入老年代中,关于分配担保进制后续会详细讲解。

  5-3.标记整理算法

    标记整理算法是基于标记清除算法的基础上进行的改进,第一个阶段都是一样,到了第二阶段有点不太一样,标记清除是直接进行清除,而标记整理则是把所有存活对象往一端移动,然后直接清除端边界以外的内存,标记整理运行过程如下图

03-java虚拟机的垃圾收集

对GC算法的采用,都使用了分代思想,不同的代采用合适的GC算法,在java堆中分了新生代与老年代,在新生代中有大量对象死亡,少量对象存活所以采用的是复制算法,在老年代中对象比较大存活率也很高、没有额外的空间担保,所以采用的是标记清除或者标记整理算法。

6.算法实现的优化

  虚拟机在对实现算法上会做很多优化,就拿可达性分析为例子,如果可达性分析需要挨个去检查引用,那么必定会消耗很多时间,而在可达性分析的期间,整个系统都要停顿,不允许出现分析过程中对象的引用关系还在发生变化,不能保证这点的话,分析结果的准确性无法得到保证,同样分析时间越长就意味着整个系统停顿也会越长。

  在目前java虚拟机(HotSpot),可达性分析得到了优化,当系统停顿下来的时候,不需要挨个去检查象,在类加载完成的时候,HotSpot就会把对象的引用存放在一个OopMap数据结构里面,HotSpot从OopMap中去获取,减少分析的时间。技术人员对虚拟机的开发都是以减少GC停顿时间、消除GC停顿为目标。

7.垃圾收集器

在java虚拟机(HotSpot),包含了7种垃圾收集器,7种垃圾收集器作用于不同的分代,如下图

03-java虚拟机的垃圾收集

如果两个垃圾收集器之间存在连线,说明可以搭配一起使用,收集器之间没有谁好谁坏,只有合不合适,所以我门选择的只是具体场景合适的收集器。

  7-1.Serial收集器

    Serial收集器是最基本的新生代收集器,它是一个单线程的收集器,单线程不仅仅是只有一条线程去完成GC的工作,在进行GC的时候,必须停止所有其他的工作线程,下图展示了Serial 收集器(老年代采用Serial Old收集器)的运行过程

03-java虚拟机的垃圾收集

  7-2.ParNew收集器

    ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行GC外,其他方面跟Serial收集器完全一样,ParNew收集器的工作过程如下图(老年代采用Serial Old收集器)

03-java虚拟机的垃圾收集

  7-3.Parallel Scavenge收集器

    Parallel Scavenge收集器也是一个多线程新生代收集器,它也使用复制算法。Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(Throughput)。

    停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务,这里的吞吐量计算公式是:吞吐量=运行用户代码时间/(运行用户代码时间+GC时间)

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

  7-4.Serial Old收集器

    Serial Old是 Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”(Mark-Compact)算法,如果在Server模式下,它还有两大用途

  1. 在JDK1.5 以及之前版本(Parallel Old诞生以前)中与Parallel
    Scavenge收集器搭配使用。
  2. 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

Serial/Serial Old配合使用的工作流程图

03-java虚拟机的垃圾收集

  7-5.Parallel Old收集器

    Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。前面已经提到过,这个收集器是在JDK 1.6中才开始提供的,在此之前,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old以外别无选择,所以在Parallel Old诞生以后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。下面是Parallel Scavenge/Parallel Old收集器配合使用的流程图。

03-java虚拟机的垃圾收集

  7-6.CMS收集器

    CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的老年代收集器,它非常符合那些集中在互联网站或者B/S系统的服务端上的Java应用,这些应用都非常重视服务的响应速度。从名字上(“Mark Sweep”)就可以看出它是基于“标记-清除”算法实现的,它的主要优点在名字上已经体现出来了并发收集、低停顿。
CMS收集器工作的整个流程分为以下4个步骤:

  1. 初始标记(CMS initial mark):仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”。

  2. 并发标记(CMS concurrent mark):进行GC Roots Tracing的过程,在整个过程中耗时最长。

  3. 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要“Stop The World”。

  4. 并发清除(CMS concurrent sweep)

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作。

通过下图可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的时间:

03-java虚拟机的垃圾收集

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

  7-7.G1收集器

G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果之一,它是一款面向服务端应用的垃圾收集器,HotSpot开发团队赋予它的使命是(在比较长期的)未来可以替换掉JDK 1.5中发布的CMS收集器,有如下4个特点

  1. 并行与并发 G1 能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短“Stop The World”停顿时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。

  2. 分代收集 与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次GC的旧对象来获取更好的收集效果。

  3. 空间整合 G1从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的。这意味着G1运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。

4.可预测的停顿 这是G1相对CMS的一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了降低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。

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

    G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率

    G1把Java堆分为多个Region,就是“化整为零”。但是Region不可能是孤立的,一个对象分配在某个Region中,可以与整个Java堆任意的对象发生引用关系。在做可达性分析确定对象是否存活的时候,需要扫描整个Java堆才能保证准确性,这显然是对GC效率的极大伤害。

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

如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:

  1. 初始标记(Initial Marking) 仅仅只是标记一下GC Roots 能直接关联到的对象,并且修改TAMS(Nest Top Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可以的Region中创建对象,此阶段需要停顿线程,但耗时很短。

  2. 并发标记(Concurrent Marking) 从GC Root 开始对堆中对象进行可达性分析,找到存活对象,此阶段耗时较长,但可与用户程序并发执行。

  3. 最终标记(Final Marking) 为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。

  4. 筛选回收(Live Data Counting and Evacuation) 首先对各个Region中的回收价值和成本进行排序,根据用户所期望的GC 停顿是时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

通过下图可以比较清楚地看到G1收集器的运作步骤中并发和需要停顿的阶段(Safepoint处)

03-java虚拟机的垃圾收集

收集器 串行/并行/并发 新生代/老年代 算法 目标 适用场景
Serial 串行 新生代 复制算法 响应速度优先 单CPU环境下的Client模式
Serial Old 串行 老年代 标记-整理 响应速度优先 单CPU环境下的Client模式、CMS的后备预案
ParNew 并行 新生代 复制算法 响应速度优先 多CPU环境时在Server模式下与CMS配合单CPU环境下的Client模式、CMS的后备预案
Parallel Scavenge 并行 新生代 复制算法 吞吐量优先 在后台运算而不需要太多交互的任务
Parallel Old 并行 老年代 标记-整理 吞吐量优先 在后台运算而不需要太多交互的任务
CMS 并发 老年代 标记-清除 响应速度优先 集中在互联网站或B/S系统服务端上的Java应用
G1 并发 both 标记-整理+复制算法 响应速度优先 面向服务端应用,将来替换CMS

参考:

《深入理解java虚拟机》