JVM的垃圾回收机制详解

原文章来自我的语雀博客

发生GC的位置

虚拟机栈、本地方法栈、程序计数器都随着线程生而生、随线程灭而灭,栈中的栈帧随着的方法调用而生、随着方法执行完毕而灭。这些区域都不需要GC。
JVM中需要GC的位置主要是堆区和方法区。堆区中不会再被程序访问到的对象会被GC、方法区中不会再被访问到的常量如字面量“hello world”,这个字面量会被存在方法区中的字符串常量池中,如果程序中没有任何一个地方引用这个常量,那么这个字符串字面量就可能被系统清理出常量池会被GC、不会再被使用的类会被GC又称为类卸载
类卸载的条件就比较苛刻了,需要满足三条。满足下列三个条件后,仅仅只是允许JVM进行类卸载,另外还需要通过JVM参数-Xnoclassgc参数控制是否允许JVM进行类卸载。

  1. 该类及其子类的实例都已经被回收
  2. 加载该类的类加载器已经被回收
  3. 程序中没有任何位置引用该类的Class对象,该类无法在程序中的任何地方通过反射被访问到

如果程序中大量使用了JDK动态代理、CGLIB在运行时生成字节码并加载到方法区中,则一般会要求JVM具备类卸载能力,以保证不会对方法区造成过大的内存压力。

如何判断对象是否可以被回收?

引用计数算法

每个对象添加一个引用计数器,记录起被引用的次数。引用次数为0的对象即为可回收对象,因为程序中没有任何一个地方引用它,就意味着它不会再被程序使用了。虽然这是一种简单高效的方法,在Python、游戏脚本领域得到了应用,但这个简单的算法需要大量额外处理一些例外情况才能保证正确地工作。比如它就无法解决循环引用的问题,假设有两个对象A、B,他们互相引用,那么它们地引用次数永远不会为0,即使外界没有任何地方引用这两个对象。Java虚拟机没有选用这种方法来管理内存。

可达性分析算法

以一些固定选用的GC Roots、以及一些临时加入的对象比如Young GC时,不能只考虑新生代中的GC Roots,因为新生代中的对象也可能被老年代中的对象引用,所以还要考虑老年代中可能引用新生代中的那些对象。也就是说,可达性分析的根节点集除了GC Roots,还有可能存在跨代引用的那些老年代对象。新生代会维护一个记忆集,标记老年代中哪些区域存在跨代引用。这样在进行可达性分析时,除了从GC Roots开始扫描,还需要从记忆集中标记的老年代对象开始扫描。作为根节点集,沿着引用链向下搜索,没有与GC Roots相连的对象可以被清除。
一般选用JVM栈中引用的变量、本地方法栈引用的变量、静态变量(类变量)、方法区中常量引用的对象、一些常驻的异常对象、系统类加载器、被synchronized持有的对象。

只要处于应用链中,就一定不会被回收吗?

对于一些“食之无味,弃之可惜”的对象,可以使用更弱的引用,内存充足时可以保留它们,但在内存不足时将这些对象从内存中剔除掉。
强引用:就是一般意义上的引用赋值,这种引用关系,只要引用链中有强引用指向某个对象,那么不管内存够不够都不会回收这个对象。
软引用:用来描述系统中那些还有用但非必要的对象。当JVM进行了一次垃圾回收后,仍要导致OOM异常之前,会回收在引用链中那些被软引用所连接的对象。
弱引用:也是描述那些有用但非必要的对象,但是引用的强度比软引用更弱,在下一次垃圾回收时,那些在引用链中只被弱引用连接的对象会被回收。
虚引用:虚引用不会对某个对象的生存时间造成影响,也无法通过虚引用来访问某个对象。对某个对象加虚引用的唯一目的就是希望在这个对象被GC时可以收到一个系统通知。

finalize()方法如何影响对象的生存时间?

