在了解jvm中垃圾回收机制前,应该对其内存空间需要有一定程度的了解;在内存空间描述中,我们知道每个被声明的变量或创建出来的对象都需要容器去承载,这个容器便是之前介绍的内存空间,当然这片空间是从服务器中申请来的,既然其是基于服务器运行的,就不可能无限制的往容器里装东西,但是伴随着程序的运行,又会源源不断的产生对象,显然二者是相矛盾的。针对两个矛盾体“对象会不断产生”,“容器大小有限制”,必须要有应对措施,而我们通常能想到的方案一般有两种:1.增加容器的容量。2.清理无用垃圾。我们所说的垃圾回收机制也是从这两点触发至发散。

增加容器容量

  怎么理解增加容器容量呢?如果单纯说增加容器容量其实是比较不严谨的说法,目前大部分面向C端的业务都是基于微服务的,微服务意味着将整个业务线通过功能拆分成每个小型服务,部署在多台小型机上,如果一味的增加容量,小型机便会过载,与当初的“微服务”理念背道而驰,所以说不能单纯增加容器容量。
  如果将“增加容器容量”换成“合理调配的容器大小”是不是更应景?在实际业务场景中,有种现象就是当我们服务刚启动时会发生频繁的GC,这是为什么呢?通过GC监控工具发现这种情况通常是由于Allocation Failure(分配内存失败,动态扩容),究其原因是由于 -Xmx -Xms两个参数设置大小不一,在初始阶段申请大小为-Xms容量,后续容量不够时会继续申请空间,即发生扩容,当需要扩容时会触发垃圾收集,如果不断扩容则会伴随着频繁的垃圾收集,那么为了避免频繁的垃圾收集将两个参数设置一致,避免启动时的动态扩容。
  既然两个参数不一致会发生频繁GC,那只要一个参数就够了,为啥还要两个?其实在不同场景下,两个参数各有妙用,如果是面向C端的服务,需要跟用户频繁的交互,无法忍受高频的停顿,为了减少GC次数两个参数务必设置一致;但与之相反,对于一些富客户端应用两个参数的梯次配置倒能节省一些空间。

清理无用垃圾

  上面分析了增加容量的场景,但是增加容量显得比较有局限性;大头的动作应该放在垃圾清理这边。我们很了解生活场景中出现的垃圾,并会将其带离日常生活区域,同理要想知道怎么清理垃圾,要从以下个方面入手

  1. 垃圾在哪里
  2. 垃圾是什么
  3. 怎么清理

1.垃圾在哪里

  在分析垃圾在哪里之前,首先应该知道jvm内存空间是如何布局的,我们现在找找垃圾在哪里,便于分析我们先放张图

如上图所示,虚拟机栈和本地方法栈里面存储基本就是各种局部变量、动态链接和方法返回值等,而我们知道上述这些描述都是实例对象的引用指针,随着线程状态决定其生死所以垃圾回收我们不考虑这两块空间,其次就是程序计数器这块空间就是指向程序执行到哪里,也算是随着线程生死。
  接下来就是元空间和堆空间,我们知道方法区存储的是类的描述信息以及类常量和运行时常量池,方法区中部分数据生命周期和类加载器相同,随着类加载的器的消亡也会产生垃圾,所以方法区也会有垃圾回收的动作发生,但通常都是伴随着很低频的Full GC,通常来说该区域垃圾回收应该较少。最后就是堆,程序所创建的实例都放在这里,这里是GC的主要目标,而且根据对象生命周期的特性,将该区域划分为两块空间,一块存放生命周期很短的那些对象,一块存放生命周期比较长的对象。
  通过以上分析,得出结论:垃圾在堆区和方法区,主要存放地还是堆区

2.垃圾是什么

  上面弄清楚了垃圾在哪里,紧接着我们探讨下究竟什么才是垃圾,jvm又是怎么区分垃圾的。
  由于本文旨在大白话探讨垃圾回收,所以我们应该先通俗的理解什么是垃圾,百度对垃圾的解释是“垃圾是失去使用价值、 无法利用的废弃物品,是物质循环的重要环节”;同理,将这个概念拓展到jvm就是对运行程序无意义的实例以及类描述等信息,其实应该重点关注的就是这个无意义。
  专业描述垃圾:运行程序中没有任何指针指向的对象被称为垃圾;

