Java基础知识专题6-Java垃圾回收(GC)详解

Java基础知识专题6-Java垃圾回收(GC)详解

前言

通过前几个章节,我们已经完成了对JVM的全面解析。
本章我们就对JVM中的最后一个重要概念垃圾回收(GC)进行一个全面的讲解。

之前我们说过,Java有一个简单易学的特性,有一个重要的原因就是它帮我们处理了复杂的内存问题,而JVM处理内存问题的核心就是GC。那么接下来我们将从:什么是GC?GC主要处理的区域?GC主要处理什么?GC的时机是什么时候?GC是怎么进行的以及GC都做了什么事?几种垃圾收集器、finalize方法这么几个方面对GC进行全面的了解。

什么是GC?

垃圾收集(Garbage Collection),由于计算机的内存是有限的,而程序运行过程中会产生各种数据占用内存,特别是Java这种面相对象的编程语言,所以为了保证程序有足够的内存空间可用,GC就得到了重视,它可以做到及时的把不在使用的对象清除、合理的释放内存。

当然GC并不是Java特有的,其实应该说GC是比Java更悠远的一个技术,早在Java诞生前,GC就存在。当下Java主流虚拟机是基于HotSpot实现的,所以我们讨论的GC都是基于这个实现来说的。

GC主要处理哪些内存区域?

上一章中我们了解了,JVM中内存分为方法区(JDK1.8后被移除,放到了计算机的直接内存中成为元空间)、堆区、栈区、程序计数器(PC计数器)以及本地方法栈。

由于栈、计数器和本地方法栈都是与线程同生命周期的,而栈中又以栈帧为执行单元,随着压入和弹出操作,这三部分可以实现自动的内存清理;而方法区和堆区是线程共享的,伴随着JVM的整个生命周期,并且这部分内存的使用都是动态的,所以GC主要内容都集中在这两个部分。

为什么会堆内存溢出?

在年轻代中经过GC后还存活的对象会被复制到老年代中。当老年代空间不足时,JVM会对老年代进行完全的垃圾回收(Full GC)。如果GC后,还是无法存放从Survivor区复制过来的对象,就会出现OOM(Out of Memory)。

OOM(Out of Memory)异常常见有以下几个原因:

  1. 老年代内存不足:java.lang.OutOfMemoryError:Javaheapspace
  2. 永久代内存不足:java.lang.OutOfMemoryError:PermGenspace
  3. 代码bug,占用内存无法及时回收。

OOM在这几个内存区都有可能出现,实际遇到OOM时,能根据异常信息定位到哪个区的内存溢出。

GC主要处理什么?(什么是JVM定义的垃圾?)

在方法区域对象中存放的是类的静态属性、常量、类元数据以及对象的实例,从而GC回收的也就是这两个区域中存放的内容。总体来说就是程序运行过程中产生的对象相关的内容。

那么GC又是如何判断对象是否该被回收呢?

JVM主要是判断对象是否存活,也就是有没有在被引用。主要使用两种方式:引用计数和可达性分析。

引用计数

就是为每个对象记录一个引用数的属性,当这个对象被引用时计数器+1,引用释放时计数器-1,计数器为0时,便表示该对象不再被引用,即可以被回收。

但是这种方式存在一个问题,那就是无法释放循环引用的对象。如下图:
Java基础知识专题6-Java垃圾回收(GC)详解
对象A、B、C的计数器永远无法归0。

可达性分析

为了解决循环引用问题,所以JVM又使用了可达性分析算法判断对象是否可以被回收。它主要的方法就是以几类JVM必须使用的基础对象作为根节点(GC Roots):

  1. 虚拟机栈中引用的对象;(因为栈中都是正在运行中的线程,如果它们引用的对象被回收,会导致他们无法正常运行下去)
  2. 方法区中被System:ClassLoader加载的类的静态属性实体引用的对象;(因为这些内容是都是系统类的静态数据,类只要不被卸载,他们基本都是随时可能被启用的)
  3. 方法区方法区中被System ClassLoader加载的类的常量的引用对象;(和2同理,存在最多的就是字符串)
  4. 本地方法栈JNI引用的对象;(和1同理,也是在运行的,不过是本地方法依赖的对象,不能轻易释放)
  5. JVM持有的对象。(被JVM以特殊目标持有的,显然GC是不敢随意回收的,如:系统类加载器、一些JVM知道的重要的异常类、一些用于处理异常的预分配对象以及一些自定义的类加载器等)