进行可达性分析后,那些覆盖了finalize()方法,而且finalize()方法还没有被系统调用过的对象会被加入一个F-Queue,稍后会有一个线程来指向F-Queue中对象的finalize()方法,这导致了finalize()方法的执行时间、执行顺序、是否被执行完成(不一定会等待某个对象的finialize()方法执行结束)因为一些运行时间较长的finalize()会导致F-Queue中的其它对象一直等待都是不可控的。如果某个对象在执行完finalize()方法后,与引用链中的某个对象建立了联系,那么它就逃脱了被回收的命运。
需要注意的是,finalize()方法只会被系统调用一次,这意味着某个覆盖了finalize()的方法只能用finalize()方法进行自救一次。
finalize()方法已被官方声明为不推荐使用的语法,虽然它被设计的初衷是类似于C++中的析构函数,但Java中更好的释放资源的方式是try-finally,因为try-finally的执行时机是可控的,且一定会被执行。但finalize()的执行时机是不可控的,甚至能否被执行完毕都是不可控的,而且只会被系统执行一次。

分代垃圾收集理论

分代收集理论是一个适用于大部分程序的经验法则。这套理论指出,绝大部分对象是朝生夕灭的,而熬过越多次垃圾回收的对象就越不容易被GC。
按照这个理论,将堆区划分为新生代和老年代,对象都在新生代中出生,熬过多次GC的对象从新生代晋升到老年代。新生代和老年代有不同的GC策略,在新生代中,假定大部分对象都会被回收,那么我们只需要标记少部分存活对象。在老年代中,假定大部分对象都不会被回收,那么只需要以较低的频率GC老年代,而且只需要关注清理少部分对象就可以了。

基本的GC算法

标记-清除算法

可以标记死亡的对象并清除,也可以标记存活的对象、清除其余对象。问题是会导致内存碎片,因为GC后内存空间得不到整理。优点是不存在对象在内存位置上的移动,可以与用户线程并发执行。

标记-复制算法

标记存活对象并整理到一个连续的空间中。一般用在存活对象较少的区域(新生代),因为每次都需要预留一定的内存区域来存放存活的对象。
例如Appel式回收法,将新生代内存布局划分为8:1:1,分别为Eden区、Survivor(from)区、Survivor(to)区,from区和to区是交替使用的,分配内存时,只有Eden区和Survivor(from)区可用,触发GC时,会将Eden区和Survivor(from)区的对象复制到Survivor(to)区,然后清理掉Eden区和Survivor(from)区。这会导致新生代每次只有90%的空间可用,但这样会避免产生内存碎片、而且对于大部分对象死亡的新生代效率较高。
一般情况下,Survivor(to)区可以容纳幸存下来的对象,但如果某次GC后幸存下来的对象的内存大小大于Survivor(to)区,就会触发分配担保机制,会将放不下的那部分对象直接分配到老年代区域中。

标记-整理算法

一般用于老年代。因为老年代发生GC的频率较低,且老年代空间一般较大,而且老年代每次被回收的对象一般比较少,且老年代会存放大对象,所以老年代对内存的使用率要求比较高,标记-整理算法将存活的对象移动到内存空间的一端,然后清理其余内存空间。由于涉及到对象的移动,所以需要暂停用户线程。
其实老年代进行标记-整理算法是一种开销很大的操作,因为老年代每次存活的对象很多,如果使用标记-整理算法就意味着需要移动大量的对象。但如果像标记-清除算法那样完全不移动对象,就会导致老年代存在很多内存碎片,使用内存碎片来分配对象则需要更大的开销,权衡之下还是使用标记-整理算法。
还有另一种折中的方法,就是在老年代大部分时间使用标记-清除算法,等到内存碎片很多的时候再进行一次标记-整理算法。

GC的大致流程

根节点枚举

迄今为止所有的垃圾收集器在这一步都必须STW(stop the world),因为不允许在分析过程中,根节点集合还会变化。
在HotSpot中,使用了一种叫做OopMap的方式,告诉JVM内存中的哪些位置存放了对象引用,这样可以优化遍历根节点集合的时间,但代价是要求所有的用户线程必须在安全点(safe point)挂起,否则会导致OopMap变化(或者说,只有在安全点处,才记录了对应的OopMap,毕竟导致OopMap变化的指令有很多)。当垃圾收集线程需要使用OopMap时,会通过一个标记位让所有的用户线程运行到最近的safe point并挂起。一般来说,循环跳转、异常跳转、方法调用之类的指令才会产生安全点。

