垃圾收集器


垃圾收集器(Garbage Collector, GC)是Java虚拟机(JVM)中的一个关键组件,它自动地管理和释放不再使用的对象所占用的内存空间。这样可以防止内存泄漏,并简化了开发者的编程任务。以下是几种常见的垃圾收集器:
在这里插入图片描述

Serial

Serial收集器是最基本、历史最悠久的收集器,在JDK1.3之前是虚拟机新生代收集的唯一选择。这是一个单线程的收集器,只使用一个CPU、一条收集线程去完成垃圾收集工作,而且在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。

STW:Stop The World,GC进行时停顿所有Java执行线程。

运行过程:
在这里插入图片描述
优点:简单高效。
在Client模式下,Serial收集器是很好的选择。

ParNew

ParNew收集器是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其他行为包括Serial收集器可用的所有控制参数(如-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样,甚至这两种收集器也共用了很多代码。
运行过程:
在这里插入图片描述
在Server模式下,ParNew收集器是首选的新生代收集器。
在JDK1.5中使用CMS来收集老年代的时候,新生代只能选择ParNew或Serial。
使用-XX:+UseConcMarkSweepGC选项后,默认新生代收集器是ParNew。
可以使用-XX:+UseParNewGC来强制指定它。
单核环境中,使用ParNew的效果不如Serial好,甚至双核环境中ParNew也未必100%超越Serial。但是随着CPU数量的增加,它对GC时系统资源的利用会更优。
默认情况下,它开启的收集线程数与CPU数量相同,在CPU非常多的情况下,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

和垃圾收集器相关的两个概念:

  • 并行(Parallel):多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。STW
  • 并发(Concurrent):用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上。

Parallel Scavenge

这是一个新生代收集器,它使用复制算法。
Parallel Scavenge收集器关注的目标是达到一个可控制的吞吐量(Throughput)。

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

STW停顿时间越短就越适合用户交互程序,良好的响应速度能提升用户体验;
而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运行任务,更适合在后台运算而不需要太多交互的任务。
运行过程:
在这里插入图片描述
Parallel Scavenge提供了两个参数用于精确控制吞吐量,分别是:

  • -XX:MaxGCPauseMills:控制最大垃圾收集停顿时间。这是一个大于0的毫秒数,收集器将尽可能地保证内存回收花费的时间不超过设定值。注意,该值并非越小越好,减少这个值,只是使得垃圾收集速度变得更快,单次GC停顿时间缩短是以缩小新生代空间换来的,同时这也意味着吞吐量下降了。
  • -XX:GCTimeRatio:吞吐量的大小。这是大于0且小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。

由于该收集器与吞吐量关系密切,所以也经常被称为吞吐量优先收集器。
Parallel Scavenge收集器还有一个重要参数-XX:UseAdaptiveSizePolicy,打开该配置后,就不需要手动指定新生代大小 (-Xmn)、Eden和Survivor的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等参数,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量。这种调节方式称为GC自适应的调节策略。

Serial Old

Serial Old是Serial收集器的老年代版本,它是单线程收集器,使用标记-压缩算法。
这个收集器的主要意义在于给Client模式下的虚拟机使用。
Server模式下两大用途:

  • 在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用
  • 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。
    在这里插入图片描述

Parallel Old

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和标记-压缩算法。JDK1.6开始提供的。
运行过程:
在这里插入图片描述

CMS

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它是HotSpot虚拟机中第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作,是具有划时代意义的垃圾收集器。它基于标记-清除算法实现的,运作过程分为四步:

  • 初始标记(initial mark):标记一下GC Roots能直接关联到的对象,速度很快
  • 并发标记(concurrent mark):GC Roots Tracing的过程
  • 重新标记(remark):修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个停顿时间一般会比初始标记阶段稍长,但远比并发标记时间短。
  • 并发清除(concurrent sweep):

运行过程:
在这里插入图片描述

CMS是一款优秀的收集器,主要优点是并发收集、低停顿。但它也不完美,至少有3个明显的缺点:

  • 对CPU资源非常敏感:在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。默认回收线程数是(CPU数量+3)/4,也就是4个以上CPU时,并发回收时垃圾收集线程不少于25%的CPU资源。当CPU不足4个时,CMS对用户程序的影响就可能很大。

  • 浮动垃圾(Floating Garbage)问题:由于CMS并发清理阶段用户线程还在运行,伴随程序运行自然还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理。这一部分垃圾就称为“浮动垃圾”。
    由于垃圾收集阶段用户线程还在运行,那就需要预留足够的内存空间给用户线程使用,因此需要预留一部分空间提供并发收集时的程序运作使用。参数-XX:CMSInitiatingOccupancyFraction用于指定老年代空间使用多少比例后触发并发标记清除(CMS)垃圾回收器。

    • JDK1.5,该参数值为68%
    • JDK1.6,该参数值提高至92%

    提高触发百分比,可以降低内存回收次数从而获取更好的性能。但是,如果CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了,性能也会随之降低。

  • 空间碎片问题:CMS是基于标记-清除算法实现的收集器,收集结束时会有大量空间碎片产生。空间碎片过多时,会给大对象分配带来大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不触发一次Full GC。
    参数-XX:UseCMSCompactAtFullCollection用于在CMS收集器顶不住要进行FullGC时开启内存碎片的合并压缩过程,默认开启。这样空间碎片问题没有了,但停顿时间也变长了。
    参数-XX:CMSFullGCsBeforeCompaction用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,表示每次Full GC都进行碎片压缩)。

