深入理解JVM(四)垃圾回收目标和垃圾回收算法

先谈引用

不论是是引用计数还是可达性分析,判断一个对象是否存活都和引用有关。java将引用分为了4种,强引用(Strong Reference),软引用(Soft Reference),弱引用(Weak Reference),虚引用(Phantom Reference),引用的强度依次减弱。

  • 强引用:指java种普遍存在的引用关系,如Object obj = new Object();这类的引用,只要强引用存在,垃圾收集器永远不会去回收
  • 软引用:描述一些还有用但不是必须的对象。对于软引用关联的对象,在系统将要发生OOM异常之前,才会列入垃圾回收的范围。如果回收后还没有足够的内存,才会发生OOM异常。java中使用SofrReference来实现。
  • 弱引用:也是描述非必须对象的。它只能存活到下一次垃圾回收发生之前。WeaReference来实现,java中WeakHashMap的key值就是弱引用的使用。
  • 虚引用:也称为幽灵引用或者幻影引用,最弱的一种引用关系。虚引用的存在不会影响对象的生存时间,也不能通过一个虚引用来获取对象的实例。它的唯一目的就是在这个对象被回收时收到一个系统通知。通过PhantomReference实现

对象已死吗?

引用计数法

基本思想:给对象添加一个引用计数器,每当有一个地方引用它就+1;引用失效就-1;当计数器为0的对象就是不能再被使用的。
优点:简单,判定效率也很高,在大部分情况下都是一个不错的算法。
缺点:很难解决对象之间循环利用的问题。比如objA和objB两个对象都有一个实例instance指向对象,除此之外再无其他引用,实际上这俩个对象都不能被访问到了,但是因为引用计数器不为0,所以无法回收内存。
所以java中没有使用该方法。

可达性分析

在主流的商用语言中,都是通过可达性分析来判断对象是否存活的。
基本思想:通过一系列被称为”GC Roots“的对象作为起点,从这些节点向下搜索,走过的路径成为引用链,如果一个对象到GC Roots没有任何一条引用链相连的话,就证明这个对象是不可用的。

哪些对象可以作为GC Roots根

1、虚拟机栈中引用的对象
2、方法区中类静态属性和常量引用的对象
3、本地方法栈中JNI(常说的Native方法)引用的对象

对象的自我救赎

即使在可达性分析中不可达的对象,也不一定是”非死不可“的,处于”缓刑“阶段。要判定一个对象真正死亡,需要两个阶段:
1、进行可达性分析后没有引用链和GC Roots相连,则进行第一次标记
2、判断这个对象是否有必要执行finalize方法。当对象没有覆盖finalize()方法,或者已经执行过这个方法。进行第二次标记。

怎么自我拯救呢?
如果有必要执行finalize()方法,虚拟机将这个对象放入一个F-Queue队列中,在稍后由虚拟机创建的一个低优先级线程Finalizer执行它。(执行指的是会触发这个方法但不保证等待它结束,必须这个finalize方法执行影响内存回收系统)。如果一个对象执行finalize方法时重新将自身引用链相连(比如把this赋值给某个变量),那么这个对象在第二次标记时被移出“即将回收”的集合,否则被回收。
注:有关于finalize()方法,大家感兴趣可以看一看,实际很少用到。

回收方法区

方法去的垃圾回收主要是两部分:废弃常量和无用的类。回收废弃常量与回收堆中的对象很相似。如果没有任何一个地方引用一个常量,这个常量将会被清理出常量池。回收一个类需要满足三个条件:
1、java堆中的所有该类的实例已经被回收
2、加载该类的ClassLoader已被回收
3、该类的Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。
在大量使用反射、动态代理、CGLib等Byte框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,保证永久代(现在的元空间)不会溢出。
参数-Xnoclassgc控制是否对类回收。

垃圾收集算法

1、标记-清除

最基础的收集算法,因为其他算法都是基于这种思路并对它的不足进行改进得到的。
步骤
1、标记所有要回收的对象
2、在标记完成后统一回收被标记的对象
缺点:
1、标记和清除的效率都不太高
2、空间问题,标记清除后会产生大量不连续的内存碎片,这样在以后分配一个大对象时,会因为没有足够的连续内存而不得不提前触发一次gc
深入理解JVM(四)垃圾回收目标和垃圾回收算法

2、复制算法

