关于java:一篇文章彻底搞懂GC

6次阅读

共计 14622 个字符,预计需要花费 37 分钟才能阅读完成。

前言

Java 相较于其余编程语言更加容易学习,这其中很大一部分起因要归功于 JVM 的主动内存管理机制。对于从事 C 语言的开发者来说,他们领有每一个对象的「所有权」,更大的势力也意味着更多的职责,C 开发者须要保护每一个对象「从生到死」的过程,当对象废除不必时必须手动开释其内存,否则就会产生内存透露。而对于 Java 开发者来说,JVM 的主动内存管理机制解决了这个让人头疼的问题,不容易呈现内存透露和内存溢出的问题了,GC 让开发者更加专一于程序自身,而不必去关怀内存何时调配、何时回收、以及如何回收。


  1. JVM 运行时数据区

=============

在聊 GC 前,有必要先理解一下 JVM 的内存模型,晓得 JVM 是如何布局内存的,以及 GC 的次要作用区域。 如图所示,JVM 运行时会将内存划分为五大块区域,其中「办法区」和「堆」随着 JVM 的启动而创立,是所有线程共享的内存区域。虚拟机栈、本地办法栈、程序计数器则是随着线程的创立被创立,线程运行完结后也就被销毁了。

1.1 程序计数器

程序计数器 (Program Counter Register) 是一块十分小的内存空间,简直能够忽略不计。它能够看作是线程所执行字节码的行号指数器,指向以后线程下一条应该执行的指令。对于:条件分支、循环、跳转、异样等根底性能都依赖于程序计数器。

对于 CPU 的一个外围来说,任意时刻只能跑一个线程。如果线程的 CPU 工夫片用完就会被挂起,期待 OS 重新分配工夫片再继续执行,那线程如何晓得上次执行到哪里了呢?就是通过程序计数器来实现的,每个线程都须要保护一个公有的程序计数器。

如果线程在执行 Java 办法,计数器记录的是 JVM 字节码指令地址。如果执行的是 Native 办法,计数器值则为Undefined

程序计数器是惟一一个没有规定任何 OutOfMemoryError 状况的内存区域,意味着在该区域不可能产生 OOM 异样,GC 不会对该区域进行回收!

1.2 虚拟机栈

虚拟机栈 (Java Virtual Machine Stacks) 也是线程公有的,生命周期和线程雷同。

虚拟机栈形容的是 Java 办法执行的内存模型,JVM 要执行一个办法时,首先会创立一个栈帧 (Stack Frame) 用于寄存:局部变量表、操作数栈、动静链接、办法进口等信息。栈帧创立结束后开始入栈执行,办法执行完结后即出栈。

办法执行的过程就是一个个栈帧从入栈到出栈的过程。

局部变量表次要用来寄存编译器可知的各种根本数据类型、对象援用、returnAddress 类型。局部变量表所需的内存空间在编译时就曾经确认,运行期间不会批改局部变量表的大小。

在 JVM 标准中,虚拟机栈规定了两种异样:

  • StackOverflowError

线程申请的栈深度大于 JVM 所容许的栈深度。栈的容量是无限的,如果线程入栈的栈帧超过了限度就会抛出 StackOverflowError 异样,例如:办法递归。

  • OutOfMemoryError

虚拟机栈是能够动静扩大的,如果扩大时无奈申请到足够的内存,则会抛出 OOM 异样。

1.3. 本地办法栈

本地办法栈 (Native Method Stack) 也是线程公有的,与虚拟机栈的作用十分相似。区别是虚拟机栈是为执行 Java 办法服务的,而本地办法栈是为执行 Native 办法服务的。

与虚拟机栈一样,JVM 标准中对本地办法栈也规定了 StackOverflowError 和 OutOfMemoryError 两种异样。

1.4. Java 堆

Java 堆 (Java Heap) 是线程共享的,一般来说也是 JVM 治理最大的一块内存区域,同时也是垃圾收集器 GC 的次要治理区域。

Java 堆在 JVM 启动时创立,作用是:寄存对象实例。简直所有的对象都在堆中创立,然而随着 JIT 编译器的倒退和逃逸剖析技术逐步成熟,栈上调配、标量替换优化技术使得“所有对象都调配在堆上”不那么相对了。

因为是 GC 次要治理的区域,所以也被称为:GC 堆。为了 GC 的高效回收,Java 堆外部又做了如下划分:

JVM 标准中,堆在物理上能够是不间断的,只有逻辑上间断即可。通过 -Xms -Xmx 参数能够设置最小、最大堆内存。

1.5. 办法区

办法区 (Method Area) 与 Java 堆一样,也是线程共享的一块内存区域。它次要用来存储:被 JVM 加载的类信息,常量,动态变量,即时编译器产生的代码等数据。也被称为:非堆(Non-Heap),目标是与 Java 堆辨别开来。

