JVM学习笔记(八 - 垃圾回收算法)

垃圾回收算法

垃圾

  • 什么是垃圾?

    就是一些不在被引用到的对象,也就是接下来都不需要被用到了,那就是垃圾了。垃圾主要出现在堆和方法区,而其中主要的是堆。经常回收新生代,很少回收老年代,几乎不回收永久代或元空间。

引用计数算法

原理

每个对象都持有一个属于自己的引用计数器,用于记录该对象的引用情况。比如说一个对象a被另一个对象b所引用到,那么相应的,对象a的引用计数器的值就会+1,相反,当对象a不再被对象b所引用,也就是断开了联系,那么对象a的引用计数器的值就会-1,当引用计数器的值为0时,那么该对象就可以视为垃圾,随时都可以进行回收。

特点

实现原理十分简单,而且回收没有延迟,只要引用计数器的值为0,就可以随时回收,效率比较高。缺点是需要为引用计数器多开辟一份空间,而且对引用计数器的值的运算也会损耗相应的时间性能,但是这些缺点对于它的优点来说,都无伤大雅。不过,这个算法有一个比较大的缺点,也是导致java不采用这种算法的原因,那就是它没有办法解决循环引用的问题,也就是一旦出现循环引用的场景,那么就可能无法回收相应的垃圾,容易出现内存泄露的问题。python采用了这个算法,看重的是它实现简单,效率高,对于循环引用的问题,python支持程序员手动断开相应的循环链来达到回收的目的。

循环引用

如下图,图1中,a被指针指向,同时也被b引用,所以引用计数器的值为2,而b只被a引用,所以引用计数器的值为1,此时的a和b都不是垃圾,不应该被回收,但到了图2所示,指针指向c,而a不再被指针指向,引用计数器的值也减为1,b则不变。按理来说,a和b都应该被作为垃圾回收了,因为它们整体不再被需要引用,但是由于它们的引用计数器的值都不为0,所以无法进行回收。久而久之,这样的循环引用垃圾就会越来越多,但又无法被回收,因此是个比较严重的问题。python的做法是允许程序员手动的吧a和b之间的连接断开,那么引用计数器的值就为0,就能被回收。

JVM学习笔记(八 - 垃圾回收算法)

可达性分析算法

原理

可达性分析算法又称为根搜索算法追踪性垃圾收集算法。原理是判断一个对象是否能从GC Roots根集合向下遍历并最终到达,也就是判断一个对象是否与GC Roots有直接或间接的联系,如果有,那么就不应该被视为垃圾,如果没有,那么就可以被视为垃圾。遍历的路径称为引用链。它的实现比引用计数算法要复杂一些,而且需要遍历判断,因此性能上也相对差一些,但是它很好地解决了循环引用可能出现的问题,java采用的就是可达性分析算法。

  • 图示

    JVM学习笔记(八 - 垃圾回收算法)

    如上图所示,对象a , b , c , d都是可以从GC Roots出发最终到达的,也就是有直接或间接的关系,而对象e,f,g则没有联系,所以可以被视为垃圾。

    举个形象的例子,从水果篮中拿出一串葡萄,通过拎葡萄串根茎的方式去拿,那么我们拿到的葡萄都是直接或间接地连着这条根茎的,就好比被引用到的对象,而散落的葡萄就好比不再被引用的对象。再一个更生动的例子,古时候的罪犯,如果是直接或间接地与贵族扯上关系的,那么大概率可以免去处罚,这些罪犯就好比被引用的对象, 而对于一个平民,与贵族没有任何关系的,往往就要受到处罚,这些罪犯就好比不被引用到的对象。

特点

实现原理相对于引用计数算法来说要复杂一些,而且效率也差一些,回收有延迟,但回收的准确率较高,很好地应对了由循环引用所引起的内存泄露问题。

GC Roots

