JVM垃圾回收(下):二十张图,彻底弄懂垃圾回收机制

JVM垃圾回收(下):二十张图,彻底弄懂垃圾回收机制

首页冒险解谜小龙飞飞更新时间:2024-06-18

目录

四种垃圾回收算法,七种垃圾收集器

上一篇讲了垃圾回收,回收哪,回收谁,如何断定对象已死的两种办法(引用计数法和可达性分析算法),以及讲明了 Java 四种引用类型根据 JVM 内存不同情况的回收JVM垃圾回收(上):面试必问,白话文讲解,小白请进,这篇就来重点讲一下怎么回收的问题!

垃圾回收算法

周志明老师的《深入理解Java虚拟机》是很棒的入门资料,我在写 JVM 的时候也参考了这本书,但是在写这一篇的时候我发现了有个错误,并且查阅了大量谷歌和维基百科资料,证实确实说法有问题,而且国内很多博客都是沿用了这种错误的说法,估计都是沿用这本书。

周志明老师在《深入理解Java虚拟机》第2版书中,69页写的是: 首先标记出所有要回收的对象,在标记完成后统一回收所有被标记的对象。

对于垃圾回收器老说,对象就分为要回收的和不需要回收的,这里周志明老师说的是标记所有要回收的对象, 但其实真正标记的是不需要回收的对象。

标记清除算法(Mark Sweep)

标记-清除算法,第一个GC算法,可以说是最古老的一种GC算法了,于1960年由 Lisp之父 John McCarthy 在其论文中发布。顾名思义,标记-清除算法分为两个阶段:

可达性分析算法:此算法主要用来判断对象是否存活,一个堆就是一块连续的内存空间,一个堆有一个根root,从这个根出发有很多个引用链,所有能到达root的 Object 对象都是可达对象,即还在被引用的存活的,不需要被垃圾回收;不能到达root 的对象如 Object5,7,8 是不可达对象,是需要被垃圾回收器回收的死亡对象,如下图:

Root Set

Mark

Sweep

标记清除算法

标记清除算法

  1. 标记

每隔一段时间或者在堆空间不足的时候才进行一次垃圾回收,每次垃圾回收先将所有堆上分配的内存单元标记为“不可到达”,然后从一组根引用开始扫描,把所有从根引用出发可以达到的单元标记为“可以到达”,其他的不可达的就是垃圾,这样就区分出来了。

  1. 清除 把标记为“不可到达”的内存单元进行回收清除。

缺点:

  1. 效率低,标记和清除都需要进行遍历,效率低;
  2. 空间碎片问题,由图可以看出,垃圾回收之后,未使用内存空间不连续
问题发现的经过:

对于清除来说,只要知道哪些要回收哪些不要回收就可以,所以貌似标记哪个都可以,但是这都是建立在可以分开的前提下。拿 JVM来说,有了可达性分析算法,需要和不需要回收的对象是可以分开的,但是最初 标记清除算法 出来的时候都还没有 JVM,连JAVA语言都不存在。

因此剥离开 JVM 分析这个算法的时候,疑惑有三:

  1. 假如如周志明老师所说的标记的是死亡对象,那么就需要一个数据结构,数组也好,链表也好来维护这些死亡对象,然后再清除这些存放着的死亡对象。然而这些死亡对象价值是很低的,开辟空间来维护不值得;
  2. 年轻代有大量的对象产生并且大量对象都是用了很快就会被回收的死亡对象,如果标记死亡对象那么要标记很多,做的工作变多了。
  3. 再加上现在 JVM 先进的可达性分析算法,GC Roots链标记的都是存活对象,还花了 OopMap 数据结构来维护这些存活对象,周老师这里却说标记死亡对象,矛盾了,让我无法说服自己,这才彻查了下这个问题,真正标记的是存活对象。
复制算法

上面的算法是鼻祖,后面的垃圾回收算法都是为了对上面的进行优化

复制算法

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

优点:

  1. 无内存碎片;
  2. 分配内存简单高效,只需移动堆顶指针,按顺序分配内存即可;

