简明易懂的JVM垃圾回收理解

写在前面

写这篇文章是为了用简明易懂的写法,尽可能的在较短的篇幅内写出对Java内存垃圾回收策略的理解。解析Java内存垃圾回收策略,算法的文章很多,有些讲的还很深入。但是对平时不常接触Java虚拟机和垃圾回收(Garbage Collection 简称GC)策略的人来说,有些过于晦涩了,概念很多,层次复杂,既不方便理解,也难以记忆。作者也有这方面难题,对Java内存垃圾回收策略有一定了解,但在面试等场合很难有条理的讲解清楚,因此用此文以简洁的写法予以呈现。目标就是读完此文后,对垃圾回收策略(GC策略)有个简明的,全局的,有序的理解,对面试时一些八股文的问题可以有条理的解答。

垃圾回收(GC)的概念

Java ,C# ,Go这类带有内存垃圾回收能力的语言,不需要程序员再手动管理对象生命周期。要有的时候直接新建对象即可,对象的销毁,内存的释放都无需关注,垃圾回收器自然会帮助识别垃圾对象和管理内存。这大大方便了程序开发,减少了开发者的心智负担。不必如C++一般把太多的精力放在程序细节,而是可以更加专注于要实现的目标。
但是Java虚拟机的垃圾回收器并不是万能的,使用不当仍然会造成内存溢出,内存泄漏等问题。因此,还是有必要了解垃圾回收机制。

Java 的垃圾回收器并不是特指一种,Java官方本身就提供了很多个GC回收器供用户选择,随着现代Java的发展,越来越多回收算法也被加入Java主线。还有各个Java虚拟机厂商(例如 Azul 的PCG、C4,RedHat的Shenandoah GC)也自己设计开发了很多优秀的垃圾回收器。

Stop The World是指在垃圾回收过程中,程序停止响应的状态。当 Stop The World 发生时,除垃圾回收所需的线程外,所有的线程都进入等待状态,所有Java代码停止,native代码可以执行,但不能与JVM交互,一切停止直到内存垃圾回收任务完成。显然,Stop The World时程序停止响应对使用方来说是很大的困扰,尤其是时间敏感性应用。由于垃圾回收原理的限制,就算是不同的垃圾回收算法,仍然会导致程序停止响应。所以,每一代的Java垃圾回收器,都把缩减 Stop The World 停顿时间作为很重要的目标。

由于不同虚拟机的实现细节不一样,这里主要讨论的还是Oracle HotSpot虚拟机

垃圾回收判断

垃圾回收区域

了解Java内存结构的人都知道,Java虚拟机分为三大部分,类加载系统,运行时数据区,执行引擎。垃圾回收并不会在每个部分上都发生作用。
Java虚拟机的垃圾回收 主要发生在运行时数据区的Java对象堆区和方法区
有一些文章说垃圾回收只会发生在以上区域,其实并不是的。

直接内存区域(Direct Memory) 的内存,也是可以被垃圾回收器回收的,是需要注意的是直接内存仅能在Full GC时被回收。

栈区的数据,基本类型数据,或者被内联展开的局部变量,在超出作用域后会自动出栈释放掉,所以其不在JVM GC的管理范围内。

对象是否可以被回收

在GC执行垃圾回收之前,首先需要区分出内存中那些是存活对象,哪些是已经死亡的对象。只有被标记为已经死亡的对象,GC才会在垃圾回收过程时,释放掉其所占用的内存空间。
那么如何判断对象是否可以被回收,或者说判断对象的生命周期是否结束?

当一个对象已经不再被任何存活的对象引用时,就可以宣判为死亡。
判断对象存活一般有两种方式:引用计数算法和可达性分析算法。

  • 引用计数法
    引用计数法(Reference Counting)比较简单,对每一个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。
    对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象的引用计数器的值为0,即表示对象A不能在被使用,可进行回收。

    • 优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。Python就支持使用引用计数的垃圾回收法
    • 缺点:(1)他需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
      (2)每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
      (3)引用计数器还有一个严重的问题,即无法处理循环引用的问题,这是一条致命的缺陷,导致在Java回收的垃圾回收器中没有使用这类算法。
  • 可达性分析法(根搜索算法)
    Java,C# ,Go都是使用可达性分析算法来判断对象是否存活的,这个算法也可以称之为根搜索算法。

这个算法的基本原理是通过一系列可被作为 GC Roots 的根对象来作为起始节点,从这些节点开始,根据引用关系向下搜索,搜索过程的就是一条引用链(Reference Chain),没有在这个链条上面的对象,也就是根节点通过引用链不可达到这个对象时,就认为这个对象是可以被回收的。