可以与用户线程并发执行的对象图扫描(并发的可达性分析)

这里使用了三色标记算法来进行可达性分析。这是一种递归的广度优先搜索,被搜索完毕的节点被标为黑色、正在搜索的节点被标为灰色、未被标记的节点被标为白色。
由于可达性分析是与用户线程并发执行的,所以用户线程可能会改变对象图的结构,这可能会导致两种问题:①原本应该被清除的对象被标记为存活,这倒问题不大,因为这只是产生了一些浮动垃圾,这些浮动垃圾会在下一次GC被清除。②原本应该存活的对象没有被标记而被误删除,这问题就很大了。
我们需要在下一步修正第二种问题。

修正第二步由于并发导致的问题

这一步显然不能与用户线程并发进行了,会暂停所有的用户线程,因为我们要修正的是并发的可达性分析导致的第二种问题。
当且仅当下面两个条件同时满足时,才会产生漏标记存活对象的问题,我们只需要避免其中的一种情况的发生就行了:①赋值器在可达性分析时并发地插入了至少一条从黑色对象到白色对象地引用。②赋值器在可达性分析时并发地删除了所有从灰色对象到白色对象的引用。
避免情况①的发生,增量更新法:CMS垃圾收集器采用了这种方法。当黑色对象插入了一条指向白色对象的引用后,将这个黑色对象变为灰色对象(即之后还会再扫描一次这个对象)。
避免情况②的发生,原始快照SATB(snapshot at beginning):G1垃圾收集器采用了这种方法,可以理解为无论引用关系删除与否,都会按照最开始的对象图进行扫描。

回收垃圾

按照之前介绍的3种GC算法(或它们的改进版本)进行垃圾回收,如果涉及到存活对象在内存空间的移动,那么这一步也需要Stop the world。

一些经典的垃圾收集器

Serial收集器 与 Serial Old收集器

这两种收集器在进行GC时,都使用单线程来进行根节点枚举、可达性分析、回收垃圾。GC期间冻结全部用户线程。
主要应用于CPU资源、堆内存缺乏的环境(例如用户桌面应用、手机端应用、部分微服务应用)。是Client VM的默认新生代、老年代垃圾收集器。例如,在单核环境下,Serial收集器不存在线程切换的开销,而且由于堆内存较小,垃圾回收的停顿时间也会比较小、而且Serial收集器本身的内存开销也是所有收集器中最少的。
image.png

Parallel Scavenge收集器 与 Parallel Old收集器

不关注单次GC的停顿时间,而关注总体的停顿时间以提高吞吐量。适用于需要最高效率利用CPU资源、尽快完成程序的运算任务,适合后台任务而不需要太多交互的分析任务。
提供参数-XX:GCTimeRatio,设置为一个正整数,默认值为99,即期望GC的总时间不超过总运行时间的1%
还有一个参数-XX:+UseAdaptiveSizePolicy来让JVM进行自适应调节一些细节参数,以达到最大的吞吐量
Parallel Old是Parallel Scavenge的老年代版本,都只是多线程并行收集,它们通常一起搭配使用。

ParNew收集器 与 CMS收集器

这个组合从JDK 9开始被G1收集器取代。但在JDK 7及之前的遗留系统中是首选组合。在激活老年代收集器CMS之后,默认使用的新生代收集器是ParNew。(使用参数-XX:UseConcMarkSweepGC来激活CMS)
CMS只能与ParNew配合使用
主要应用于服务端模式(实际上client模式都要被摒弃了,本身java就不适合用来写客户端)
ParNew收集器是Serial收集器的多线程版本,除了使用多个垃圾收集线程外,其余行为包括所有控制参数(例如 -XX:SurvivorRatio用于指定年轻代中 Eden 区域和 Survivor 区域的大小比例。
-XX:PretenureSizeThreshold用于指定对象在年轻代中直接进入老年代的阈值大小
-XX:HandlePromotionFailure控制JVM处理晋升失败时的行为等)与Serial完全一致。
另外,可以使用-XX:ParallelGCThreads来限制垃圾收集的线程数。

CMS 收集器

