《深入理解java虚拟机》---垃圾收集(3)

垃圾收集(Garbage Collection GC),很多人认为他就是java的附属产物,其实不然,它出现的时间比java早多了,1960年诞生于MIT的Lisp,诞生之初人们就绪思考一下几个问题:

  • 那些内存需要被回收
  • 什么时候回收
  • 如何回收

一、怎么判断对象已死

引用计数算法:当一个对象被引用一次,计数器就增加1,所以当一个对象的计数器为0的时候就代表这个对象已死,这个是很多语言中用到的方法,但是有一种情况就是当两个对象相互引用的时候,计数器是永远也不会是0的,当时这两个对象是无用的,而java是可以回收这样的两个对象的,那就说明java用的是另外一种算法来判断对象是不是已死,叫做可达性分析算法。

可达性分析算法:这个算法的基本思想是通过一系列的称为“GC Roots”的对象作为起始点,从这从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象没有任何引用链到这个“GC Roots”的时候,就证明这个对象就是不可用的,判定为可回收对象。java中能够作为GC Roots的对象包括下面几种:

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

java引用:从上面的两个算法中我们可以知道对象一旦没有被引用就可以别回收,可是现在的希望描述一种当没存空间还足够的时候,则能保留在内存之中,如果内存空间在进行垃圾回收后还是非常紧张,则可以抛弃这些对象,为了应对这些现象,在JDK1.2之后,java将引用的概念分为以下四种:

  • 强引用:类似Object o = new Object();,只要存在永远不会被回收
  • 软引用:描述一些还有用但并非必须的对象,当将要发生内存溢之前,将会把这些对象列入到回收范围之内
  • 弱引用:描述非必须对象,不管内存可足够,都会被回收掉
  • 虚引用:最弱的一种引用关系,一个对象是否有虚引用的存在不会对其生命周期有影响

生存还是死亡:一个对象别进入可回收的范围内,并不意味着这个对象就一定会被回收,因为一个对象的回收至少会经历两次标记过程,只要在第一次被标记后执行了finalize()方法后重新获得引用链,这样就不会被回收,但是同一个对象finalize()只能够执行一次,但是建议不要用这种方法来拯救一个对象,当年各种方式的创建也只是为让C/C++程序员更容易接受它做出的妥协,可以使用try-finally或者其他方式来替代。

回收方法区:前面的文章中也说过方法区的实现方式是永久代的,永久代的垃圾回收主要是废弃常量和无用的类,判定一个废弃常量比较简单,但是判断一个类是不是无用的类就要满足以下条件:

  • 该类的所有实例都已经被回收
  • 加载该类的ClassLoader被回收
  • 该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射获得该类

二、垃圾收集算法

  1. 标记-清除算法:分为标记和清除两个阶段,是最基础的收集算法,不足之处主要是效率不高,清除之后产生大量不连续的内存碎片,所以导致在分配大对象的时候会触发又一次的垃圾回收动作,影响执行效率。
  2. 复制算法:为了不连续内存碎片问题,复制算法出现了,他将可用内存分为大小相等的两块,每次只使用其中的一块,当使用的一块内存用完了,就将可用对象复制另外一块内存中去,然后清除使用的这块内存,现在的商业虚拟机都是采用这种方式来收集新生代,因为新生代中的对象都是朝生夕死的,所以分配内存的时候分为Eden:Survivor:Survivor=8:1:1,每次使用都是一个Eden和一个Survivor,然后将存活的对象复制到另外一个Survivor中,但是一旦存活的对象大于10%呢,这个时候老年代的内存来分配担保了,由于老年代中存活率比较高所以不适合这种算法。
  3. 标记-整理算法:由于复制是采用复制的手段,走在效率方面还是不够理想,而且还会浪费空间,该算法与标记清除算法不同的是在后续步骤中不是直接对可回收对象进行清理,而是将所有的存活对象都向一段移动,然后直接清理掉端边界以外的内存。
  4. 分代收集算法:当前的商业虚拟机都是采用的这种算法,其实就是对当年算法的整合,根据特点来选择不同的算法,由于新生代中的对象存活量少,所以采用复算法合适,老年代中的存活量高,没有额外的空间对其进行担保,所以采用标记-清理或者标记-整理算法更加合适一点。

