再看JVM:垃圾回收那些事

《再看JVM:垃圾回收那些事》

 

JVM虚拟机为使用者提供了自动内存管理机制,使的程序员在使用完对象后手动释放占用内存的工作中解脱出来。内存的动态分配和回收完全使得一切都看起来那么美妙,但是再好的机器也有出问题的时候不是。在项目中需要排查各种内存溢出、内存泄漏问题时,就有必要来了解了解JVM内部对内存回收的那些事了。小白因为要在组内做一次JVM垃圾回收的技术分享,于是又再次研读了《深入理解Java虚拟机》一书中垃圾收集相关章节。实在是感觉每看一遍,都有不同的收获,本文参考虚拟机神书对GC相关知识加以梳理,同时有的地方谈了一些小白自己的理解,有失偏颇,还望指正。

一、垃圾的确定

何为垃圾?数数JVM运行期的内存结构,也就方法区和堆内存两块内存区域是线程共享的,虚拟机栈、程序计数器、本地方法栈都是线程私有的,私有就意味着这部分内存会随线程的结束而释放,因此垃圾回收是无须关注线程私有的内存的。反倒是方法区和堆(主要),由于是线程共享的,每个线程都可以在这块区域写数据。随着线程的结束,这部分内存就会存在大量无用的数据。这些数据就是我们常说的垃圾,而这些垃圾占用的内存,就是垃圾回收的目标内存。堆内存中的垃圾便是无用、或者称之为死亡的对象,方法区中的垃圾便是无用的常量和类数据。

1.1 对象的死亡宣告机制

空间紧张的内存世界,对于对象而言实在太为残酷,可以说毫无人道主义。只要你没什么用了,那么不好意思,法官便要宣判你的死亡了,然后交由刽子手行刑。但是残酷归残酷,法官是有原则的,就是它需要科学的机制来准确的判定你是否无用,因为只有这种原则才能保证法官所在的世界正常运转。

1.1.1 判定算法

对象是否无用的判定算法有如下两种:

引用技术算法:于对象内部维护一计数器,每有一处运用某个对象,该对象的引用计数器便加一,每有一处的引用失效,该对象的引用计数器减一。计数器为0的对象便是无用的,也就是死亡对象。

  • 优点:实现简单,判定简单
  • 缺点:无法解决对象互相引用的问题(A的属性引用B,B的属性引用A,除此之外,A、B两个对象毫无用处)

可达性分析算法:选取特定性质的对象作为根对象(GC Roots),像从树的根节点往下遍历一样,从GC Roots向下遍历其引用链,若存在对象到GC Roots怎么都不可达(无任何一条调用链),那么这些对象便可被回收。

  • 优点:判定准确,不存在对象互相引用的问题
  • 缺点:实现复杂,判定效率比计数法低

主流的Java虚拟机采用的基本都是可达性分析算法,主要看重便是其不存在对象互相引用无法回收的问题。该算法中存在一个概念GC Roots,虚拟机会遍历这些对象的调用链来确定其他对象是否存活。那便有一个前提,可以作为GC Roots的对象必须保证是存活的对象。

  • 虚拟机栈中(本地变量表)引用的对象。这些对象随线程生而存在,随线程死而被释放,因此这类对象只要存在,就一定存活。
  • 本地方法栈中引用的对象。原因同上。
  • 方法区中类的静态属性引用的对象。一般很少会进行方法区的内存回收,且类的回收判定较为严苛,因此这中对象基本都是存活的。另外小白也认为这类对象中选作GC Roots的应该是以jdk自身的类为主的。
  • 方法区中常量引用的对象。

1.1.2 对象的引用

判定对象是否无用,其实归根到底是判定对象的引用是否还存在。引用这个概念是比较java特色的词语,可以类比C、C++中的指针去理解。一个引用类型的变量的值,是另一块内存的起始地址。更为java特色的是,1.2之后,对引用(Reference)进行了具体化的扩充,也就是常说的强、软、弱、虚四种。

强引用:就是我们日常new对象前声明的引用。比如: Object obj = new Object() 。其中obj就是强引用。

软引用(SoftReference):一般用来表示可以存在但非必须的对象。这类对象在内存充足时是可以存在的,但是在内存不足即将溢出时,会被回收掉。可使用 SoftReference 类实例化,构造参数为要引用的对象。适用场景小白觉的应该是一些非必要的缓存数据,比如图片文件的流对象,内存充足时缓存下来,每次使用直接读流,内存紧张时被回收,下次使用再从原路径读取。