缺点:

  1. 内存缩小为了原来的一半,空间利用率太低;
  2. 如果存活的对象很多,需要复制很多对象
标记整理算法(Mark-Compact)

标记整理算法

标记-整理(Mark-Compact)算法,标记过程 仍然与“标记-清除”算法一样,但后续步骤不 是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,Compact就是压缩的意思,然后直接清理掉端边界以外的内存。

分代收集算法分代思想来源

目前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,这种算法是根据对象存活周期的不同将内存划分为几块,然后每一块根据此块内对象的存亡时间特点不同,选择上面的三种算法的某一种来发挥算法的优点。

下面图关系是一个博士做的测试,得到的结论:即大部分的 Java 对象只存活一小段时间,而存活下来的小部分 Java 对象则会存活很长一段时间。

Java 对象生命周期的直方图

正式因为这个特点,才造就了 Java 虚拟机的分代回收思想。简单来说,就是将堆空间划分为两代,分别叫做新生代和老年代。新生代用来存储新建的对象。当对象存活时间够长时,则将其移动到老年代。

分代收集年轻代

红色的代表存活的对象,From和To都为survivor区。

绝大多数刚创建的对象会被分配在Eden区,其中的绝大多数对象很快就会消亡。Eden区是连续的内存空间,因此在其上分配内存极快;当Eden区满的时候,执行Minor GC。

如图,将年轻代内存分为一块较大的 eden(西方伊甸园代表生命降生) 空间和两块较小的 survivor(生存) 空间,默认比例是 8:1:1,即每次新生代中可用内存空间为整个新生代容量的 90%,每次使用 eden 和其中一个 survivour。当回收时,将 eden 和 survivor 中还存活的对象一次性复制到另外一块 survivor 上,最后清理掉 eden 和刚才用过的 survivor,若另外一块 survivor 空间没有足够内存空间存放上次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

年老代

年轻代可看出用的是“复制算法”,老年代的对象存活的比较多,死亡的比较少,使用标记整理算法。

永久代

很多人都把方法区称为永久代,本质上两者不等价。

Java虚拟机规范中不要求对方法区进行垃圾收集,因为这个区域的收集性价比很低。但是在很多大量使用反射,动态代理,CGLIB等 ByteCode框架,以及动态生成JSP,以及OSGI这类频繁定义ClassLoder的地方,方法区的运行时数据区在运行时疯狂的增加内容,没有垃圾回收器很容易发生OOM。

因此HotPot设计团队设计了永久代的垃圾回收,这个说法是建立在HotPot虚拟机的,其设计团队把GC分代收集扩展到了方法区,也即是用永久代来实现方法区以至于垃圾收集器可以像管理堆一样管理方法区内存,对其他的虚拟机来说是不存在永久代的概念的。

永久代的回收有两种:常量池中的常量,无用的类信息,常量的回收很简单,没有引用了就可以被回收。对于无用的类进行回收,必须保证3点:

分代区域大小参数

image

没有直接设置老年代的参数,但是可以设置堆空间大小和新生代空间大小两个参数来间接控制。

老年代空间大小=堆空间大小-年轻代大空间大小

内存分配与回收策略
  1. 大对象直接进入老年代 大对象是指,需要大量连续内存空间的 Java对象,典型的大对象就是很长的字 符串或者大数组。 -XX:PretenureSizeThreshold可以令大于这个设置值的对象直接在老年代分配。 这样可以避免在Eden区及两个Survivor 区之间发生大量的内存复制;
  2. 长期存活的对象将进入老年代 对象在Survivor区中每“熬过”一次Minor GC,年 龄就增加1岁,当它的年龄增加到一定程度(默认为 15岁),就将会被晋升到老年代中;
  3. 动态对象年龄判定 如果在Survivor空间中相同年龄所有对象 大小的总和大于Survivor空间的一半,年 龄大于或等于该年龄的对象就可以直接进 入老年代,无须等到 MaxTenuringThreshold中要求的年龄;
  4. 空间分配担保 HandlePromotionFailure,检查老年代 最大可用的连续空间是否大于历次晋升到 老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC;如果小于,或者设置不允许冒险,那这时也要改为进行一次Full GC。