然后向下做网状拓扑搜索,搜索所走过的路径成为引用链。当一个对象没有任何引用链能够连接到达GC Roots时,表示这个对象没有被引用了,就可以回收了。

如下图可见,橙色的对象都是没有到达GC Roots的引用链的对象,根据可达性分析是可以回收的对象。
Java基础知识专题6-Java垃圾回收(GC)详解
这个方法是基于JVM的实现约定的,可以解决循环引用的问题。

既然回收的是对象,那么就必须提一下对象的引用类型,后面章节会细讲:

  1. 强引用:除非对象死了,否则不回收;(注:ArrayList的clear方法就是有利于垃圾回收的方法)
  2. 软引用:除非死了,或者内存不足了,一般情况不会回收
  3. 弱引用:只要执行GC,就会回收这类对象,不管内存够不够
  4. 虚引用:他不会影响对象生命周期,持有它和没有一样,GC执行就会回收掉

什么时机触发GC?

要想了解什么时候触发GC,首先我们需要看一下堆内存的结构,因为GC主要是处理堆区的内存,然后再看看创建对象的过程中内存干了什么?了解了这两个内容,我们自然就知道触发GC的时机了。

首先咱们看一下堆区的内存的模型,由于JDK1.8以后堆区做了较大的变动,所以我们分别看一下JDK1.7的和JDK1.8的,如下面二图:
Java基础知识专题6-Java垃圾回收(GC)详解
Java基础知识专题6-Java垃圾回收(GC)详解
JVM之所以将堆分成这么多部分,主要是用以判断哪些部分的内容需要被回收、哪些部分的内容暂时不用回收(支持分代回收算法),从而减少垃圾扫描时间和GC频率。西面是几个大区的简化理解:

  1. 新生代(最大):存放刚刚创建的对象,刚刚创建的对象很有可能都是一次性的,使用完就废弃了,所以存在着大量的垃圾对象,那么这个部分的对象应该是优先回收的,放在新生代;
  2. 老年代:这里都是那些使用频繁的对象,经历了多次GC依然被引用的对象,可以说是屹立不倒,放在老年代;
  3. 永久代:永久代存放的就是永远不会被回收的对象,其本身就是一个大问题,只有JDK崩溃了,永久代才会被释放;所以JDK1.8以后,永久代就被元空间替换(元空间与永久代上类似,都是方法区的实现,他们最大区别是:元空间并不在JVM中,而是使用本地内存。),由于类加载后可能会一直被使用,所以类元信息都放在永久代(方法区,JDK1.18后叫元空间);
  4. 伸缩区:最后就是每个块都会有一个伸缩去,它的存在就是让JVM可以根据空间的使用情况,动态扩充本区域,有效的提升内存性能。

注:移除永久代原因:为融合HotSpot JVM与JRockit VM(新JVM技术)而做出的改变,因为JRockit没有永久代。有了元空间就不再会出现永久代OOM问题了。

知道了堆区的分部方式,接下来就看一个Java对象的创建流程了,见下图:

Java基础知识专题6-Java垃圾回收(GC)详解
从上图可以看出来,GC的触发是在创建对象过程中,申请空间出现问题的时候。有两种情况:小范围GC、全量GC。

  1. 小范围GC(Minor GC):触发条件是当Eden区满时(范围小、消耗小、优先触发);
  2. 全量GC(Full GC)
    触发条件是:
    a.老年代空间不足;
    b.方法区空间不足,会导致Class Method元信息的卸载;
    c.通过Minor GC后进入老年代的平均大小大于老年代的可用内存;
    d.由Eden区、S0区向S1区复制时,对象大小大于S1可用内存,则把对象转存到老年代,且老年代的可用内存小于该对象大小。
  3. 主GC(Major GC):当老年代空间不足时。

