JVM 对象引用标记 与 内存回收算法

1、为什么要进行垃圾回收?


每当在我们写代码的时候,不管是new一个对象,还是引用,还是填充数据到数组,都是要占用空间,那么如果不及时回收就会对系统的运行产生影响。java和c 一个很大的区别就在于,java的垃圾回收主要是jvm去做,而c语言是自己去控制。虽然JAVA可以手动的调用方法 system.gc 去手动控制垃圾回收,但据说达不到立马回收的效果。c 语言则是要自己去申请一块内存空间malloc ,使用完成还需要手动去释放掉,如果没有及时释放,或者申请出现内存过大等,会造成内存溢出等异常,不过功底深厚的大牛都会做的比较牛逼,很好的去控制。

2、如何判断对象生死?

在内存回收的过程中,首先要确定一点,该对象是否应该被回收,哪些还"存活",哪些已"死亡"。

  • 引用计数法

当一个对象在代码中被引用,那么就会在这个对象的引用计数值 + 1,当引用失效的时候,计数值则相应 - 1。但是如果两个对象存在相互引用的情况,如下:

A a = new A(); 
    B b = new B();
    a.instance = b;
    b.instance = a;

虚拟机就无法去判断这两个对象是否需要被回收,因为彼此的引用值都不是 0。就是说引用计数法很难解决对象之间的相互引用问题, 也无法通知GC回收器去及时回收它。因为存在这种缺点,所以现在的虚拟机基本上并不是通过引用计数法来判断对象是否存活。那是通过什么方法? 请看官往下看。

  • 可达性分析算法

现在主流的商用语言的视线中都是通过可达性分析来判断对象是否存活,比如JAVA,C#等。这种方法基本思想 ——以 GC Roots的对象作为起点向下搜索,搜索走过的路径被称为"引用链",当一个对象没有任何引用链相连,那么这个对象就是不可用的。如下图所示:

JVM 对象引用标记 与 内存回收算法

gc roots 是什么? 是满足下面任意条件的某个对象。

  • 虚拟机栈中reference对象;
  • 方法区静态属性引用对象;
  • 方法区常量引用对象;
  • 本地方法栈 所谓的native方法 引用的对象。

Hotspot中的native方法引用Java对象用的是通过句柄(handle)来引用。HotSpot的JNI handle是放在若干不同的区域里的,但不会放在GC堆中。传递参数用的handle直接在栈上;local handle放在每个Java线程中的JNIHandleBlock里;global handle放在VM全局的JNIHandleBlock里。
关于什么是native方法请如下链接:https://segmentfault.com/n/13...

注:并不是不可达的对象就必须 "死",他们还是处于"缓刑", 真正要宣告一个对象死亡,需要经过两次标记的过程:经过可达性分析后对象没有和GC Roots 连接的引用链,那么需要被标记一次然后还需要经过筛选(筛选条件:判断该对象是否有必要执行finalize()方法),如果对象已经调用了或者没有覆盖finalize方法(finalize() 方法只会被执行一次!),那么 虚拟机判定该对象是 "没有必要执行该方法"。

如果该对象有必要执行finalize方法,那么对象会被放置在一个叫做F-Queue 的队列之中,之后会由虚拟机自动建立,由低优先级的Finalize 方法去执行。(执行时只去触发对象的finalize()方法,但是并不等待他运行结束,防止有的对象finalize()进行缓慢,或者死循环,会导致队列持续等待,进而内存回收系统崩溃。)稍后GC 会对F-Queue 队列中的对象进行第二次标记,当finalize 方法执行后成功将对象连接到引用链上任何一个对象,那么这个对象就被拯救成功了,不然则go die!

什么是引用?

Java中定义:如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就称是这块内存的一个引用。

  • 强引用:类似于A a = new A(),只要引用在,就永远不会回收被引用的对象。
  • 软引用:描述有用但并非必须的独享。在系统将要发生内存溢出的时候,会将这部分对象回收。
  • 弱引用:非必需对象引用,能生存到下次发生GC之前。
  • 虚引用:一点用都没。只有当对象被垃圾回收器回收的时候会收到一个系统通知。

方法区回收

在我看来方法区的内存,回收起来并没有新生代那么明显。方法区大多存有类的描述信息,静态变量,常量,方法等信息,这些大多是系统常用的,很少去回收,回收效率微乎其微。然而永久带的方法回收主要分成两部分,一种是常量的回收,另一种是类的回收。

  • 常量的回收

相当于这个常量已经被废弃掉了。例如:方法区的常量池中有一个字符串常量 "java", 当系统中没有一个String对象指向这个常量的值得时候,那么这个常量在发生GC的时候将会被回收。
类的回收

  • 类的回收

相对于常量的回收会麻烦多,需要满足下面三个条件才会被回收:
1、类中所有的对象都被回收,就是堆中不存在该类的任何的实例;
2、加载该类的classloader被回收;
3、该类对应的java.lang.Class对象没有在任何地方引用,或是通过反射机制访问不到该类。

注: 在大量使用反射,动态代理,cGLib等ByteCode框架、动态生成Jsp等频繁定义classLoader的场景都需要虚拟机具备类的卸载功能,防止永久带不会溢出。

3、垃圾回收算法

  • 标记-清除算法

    标记-清除 算法是最基础的算法,为什么呢?因为后面的要讲的算法很多是从这个基本的算法改变其不足演变而来。

标记-清除(Mark-Sweep) 算法正如其名字所说由两个部分来完成。首先,要对需要回收的对象进行标记,如何标记上面已经提过。然后,要对这些被标记的对象进行收集。

标记:标记的过程其实就是,遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象。
清除:清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。

如下图所示:

JVM 对象引用标记 与 内存回收算法

