深入Java虚拟机(3):GC(垃圾回收)算法和垃圾回收器

       在上篇JVM内存结构有提到垃圾回收相关的内容,并且通过从内存的角度来分析为什么要进行垃圾回收,本文将详细讲解各种垃圾回收(Garbage Collection)算法以及垃圾回收器和实现原理。本文主要是概念性的东西,希望大家在看各种垃圾回收算法的时候,能够结合实际去考虑不同算法的优劣。     

 注:本文相关图片资源来源均在文尾给出参考文献来由

在我们进入文章之前可以先想几个问题:哪些JVM内存区域需要回收?哪些对象会被回收?到底怎么回收呢?以及什么时候回收?

哪些JVM内存区域需要回收

       在JVM内存结构介绍了五个内存区域,有提到堆是垃圾回收主要的地方,另外还有方法区也是需要进行垃圾回收来管理内存空间,这两个区域也是线程共有的,那还有三个区域是否需要呢?回答是不需要的,其他三个区域:JVM栈、本地方法栈和程序计数器是线程私有的,它们的生命是由线程决定的,随线程而生也随线程而灭,所以这三个区域是不需要过多考虑回收的问题。

哪些对象会被回收

      垃圾回收主要是对堆进行回收,堆中存放的是实例对象,那垃圾回收要回收的肯定是对象了,什么对象会被垃圾回收器当做要回收销毁的对象呢?在回收算法的角度来看就是这个“对象已死”的时候就会被回收销毁,判断对象存活通常有两种方式:引用计数算法和根搜索算法

引用计数算法:给对象添加一个计数器用来计该对象目前被多少地方引用,每增加一个地方引用就将计数器值加1,每失效一个引用就将计数器值减1,当计数器值为0的时候就代表这个对象没有地方被使用了就相当用“死了”,这时候就可以被垃圾回收掉。Java语言判断对象是否存活不是用的这样的方法,因为有个致命的问题就是:当两个对象相互引用的时候,而这两个对象在其它任何地方都没有被引用时,这两个对象就不会被垃圾回收掉。

根搜索算法:其实有点像一种树的数据结构,通过一系列名为“GC Roots”的对象(这类对象在下面会给出)作为起始点(根),从这些起点向下搜索,搜索的路径称为引用链,在这条链中能搜索到的对象就代表是存活不会被回收的,当某个对象与这条链中不相连的时候就代表该对象需要被回收了,可以看下面的图,白色区块的几个对象是将要被回收掉的

能作为“GC Roots”的对象包括以下几种:

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

 

垃圾回收算法


标记-清除算法

思路:从字面意思就能大概知道是分两步进行,先标记要回收的对象,然后下一步就是清除掉被标记的对象。

缺点:首先是效率问题,每次都要去标记回收对象然后再扫描整个内存逐个回收被标记的对象;另外一个问题就是标记清除后会产生大量不连续的内存碎片区,如果空间碎片太多的话就意味着整块内存没有一处足够大的连续的内存空间(如下图标记清除算法示意图),那当程序运行时需要给一个占空间较大的对象分配空间的时候却找不到足够的空间时,就会导致提前触发另一次垃圾回收的动作,而垃圾回收也是通过线程去执行的,这时候从线程角度来看,程序运行的线程是会被影响的,因为CPU要却换到垃圾回收的线程上面去执行,这样也就会导致对程序运行有影响,那么在一个应用程序运行过程中,垃圾回收如果太频繁的话,肯定是会影响到正常程序线程的运行,这也是我后续文章会介绍的关于GC调优相关的内容,大家在这里要明白有这么个问题的存在,后续学GC调优的内容才会有目的感!

 

复制算法

思路:将整块堆内存容量划分大小相等的两块,每次只使用其中的一块,当被使用的这块内存用完时,就将存活的对象复制到另外一块未使用的内存上面,然后把使用的这块内存整体回收清理掉(如下图复制算法示意图),这在回收效率上是有提高的,也解决了空间碎片的问题

