Bootstrap

Java的垃圾回收机制及算法

写在前面:

该系列文章,主要是为了深入学习Java完成的一条链,推荐阅读的整体顺序为:Java的内存模型(根源),一个java文件被执行的历程一个Java类的加载Java的垃圾回收机制及算法Linux(六):系统运维常用命令   Java程序运行状态的监控(实用,定位Java程序问题)

Java的垃圾回收机制

前面的已经说过关于 Java在内存中的内存划分(Java的内存模型),一个java文件执行的流程、一个Java类如何被加载,本着有始有终的原则,下面该说一下 如何 “ 死亡 ”。

Java的编译流程已经提过了,其实更多的就是类+对象+内存,就是将程序员抽象的类文件,加载到内存区,形成一个个的对象,对象可以极端的理解为就是内存中的一部分,内存划分好块之后,对象都在堆中创建,其中一个对象,便是堆区中的一块内存。

随着程序的运行,各种套娃的逻辑,new的使用,数据的转存等等,都会让堆内的对象递增,这就涉及一个问题,堆内的内存是有限的,对象如何只是不停的增加,早晚会爆炸,也就是常说的堆栈溢出;最经典就是C++,习惯写C++的小伙伴肯定知道,每次创建对象,都要想着析构,因为C++是不会自己清除无效对象的,它的内存空间就会不停的增加,只有研发人员自发的去释放掉,这样就要求写C++的研发要时刻牢记内存的释放。

但是Java就不需要,我们只需要无脑的new new new ,套娃套娃套娃,原因就是Java有一套自动的对象销毁机制,也叫垃圾回收机制,这也是学习JVM,以及各种面试都会问到的点。

通常要聊Java的垃圾回收机制,需要搞清除三个问题:

  1. 哪些内存会被回收清理
  2. 怎样回收清理
  3. 什么时候会被回收清理

哪些内存(对象)会被回收清理

之前说过,在java中,万物皆对象,而对象存在于堆内存中,要说哪些对象会被清除,其实可以理解为,哪些对象已经“死了”。也就是说哪些对象已经不再被程序需要,不再被调用,这里判断对象是否需要被回收,一般是两种算法:引用计数器算法和可达性算法

引用计数器法

原理其实很简单,给运行的对象添加一个引用计数器,每当有一个地方引用它时,计数器+1;当引用失效时,计数器就-1,任何时刻计数器为0的对象,就视作不可能再被使用。这一种方式,实现简单,逻辑也清晰,大部分的情况下,它都可以达到很好的效果,尽管这样,计数器算法还是存在但是的,但是它无法解决循环引用的场景,这也是主流Java虚拟机没有选用这一算法的原因。

可达性分析法

此算法的核心思想:通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为“引用链”,当一个对象到 GC Roots 没有任何的引用链相连时(从 GC Roots 到这个对象不可达)时,证明此对象不可用。

想要了解什么是可达性对象GC Roots,需要先了解一下java的内存模型,java的内存模型

 

总体来说一般存在于:虚拟机栈、java方法区、本地方法区的对象都是可达的,也就是GCRoots对象

1、方法区静态属性引用的对象 全局对象的一种,Class对象本身很难被回收,回收的条件非常苛刻,只要Class对象不被回收,静态成员就不能被回收。

2、方法区常量池引用的对象 也属于全局对象,例如字符串常量池,常量本身初始化后不会再改变,因此作为GC Roots也是合理的。

3、方法栈中栈帧本地变量表引用的对象 属于执行上下文中的对象,线程在执行方法时,会将方法打包成一个栈帧入栈执行,方法里用到的局部变量会存放到栈帧的本地变量表中。只要方法还在运行,还没出栈,就意味这本地变量表的对象还会被访问,GC就不应该回收,所以这一类对象也可作为GC Roots。

4、JNI本地方法栈中引用的对象 和上一条本质相同,无非是一个是Java方法栈中的变量引用,一个是native方法(C、C++)方法栈中的变量引用。

5、被同步锁持有的对象 被synchronized锁住的对象也是绝对不能回收的,当前有线程持有对象锁呢,GC如果回收了对象,锁不就失效了嘛。

可达性分析就是JVM首先枚举根节点,找到一些为了保证程序能正常运行所必须要存活的对象,然后以这些对象为根,根据引用关系开始向下搜寻,存在直接或间接引用链的对象就存活,不存在引用链的对象就回收。注:注意的是被判定为不可达的对象不一定就会成为可回收对象。被判定为不可达的对象要成为可回收对象必须至少经历两次标记过程,如果在这两次标记过程中仍然没有逃脱成为可回收对象的可能性,则基本上就真的成为可回收对象了。