G1

G1(Garbage-First)收集器是当今收集器技术发展的最前沿成果之一,它是一款面向服务端应用的垃圾收集器。特别适用于具有多核处理器和大内存的机器。G1在JDK 7u4版本中正式推出,并且在JDK 9中成为默认的垃圾收集器。它的主要目标是在满足高吞吐量的同时,尽可能缩短垃圾收集造成的停顿时间。
它的特点如下:

  • 并行与并发:G1能充分利用多核CPU的硬件优势,使用多个CPU来缩短STW(Stop The World)的停顿时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式继续执行。
  • 分区域收集:G1将整个堆内存划分为多个大小相等的独立区域(Region),这些区域在逻辑上是连续的,但在物理内存上可能不是连续的。每个Region都可以作为Eden、Survivor或Old区等。这种设计使得G1更加灵活。G1取消了物理分代,保留了逻辑分代。
  • 优先回收垃圾最多区域:G1通过跟踪每个Region中的垃圾堆积情况,并根据回收价值和成本进行排序,优先回收垃圾最多的Region。这有助于最大限度地提高垃圾收集的效率。
  • 空间整合:从整体看,G1是基于标记-压缩算法实现的收集器,从局部(两个Region之间)上来看是基于复制算法实现的。这意味着G1运行期间不会产生内存空间碎片,收集后能够提供规整的可用内存。
  • 可预测的停顿:降低停顿时间是G1和CMS共同的关注点。G1能建立可预测的停顿时间模型,让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器特征了。这一特性使得G1非常适合需要严格控制停顿时间的应用场景。

G1区域划分

分区主要用于分配对象,分区大小的影响:

  • 分区过小:内存管理器可能不断地向OS请求新的分区,从而导致分配效率下降,也可能因为分配导致分区碎片化严重
  • 分区过大:分配效率可能比较高,但存活对象过多而导致回收时效率低下。

