Java JVM内存优化

文摘   职场   2024-08-12 11:30   江苏  
  1. 前言

    之前写了《SpringBoot集成Prometheus》、《如何快速分析Java OOM文件》和《Spring Boot AOP 拦截请求统计耗时》3篇关于应用健康监控的文章,在运维同学的配合下,完成了对一些服务监控指标的配置提醒,当触发某些逻辑后会发消息通知,如下图:


  2. 背景

    监控不等于应用没问题的,当上述问题发生后我们需要排查下,GC该不该出现呢?有的时候是正常的GC,如果太多的话说明应用某个地方存在问题!因目前团队开发是基于jdk8,但运维同学的发布脚本中的VM参数用的是G1收集器(G1是jdk7开始提供的,经过2个版本的发展,到jdk9被设置为默认的垃圾收集器),正好老大让准备点资料给同学们分享,基于此,为提升预警的“质量”,需要调研相关资料,理解G1垃圾收集器的工作原理,试着通过一些参数配置能尽量让该目标实现。


  3. 现状

    进入启动脚本 -Xloggc:/data/logs/${app}/gc.log,查询日志发现系统存在较多的young gc、mixed gc和Humongous Allocation:


    G1提供三种垃圾回收模式 Young GC、Mixed GC 和 Full GC:

    Young GC:JVM无法将新对象分配到eden区域时(新生代的区域总大小超过新生代大小的限制),如果超出就会进行young gc,针对年轻代区域(Eden/Survivor)。如果对象在新生代中经历了多次GC仍然存活,它会被晋升到老年代。

    Mixed GC:在年轻代的基础上再选部分“价值大”的老年代进行回收(但并不是Full GC)

    Full GC:当老年代中的对象数量达到一定阈值时,会触发一次Major GC(老年代垃圾收集),回收老年代中的垃圾对象。如果老年代中的空间不足以分配新对象,还会触发一次Full GC(全量垃圾收集),回收整个堆内存中的垃圾对象。


    再次分析日志发现young gc数据转移和对象复制时间如下:

  4. 通常情况下新生代对象的生命周期通常很短,基本上一次回收就会释放掉,为什么还有那么多(大)对象存活需要转移到幸存区呢?


  5. 复习

    Java 内存区域:根据Java虚拟机规范,Java运行时数据区域分为:堆、方法区、虚拟机栈、本地方法栈和程序计数器。如图:

    其中虚拟机栈、本地方法栈和程序计数器则为线程私有;(heap)和方法区是所有线程共享,是JVM所管理的内存中最大的一块区域,从垃圾回收的角度来看,垃圾收集器采用分代回收的思想,所以堆分代划分为:新生代、老年代和永久代。

    在JDK>=1.8 之后将永久代变成了元空间,主要区别有:

    存储位置:

    1. 永久代:在JDK 7之前,永久代是JVM堆的一部分,与新生代和老年代的地址是连续的。

    2. 元空间:从JDK 7开始,元空间不再使用JVM的内存,而是直接使用操作系统的本地内存。

    存储内容:

    1. 永久代:原本用于存放类的元数据信息、静态变量以及常量池等。

    2. 元空间:现在类的元信息(如类文件)和静态变量等被存储在元空间中,而常量池等数据则并入堆中。

    回收机制:

    1. 永久代:回收效率较低,通常在Full GC时才会触发,这可能导致在开发中大量创建字符串时出现内存不足的问题。

    2. 元空间:元空间的回收机制与永久代不同,它会在JAVA程序运行过程中根据GC后的调整进行内存回收。

    性能和内存溢出问题:

    1. 永久代:由于字符串常量池存在于永久代中,容易出现性能问题和内存溢出。

    2. 元空间:使用本地内存作为存储空间,避免了字符串常量池的问题,提高了性能并减少了内存溢出的风险。


  6. 哪些内存需要回收?

    需要回收的对象主要是那些不再被引用的对象,即不可达对象。当一个对象没有任何引用指向它时,它就被认为是垃圾对象,应该被回收。

    垃圾回收的两种判定方法

    引用计数算法:

    当创建对象的时候,为这个对象在分配堆空间,同时会产生一个引用计数器,使得引用计数器+1,当有新的引用时,引用计数器继续+1,当其中一个引用销毁时,引用计数器-1,当引用计数器减为0的时候,标志着这个对象已经没有引用,可当作垃圾回收。

    但会存在一个问题:A中一个属性引用B,B中一个属性引用A,当一个业务完成后,堆中仍然 A\B两个对象循环依赖的,导致引用计数器不为0,无法回收。

    ObjA.obj=ObjB

    ObjB.obj=ObjA

    可达性分析算法:

    从GC Root开始,寻找对应的引用节点,找到这个节点之后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点。


    哪些可以作为GC Root:虚拟机栈中的引用的对象、方法区中的static\final对象、本地方法栈中引用的对象。


    三色标记:

    初始化全白色->GC ROOTS直接引用变灰色->再次从灰色集合中取元素,直接关联的标灰色,已经标过的标黑色->重复上一步直到所有灰色集合处理完->结束后,仍然为白色的即视为垃圾,等待GC触发时回收。


  7. 什么时候回收?

    JVM的内存回收是由垃圾收集器(Garbage Collector,简称GC)自动完成的。垃圾收集器会在以下情况触发:

    系统内存不足:当JVM中的堆内存不足以分配新对象时,垃圾收集器会被触发。

    手动触发:虽然不常见,但某些情况下可以通过System.gc()方法建议JVM进行垃圾回收,但具体是否执行由JVM决定。

    定时回收:某些垃圾收集器(如CMS)支持定时执行垃圾回收。


  8. 如何回收?

    JVM中的垃圾回收器有多种,每种垃圾收集器都有自己的回收策略。常见的垃圾收集器有:Serial、Parallel、CMS、G1等。

    垃圾回收器分类

    按线程数分

    串行:同一时间只有一个CPU执行GC

    并行:同一时间允许多个CPU执行GC

    按工作模式分

    并发:与工作线程交替工作

    独占:与串行一样

    按工作的内存区分

    年轻代:效率高、采用复制算法,对内存占用控制不准,容易溢出

    老年代:效率低,触发STW,一般启动次数少


    垃圾回收算法

    标记清除

    算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。

    缺点:

    1. 执行效率不稳定:如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。

    2. 内存空间的碎片化问题:标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致后续应用程序需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

  9. 复制算法

    解决标记清除的缺点,将内存分成2部分,每次使用一块,当需要回收时将存活的直接移到另一块上,直接清除掉原来使用的那块。

    注:15次移动会变成老年代 MaxTenuringThreshold 参数可以i控制。

    缺点:

    当存活率较高时复制需要更多时间也浪费了空间使用率


    标记整理法

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

    缺点:

    整理阶段移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。


  10. G1堆结构

    g1堆是一块内存区域,被分成许多固定大小的region(取值范围是1~32M,默认2M);大小可以在jvm启动参数中用-XX:G1HeapRegionSize指定,参考如下事例:



  11. G1工作流程

    初始标记(GC ROOT关联对象)->并发标记(扫描整个堆里的对象图找出要回收的对象)->最终标记(处理并发阶段留下的引用变更记录)->筛选回收(对各个Region进行价值排序VM参数选择回收的Region集)。


    漏标问题的两种解决方法:

    Incremental Update 增量更新法,CMS垃圾回收器采用。思路是拦截每次赋值动作,只要赋值发生,被赋值的对象就会被记录下来,在重新标记阶段再确认一遍。

    Snapshot At The Beginning,SATB原始快照法,G1垃圾回收器采用思路也是拦截每次赋值动作,不过记录的对象不同,也需要在重新标记阶段对这些对象二次处理,新加对象会被记录,被删除引用关系的对象也被记录。

    传送门:https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/G1GettingStarted/index.html


  12. G1 VS CMS

    区别一:使用范围不一样

    a.CMS收集器是老年代的收集器,可以配合新生代的Serial和ParNew收集器一起使用

    b. G1收集器收集范围是老年代和新生代。不需要结合其他收集器使用,所以G1也叫整堆收集器


    区别二:STW时间纬度

    a.CMS收集器以最小的停顿时间为目标

    b.G1是一个可预测的软实时模型(可通过-XX:MaxGCPauseMillis设置期望达到的最大GC停顿时间指标,defalut:200 ms


    区别三:垃圾碎片

    a.CMS收集器是使用“标记-清除”算法进行的垃圾回收,容易产生内存碎片

    b.G1将内存区别分成一个个Region,逻辑上分代(资源回收后角色可相互转换),物理上不连续;使用的是“标记-整理”算法,进行了空间整合,没有内存空间碎片。


    区别四:垃圾回收过程不一样

    总体来说都是4大步,但G1最后一个过程是筛选回收,CMS是并发清理。



    传送门:

    https://docs.oracle.com/javase/9/gctuning/garbage-first-garbage-collector-tuning.htm


  13. 空间分配担保原则

    开发过程中,应用有时候会触发OutOfMemory,为什么呢?底层跟空间分配担保原则有关系:Young GC前,JVM会先检查老年代最大连续可用空间是否大于新生代对象的总大小(极端情况GC后所有对象都保留),如果大于则运行,反之会检查vm启动参数-XX:HandlePromotionFailure 是否允许失败,如果允许失败,则触发Young GC(存活对象小于survivor可用则放在survivor;存活对象大于survivor小于老年代可用则放在老年代;如果比2者都大触发Full GC仍然没有足够的空间则OOM) ,如果不允许失败,则触发 Full GC。为方便理解,大致画一下

  14. 实际调优


    可以看到配置的300ms,正常情况下用不到!结论一:这里可以试着调整MaxGCPauseMillis,以提高g1对回收价值的判断,提高GC频率腾出更多空间。


    再次分析日志:

    发现存在大量to区空间耗尽的(对象转移失败),没有空闲Region分配给老年代或幸存区,导致young gc耗时明显上涨。


    Evacuation Failure(疏散或转移失败):将对象转移到其它Region中,老年代在垃圾收集器释放出足够的空间前就已经被耗尽。


    上面2种情况说明程序存在资源分配不足(够),可能服务产生了巨型对象或正常情况下给到的配置不够。所以接着验证是否有巨型对象(Hugmongous Objects)



    G1认为只要大小超过了一个Region容量一半的对象即可判定为巨型对象。它不属于新生代也不属于老年代,所以会在新生代或者老年代回收的时候会顺带回收大对象的Region内存,也就是说大对象的回收依靠mixed gc。

    通过以上分析,这里有几个地方可以调整:

    a.可以通过调低InitiatingHeapOccupancyPercent,使整堆使用量与堆总大小的比值减小来更早触发对堆的gc操作

    b.适当调高ConcGCThreads,并发标记阶段,并行执行的线程数(太大影响业务线程)

    c.适当调高G1ReservePercent,G1 为了保留一些空间用于年代之间的提升 10<=x<=50

    d.大对象会导致过多老年代碎片化,适当调高G1HeapRegionSize 1<=x<=32

    e.代码层面避免大对象出现 (这点是根本,也最重要 X 3)


  15. 参数调整

    查询资料,个人觉得跟我们当前业(服)务有关的参数整理摘抄如下:

    -XX:G1HeapRegionSize:设置每个Region的大小,值是2的幂次方,范围是1MB~32MB之间。-XX:MaxGCPauseMillis:设置期望达到的最大GC停顿时间指标,defalut:200 msXX:InitiatingHeapOccupancyPercent (IHOP):设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。defalut:45%-XX:GCTimeRatio:参数为0~100之间的整数(G1默认是9),值为 n 则系统将花费不超过 1/(1+n) 的时间用于垃圾收集。因此G1默认最多 10% 的时间用于垃圾收集-XX:ParallelGCThreads:指定GC工作的线程数量- CPU核心数 <= 8,则为 ParallelGCThreads=CPU核心数- CPU核心数 > 8,则为 ParallelGCThreads = CPU核心数 * 5/8 + 3 向下取整G1可以通过参数控制新生代内存的大小:- -XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%)。- -XX:G1MaxNewSizePercent:新生代内存最大空间(默认整堆60%)。-XX:SurvivorRatio:Eden和一个Survivor的比例,default:8。-XX:MaxTenuringThreshold:从新生代晋升到老年代年龄阈值default:15。-XX:TargetSurvivorRatio:Survivor区内存使用率,增大该值会降低到老年代概率,default:50。-XX:ConcGCThreads:并发标记阶段,并行执行的线程数。-XX:G1ReservePercent:G1 为了保留一些空间用于年代之间的提升,default:10%.G1MixedGCCountTarget:值越大,收集老年代分区越少 default:8。G1OldCSetRegionThresholdPercent:表示一次最多收集10%的分区 default:10。+G1EagerReclaimHumongousObjects 是否在YGC时回收大对象,default:true。G1MixedGCLiveThresholdPercent:老年代中的存活对象的占比(default85%)小于该参数才会被选入CSet。G1OldCSetRegionThresholdPercent:一次Mixed GC中能被选入CSet的最多老年代数量。

    上述参数需要特别说明

    G1HeapRegionSize值越大,Region数量越少,值越小,Region数量越大;

    MaxGCPauseMillis回收同等Region的情况下,值越大,清理频率越低,值越小,清理频率越高。

    但如果把MaxGCPauseMillis调得太低,很可能由于停顿目标时间太短,导致每次选出来的有效回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。


    传送门:

    https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html

  16. 其他维度

    网上有张图能很清晰的说明JVM内存结构的布局和相应的控制参数:


    划水摸鱼用:

    https://cloud.tencent.com/developer/article/2153851

    https://zhuanlan.zhihu.com/p/626394477

    https://zhuanlan.zhihu.com/p/640755958

    https://zhuanlan.zhihu.com/p/447043106


学而时习之,不亦说乎?有朋自远方来,不亦乐乎?人不知而不愠,不亦君子乎?

——《论语.学而》

晚霞程序员
一位需要不断学习的30+程序员……