GC Roots是一些比较主要的不应该被视为垃圾的对象,这些对象就组成了根集合,GC Roots一般可以是以下的这些对象

  1. 虚拟机栈所引用到的对象

    也就是栈帧中的局部变量表的一些引用变量所指向的对象。

  2. 本地方法栈中本地方法所引用到的对象

  3. 类静态变量所指向的对象

    比如一些引用类型的静态变量的对象

  4. 方法区中所引用到的对象

    比如所运行时常量池中所引用到的对象

  5. 被同步锁synchronized所持有的对象

  6. java虚拟机内部的引用

    比如说基本类型的Class对象,一些常驻的异常对象(如:NullPointerException,OutOfMemoryError等等),系统类加载器

  • 总结的说,可以成为Root的一般都是在非堆空间的区域,而且指向了堆空间的对象。

  • 在一些特殊的情况,比如说进行部分回收时,堆空间中的对象也会临时性地加入成为Root。比如说只针对新生代的垃圾回收,那么老年代中的对象就会临时地加入到根集合中,也就是在新生代中被老年代对象引用到的对象也不应该被视为垃圾。

对象的finalization机制

概述

  • 回收一个对象前,都会进行至少两次标记第一次标记是查看是否可达第二次标记则是查看有没有必要调用对象的finalize()方法,如果有必要调用,就会把该对象插入到F-Queue队列中,由JVM自动创建的一个低优先级的线程Finalizer来调用该方法,否则就直接回收对象。

finalize方法

  • 什么是finalize()方法?

    finalize()方法定义在Object类中,是一个protected修饰的空方法,也就意味着所有的对象都能够去重写finalize()方法。但实际开发时很少会需要去重写该方法,可能有时候需要在回收对象前关闭一些资源,比如流,套接字等会去重写finalize方法。如果对象没有选择重写finalize方法,那么在垃圾回收时就会认为没有必要去调用,直接就进行回收,因为该方法本来就是一个空方法。而且finalize方法只会调用一次,也就是说如果一个对象在第一次调用finalize方法时由于某些情况不予以回收,那么第二次回收时会忽略finalize方法,直接就进行回收。

注意

永远不要主动去调用finalize()方法,JVM会在适当的时机自动执行

  • finalize方法的执行是由一个优先级很低的Finalizer线程完成的,因此它无法保证马上就能执行finalize()方法,如果此时主动调用的话,还可能会复活对象浪费空间。

  • finalize方法会在一定程度上影响垃圾回收的性能,因为如果重写了该方法,那么回收前就会先调用该方法,如果finalize方法编写不当,比如说大量的循环等,会严重拉胯垃圾回收的性能。因此不建议主动去调用finalize方法。

  • 当在finalize()方法中使得该对象重新与GC Roots建立联系时,那么该对象就会被复活,不会被回收。

对象的三种状态

在JVM中,针对于垃圾回收,对象会有以下三种可能的状态

  • 可触及的

    也就是与GC Roots有直接或间接联系的,不会被视为垃圾,因此不会被回收。

  • 可复活的

    与GC Roots没有任何联系,但是重写了finalize()方法,而且还没有被调用过。

  • 不可触及的

    与GC Roots没有任何联系,也没有重写finalize()方法,或者重写了该方法,调用后没有复活,又或者已经被调用复活过一次了。

标记-清除算法

基本思想

从根节点开始遍历所有的可达对象,给遍历到的可达对象的对象头中予以标记,注意,标记的是可达对象。标记完所有的可达对象后对整个需要垃圾回收的区域进行线性遍历,把没有标记的对象都予以“清除”,清除后的空闲空间时零散的,而且不是实际意义上的置空,而是在空闲列表上记录相应的地址,下一次需要分配空间时,就从空闲列表中找出合适的空间进行覆盖。回收过程也需要停止用户线程,来保证该算法是在一个前后一致的快照中执行。

图示

JVM学习笔记(八 - 垃圾回收算法)

优点

  • 实现原理简单,比较容易想到。
  • 留下的对象都没有进行移动,保持同样的地址,不需要额外去修改相关的引用关系。