G1的解决方法:预先设计不同的分区大小和分区个数,然后根据堆空间大小来计算一个相对合适的值。JVM中分区大小可以是六值:1MB、2MB、4MB、8MB、16MB、32MB。分区大小可以通过参数HeapRegionSize指定;默认情况下,整个堆空间分为2048个分区。没有设置相关参数时,通过计算得到分区大小和分区个数。

  • 根据堆空间大小和默认的分区数计算得到一个分区大小: R e g i o n S i z e = M a x H e a p S i z e + I n i t i a l H e a p S i z e 2 ∗ 2048 RegionSize = \dfrac{MaxHeapSize + InitialHeapSize}{2 * 2048} RegionSize=22048MaxHeapSize+InitialHeapSize
    • RegionSize:分区大小
    • MaxHeapSize:设置的堆空间值
    • InitialHeapSize:设置的初始堆空间值(通常是最小堆空间值)
    • 2048:默认的分区个数
  • RegionSize取整:当RegionSize位于[1MB, 32MB]之间时,则对其向上取整,确保其落在集合六值中
    • 当RegionSize>32MB时,设置RegionSize=32MB,重新计算分区个数
    • 当RegionSize<1MB时,设置RegionSize=1MB,重新计算分区个数
    • 对于需要重新计算分区个数的两种情况,计算公式为: R e g i o n N u m = M a x H e a p S i z e + I n i t i a l H e a p S i z e R e g i o n S i z e RegionNum = \dfrac{MaxHeapSize + InitialHeapSize}{RegionSize} RegionNum=RegionSizeMaxHeapSize+InitialHeapSize

内存模型

传统分区GC的内存

在这里插入图片描述

G1的内存

在这里插入图片描述
Humongous:用于存放大对象的区域。G1中大对象的默认定义是大小超过TLAB的最大值的对象。而TLAB的最大值定义为超过分区大小的一半,即 H e a p R e g i o n S i z e 2 \dfrac{HeapRegionSize}{2} 2HeapRegionSize。大对象的定义仅仅与分区大小相关。

分区管理的影响

分区管理会对内存的使用效率产生影响。分区大小固定,对象分配使用的空间都从这个分区中分配。当一个分区中剩余空间不足以满足下一个对象所需要的空间时,如何处理?通常有两种方法:

  • 跨区分配:利用剩余的空间,让对象跨越两个分区。即对象的前半部分在第一个分区,后半部分在第二个分区中。这样可以提高内存使用效率,减少内存碎片,但是会带来管理上的复杂度。根本原因是多个分区之间并不连续,在一些场景中会根据对象的起始地址访问整个对象。按照这种设计,就需要在对象的访问中不断地检查是否跨越了分区。
  • 启用新分区:直接放弃分区中剩余的空间,新分配一个分区供对象使用,保证分区的起始地址总是指向一个有效的对象,不用处理对象跨越分区的情况。好处是处理对象分配简单,对象访问也简单。问题是会有一定的空间浪费。特殊情况:对象非常大(简称大对象),超过一个分区的大小时,按照这种设计就无法分配。解决方法:当大对象占多个分区时,要求占用的多个分区的内存必须连续,但当连续空间比较大时,会对内存使用造成压力(可能因为内存碎片化没有连续的大空间供大对象分配使用)。

不同的JVM产品会选择不同的实现。JVM使用第二种方法,OpenJ9使用第一种。OpenJ9的Balanced GC中对大对象数组采用了不同的设计方式,即数组对象可能使用不连续的地址来保存对象。
使用不连续的分配方式可能对Java应用的运行造成一定的问题,主要原因是程序在使用时,可能通过大对象头获得对象的地址,并根据这个地址访问对象的成员变量。如果地址不连续,那么通过地址访问连续空间就会发生内存访问异常问题。例如,JNI中有一些API可以返回对象的地址,本地函数中可以直接操作内存。而JVM中对应大对象使用的连续空间则不会出现这样的问题。

分代下的分区管理