弱引用(WeakReference):也是描述非必须的对象。但这个引用关系比软引用更弱,弱引用引用的对象只要发生垃圾回收,便会被回收,但是在发生垃圾回收之前,还是可以通过若引用获取到该对象的。适用场景和软引用类似。

虚引用(PhantomReference):准确叫幻影引用吧,也就是引用是假的。虚引用和对象的生存周期毫无关系。无法通过虚引用获取到对应对象。唯一的作用就是使这个对象在被回收时收到一个系统通知。可以被实例化,但必须和一个引用队列关联使用。虚拟机在回收这个对象的时候便会把该引用添加进引用队列,程序便可通过监控引用队列来实现在对象回收前进行一些操作。

1.2 确定对象真正死亡的过程

虚拟机不会简单地通过一次可达性分析就判定某个对象死亡继而进行回收的。一个对象在确定要回收时至少已经经历了两次判定标记。这里说的每一次标记可以理解为一次可达性判断。虚拟机标记对象的过程如下图(小白根据自己理解的画的图,欢迎讨论):

《再看JVM:垃圾回收那些事》

 

虚拟机对对象进行第一次标记的时候,对不可达的对象进行筛选,判断是否有必要执行 finalize() 方法。若对象没有覆盖该方法或已经执行过该方法,JVM会认为该对象没有必要执行 finalize()方法。

而有必要的对象,会被放进一个F-Quene队列,由低优先级的Finalizer线程触发这个队列中对象的finalize()方法。稍后,JVM对该队列中的所有对象进行一次小规模(队列中)标记。如果有对象在finalize()方法中拯救了自己,也就是在这个方法中建立了存活对象到 this (自己)的引用链(具体如何拯救可以百度或去书里看代码),这个对象会在这次小规模标记中标记为可达,否则依旧是不可达。

在第二轮标记开始后,JVM会再次判定对象,将被两次及其以上被标记为不可达的对象内存回收,将拯救了自己的对象移出待回收集合。

注意:

  • 所有重写过finalize()方法的对象在被回收前才会被执行finalize()方法,并且只要是同一个对象,这个方法在这个对象的整个生命周期中也只会被执行一次。
  • Finalizer线程触发F-Quene队列里对象的finalize()方法时并不保证该方法执行结束,底层应该是有时间限制,超过这个时间会被强制结束。因为如果某个对象的finalize()方法执行缓慢甚至是发生了死循环,便会使Finalizer线程无法触发队列中其他对象的finalize()方法。
  • 一个对象只能拯救自己一次,因为每个对象重写的finalize()只能被触发一次。这次如果救活了,下次该对象被回收时便不会进入F-Quene队列。

1.3 方法区垃圾的判定

对于方法区,并不强制要求虚拟机实现这部分的垃圾回收。主要是因为收集效率低,即耗时长、回收空间少。

方法区主要回收废弃常量和无用类。废弃常量的判定与堆内存中对象的判定相同。类是否需要回收是由开发人员决定的,HotSpot虚拟机提供的配置参数为 -Xnoclassgc 。

类的判定取决于下面三个因素:

  • 堆中不存在该类的实例;
  • 加载该类的ClassLoader已经被回收;
  • 任何地方都不存在该类对应的java.lang.Class的引用。

二、垃圾的回收

垃圾由谁来回收,又是怎样回收呢?虚拟机内部提供了适合不同场景下的垃圾收集器来进行垃圾回收,程序员可以自己设定。这些垃圾收集器在程序运行时就是虚拟机内部的一个线程,需要注意的一点是这个线程是守护线程,它会伴随着我们程序(主线程)一起结束。GC线程在回收垃圾时,是根据特定的收集算法取进行垃圾内存释放的。

2.1 垃圾收集算法

  • 标记-清除算法 :如名字一般,先标记内存中需要回收的对象(这里所说的标记,就是对象被最终判定死亡的标记过程),标记完成后统一对所有被标记的对象进行回收,释放其所占用的内存。
  • 复制算法 :需要将内存划分为大小相等的两块,每次只使用其中一块。需要回收时将使用的这块内存中所有存活的对象(也是需要对对象进行判定的)复制到没用的那块内存上,然后将之前使用的那块内存整块清理,再改用复制过来的这块内存。
  • 标记-整理算法 :与标记清除算法类似,不同的是标记完成后不直接清理,而是先将存活的对象统一向一端移动,移动完成后直接清理存活对象区域以外的空间。
  • 分代收集算法 :依赖于上述算法。主要是根据对象的生命周期将内存划分为几块,每块内存采取合适的收集算法。一般来说,JVM把堆内存分为新生代和老年代。

