JVM(九):垃圾回收算法

在本文中,我们将从概念模型的角度探讨 JVM 是如何回收对象,包括 JVM
是如何判断一个对象已经死亡,什么时候在哪里进行了垃圾回收,垃圾回收有几种核心算法,每个算法优劣是什么等。

为何需要GC

Java 中的一个核心技术就是自动垃圾回收,该技术使得程序员可以不用像写 C++
一样手动分配和释放内存,那么为何还需要我们去学习垃圾回收呢。这里就要说到两个概念了。

* 内存泄露:有已经不再使用的对象仍然占用着内存;
* 内存溢出:已经没有足够的空间可以让 JVM 分配内存给对象了。
大量的内存泄露会引发内存溢出,但内存溢出不一定是内存泄露引起的,也可能是因为总共的内存空间就不够大,而需要分配的对象太大导致。

学习垃圾回收的背后逻辑,可以让我们在程序发生内存溢出的时候,快速高效地排查出问题进行解决。并且学习了 GC 的细节,也有助于我们调节 JVM
的一些运行参数,让系统达到更高的并发量。

对象的死亡

如果要销毁一个对象,那么就需要确定该对象已经死亡,只有这样才能够将该对象所占的内存空间进行释放。那么 JVM 是如何判断一个对象已经死亡了呢。

引用计数法

引用计数法实现十分简单,就是给每一个对象增加一个计数器,每当有一个地方对其进行了引用就 +1,当引用失效时就 -1,如果计数器的值为
0,则代表该对象已经不再被使用了,可以对其回收了。

这种方式的最大优点就是实现简单,判定效率高。但其有一个致命的缺点就是 循环引用问题
。当两个对象互相引用,但其实他们已经没有任何其他用处了。此时因为彼此间还存在引用,就会发生循环引用,使用引用计数法就无法对其进行回收。

可达性分析算法

正是因为引用计数法那个致命的缺点,因此主流的实现都是通过 可达性分析 来判断对象能否进行销毁。其核心思想是 通过一系列称为 "GC Roots"
的节点来作为起始点,从这些节点开始搜索,这个搜索的轨迹被称为 "引用链",如果一个对象没有包含在任何一个引用链中,那么就判断该对象是无效的。



概念中说到是通过 GC Roots 来作为起始点,那么哪些对象可以作为GC Roots呢。

* 虚拟机栈中引用的对象;
* 本地方法栈中引用的对象;
* 方法区中静态属性引用的对象;
* 方法区中常量引用的对象。
引用的区分

在判断对象能否被销毁的时候,都使用到了 引用 这个词语,说的是如果有被引用的,那么就不销毁,如果没有引用则将其进行销毁,这种分别方式非黑即白,太过强硬,因此
JDK1.2 之后对引用的含义进行了扩充,实现了多级回收的效果。即在内存不紧张的时候,有一些对象是可以进行保留的,但如果内存紧张的时候,就需要对其进行回收。

* 强引用:我们平常在编程使用的引用都是这种,普遍存在的引用,只要是这个,就不会被回收;
* 软引用:有用但非必需。在内存很紧张快要溢出的时候,就会回收这些对象,如果回收后还没有空余空间才会报内存溢出。这种引用通常用来实现内存敏感的缓存;
* 弱引用:比软引用更弱一些,只能活到下一次垃圾回收前。其实主要回收的就是这些内存;
* 虚引用:虚引用不对对象生存时间造成影响,也无法通过虚引用获得对象实例。其存在的价值就是在对象收到回收的时候,能够让系统做一些事。应用场景为跟踪对象被
GC 的活动,因为其被回收的时候系统会受到一条系统通知。
方法区的回收

前面说的都是对象的回收,即对堆内存的回收,但其实在方法区内也是有垃圾回收的。在方法区内回收的内容主要是 废弃常量 和 无用的类。

其中废弃常量很好理解。就是常量池中的一个常量已经没有任何对象引用它了,即其已经没有价值了,那么就会将其移出常量池,回收其空间。

而对无用的类进行回收又是怎么理解的呢。首先我们需要判断什么是无用的类。一个类是无用的,需要满足以下3点:

* 该类的实例都已经被回收了,即堆中没有该类的实例对象
* 加载该类的 ClassLoader 已经被回收
* 无法通过反射访问该类,即该类对应的 java.lang.Class 对象没有被调用
只有满足以上3点的类才可以被回收,但其是否回收取决于 JVM 启动时的参数控制。JVM 可以在启动时设置不对类进行回收。

回收算法

上面我们已经明白了什么对象是可以回收的,那么我们该如何针对这些对象进行回收呢。回收前后内存空间又是如何布局的呢。下面就让我们来看一下几个主流的 GC 算法。

标记-清除算法

标记-清除算法是最简单,最基本的算法。其本质就如同其名字一样,分为2个步骤,首先标记出所有需要清除的对象,然后在回收阶段,统一清除即可。




但其拥有两个严重的缺点。一个是标记和清除阶段都不快,效率很低;另一个是其只是单纯的将无用的对象清除,很容易造成大量的内存碎片,如果内存碎片太多,那么在分配大对象的时候,就很容易造成内存不够的情况。因此针对这些情况,就出现了几个改进的优良版本。

标记-整理算法


标记-整理算法解决的是内存碎片的问题,在标记阶段还是采取一样的解决方式,但在下个阶段并不是直接清除掉无用对象,而是先将有用的对象移到内存的一边,然后直接回收掉分界线一边的对象,这样就可以腾出许多规整的空间。



复制算法

标记-整理算法只是解决了内存碎片的问题,但是效率问题还是一个痛点,因此就有人提出了复制算法。其将内存空间分为 2
部分,每一次只使用其中一块,当这一块的空间用完了,就将存活的对象复制到另一边去,然后将使用过的空间直接清理掉即可。这种算法十分的高效,也解决了内存碎片的问题。



但其将可用空间简单的划分为了50-50,代价十分的高昂。不过经过研究表明,新生代对象大部分都是朝生夕死的,因此不需要按照 1:1
的比例来划分空间。商用的虚拟机 HotSpot 就是默认将内存划分为 8:1:1,即一块Eden区,两个Survivor区,在进行分配时,将 Eden 和一个
Survivor 直接复制到另一个 Survivor 即可,这样解决了复制算法空闲空间太大的问题,又提高了 GC 的效率。

但是也正是因为这样的划分,Survivor
的内存空间是比较小的,因此需要有一个其他内存进行分配担保,确保大对象也能够进行内存分配,这就老年代存在的价值之一。当另一块 Survivor
没有足够空间放置对象时,将会直接将对象分配至老年代。而老年代采取的 GC 算法为标记-整理算法。

分代算法

经过上面 3
种算法的分析,想必大家也想到了,分代算法其实并不是一个新算法,其只是根据前面算法的优劣将内存空间进行了划分,对每个不同的空间采取不同的算法,以便根据各个不同的年代采取不同的,最适合的算法。



在 Java8 之前,方法区称为永久代,也如同堆空间一样被 GC 进行管理,但在 Java8 之后,这种实现方式被MetaSpace
取代,采用直接内存的方式来进行内存分配管理.

Hotspot
将内存划分为新生代和老年代。新生代因为大部分的对象都是快节奏的,因此采用复制算法来处理。而老年代因为对象存活率高,且已经没有额外空间对齐进行分配担保了,因此采用标记-清理或标记-整理算法进行处理。

总结


在本文中,我们介绍了什么是垃圾回收,如何判断对象应该进行回收了,以及回收逻辑的几个不同抽象模型。在后面的文章,我们将对算法的具体实现进行探讨,了解当前业内主流的虚拟机实现,看看在实际生产情况下,不同的
垃圾收集器 的具体实现方式.



文章在公众号 “iceWang" 第一手更新,有兴趣的朋友可以关注公众号,第一时间看到笔者分享的各项知识点,谢谢!笔芯!

本系列文章主要借鉴自《深入分析 JavaWeb 技术内幕》和《深入理解 Java 虚拟机-JVM高级特性与最佳实践》。

友情链接
KaDraw流程图
API参考文档
OK工具箱
云服务器优惠
阿里云优惠券
腾讯云优惠券
华为云优惠券
站点信息
问题反馈
邮箱:ixiaoyang8@qq.com
QQ群:637538335
关注微信