从应用视角来说,看到两个代:新生代和老生代。新生代用于响应应用的内存分配请求,老生代用于新生代回收后长期存活对象的晋升。应用不知道内存如何组织的,只需要按需使用内存,只要内存空间没有耗尽,都可以正常地分配内存。应用通过对象地址访问一块连续的内存空间,看起来就像是内存是连续的。
从JVM内部来看,内存被划分成分区,并且分区被映射到新生代或者老生代。
在这里插入图片描述

回收机制

G1对新生代和老生代采用不同的回收算法进行回收。

  • 新生代:复制算法。根据新生代的特性,这个很容易想到。
    G1采用并行复制算法,其基本思想与ParNew、Parallel Scavenger基本相同。但由于分区设计,又略有不同:
    • G1的新生代分区仅仅包含Eden和Survivor的From空间。在复制算法执行的时候直接从Free空间获取分区,这样可以极大地提高内存利用率。但是新生代回收时,可能无法从Free空间中获得分区,这将导致新生代回收失败。如果失败,将升级为Full GC。参数G1ReservePercent用于配置预留内存区域的百分比大小,范围是[0-50],默认是10,用于在Free空间中保留一部分分区,在新生代回收时使用。
    • 引用集处理:G1中引用关系保存在被引用者所在的分区,所以只需要处理被引用者分区,多个线程即可并行地处理新生代中的每一个分区对应的引用集。
  • 老生代:增量回收。根据G1的设计理念,期望应用运行时以固定时长执行垃圾回收,这就意味着垃圾回收产生的停顿时间比较稳定。而老生代分区中的活跃对象比较多,如果要高效地回收,一个可能的方法就是增量回收,而且每次都回收垃圾比较多的分区。在回收过程中活跃对象越少,复制对象的成本就越低。

G1收集器的运行步骤如下:

  • 初始标记(Initial Marking):
  • 并发标记(Concurrent Marking):
  • 最终标记(Final Marking):
  • 筛选回收(Live Data Counting and Evacuation):
    运行过程:
    在这里插入图片描述
    G1除了对新生代进行回收,还可以在进行Minor GC时增量回收部分老生代分区,也叫混合回收(Mixed GC)。这种方法本质上是把两种类型的回收合并为一种。它减少了多种回收交互带来的复杂性,但是也给保证停顿时间这一目标带来了不确定性。

Minor GC和Mixed GC

Minor GC和Mixed GC采用的是同一复制回收算法,两者唯一的区别是回收集(Collection Set,简称CSet)不同。

  • Minor GC:回收新生代
  • Mixed GC:回收新生代和部分老生代

G1基于分区管理,有两个不同:

  • 内存不再连续,分配和回收都以分区为单位
  • 在对象分配时由于分区存在明确的边界,因此分配时需要考虑边界对齐

G1中,大对象不从Eden分配,直接分配在老生代空间中。因为大对象存在的成员变量比较多,引用关系也多。所以Minor GC和Mixed GC尽量不回收大对象,除非大对象死亡。

NUMA-Aware支持

JDK14中,G1提供NUMA特性的支持。它的特性:

  • 为每个Node(节点)绑定一个分区,该分区用于处理应用的对象分配请求
  • 对大对象特殊对待,尽量按照内存均衡的原则在不同的Node上分配内存,防止某一个Node上的应用过多的分配大对象将本地内存耗尽。
  • 在垃圾回收执行的过程中,优先保证复制前后的分区位于同一个Node上,这样就保证在垃圾回收结束后,应用访问的内存刚好位于本地Node上。

云场景支持