此处要注意Full GC和Major GC还是有一定的区别的:
Full GC几乎包含了堆内存的全部区域回收,而Major GC只对老年代进行回收。

  1. 最后一种就是程序代码显式调用System.gc,系统则会建议执行Full GC,但是不是必然执行的,系统会自行判断,如果非必要还是不会执行的。

GC的过程都要做什么事?

主要就是清理对象,释放空间,整理内存的工作。

由于Java堆分了新生代、老年代,每个部分存放的内容定义是完全不一样的,所以GC在处理不同的部分的时候采用了不同的算法,目的就是更加贴合这个区域的特性,提高GC效率。

GC常用的算法

GC常用的算法有:标记-清除算法、标记-压缩算法、复制算法、分代收集算法,目前主流的JVM(HotSpot)采用的是分代收集算法。

标记-清除算法(Mark-Sweep)

从名称顾名思义:先标记再清除,此算法分为两个步骤:

  • 第一步:为每个对象存储一个标记位,记录该对象的状态(活跃或死亡),检查的方式就是可达性分析;
  • 第二步:对被标记死亡的对象进行清除。

过程如下图所示:
Java基础知识专题6-Java垃圾回收(GC)详解
优点:

  • 该算法不移动对象,避免了内存移动带来的开销。

缺点:

  • 标记的时候需要遍历堆中的全部对象,清除的时候又要遍历一遍全堆对象,效率低、算法复杂度高;
  • 由于不移动对象,导致出现非常多的内存碎片空间,如果需要为一个大对象分配空间,而清除的对象都是保存在不连续的空间上的小对象,一次GC很可能无法满足空间需求,从而导致频繁GC;而且导致内存空间利用率降低。

标记-压缩算法(Mark-Compact),有些资料也叫:标记-整理算法

从名称上也是顾名思义的:先标记再整理,他可以说是标记-清除算法的一个升级版,此算法也是分为两个步骤:

  • 第一步:为每个对象存储一个标记位,记录该对象的状态(活跃或死亡),使用的依然是可到达分析;
  • 第二步:清除死亡状态的对象,空出占用的空间,然后对将其他所有存活对象都网左端空闲的空间进行移动,并更新引用其对象的指针。

过程如下图所示:
Java基础知识专题6-Java垃圾回收(GC)详解
优点:

  • 解决了标记-清除算法导致大量内存碎片的问题。

缺点:

  • 与标记-清除算法一样,复杂度较高;
  • 同时如果存活下来的对象较多,整理阶段需要大量的移动对象的存储位置,且引用指针的更新也很费时,导致算法效率低下。

复制算法(Copying)

该算法可以说是典型的“用空间换时间”的思维。它将内存分为两部分,每次只是用其中一部分,当这部分内存满了以后,将内存中存活的对象复制到另一个半内存中继续使用,然后将之前的内存清空,依次循环下去。

过程如下图所示:
Java基础知识专题6-Java垃圾回收(GC)详解
优点:

  • 实现简单,不产生碎片,对内存无限的场景合适。

缺点:

  • 每次使用一般内存,对原本资源紧张的内存浪费严重。(不差钱另说)

分代收集算法(Generational Collection)

首先分代收集算法从字面意思不难看出,就是将垃圾分成不同的“年龄代”进行分类回收。

所以分代收集算法就是基于堆区的内存分代划分的方式设计的,它有一个基本的前提:绝大部分对象的生命周期都非常短暂,存活时间短!(编程一定要注意GC的这个特性,因为大部分JVM的GC都是使用了分代收集算法)。

分代收集算法之所以成功,是因为它是融合了前面三种基本算法,然后根据不同对象的特性,将内存分区后,根据每个分区的存储对象的实际情况和特性,搭配合适的算法进行GC。从而使每个分区和每个算法都得到最大的功能发挥!

接下来我们就介绍一下每个分区的特点及采用的算法:

新生代(Young Generation)

