JVM虚拟机之垃圾收集算法和垃圾收集器

前言

现在面试多少都会问到JVM虚拟机之类的问题,但是平时开发工作中又很少会用到这些知识,所以要想理解这些抽象的概念一方面需要多看多理解,一方面要在平时的开发工作中多往底层考虑一点,将知识串起来,形成思维导图一样的知识体系,一旦将知识碎片拼接起来,就会有一种豁然开朗的感觉。

这篇博客可以看做是《深入理解JAVA虚拟机》的学习笔记,加上一些个人的理解,学习需要整理输出和应用,主动学习可以加深理解。

垃圾收集算法

常见的垃圾收集算法有标记清除算法复制收集算法标记整理算法分代收集算法

以下图示中,蓝色是存活对象,灰色是可回收内存,白色是未使用内存。

标记清除算法(Mark-Sweep)

算法分为两个阶段:

  • 标记阶段:标记出所有需要回收的对象
  • 清除阶段:标记阶段完成后统一回收所有被标记的对象

回收前状态:
在这里插入图片描述
回收后状态:
在这里插入图片描述
它是最基础的垃圾收集算法,但是会有两个明显的问题:

  • 效率问题:标记和清除两个过程的效率都不高
  • 空间问题:标记清除之后会产生大量的不连续内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次收集动作。

复制收集算法(Copy-Collection)

为了解决不连续空间碎片问题,出现了复制收集算法。
它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存使用完了,就将还存活的对象复制到另一块中,然后再把已使用的一块内存空间全部清理掉。
这样使得每次都是对整个半区进行内存回收,内存分配时就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

回收前状态:
在这里插入图片描述
回收后状态:
在这里插入图片描述
这种算法的代价是将内存缩小为原来的一半,在对象存活率较高时就要进行较多的复制操作,效率也会变低。
一般适用于对象存活率不高的场景

标记整理算法(Mark-Compact)

算法也分为两个阶段:

  • 标记阶段:标记出所有需要回收的对象
  • 整理阶段:不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

回收前状态:
在这里插入图片描述
回收后状态:
在这里插入图片描述
相当于在标记清除算法的基础上进行了一次内存整理的操作。

分代收集算法(Generational-Collection)

当前虚拟机的垃圾收集都采用分代收集算法,这不是一种具体的算法,只是根据对象存活周期的不同,将内存划分为几块,这样就可以根据各个块对象的存活特点选择合适的垃圾收集算法。
IBM公司的专门研究表明,新生代中的对象98%都是朝生夕死的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor空间。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另一块Survivor空间上,最后清理掉Eden和刚才使用过的Survivor空间。
基于这些大量的研究数据,Java虚拟机将堆划分为新生代和老年代,在新生代中,每次垃圾收集时都有大量对象死亡,只有少量存活,就可以选用复制收集算法,只需要付出少量存活对象的复制成本就可以完成收集。
而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记清除算法或者标记整理算法。

注意,标记清除算法或者标记整理算法,比复制收集算法要慢10倍以上。

垃圾收集器

如是说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
常见的垃圾收集器有7种:

Serial和Serial Old收集器

新生代使用复制收集算法,老年代使用标记整理算法

-XX:+UseSerialGC
-XX:+UseSerialOldGC

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。
顾名思义它是一个单线程收集器,但是单线程意义不仅仅是它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程(Stop The World),直到它收集结束。
在这里插入图片描述
它优于其他收集器的地方:
简单而高效,对于单个CPU机器来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集工作,自然可以获得最高的单线程收集效率。

Serial Old收集器是Serial收集器的老年代版本,它同样是一个单线程收集器。
主要有两大作用:

  • 在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用
  • 作为CMS收集器的后备方案

ParNew收集器

新生代使用复制收集算法,老年代使用标记整理算法