三、HotSpot算法实现

枚举根节点:可达性分析中从GC Roots节点向下找引用链,这个过程是很复杂的,并且在查找的过程当中由于程序还在运行可能会有别的引用的创建,所以执行的时候需要执行系统停顿,并且虚拟机应当有办法直接得知那些地方存在着对象的引用,在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的,在类加载的过程中就会把相关信息计算出来,在JIT编译的过程就会记录引用位置,这样GC在扫描的时候就直接得到这些信息了。

安全点:在OopMap的协助下,HotSpot可以快速且准确的完成GC Roots枚举,但是问题是OopMap内容变化的指令非常多,如果为每一条指令生成对应的OopMap,那将会需要大量的额外空间,这样的GC的空间成本将会变得很高。其实在记录引用信息的时候是在特定的位置上,这个位置就是安全点,那么如何在GC发生时让所有线程都跑到最近的安全点上停顿下来,有以下两种:

  • 抢先式中断:不需要线程的执行代码主动去配合,在GC发生时,首先把所有的线程中断,如果发现线程中观的地方不是在安全点上,就恢复线程,让他跑到最近的安全点上,现在几乎所有的虚拟机都是采用这种方式来响应GC事件。
  • 主动式中断:当GC需要中断线程的时候,不直接对线程进行操作,仅仅简单的设置一个标志,各个线程执行的时候主动去轮询这个标志,发现中断标志位真时就自己中断挂起,轮询标志的地方和安全点是重合的,另外在加上创建对象需要分配内存的地方。

安全区域:安全点看似解决了如何进入GC的问题,但是也只是保证程序执行时情况,没有执行的时候,也就是没有分配CPU时间,比如说处于sleep状态,线程无法响应JVM请求运行到安全点挂起。这个时候就需要设立安全区域来解决问题,在这个区域中的任何位置开始GC都是安全的,也就是在线程执行到安全区域时,首先标识自己已经进入安全区域,当这段时间内JVM要发起GC时,就不用管标识为安全区域的线程了,但是该线程要离开安全区域的时候,需要检查系统是否已经完成了根节点的枚举,没有完成需要继续等待。

四、垃圾收集器

前面说的都是一些理论知识,现在说的垃圾收集器才是具体的实现,对于不同的厂商、不同的版本可能会有比较大的差别,这里就以JDK1.7 Update14之后的HotSpot虚拟机作为研究对象,值得一提的这个版本中提供了G1(之前一直处于实验状态)在内的七种收集器,如下图

 