JVM 标准对办法区的限度比拟宽松,JVM 甚至能够不对办法区进行垃圾回收。这就导致在老版本的 JDK 中,办法区也别称为:永恒代(PermGen)。

应用永恒代来实现办法区不是个好主见,容易导致内存溢出,于是从 JDK7 开始有了“去永恒代”口头,将本来放在永恒代中的字符串常量池移出。到 JDK8 中,正式去除永恒代,迎来元空间。


  1. GC 概述

========

垃圾收集(Garbage Collection)简称为「GC」,它的历史远比 Java 语言自身长远,在 1960 年诞生于麻省理工学院的 Lisp 是第一门开始应用内存动态分配和垃圾收集技术的语言。

要想实现主动垃圾回收,首先须要思考三件事件: 后面介绍了 JVM 的五大内存区域,程序计数器占用内存极少,简直能够忽略不计,而且永远不会内存溢出,GC 不须要对其进行回收。虚拟机栈、本地办法栈随线程“同生共死”,栈中的栈帧随着办法的运行井井有条的入栈、出栈,每个栈帧调配多少内存在编译期就曾经根本确定,因而这两块区域内存的调配和回收都具备确定性,不太须要思考如何回收的问题。

办法区就不一样了,一个接口到底有多少个实现类?每个类占用的内存是多少?你甚至能够在运行时动静的创立类,因而 GC 须要针对办法区进行回收。

Java 堆也是如此,堆中寄存着简直所有的 Java 对象实例,一个类到底会创立多少个对象实例,只有在程序运行时才晓得,这部分内存的调配和回收是动静的,GC 须要重点关注。

2.1 哪些对象须要回收

实现主动垃圾回收的第一步,就是判断到底哪些对象是能够被回收的。一般来说有两种形式:援用计数算法和可达性剖析算法,商用 JVM 简直采纳的都是后者。

2.1.1 援用计数算法

在对象中增加一个援用计数器,每援用一次计数器就加 1,每勾销一次援用计数器就减 1,当计数器为 0 时示意对象不再被援用,此时就能够将对象回收了。

援用计数算法 (Reference Counting) 尽管占用了一些额定的内存空间,然而它原理简略,也很高效,在大多数状况下是一个不错的实现计划,然而它存在一个重大的弊病:无奈解决循环援用

例如一个链表,按理只有没有援用指向链表,链表就应该被回收,然而很遗憾,因为链表中所有的元素援用计数器都不为 0,因而无奈被回收,造成内存透露。

2.1.2 可达性剖析算法

目前支流的商用 JVM 都是通过可达性剖析来判断对象是否能够被回收的。 这个算法的基本思路是:

通过一系列被称为「GC Roots」的根对象作为起始节点集,从这些节点开始,通过援用关系向下搜查,搜查走过的门路称为「援用链」,如果某个对象到 GC Roots 没有任何援用链相连,就阐明该对象不可达,即能够被回收。

对象可达指的就是:单方存在间接或间接的援用关系。根可达或 GC Roots 可达就是指:对象到 GC Roots 存在间接或间接的援用关系。

能够作为 GC Roots 的对象有以下几类: 可达性剖析就是 JVM 首先枚举根节点,找到一些为了保障程序能失常运行所必须要存活的对象,而后以这些对象为根,依据援用关系开始向下搜查,存在间接或间接援用链的对象就存活,不存在援用链的对象就回收。

对于可达性剖析的详细描述,能够看笔者的文章:《大白话了解可达性剖析算法》。

2.2 何时回收

JVM 将内存划分为五大块区域,不同的 GC 会针对不同的区域进行垃圾回收,GC 类型个别有以下几大类:

  • Minor GC

也被称为“Young GC”、“轻 GC”,只针对新生代进行的垃圾回收。

  • Major GC

也被称为“Old GC”,只针对老年代进行的垃圾回收。

  • Mixed GC

混合 GC,针对新生代和局部老年代进行垃圾回收,局部垃圾收集器才反对。

  • Full GC

整堆 GC、重 GC,针对整个 Java 堆和办法区进行的垃圾回收,耗时最久的 GC。

什么时候触发 GC,以及触发什么类型的 GC 呢?不同的垃圾收集器实现不一样,你还能够通过设置参数来影响 JVM 的决策。

一般来说,新生代会在 Eden 区用尽后才会触发 GC,而 Old 区却不能这样,因为有的并发收集器在清理过程中,用户线程能够持续运行,这意味着程序依然在创建对象、分配内存,这就须要老年代进行「空间调配担保」,新生代放不下的对象会被放入老年代,如果老年代的回收速度比对象的创立速度慢,就会导致「调配担保失败」,这时 JVM 不得不触发 Full GC,以此来获取更多的可用内存。

2.3 如何回收

定位到须要回收的对象当前,就要开始进行回收了。如何回收对象又成了一个问题。什么样的回收形式会更加的高效呢?回收后是否须要对内存进行压缩整顿,防止碎片化呢?针对这些问题,GC 的回收算法大抵分为以下三类:

  1. 标记 - 革除算法
  2. 标记 - 复制算法
  3. 标记 - 整顿算法

