三、垃圾回收

3.1、如何判断对象可以回收

1、引用计数法

  • 定义

此种算法会在每一个对象上记录这个对象被引用的次数,只要有任何一个对象引用了次对象,这个对象的计数器就+1,取消对这个对象的引用时,计数器就-1,在分配对象时会将计数器的值置为1

任何一个时刻,如果该对象的计数器为0,那么这个对象就是可以回收的。

  • 缺点
  1. 计数器值增减频繁
  2. 实现繁琐,更新引用时很容易导致内存泄露。
  3. 循环引用无法回收(最重要的缺点),两个已经失去作用、但互相引用的对象无法被引用计数法判断为可回收垃圾。

image-20210521172324702

  • 注意:JVM 没有使用引用计数法

2、可达性分析方法

  • 定义

这个算法的基本思想是通过一系列称为 “GC Roots“(肯定不能被当为垃圾回收的对象) 的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链(即GC Roots到对象不可达)时,则证明此对象可以被当成垃圾进行回收。

JVM 中的垃圾回收器采用可达性分析算法来探索所有存活的对象。

  • 哪些对象可以作为 GC Root
  1. 虚拟机栈(栈帧中的局部变量区,也叫做局部变量表)中引用的对象。

  2. 方法区中的类静态属性引用的对象和常量引用的对象。

  3. 本地方法栈中JNI(Native方法)引用的对象。

  4. 正在加锁的对象

3、五种引用

image-20210521193931546

image-20210521190911142

Java 中常见的五种引用分别为:强引用、弱引用、虚引用、软引用和终结器引用

  • 强引用:不回收

如果一个对象具有强引用,那就类似于必不可少的物品,不会被垃圾回收器回收。当内存空间不足,Java虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不回收这种对象。

Java 中绝大部分引用都是强引用。

  • 软引用:内存不足即回收

软引用是用来描述一些有用但并不是必需的对象,在Java中用 java.lang.ref.SoftReference 类来表示。

对于只有软引用的对象来说:当系统内存充足时它不会被回收,当系统内存不足时它才会被回收。因此,这一点可以很好地用来解决OOM的问题,并且这个特性很适合用来实现缓存:比如网页缓存、图片缓存等。

  • 弱引用:发现即回收

弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

弱引用需要用java.lang.ref.WeakReference类来实现,它比软引用的生存期更短。

对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,都会回收该对象占用的内存。

ThreadLocal 中的 ThreadLocalMapEntry 就继承了 WeakReference

image-20210521192342894

  • 虚引用:对象回收跟踪

虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。

虚引用,顾名思义,就是形同虚设,与其他几种引用都不太一样,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。

虚引用需要java.lang.ref.PhantomReference 来实现。

如果一个对象仅持有虚引用,那么它就和没有任何引用一样在任何时候都可能被垃圾回收器回收,它不能单独使用也不能通过它访问对象,虚引用必须和引用队列(RefenenceQueue)联合使用。

虚引用的主要作用是跟踪对象垃圾回收的状态。仅仅是提供了一种确保对象被 finalize 以后,做某些事情的机制。

  • 终结器引用

它用以实现对象的 finalize 方法,也可以称为终结器引用。无需手动编码, 其内部配合引用队列使用。

3.2、垃圾回收算法 – 标记清除

1、定义

标记清除算法是一种分两阶段对对象进行垃圾回收的算法。

第一阶段:标记。从根节点(GC Root)出发遍历对象,对访问过的对象打上标记(一般是在对象的 Header 中记录),证明该节点为可达,并非可以回收的垃圾节点

第二阶段:清除。对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。

image-20210521201558262

2、缺点

  • 回收后会产生大量不连续的内存空间,即内存碎片。

由于Java在分配内存时通常是按连续内存分配,那么当碎片空间不足以分配给新的对象时,就造成了内存浪费。

  • 进行垃圾回收时,应用需要挂起
  • 标记和清除的效率不高,尤其是要扫描的对象比较多的时候

3、优点

  • 可以解决循环引用的问题
  • 必要时才回收(内存不足时)

3.3、垃圾回收算法 – 标记整理

image-20210521204010481

1、定义

