优化内存利用:深入了解垃圾回收算法与回收器(二)

简介: 优化内存利用:深入了解垃圾回收算法与回收器(二)

Parallel Scavenge 收集器

Parallel Scavenge 作为一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也能够并行收集的多线程收集器

Parallel Scavenge 收集器通常会用来与 ParNew 收集器作比较,CMS 老年代收集器是选用的 ParNew 作为它的年轻代收集器,主要在于它们的关注点不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的关注点是达到一个可控制的吞吐量(Throughput)

所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:

用户代码运行时间 / (用户代码运行时间 + 垃圾收集运行时间)

若虚拟机完成某项任务,用户代码+垃圾收集总共耗时了 100 分钟,其中垃圾收集花掉了 1 分钟,那么吞吐量就是 99%

停顿时间越短,越适合需要与用户交互或需要保证服务响应质量高的程序,良好的响应速度能够提升用户的体验度

吞吐量越高,则可以很高效率的利用服务器的资源,尽快完成程序的运算任务,主要适合在后台进行运算而不需要太多交互的分析任务`

Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间:-XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小:-XX:GCTimeRatio 参数

  1. -XX:MaxGCPauseMillis:允许值是一个大于 0 的毫秒数,收集器将尽量保证内存回收花费的时间不超过用户设定的值

内存回收花费时间缩短是以牺牲吞吐量、新生代空间为代价换取的,系统把新生代大小调整的少一点,收集 300MB 新生代肯定比收集 500MB 新生代速度要快,但这也直接会导致垃圾收集的发生过于频繁,原来 10 秒收集一次,停顿 100 毫秒,现在变成 5 秒收集一次,停顿 70 毫秒;停顿时间的确在下降,但吞吐量也下降了

  1. -XX:GCTimeRatio:值应当是一个大于 0 小于 100 的整数,也就是垃圾收集时间占总时间的比率

若设置这个参数设为 N,表示用户代码执行时间与总执行时间之比为 N:N+1;譬如将此参数设置为 19,那允许的最大垃圾收集时间就占总时间的 5%(即:1 / (1+19) )默认值为 99,即允许最大 1%(1 / (1+99) )的垃圾收集时间

由于该收集器与吞吐量密切相关,Parallel Scavenge 收集器也经常被称作为 “吞吐量优先收集器”;除了上述两个参数之外,还提供了一个参数:-XX:+UseAdaptiveSizePolicy

该参数是一个开关参数,默认是开启的,当参数开启之后,就不需要人工指定新生代的大小(-Xmn)、Eden 与 Survivor 区比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了;虚拟机会根据当前系统的运行情况收集性能的监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并行收集,基于标记-整理算法实现

Parallel Old 收集器直到 JDK 6 才开始提供,在此之前,新生代 Parallel Scavenge 收集器,老年代除了 Serial Old 收集器别无选择,其他表现良好的老年代收集器无法与其配合工作,如:CMS

由于 Serial Old 收集器采用单线程无法充分利用多核服务器的并行处理能力,故 Parallel Scavenge 收集器未必能在整体上获得吞吐量的最大化;直到 Parallel Old 收集器出现,“吞吐量优先” 才有了名副其实的搭配组合,在注重吞吐量或处理器资源较为稀缺的场合下,都可以优先考虑 PS+PO

CMS 收集器

CMS(Concurrent Mark Sweep)是一款以获取最短回收停顿时间为目标的收集器,它非常适用于要求服务响应速度,希望停顿时间尽可能短,以给用户带来良好的交互体验

CMS:Mark Sweep,它是基于标记-清除算法实现的,开启并发回收的第一款垃圾收集器 > 里程碑,它出现的目的是因为无法忍受前面收集器在开启垃圾回收时 > 所有用户线程都不可用以及 STW 时长过长

CMS 工作过程相比前面几种收集器会复杂一些,整个过程分为几个阶段,如下:

  1. CMS initial Mark:初始标记,会发生 STW,仅仅是标记一下 GC Roots 能直接关联到的对象,速度很快
  2. CMS Concurrent Mark:并发标记,从 GC Roots 直接关联对象开始遍历整个对象图的过程,该过程虽然过长但不会暂停用户线程,可以与垃圾收集线程一起并发运行
  3. CMS Remark:重新标记,会发生 STW,为了修正并发标记阶段,因用户线程继续运行的情况下而导致引用产生变化的那一部分记录,该阶段停顿时间稍微比初始化阶段长一些,但也远比并发标记阶段花费的时间短
  4. CMS Concurrent Sweep:并发清除,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以该阶段也是可以与用户线程同时并发的

CMS 是一款优秀的收集器 > 并发收集、低停顿,它是 HotSpot 虚拟机中追加低停顿的第一次成功尝试,但它还远远达不到完美的程度,它存在一些明显的缺点,如下:

  1. 资源占用问题,在执行并发处理阶段,它虽然不会造成用户线程停顿,但却因此会占用一部分线程 > 处理器的计算能力,而导致应用程序变慢,降低总吞吐量;CMS 默认启动的回收线程数(处理器核心数量 + 3)/ 4,也就是说,若处理器核心数在 4 个或以上,并发回收时垃圾收集线程只占用不少于 25% 的处理器运算资源
  2. 浮动垃圾问题,无法处理每次回收阶段时产生的 “浮动垃圾”,有可能出现 “Concurrent Mode Failure” 失败而导致另一次完全 STW Full GC 的产生;在 CMS 并发标记、并发清理阶段,用户线程还是在继续运行的,用户程序在运行必然就会有新的垃圾对象不断产生,但这部分垃圾对象的产生是出现在标记过程结束过后的,CMS 也就无法在当次收集中处理掉它们,只能留在下一次垃圾收集时再行清理,这一部分就称之为 “浮动垃圾”

由于在垃圾收集阶段用户线程要持续运行,那就还得预留足够的内存空间提供给用户线程使用,因此 CMS 收集器不能像其他收集器那样等待到老年代几乎完全填满了再进行垃圾收集,必须预留一部分内存空间供并发收集时的用户线程运行时使用

在 JDK5 默认设置下,CMS 收集器当老年代使用了 68% 空间就会被开启收集,该值设置得过于保守,若在实际工作中,对象增长的速度不过于快,可以适当调高:-XX:CMSInitiatingOccupancyFraction 参数值来提高 CMS 触发的百分比,降低内存回收的频率,以获取更好的性能;在 JDK6 时,CMS 收集器启动阈值就默认提升到了 92%,这也就会容易引发出另外一种风险 > 要是 CMS 运行期间预留的内存无法满足程序分配新对象的需求,就会出现一次 “Concurrent Mode Failure”,此时虚拟机就不得不开启后备预案:冻结用户线程的运行,临时启用 Serial Old 单线程收集器来重新进行老年代的垃圾收集,从而停顿时间就会变得很长了!



综合以上所述,-XX:CMSInitiatingOccupancyFraction 参数值大小的调整应当结合实际项目的情况,Trade Off 权衡利弊之下进行调整

  1. 内存碎片问题,CMS 基于标记-清除算法实现的收集器,也就意味着收集结束时会产生大量的空间碎片,空间碎片过多时,在分配大对象时会带来很大的麻烦,往往会出现在老年代明明还有很多剩余空间,但就是无法找到有足够大的连续空间分配给当前的大对象,从而不得不提前触发一次 Full GC

CMS 为了解决内存碎片过多产生的问题,提供了:-XX:UseCMSCompactAtFullCollection 参数(默认是开启的),用于在 CMS 收集器不得不进行 Full GC 时开启内存碎片的合并整理,由于该内存整理必须移动存活对象,所以是无法并发的,虽然内存碎片问题加以解决,但停顿时间随即变长了


基于以上停顿时间变长问题,CMS 提供了:-XX:CMSFullGCsBeforeCompaction 参数(默认值为 0,代表每次进入 Full GC 时都会进行碎片整理),其作用是要求 CMS 收集器在执行若干次(数量由参数值指定)不整理空间的 Full GC 之后,下一次进行 Full GC 前会先进行碎片整理


以上两个参数,在 JDK9 版本中,开始废弃

Garbage First 收集器

Garbage First,简称 G1 收集器,它是一款面向服务端应用的垃圾收集器,它开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式

从 JDK9 版本开始,G1 宣告取代 JDK8 PS+PO 收集器组合,成为服务端模式下的默认垃圾收集器,而 CMS 则沦落为不推荐使用的收集器,在 JDK9 版本及以上开启 CMS 收集器的话,会提示 CMS 将会在未来被废弃

作为 CMS 收集器的替代者、继承人,设计者们希望作出一款能够建立起 “停顿时间模型“(Pause Prediction Model)收集器

停顿时间模型:能够支持指定在一个长度为 M 毫秒的时间片内,消耗在垃圾收集上的时间大概率尽可能保证不超过 N 毫秒这样的目标 > 通过 -XX:MaxGCPauseMillis 参数指定,默认值为 200 毫秒


该参数若设置的过于低,G1 根据进行垃圾收集时过于倾向该时间,那么回收的 Region 区域过小,从而导致垃圾收集速度逐渐跟不上分配对象内存的速度,导致垃圾慢慢堆积,最终造成堆占满引发 Full GC 反而降低性能,所以通常该参数一般会使用默认的或上下可再微调 100 毫秒

在 G1 设计初衷,思想上就有了巨大的改变,在 G1 收集器出现之前的其他收集器,包括 CMS 在内,垃圾收集的目标要么是整个新生代(Minor GC)要么是整个老年代(Major GC)要么就是整个堆(Full GC)而 G1 跳出了这个囚笼,它可以面向堆内存任何部分来组成回收集(Collection Set,简称 CSet)进行回收,衡量回收的标准不再是看它属于哪个代,而是考量那块的内存中存放的垃圾最多,回收收益内存最大,这就是 G1 收集器中的 Mixed GC 模式,也就是 “Grabage First” 名字的由来

G1 开创的基于 Region 堆内存布局是它能够实现该目标的关键,虽然 G1 仍然是遵循分代收集理论进行设计的,但其堆内存布局与其他收集器有非常明显的差异:G1 不再坚持固定内存大小以及固定数量的分代区域划分,而是把连续的 Java 堆内存划分为多个大小相等的独立区域(Region)


每一个 Region 都可以根据其需要,扮演新生代的 Eden 区、Survivor 区或者老年代;收集器能够对扮演不同角色的 Region 采用不同的策略去处理,如此而来,无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果


Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象;G1 认为只要大小超过了一个 Region 50% 容量的对象;每个 Region 大小可以通过参数:-XX:G1HeapRegionSize 设定,取值范围为 2~32 MB,且应为 2 的 N 次幂;对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 区域中,作为老年代的一部分来进行看待

在 G1 中仍然保留新生代、老年代的概念(逻辑分代,物理不分代),但新生代、老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合;G1 收集器之所以能建立可预测的停顿时间模型,是因为它将 Region 作为单位回收的最小单元,即每次收集到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个堆中进行全区域的垃圾收集工作

任何一款垃圾收集器出来都必然会有更大的优化空间,G1 收集器至少有一些关键的细节问题需要妥善解决,如下:

  1. 在前面 「垃圾回收算法 > 分代收集 > 跨代引用假说」 有提到新生代对象会引用老年代对象,导致新年代的这部分对象会一直无法回收,最终会膨胀进入到老年代;在 G1 中,同样会存在跨 Region 的引用对象,解决思路:使用记忆集避免全堆作为 GC Roots 扫描,但在 G1 收集器上记忆集应用要复杂很多,它的每个 Region 都维护自己的记忆集,这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内

以前的垃圾收集器为了解决跨代引用问题,建立了名为记忆集(Remembered Set)数据结构,用于避免再回收新生代时把整个老年代加进 GC Roots 扫描范围中


记忆集其实只是一种 “抽象” 数据结构,只单独定义了记忆集的行为意图,并没有定义其行为的具体实现,而卡表就是记忆集的一种具体实现,卡表定义了记忆集中的记录精度、与堆内存之间的映射关系;它们之间的关系就类比于接口 > Map、实现类 > HashMap 关系


HotSpot 将卡表比作是一个字节数组,卡表数组中每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作为 “卡页”(Card Page)在一个卡页内存中通常包含不止一个对象,只要卡页中存在一个或多说对象的字段存在跨代指针引用,那就将对应卡表中该卡页的元素标识为 1(Dirty)没有则标识为 0,随即在垃圾收集时,只需要将这些标识为 Dirty 卡页元素,就能知道哪些卡页内存块中包含跨代指针,将它们一并加入到 GC Roots 中一并扫描


G1 记忆集在存储结构的本质上是一种哈希表,Key > Region 起始地址,Value > 多个卡表索引号集合;这种 “双向” 卡表结构(上面说的卡表是单向的,这种结构还记录了谁指向我)比原来的卡表实现起来更复杂,同时由于 Region 数量比传统收集器的分代数量明显要多得多,因此也就说明了为什么 G1 收集器比其他的传统垃圾收集器要更高的内存占用

  1. 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?首先要解决的是用户线程在改变对象引用关系时,必须保证其不能打破原本对象的图结构,导致标记结果出现错误(多标、漏标)

CMS 收集器中采用的增量更新(Incremental Update)算法实现的

G1 收集器则是通过原始快照(Snapshot At The Beginning > SATB)算法实现的


这两种算法是如何实现以及其原理图在下篇文章详细拆解

  1. 用户通过 -XX:MaxGCPauseMillis 参数指定的停顿时间只意味着在垃圾收集发生之前的期望值,G1 收集器的停顿预测模型是以 “衰减均值”(Decaying Average)作为理论基础去实现的,在垃圾收集过程中,G1 收集器会记录每个 Region 回收耗时、每个 Region 记忆集里的脏卡数量等各个可测量的步骤花费成本,并分析得出平均值、标准偏差、置信度等统计信息

通过对这些信息进行预测,开始回收的话,由哪些 Region 组成回收集才可以不超过期望停顿时间的约束下获取最短的回收时长

G1 收集器的运作过程大致分为几个阶段,如下:

  1. Initial Marking:初始标记,仅仅只是标记一下 GC Roots 能直接关联到的对象,该阶段需要 STW,但耗时很短,而且是借用在进行 Minor GC 的时刻同步完成的,所以 G1 收集器在该阶段实际上并没有额外的开销
  2. Concurrent Marking:并发标记,从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户线程并发执行,无须 STW;当对象图扫描完成以后,还需要重新处理 SATB 记录下在并发时有引用变动的对象
  3. Final Marking:最终标记,对用户线程做另一个短暂的暂停,该阶段需要 STW,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录
  4. Live Data Counting and Evacuation:筛选回收,负责更新 Region 统计数据,对各个 Region 回收价值、成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 还剩下的存活对象复制到空的 Region 中,再清理掉整个旧 Region 全部空间

在这里的操作涉及到存活对象的移动,是必须暂停用户线程,由多条收集线程 并行完成

从以上四阶段来看,G1 收集器除了并发标记以外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹追求低延迟,它目标在于延迟可控的情况下获取尽可能高的吞吐量,所以才能担当起 “全功能收集器” 重任与期望

It meets garbage collection pause time goals with a high probability,while achieving high throughput

最后,CMS、G1 这两款垃圾收集器在我们目前的工作中是比较常用的(JDK8),通过以下对比 CMS、G1 这两垃圾收集器的区别及优劣,来着重选用哪款垃圾收集器

  1. CMS 使用标记-清除算法实现垃圾收集,G1 从整体来看是基于标记-整理算法实现的垃圾收集,因为它可用的内存空间是连续的,但从局部(两个 Region 之间)上看又是标记-复制算法实现;这两者意味着 G1 不会产生内存碎片,在为分配大对象时有卓越的优势
  2. 使用 G1 作为垃圾收集,它为了垃圾收集能够保持低延迟、高吞吐特征,产生的内存占用还是程序运行时的额外执行负载都要比 CMS 要高,在机器内存不足以支撑时,只能取舍选用 CMS 作为我们的首选垃圾收集器了

内存占用:G1、CMS 都使用卡表(Card Table)来处理跨代指针


G1 卡表实现更为复杂,而且堆中每个 Region,无论是扮演新生代还是老年代角色,都必须有一份卡表,这导致记忆集以及其他内存消耗可能会占整个堆容量的 20% 乃至更多的内存空间


相比之下,CMS 卡表比较简单,只有唯一一份,而且只需要处理老年代到新年代的引用,反过来则不需要,因为 在所有垃圾收集器中,只有 CMS 是专门只针对老年代进行回收,也就是 Major GC

  1. CMS、G1 两者都有并发标记这个阶段,导致了它们两者在使用用户线程运行时负载会有所不同

1、写屏障使用方式不同

两者都使用到了写屏障,CMS 用写后屏障来更新及维护卡表;G1 除了使用写后屏障来进行同样的卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况


2、处理标记产生的多标、漏标问题方式不同

CMS 采用增量更新算法解决,G1 采用原始快照算法解决

低延迟垃圾收集器

Shenandoah、ZGC 收集器,几乎整个过程全部都是并发的,只有初始标记、最终标记阶段会有短暂暂停,这部分停顿时间基本上是固定的(堆容量足够下,实现垃圾收集停顿时间都不会超过 10 毫秒),与堆容量、堆中对象数量没有正比例关系,这两款在 JDK11 后续版本有被开始使用,被官方命名为 “低延迟垃圾收集器”

在这里,只是浅谈还有这两款低延迟垃圾收集器,由于在工作中暂未接触到这两款收集器,故不作过多介绍

并行、并发

在介绍以上不同的收集器时,有经常提到并行回收、并发回收等词;并行(Parallel)、并发(Concurrent)在并发编程中是很专业的名词

并发编程

1、并行:多个工作任务在同一时刻在同一个 CPU 上交替执行

2、并发:多个工作任务在同一时刻分散给机器中多个 CPU 同时执行

但在垃圾收集器上下文中,它们所对应的语义又有所不同, 如下:

并行: 描述的是多条垃圾收集线程之间的关系,说明在同一时间又多条这样的收集线程在协同工作,而此时用户线程是处于阻塞/等待状态的

并发: 描述的是垃圾收集线程与用户线程之间的关系,说明在同一时间垃圾收集线程与用户线程都在运行;由于用户线程未处于阻塞/等待状态,所以应用程序仍然能够响应用户请求,但由于垃圾收集线程占用了一部分的系统资源,此时应用程序处理的吞吐量必然会受到一定的影响

总结

该篇博文是从周志明教授编写的 《深入理解 Java 虚拟机》巨著里面的内容结合自身的一些理解,整理出来的一些内容,垃圾收集算法是内存回收的方法论:分代收集理论(弱分代学说、强分代学说、跨代引用学说)标记-清除算法、标记-复制算法、标记-整理算法这三种算法之间的优劣势;垃圾收集器就是内存回收的实践者,从 Serial 系列收集器到 Parallel 系列收集器,为 CMS 老年代收集器搭配而生的 ParNew 新生代收集器,最重要莫过于 CMS、G1 这两种并发收集器了,里程碑式意义的存在;在最后,介绍了在垃圾收集领域内,并行、并发的区别,希望书中的精髓以及自身的理解能够帮助到您!

参考文献:《深入理解 Java 虚拟机》周志明著

博文放在 JVM 专栏里,欢迎订阅,会持续更新!

如果觉得博文不错,关注我 vnjohn,后续会有更多实战、源码、架构干货分享!

推荐专栏:Spring、MySQL,订阅一波不再迷路

大家的「关注❤️ + 点赞👍 + 收藏⭐」就是我创作的最大动力!谢谢大家的支持,我们下文见!

目录
相关文章
|
22天前
|
存储 缓存 监控
|
11天前
|
存储 JavaScript 前端开发
JavaScript垃圾回收机制与优化
【10月更文挑战第21】JavaScript垃圾回收机制与优化
21 5
|
10天前
|
存储 JavaScript 前端开发
如何优化代码以避免闭包引起的内存泄露
本文介绍了闭包引起内存泄露的原因,并提供了几种优化代码的策略,帮助开发者有效避免内存泄露问题,提升应用性能。
|
11天前
|
并行计算 算法 IDE
【灵码助力Cuda算法分析】分析共享内存的矩阵乘法优化
本文介绍了如何利用通义灵码在Visual Studio 2022中对基于CUDA的共享内存矩阵乘法优化代码进行深入分析。文章从整体程序结构入手,逐步深入到线程调度、矩阵分块、循环展开等关键细节,最后通过带入具体值的方式进一步解析复杂循环逻辑,展示了通义灵码在辅助理解和优化CUDA编程中的强大功能。
|
19天前
|
算法 Java 程序员
内存回收
【10月更文挑战第9天】
38 5
|
19天前
|
存储 监控 算法
Java中的内存管理与垃圾回收机制解析
本文深入探讨了Java编程语言中的内存管理方式,特别是垃圾回收机制。我们将了解Java的自动内存管理是如何工作的,它如何帮助开发者避免常见的内存泄漏问题。通过分析不同垃圾回收算法(如标记-清除、复制和标记-整理)以及JVM如何选择合适的垃圾回收策略,本文旨在帮助Java开发者更好地理解和优化应用程序的性能。
|
20天前
|
存储 弹性计算 算法
前端大模型应用笔记(四):如何在资源受限例如1核和1G内存的端侧或ECS上运行一个合适的向量存储库及如何优化
本文探讨了在资源受限的嵌入式设备(如1核处理器和1GB内存)上实现高效向量存储和检索的方法,旨在支持端侧大模型应用。文章分析了Annoy、HNSWLib、NMSLib、FLANN、VP-Trees和Lshbox等向量存储库的特点与适用场景,推荐Annoy作为多数情况下的首选方案,并提出了数据预处理、索引优化、查询优化等策略以提升性能。通过这些方法,即使在资源受限的环境中也能实现高效的向量检索。
|
3月前
|
存储 编译器 C语言
【C语言篇】数据在内存中的存储(超详细)
浮点数就采⽤下⾯的规则表⽰,即指数E的真实值加上127(或1023),再将有效数字M去掉整数部分的1。
328 0
|
11天前
|
存储 C语言
数据在内存中的存储方式
本文介绍了计算机中整数和浮点数的存储方式,包括整数的原码、反码、补码,以及浮点数的IEEE754标准存储格式。同时,探讨了大小端字节序的概念及其判断方法,通过实例代码展示了这些概念的实际应用。
21 1
|
15天前
|
存储
共用体在内存中如何存储数据
共用体(Union)在内存中为所有成员分配同一段内存空间,大小等于最大成员所需的空间。这意味着所有成员共享同一块内存,但同一时间只能存储其中一个成员的数据,无法同时保存多个成员的值。