垃圾回收搜索根 GC Roots

上面说了,判断对象是否存活,需要从垃圾回收搜索根 GC Roots开始搜索,遍历全部可达对象。
那么哪些对象可以作为GC根节点呢?这里很多文章列举许多可以作为搜索根节点的对象,但是很散乱,云里雾里的,看不出什么规律。

其实,这块知道了原理,是比较好理解的。判断对象是否存活,还要从根开始搜索。那么自然是程序运行时不会被销毁的对象,和当前程序运行时刻明确已知存活的对象,作为搜索根最合适啊。总之,GC Roots 就是一组必须活跃的引用。

  • 程序运行时不会被销毁的对象
    比如方法区中的类元信息,类文件常量池表,运行时常量池。本地方法栈中的JNI引用的对象,JVM内部支撑运行的Java对象,例如基本数据类型的Class对象,一些常驻的异常对象(NullPointExcepiton),系统类加载器,JNI调用的接口等。这些都是JVM启动时被加载到虚拟机内部的,并且在程序全过程中不会被销毁的对象,自然可以作为GC Roots使用。

  • 当前程序运行时刻明确已知存活的对象
    当前程序运行的这一时刻,CPU正在执行的所有Java线程的虚拟机栈帧中的对象自然是存活的,那么此栈帧中引用的对象,例如线程调用方法时,使用或产生的参数、局部变量、临时变量等都可以作为GC Roots使用。当然还有所有synchronized同步锁的持有对象。换句话说,当前所有正在被调用的方法的引用类型的参数/局部变量/临时值。

安全点 Safepoint

JVM并不能在程序运行到任意位置都可以开启垃圾回收,只有在那些被标记为安全点的位置,JVM才能开始内存垃圾回收。
安全点的选定既不能太少以至于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。
安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析。只要不离开这个安全点,Java虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。
程序运行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的。“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生安全点。
对于安全点,另一个需要考虑的问题就是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。

这个对这个问题,有两种解决方案:

抢先式中断(Preemptive Suspension)
抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机采用这种方式来暂停线程从而响应GC事件。

主动式中断(Voluntary Suspension)
主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

安全区域 Safe Region
指在一段代码片段中,引用关系不会发生变化。在这个区域中任意地方开始GC都是安全的。也可以把Safe Region看作是被扩展了的Safepoint。

垃圾回收的时刻

当程序创建一个新的对象或者基本类型的数据,内存空间不足时,会触发GC的执行。

不同的垃圾回收器,会有不同的回收策略,但大致可以分为两类:分代回收和局部回收两种策略。
垃圾回收策略不等于垃圾回收算法,正因为不同的回收算法有各自的缺陷,所以才会使用不同的垃圾回收策略。

垃圾回收算法

垃圾回收算法,主流的都是基于先标记垃圾对象,然后进行处理的方式。

标记—清除算法 Mark and Sweep

这个算法和它的名字一样,分两个步骤:标记 和 清除。首先标记出所有存活的对象,再扫描整个空间中未被标记的对象直接回收。

内存中的对象构成一棵树。开始垃圾回收时,第一:标记,标记从树根可达的对象(图中水红色),第二:清除(清除不可达的对象)。标记清除的时候程序会停止运行,如果不停止,此时如果存在新产生的对象,这个对象是树根可达的,但是没有被标记(标记已经完成了),会产生漏标记。
标记—清除算法

内存区域状态
标记 - 清除算法一次回收过后,可以看到完整的内存区域产生了大量空洞。这时由于回收后没有进行整理的操作,所以会存在内存空间碎片化的问题,这个确实是缺点,但也是这个算法的特点,正因为它不进行整理,所以效率才高。
另一个问题就是如果要创建一个较大的对象,长度超过每个空洞的,则无法创建。此时虽然总的剩余内存空间大小足够,但由于不能连续分配,导致大对象无法创建。

标记—复制算法 Mark and Copy

标记—复制算法
把内存分成两块相等区域:空闲区域和活动区域,每次只使用其中的一块,称为活动区域。进行垃圾回收时,第一还是标记可达对象,标记之后把可达的对象复制到空闲区,然后将空闲区变成活动区。同时把以前活动区对象不可达的内存垃圾对象清除掉,变成空闲区,以备下次交换。

这种算法的优点是效率高,因为是整块内存进行清除的,同时复制到空闲区域的对象是在内存中连续分布的,不会有空穴,所有内存不会有碎片化的问题。缺点就是耗费空间,毕竟有一块相等大小的空间不能使用了。
还有个问题就是可能存在劣化,假定活动区域全部是活动对象,这个时候进行交换的时候就相当于多占用了一倍空间,但是没有回收到内存空间,耗费了一次清理时间效果却非常差。