标记整理算法和标记清除算法一样分为两个阶段,即先标记后整理。由于标记清除算法的一个缺点就是会产生大量内存碎片,而标记整理算法会对内存空间进行一次整理,解决内存碎片化问题。

  • 标记。从根节点(GC Root)出发遍历对象,对访问过的对象打上标记(一般是在对象的 Header 中记录),证明该节点为可达,并非可以回收的垃圾节点
  • 整理。在遍历结束后, 对于标记过的对象,把它们从内存开始的区域按顺序依次摆好,整整齐齐的, 中间没有任何的缝隙。在摆放完最后一个标记过的对象后, 把之后的内存区域直接回收掉. (这里最耗时的步骤是,当你移动一个对象的内存位置时,你需要让所有之前依赖这个对象的对象更新一下引用地址信息,这样才不会在移动之后出错.)

2、优点

  • 消除了标记清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。
  • 消除了复制算法当中,内存减半的高额代价。

3、缺点

  • 从效率上来说,标记-整理算法要低于复制算法。
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。
  • 移动过程中,需要全程暂停用户应用程序。

3.4、垃圾回收算法 – 复制

1、定义

复制算法将内存划分为两个区间,在任意时间点,所有动态分配的对象都只能分配在其中一个区间(称为活动区间),而另外一个区间(称为空闲区间)则是空闲的

当有效内存空间耗尽时,JVM 将暂停程序运行,开启复制算法 GC 线程。接下来GC线程会将活动区间内的存活对象,全部复制到空闲区间,且严格按照内存地址依次排列,与此同时,GC线程将更新存活对象的内存引用地址指向新的内存地址

此时,空闲区间已经与活动区间交换,而垃圾对象现在已经全部留在了原来的活动区间,也就是现在的空闲区间。事实上,在活动区间转换为空间区间的同时,垃圾对象已经被一次性全部回收。

  • 垃圾清理前(1、4 为垃圾)

image-20210521231513342

  • 将所有存活对象复制到原来的空闲区间中,并按照内存地址排序,更新引用,然后清除原来活动区间(现空闲区间)中的所有垃圾对象(1、4)

image-20210521231610100

2、优点

  • 不产生内存碎片问题,能保持对象的完整性。
  • 可实现高速分配

GC 复制算法不使用空闲链表。这是因为分块是一个连续的内存空间。比起 GC 标记 - 清除算法和引用计数法等使用空闲链表的分配,GC 复制算法明显快得多。

3、缺点

  • 复制这一工作所花费的时间,在对象存活率达到一定程度时,将会变的不可忽视

如果对象的存活率很高,我们可以极端一点,假设是100%存活,那么我们需要将所有对象都复制一遍,并将所有引用地址重置一遍。

  • 堆使用效率低下,会浪费 50% 的内存

GC 复制算法把堆二等分,通常只能利用其中的一半来安排对象。也就是说,只有一半 堆能被使用。相比其他能使用整个堆的 GC 算法而言,可以说这是 GC 复制算法的一个重大的缺陷。

通过搭配使用 GC 复制算法和 GC 标记 - 清除算法可以改善这个缺点

复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,我们必须要克服50%内存的浪费

3.5、三种垃圾回收算法对比

1、内存整齐度

复制算法 = 标记整理算法 > 标记清理算法

2、内存利用率

标记整理算法 = 复制算法 > 标记清理算法

3.6、分代垃圾回收

1、分代说明

堆内存是JAVA虚拟机所管理的内存最大的一块,Java堆被所有线程共享,几乎所有的对象实例都是在堆中分配内存,因此Java的堆是垃圾回收的主要区域

JVM的内存分代讲的就是堆内存的分代,为了更加高效的回收垃圾,将内存划分为了多个generation(代)。

JVM堆可以划分为新生代、老年代、永久代(JDK1.7),在JDK1.8中,永久代被元空间(Metaspace)所代替,并且元空间已经不在堆中了。

2、永久代和元数据的区别

永久代是 HotSpot 虚拟机特有的概念,并且在JDK1.8之后,永久代就彻底消失了。

永久代存储类信息、常量、静态变量、即时编译器编译后的代码等数据,并且永久代必须指定大小限制,因此就会导致性能问题和内存溢出的问题。永久代会给GC带来不必要的复杂性。

元空间的本质和永久代类似,但是元空间并不在堆中,而是直接使用了本地内存,元数据可以设置限制,也可以不设置,它的大小仅受本地内存限制。

