写点什么

后端开发和你聊聊 JVM 如何优化

  • 2024-08-02
    北京
  • 本文字数:5052 字

    阅读完需:约 17 分钟

后端开发和你聊聊JVM如何优化

作者:京东零售京麦研发 马万全


首先应该明确的是 JVM 调优不是常规手段,JVM 的存在本身就是为了减轻开发对于内存管理的负担,当出现性能问题的时候第一时间考虑的是代码逻辑与设计方案,以及是否达到依赖中间件的瓶颈,最后才是针对 JVM 进行优化。

1.JVM 内存模型

针对 JAVA8 的模型进行讨论,JVM 的内存模型主要分为几个关键区域:堆、方法区、程序计数器、虚拟机栈和本地方法栈。堆内存进一步细分为年轻代、老年代,年轻代按其特性又分为 E 区,S1 和 S2 区。关于内存模型的一些细节就不在这里讨论了,如下是从网上找的内存模型图:



接下来从内存模型简单流转来看一个对象的生命周期,对 JVM 的回收有一个概念,其中弱化堆栈和程序计数器

1.首先我们写的.java 文件通过 java 编译器 javac 编译成.class 文件

2.类被编译成.class 文件后,通过类加载器(双亲委派模型)加载到 JVM 的元空间中

3.当创建对象时,JVM 在堆内存中为对象分配空间,通常首先在年轻代的 E 区(这里只讨论在堆上分配的情况)

4.对象经历 YGC 后,如果存活移动到 S 区,多次存活后晋升到老年代

5.当对象不再被引用下一次 GC,垃圾收集器会回收对象并释放其占用的内存。

1.1 年轻代回收原理

对象创建会在年轻代的 E 区分配内存,当失去引用后,变成垃圾存在 E 区中,随着程序运行 E 区不断创建对象,就会逐步塞满,这时候 E 区中绝大部分都是失去引用的垃圾对象,和一小部分正在运行中的线程产生的存活对象。这时候会触发 YGC(Young Gc)回收年轻代。然后把存活对象都放入第一个 S 区域中,也就是 S0 区域,接着垃圾回收器就会直接回收掉 E 区里全部垃圾对象,在整个这个垃圾回收的过程中全程会进入 Stop the Wold 状态,系统代码全部停止运行,不允许创建新的对象。YGC 结束后,系统继续运行,下一次如果 E 区满了,就会再次触发 YGC,把 E 区和 S0 区里的存活对象转移到 S1 区里去,然后直接清空掉 E 区和 S0 区中的垃圾对象

1.2 、那么对象什么时候去老年代呢?

1.2.1、对象的年龄

躲过 15 次 YGC 之后的对象晋升到老年代,默认是 15,这个值可以通过-XX:MaxTenuringThreshold 设置

这个值设置的随意调整会有什么问题?

现在 java 项目普遍采用 Spring 框架管理对象的生命周期。Spring 默认管理的对象都是单例的,这些对象是长期存活的应该直接放到老年代中,应该避免它们在年轻代中来回复制。调大晋升阀值会导致本该晋升的对象停留在年轻代中,造成频繁 YGC。但是如果设置的过小会导致程序中稍微存在耗时的任务,就会导致大量对象晋升到老年代,导致老年代内存持续增长,不要盲目的调整晋升的阀值。

1.2.2、动态对象年龄判断

JVM 都会检查 S 区中的对象,并记录下每个年龄段的对象总大小。如果某个年龄段及其之前所有年龄段的对象总大小超过了 S 区的一半,则从该年龄段开始的所有对象在下一次 GC 时都会被晋升到老年代。假设 S 区可以容纳 100MB 的数据。在进行一次 YGC 后,JVM 统计出如下数据:

•年龄 1 的对象总共占用了 10MB。

•年龄 2 的对象总共占用了 20MB。

•年龄 3 的对象总共占用了 30MB。