缺点:实际用的内存空间只有一半了,这样在大型的项目当中会容易出现内存不够用的情况;另外一个隐性的问题非常值得思考的就是在堆中有些对象是长期存活的,但是还是在每次回收的时候被“移来移去”,这样也会导致效率降低,按道理这些长期存活的对象是不需要经常被“针对”的,从这个隐性问题出发大家可以自己试着想想更好的类似这样的算法思路?下文会提到

 

标记-整理算法

思路:其实说标记-整理-清除算法可能会更直观,这种算法多了一步整理步骤,在标记后不是立即清除标记对象,而是先把存活的对象向内存的一端移动,然后再直接清理掉端边界以外的内存(如下图算法示意图)。该种算法解决了标记清除算法的空间碎片弊端,另外大家可以想想是不是也解决了上面提到复制算法的隐性问题?大家好好想想,比如这块空间有很多长期存活的对象,在经过多次标记-整理算法之后长期存活的对象其实是不再会在后续的每次回收过程中再被移动了,因为这些对象早已经在内存的一端了,不知道大家能不能体会到这一点,其实可以脑补多次经过这种算法回收的动态图像,就应该能明白了。

缺点:要说缺点我能想到的是如果堆内存的对象们基本上是都是“昙花一现”的话,那这种算法在进行回收的时就显得不是最好的方式了,因为涉及到当前回收时大量的存活对象要移动到一端,但是下次可能又会被清理掉,而又会有一大批“逃过此劫,逃不过下一劫”的对象又要从内存里的“五湖四海”处移动到这一端来。

通过分析复制算法的隐性问题和标记整理算法的优点后大家应该会有这么一个认知就是:进行垃圾回收时,结合对象的生命周期来考虑是很有必要的!下一个算法思路就会着重提到。

 

分代收集算法

思路:当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,其实就是根据对象的生命周期的不同,将堆内存划分为几块,这样就可以根据不同的区块里对象的生命周期特点采用最适当的收集算法。

这里就引出了我在JVM内存结构中堆内存介绍时描述过的堆里面具体的结构划分空间了(如下图),我在里面就介绍过堆里面的结构划分为:年轻代和老年代,年轻代里面的结构又被划分为三个区域:Eden、FromSpace(Survivors1)和ToSpace(Survivors2),在这里就来分析为什么堆里面的结构要这样划分,首先来介绍不同代的特征。

年轻代里的对象:用一个词语来表示“昙花一现”,创建出来后,存活时间很短。

老年代里的对象:用一个词语来表示“久经沙场”,都是经历过风风雨雨的,来到老年代可是都没那么容易就die的。

结合本文介绍过的不同算法的思路,大家应该能选出年轻代和老年代分别用上述哪种算法更合适了吧?根据年轻带里面的对象特性采用复制算法的思路是更优的,老年代采用标记整理算法是更优的。

不过在对年轻代进行上面说的复制算法的方式实现的话是有上述缺点的,年轻代里面的结构被划分为三个区域就是为了解决这个缺点,这也是结合这个年轻代特性进行划分的,IBM的专门研究表明,年轻代中的对象98%是“朝生夕死”的,如果按上面复制算法把年轻代1:1进行划分,是没有必要的,所以才划分成三个区域,使用情况就是在给对象分配空间时只用Eden和其中一个Survivor空间,当进行垃圾回收时将存活的对象(占少数)复制到另外一个没使用的Survivor空间,然后将Eden和使用过的Survivor空间全部回收清理掉,两个Survivor是一段较小的内存,HotSpot虚拟机默认Eden和两个Survivor的大小比例是8∶1:1。

现在又来分析一个问题,假如某次进行复制到Survivor时Survivor空间不够的话,怎么办?就会需要依赖老年代来进行分担了。