3、新生代和老年代

新生代和老年代是垃圾回收最主要的区域。

一般将更有价值,需要长时间存活的对象放在老年代中,用完则丢弃的对象放在新生代中。

老年代的垃圾回收触发频率较低,新生代频繁触发垃圾回收

image-20210522131113329

新生代和老年代都在堆内存中,新生代和老年代所占的默认比例为1 : 2,其中新生代又由一个伊甸(Eden)区和两个幸存者(Survivor)区组成,三个区的默认比例为8:1:1。

  • 新生代

YoungGC 对应于新生代,第一次YGC只回收 eden 区域,回收后大多数(百分之九十八左右)的对象会被回收,活着的对象通过复制算法进入Survivor0(后续用S0和S1代替)。再次YGC后eden+S0中活着的对象进入S1。再次YGCeden+S1中活着的对象进入到S0。依次循环。

在将 eden 区与其中一个survivor区作为 From 区时,需要将另外一个survivor区作为 To 区,即保证 To 区为空。

  • 当一个对象的年龄(经历的YGC次数)足够时(传统的垃圾回收器一般是15,CMS垃圾回收器是6),进入老年代

  • 如果遇到一个对象S区装不下,则直接进入老年代。

  • 老年代

老年代的垃圾回收或称叫做 FullGC,当老年代空间不足时,就会触发 FullGC;另外,如果元空间区域的内存达到了所设定的阈值-XX:MetaspaceSize=,也会触发FullGC

FullGC 采用的是标记整理算法,这个算法的效率是比较低的,因为它要标记出或者的对象,然后移到内存的一侧,最后再清空区域外的内存。这个过程会十分消耗时间。

因此优化 JVM 最重要的一点就是优化 FullGC,尽可能的不要执行 FullGC

4、对象转移过程

  • 当我们创建一个新对象后,这个新对象会默认占用新生代中伊甸(Eden)区的一块空间
  • 当伊甸区空间不够时,此时会触发一次 Minor GC(Young GC),第一次 Minor GC 只针对伊甸区,并将第一块幸存者(survivor0)区作为复制算法的 To 区,然后将存活下来进入幸存区的对象的寿命 + 1。
  • 此时由于伊甸区已经被清空,所以后面新创建的对象可以继续存放在伊甸区中,在后面的复制算法中,会将伊甸区和上面的第一块幸存者(survivor0)区作为 From 区,然后将另外一块幸存者(survivor1)区作为 To 区,此时需要对幸存的对象的寿命 + 1
  • 新生代中的对象不会永远呆在新生代中,当新生代中对象的寿命超过一个阈值(15)时,这个对象会被转移到老年代。GC分代年龄存储在对象的 header 中
  • 如果遇到一个对象 Survivor 区装不下,则直接进入老年代。
  • 当新生代空间实在不足或老年代空间不足时,会使用 Full GC 对新生代与老年代进行一次力度较大的垃圾回收。
  • Minor GC 会引发 stop the world,即暂停其他用户线程,直到垃圾回收线程完成工作后继续运行其他用户线程,Minor GC 使用的时间较短
  • 对象寿命的阈值是15,超过这个阈值,新生代对象会被转移到老年代中,这是由于对象头中,寿命占 4 bit,而4 bit 最大的值即为15,不同垃圾回收器中寿命阈值不同。
  • Full GC 也会引发 stop the world,但占用的时间会更长。

5、相关 VM 参数

含义参数
堆初始大小-Xms
堆最大大小-Xmx-XX:MaxHeapSize=size
新生代大小-Xmn(-XX:NewSize=size + -XX:MaxNewSize=size)
幸存者比例(动态)-XX:InitialSurvivorRatio=ratio-XX:+UseAdaptiveSizePolicy
幸存者比例-XX:SurvivorRatio=ratio
晋升阈值-XX:MaxTenuringThreshold=threshold
晋升详情-XX:+PrintTenuringDistribution
GC 详情-XX:+PrintGCDetails -verbose:gc
Full GCMinor GC-XX:+ScavengeBeforeFullGC

6、分析以下参数和日志

在参数中,我们指定堆初始大小和堆最大大小为 20M,新生代大小为 10M ,所以老年代大小为 10M (20 - 10)