此时,年龄 1 至 3 的对象总共占用了 60MB,超过了 S 区一半的容量(50MB)。根据动态对象年龄判断规则,所有年龄为 3 及以上的对象在下一次 GC 时都将被晋升到老年代,而不需要等到它们的年龄达到 15。(注意:这里 S 区指的是 S0 或者 S1 的空间,而不是总的 S,总的在这里是 200MB)

这个机制使得 JVM 能够根据实际情况动态调整对象的晋升策略,从而优化垃圾收集的性能。通过这种方式,JVM 尽量保持 S 区空间的有效利用,同时减少因年轻代对象过多而导致的频繁 GC。

1.2.3.大对象直接进入老年代

如果对象的大小超过了预设的阈值(可以通过-XX:PretenureSizeThreshold 参数设置),这个对象会直接在老年代分配,因为大对象在年轻代中经常会导致空间分配不连续,从而提早触发 GC,避免在 E 区及两个 S 区之间来回复制,减少垃圾收集时的开销。

1.2.4.临时晋升

在某些情况下,如果 S 区不足以容纳一次 YGC 后的存活对象,这些对象也会被直接晋升到老年代,即使它们的年龄没有达到晋升的年龄阈值。这是一种应对空间不足的临时措施。

1.3 老年代的 GC 触发时机

一旦老年代对象过多,就可能会触发 FGC(Full GC),FGC 必然会带着 Old GC,也就是针对老年代的 GC 而且一般会跟着一次 YGC,也会触发永久代的 GC,但具体触发条件和行为还取决于使用的垃圾收集器,文章的最后会简单的介绍下垃圾收集器。

Serial Old/Parallel Old

当老年代空间不足以分配新的对象时,会触发 FGC,这包括清理整个堆空间,即年轻代和老年代。

CMS

当老年代的使用达到某个阈值(默认情况下是 68%)时,开始执行 CMS 收集过程,尝试清理老年代空间。如果在 CMS 运行期间老年代空间不足以分配新的对象,可能会触发一次 Full GC。 启动 CMS 的阈值参数:-XX:CMSInitiatingOccupancyFraction=75,-XX:+UseCMSInitiatingOccupancyOnly

G1

G1 收集器将堆内存划分为多个区域(Region),包括年轻代和老年代区域。当老年代区域中的空间使用率达到一定比例(基于启发式方法或者显式配置的阈值)默认 45%时,G1 会计划并执行 Mixed GC,这种 GC 包括选定的一些老年代区域和所有年轻代区域的垃圾收集。

Mixed GC 的阈值参数-XX:InitiatingHeapOccupancyPercent=40,-XX:MaxGCPauseMillis=200

2.JVM 优化调优目标:

2.1JVM 调优指标

低延迟(Low Latency):GC 停顿时间短。

高吞吐量(High Throughput):单位时间内能处理更多的工作量。更多的是 CPU 资源来执行应用代码,而非垃圾回收或其他系统任务。

大内存(Large Heap):支持更大的内存分配,可以存储更多的数据和对象。在处理大数据集或复杂应用时尤为重要,但大内存堆带来的挑战是 GC 会更加复杂和耗时。

但是不同目标在实现是本身时有冲突的,为什么难以同时满足?

低延迟 vs. 高吞吐量:要想减少 GC 的停顿时间,就需要频繁地进行垃圾回收,或者采用更复杂的并发 GC 算法,这将消耗更多的 CPU 资源,从而降低应用的吞吐量。

低延迟 vs. 大内存:大内存堆意味着 GC 需要管理和回收的对象更多,这使得实现低延迟的 GC 变得更加困难,因为 GC 算法需要更多时间来标记和清理不再使用的对象。

高吞吐量 vs. 大内存:虽然大内存可以让应用存储更多数据,减少内存管理的开销,但是当进行全堆 GC 时,大内存堆的回收过程会占用大量 CPU 资源,从而降低了应用的吞吐量。

2.2 如何权衡

在实际应用中,根据应用的需求和特性,开发者和运维工程师需要在这三个目标之间做出权衡:

2.2.1Web 应用和微服务 - 低延迟优先

场景描述:对于用户交互密集的 Web 应用和微服务,快速响应是提供良好用户体验的关键。在这些场景中,低延迟比高吞吐量更为重要。

推荐收集器:大内存应用推荐 G1,内存偏小可以使用 CMS,CMS 曾经是低延迟应用的首选,因其并发回收特性而被广泛使用。不过由于 CMS 在 JDK 9 中被标记为废弃,并在后续版本中被移除可以使用极低延迟 ZGC 或 Shenandoah。这两种收集器都设计为低延迟收集器,能够在大内存堆上提供几乎无停顿的垃圾回收,从而保证应用的响应速度,但是支持这两个回收器的 JDK 版本较高,在 JDK8 版本还是 CMS 和 G1 的天下。

2.2.2 大数据处理和科学计算 - 高吞吐量优先

场景描述:大数据处理和科学计算应用通常需要处理大量数据,对 CPU 资源的利用率要求极高。这类应用更注重于高吞吐量,以完成更多的数据处理任务,而不是每个任务的响应时间。

推荐收集器:Parallel GC。这是一种以高吞吐量为目标设计的收集器,通过多线程并行回收垃圾,以最大化应用吞吐量,非常适合 CPU 资源充足的环境。

2.2.3. 大型内存应用 - 大内存管理优先

场景描述:对于需要管理大量内存的应用,例如内存数据库和某些缓存系统,有效地管理大内存成为首要考虑的因素。这类应用需要垃圾回收器能够高效地处理大量的堆内存,同时保持合理的响应时间和吞吐量。

推荐收集器:G1 GC 或 ZGC。G1 GC 通过将堆内存分割成多个区域来提高回收效率,适合大内存应用且提供了平衡的延迟和吞吐量。ZGC 也适合大内存应用,提供极低的延迟,但可能需要对应用进行调优以实现最佳性能。

3.JVM 优化一般是针对于两种场景

3.1 新应用上线,通过预估核心接口流量进行压测,观察 JVM 的 GC 情况并调优

压测需要观察那些重要的指标呢

•YGC 与 FGC 频率和耗时

•YGC 过后多少对象存活

• 老年代的对象增长速率

通过 jstat 观察出来上述 JVM 运行指标!

3.2 老应用通过监控收到 JVM 异常反馈,或者程序出现下列问题进行优化

3.2.1 应用出现 OutOfMemory 等内存异常

(1)堆内存溢出 Java heap space

对象持续创建而不被回收或者来不及回收,导致堆内存耗尽。

超预期请求:面临突发的高并发请求或处理大量数据时,创建了大量线程和对象,GC 回收后的空间,不足以放下存活的对象就会造成 OOM。需要我们做好流量控制和预估,然后针对这种情况提前扩容或者限流。

内存泄漏:大量对象引用没有释放,JVM 无法对其自动回收,常见于使用了 File 等资源没有回收,是否使用 JDK 线程池工具等,都是编码异常需要导出 dump 文件针对代码进行分析。

滥用缓存:本地缓存工具占用大量内存,导致堆使用空间变小,需要合理设置缓存大小以及超时时间

大量对象:再循环中创建大量对象导致堆内存被占满,避免在循环中创建对象。重复对象使用池化技术

大对象或大数组:创建超大数组,上传或者导出大文件,查询不带条件拖库,编码做好边界限制,有一个良好的编码习惯。

(2)元空间溢出 Metaspace

元空间的溢出通常是因为加载的 class 数目太多或体积太大

例如:动态生成大量 Class 对象,比如某些框架(如 OSGi、ASM)动态生成大量的类,这些类占用的空间可能超过了元空间的限制,或者加载了大量的第三方库,这些库中包含的类和常量占用了大量的方法区空间。如果是正常类加载需要调大元空间-XX:MaxMetaspaceSize,否则需要导出 DUMP 文件,分析是否存在重复类

(3)虚拟机栈和本地方法栈溢出