当两个Survivor切换了几次(HotSpot虚拟机默认15次,用-XX:MaxTenuringThreshold控制,大于该值进入老年代)之后,任然存活的对象(这些对象其实就是相当于“久经沙场”了嘛),将被复制到老年代。老年代中的对象还来于当对象比较大(比如长字符串或大数组),年轻代空间不足,则大对象会直接分配到老年代上(大对象可能触发提前GC,应少用,更应避免使用短命的大对象,所以在写代码的时候也要尽量留意这些问题,不要不过脑的写一堆“纯真”的代码,这也是为什么要学习基础相关内容的必要性所在),这里介绍两个JVM内存结构中没有介绍的两个参数让大家了解一下:

  • 用-XX:PretenureSizeThreshold来控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上
  • 用-XX:+UseAdaptiveSizePolicy开关来控制是否采用动态控制策略,如果动态控制,则动态调整Java堆中各个区域的大小以及进入老年代的年龄

各种回收算法到这里就介绍完毕了,现在大家来看下针对堆的回收来看什么时候会进行垃圾回收

 

何时进行垃圾回收

年轻代:每次Eden区满了,就执行一次垃圾回收,对年轻代的回收也叫Minor GC。

老年代:当年老代内存不足时,就会执行一次垃圾回收,对老年代的回收也叫Full GC。

       很明显年轻代回收频率比老年代高得多,另外垃圾回收的执行频率对程序有很大的影响,上文也有提到过,有时候工作中生产问题就会涉及到是这类问题,这也是学习GC调优的必要性,但是学习GC调优之前,本文以及相关的基础内容大家势必要透彻,因为大家要先明白垃圾回收的执行频率以及效率到底是受什么因素影响的,相信通过阅读博主的这几篇文章,大家可以总结出个大概来。

       另外我贴出《码出高效:Java开发手册》里面对新生代和老年代的解释,有兴趣的可以看本书籍:

       堆分成两大块新生代和老年代。对象产生之初在新生代, 步入暮年时进入老年代, 但是老年代也接纳在新生代无法容纳的超大对象。新生代= 1 个Eden 区+ 2 个Survivor 区。绝大部分对象在Eden 区生成, 当Eden 区装填满的时候, 会触发YoungGarbage Collection , 即YGC。垃圾回收的时候, 在Eden 区实现清除策略, 没有被引用的对象则直接回收。依然存活的对象会被移送到Survivor 区, 这个区真是名副其实的存在。Survivor 区分为so 和Sl 两块内存空间, 送到哪块空间呢?每次YGC 的时候, 它们将存活的对象复制到未使用的那块空间,然后将当前正在使用的空间完全清除, 交换两块空间的使用状态。如果YGC 要移送的对象大于Survivor 区容量的上限,则直接移交给老年代。假如一些没有进取心的对象以为可以一直在新生代的Survivor 区交换来交换去,那就错了。每个对象都有一个计数器,每次YGC 都会加1 。-XX:MaxTenuringThreshold 参数能配置计数器的值到达某个阐值的时候, 对象从新生代晋升至老年代。如果该参数配置为1 ,那么从新生代的Eden 区直接移至老年代。默认值是15 , 可以在Survivor 区交换14 次之后, 晋升至老年代。下图为具体进行YGC和FGC的流程图:

 

方法区的回收


      前面都是将关于堆的回收内容,方法区也是需要回收的,方法区也被俗称永久代,永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。

回收废弃常量:与回收Java堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串“abc”已经进入了常量池中,但是当前系统没有任何一个String对象是叫做“abc”的,换句话说是没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果在这时候发生内存回收,而且必要的话,这个“abc”常量就会被系统“请”出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。

回收无用的类:

判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。首选无用的类满足三个条件:

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

虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class及-XX:+TraceClassLoading、 -XX:+TraceClassUnLoading查看类的加载和卸载信息。

 

 

垃圾回收器


       如果说收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大的差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。这里讨论的收集器基于Sun HotSpot虚拟机1.6版 Update 22,图片如下:

本文就不详细讲解各种收集器具体的内容了,在这里给出博主认为写得好的垃圾回收器内容的文章推荐:http://www.ityouknow.com/jvm/2017/08/29/GC-garbage-collection.html。后续博主会结合自己的理解更新这块内容。

 

参考文献:

1.https://www.cnblogs.com/gw811/archive/2012/10/19/2730258.html

2.https://blog.csdn.net/mccand1234/article/details/52078645

上面两个地址都是两位大牛博主,有很多好文章,大家可以去学习学习

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值