-XX:+UseParNewGC

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为 (控制参数、收集算法、回收策略等等)和Serial收集器完全一样,这两种收集器也共用了相当多的代码。默认的收集线程数跟CPU核数相同,当然也可以用参数(-XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。
在这里插入图片描述
ParNew收集器除了多线程收集之外,其他与Serial收集器相比并没有太多创新之处,但它却是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。

ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百地保证可以超越Serial收集器。
当然,随着可以使用的CPU的数量的增加,它对于GC时系统资源的有效利用还是很有好处的。它默认开启的收集线程数与CPU的数量相同。

Parallel Scavenge和Parallel Old收集器

新生代使用复制收集算法,老年代使用标记整理算法

-XX:+UseParallelGC
-XX:+UseParallelOldGC

Parallel Scavenge收集器是一个新生代收集器,类似于ParNew收集器,是并行的多线程收集器,是Server模式(内存大于2G,2个CPU)下的默认收集器。
在这里插入图片描述
Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 +垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。
Parallel Scavenge收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量

-XX:+UseAdaptiveSizePolicy

虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)。自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。
由于与吞吐量关系密切,Parallel Scavenge收集器也经常称为“吞吐量优先”收集器。

Parallel Old是Parallel Scavenge收集器的老年代版本,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。

CMS收集器

使用标记清除算法

-XX:+UseConcMarkSweepGC

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器, 它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

CMS收集器收集过程

顾名思义,它是基于标记清除算法实现的,运作过程相比于前面几种垃圾收集器来说更加复杂一些,整个过程主要分为四个步骤:

  • 初始标记(CMS initial mark):暂停所有的用户线程,记录GC Roots能直接关联到的对象,速度很快。
  • 并发标记(CMS concurrent mark):同时开启用户线程和GC线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以GC线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方(GC Roots Tracing)。
  • 重新标记(CMS remark):重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。
  • 并发清除(CMS concurrent sweep):开启用户线程,同时GC线程开始对未标记的区域做清扫。
  • 并发重置(CMS concurrent reset):对标记的区域进行重置。

在这里插入图片描述

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS收集器优缺点

CMS是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿。
但是也有4个明显的缺点:

  • CMS收集器对CPU资源非常敏感,会和服务抢资源
  • CMS收集器无法处理浮动垃圾(Floating Garbage),在并发清理阶段又产生的垃圾,这种浮动垃圾只能等到下一次GC再清理了
  • 执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发Full GC,也就是concurrent mode failure,此时会进入Stop The World,用Serial Old垃圾收集器来回收
  • CMS是一款基于标记清除算法实现的收集器,收集结束时会有大量空间碎片产生

空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。

可以通过参数

-XX:+UseCMSCompactAtFullCollection

让JVM在执行完标记清除后再做一次内存整理。

CMS收集器参数

-XX:+UseConcMarkSweepGC:启用CMS收集器
-XX:ConcGCThreads:并发的GC线程数
-XX:+UseCMSCompactAtFullCollection:Full GC之后做压缩整理(减少碎片)
-XX:CMSFullGCsBeforeCompaction:多少次Full GC之后压缩一次,默认是0,代表每次Full GC后都会压缩一次
-XX:CMSInitiatingOccupancyFraction:当老年代使用达到该比例时会触发Full GC(默认是92,这是百分比)
-XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整
-XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次Minor GC,目的在于减少老年代对年轻代的引用,降低CMS GC的标记阶段时的开销,一般CMS的GC耗时80%都在重新标记阶段

G1收集器

新生代和老年代都使用复制收集算法

-XX:+UseG1GC

G1收集器将Java堆划分为多个大小相等的独立区域(Region),JVM最多可以有2048个独立区域。
一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以用参数-XX:G1HeapRegionSize手动指定Region大小,但是推荐默认的计算方式。 G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合。
在这里插入图片描述
默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200MB左右的内存,对应大概是100个Region,可以通过-XX:G1NewSizePercent设置年轻代初始占比,在系统运行中,JVM会不停的给年轻代增加更多的Region,但是年轻代最多的占比不会超过60%,可以通过-XX:G1MaxNewSizePercent调整。年轻代中的Eden和Survivor对应的Region也跟之前一样,默认8:1:1,假设年轻代现在有1000个Region,Eden区对应800个,S0对应100个,S1对应100个。

一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能可能会动态变化。

G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1有专门分配大对象的Region叫Humongous区,而不是让大对象直接进入老年代的Region中。在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,比如按照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放入Humongous中,而且一个大对象如果太大,可能会横跨多个Region来存放。

Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销。

Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。

G1收集器收集过程