线程请求的栈深度超过了虚拟机栈和本地方法栈允许的最大深度。这种情况通常发生在深度递归调用的情况下(-Xss 参数设置栈的大小)。

应用创建了过多线程,超出了系统承载能力,尤其是在 32 位系统上,每个线程的栈空间(默认 1MB)会占用一定的地址空间,可能会导致系统无法分配足够的地址空间给新的线程。

(4)直接内存溢出 Direct buffer memory

Java 允许应用程序通过 Direct ByteBuffer 直接访问堆外内存,许多高性能程序通过 Direct ByteBuffer 结合内存映射文件(Memory Mapped File)实现高速 IO。Direct ByteBuffer 的默认大小为 64 MB

•检查堆外内存使用代码,排查是否正确使用 ByteBuffer.allocateDirect

•检查是否直接或间接使用了 NIO,如 netty,jetty 等。

•通过启动参数 -XX:MaxDirectMemorySize 调整 Direct ByteBuffer 的上限值。

3.2.2 Heap 内存(老年代)持续上涨达到设置的最大内存值;

老年代持续上涨是 JVM 优化的重要指标,但是老年代持续上涨有多种原因

内存泄漏:最开始的表现也是老年代的持续上涨,触发 FGC 无法回收抛出 OOM,系统宕机!

正常情况:可能是因为流量徒增导致年轻代处理不过来,临时移入老年代,执行 FGC 后内存明显下降!

大对象:大对象直接分配在老年代,触发 FGC 后内存明显下降!

年轻代的 S 区设置过小:E 区正常回收后存活的对象,在 S 区放不下直接晋升到老年代,有一个大坑就是 JAVA8 默认收集器 Parallel Scavenge 为了处理更大的吞吐量会动态调整 S 区,在线上运行一段时间后 S 区会变得很小,导致大量对象进入到老年代,我在优化实战中排查过这个问题

3.2.3 FGC 次数频繁

频繁进行 FGC 如果出现 OOM 按照 3.2.1 进行排查

频繁 FGC 但是内存能被回收按照 3.2.2 进行排查

3.2.4 GC 停顿时间长

YGC 停顿


可以看看这篇文章总结的挺好的:JVM性能调优--YGC

FGC 停顿

FGC 的触发一般是老年代或者元空间内存不足,FGC 执行本身是比较耗时的操作,会回收整个堆内存以及元空间,我们在优化 JVM 尽量避免 FGC,或者尽量少的 FGC。FGC 停顿指标需要结合 FGC 执行频率,以及历史执行时间来看如果是因为内存空间大导致回收慢可以选择 G1 针对大内存进行处理

总结:

JVM 优化没有拿过来直接用的方案,所有好的 JVM 优化方案都是在当前应用背景下的,还是开头那句话 JVM 调优不是常规手段,如果没有发现问题尽量不主动优化 JVM,但是一定要了解应用的 JVM 运行情况,这时候好的监控就显得格外重要。

那么好的 JVM 应该是什么样的呢?简单的说就是尽量让每次 YGC 后的存活对象小于 S 区域的 50%,都留存在年轻代里。尽量别让对象进入老年代。尽量减少 FGC 的频率,避免频繁 FGC 对 JVM 性能的影响

了解了 JVM 优化的基本原理之后,实战就需要在日常中积累了,墨菲定律我觉得在这个场景很适用,不要相信线上的机器是稳定的,如果观察到监控有异常,过一会可能恢复了就不了了之,要敢于去排查问题,未知的总是令人恐惧的,在排查的过程中会加深自己对 JVM 的理解的同时,也会对应用更有信心。

发布于: 2024-08-02阅读数: 17
用户头像

还未添加个人签名 2024-01-12 加入

京东零售那些事,有品、有调又有料的研发资讯,带你深入了解程序猿的生活和工作。

评论

发布
暂无评论
后端开发和你聊聊JVM如何优化_后端_京东零售技术_InfoQ写作社区