新生代包含两部分伊甸区(Eden,1个,占用80%空间)和幸存区(Survivor,2个,各占10%空间)。由于大多数对象的存活时间是非常短的,所以他们一进入新生代用完就废了,而留下来要一直使用的会非常少。由于它本身的这个特性,再加上为了减少碎片空间的产生,所以新生代划分了幸存区,并采用复制算法,把幸存下来的非常少量的对象直接复制到From幸存区(S0),然后空出Eden区继续使用,当下次GC后再将Eden和From的存活对象复制到To区(S1),空出Eden和From继续使用,直到部分对象一直游走在From区和To区持续游荡一段时间以后,根据Survivor的存活阈值判断这些对象可能是一直要用的,则会被复制到老年代去。

整个过程如下图所示:
Java基础知识专题6-Java垃圾回收(GC)详解

  • 第一次Minor GC:当Eden区占满后,将存活的对象复制到S0区,然后清理Eden区继续使用;
  • 第二次Minor GC:当Eden区又一次占满后,将Eden区和S0存活的对象复制到S1区,然后清理Eden区和S0区继续使用;
  • 第三次Minor GC:当Eden区又一次占满后,将Eden区和S1区存活的对象复制到S0区,然后清理Eden区和S1区继续使用;
  • 就这样将存活的对象在S0区和S1区之间来回的复制,直到某次Minor GC发现存活的对象生存年龄达到了阈值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置),那么这些对象就会被复制到老年代(Tenured)去。
老年代(Old Generation)

老年代所存储的都是经过新生代多次Minor GC后,其年龄超过年轻代阈值的对象,表明这些对象的都是系统一直在使用的,则会不断的有对象进入老年代。而老年代的空间比年轻代要大,这样它能存放更多的对象,同时在老年代执行GC的次数也相对较少,当老年代内存不足时会自动执行主GC(Major GC)或者全量GC(Full GC)。

老年代采用主要的GC策略(同时它也是一种GC收集器)是:Concurrent Mark-Sweep(并发标记-清除算法,CMS算法),是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。由于它是应用程序线程和GC线程交替并发执行,所以对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。CMS采用的基础算法是:标记—清除。

它的过程较为复杂,如下图所是:
Java基础知识专题6-Java垃圾回收(GC)详解

  • 第一步:初始标记(STW Initial Mark):JVM会暂停正在执行的任务(STW),由更对象(GC Roots)扫描所有的关联对象,并做出标记。此过程只会导致JVM短暂的暂停;
  • 第二步:并发标记(Concurrent Marking):恢复所有暂停的线程,并且对之前标记过的对象进行扫描,取得所有和标记对象有关联的对象;
  • 第三步:并发预清理(Concurrent Precleaning):查找所有在并发标记阶段新进入老年代的对象(一些对象可能从新生代晋升来,或者一些被分配到老年代),通过重新扫描,减轻下一阶段工作;
  • 第四步:重新确认标记(STW Remark):此阶段JVM也会暂停正在执行的任务,对在并发标记阶段被改变引用或新创建的对象进行标记;
  • 第五步:并发清理(Concurrent Sweeping):恢复所有暂停的线程,对所有标记的垃圾对象进行清理,并且会尽量将已回收对象的空间拼凑为一个整体。在此阶段收集器线程和应用线程并发执行;
  • 第六步:并发重置(Concurrent Reset):重置CMS收集器的数据结构,等待下一次垃圾回收。

CMS优点:

  • 尽可能的降低了应用线程的停顿时间,提高程序的相应效率。

CMS缺点:

  • 分出一半CPU去做GC,影响系统通吞吐量和性能;
  • 清理不彻底,并行阶段可能又会产生新的垃圾,无法清理;
  • 清理后会产生碎片空间(标记-清除算法本来就有的问题);
  • 针对碎片问题,如果最后经过几次Full GC后,进行一次碎片整理,由于整理必然会停掉所有运行中的任务,则又会降低效率;
  • 因为和用户线程一起运行,不能在空间快满时再清理,因为是与应用并发进行的,如果是快满的时候才清理,很可能会导致还没清理完,应用线程又要申请空间了,而内存空间预留不够,从而导致OOM。