缺点

  • 回收效率不算高,除了遍历可达对象外还要额外进行一次回收区域的遍历。
  • 容易出现大量的内存碎片,回收后留下的空闲空间是零散的,需要额外维护一个空闲列表,而且出现比较大的对象时容易报OOM异常。

标记-压缩算法

基本思想

在标记-清除算法的基础上加上压缩空间的步骤,考虑到标记-清除算法会产生内存碎片的问题,在清除的同时把留存的对象移动到内存的一段,使得留存的对象一个挨一个地拼接摆放,留下连续的空闲空间,不再需要额外维护空闲列表。回收过程也需要停止用户线程,来保证该算法是在一个前后一致的快照中执行。

图示

JVM学习笔记(八 - 垃圾回收算法)

优点

  • 解决了内存碎片的问题,留下的是一片连续的空闲空间,无需维护空闲列表,分配内存时可采用指针碰撞。

缺点

  • 多了压缩的步骤,回收的效率比标记-清除算法还要低。
  • 回收后对象可能会发生移动,也就意味着还需要维护相关的引用信息。

复制算法

基本思想

多开辟一块与回收区域空间大小一致的空闲区域,在遍历可达对象的同时依次把可达对象完整地复制一份到空闲区域,而且是通过指针碰撞的方式去存放复制对象,其余的都进行回收,最后留下完全空闲的区域,用于下次回收复制,两区域交替复制,以此类推。回收过程也需要停止用户线程,来保证该算法是在一个前后一致的快照中执行。

图示

JVM学习笔记(八 - 垃圾回收算法)

优点

  • 回收效率高,免去额外的遍历和压缩流程,性能优良。
  • 解决了内存碎片的问题,保证了空闲空间的连续性。

缺点

  • 需要额外空出一倍的空间,增加了内存空间的开销,减少了空间的利用率。
  • 存活对象的地址发生了变动,需要维护相关的引用关系。
  • 当存活对象比较多时效率反而不理想,不理想的情况是所有的对象都存活,就意味着需要把所有的对象都复制一遍,而且此次的复制操作是毫无意义的。

分代收集算法

基本思想

没有最好的算法,只有更优的算法。到目前为止也没有哪种算法是绝对最优的,其他问题得具体分析,针对不同的区域特性使用不同的算法是比较明智的方案。比如说针对新生代,回收比较频繁,而且一般存活对象占的比例不大,那么就非常适合采用复制算法来进行垃圾回收,所以幸存者0区和幸存者1区总是会空出一个区去存放存活的对象,而且由于回收比较频繁,也匹配对算法时间性能的需求。而对于老年代,回收没那么频繁,而且一般回收的对象不多,那么复制算法的优点就难以凸显了,而且还需要空出一块区域,会极其浪费空间,所以不适合使用复制算法,反而标记-压缩算法比较适合。目前,所有的垃圾回收器都采用了分代收集算法,针对不同的区域采用不同的算法进行回收。

增量收集算法

基本思想

目前还没有哪种算法能保证没有延迟,也就是STW是必然会触发的,只是时间长短各不一样罢了。STW的时间比较长的话,会严重影响用户的体验,因此如今垃圾回收的算法正在不断地缩短STW的时长。增量收集的思想是不一次性地把整个区域的垃圾都回收,而是每次只回收一部分就切换回用户线程,这样交替并发地执行,一点点地回收,可以在一定程度上细分STW的时长,使得延迟不那么明显,确保用户体验不至太差。当然,这样来回切换也会损耗相应的性能,使得总体的垃圾回收成本上升,造成系统的吞吐量下降。

分区收集算法

基本思想

大致的想法也是分担STW的时长。主要的思想是把回收的区域划分成若干块区域,然后根据给定的时间段去回收,时间段短的就少回收几块区域,时间段长的就多回收几块区域,就这样与用户线程来回切换并发进行,直至全部区域都被回收,同样也会损耗相应的性能。