JVM在启动时会预分配内存,并且在运行时扩展内存直到用户定义的堆空间,当没有服务请求时,JVM已经分配的内存不会释放,从而造成大量的内存浪费。
在云场景中,这个缺点被放大:流量高峰时,云用户要为使用的资源多付更多的费用;但是流量低谷时,由于JVM本身的缺陷,内存资源并不会释放,导致云用户需要在流量低谷时为峰值的流量付费,这各云场景中提倡的按需使用、按需付费相矛盾。
JDK12中引入一个新的JEP 345,为G1提供优雅的释放内存机制,其做法是引入额外的线程,该线程周期性地触发Minor GC,在Minor GC中触发并标记,在并发标记的一个暂停应用的阶段(再标记阶段)释放内存。
不过,JDK8和11并没有支持该我,有两个临时解决方案:

  • 使用华为公司的毕昇JDK和阿里公司的龙井JDK
  • 在应用程序代码中加入System.gc()。不想修改代码的应用,可以通过注入Agent的方式,在Agent中周期性地调用System.gc(),它可以触发Full GC,在Full GC中释放内存。

基本配置参数

  • 启用G1GC
    使用-XX:+UseG1GC来指定JVM使用G1垃圾回收器,这是启动G1的基础。
  • 设置堆大小
    -Xms-Xmx分别设定初始和最大堆内存,对G1性能至关重要。
  • 自适应大小调整
    -XX:MaxGCPauseMillis定义最大垃圾收集暂停时间目标,帮助自动调整堆大小。
  • 并行线程数
    -XX:ParallelGCThreads控制G1并行收集线程的数量,优化多核处理器性能。

ZGC

Oracle最新款的新一代垃圾回收器。有三大目标:

  • 支持TB级内存,目前支持4TB~16TB
  • 停顿时间控制在10ms以内,实测通常低于10ms
  • 对程序吞吐量影响小于15%

ZGC堆内存布局
在这里插入图片描述

设计思路借鉴了商业垃圾回收器Azul的C4。
ZGC特性:

  • 不分代:垃圾回收时对全量内存进行标记,回收时仅针对部分内存回收,优先处理垃圾比较多的页面
  • 最初仅支持Linux64/X86,后扩展至Mac、Windows和AArch64平台
  • 内存分区管理,且支持不同的分区粒度。在ZGC中分区称为页面,有小页面、中页面、大页面三种
  • 颜色指针:通过设计不同的标记位区分不同的虚拟空间,而这些不同标记位指示不同虚拟空间通过mmap被映射在同一物理地址。颜色指针用于快速实现并发标记、转移和重定位
  • 设计了读屏障,实现了并发标记和并发转移的处理
  • 支持NUMA,尽量把对象分配在应用访问速度比较快的地方

内存管理

ZGC吸收了以前垃圾收集器的经验,并做了增强:

  • 在JVM内部对内存进行抽象,设计了虚拟内存管理和物理内存管理。其中虚拟内存的管理是面向应用的,为应用提供以分页为粒度的管理方式,满足应用的分区请求;而物理内存管理是面向OS的,它负责向OS请求和归还内存,而且当向OS请求内存时不再需要连续的内存空间。
  • 提供高速的分配机制,设计了不同层次的缓存,包括:应用线程级缓存、CPU级缓存和节点级缓存。

Shenandoah

Shenandoah是2014年,RedHat公司提出的一款完全并发的垃圾回收器。它是G1的演化版本,在G1的基础上进行了改进,将并行复制修改为并发复制。
Shenandoah也是基于分区的管理机制。当用户不指定分区大小时,分区的取值可以是256KB、512KB、1MB、2MB、4MB、8MB、16MB、32MB;当用户指定时可以设置其他的值,但要满足分区大小为2的幂次,且需要同时设置最小分区和最大分区的大小。

Eplison

Epsilon(A No-Op Garbage Collector)垃圾回收器控制内存分配,但是不执行任何垃圾回收工作。一旦Java的堆被耗尽,JVM就直接关闭。设计的目的是提供一个完全消极的GC实现,分配有限的内存,最大限度降低消费内存占用量和内存吞吐时的延迟时间。一个好的实现是隔离代码变化,不影响其他GC,最小限度的改变其他的JVM代码。它主要用于需要剥离垃圾收集器影响的性能测试和压力测试。
由Red Hat推出,JDK11中新增的垃圾收集器。

  • 12
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值