垃圾收集器

图中上面的是年轻代的垃圾收集器,下面的是老年代的垃圾收集器,两个收集器之间有连线,说明它们可以配合使用。

没有最好的垃圾收集器,只有最合适的,存在即合理。

Serial 收集器(复制算法)

单线程(单线程的意义不仅仅说明它会使用一个 cpu或一条垃圾收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集的时候,必须暂停其他所有工作线程即“stop the world”,直到他收集结束)。

“stop the world”是垃圾收集器在后台自己发起和结束的,在用户不可见偷偷的把用户的线程停掉,如果运行一小时就要停下五分钟进行收集,这是很痛苦的,因此缩短这个停顿时间是一代一代垃圾收集器努力的方向。

image

开启参数: -XX: UseSerialGC

适用场景:用户的桌面应用场景(这种收集器简单,桌面应用分配给虚拟机管理的内存一般几十兆到几百兆,收集停顿时间也就是几十毫秒上百毫秒,所以用这个停顿时间是完全可以接受的)

ParNew 收集器(复制算法)

Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、 收集算法、 Stop The World、 对象分配规则、 回收策略等都与Serial收集器完全一样。

开启参数:-XX: UseParNewGC

适用场景: 在Server模式下,ParNew收集器是一个非常重要的收集器,因为除Serial外,目前只有它能与老年代的CMS收集器配合工作;但在单个CPU环境中,不会比Serail收集器有更好的效果,因为存在线程交互开销。

Parallel Scvenge收集器(复制算法)

其他与ParNew类似,特别之处在于:CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间;而其目标是达到一个可控制的吞吐量,适合在后台运算,没有太多的交互。

Parallel Scavenge关注点:可控的吞吐量

开启参数:-XX: UseParallelGC

适用场景: 后台计算不需要太多交互,例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。

Parallel Scavenge提供了两个参数用于精确控制吞吐量:

Serial Old 收集器(标记-整理算法)

从这里开始就是老年代垃圾收集器。

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用,即用户的桌面应用场景。

Parallel Old 收集器(标记-整理算法)

Parallel Old 收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器从 JDK 1.6中才开始提供的,用来代替老年代的Serial Old收集器。

适用场景:特别是在Server模式,多CPU的情况下;这样在注重吞吐量以及CPU资源敏感的场景,就有了Parallel Scavenge加Parallel Old收集器的"给力"应用组合。

Concurrent Mark Sweep 收集器(CMS)

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS收集器是基于 “标记—清除”算法实现的。整个过程分为4个步骤,

  1. 初始标记(仅标记一下GC Roots能直接关联到的对象;速度很快;但需要"Stop The World")
  2. 并发标记(进行GC Roots Tracing的过程;刚才产生的集合中标记出存活对象;应用程序也在运行;并不能保证可以标记出所有的存活对象;)
  3. 重新标记(为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录;需要"Stop The World",且停顿时间比初始标记稍长,但远比并发标记短;采用多线程并行执行来提升效率;)
  4. 并发清除 (回收所有的垃圾对象)。

优点:并发收集,低停顿;

缺点:

  1. 不能处理浮动垃圾,由于 cms 并发清除阶段,用户线程还在继续执行,伴随程序进行,还有新的垃圾产生,这一部分垃圾发生在标记之后,cms 无法在当次收集时处理他们,只能留到下一次gc。可能出现"Concurrent Mode Failure"失败。这时JVM启用后备预案:临时启用Serail Old收集器,停顿时间变长了,而且可能导致另一次Full GC的产生。
  2. 对cpu 资源敏感。并发收集虽然不会暂停用户线程,但因为占用一部分CPU资源,还是会导致应用程序变慢,总吞吐量降低。cms 默认启动的回收线程数是(cpu 数量 3)/4。当CPU数量多于4个,收集线程占用的CPU资源多于25%,对用户程序影响可能较大;不足4个时,影响更大,可能无法接受。
  3. 使用的标记清除算法,会产生大量内存碎片,大对象分配困难,会出现老年代明明又很多空间但是不连续,需要提前触发另一次 Full GC 动作。为了解决这个问题CMS提供了参数 -XX: UseCMSCompactAtFullCollection (默认开启),用来顶不住Full GC时启动压缩内存碎片,停顿时间变长了就,又提供了另外一个参数 -XX:CMSFullGCsBeforeCompaction=0 来决定什么执行多少次不压缩的Full GC之后,来一次压缩的,默认为0即每次要进入Full GC时都来一次内存碎片整理。