方法1

  我们让当前运行程序中指向的对象维护一个计数器,当有被引用则计数器值加一,当引用失效时计数器值减一;在进行垃圾收集时直接将这些计数器为0的对象都归类为垃圾;这是个不错的方法,但是想想会有什么问题呢?如果A->B->C->B,当A指向BCB的这个引用断了之后,B->C->B还是互相引用;最终在垃圾归类时无法将其判断为垃圾,会造成比较严重的内存泄露;那既然这种发放有缺陷,有没有比较好点的方法;我们接着看方法2

方法2

  首先找一组活跃的对象,然后往下找其引用的对象,层级遍历找到所有对象,这样会形成一个搜索链,当然这个搜索链有可能是多叉互连的,有点像数据结构中的有向图,当图中的某个节点找不到一条路径是走向活跃对象的,那么这个节点可以归类为垃圾,这就是鼎鼎大名的可达性分析算法

小故事:
一个有名望的老宗师收了一帮弟子,他们之间就是师徒关系,然后弟子们开枝散叶又收了很多弟子,如此往复老宗师名满天下,但老宗师有个比较前卫的想法,一个徒弟可以不止有一个师傅,这样一来,当某个徒弟背叛了师门,那么只要他的弟子还有一个师傅没有背叛师门那么,这个弟子还是属于宗门,否则就不属于宗门了,对于这种不属于宗门的弟子会有专门的执法组去清理掉

在jvm中哪些对象可以作为这组活跃对象呢?

  • 虚拟机栈中的引用对象(各个线程被调用的方法中的参数或者局部变量)
  • JNI中的引用对象(本地方法中引用对象)
  • 方法区中静态属性引用的对象
  • 所有被同步锁(synchronized)持有的对象
  • 方法区中常量引用的对象(String Tables)
  • java虚拟机内部的引用,比如ClassLoader、各种异常类等常驻对象
  • 分代引用时部分堆内对象

3.怎么清理

  从上面一路走下来,终于到了清理阶段;日常生活中的垃圾清理有专门的清洁工负责,所以在jvm中应该也存在这样一个角色我们称之为垃圾回收器
  在探讨“垃圾在哪里”一小节中有提到,垃圾主要产生在堆中,那我们就重点以堆为例;根据对象的生命周期堆被分为了两大块,一块是存放朝生夕死对象的Eden区一块是存放存活时间较久的对象称为老年代;我们一块一块回收

新生代

  新生代中的对象大部分都是朝生夕死的,而那些躲过垃圾回收的对象通过晋升或担保机制都去了老年代,针对朝生夕死这个特性,直接看jvm给出的回收方式

回收算法

标记-复制:将整个内存区域分为两块,在垃圾回收时,将正在使用的一块中的存活对象复制到另一块中;然后整个清除使用中的那块内存
优点:实现过程简单,运行高效,保证内存空间连续性,不会产生内存碎片
缺点:需要两倍的内存空间,对象移动时引用地址也会发生变化
  上述的算法描述的已经很明了,也给出了其优缺点,由于在对象移动过程中引用地址会发生改变,所以应当尽量的减少需要移动的对象,到了这里其实已经跟新生代对象的生命周期有了个良好的匹配:大部分对象朝生夕死,也就是说,存活下来的对象会很少;且该种算法不会产生内存碎片,避免再维护一个空闲列表,后续给对象分配空间可以直接使用指针碰撞即可

匹配的回收器

Serial:使用复制算法,串行回收,会发生STW,单核CPU下简单高效优于其他回收器(没有线程上下文切换)

ParNew GC:Serial的并行版本

Parallel Scavenge:高吞吐量回收器,适合后台运行不需要太多交互的场景

老年代

回收算法

标记-清除(Mark - Sweep):使用根节点搜索算法标记所有被引用的对象,遍历堆内存中所有对象,如果对象没有被标记,那么说明该对象应该被清除;其实是将垃圾对象所用地址放在一个空闲列表中,后续创建对象时,可从空闲列表中分配,垃圾对象还在原位置,并不会被清理,后续对象覆盖
优点:实现简单,清除速度快,清除过程是直接使用新对象覆盖即可,不需要清理内存块也不需要移动对象
缺点:效率低下;GC过程中需要停止整个应用,导致用户体验差;容易产生内存碎片,需要维护一个空闲列表;从标记到清除需要遍历两次

标记-整理(Mark - Compact):使用根节点搜索算法标记所有被引用的对象,将所有标记过的对象放在内存的一端按顺序存放,然后将剩下的空间清理掉
优点:解决了标记清除算法中产生的内存碎片问题
缺点:效率上低于复制算法,移动对象时需要改变引用地址,移动过程中会发生STW
  上述两个算法各有优劣,可以看出来标记-清除算法用在对响应速度要求较高的场景下,在面向C端的应用中,高停顿是不能被容忍的;相反的像某些富客户端应用对停顿时间并没有那么高的要求或者跟用户接触不是那么频繁,且对内存要求比较高,则可以使用标记整理算法

