Fork me on GitHub
0%

JVM 的垃圾收集算法

在《Java 中对象和数组的创建》一文中我们主要介绍了在 Java 虚拟机中创建一个对象的完整过程,而我们的程序在运行时可能无时无刻不在创建对象,每创建一个对象就需要在堆中进行内存分配,可是堆内存大小毕竟是有限的,总会有用完的时候,那如果用完了该怎么办呢?

在我们 Java 编码的过程中好像确实没有关注过这个问题,需要对象的时候就创建,似乎内存真的是用不完的,然而事实并非如此,这主要得益于 JVM 强大的垃圾自动回收技术,因为我们创建的对象并非所有的对象都是伴随着程序执行开始到结束的,反而大部分对象在方法执行结束或者线程执行完毕时就不再需要了,那么这部分对象的内存就可以进行回收二次利用,这才使得内存看起来用不完一样。

对于 JVM 的运行时数据区域,我们已经知道程序计数器,虚拟机栈,本地方法栈都是随着线程的创建而创建,随着线程结束同样也就结束了,这几个区域的回收都是相对比较确定的。而对于堆区和方法区来说是在 JVM 启动时创建,所有线程共享的区域,这部分区域的内存在程序运行过程中时刻都在变化,因此 JVM 的垃圾自动回收主要关注这两个区域,当然对于方法区的垃圾回收相对于堆区来说效率并没有那么高,它主要针对废弃常量和无用类,在该区域可回收的内存有限,那么垃圾回收的主战场自然也就落在了堆区。

如何判定对象是否还存活

既然要对不再需要的对象进行回收,那么首先就需要判定哪些对象是不再需要了的,如何判定呢?目前主要有以下两种方式:

引用计数算法

引用计数算法通过对 Java 程序中的对象引用进行计数的方式来判定,每一个对象都有一个引用计数器,当有一个地方引用了该对象时,对象的引用计数器就加一,引用失效计数器减一,当对象的引用计数器是 0 时就认为该对象可以被回收了。这种方式看起来很简单,但是它有一个致命的问题:循环引用问题。比如说 A 对象中引用了 B 对象,B 对象也引用了 A 对象,他们之间互相引用,它们俩的引用计数器都不为 0,但是除了他们之间互相引用之外没有其他任何地方引用了这两个对象,事实上这两个对象的内存也是可以被回收的,但由于引用计数器不为 0 从而没有被回收,因此这种判定方式也没有作为 JVM 判定对象不再存活的方式,而是采用了下面的可达性分析算法。

可达性分析算法

可达性分析算法是通过一些 GC Roots 的对象来作为起点,然后往下搜索,搜索所经过的路径称为引用链,当一个对象没有任何一条引用链可达时,那就说明该对象不可达,也就间接说明该对象内存可以被回收了。这种方式就解决了上面所说的循环引用问题,比如说下图中 Object6 和 Object7 虽然相互引用,但是从 GC Roots 到这两个对象已经没有引用链可达了,因此这两个对象都可以被回收。

GC_Roots

这种判定方式的关键在于如何确定 GC Roots 对象,在 Java 中下面这几种对象可作为 GC Roots 对象:

  • 虚拟机栈栈帧中本地变量表所引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量所引用的对象
  • 本地方法栈中引用的对象

虽然说上面的定义看起来挺简单的,但真正实现过程中需要考虑的问题就很多了,由于在确定这些 GC Roots 对象的同时,这些区域的对象也是在实时变化的,就算确定好了这些对象之后,还要经历从这些对象开始进行搜索的过程,而这个过程对象也是在时刻变化的,这些都是在具体实现的过程中需要考虑的问题。

分代收集思想

在介绍垃圾收集算法前,我们先来了解一下堆中的分代收集的思想,也许你之前听说过新生代和老年代,新生代的对象在经历多次垃圾收集之后便会移动到老年代,那么这里面具体是怎么回事呢?首先分代收集主要是基于堆中对象的两大特性而产生的,这两大特性就是:

  1. 堆中的大部分对象都是朝生夕死的,也就是说大部分对象往往创建完使用一次就不再需要了

  2. 在经历多次垃圾收集之后还存活的对象往往是一直有用的,这种对象存活的时间更长

基于这两个特性,可以将堆区分成两块区域,每次创建对象只在其中一块区域分配内存,根据第一个特性垃圾收集可以在该区域取得很好的效果,大部分对象都可以进行回收,在经历几次回收还存活着的对象可以根据第二个特性将这类对象移至另一个区域,该区域的垃圾收集可以在内存实在紧张时再进行回收,毕竟这部分区域回收的效果有限。

上面就是分代收集的大致思路,在 JVM 中对上面的描述更具体的实现方式是将堆区分为新生代和老年代两个区,而新生代中又分成一块较大的 Eden 区和两块同等大小的 Survivor 区(一个称为 from,另一个称为 to),每次创建对象在优先 Eden 区和 from 区中分配内存,在垃圾收集发生时,将 Eden 区和 from 区中还存活的对象移动到 to 区,然后清空 Eden 区和 from 区,这时将刚才 Survivor 区中的 from 区称为 to 区,to 区称为 from 区,接下来创建对象则在此时的 Eden 区和 from 区分配内存,也就是说 Survivor 区中每次都有一块区域是空着的,对于 Eden 区,from 和 to,它们的比例是 8:1:1,相当于每次只有 10% 的区域是空着的,这不至于浪费太多空间。