开启参数:-XX: UseConcMarkSweepGC

适用场景:互联网站或者WEB服务端

G1 收集器

周志明-深入了解Java虚拟机

看上面两段介绍。

开启参数:-XX: UseG1GC

适用场景:服务端应用

G1 收集器

我司JVM参数

#jvm参数设置 RUNJVM_ARGS="-server-Dfile.encoding=UTF-8-Dsun.jnu.encoding=UTF-8-Djava.io.tmpdir=/tmp-Djava.net.preferIPv6Addresses=false"&&\ JVM_GC="-Xloggc:/tmp/gc.log-XX: DisableExplicitGC-XX: PrintGCDetails-XX: PrintHeapAtGC-XX: PrintTenuringDistribution-XX: UseConcMarkSweepGC-XX: PrintGCTimeStamps-XX: PrintGCDateStamps"&&\ JVM_GC=$JVM_GC"-XX:CMSFullGCsBeforeCompaction=0-XX: UseCMSCompactAtFullCollection-XX:CMSInitiatingOccupancyFraction=80"&&\ JVM_HEAP="-XX:SurvivorRatio=8-XX: HeapDumpOnOutOfMemoryError-XX:ReservedCodeCacheSize=128m-XX:InitialCodeCacheSize=128m" ENTRYPOINTjava-Djetty.http.port=8080\ ${JVM_ARGS}\ ${JVM_GC}\ ${JVM_HEAP}\ -Dspring.profiles.active=${PROFILE}\ -Xmx${JVM_XMX}\ -Xms${JVM_XMS}\ -jar/root/${MODULE_NAME}.jar

我公司用的是CMS收集器。

  1. -XX:CMSInitiatingOccupancyFraction这个参数是指在使用CMS收集器的情况下,老年代使用了指定阈值的内存时,触发FullGC。如: -XX:CMSInitiatingOccupancyFraction=80 : CMS垃圾收集器,当老年代达到80%时,触发CMS垃圾回收;
  2. -XX:SurvivorRatio参数指2个Survivor区和Eden区的比值:

-XX:SurvivorRatio=8表示 两个Survivor : Eden = 2: 8 ,每个Survivor占 1/10;

  1. JVM一个有趣的,但往往被忽视的内存区域是“代码缓存”,它是用来存储已编译方法生成的本地代码。代码缓存确实很少引起性能问题,但是一旦发生其影响可能是毁灭性的。如果代码缓存被占满,JVM会打印出一条警告消息,并切换到interpreted-only 模式:JIT编译器被停用,字节码将不再会被编译成机器码。因此,应用程序将继续运行,但运行速度会降低一个数量级,直到有人注意到这个问题。就像其他内存区域一样,我们可以自定义代码缓存的大小。相关的参数是-XX:InitialCodeCacheSize 和-XX:ReservedCodeCacheSize。

有收获的朋友点个“在看”否?欢迎关注我,一起成长~

关注我,一起成长~

参考资料: 周志明《深入了解Java虚拟机》, 谷歌,维基百科

往期推荐: JVM垃圾回收(上):面试必问,白话文讲解,小白请进

Java跨平台根本原因,面试必问JVM内存模型白话文详解来了

不好意思,你可能连new对象实际在做什么都不知道!

查看全文
大家还看了
也许喜欢
更多游戏

Copyright © 2024 妖气游戏网 www.17u1u.com All Rights Reserved