回收器

Serial Old:使用标记整理算法,串行回收,会发生STW,作为CMS的后备方案

Parallel Old:使用标记整理算法,并行回收

CMS:使用标记清理算法,并发回收

聊聊搭配

  上面简单的叙述了下垃圾回收算法和回收器,通常情况下都是成对出现的,在jdk1.8中默认的是搭配是Parallel Scavenge和Parallel Old,但是据我所知市面上面向C端业务的服务端,使用的基本都是低延迟的ParNew和CMS,其实CMS的并发回收还是有得一说,具体过程分为以下几个步骤

  1. 初始标记:STW,仅仅只是标记处GC Roots能直接关联的对象,由于直接关联的对象很少,所以这个速度很快
  2. 并发标记:从GC Roots直接关联对象开始,遍历整个对象图,该过程比较漫长,但是和用户线程并发执行
  3. 重新标记:STW,因为并发标记过程中用户线程还在执行,所以需要重新标记下执行过程中关联的对象,这个过程比初始标记耗时久,但是比并发标记快(这里标记的是初始阶段怀疑是垃圾的对象,并不是新产生的垃圾对象)
  4. 并发清理:清理掉标记过程中已死亡的对象,因为该过程不需要移动存活对象所以可以并发执行(如果使用标记压缩算法就无法达到并发清理,需要STW会将延迟升高)
  5. 由于回收过程用户线程还在执行,所以需要提供足够大的内存,且不能在堆满了之后才开始清理,应该在达到一个阈值时就开始清理
  6. 清理期间预留内存无法满足程序需要就会,报错“Concurrent Mode Failure”,这时候就会采取预备方案,临时使用Serial Old对老年代进行垃圾回收

上述流程就是CMS的回收过程,但是CMS也不是一个完美的回收器,不然jdk1.9中就不会将其标记为过期用G1回收器替代了,那我们探讨下为啥它不好呢,首先使用标记清除算法伴随着Full GC无法避免的会产生内存碎片,当有大量内存碎片产生时,大对象再次进入会触发Full GC,如果还是没有足够空间就会有高停顿的Serial Old回收器代替它回收,这个代价是昂贵的,而且并发标记阶段产生的垃圾(浮动垃圾)没法回收;当了解了以上的不足,我们试着去改进它如果将它的清理换成整理好不好,显然不好,整理期间会STW,当移动的对象过多是,这个STW会很致命,那么现在需要考虑的就是怎么避免这个STW,即如何将移动后的对象引用重新指向,后续的Shenandoah和ZGC回收器在处理基于Region的跨代引用时均致力于解决这个问题,在这里就不继续的探讨下去了,有兴趣的可自行查阅

  既然作为下个稳定版的jdk11默认的垃圾回收器是G1,那我们有理由知道G1好在哪里。从上面垃圾回收器的介绍中我们很在意其“停顿”和“吞吐量”,而且之所以出现那么多对垃圾回收器,也是基于这两个特性;根据业务特性,选择适合自己的组合,但是有没有一种回收器即能高吞吐又能停顿;请看G1

G1回收器

简述

  • G1是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的Region来表示Eden、幸存者0区,幸存者1区,老年代等。
  • G1 GC有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region,由于这种方式的侧重点在于回收垃圾最大量的区间(Region),所以我们给G1一个名字:垃圾优先(Garbage First)。
  • G1(Garbage-First)是一款面向服务端应用的垃圾收集器,主要针对配备多核CPU及大容量内存的机器,以极高概率满足GC停顿时间的同时,还兼具高吞吐量的性能特征。
  • G1新增一个叫Humongous的内存区域,用来存储大对象(超过1.5个Region)
  • G1每个 Region 都维护有自己记忆集,用来解决跨代引用。

优点

  1. G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW
  2. G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
  3. G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况
  4. Region之间是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact)算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显。

缺点

  1. 在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(overload)都要比CMS要高。
  2. 每个 Region 都维护有自己记忆集,用来解决跨代引用。根据经验:G1 至少要耗费大约相当于Java 堆容量 10% 至 20% 的额外内存来维持收集器工作;
  3. 如果内存回收的速度赶不上分配的速度,G1 收集器也要停止用户线程,执行 Full GC。