以上四种算法是GC的算法理论基础,接下来就介绍四种常见的垃圾收集器。

垃圾收集器

垃圾收集器,就是内存回收的具体实现了,他们是基于上面的算法的具体实现。在整个内存空间中不单单使用一种,由于各个区块的特点不同、垃圾回收的时机也各不相同,所以这些收集器都是组合使用的。

了解垃圾收集器前先了解一个知识:JVM的Client模式与Server模式。

  • Client模式:JVM启动块,内存处理的方式比较暴力;
  • Server模式:JVM启动慢,但是启动后运行有长期稳定的特点,而且程序运行速度比Client要快很多。

32位的JDK一般都支持server和client两种模式,64位的虚拟机好像只支持server模式。

如何知道JVM的模式:Java -version 中会显示,后面的JVM调优章节细讲。
Java基础知识专题6-Java垃圾回收(GC)详解

下面就讲解一下每种垃圾收集器使用的算法及特点。

Serial收集器

串行收集器,它是最古老的,同时也是最稳定以及效率高的收集器,但是运行时可能会产生一个较长时间的应用暂停,因为它是一个单线程串行的回收机制。

作用于:

  • 新生代使用复制算法,单线程串行处理
  • 老年代使用标记-压缩算法,单线程串行处理

Serial Old收集器

串行老年代收集器,它是Serial收集器的拆分版本,只针对老年代,是JVM在Client模式下老年代默认的收集器。

作用于:

  • 老年代使用标记-压缩算法
  • 新生代可以搭配其他各种能够处理新生代的收集器

ParNew收集器

ParNew(半新半旧收集器),是Serial收集器的一个多线程版本,只针对新生代的部分进行了多线程处理,老年代部分不变。

作用于:

  • 新生代使用复制算法,并行处理
  • 老年代使用标记-压缩算法,单线程串行处理

Parallel Scavenge

并行收集器,它与ParNew类似,是一个多线程并行收集器,更加关注吞吐量(吞吐量 = 用户代码运行时间 /(用户代码运行时间 + 垃圾收集时间))的收集器,被称为“吞吐量优先"收集器。

作用于:

  • 新生代使用复制算法,并行处理
  • 老年代使用标记-压缩算法,单线程串行处理

Parallel Old收集器

是Parallel Scavenge的一个升级版本,对老年代的处理也使用并行处理。

作用于:

  • 新生代使用复制算法,并行处理
  • 老年代使用标记-压缩算法,并行处理

CMS收集器

基于CMS处理策略的收集器,并发阶段会降低吞吐量,GC独占阶段会产生短暂停顿,不适合大吞吐量场景。

作用于:

  • 老年代使用标记-清除算法,与应用线程并发交替执行
  • 新生代需要配合:ParNew或者Serial

G1收集器(基于JDK1.8)

G1收集器(Garbage First,开发者很皮!),是JDK1.7 u4版本后正式引入Java中的,它的目标是替换CMS,解决多CPU以及大内存的服务器环境下,要求快速响应的服务器场景下的垃圾收集器。

G1有以下几个特点:

  1. 并行与并发:G1能够充分使用多CPU、多核的硬件优势,使用多个CPU来缩短垃圾回收带来的停顿时间(Stop-the-world),它想CMS一样让GC线程与应用线程并发;
  2. 分代收集:基于过去分代收集的成功,G1没有抛弃分代收集,依然是根据内容特点进行分代收集处理;
  3. 空间整理:它与CMS不同,它从整体来看是基于标记-压缩算法实现,从局部上则是基于复制算法实现,这两种算法的组合使它有效的解决了CMS存在的碎片化内存的问题;
  4. 可预测的停顿:这是G1相对于其他收集器最大的优势,他能够让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒;
  5. 一体化收集器:G1是个一体化的收集器,它不想其他收集器要搭配使用,它自己基本就能搞定整个Java堆内存的垃圾收集。(JDK10之前,Full GC是交给Serial Old处理)