但也只是假设每次垃圾收集之后新生代存活下来的对象只有新生代内存大小的 10%,一旦超过这个大小就需要考虑将新生代的对象移至老年代中。在经历了多次垃圾收集之后就很有可能只占 10% 空间的 Survivor to 区中没有足够的内存来存放 Eden 区和 from 区中还存活的对象,这时就需要将部分年龄较大的对象移至老年代了,通常一次垃圾收集存活下来的对象年龄会加一,年龄超过一定大小(默认 15 岁)之后就会进入老年代,还有一种情况是针对大对象的分配,当新生代中无法为一个大对象进行内存分配时,这个对象也会直接在老年代分配内存。

当然在分代收集思想中其实存在一个”跨代引用”的问题,那就是在新生代垃圾收集判定对象是否存活时,其中有的对象可能被老年代对象所引用,那么在新生代收集时是否需要对整个老年代进行扫描呢?本来分代收集就是为了提高垃圾收集的效率,如果依然需要对老年代进行扫描,那分代收集没有多大意义了,而这种情况又该如何解决呢?常见的做法是在新生代中开辟一小块区域来存储老年代中存在跨代引用的对象的区域,在新生代发生垃圾收集时就只会扫描老年代中存在跨代引用的部分区域,从而避免了扫描整个老年代。

在介绍完新生代和老年代之后,根据对堆区不同区域产生的垃圾收集行为可分为部分收集(Partial GC)和完整收集(Full GC),部分收集指只收集堆中的部分区域,其中针对新生代的收集称为 Minor GC 或 Young GC,针对老年代的收集称为 Major GC 或 Old GC。而完整收集一般是在内存比较紧张时才会触发的整个堆区和方法区的垃圾收集。

常见的几种垃圾收集算法

标记-清除算法

标记-清除算法正如名称所描述的那样,先对存活对象进行标记,然后再统一回收未标记的对象内存。当然这里也可以反过来,标记需要回收的对象,再将标记的对象进行回收,这取决于具体的垃圾回收器采用哪种方式实现。

对于标记-清除算法,它有两个比较明显的缺陷,一个是当遇到大量的对象需要被回收时,标记和清除的过程就会随着加长,效率比较低。还一个就是会产生大量的碎片空间,当遇到稍大对象可能就无法满足内存分配,这时将会触发一次垃圾回收动作,其实呢也许总的剩余内存是够的,但却没有足够的连续空间来分配。根据这种收集算法的特点,同时结合上面对堆中分代收集的描述,老年代比较适合采用标记-清除算法,老年代中一般都是存活比较长的对象,可回收的对象相对较少。

Mark-Sweep

标记-复制算法

由于采用标记-清除算法在大量对象需要进行回收时效率比较低,因此就有了标记-复制算法,将可用内存区域分成 A B 两块区域,每次使用 A 区域来分配内存,当 A 区域内存用完了之后,将 A 区域中还存活着的对象复制到 B 区域,然后将 A 区域中使用过的内存一次性全部回收。

这种方式一个是需要避免在标记之后有大量对象还存活的情况,不然就需要执行大量对象的复制工作了。还有一个就是需要预留一半的空间出来,这就相当于浪费了一半的空间,这部分空间没办法得到利用。在上面分代收集思想中对于新生代刚好符合每次只有少量对象存活的特性,因此上面在分代收集中所提到的新生代中 Survivor from 和 Survivor to 区就是采用了这种算法,同时为了避免内存浪费,让 Eden 区和两个 Survivor 区的比例为 8:1:1,这样在新生代中就只有 10% 的内存是空着的。

Mark-Copying

标记-整理算法

同样的在标记-清除算法的基础上,由于标记-清除算法会产生大量的碎片空间,因此就有了标记整理的算法,在标记之后先对存活的对象进行移动操作,也就是将这部分对象进行整理放到内存的一边,然后再将剩下的空间进行清理,这样就防止了产生大量的碎片化空间。这种方式也有一个比较严重的问题,那就是在标记之后如果存活的对象很多,那么要移动这部分对象的过程就会变得很长,同时由于对象需要进行移动,这时必须要暂停用户线程,这就让我们的程序看起来会暂停一下,也就是著名的 “Stop The World” 现象。

Mark-Compact

好了,上面大概介绍了 JVM 中是如何判定对象是否还存活,以及非常经典的分代收集思想,同时列举了几种常见的垃圾收集算法。到这里你可能会觉得这些知识点看起来好像对我们的编码没什么影响呀,但是呢这些东西就好比是我们的内功,那些武林高手到最后比拼的不都是谁的内功更深一筹么。

 wechat
扫描上面图中二维码关注微信公众号