感觉清理完成之后,内存零零散散,故该算法有以下两个缺点:
缺点一:被标记的对象在内存中分布很零散,回收之后可用内存很零碎。如果当一个进程需要申请一块连续的较大内存时,无法找到足够的连续内存,不得不提前触发一次垃圾回收的动作。
缺点二标记和清除的过程效率不高,而且在进行GC的时候,需要停止应用程序,这会导致用户体验非常差,尤其对于交互式的应用程序来说简直是无法接受。

复制算法

复制算法是对标记-清除算法在回收后出现很多内存碎片的一种改进,而且效率也有所提升。

复制算法(copying)将可用的内存容量划分成大小相等两块,每次只使用其中一块,当一块内存用完了,将还存活的对象复制到另一块内存中,然后将之前使用过的内存空间全部清理掉。当有效内存空间耗尽时,JVM将暂停程序运行,开启复制算法GC线程 ,它会将活动区间内的存活对象,全部复制到空闲区间,且严格按照内存地址依次排列,与此同时,GC线程将更新存活对象的内存引用地址指向新的内存地址。 清空之前的内存块,减少了大量不连续的内存碎片的产生,实现简单且运行高效。

如下图所示:
JVM 对象引用标记 与 内存回收算法

细心的读者会发现,这种算法有个很大的缺点——将可使用的内存缩小成原来的一半,代价太大了!所以现在的虚拟机厂商都采用复制算法来回收新生代。研究表明 新生代对象98%都是 “朝生夕死” 所以不需要按照1:1划分内存空间,而是将内存分为一块较大的Eden 空间和两块较小的Survivor 空间。每次只是用 Eden 和 其中一块 Survivor区域,另一块Survivor 则用来当作保留区。 那么这样一来,每次进行回收的时候只需要将Eden 和 Survivor生还的对象复制到另一块 Survivor空间,然后清理。HotShot虚拟机 新生代划分比例默认 Eden:Survivor = 8:1。然而这样分配内存,有个问题。当作保留区的Survivor的内存大小不够承载 使用中的Eden和一块Survivor区域的存活对象怎么办?此时需要依赖其他内存(老年代)进行分配担保

分配担保(Handle Promotion)——如果另一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象直接通过分配担保机制进入老年代。

JVM 对象引用标记 与 内存回收算法

发生Minor GC(只回收Eden和Survivor区)前,虚拟机检查老年代最大可用连续空间是否大于新生代所有对象总空间。如果大于,那么Minor GC确保是安全的。如果不大于,则需要查看虚拟机HandlePromotionFailure参数设置,是否允许担保失败。若允许(true),会继续检查老年代最大连续可用空间是是否大于历次晋升到老年代的对象平均大小。如果大于,会尝试一次 Minor GC,尽管是有风险。(因为仅仅是历次晋升到老年代对象平均大小与老生代最大连续空间比较,如果内存小无法容纳,此时进行Minor GC 会清理原本存活的对象所以是冒险的,进而需要进行Full GC)如果小于或者Handle Promotion Failure不允许冒险,那么要进行一次Full GC。

在jdk1.6 update24以后handle promotion failure 参数已经不会影响到分配担保的判定,具体代码如下:

bool TenuredGeneration::promotion_attempt_is_safe(size_t  
max_promotion_in_bytes) const   
{  
   // 老年代最大可用的连续空间  
   size_t available = max_contiguous_available();    
   // 每次晋升到老年代的平均大小  
   size_t av_promo  = (size_t)gc_stats()->avg_promoted()->padded_average();  
   // 老年代可用空间是否大于平均晋升大小,或者老年代可用空间是否大于当此GC时新生代所有对象容量  
   bool   res = (available >= av_promo) || (available >=max_promotion_in_bytes);  
   return res;  
}

如果老年代连续空闲空间大于历届晋升到老年代的对象的平均空间可以直接minor GC 否则 Full GC。

标记-整理算法

复制算法是需要将对象从从内存一个区域复制到另一个区域,当发现对象存活率很高的情况下,效率很低。而且在老生代的回收中,大多不采用复制算法,没有额外的空间进行分配担保。

标记-整理算法(Mark-Compact),过程和Mark-Sweep 方法过程一样,也需要对对象进行标记,不过后续步骤不是直接对可回收对象进行清理,算法分成两个部分。  

标记:遍历GC Roots,然后将存活的对象标记。
整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。

JVM 对象引用标记 与 内存回收算法

标记-整理算法不仅可以弥补标记-清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。不过标记-整理算法,效率是唯一缺点。它需要对存活对象进行标记,然后要整理存活对象内存地址,相对于复制算法效率较低

分代收集算法

分代收集算法,当前商用的虚拟机的垃圾收集大都采用这种算法。也不算是算法,只是把java 堆分成 新生代,老生代。分代之后,不同区域可以使用不同的收集算法。比如:

  • 新生代 每次垃圾回收都会有大批对象死去,只有少量存活,那就采用复制-算法
  • 老生代 对象存活率高,也没额外的空间对它进行分配担保,使用标记-整理/清除算法会更好来回收。不同场景使用不同的算法更加有利于整体效率的提升。

JVM 在进行GC 时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。因此GC按照回收的区域又分了两种类型,一种是普通GC(minor GC),一种是全局GC(major GC or Full GC),它们所针对的区域如下。
普通GC(minor GC):只针对新生代区域的GC。
全局GC(major GC or Full GC):针对年老代的GC,偶尔伴随对新生代的GC以及对永久代的GC。

注:由于年老代与永久代相对来说GC效果不好,而且二者的内存使用增长速度也慢,因此一般情况下,需要经过好几次普通GC,才会触发一次全局GC。

相关推荐