JVM之垃圾回收算法
1.概述
在JVM中,最大的亮点就是自动垃圾回收机制,那它是根据什么依据来判断是垃圾的呢,又是根据什么算法来回收垃圾的呢? 不同的垃圾回收算法有不同的特点和应用场景,本文整理了JVM常见的几种垃圾回收算法,以及其优缺点和适用场景供读者参考。
不熟悉JVM内存模型的可先参考如下这篇文章(点击直接跳转)。
JVM之内存模型
2.什么是垃圾
现实中,垃圾是指废弃物、废弃物品、废弃材料等无用物品或废弃物质。这些物品或材料通常被认为没有价值或已经失去了原有的价值,因此需要被处理、清除或回收。
同理,在JVM中,垃圾是指不再被程序使用或引用的对象,对象在JVM中已经没有任何关联了,它已经没有使用价值了,这时候JVM就需要把这些对象判定为垃圾,选择合适时机清除以便回收占用的内存空间。
3.为什么会有垃圾回收
JVM有垃圾回收机制的原因是为了解决内存管理的问题。在传统的编程语言中,开发人员需要手动分配和释放内存,这可能导致内存泄漏、内存溢出等问题。而Java作为一种高级语言,旨在提供更简单、更安全的编程环境,因此引入了垃圾回收机制来自动管理内存。
垃圾回收机制的主要目标是自动检测和回收不再使用的对象,从而释放它们所占用的内存空间。这样可以避免内存泄漏(一些对象被分配了内存却无法被释放,导致内存资源的浪费)。同时,垃圾回收机制还可以防止内存溢出(即程序需要的内存超过了可用内存的情况)。
通过垃圾回收机制,JVM可以在程序运行时自动识别和清理不再使用的对象,使得开发人员无需手动管理内存。这样可以提高开发效率、减少错误,并且使程序更加可靠和稳定。
4.如何判断为垃圾
通过概念我们已经了解jvm的垃圾对象以及垃圾对象回收的原因,那JVM是怎么认定对象是垃圾对象需要回收呢,通常有引用计数法和可达性分析法这两个方式来判断,下面详细介绍。
4.1引用计数法
JVM运行时,引用计数法是通过维护一个引用计数器来跟踪每个对象被引用的次数,当对象被引用时,计数器加1,当引用被释放时,计数器减1。当对象的计数器为0时,表示该对象没有被任何引用指向,就可以判定为垃圾对象,在JVM进行垃圾回收时被回收掉,如下图所示。
引用计数法的优点是实时性较高,垃圾对象可以很快被识别和回收。然而,该算法也存在一些问题:
- 循环引用问题:当存在循环引用的情况时,即使对象不再被程序使用,但由于循环引用导致计数器不会为0,对象仍然无法被回收,造成内存泄漏。
- 计数器操作开销:每次引用的增加和减少都需要对计数器进行操作,这会增加额外的开销,降低程序的性能。
如上图所示,两个对象相互引用,或者多个对象循环引用,尽管他们已经不再被使用了,但是引用计数器不为0,所以无法判断为垃圾,不能被回收。
所以目前JVM垃圾回收没有采用引用计数法,原因就是对象互相引用的情况下,无法判定是否为垃圾对象。
4.2可达性分析法
可达性分析法是一种常用的判定对象是否为垃圾的算法,该算法的基本思想是通过判断对象是否可达来决定其是否为垃圾。
在可达性分析法中,JVM从一组根(称为GC Roots)的对象开始,这些根对象包括活动线程的栈帧、静态变量、方法区中的常量等。然后JVM通过遍历根对象的引用链,找出所有被根对象直接或间接引用的对象,这些被引用的对象被认为是可达的。而那些没有被根对象直接引用或间接引用的对象则被认为是不可达的,即垃圾对象。
一旦确定了哪些对象是可达的,垃圾回收器就可以回收这些不可达对象所占用的内存空间,回收后,这些内存空间可以被重新分配给新的对象使用。
可达性分析法的优点是能够准确地判断对象是否为垃圾,并解决了引用计数法中的循环引用问题。
然而,该算法也存在一些缺点,比如遍历引用链的过程可能会比较耗时,对于大规模的对象图可能会影响程序的性能。
GC Roots的类型大致如下:
- 虚拟机栈中的变量所引用的对象。
- 方法区中静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法中栈(Native方法)引用的对象。
- 虚拟机内部的引用对象(类记载器、基本数据对应的Class对象,异常对象)。
- 所有被同步锁(Synchronnized)持有的对象。
- 描述虚拟机内部情况的对象(如 JMXBean、JVMTI中注册的回调、本地缓存代码)。
- 垃圾搜集器所引用的对象。
- 其他
5.垃圾回收算法有哪些
5.1标记清除算法
标记清除算法是一种常用的垃圾回收算法,用于回收不再使用的对象。该算法分为两个阶段:标记阶段和清除阶段。
-
标记阶段
- 从根对象开始,通过可达性分析算法,标记所有被根对象直接或间接引用的对象为可达对象。
- 遍历整个对象图,将可达对象进行标记。
-
清除阶段
- 遍历整个堆内存,将未标记的对象判定为不可达对象,即垃圾对象。
- 释放垃圾对象所占用的内存空间,使其可供后续的对象使用。
5.1.1优点
标记清除法的特点就是简单直接,速度也非常快,适合存活对象多,需要回收的对象少的场景。
5.1.2缺点
- 内存碎片问题:由于清除阶段只是简单地释放垃圾对象所占用的内存空间,留下了被释放对象之间的不连续的内存碎片。这可能导致后续对象分配内存时无法找到足够的连续空间,从而触发一次新的内存分配和垃圾回收。
- 垃圾回收效率问题:标记清除算法需要遍历整个堆内存,包括所有的存活对象和垃圾对象,这可能会消耗较多的时间和资源。
为了解决标记清除算法的缺点,现代的JVM通常会采用其他的垃圾回收算法,如复制算法、标记-整理算法、分代收集算法等,以提高垃圾回收的效率和内存利用率。
5.1.3适用场景
标记清除算法比较适合以下场景的对象。
- 长时间存活的对象:标记清除算法适用于存在长时间存活的对象的场景。由于标记清除算法不会将存活对象移动到不同的内存区域,避免了复制算法中的复制开销。
- 大对象:对于大对象,复制算法可能会导致较高的复制成本。而标记清除算法只需要标记和清除不再使用的对象,不需要进行对象的复制操作,因此适用于大对象的场景。
- 不规则的内存分配模式:标记清除算法可以处理不规则的内存分配模式,因为它不需要保持内存的连续性。相比之下,复制算法需要保证内存的连续性,对于不规则的内存分配可能会浪费较多的空间。
综上所述,所以标记清除算法适合作为老年代的垃圾回收算法,因为老年代里对象一般都是长时间存活的或者大对象,可回收的对象少,直接标记清除效率高。因涉及到内存碎片问题,所以就有后续的标记整理算法。
5.2复制算法
复制算法是将堆内存分为两个相等大小的区域,每次只使用其中一块区域,另一块区域只有在垃圾回收时才用,当进行垃圾回收时,将标记的存活对象从已使用区域复制到另一个未使用区域,然后清空已使用区域中的所有对象,完成垃圾回收,这样两个区域轮流循环使用。
复制算法的基本流程如下:
- 将堆内存分为两个相等大小的区域,可以称作From区域和To区域。
- 垃圾回收时,从根对象开始,通过可达性分析算法,标记所有被根对象直接或间接引用的对象为可达对象。
- 遍历所有可达对象,将其复制到To区域,并更新引用关系。
- 清空From区域中的所有对象。
- 将From区域和To区域的角色互换,使得To区域成为新的From区域,From区域成为新的To区域。
5.2.1优点
- 内存碎片问题:复制算法有效地解决了内存碎片问题。由于每次只使用一半的堆内存,复制到另一块堆内存区域时也是占用连续的内存空间,不会出现内存碎片的情况,从而避免了内存分配的复杂性。
- 分配效率高:复制算法适用于存活对象较少的场景。由于只复制存活对象到To区域,可以快速完成对象的分配,避免了在复制过程中查找可用内存的开销。
5.2.2缺点
- 内存利用率问题:由于每次只使用一半的堆内存,使用复制算法时会浪费另一半的内存空间。
- 存活对象多会非常耗时:因为复制移动对象的过程是比较耗时的,这个不仅需要移动对象本身,还需要修改使用了这些对象的引用地址,所以当存活对象多的场景会非常耗时,复制法比较适合存活对象较少的场景,当存活对象较多时,复制算法的效率会降低。
- 适用范围有限:复制算法适用于存活对象较少、分配频繁的场景。对于存活对象较多的场景,复制算法的效率会降低。
5.2.3适用场景
- 存活对象较少:复制算法适用于存活对象较少的场景。由于每次只使用一半的堆内存,复制算法可以快速复制存活对象到To区域,并清空From区域,从而有效地回收垃圾对象。
- 内存碎片问题较严重:复制算法可以有效解决内存碎片问题。由于每次只使用一半的堆内存,不会出现内存碎片的情况,从而避免了内存分配的复杂性。
- 对象分配频繁:复制算法适用于对象分配频繁的场景。由于复制算法只需简单地将存活对象从From区域复制到To区域,不需要考虑内存碎片和对象的连续性,因此可以快速完成对象的分配和回收。
只有少量对象存活的场景,这也正是新生代对象的特点,所以一般新生代的垃圾回收器基本都会选择标记复制法。
5.3标记整理算法
标记算法通过标记阶段标记出所有存活的对象,然后将它们移动到内存的一端,然后将剩余的空间整理成连续的块,以便后续的对象分配。
标记整理法分为标记和整理两个阶段,标记阶段会先把存活的对象和可回收的对象标记出来;标记完再对内存对象进行整理,这个阶段会把存活的对象往内存的一端移动,移动完对象后再清除存活对象边界之外的对象。
标记整理算法的基本流程如下:
- 标记阶段:从根对象开始,通过可达性分析算法,标记所有被根对象直接或间接引用的对象为可达对象,将它们标记为存活对象。
- 整理阶段:遍历堆内存,将存活对象移动到一端,然后将剩余的空间整理成连续的块。
- 更新引用关系:更新所有指向被移动对象的引用,使其指向对象移动后的新地址。
- 清理未标记对象:清理未标记的对象,即未被引用的对象,将其空间回收。
5.3.1优点
标记整理法是解决了复制法浪费空间、不适合存活对象多场景的短板,又解决了标记清除法空间碎片的短板, 所以对于复制法不适合的场景,同时又不能忍受标记清除法的空间碎片问题,就可以考虑标记整理法。
- 内存利用率高:标记整理算法可以有效地解决内存碎片问题,通过整理阶段将存活对象移动到一端,使得剩余的空间连续,提高了内存利用率。
- 分配效率高:由于整理阶段将存活对象移动到一端,对象的分配可以简单地在连续的空间中进行,避免了在复制过程中查找可用内存的开销。
5.3.2缺点
标记整理算法的缺点:
- 整理开销较大:标记整理算法需要将存活对象移动到一端,这涉及到对象的复制和引用更新操作,可能会增加一定的开销。
- 暂停时间较长:标记整理算法在整理阶段需要移动对象,因此会引起一段时间的暂停(Stop The World,简称STW),可能会影响应用程序的吞吐量和响应时效性。
5.3.3适用场景
- 老年代的垃圾回收:标记整理算法通常用于老年代的垃圾回收,因为老年代中的对象生命周期较长,内存碎片问题更为严重。通过标记整理算法,可以将存活对象整理到一端,清理未标记的对象,并压缩内存空间,提高内存利用率。
- 内存碎片问题较严重:标记整理算法适用于内存碎片问题较严重的场景。由于标记整理算法可以整理内存空间,使得剩余空间连续,可以有效解决内存碎片问题,提高内存利用率。
- 对象存活率较高:标记整理算法适用于对象存活率较高的场景。
5.4分代收集(Generational Collection)算法
上述的标记清除、复制、标记整理三种垃圾回收算法,都具有各自的优点和缺点,相互之间不能完全取代,同样的这三种算法都无法对所有类型(长生命周期、短生命周期、大对象、小对象)的对象进行回收。因此,根据不同类型的垃圾回收对象,采用不同的垃圾收集算法,这样的算法应用被称为分代收集算法(Generational Collection)。
严格来说分代收集算法应该是一种垃圾收集的理论,它是一种设计思想,并没有新的算法设计,当前主流虚拟机的垃圾收集都采用分代收集算法,它只是根据对象存活周期的不同将堆内存划分成新生代、老年代、永久代(JDK8之前叫方法区,包括JDK8及之后的版本叫元空间),然后就可以根据各个年代的特点选择合适的垃圾收集算法。比如在新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
- 新生代(Young Generation)
新生代是存放新创建的对象的区域,通常使用复制算法(Copying Algorithm)进行垃圾回收。分为Eden区、Survivor区(一般有两个,称为From区和To区),当进行垃圾回收时,首先将Eden区和其中一个使用的Survivor区中的存活对象复制到另一个Survivor区,然后清空Eden区和使用过的Survivor区,完成垃圾回收,两个Survivor交替使用。
- 老年代(Old Generation)
老年代是存放存活时间较长的对象的区域,通常使用标记清除或标记-整理算法(Mark-Sweep-Compact Algorithm)进行垃圾回收。标记-整理算法首先通过可达性分析算法标记出存活的对象,然后将存活对象整理到一端,并清理未标记的对象,最后进行内存压缩,使得剩余空间连续,提高内存利用率。
- 永久代(Permanent Generation)
JDK8之前叫方法区,包括JDK8及之后的版本叫元空间(Metaspace),用于存放类的元数据信息的区域,替代了传统的永久代(Permanent Generation)。这个区域的垃圾回收主要是通过对类的加载和卸载来实现,不同于堆内存的垃圾回收。
通过将堆内存划分为不同的代,并使用不同的垃圾回收算法,分代收集算法能够根据对象的生命周期和存活性质,针对不同代选择最适合的垃圾回收策略。这样可以在保证垃圾回收效率的同时,减少垃圾回收的暂停时间,提高应用程序的响应性能。
6.总结
不同的垃圾回收算法有不同的特点和应用场景。
- 标记清除算法简单但会产生空间碎片,同时可回收对象如果太多也会影响其性能。
- 复制算法解决了空间碎片的问题,又适合使用在大部分对象都是可回收的场景,但由于总会留一块内存用来保存垃圾回收时存活的对象,堆内存空间利用率不高。
- 标记整理算法解决了空间碎片问题和充分利用堆内存但实现复杂效率慢。
所以熟悉了垃圾回收算法的特点,再根据实际业务情况选择合适的垃圾收集器(不同的垃圾收集器采用不同的垃圾回收算法),结合各种调优手段来充分压榨JVM性能,为了提高垃圾回收的效率,现代的JVM通常也会采用一些优化手段,如分代回收、增量式回收、并发回收等,这些技术可以在保证程序性能的同时,有效地进行垃圾回收,最大限度的提高JVM的运行效率才是王道。