G1收集器堆内存的划分采用了全新的区域化治理的思维,它将整个堆内存分为N个不同类别区域(Region),类型依然为:

  • Eden Regions:年轻代-Eden区
  • Survivor Regions:年轻代-Survivor区
  • Old Regions:老年代
  • Humongous Regions:巨型对象区
  • Free Regions:可用区

Java基础知识专题6-Java垃圾回收(GC)详解
针对分区有几个注意点:

  1. JVM启动时会自动设置区域的大小(每个区域的大小范围:1MB-32MB,最多可以分2048个区,即支持最大内存为:64G);
  2. 同类型的区域可以是不连续的;
  3. 分区的类型时不固定,一块空间可能随着GC结束后转换为其他类型;
  4. 巨型对象区用来保存哪些占用Region空间50%以上的大对象,如果对象过大一个H区无法保存,则会使用多个连续的H区来保存,由于巨型对象的转移会影响GC效率,所以一旦发现巨型对象死了,会直接回收;
  5. 从分配图上可以看出,分区有效的利用了空间,因为收集整体是使用标记-整理算法,Region之间则是使用复制算法,GC后会将存活对象复制到可用区,所以基本不会产生碎片空间。

G1未来的优化点就是尽量少的使用Full GC,保证效率!由于G1依然是在不断优化的,后续会单独使用章节去细讲,现在基本了解它是未来的主流GC趋势就可以,其实JDK1.9以后G1就是默认的GC了。

收集器是作用区域和使用搭配

Java基础知识专题6-Java垃圾回收(GC)详解
要注意的是看JVM使用哪个收集器要关注JVM的模式和版本,以及使用者自定义的JVM启动参数,一下是Server模式下默认值:
jdk1.7 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
jdk1.9 默认垃圾收集器G1

finalize()方法介绍

finalize()是Object对象的一个protected方法,所以所有的对象都是可以重写这个方法的。该方法会在GC将要回收对象占用的内存前进行调用。

finalize()只会在对象内存回收前被调用一次(The finalize method is never invoked more than once by a Java virtual machine for any given object. );
finalize()的调用具有不确定行,只保证方法会调用,但不保证方法里的任务会被执行完(比如一个对象手脚不够利索,磨磨叽叽,还在处理各种逻辑的时候,就已经被杀死回收了)。

两个作用:

  1. 让对象“起死回生”,即用finallize()方法让对象逃过GC;
  2. 用来清理JNI创建的本地对象。

结语

至此Java的GC就基本介绍完了,GC是Java的一把利器,它帮我们解决了内存的问题,但是同样的由于Java为我们提供了GC,导致很多程序员写代码的时候根本不考虑内存问题,从而出现调试没有问题,但是吞吐量或者数据量一大,内存就出现OOM的错误,导致重大问题的情况。

所以基于这个情况,我理解还是应该了解GC的习性,在编程的时候一定要多多考虑,保证我们的开发质量。以下是几点关于内存的编程建议:

  1. 对象不用了就释放引用(将引用对象赋值为null),别占着不放;
  2. 少用finalize方法,它就是Java给的一个释放对象和资源的机会,但代价是加大GC的工作量,不到万不得已最好少用;
  3. 常用的资源对象(图片、数据流等),能存一分在内存中就不要总是新建,他们都很占空间,容易OOM;
  4. 对集合类型对象,GC回收很费劲,要注意使用场景;
  5. 全局变量、静态变量都是容易成为悬挂对象的,导致内存占用;
  6. System.gc()可是一个不靠谱的方法,不一定执行,谨慎使用,不一定能达到你想要的效果;
  7. Full GC会Stop-the-world,能通过编程合理利用内存,就能更少的触发它。

参考资料

https://www.cnblogs.com/diaozhaojian/p/10510608.html
https://www.jianshu.com/p/f36ca4e4bd10/
https://blog.****.net/laomo_bible/article/details/83112622
https://segmentfault.com/a/1190000013365885
https://blog.51cto.com/12445535/2372976
https://www.cnblogs.com/UncleWang001/articles/10422289.html
https://blog.****.net/lijingyao8206/article/details/80513383