《《深入理解java虚拟机》---垃圾收集(3)》

  1. Serial收集器:是最基本、发展历史最悠久的收集器,是新生代中的单线程收集器,工作时必须暂停其他所有的工作线程,直到他收集结束,他在运行在Client模式下的虚拟机一个很好的选择,优势就是简单而高效,没有线程交互的开销,专心做垃圾收集
  2.  ParNew收集器:是Serial的多线程版本,所有控制参数和算法与其一模一样,是运行在Server模式下的虚拟机一个很好的选择,和Serial收集器一样都是能够很好的与CMS收集器配合工作
  3. Parallel Scavenge收集器:与ParNew一样属于新生代中并行收集器,采用复制算法,不同的地方就是他关注的不是垃圾收集时用户线程停顿的时间,而是吞吐量,吞吐量指的是CPU用于运行用户代码的时间与CPU总消耗的时间的比值,也就是吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),对于吞吐量提供了几个参数用于调节,MaxGCPauseMillis参数,设定内存回收的极限值,GCTimeRatio参数,是0-100之间的整数,是吞吐量的倒数(1/(1+GCTimeRatio参数)),-XX:+UseAdaptiveSizePolicy参数,开关参数,参数打开之后,就不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例等细节参数,虚拟机会根据当前系统运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应的调节策略,对于收集器运行不太熟悉的人来说比较好用,也是与ParNew收集一个重要的区别
  4. Serial Old收集器:这是Serial收集器的老年代版本,同样是单线程,采用标记-整理算法实现,在Server模式下,JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用,也可以作为CMS收集器的备用预案
  5. Parallel Old收集器:是Parallel Scavenge收集器的老年代版本,多线程,采用标记-整理算法实现,JDK1.6之后才提供,与Parallel Scavenge收集器配合使用,在吞吐量方面就有比较大的优势
  6. CMS收集器:是一种以获取最短停顿时间为目标的收集器,是老年代中基于标记-清除算法实现,运作过程分为:初始标记、并发标记、重新标记、并发清除四个步骤,其中初始标记和重新标记任然需要暂停用户线程,他的优点就是并发收集,低停顿。当然也有自己的缺点:(1)对CPU资源非常敏感;(2)无法处理浮动垃圾,可能出现“Concurrent  Mode Failure”失败而导致另一次的Full GC的产生;(3)基于算法实现可能在收集结束后出现大量空间碎片
  7. G1收集器是当前收集技术发展的最前沿成果之一,将新生代和老年代都是看做一个整体,然后把这个整体划分为多个大小相等的独立区域(Region),新生代和老年代都是一部分独立区域,有四个特点:(1)并发与并行;(2)分代收集;(3)空间整合;(4)可预测的停顿,之所以能够预测停顿时间模型,是因为他有计划的避免对java堆中进行全区域的垃圾收集,会跟踪各个Region里面的垃圾收集的价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也是Garbage-First名称的由来),可以提高收集效率,可是这里有一个问题就是如何保证在收集Region时,正好其中的一个对象被的Region引用了,是不是要对所有的Region进行扫描?为了解决这个问题,我们使用了Remembered Set来避免全堆扫描,G1中的每个Region都有一个对应的Remembered Set,虚拟机发现程序对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中,如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set中,当进行内存回收的时候,在GC的根节点的枚举范围中加入Remembered Set就可以保证在不扫面全堆的情况下不会有遗漏,如果不计算维护Remembered Set的操作,G1收集器的运作步骤为:(1)初始标记;(2)并发标记;(3)最终标记;(4)筛选回收

五、GC日志

阅读GC日志是处理java虚拟机内存问题的基础技能,只是一些人为确定的规则,没有太多的技术含量。

六、内存分配和回收策略

对象优先在Eden分配:大多数情况下,对象在新生代Eden中分配,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC

大对象直接进入老年代:所谓的大对象是指需要大量连续内存空间的java对象,典型的大对象就是那种很长的字符串和数组,大对象对内存分配来说是一个坏消息,特别是短命的大对象,会造成空间不够,提前触发垃圾收集,这个对于开发人员来说一定是要注意的,不然很影响运行的效率。

长期存活的对象将进入老年代:虚拟机给每个对象一个年龄计数器,当对象在Eden中出生并熬过一次Minor GC后任然存在,就会转移到Survivor空间中,对象年龄设置为1,在Survivor中每熬过一次Minor GC年龄就加1,当年龄超过默认值(15)的时候就会进入老年代,这个默认值是可以通过-XX:MaxTenuringThreshold参数来设定的 

动态对象年龄判断:为了应对不同程序的内存情况,虚拟机并不是永远要求对象的年龄必须达到XX:MaxTenuringThreshold才能晋升到老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象就可以直接进入老年代,无需等到XX:MaxTenuringThreshold要求的年龄。

空间分配担保:在发生Minor GC之前,虚拟机会先检查老年代中的最大可连续空间是否大于新生代所有对象的空间,如果这个条件成立,那么Minor GC可以确保是安全的,如果不成立,那虚拟机就会查看HandlePromotionFailure设置值是否允许担保失败,如果允许,那么会继续检查检查老年代中的最大可可连续空间是不是大于历次晋升到老年代对象的平均大小,如果大于就尝试一次Minor GC,尽管是有风险的,如果小于或者HandlePromotionFailure不允许,那么这时就要改为进行一次Full GC

备注:

  1. 新生代GC(Minor GC):指发生在新生代中的垃圾收集动作,因为java对象大多都具有朝生夕灭的特征,所以Minor非常频繁,一般回收速度比较快
  2. 老年代(Major GC/Full GC):指发生在老年代的GC,出现了Full GC,经常会伴随着至少一次的Minor GC,但是不绝对,因为Parallel Scavenge收集器的收集策略中就有直接进行Full GC的策略选择过程,Full GC的速度一般会比Minor GC慢十倍以上

 

    原文作者:java虚拟机
    原文地址: https://blog.csdn.net/hy_coming/article/details/82052453
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