上面简单描述了各种算法的基本思想。小白这里梳理各种算法的优缺点及适应场景如下:

标记-清除算法: 标记和清除两个过程效率都不太高 ,在死亡对象特别多的情况下尤为突出。另外收集完成后会造成 内存碎片化严重 ,回收的空间不连续。这两个特点决定了该算法 适合在对象存活周期特别长的情况下使用 ,因为这种情况下每次收集时死亡对象小,在清理时对特定空间的清理就会变少。

复制算法:很明显的缺点是 浪费一半内存 ,但其 简单高效,且回收后内存连续 的优点也很突出。该算法中回收时是清理使用的内存半区,然后切换复制后的内存半区来使用,相比标记-清理算法肯定实现简单,运行高效。但是需要注意的是,在对象存活较多的情况下,对应的复制操作就会越多,效率就会越低。因此,复制算法 适合在对象存活周期较短的情况使用 。

标记-整理算法:很好的弥补了标记-清理算法的缺点,回收后空间连续, 无内存碎片化问题。效率上小白感觉大多数情况下是比标记-清理算法略微差一些的,这个没有深入研究,只是推测,本身多了一个移动的步骤,如果效率也好的话,那标记-清除算法就没有必要存在了。也 适用于对象存活周期特别长的情况 。

分代收集算法:集百家之长,一般是 首选 。 堆内存被分为新生代和老年代 。 新生代对象存活周期短,大都朝生夕死,采用复制算法 。HotSpot虚拟机默认按8:1:1的比例将新生代分为Eden区域和两块一样大的Survivor区域,每次使用Eden和一块S区,回收时将存活的对象复制到另一块S区,回收完成后再使用这块S区和Eden区。这样每次只会闲置10%的新生代空间,对于获得了高效率的结果来说这个代价还可以接受。 老年代一般存放存活周期长的对象,每次收集对象存活率高,只能使用标记-清除(整理)算法 。注意:新生代中,若收集时存活对象预留的那块S区放不下时,会依赖老年代存放,具体的机制下面会提到。

2.2 回收的执行者-垃圾收集器

上面提到了HotSpot虚拟机对堆内存的划分以及收集算法的选用,这里简单梳理下收集算法在新生代和老年代具体实现,也就是各个区域的垃圾收集器。

新生代收集器:Serial、ParNew、Parallel Scavenge、G1

老年代收集器:Serial Old、CMS、Parallel Old、G1

搭配组合使用于整个堆内存的回收,可搭配的方式如图:

《再看JVM:垃圾回收那些事》

 

各收集器的工作原理这里不罗列了,感兴趣的朋友看下书就知道了,小白只梳理各自的优缺点及适用场景:

  • Serial :新生代收集器、单线程,适用于单CPU单核环境,需设置合适停顿时间
  • ParNew :新生代收集器、多线程,默认开启收集线程数和CPU数目相同,适用于多核多CPU场景
  • Parallel Scavenge :新生代收集器、多线程、与用户线程并行、可设置自适应调节(JVM自调优)、关注点是吞吐量(用户程序运行时间与其加上垃圾回收时间和的比值)、适合在后台运算,不适合存在太多交互的场景
  • Serial Old :老年代收集器、单线程,搭配合适的新生代收集器以及CMS收集器发生问题时的备案
  • Parallel Old :老年代收集器、多线程、适合注重推图量以及CPU资源敏感的场合
  • CMS :老年代收集器、并发收集、低停顿,无法处理浮动垃圾、使用Serial Old作备案,基于标记-清除算法,适用互联网站或者B/S系统的服务端
  • G1 :JDK1.7及以后可用,并行并发、可独立进行分代收集、空间整合、可预测的低停顿,主要用来取代CMS

需要注意的一点是,上面提到的并行是指GC和应用程序线程并行,并发则指的是多线程回收。

2.3 GC线程工作机制(HotSpot)

HotSpot虚拟机中GC线程在开始工作时是需要挂起应用程序的所有线程以保证回收操作的准确性的,准确说是保证选择的GC Roots对象和程序当前上下文的一致性。小白画了流程图如下,来更形象地描述GC如何停止工作线程。