CMS是一个以"最短回收停顿时间"为目标的老年代收集器,采用标记-清除算法,不涉及到存活对象在内存空间的移动,这就让CMS收集器在回收垃圾的阶段可以与用户线程并发执行。例如互联网网站等关注服务的响应速度的应用,希望系统发生GC时停顿时间尽可能短,为用户提供更好的交互体验,CMS收集器就很符合这种需求。
CMS收集器运作的基本流程:

  1. 初始标记:即遍历根节点集,在所有用户线程运行到安全点并挂起后,会短暂地STW,标记一下GC Roots能直接关联到的对象。
  2. 并发标记:遍历整个对象图,进行可达性分析。这个过程耗时较长,会占用一些CPU资源和内存资源,但可以与用户线程并发执行。
  3. 重新标记:修正并发标记时由于与用户线程并发执行导致对象图变动引发的问题,这个问题主要是指漏标记一些存活对象,如果不修正,则可能导致一些存活对象被误删除。CMS收集器采用的是增量更新法。
  4. 并发清除:CMS收集器使用标记-清除算法,所以这一步可以与用户线程并发执行。清理删除已经死亡的对象。

总体来看,CMS收集器只在初始标记、重新标记两个阶段会有一些短暂的停顿。
CMS收集器的缺点:

  1. CMS收集器对系统性能的影响决定于CPU资源:CMS默认启动的回收线程数是(处理器核心数量+3)/4,也就是说,当处理器核心数在4个以上时,并发阶段CMS只会占用不少于25%的CPU资源,但如果处理器核心数少于4个,CMS对系统性能的占用会越来越明显,如果本来处理器负载就很高,CMS的并发标记、并发清除可能就会导致系统的性能大幅下降。
  2. 由于CMS收集器进行GC时的大部分时间都与用户线程并发执行,然而用户线程在执行时必然伴随着内存空间的使用,所以CMS收集器没法像其它老年代收集器那样,等到老年代空间几乎被用完时才进行GC,实际上,在JDK 5的默认设置下,当老年代的内存占用达到68%时就会触发CMS GC,但CMS收集器没法处理用户线程并发执行时产生的浮动垃圾,如果CMS在GC的过程中,老年代内存已经无法满足用户线程的内存分配需求,那么就会发生并发失败"Concurrent Mode Failure",这时JVM不得不冻结所有用户线程,临时启用Serial Old收集器来进行老年代GC,这样GC的停顿时间就很长了。所以参数-XX:CMSInitiatingOccupancyFraction用来设置CMS GC的触发百分比设置得太高容易导致并发失败产生,性能反而降低,生产环境中需要根据实际情况权衡设置。
  3. 第三个问题就是标记-清除算法导致的内存碎片问题。当CMS GC的次数增多时,弥散在老年代的内存碎片就会越来越多,这样当某次为大对象分配内存时,明明老年代有足够的空间,但就是找不到一块连续的内存空间来存放大对象,这样就不得不触发一次Full GC。CMS收集器提供了两个参数来缓解这个问题,一个是-XX:+UseCMSCompactAtFullConllection,让CMS收集器在不得不触发Full GC时进行内存空间整理,但这样会导致停顿时间变长,因为内存空间整理涉及到存活对象的移动,需要暂停用户线程。另一个参数是-XX:CMSFullGCsBeforeCompaction,可以设置CMS GC在进行几次Old GC后、进行一次内存空间整理。不过上述的两个参数在JDK 9都被废弃了(因为有G1 收集器了)。

注意:目前只有CMS收集器有Old GC(专门针对老年代的GC)。整堆收集Full GC指的是整堆、以及方法区的垃圾收集。
另外,只有G1 收集器有Mixed GC:针对整个新生代和部分老年代的垃圾收集。

G1收集器

概述

JDK9开始,G1收集器取代了Parallel Scavenge与Parallel Old的组合,而CMS则被声明为不推荐使用。JDK9发布之日,G1收集器成为了server模式的默认垃圾收集器。
G1收集器最大的特点就是用一种新的内存布局来实现了一种停顿预测模型。
先来看G1收集器的内存布局,G1收集器将整个Java堆分成了约2000个Region,每个块的大小为1-32M,每个Region都可以扮演新生代、Survivor(from)、Survivor(to)、老年代(老年代包含了Humongous区)。
在进行内存空间分配时:

  • 小于一半region size的对象可以直接存入新生代Region
  • 大于一半且小于一个region size的对象直接存入老年代Region中,这个region又叫Humongous region,我们也可以把它叫做H区(H区是特殊的老年代Region)
  • 比一个region size还要大的对象,需要存入连续的多个H区中

