Java虚拟机进阶之路——内存回收与垃圾收集算法

1.概述

  三个问题的提出:那些内存需要回收?什么时候回收?如何回收?

  需要被回收的是垃圾,垃圾是内存中不再被利用到的空间,垃圾回收由虚拟机垃圾收集器来完成。

以上是我对垃圾收集概述三个问题的初步回答。

2.对象已死?(如何判断对象的存亡?)

  ◇引用计数法

  在对象中添加一个计数器,每当有一个地方引用这个对象时,计数器加一,引用消失减一;任何时刻计数器为零那么这个对象就不可能再被引用。

  这个方法很简单,执行效率也很高,但是存在一些问题,譬如计数器本身需要占用一部分的内存,更重要的是,此方法无法解决对象间相互持有引用(循环引用)的问题,极端情况下这将直接导致虚拟机对于对象存亡的判定出现严重偏差。

  ◇可达性分析算法

通过一系列称为GCROOTS的根对象做为起始节点集,根据引用关系向下搜索,走过的路径称为引用链,如果一个对象到GCroots间没有任何引用链相连,那此对象不可达,证明对象不会再被使用。

问题来了,哪些算GCroots?

  • 虚拟机栈中的引用对象,所使用的方法中的参数、局部变量、临时变量等。
  • 类静态属性引用的对象。
  • 常量引用的对象,如字符串常量池里的引用。
  • 本地方法引用的对象。
  • 类加载器。
  • 同步锁(synchronized)持有的对象。
  • 根据收集器和回收区域的不同而加入的其他对象。

HotSpot使用一种叫oopMap的数据结构达到准确GC的目的。

这是一种用来描述对象引用关系的数据结构,类加载完成后,虚拟机会记录对象在哪个偏移位置上有哪些引用。

3.再谈引用

  引用分为强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference4种,这4种引用强度依次逐渐减弱。

  强引用是最传统的一种定义,即new,在任何情况下只要强引用存在,垃圾收集器就永远不会回收被引用的对象。

  软引用是对于一些还有用但非必须的对象,在系统即将发生内存溢出前,会把这些对象列入回收范围进行第二次回收,如果这次回收还没有清理出足够的内存,才会抛出异常。

  弱引用是比软引用更弱的引用,系统会回收掉只有弱引用关联的对象。

  虚引用是只要发生GC就会被回收的,只是为了能在这个对象被回收时收到一个系统通知。

4.对象最后的生还机会

  最初被标记为不可达的对象也不是“非死不可”,在它真正死亡之前会经历最多两次标记;最初发现对象没有和任何一个引用链产生关联时,对象被第一次标记,之后会进行筛选,筛选条件是对象是否有必要执行finalize)方法,此时对象没有覆盖此方法或此方法已经被调用一次,则对象彻底死亡。拯救也很简单,只要在对象的finalize()方法中与引用链上任何一个对象建立关系即可,那样的话对象会在筛选中被移除出即将清理的集合,即被拯救成功。记住!!复活机会只有一次(那个方法只会被系统调用一次)。

▼回收方法区

  方法区主要回收两部分内容:废弃的常量和不用的类型

判断一个常量是否被废弃相对简单,但判断一个类型是否不会再被使用则相对麻烦,准则如下:

  • 该类的所有实例已被回收
  • 加载该类的加载器已被回收
  • 该类对应的class对象没在任何地方被引用,也无法通过反射访问到该类的方法。

(死亡并不是终点,被遗忘才是)

5.垃圾收集算法

█分代收集

现在的虚拟机大多数都遵循“分代收集”,这主要建立在两个分代假说上:

弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。

强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡

这也决定了垃圾收集器要划分区域进行垃圾收集,并且相应的产生了后面的几个概念:

Minor GC(只针对新生代发生的垃圾收集,频繁且快速)、Major GC(只针对老年代的收集)、Mixed GC(整个新生代外加部分老年代,G1会这么干)、Full GC(整堆和方法区)

标记复制算法、标记整理算法、标记清除算法

新生代:在每次垃圾收集结束之后都会有大量的对象死去,而每次回收过后还存活的对象,将会逐步进入老年代。

假如现在发生一次对于新生代的收集,但新生代中对象可能会被老年代有的对象引用,在进行应有对象存亡判定外,还要进行老年代区域的相应判定,这无疑会增加开销,这里,又一个假说应运而生:

跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极

少数。(存在互相引用关系的两个对象,应当倾向于同生共死)

每一个对象是否存在跨代引用,存在哪些跨代引用,会有一种叫“记忆集”的数据结构进行记录,这东西把老年代分成小块,标记出老年代哪一块存在跨代引用,在进行GC时候,只有这些包含了跨代引用的老年代才会被加入GCroots进行扫描,这对于GC来说仍然是划算的。

标记—清除算法