《再看JVM:垃圾回收那些事》

 

图里引入了两个概念,这里简单说一下。安全点是在程序运行的特定位置,记录了该位置的指令执行时内存中可作为GC Roots的引用的内存地址,方便虚拟机直接去具体位置枚举根节点,而不是在整个内存中查找。设置安全点也是避免虚拟机为每条指令都记录引用信息浪费太多空间。安全域是指该区域内的指令不会导致当前内存中的引用发生变化,也就是说线程在安全域执行不会影响GC的准确性。安全域解决了处于某种状态(比如Sleep或是Blocked)线程无法响应JVM中断要求的问题。

三、垃圾何时回收

了解了什么是垃圾以及如何回收,接下来就简单聊聊虚拟机什么时候会进行垃圾回收(不会去详细说明内存如何分配以及各种虚拟机参数)。首先需要明确的是,进行垃圾回收会发生STW问题,无法避免,所谓的并行也只是整体看上去是并行的,那么就意味着频繁的垃圾回收会极为影响应用程序的性能,因此垃圾的回收只能发生在必要的时候,也就是可用内存不足以为对象分配的时候。

HotSpot虚拟机将 堆内存划分为新生代和老年代 。 新生代 又划 分为三块 ,一块较大的 Eden空间 和 两块 较小 的Survivor 空间,默认比例为 8:1:1 。划分的目的是因为HotSpot采用复制算法来回收新生代,设置这个比例是为了充分利用内存空间,减少浪费。新生成的对象在Eden区分配(大对象除外,大对象直接进入老年代,大小的判别阈值可配置),当Eden区没有足够的空间进行分配时,虚拟机将发起一次 Minor GC 。GC开始时,对象只会存在于Eden区和From Survivor区,To Survivor区是空的(作为保留区域)。GC进行时,Eden区中所有存活的对象都会被复制到To Survivor区,而在From Survivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阀值(默认为15,新生代中的对象每熬过一轮垃圾回收,年龄值就加1,GC分代年龄存储在对象的header中)的对象会被移到老年代中,没有达到阀值的对象会被复制到To Survivor区。然后清空Eden区和From Survivor区,新生代中存活的对象都在To Survivor区。接着, From Survivor区和To Survivor区会交换它们的角色,也就是新的To Survivor区就是上次GC清空的From Survivor区,新的From Survivor区就是上次GC的To Survivor区,总之,不管怎样都会保证To Survivor区在一轮GC后是空的。GC时当To Survivor区没有足够的空间存放上一次新生代收集下来的存活对象时,需要依赖老年代进行分配担保,将这些对象存放在老年代中。另外有个特殊情况是,在Minor GC后,如果S区有相同年龄的存活对象,且相同年龄的对象占用空间超过了S区的50%,这些对象也会被提前放入老年代。

当有对象放进老年代而最终内存不足时, 老年代 才会进行 Major GC ,其经常伴随至少一次的Minor GC。老年代的GC一般比新生代的GC慢10倍以上。因此一般来说要尽量减少虚拟机进行老年代GC。

HotSpot提供的优化措施是 分配担保机制 ,可通过HandlePromotionFailure参数设置是否允许担保失败。一般在进行Minor GC前,此次GC后存活的对象有多少是无法预知的,最坏的情况就是所有对象都存活,那么一块Survivor区域是绝对放不下,这个时候就需要把存活的对象提前放入老年代。但是老年代也无法保证能放下啊,所以绝对安全的情况就是老年代的最大可用的连续空间(不确定)大于新生代所有对象总空间。分配担保机制就是在非绝对安全的情况下,检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,担保此次Minor GC安全(有风险),如果小于(担保失败)直接Full GC。另外,如果设置不允许担保失败(其实就是关闭担保机制)就意味着每次新生代空间不足都会Full GC。

注意:Full GC究竟是哪里的GC众说纷纭,小白这里认为其并不单单指老年代GC,而是一次整个堆内存及永久带的GC。但是在去永久带后,也就只是整个堆内存的GC了。

顺便在此给大家推荐一个Java架构方面的交流学习群:698581634,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系,主要针对Java开发人员提升自己,突破瓶颈,相信你来学习,会有提升和收获。在这个群里会有你需要的内容  朋友们请抓紧时间加入进来吧。

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