image-20210522143436418

  • 在截图中,显示了堆和元空间的数据

image-20210522143718997

在新生代中,内存空间被划分为3块,其中伊甸区占 4/5 ,其余两块幸存区各占 1/10 ,由于一块幸存者区要作为复制算法的 To 区,所以 10M 内存中有 1M 必须为空,故可用内存只有 9M.

total 为可用的内存大小,used 表示已经使用的大小

image-20210522144237324

从上面日志中可以看到新生代中内存划分和分配比例。

image-20210522144500450

上面日志打印了老年代的内存占用信息,由于堆初始大小、堆最大大小和新生代内存大小都已经被指定,所以老年代内存大小也被指定。

7、大对象直接晋升至老年代

对于无法放入新生代,但可以放入老年代的大对象来说,JVM 会直接将该对象放入老年代,不会触发 GC

  • 放置大对象前

image-20210522143436418

  • 往堆中直接塞一个 8M 的大对象,查看日志

image-20210522145752735

可以看到,大对象直接被塞进了老年代,同时没有触发 GC

  • 一个线程的OOM不会导致进程失效

3.7、垃圾回收器

1、串行(Serial)

  • 简介

Serial 是一类用于新生代单线程收集器,采用复制算法进行垃圾收集。Serial进行垃圾收集时,不仅只用一条单线程执行垃圾收集工作,它还在收集的同时,所用的用户必须暂停

适用于堆内存较小的个人电脑

image-20210522153952355

从上图可知当应用程序进行到一个安全的节点的时候,所有的线程全都暂停,等到GC完成后,应用程序线程继续执行。

  • 优点

简单高效,由于采用的是单线程的方法,因此与其他类型的收集器相比,对单个 cpu 来说没有了上下文之间的的切换,效率比较高。

  • 缺点

会在用户不知道的情况下停止所有工作线程,用户体验感极差,令人难以接受。

  • 开启命令

其中 Serial 工作在新生代,使用的算法是复制算法

SerialOld 工作在老年代,使用的是标记整理算法

1
-XX:+UserSerialGC = Serial + SerialOld

2、吞吐量优先

使用于多线程、堆内存较大、拥有多核 CPU 的环境(服务器)

  • 简介

Parallel Scavenge 是一款用于新生代的多线程收集器,采用复制算法。与 ParNew 的不同之处在于 Parallel Scavenge 收集器的目的是达到一个可控制的吞吐量,而 ParNew 收集器关注点在于尽可能的缩短垃圾收集时用户线程的停顿时间。

吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值, 即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。

image-20210522152437296

  • 优点

追求高吞吐量,高效利用CPU,是吞吐量优先,且能进行精确控制。

  • 开启命令

JDK 8 默认开启,ParallelOldGC 工作在老年代,使用 标记整理算法

1
-XX:+UseParallelGC ~ -XX:++UseParallelOldGC

3、响应时间优先

使用于多线程、堆内存较大、拥有多核 CPU 的环境(服务器)

  • 简介

ParNew 收集器其实就是Serial的一个多线程版本,其在单核 cpu 上的表现并不会比 Serail 收集器更好,在多核机器上,其默认开启的收集线程数与 cpu 数量相等。

image-20210522152620792

  • 优点

随着 cpu 的有效利用,对于GC时系统资源的有效利用有好处。

  • 缺点

会在用户不知道的情况下停止所有工作线程

3.8、垃圾回收器 – G1

1、定义

G1(Garbage First)垃圾收集器是当今垃圾回收技术最前沿的成果之一。早在JDK7就已加入JVM的收集器大家庭中,成为 HotSpot 重点发展的垃圾回收技术。同优秀的CMS垃圾回收器一样,G1也是关注最小时延的垃圾回收器。

G1最大的特点是引入分区的思路,弱化了分代的概念,合理利用垃圾收集各个周期的资源,解决了其他收集器甚至CMS的众多缺陷。

  • 2004 论文发布
  • 2009 JDK 6 体验
  • 2012 JDK 7 官方支持
  • 2017 JDK 9 默认

2、使用场景

  • 同时注重吞吐量和低延迟,默认的暂停目标是 200 ms

  • 适合超大堆内存,G1 会将堆划分为多个大小相等的 Region

  • 整体上是标记 + 整理算法,两个区域间是复制算法