image.png
G1收集器强大之处在于其建立起了一种"停顿预测模型",即保证每次GC的停顿时间大概率不超过N毫秒。G1收集器使用一种Mixed GC收集模式、将Region作为单次回收的最小单元。进行Mixed GC时,回收全部的新生代Region、挑选部分价值最高的老年代Region进行回收,以保证每次GC的停顿时间符合用户的预期。这里的回收价值指的是回收所获得的空间大小、以及回收所需时间的经验值,后台会维护一个优先级列表,每次优先回收哪些回收价值最大的Region。
可以使用-XX:MaxGCPauseMillis来指定每次GC的最长停顿时间,默认值为200毫秒。这个停顿时间预期值应该是合理的值,如果设置得过小,则每次回收的Region空间就会很少,这样一来堆内存就会越来越匮乏,最后不得不冻结用户线程,进行Full GC、产生较长时间的Stop the world,导致性能反而降低。反之,如果预期停顿时间设置得合理,每次都在可接受的时间范围内进行GC,而且堆空间一直处于比较理想的状态,这样程序的性能就会比较理想。

G1收集器的工作流程
  1. 初始标记:短暂地暂停用户线程,标记GC Roots(并且会根据每个Region中的(使用卡表实现的)记忆集,将可能存在跨代引用的Region纳入GC Roots的标记范围,由于每个Region都维护了卡表,所以G1收集器会占用更多的额外的内存空间,根据经验,G1收集器会额外占用10%~20%的堆空间来维持工作,这是一种空间换时间的思路。)能直接关联到的对象,修改TAMS指针的值。每个Region中都有两个TAMS指针,并发回收时,用户线程分配的对象都需要在TAMS指针以上,这些新分配的对象不纳入回收范围。
  2. 并发标记:对整个对象图进行可达性分析。与CMS存在同样的并发失败问题,如果GC的过程中,堆内存无法满足用户线程的内存分配需求,就会被迫冻结所有用户线程,触发一次停顿时间较长的Full GC。
  3. 最终标记:短暂地暂停用户线程,处理遗留的SATB(snapshot at beginning),可以理解为无论引用关系删除与否,都会按照最开始的对象图进行扫描。
  4. 筛选回收:更新Region的统计数据,根据Region的回收价值和成本进行排序,然后根据用户的期望停顿时间指定回收计划,对于那些决定要回收的Region,会将其中存活的对象复制到空的Region中,由于涉及到存活对象的移动,所以这一步也需要短暂地暂停用户线程,但暂停的期望时间是用户可以设置的。
G1收集器的优点
  1. G1收集器的期望停顿时间是可设置的,如果期望停顿时机设置得合理,GC的速度跟得上对象分配的速度,那么停顿时间可控、内存空间也一直处于比较理想的状态,一切都会运行得很完美。
  2. G1收集器在局部使用标记-复制算法,但从整体来看,G1收集器是标记-整理算法,毕竟存活对象会被整理到空的Region中,这就意味着G1收集器不会产生内存空间碎片,能提供规整的内存空间,这种特性有利于程序长时间运行,分配大对象时不容易因找不到连续的内存空间而提前触发GC。image.png
G1收集器的缺点
  1. 额外的内存占用和执行负载都比较高,主要是因为G1对写屏障的复杂操作消耗了更多运算资源,而且G1中的每一个Region都维护了一张卡表(记忆集),而CMS收集器只针对老年代到新生代的跨代引用维护了一张卡表。

总而言之,目前在小内存应用上,CMS的表现还是会优于G1,在大内存应用上G1则大多能发挥其优势,Java堆内存的平衡点通常在6GB到8GB之间。然而,随着HotSpot对G1的不断优化,也会让比对结果继续向G1倾斜。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值