思想:将内存分为大小相等的两块,每次使用其中的一块。当这一块内存使用完了,就将还存活的对象移动到另一块上去,然后一次清理掉用过的那一块内存。
优点:因为每次都是对整个半区回收,所以不用考虑内存碎片的问题,只要移动指针,按顺序分配即可,实现简单,运行高效。
缺点:将内存缩小了一半,代价太高。
内存回收前:
深入理解JVM(四)垃圾回收目标和垃圾回收算法
内存回收后:
深入理解JVM(四)垃圾回收目标和垃圾回收算法
现在的商用虚拟机都采用这种算法来回收新生代,但并不是上述所说进行1:1内存划分内存空间。因为98%的对象都是朝生夕死的,所以一般做法是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块survivor。当回收时,将Eden和Survivor区还存活的对象一次移动到另一块Survivor区,然后清除刚才用过的空间。HotSpot默认Eden:Survivor为8:1,也就说每次新生代可用内存为新生代的90%。当survivor区空间不足时,需要依赖其他内存(老年代)进行担保。关于内存担保机制会在后面的内存分配策略部分讲到,继续往下看。

3、标记-整理算法

这种算法多用在老年代的收集中。
1、标记所有存活的对象
2、将所有存活对象向一端移动,然后清理掉端边界以外的内存
深入理解JVM(四)垃圾回收目标和垃圾回收算法

4、分代收集算法

这种算法并没有什么新思想,就是”分代收集“。就是根据对象存活周期的不同将内存划分为几块。一般把堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。新生代因为生存率低,大部分对象都会死去,所以采用复制算法。而老年代中对象存活率较高,而且没有担保,所以采用标记-清除或标记整理。

HotSpot的算法实现

1、枚举根节点

先提出枚举根节点的出现的俩个问题
问题1:寻找可以作为GC Roots节点主要在全局性引用(常量,静态变量)与执行上下文(就是指虚拟机栈中的引用),然而现在方法区的大小就可能高达百兆!如果逐个检查,耗费时间太久!
问题2:这个分析工作必须要在一个能确保一致性的快照中进行-------一致性指的是整个分析期间整个执行系统看起来像是冻结在某个时间点上,不能出现在分析期间对象的引用还在不断变化,否则不能确保分析结果的准确性。这点时导致GC期间必须停顿(又称stop the world)所有java执行线程的重要原因。

目前所有主流java虚拟机都使用的是准确式G(准确式指虚拟机知道每个位置的数据是什么类型),所以执行停顿时不需要进行全局的扫描。在hotSpot中使用一组OopMap的数据结构来达到这个目的,所以GC时候就可以直接得到这些信息了。

安全点

在OopMap的帮助下,hotspot可以很快的完成GC Roots的枚举,但一个很现实的问题随之而来:可能导致引用关系发生变化,或者说OopMap内容变化的指令非常多,如果每一条指令都生成一个OopMap,会耗费大量的空间,GC的成本就会很高。

实际上,hotspot只是在”特定的位置“记录了这些信息,即安全点(safepoint),程序只有在到达安全点的时候才能暂定。Safepoint的选择既不能太少,让每一次GC等待时间过长,也不能太多以至于过分增大运行时的负荷。所以,安全点的选取基本上是以“是否具有让程序长时间执行的特征”为标准选定。“长时间执行”的最明显特征就是指令复用,例如方法调用、循环跳转、异常跳转等,所以具备这些功能的指令才会产生safepoint。

如何选择安全点:一般选择如下几个位置

  • 循环的末尾
  • 方法临返回之前
  • 调用方法之后
  • 抛异常的位置

为什么选择这些地方作为安全点
主要的目的就是避免程序长时间无法进入 Safe Point。比如 JVM 在做 GC 之前要等所有的应用线程进入安全点,如果有一个线程一直没有进入安全点,就会导致 GC 时 JVM 停顿时间延长。比如这里,超大的循环导致执行 GC 等待时间过长

对于safepoint,有一个问题,如何在GC发生时让所有线程都“跑"到最近的安全点再停顿

  • 抢先式中断:GC发生时,首先中断全部线程,如果发现某个线程中断的地方不再安全点,那么恢复这个线程,让其运行到最近的安全点。(几乎不使用这种方式)
  • 主动式中断:当GC需要中断线程时,不直接对线程操作,而是简单设置一个标记,各个线程执行时主动进行轮询,发现中断标识为真时就将自己中断挂起。轮询标志的地方和安全点时重合的,另外再加上创建对象需要分配内存的地方。
    hotspot采用主动式中断。

安全区域

如果一个线程不运行,比如线程处于sleep状态或者Blocked状态,这时候就无法响应jvm的中断请求,运行到安全的地方去中断挂起了。JVM也不可能等待cpu分配给这个线程时间,这个时候就需要用到Safe Region

安全区域是指一段时间内,引用关系不会发生变化。在这个区域内任意时间进行GC都是安全的,

线程在进入Safe Region时要标识自己进入了Safe Region;这样GC时候就不需要官标识为Safe Region状态的线程了。如果一个线程要离开Safe Region了,先检查系统是否完成了根节点枚举(或者GC过程),直到收到一个安全离开的信号为止才可以离开。

本来垃圾回收的几个收集器也打算一起说,但是这样篇幅就太长了,所以将垃圾回收器留到下一篇博客。

参考文章:
JVM中的安全点
JAVA垃圾收集机制