标记—整理算法 Mark and Compact

标记-清除算法会产生内存碎片,可能导致在内存足够的情况下不能分配大对象。而标记-整理算法,就是在其基础之上,增加了整理这个操作,去解决这些内存空间碎片化的问题。
标记—整理算法就是优化了的标记—清除算法。

在这里插入图片描述

清理过程和标记-清除算法一样,先标记,但清除之前,会先进行内存碎片整理。把所有存活的对象往内存空间的头部移动,然后清理掉存活对象边界以外的内存,即完成了清除的操作。标记-整理算法是在 标记-清除 算法之上,又进行了对象的移动排序整理,因此成本更高,但却解决了内存碎片的问题。
标记—整理算法和标记-复制算法的,应该是是否存在内存中划分两块相等区域,用于对象互相复制。

垃圾回收器

分代垃圾回收策略

上面介绍的三种垃圾回收算法,都有各自的缺陷。那么有没有办法扬长避短呢?有的,现代Java的垃圾回收器都是采用分代回收策略,组合使用不同的算法,以期实现最优的垃圾回收机制。

大多数的商业虚拟机,都采用分代回收的理论来设计垃圾收集器,这个理论建立在两个分代假说上:

弱分代假说:绝大多数对象都是朝生暮死的。
强分代假说:熬过越多次的垃圾回收的对象,就越难消亡

既然绝大多数对象都熬不过几次垃圾回收,而熬过多次回收的对象又很难消亡,那么可以根据对象的年龄把它们划分到不同的区域,例如新生代区域和老年代区域,然后分而治之。

例如新生代,绝大多数对象都是朝生夕死的,每次触发GC,这个区域里大部分对象都会被回收,使用可达性分析法,从根节点顺着引用链遍历下去,只有在这个引用链上的才是存活的,假设本次触发GC,这个区域里90%的对象都要被回收,但实际上只需要操作引用链上10%的对象就可以了。

对于熬过很多次依然存活的对象,这种对象一般很难被回收了,这样的情况下,每次GC都对他们进行搜索标记,太浪费资源。把它们放到老年代区,这样JVM就能以较少的频率来回收这个区域,假如老年代的空间占比是60%,在不触发老年代回收的情况下,只需要对占比40%内存空间的新生代进行搜索和释放,效率提升还是很明显的!

各区域触发垃圾回收的类型:
针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:

  • Partial GC:部分区域GC,并不收集整个GC堆的模式
    • Young GC:只收集young gen的GC
    • Old GC:只收集old gen的GC。只有CMS的concurrent collection是这个模式
    • Mixed GC:收集整个young gen以及部分old gen的GC。只有G1有这个模式
  • Full GC:收集整个堆,包括年轻代,老年代(永久带如果存在的话)元空间等所有部分的模式。

有说法,将Minor GC等同于只回收新生代区域youngGC,Major GC等同于只回收老年代区域的oldGC。

并不是这样的,Minor GC和Major GC是指在Hotspot JVM实现的Serial GC, Parallel GC, CMS, G1 GC收集器收集过程中的,大致可以对应到某个Young GC和Old GC算法组合的阶段。所以Minor GC和Major GC并不是Young GC,Old GC对应的。MajorGC通常和FullGC等价,并不一定等于Old GC。

就Azul的Pauless到C4的发展历程来看,选择实现分代的最大好处是,GC能够应付的应用内存分配速率(allocation rate)可以得到巨大的提升。并发GC根本上要跟应用玩追赶游戏:应用一边在分配,GC一边在收集,如果GC收集的速度能跟得上应用分配的速度,那就一切都很完美;一旦GC开始跟不上了,垃圾就会渐渐堆积起来,最终到可用空间彻底耗尽的时候,应用的分配请求就只能暂时等一等了,等GC追赶上来。所以,对于一个并发GC来说,能够尽快回收出越多空间,就能够应付越高的应用内存分配速率,从而更好地保持GC以完美的并发模式工作。虽然并不是所有应用中的对象生命周期都完美吻合弱分代假说(weak generational hypothesis)的假设,但这个假设在很大范围内还是适用的,因而也可以帮助并发GC改善性能。——Azul JDK研发者RednaxelaFX

新生代(Young generation)

绝大多数新创建的对象都会被分配到这里,这个区域触发的垃圾回收称之为Minor/Young GC。

老年代(Old generation)

对象在新生代周期中存活了下来的,会被晋升到老年代。一些较大的对象,也会被直接分配到老年代。默认情况下这个区域分配的空间要比新生代多,当然这是可以调整的。正是由于对象经历的GC次数越多越难回收

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值