G1收集器一次Mixed GC的运作过程大致分为以下几个步骤:

  • 初始标记(Initial Marking):暂停所有的用户线程,记录GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。
  • 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
  • 最终标记(Final Marking):修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面。最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。
  • 筛选回收(Live Data Counting and Evacuation):首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间(可以使用JVM参数-XX:MaxGCPauseMillis指定)来制定回收计划。这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

在这里插入图片描述
比如说老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本 次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个 Region刚好需要200ms,那么就只会回收800个Region,尽量把GC导致的停顿时间控制在 我们指定的范围内。

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。

G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)。比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面这个Region进行回收。

这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。

G1收集器收集分类

Young GC

Young GC并不是说现有的Eden区放满了就会马上触发,而且G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数-XX:MaxGCPauseMills设定的值,那么会增加年轻代的Region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数-XX:MaxGCPauseMills设定的值,那么就会触发Young GC。

Mixed GC

不是Full GC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercen)设定的值则触发,回收所有的年轻代和部分老年代(根据期望的GC停顿时间确定老年代垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个Region中存活的对象拷贝到别的Region里去,拷贝过程中如果发现没有足够的空Region能够承载拷贝对象就会触发一次Full GC。

Full GC

停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次Mixed GC使用,这个过程非常耗时。

G1收集器优化建议

假设参数-XX:MaxGCPauseMills设置的值很大,导致系统运行很久,年轻代可能都占用了堆内存的60%了,此时才触发年轻代GC,那么存活下来的对象可能就会很多,此时就会导致Survivor区域放不下那么多的对象,就会进入老年代中;或者是年轻代GC过后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor区域的50%,也会快速导致一些对象进入老年代中。
所以这里核心还是在于调节-XX:MaxGCPauseMills这个参数的值,在保证年轻代GC别太频繁的同时,还得考虑每次GC过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发Mixed GC。

G1收集器特点

G1收集器被视为JDK1.7以上版本Java虚拟机的一个重要进化特征。它具备以下特点:

  • 并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者
    CPU核心)来缩短Stop The World停顿时间。部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
  • 分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。
  • 空间整合:与CMS的标记清除算法不同,G1从整体来看是基于标记整理算法 实现的收集器,从局部上来看是基于复制收集算法实现的。
  • 可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1和 CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数-XX:MaxGCPauseMillis指定)内完成垃圾收集。

G1收集器参数

-XX:+UseG1GC:开启G1收集器
-XX:ParallelGCThreads:指定GC工作的线程数量 
-XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区
-XX:MaxGCPauseMillis(默认200ms):目标暂停时间 
-XX:G1NewSizePercent(默认整堆5%):年轻代内存初始空间
-XX:G1MaxNewSizePercent:年轻代内存最大空间 
-XX:TargetSurvivorRatio(默认50%):Survivor区的填充容量,Survivor区域里的一批对象(年龄1+年龄2+...+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n()以上的对象都放入老年代
-XX:MaxTenuringThreshold(默认15):最大年龄阈值
-XX:InitiatingHeapOccupancyPercent(默认45%):老年代占用空间达到整堆内存阈值,则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个Region,如果有接近1000个Region都是老年代的Region,则可能就要触发MixedGC了
-XX:G1HeapWastePercent(默认5%): GC过程中空出来的Region是否充足的阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收就结束了。
-XX:G1MixedGCLiveThresholdPercent(默认85%):Region中的存活对象低于这个值时才会回收该Region,如果超过这个值,存活对象过多,回收的的意义不大。
-XX:G1MixedGCCountTarget(默认8):在一次回收过程中指定做几次筛选回收,在最后筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。

如何选择垃圾收集器

  • 优先调整堆的大小让服务器自己来选择
  • 如果内存小于100M,使用串行收集器
  • 如果是单核,并且没有停顿时间的要求,使用串行或让JVM选择
  • 如果允许停顿时间超过1秒,选择并行或者JVM选择
  • 如果响应时间最重要,并且不能超过1秒,使用并发收集器

总结

在这里插入图片描述
连线表示可以搭配使用,官方推荐使用G1收集器,因为其性能高,但是没有最好的收集器,也没有万能的收集器,我们能做的就是根据具体应用场景选择适合的垃圾收集器,适合的就是最好的。

猜你喜欢

转载自blog.csdn.net/CX610602108/article/details/106157092