首先标记出所有需要回收的对象,在完成标记后,统一回收被标记的对象,也可以反过来标记存活的对象。

两个缺点:①执行效率不高,需大量标记

②回收后空间碎片太多导致后续分配大对象时没有足够的可用空间导致另外的垃圾回收,增加时间开销。

  标记—复制算法

半区复制:将空间划分为相等的两块,一次只使用一块,当其中一块用完了,就把这快内存上的存活对象复制进入另一块内存,再把已使用过的区域进行一次统一清理。

 缺点:造成空间浪费

 Appel式回收:这个方法是把新生代分为一个较大Eden区和两个较小Survivor区(8:1:1),在使用时只用Eden区和一块Survivor区,在发生垃圾收集时,将Eden区和Survivor区存活的对象一次性复制到另外一个Survivor区,然后对原空间进行清理。

Appel式回收还有一个充当罕见情况的逃生门的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion),如果另一块Survivor区空间不足以容纳存活对象,那么这些对象将通过分配担保直接进入老年代。

  标记—整理算法

(针对老年代对象大量存活而提出)这个算法的标记过程和标记清除算法一样,不同的是它并不直接清理,而是让所有存活对象向空间一端移动,然后直接清理掉边界以外的内存。

缺点:移动对象后要更新这些对象的引用以指向正确的地址是一项繁重的工作。但不移动对象的话会有大量的内存碎片,在分配时不方便。

垃圾收集停顿时间来看,不移动对象只需要短暂停顿或者不停顿,但从整个程序吞吐量来看,移动对象会更划算。

HotSpot算法细节实现

根节点枚举

以可达性分析算法中从GC roots集合找引用链这个操作举例子

首先要知道收集器在根节点枚举这个环节必须暂停用户线程,即Stop The World

在用户线程停顿下来时候,其实并不需要一个不漏的检查完所有执行上下文和引用,虚拟机自有办法知道哪些地方存着对象引用。

在HotSpot中,用一组叫OopMap的数据结构来解决这个问题。一旦类加载完成,虚拟机会把对象什么偏移量上是什么类型计算出来,也会在特定的位置和栈里记录下哪些位置是引用,这样方便收集器扫描时知道这些信息,不用一个不漏的找。

安全点

其实虚拟机也没有为每条指令都生成一个OopMap,只是在特定的位置记录了这些信息,这些位置就被称为安全点(safepoint)。

这东西决定了程序执行时并不是随心所欲地想在哪停下并开始垃圾收集就这样干,而是强制要求必须执行达到安全点后才能暂停。因此安全点怎么选择成为了一个问题,安全点根据“是否具有让程序长时间执行的特征”进行选取。长时间执行的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

关于安全点的另一个考虑是,如何让垃圾发生时的所有程序都跑到最近的安全点停顿下来,这里有两种方案:抢先式中断和主动式中断。

抢先式:抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。

  主动式:主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

安全区域

当程序不执行时,即没有分配处理器时间时,典型的是sleep和blocked状态,这时候线程没办法响应虚拟机并挂起自己,这种情况就必须要引入安全区域来解决。

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任

意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

记忆集与卡表(针对新生代)

  为解决对象跨代引用问题而产生,避免将整个老年代加入GC roots扫描范围,是一种用于记录非收集区域指向收集区域的指针合集的抽象数据结构。

  卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

它指的是用一种称为“卡表”的方式实现记忆集,定义了记忆集的记录精度、与堆内存的映射关系等。

卡表最简单的形式是字节数组,数组中每一个元素都对应着其标识的内存区域中一块特定大小的内存块(卡页)。一个卡页内通常有不止一个对象,如果卡页内有一个或多个对象存在跨代指针,那就将对应的卡表的数组元素标识为1,称为变脏(Dirty),在垃圾收集时,只要筛选出卡表中变脏的元素,就能知道哪些卡页内存块中包含着跨代指针,就将他们加入GC roots一并扫描。

写屏障

  我们已经解决了如何使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维

护的问题,例如它们何时变脏、谁来把它们变脏等。

  卡表变脏时间:有其他分代区域中对象引用了本区域对象时,其对应的 卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。

  如何变脏?如何维护卡表信息使之准确?

HotSpot虚拟机里是通过写屏障Write Barrier)技术维护卡表状态的。

写屏障可以看作在虚拟机层面对引用类型字段赋值这个动作的AOP[2],在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障。

并发可行性分析、

  如果用户线程和收集器是并发执行呢?怎样解决一直在变化中的引用关系呢?

·赋值器插入了一条或多条从黑色对象到白色对象的新引用;

·赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

要解决并发扫描时的对象消失问题,只需破坏两个条件其中的一个,则有两种方法:增量更新和原始快照

增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新 插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象

了。

  原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删 除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

这部分要根据书中的图来看灰色黑色白色对象

CMS基于增量更新来做并发标记,G1和Shenandoah使用原始快照方法。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值