怎样回收清理

使用发现算法垃圾被标记后,接下来就是如何去清除,常见的清除算法有:标记清除法、复制清除、标记整理算法、分代收集;其中当前主流使用的是分代收集

标记清除算法(Mark Sweep)

该算法很简单,使用通过可达性分析分析方法标记出垃圾,然后直接回收掉垃圾区域。简单粗暴,即标记删除的对象,对其进行内存回收;它的一个显著问题是一段时间后,内存会出现大量碎片,导致虽然碎片总和很大,但无法满足一个大对象的内存申请,从而导致 OOM,而过多的内存碎片(需要类似链表的数据结构维护),也会导致标记和清除的操作成本高,效率低下。

 

标记整理算法(Generation Collection)

标记整理法,知识在标记清除的基础上,追加了碎片的散落问题,在清除之后进行了碎片的整理,但副作用是增了了GC的时间。

复制算法(Copying)

为了解决标记清除算法的效率问题,有人提出了复制算法。它将可用内存一分为二,每次只用一块,当这一块内存不够用时,便触发 GC,将当前存活对象复制(Copy)到另一块上,以此往复。这种算法高效的原因在于分配内存时只需要将指针后移,不需要维护链表等。但它最大的问题是对内存的浪费,使用率只有 50%。

但这种算法在一种情况下会很高效:Java 对象的存活时间极短。据 IBM 研究,Java 对象高达 98% 是朝生夕死的,这也意味着每次 GC 可以回收大部分的内存,需要复制的数据量也很小,这样它的执行效率就会很高。

 

分代收集(Generation Collection)

在实际的Java程序中,大部分的对象存活周期都较短,基本上创建完,紧接着处理完数据就被丢弃了,大部分 Java 对象是朝生夕死的,所以我们将内存按照 Java 生存时间分为 新生代(Young)老年代(Old),前者存放短命僧,后者存放长寿佛,当然长寿佛也是由短命僧升级上来的。然后针对两者可以采用不同的回收算法,比如对于新生代采用复制算法会比较高效,而对老年代可以采用标记-清除或者标记-整理算法。这种算法也是最常用的。

JVM Heap 分代后的划分一般如下所示,新生代一般会分为 Eden、Survivor0、Survivor1区,便于使用复制算法。

将内存分代后的 GC 过程一般类似下图所示:

    1. 对象一般都是先在 Eden区创建

    2. Eden区满,触发 Young GC,此时将 Eden中还存活的对象复制到 S0中,并清空 Eden区后继续为新的对象分配内存

    3. Eden区再次满后,触发又一次的 Young GC,此时会将 EdenS0中存活的对象复制到 S1中,然后清空EdenS0后继续为新的对象分配内存

    4. 每经过一次 Young GC,存活下来的对象都会将自己存活次数加1,当达到一定次数后,会随着一次 Young GC 晋升到 Old

    5. Old区也会在合适的时机进行自己的 GC

垃圾收集器运行机制

有了垃圾回收算法,那么其具体的实现就是垃圾收集器,也是我们实际使用中会具体用到的。现代的垃圾收集机制基本都是分代收集算法;新生代和老生代也有不同的回收器。

YoungOld区有不同的垃圾收集器,实际使用时会搭配使用,也就是上图中两两连线的收集器是可以搭配使用的。这些垃圾收集器按照运行原理大概可以分为如下几类:

  • Serial GC,串行,单线程的收集器,运行 GC 时需要停止所有的用户线程,且只有一个 GC 线程

  • Parallel GC,并行,多线程的收集器,是 Serial 的多线程版,运行时也需要停止所有用户线程,但同时运行多个 GC 线程,所以效率高一些

  • Concurrent GC,并发,多线程收集器,GC 分多阶段执行,部分阶段允许用户线程与 GC 线程同时运行,这也就是并发的意思。

什么时候会被回收清理

  1. Young 区的GC 都是在 Eden 区满时触发

  2. Serial Old 和 Parallel Old 在 Old 区是在 Young GC 时预测Old 区是否可以为 young 区 promote 到 old 区 的 object 分配空间,如果不可用则触发 Old GC。这个也可以理解为是 Old区满时。

  3. CMS GC 是在 Old 区大小超过一定比例后触发,而不是 Old 区满。这个原因在于 CMS GC 是并发的算法,也就是说在 GC 线程收集垃圾的时候,用户线程也在运行,因此需要预留一些 Heap 空间给用户线程使用,防止由于无法分配空间而导致 Full GC 发生。

;