3、相关 JVM 参数

  • 开启方式
1
-XX:+UseG1GC
  • 指定 G1 区域的大小
1
-XX:G1HeapRegionSize=size

4、G1 垃圾回收阶段

image-20210522192817910

G1 垃圾回收器的回收工作可以分为三个阶段,分别为

  • Young Collection :对新生代的垃圾进行收集
  • Young Collection + Concurrent Mark: 对新生代垃圾进行收集并添加并发标记
  • Mixed Collection :混合垃圾收集(对新生代、新生区、老年代都进行一次规模较大的垃圾收集)

以上三个过程循环进行

5、Young Collection

会产生 Stop The World ,阻塞其他用户线程

  • 新创建的对象放入伊甸(Eden)区中,在新生区的伊甸区被占满后,此时会触发一次 Young Collection

image-20210522193725471

  • Young Collection 会将伊甸区中的存活对象拷贝到幸存区中。

image-20210522194038070

  • 在幸存区空间不足或者幸存区对象寿命达到阈值后,此时幸存区中的一部分对象会被放入老年代,一部分会被当成垃圾回收,另一部分会放入其他的幸存区中。

image-20210522201635379

6、Young Collection + CM

  • Young GC 时会进行 GC Root 的初始标记
  • 老年待占用堆空间比例达到阈值时,进行并发标记(不会 STW ),由下面的 JVM 参数决定
1
-XX:InitiatingHeapOccupancyPercent=percent(默认为 45%)

image-20210522204057591

7、Mixed Collection

会对 E 、 S 、O 进行全部垃圾回收

  • 最终标记(Remark)会产生 STW
  • 拷贝存活(Evacuation)会产生 STW

image-20210522204500487

由于我们会指定一个 GC 最大暂停时间,在这个事件内,G1 可能无法对所有老年代垃圾进行回收,所以它会有选择地回收一部分老年代的垃圾(回收价值最大的垃圾)进行回收

优先回收垃圾最多的区域

8、Full GC 和 Minor(Young) GC

  • Serial GC

    • 新生代内存不足发生的垃圾收集 - Minor GC
    • 老年代内存不足发生的垃圾收集 - Full GC
  • Parallel GC

    • 新生代内存不足发生的垃圾收集 - Minor GC
    • 老年代内存不足发生的垃圾收集 - Full GC
  • CMS

    • 新生代内存不足发生的垃圾收集 - Minor GC
    • 老年代内存不足
  • G1

    • 新生代内存不足发生的垃圾收集 - Minor GC
    • 老年代内存不足

    在 G1 进行并发标记、混合收集(且垃圾清除的速度大于垃圾产生速度)时,不直接称为 Full GC,此时仍然处于并发垃圾收集的阶段;

    只有垃圾回收的速度更不上垃圾产生的速度时,这个时候并发收集失败,这个时候退化为一个串行(Serial)收集,此时称为 Full GC

9、JDK 8 字符串去重

  • 优点:节省大量内存
  • 缺点:略微多占用了 CPU 时间,新生代回收时间略微增加

开启参数

1
-XX:+UseStringDeduplication # 默认打开
1
2
String s1 = new String("hello"); // char[] {'h','e','l','l','o'}
String s2 = new String("hello"); // char[] {'h','e','l','l','o'}
  • 将所有新分配的字符串放入一个队列
  • 当新生代回收时,G1 并发检查是否有字符串重复
  • 如果它们值一样,让他们引用同一个 char[]
  • 注意,与 String.intern() 不一样
    • String.intern() 关注的是字符串对象
    • 字符串去重关注的是 char[]
    • JVM 内部,使用了不同的字符串表

10、JDK 8 并发标记类卸载

所有对象都经过并发标记后,就能直到哪些类不再被使用,当一个类加载器的所有类都不再使用后,则卸载它所加载的所有类

1
-XX:+ClassUnloadingWithConcurrentMark #默认启用

11、巨型对象

  • 一个对象大于 Region 的一半时,称为巨型对象。
  • G1 不会对巨型对象进行拷贝,回收时优先考虑巨型对象。
  • G1 会跟踪老年代所有 incoming 引用,这样老年代 incoming 引用为 0 的巨型对象就可以在新生代垃圾回收时被处理掉。