具体算法的回收细节,上面会介绍到。


  1. GC 回收算法

==========

JVM 将堆划分成不同的代,不同的代中寄存的对象特点不一样,针对不同的代应用不同的 GC 回收算法进行回收能够晋升 GC 的效率。

3.1 分代收集实践

目前大多数 JVM 的垃圾收集器都遵循“分代收集”实践,分代收集实践建设在三个假说之上。

3.1.1 弱分代假说

绝大多数对象都是朝生夕死的。

想想看咱们写的程序是不是这样,绝大多数时候,咱们创立一个对象,只是为了进行一些业务计算,失去计算结果后这个对象也就没什么用了,即能够被回收了。再例如:客户端要求返回一个列表数据,服务端从数据库查问后转换成 JSON 响应给前端后,这个列表的数据就能够被回收了。诸如此类,都能够被称为「朝生夕死」的对象。

3.1.2 强分代假说

熬过越屡次 GC 的对象就越难以回收。

这个假说齐全是基于概率学统计来的,经验过屡次 GC 都无奈被回收的对象,能够假设它下次 GC 时依然无奈被回收,因而就没必要高频率的对其进行回收,将其挪到老年代,缩小回收的频率,让 GC 去回收效益更高的新生代。

3.1.3 跨代援用假说

跨代援用绝对于同代援用是极少的。

这是依据前两条假说逻辑推理得出的隐含推论:存在相互援用关系的两个对象,应该偏向于同时生存或者同时沦亡的。举个例子,如果某个新生代对象存在跨代援用,因为老年代对象难以沦亡,该援用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后降职到老年代中,这时跨代援用也随即被打消了。

3.2 解决跨代援用

跨代援用尽管极少,然而它还是可能存在的。如果为了极少的跨代援用而去扫描整个老年代,那每次 GC 的开销就太大了,GC 的暂停工夫会变得难以承受。如果疏忽跨代援用,会导致新生代的对象被谬误的回收,导致程序谬误。

3.2.1 Remembered Set

JVM 是通过记忆集(Remembered Set)来解决的,通过在新生代建设记忆集的数据结构,来防止回收新生代时把整个老年代也加进 GC Roots 的扫描范畴,缩小 GC 的开销。

记忆集是一种由「非收集区域」指向「收集区域」的指针汇合的形象数据结构,说白了就是把「年老代中被老年代援用的对象」给标记起来。记忆集能够有以下三种记录精度:

  1. 字长精度:记录准确到一个机器字长,也就是处理器的寻址位数。
  2. 对象精度:准确到对象,对象的字段是否存在跨代援用指针。
  3. 卡精度:准确到一块内存区域,该区域内的对象是否存在跨代援用。

字长精度和对象精度太精细化了,须要破费大量的内存来保护记忆集,因而许多 JVM 都是采纳的「卡精度」,也被称作:“卡表”(Card Table)。卡表是记忆集的一种实现,也是目前最罕用的一种模式,它定义了记忆集的记录精度、与对内存的映射关系等。

HotSpot 应用一个字节数组来实现卡表,它将堆空间划分成一系列 2 次幂大小的内存区域,这个内存区域就被称作「卡页」(Card Page),卡页的大小个别都是 2 的幂次方数,HotSpot 采纳 2 的 9 次幂,即 512 字节。字节数组的每一个元素都对应着一个卡页,如果某个卡页内的对象存在跨代援用,JVM 就会将这个卡页标记为「Dirty」脏的,GC 时只须要扫描脏页对应的内存区域即可,防止扫描整个堆。

卡表的构造如下图所示:

3.2.2 写屏障

卡表只是用来标记哪一块内存区域存在跨代援用的数据结构,JVM 如何来保护卡表呢?什么时候将卡页变脏呢?

HotSpot 是通过「写屏障」(Write Barrier)来保护卡表的,JVM 拦挡了「对象属性赋值」这个动作,相似于 AOP 的切面编程,JVM 能够在对象属性赋值前后染指解决,赋值前的解决叫作「写前屏障」,赋值后的解决叫作「写后屏障」,伪代码如下:

void setField(Object o){before();// 写前屏障
    this.field = o;
    after();// 写后屏障}
复制代码

开启写屏障后,JVM 会为所有的赋值操作生成相应的指令,一旦呈现老年代对象的援用指向了年老代的对象,HotSpot 就会将对应的卡表元素置为脏的。

请将这里的「写屏障」和并发编程中内存指令重排序的「写屏障」辨别开,防止混同。

除了写屏障自身的开销外,卡表在高并发场景下还面临着「伪共享」的问题,古代 CPU 的缓存零碎是以「缓存行」(Cache Line)为单位存储的,Intel 的 CPU 缓存行的大小个别是 64 字节,多线程批改相互独立的变量时,如果这些变量在同一个缓存行中,就会导致彼此的缓存行无端生效,线程不得不频繁发动 load 指令从新加载数据,而导致性能升高。

一个 Cache Line 是 64 字节,每个卡页是 512 字节,64✖️512 字节就是 32KB,如果不同的线程更新的对象处在这 32KB 之内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了防止这个问题,HotSpot 反对只有当元素未被标记时,才将其置为脏的,这样会减少一次判断,然而能够防止伪共享的问题,设置 -XX:+UseCondCardMark 来开启这个判断。

3.3 标记革除

标记革除算法分为两个过程:标记、革除。

收集器首先标记须要被回收的对象,标记实现后对立革除。也能够标记存活对象,而后对立革除没有被标记的对象,这取决于内存中存活对象和死亡对象的占比。

毛病:

  1. 执行效率不稳固

标记和革除的工夫耗费随着 Java 堆中的对象一直减少而减少。2. 内存碎片 标记革除后内存会产生大量不间断的空间碎片,不利于后续持续为新生对象分配内存。

3.4 标记复制

为了解决标记革除算法产生的内存碎片问题,标记复制算法进行了改良。

标记复制算法会将内存划分为两块区域,每次只应用其中一块,垃圾回收时首先进行标记,标记实现后将存活的对象复制到另一块区域,而后将以后区域全副清理。

毛病是:如果大量对象无奈被回收,会产生大量的内存复制开销。可用内存放大为一半,内存节约也比拟大。 因为绝大多数对象都会在第一次 GC 时被回收,须要被复制的往往是极少数对象,那么就齐全没必要依照 1:1 去划分空间。HotSpot 虚拟机默认 Eden 区和 Survivor 区的大小比例是 8:1,即 Eden 区 80%,From Survivor 区 10%,To Survivor 区 10%,整个新生代可用内存为 Eden 区 + 一个 Survivor 区即 90%,另一个 Survivor 区 10% 用于分区复制。

如果 Minor GC 后仍存活大量对象,超出了一个 Survivor 区的范畴,那么就会进行调配担保(Handle Promotion),将对象间接调配进老年代。

3.5 标记整顿

标记复制算法除了在对象大量存活时须要进行较多的复制操作外,还须要额定的内存空间老年代来进行调配担保,所以在老年代中个别不采纳这种回收算法。

可能在老年代中存活的对象,个别都是历经屡次 GC 后仍无奈被回收的对象,基于“强分代假说”,老年代中的对象个别很难被回收。针对老年代对象的生存特色,引入了标记整顿算法。

标记整顿算法的标记过程与标记革除算法统一,然而标记整顿算法不会像标记革除算法一样间接清理标记的对象,而是将存活的对象都向内存区域的一端挪动,而后间接清理掉边界外的内存空间。 标记整顿算法相较于标记革除算法,最大的区别是:须要挪动存活的对象。GC 时挪动存活的对象既有长处,也有毛病。

毛病 基于“强分代假说”,大部分状况下老年代 GC 后会存活大量对象,挪动这些对象须要更新所有 reference 援用地址,这是一项开销极大的操作,而且该操作须要暂停所有用户线程,即程序此时会阻塞进展,JVM 称这种进展为:Stop The World(STW)。

长处 挪动对象对内存空间进行整顿后,不会产生大量不间断的内存碎片,利于后续为对象分配内存。

由此可见,不论是否挪动对象都有利弊。挪动则内存回收时负责、内存调配时简略,不挪动则内存回收时简略、内存调配时简单。从整个程序的吞吐量来思考,挪动对象显然更划算一些,因为内存调配的频率比内存回收的频率要高的多的多。

还有一种解决形式是:平时不挪动对象,采纳标记革除算法,当内存碎片影响到大对象调配时,才启用标记整顿算法。


  1. 垃圾收集器

=========

依照《Java 虚拟机标准》实现的 JVM 就举不胜举,且每个 JVM 平台都有 N 个垃圾收集器供用户抉择,这些不是一篇文章能够说的分明的。当然,开发者也没必要理解所有的垃圾收集器,以 Hotspot JVM 为例,支流的垃圾收集器次要有以下几大类:串行 :单线程收集,用户线程暂停。 并行 :多线程收集,用户线程暂停。 并发:用户线程和 GC 线程同时运行。

后面曾经说过,大多数 JVM 的垃圾收集器都遵循“分代收集”实践,不同的垃圾收集器回收的内存区域会有所不同,大多数状况下,JVM 须要两个垃圾收集器配合应用,下图有虚线连贯的代表两个收集器能够配合应用。

4.1 新生代收集器

4.1.1 Serial

最根底,最早的垃圾收集器,采纳标记复制算法,仅开启一个线程实现垃圾回收,回收时会暂停所有用户线程 (STW)。 应用-XX:+UseSerialGC 参数开启 Serial 收集器,因为是单线程回收,因而 Serial 的利用范畴很受限制:

  1. 应用程序很轻量,堆空间不到百 MB。
  2. 服务器 CPU 资源缓和。

4.1.2 Parallel Scavenge

应用标记复制算法,多线程的新生代收集器。 应用参数 -XX:+UseParallelGC 开启,ParallelGC 的特点是十分关注零碎的吞吐量,它提供了两个参数来由用户控制系统的吞吐量:-XX:MaxGCPauseMillis:设置垃圾回收最大的进展工夫,它必须是一个大于 0 的整数,ParallelGC 会朝着这个指标去致力,如果这个值设置的过小,ParallelGC 就不肯定能保障了。如果用户心愿 GC 进展的工夫很短,ParallelGC 就会尝试减小堆空间,因为回收一个较小的堆必定比回收一个较大的堆耗时短嘛,然而这样会更频繁的触发 GC,从而升高零碎的吞吐量。

-XX:GCTimeRatio:设置吞吐量的大小,它的值是一个 0~100 的整数。假如 GCTimeRatio 为 n,那么 ParallelGC 将破费不超过 1/(1+n) 的工夫进行垃圾回收,默认值为 19,意味着 ParallelGC 用于垃圾回收的工夫不会超过 5%。

ParallelGC 是 JDK8 的默认垃圾收集器,它是一款吞吐量优先的垃圾收集器,用户能够通过 -XX:MaxGCPauseMillis-XX:GCTimeRatio来设置 GC 最大的进展工夫和吞吐量。但这两个参数是互相矛盾的,更小的进展工夫就意味着 GC 须要更频繁进行回收,从而减少 GC 回收的整体工夫,导致吞吐量降落。

4.1.3 ParNew

ParNew 也是一个应用标记复制算法,多线程的新生代垃圾收集器。它的回收策略、算法、及参数都和 Serial 一样,只是简略的将单线程改为多线程而已,它的诞生只是为了配合 CMS 收集器应用而存在的。CMS是老年代的收集器,然而 Parallel Scavenge 不能配合 CMS 一起工作,Serial 是串行回收的,效率又太低了,因而 ParNew 就诞生了。

应用参数 -XX:+UseParNewGC 开启,不过这个参数曾经在 JDK9 之后的版本中删除了,因为 JDK9 默认 G1 收集器,CMS 曾经被取代,而 ParNew 就是为了配合 CMS 而诞生的,CMS 废除了,ParNew 也就没有存在价值了。

4.2 老年代收集器

4.2.1 Serial Old

应用标记整顿算法,和 Serial 一样,单线程独占式的针对老年代的垃圾收集器。老年代的空间通常比新生代要大,而且标记整顿算法在回收过程中须要挪动对象来防止内存碎片化,因而老年代的回收要比新生代更耗时一些。

Serial Old 作为最早的老年代垃圾收集器,还有一个劣势,就是它能够和绝大多数新生代垃圾收集器配合应用,同时它还能够作为 CMS 并发失败的备用收集器。

应用参数 -XX:+UseSerialGC 开启,新生代老年代都将应用串行收集器。和 Serial 一样,除非你的利用十分轻量,或者 CPU 的资源非常缓和,否则都不倡议应用该收集器。

4.2.2 Parallel Old

ParallelOldGC 是一款针对老年代,多线程并行的独占式垃圾收集器,和 Parallel Scavenge 一样,属于吞吐量优先的收集器,Parallel Old 的诞生就是为了配合 Parallel Scavenge 应用的。

ParallelOldGC 应用的是标记整顿算法,应用参数 -XX:+UseParallelOldGC 开启,参数 -XX:ParallelGCThreads=n 能够设置垃圾收集时开启的线程数量,同时它也是 JDK8 默认的老年代收集器。

4.2.3 CMS

CMS(Concurrent Mark Sweep)是一款里程碑式的垃圾收集器,为什么这么说呢?因为在它之前,GC 线程和用户线程是无奈同时工作的,即便是 Parallel Scavenge,也不过是 GC 时开启多个线程并行回收而已,GC 的整个过程仍然要暂停用户线程,即 Stop The World。这带来的结果就是 Java 程序运行一段时间就会卡顿一会,升高利用的响应速度,这对于运行在服务端的程序是不能被接管的。

GC 时为什么要暂停用户线程? 首先,如果不暂停用户线程,就意味着期间会一直有垃圾产生,永远也清理不洁净。其次,用户线程的运行必然会导致对象的援用关系产生扭转,这就会导致两种状况:漏标和错标。

  1. 漏标

本来不是垃圾,然而 GC 的过程中,用户线程将其援用关系批改,导致 GC Roots 不可达,成为了垃圾。这种状况还好一点,无非就是产生了一些浮动垃圾,下次 GC 再清理就好了。2. 错标 本来是垃圾,然而 GC 的过程中,用户线程将援用从新指向了它,这时如果 GC 一旦将其回收,将会导致程序运行谬误。

为了实现并发收集,CMS 的实现比后面介绍的几种垃圾收集器都要简单的多,整个 GC 过程能够大略分为以下四个阶段:1、初始标记 初始标记仅仅只是标记一下 GC Roots 能间接关联到的对象,速度很快。初始标记的过程是须要触发 STW 的,不过这个过程十分快,而且初试标记的耗时不会因为堆空间的变大而变慢,是可控的,因而能够疏忽这个过程导致的短暂进展。

2、并发标记 并发标记就是将初始标记的对象进行深度遍历,以这些对象为根,遍历整个对象图,这个过程耗时较长,而且标记的工夫会随着堆空间的变大而变长。不过好在这个过程是不会触发 STW 的,用户线程依然能够工作,程序仍然能够响应,只是程序的性能会受到一点影响。因为 GC 线程会占用肯定的 CPU 和系统资源,对处理器比拟敏感。CMS 默认开启的 GC 线程数是:(CPU 外围数 +3)/4,当 CPU 外围数超过 4 个时,GC 线程会占用不到 25% 的 CPU 资源,如果 CPU 数有余 4 个,GC 线程对程序的影响就会十分大,导致程序的性能大幅升高。

3、从新标记 因为并发标记时,用户线程仍在运行,这意味着并发标记期间,用户线程有可能扭转了对象间的援用关系,可能会产生两种状况:一种是本来不能被回收的对象,当初能够被回收了,另一种是本来能够被回收的对象,当初不能被回收了。针对这两种状况,CMS 须要暂停用户线程,进行一次从新标记。

4、并发清理 从新标记实现后,就能够并发清理了。这个过程耗时也比拟长,且清理的开销会随着堆空间的变大而变大。不过好在这个过程也是不须要 STW 的,用户线程仍然能够失常运行,程序不会卡顿,不过和并发标记一样,清理时 GC 线程仍然要占用肯定的 CPU 和系统资源,会导致程序的性能升高。

CMS 开拓了并发收集的先河,让用户线程和 GC 线程同时工作成为了可能,然而毛病也很显著:1、对处理器敏感 并发标记、并发清理阶段,尽管 CMS 不会触发 STW,然而标记和清理须要 GC 线程染指解决,GC 线程会占用肯定的 CPU 资源,进而导致程序的性能降落,程序响应速度变慢。CPU 外围数多的话还略微好一点,CPU 资源缓和的状况下,GC 线程对程序的性能影响十分大。

2、浮动垃圾 并发清理阶段,因为用户线程仍在运行,在此期间用户线程制作的垃圾就被称为“浮动垃圾”,浮动垃圾本次 GC 无奈清理,只能留到下次 GC 时再清理。

3、并发失败 因为浮动垃圾的存在,因而 CMS 必须预留一部分空间来装载这些新产生的垃圾。CMS 不能像 Serial Old 收集器那样,等到 Old 区填满了再来清理。在 JDK5 时,CMS 会在老年代应用了 68% 的空间时激活,预留了 32% 的空间来装载浮动垃圾,这是一个比拟偏激进的配置。如果理论援用中,老年代增长的不是太快,能够通过-XX:CMSInitiatingOccupancyFraction 参数适当调高这个值。到了 JDK6,触发的阈值就被晋升至 92%,只预留了 8% 的空间来装载浮动垃圾。如果 CMS 预留的内存无奈包容浮动垃圾,那么就会导致「并发失败」,这时 JVM 不得不触发准备计划,启用 Serial Old 收集器来回收 Old 区,这时进展工夫就变得更长了。

4、内存碎片 因为 CMS 采纳的是「标记革除」算法,这就象征这清理实现后会在堆中产生大量的内存碎片。内存碎片过多会带来很多麻烦,其一就是很难为大对象分配内存。导致的结果就是:堆空间明明还有很多,但就是找不到一块间断的内存区域为大对象分配内存,而不得不触发一次 Full GC,这样 GC 的进展工夫又会变得更长。针对这种状况,CMS 提供了一种备选计划,通过-XX:CMSFullGCsBeforeCompaction 参数设置,当 CMS 因为内存碎片导致触发了 N 次 Full GC 后,下次进入 Full GC 前先整顿内存碎片,不过这个参数在 JDK9 被弃用了。

4.2.3.1 三色标记算法

介绍完 CMS 垃圾收集器后,咱们有必要理解一下,为什么 CMS 的 GC 线程能够和用户线程一起工作。

JVM 判断对象是否能够被回收,绝大多数采纳的都是「可达性剖析」算法,对于这个算法,能够查看笔者以前的文章:大白话了解可达性剖析算法。

从 GC Roots 开始遍历,可达的就是存活,不可达的就回收。

CMS 将对象标记为三种色彩: 标记的过程大抵如下:

  1. 刚开始,所有的对象都是红色,没有被拜访。
  2. 将 GC Roots 间接关联的对象置为灰色。
  3. 遍历灰色对象的所有援用,灰色对象自身置为彩色,援用置为灰色。
  4. 反复步骤 3,直到没有灰色对象为止。
  5. 完结时,彩色对象存活,红色对象回收。

这个过程正确执行的前提是没有其余线程扭转对象间的援用关系,然而,并发标记的过程中,用户线程仍在运行,因而就会产生漏标和错标的状况。

漏标 假如 GC 曾经在遍历对象 B 了,而此时用户线程执行了A.B=null 的操作,切断了 A 到 B 的援用。 原本执行了 A.B=null 之后,B、D、E 都能够被回收了,然而因为 B 曾经变为灰色,它仍会被当做存活对象,持续遍历上来。最终的后果就是本轮 GC 不会回收 B、D、E,留到下次 GC 时回收,也算是浮动垃圾的一部分。

实际上,这个问题仍然能够通过「写屏障」来解决,只有在 A 写 B 的时候退出写屏障,记录下 B 被切断的记录,从新标记时能够再把他们标为红色即可。

错标 假如 GC 线程曾经遍历到 B 了,此时用户线程执行了以下操作:

B.D=null;// B 到 D 的援用被切断
A.xx=D;// A 到 D 的援用被建设
复制代码

B 到 D 的援用被切断,且 A 到 D 的援用被建设。此时 GC 线程持续工作,因为 B 不再援用 D 了,只管 A 又援用了 D,然而因为 A 曾经标记为彩色,GC 不会再遍历 A 了,所以 D 会被标记为红色,最初被当做垃圾回收。能够看到错标的后果比漏表重大的多,浮动垃圾能够下次 GC 清理,而把不该回收的对象回收掉,将会造成程序运行谬误。

错标只有在满足上面两种状况下才会产生:

  1. 灰色指向红色的援用全副断开。
  2. 彩色指向红色的援用被建设。

只有突破任一条件,就能够解决错标的问题。

原始快照和增量更新 原始快照突破的是第一个条件:当灰色对象指向红色对象的援用被断开时,就将这条援用关系记录下来。当扫描完结后,再以这些灰色对象为根,从新扫描一次。相当于无论援用关系是否删除,都会依照刚开始扫描时那一瞬间的对象图快照来扫描。

增量更新突破的是第二个条件:当彩色指向红色的援用被建设时,就将这个新的援用关系记录下来,等扫描完结后,再以这些记录中的彩色对象为根,从新扫描一次。相当于彩色对象一旦建设了指向红色对象的援用,就会变为灰色对象。

CMS 采纳的计划就是:写屏障 + 增量更新来实现的,突破的是第二个条件。

当彩色指向红色的援用被建设时,通过写屏障来记录援用关系,等扫描完结后,再以援用关系里的彩色对象为根从新扫描一次即可。

伪代码大抵如下:

class A{
    private D d;

    public void setD(D d) {writeBarrier(d);// 插入一条写屏障
        this.d = d;
    }

    private void writeBarrier(D d){// 将 A -> D 的援用关系记录下来,后续从新扫描}
}
复制代码

4.3 混合收集器

4.3.1 G1

G1 的全称是「Garbage First」垃圾优先的收集器,JDK7 正式应用,JDK9 默认应用,它的呈现是为了代替 CMS 收集器。

既然要代替 CMS,那么毫无疑问,G1 也是并发并行的垃圾收集器,用户线程和 GC 线程能够同时工作,关注的也是利用的响应工夫。

G1 最大的一个变动就是,它只是逻辑分代,物理构造上曾经不分代了。它将整个 Java 堆划分成多个大小不等的 Region,每个 Region 能够依据须要表演 Eden 区、Survivor 区、或者是老年代空间,G1 能够对表演不同角色的 Region 采纳不同的策略去解决。

G1 之前的所有垃圾收集器,回收的范畴要么是整个新生代(Minor GC)、要么是整个老年代(Major GC)、再就是整个 Java 堆(Full GC)。而 G1 跳出了这个樊笼,它能够面向堆内任何局部来组成回收集(Collection Set,简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是判断哪个 Region 垃圾最多,抉择回收价值最高的 Region 回收,这也是「Garbage First」名称的由来。

尽管 G1 依然保留了分代的概念,然而新生代和老年代不再是固定不变的两块间断的内存区域了,它们都是由一系列 Region 组成的,而且每次 GC 时,新生代和老年代的空间大小会动静调整。G1 之所以能管制 GC 的进展工夫,建设可预测的进展工夫模型,就是因为它将 Region 作为单次回收的最小单元,每次回收的内存空间都是 Region 大小的整数倍,这样就能够防止在整个 Java 堆内进行全区域的垃圾收集。

G1 会跟踪每个 Region 的垃圾数量,计算每个 Region 的回收价值,在后盾保护一个优先级列表,而后依据用户设置的容许 GC 进展的工夫来优先回收“垃圾最多”的 Region,这样就保障了 G1 可能在无限的工夫内回收尽可能多的可用内存。

G1 的整个回收周期大略能够分为以下几个阶段:

  1. Eden 区内存耗尽,触发新生代 GC 开始回收 Eden 区和 Survivor 区。新生代 GC 后,Eden 区会被清空,Survivor 区至多会保留一个,其余的对象要么被清理,要么被降职到老年代。这个过程中,新生代的大小可能会被调整。
  2. 并发标记周期 2.1 初始标记 :仅标记 GC Roots 间接关联的对象,会随同一次新生代 GC,且会导致 STW。2.2 根区域扫描 :初始标记时触发的新生代 GC 会将 Eden 区清空,存活对象会挪动到 Survivor 区,这时就须要扫描由 Survivor 区间接可达的老年代区域,并标记这些对象,这个过程能够并发执行。2.3 并发标记 :和 CMS 相似会扫描并查找整个堆内存活的对象并标记,不会触发 STW。2.4 从新标记 :触发 STW,修改并发标记期间因为用户线程继续执行而导致对象间的援用被扭转。2.5 独占清理 :触发 STW,计算各个 Region 的回收价值,对 Region 进行排序,辨认可供混合回收的区域。2.6 并发清理:辨认并清理齐全闲暇的 Region,不会造成进展。
  3. 混合回收:并发标记周期中的并发清理阶段,G1 尽管也回收了局部空间,然而比例还是相当低的。然而在这之后,G1 曾经明确晓得各个 Region 的回收价值了。在混合回收阶段 G1 会优先回收垃圾最多的 Region,这些 Region 既蕴含了新生代,也蕴含了老年代,故称之为“混合回收”。被清理的 Region 内的存活对象会被挪动到其余 Region,这也防止了内存碎片。

和 CMS 一样,因为并发回收时用户线程依然在运行,即分配内存,因而如果回收速度跟不上内存调配的速度,G1 也会在必要的时候触发一个 Full GC 来获取更多的可用内存。

应用参数 -XX:+UseG1GC 来开启 G1 收集器,-XX:MaxGCPauseMillis来设置指标最大进展工夫,G1 会朝着这个指标去致力,如果 GC 进展工夫超过了指标工夫,G1 就会尝试调整新生代和老年代的比例、堆大小、降职年龄等一系列参数来希图达到预设指标。-XX:ParallelGCThreads用来设置并行回收时 GC 的线程数量,-XX:InitiatingHeapOccupancyPercent用来指定整个 Java 堆的使用率达到多少时触发并发标记周期的执行,默认值是 45。

4.3.2 面向未来的 ZGC

ZGC 是在 JDK11 才退出的具备实现性质的低提早垃圾收集器,它的指标是心愿在尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都能够把 GC 的进展工夫管制在十毫秒以内。

ZGC 面向的是超大堆,最大反对 4TB 的堆空间,它和 G1 一样,也是采纳 Region 的内存布局模式。

ZGC 最大的一个特点就是它采纳着色指针 Colored Pointer 技术来标记对象。以往,如果 JVM 须要在对象上存储一些额定的、只供 GC 或 JVM 自身应用的数据时(如 GC 年龄、偏差线程 ID、哈希码),通常会在对象的对象头上减少额定的字段来记录。ZGC 就厉害了,间接把标记信息记录在对象的援用指针上。

Colored Pointer是什么?为什么对象援用的指针自身也能够存储数据呢?在 64 位零碎中,实践上能够拜访的内存大小为 2 的 64 次幂字节,即 16EB。然而实际上,目前远远用不到这么大的内存,因而基于性能和老本的思考,CPU 和操作系统都会施加本人的束缚。例如 AMD64 架构只反对 54 位(4PB)的地址总线,Linux 只反对 46 位(64TB)的物理地址总线,Windows 只反对 44 位(16TB)的物理地址总线。

在 Linux 零碎下,高 18 位不能用来寻址,残余的 46 位能反对最大 64TB 的内存大小。事实上,64TB 的内存大小在目前来说也远远超出了服务器的须要。于是 ZGC 就盯上了这剩下的 46 位指针宽度,将其高 4 位提取进去存储四个标记信息。通过这些标记位,JVM 能够间接从指针中看到其援用对象的三色标记状态、是否进入了重调配集(即被挪动过)、是否只能通过 finalize()办法能力被拜访到。这就导致 JVM 能利用的物理地址总线只剩下 42 位了,即 ZGC 能治理的最大内存空间为 2 的 42 次幂字节,即 4TB。 目前 ZGC 还处于试验阶段,能查到的材料也不多,笔者当前再整顿更新吧。

  1. 读懂 GC 日志

==========

待写 ……

  1. GC 的调优

=========

待写 ……

参考:《2020 最新 Java 根底精讲视频教程和学习路线!》

链接:https://juejin.cn/post/692408…

正文完
 0