探秘Java:谈一谈JVM调优

2022-11-08 09:22:50 浏览数 (1)

“人生苦短,不如养狗 作者:Brucebat.Sun ”

一、前言

  相信无论是初识Java的新手还是混迹职场多年的老鸟,或多或少都听过甚至深入研究过JVM调优相关的原理。就笔者而言,对于JVM调优的理论学习一直在断断续续地进行着,但真正意义上的实践活动却从未开展过。直到前一段时间自己在线上进行测试时发现机器出现明显卡顿的情况,才真正有了一次践行理论的机会。正所谓趁热打铁,趁着这次调优的体验还热乎,笔者准备结合过往学习的理论进行一次小小的总结。

  下面我们将基于以下三个部分进行探讨和总结:

  这里需要点明,由于笔者在工作中仍然使用的是JDK1.8进行开发,且生产环境使用的垃圾收集器为CMS,所以下面的讨论和总结更多的会基于这样一个基本的环境配置进行。

二、JVM基础

  在这一部分中我们不会关注JVM运行时内存区域、Java内存模型等知识点,而是会将关注点放在JVM提供的一个极其重要的能力——内存自动化管理上。这个能力既是JVM最重要的特性之一,也是JVM需要进行调优的根本原因。

  JVM的内存自动化管理主要处理以下两件事情:

  • 自动给对象分配内存;
  • 自动回收分配给对象的内存,也即垃圾收集机制;

  需要注意的是,前者的执行策略会受到后者执行策略的影响,也就是说经典分代收集理论不仅会应用于对象内存(或者说垃圾)收集阶段,同时也会在对象内存分配时产生作用。

“分代收集理论:分代收集理论主要建立在两个分代假说之上——弱分代假说和强分代假说。前者指明了对大多数对象都是朝生夕死的特性,后者则指明了熬过越多次垃圾收集的对象就越难以消亡。在分代收集理论中,垃圾收集器会将Java堆划分成不同的区域,总体来看是按照对象年龄来划分不同的区域(新生代、老年代),但在每个区域内部又会按照具体的收集规则进行进一步的划分。垃圾收集器在进行对象内存回收时会按照上面划分的不同区域进行分别回收,每次只回收其中一个或者某些部分的区域,这也就有了我们经常听到的Minor GC、Major GC、Full GC这样的回收类型。 ”

2.1 内存自动分配

  在Java当中流传着这样一句话:一切皆对象。当我们使用Java语言进行业务逻辑编写时,所有的逻辑操作和数据处理都会封装在类当中,在实际使用时需要创建对应类的对象实例来进行具体的方法执行。在这一过程中,我们创建的对象实例需要分配一定的内存空间来维护对象自身的一些信息和持有的数据等内容。JVM在进行内存分配时遵循了以下原则:

  • 对象优先在Eden区分配:在经典分代设计中,新创建的对象优先会在新生代中进行内存的分配。而使用了标记-复制算法的垃圾收集器则会将新生代划分为一块较大的Eden空间和两块较小的Survivor空间,在ParNew CMS的组合当中默认情况下三者的比例为8:1:1。当然,三者的比例并不是固定的,JVM提供了-XX:SurvivorRatio参数来进行三者比例的调整。新创建对象的内存分配会优先在Eden空间中进行,当Eden分区的内存空间无法满足内存分配的需要时,虚拟机会发起一次MinorGC进行新生代内存空间的回收。
  • 大对象直接进入老年代:大部分情况下,新创建的对象会按照上面的原则进行内存分配,但是如果一个对象非常庞大需要大量连续内存空间时,此时会直接在老年代中为其分配内存。设定这一原则的主要目的是为了避免新生代在进行YGC时Eden区域和Survivor区域之间来回复制,从而产生大量的内存拷贝情况。JVM内部设置了一个阈值来判断一个对象是否为大对象,同时提供了-XX:PretenureSizeThreshold参数来进行阈值的调整(注意该参数只在Serial和ParNew两款垃圾收集器中生效)。在日常开发中,典型的大对象有超长的字符串对象以及数组对象。
  • 长期存活的对象会进入到老年代:新创建的对象会优先在Eden区进行分配,但是在经过Minor GC后仍然存活并能够被Survivor容纳的话,那么对象的年龄就会增加一岁。往后每经历过一次Minor GC并存活下来,对象的年龄都会增加一岁,直到达到新生代对象的年龄限制,达到年龄限制的对象会被迁移到老年代中。JVM提供了-XX:MaxTenuringThredshold来进行年龄限制的调整。
  • 动态对象年龄判定:新生代对象晋升老年代的年龄限制并不完全是按照上面的参数进行判断的,如果Survivor空间中相同年龄的对象所占的空间大小达到了该空间的一半,则年龄大于或者等于该年龄的对象就可以直接进入到老年代中。

2.2 内存自动回收(垃圾回收机制)

  对象内存自动回收机制,也即我们经常听到的垃圾回收机制。对象内存自动回收机制的目的是为了将内存空间中不再使用的对象移除,为需要内存空间的对象腾出地方。

  让我们再进一步探究一下达成上述这一目的的原因是什么?这主要是由于Java对象“朝生夕死”的特性造成的。在一个Java应用程序运行的过程中,会有大量的对象诞生,同时会有大量的对象死亡(不再被使用),如果不及时将死亡的对象从内存空间中清理出去,那么程序将无法再进行正常的运转。这就如同刚吃饱饭的你是没有办法再吃一顿烧烤或者火锅的,生吞硬塞的下场只有胀破肚皮。

  为了实现这一目的,JVM需要做到以下三件事情:

  • 判断一个对象是否死亡
  • 标记这个死亡的对象
  • 回收死亡对象的内存

  第一件事情的处理是通过可达性分析算法来完成,目前市面上已知的垃圾收集器都是基于这一算法来进行对象存活的判定。

  第二件事情的处理会根据第三件事情的处理方式不同而采取不同的逻辑执行,主要分为两种,标记所有死亡的对象或者标记所有存活的对象。为了保证标记的准确性,JVM在进行对象标记时需要暂停用户线程,这一过程被形象地描述为“Stop The World”。

  第三件事情的处理主要有三种方式:清除、复制和整理。

直接清除意味着直接将所有标记为死亡的对象从内存空间中移除,这种方式的回收会导致内存空间产生大量不连续的内存碎片,过多的内存碎片会导致对象在分配内存时找不到连续可用的内存空间而触发新一次的垃圾收集动作。

复制操作中对于对象的标记逻辑和直接清除中的逻辑正好相反,复制操作会将所有标记为存活的对象从原有的内存区域复制到另一块空闲的连续内存空间中,然后对原先的空间直接进行清理操作。为了实现复制操作,需要将内存空间划分为两块(半区复制)或者三块(Appel式回收)。这种方式的回收能够保证内存空间中不出现内存碎片,但是在对象存活率较高时这种方式的回收效率就会大大降低。

整理操作为了解决复制操作中存在的问题,采取了将所有标记以存活的对象向内存空间的一端移动,待移动完毕后将其余空间清除的方式进行内存的回收。但这一操作的开销相当大,并且在执行过程中也需要暂停用户线程。

  从上面的分析可以看到,JVM在实现内存回收的过程中需要暂停用户线程(后续简称为STW),主要在以下两个阶段:

  1. 首先是标记阶段,标记阶段的STW主要是为了避免GC线程和用户线程并发执行时对象引用链在被GC线程读取的情况下又被用户线程变更导致的GC线程回收对象错误的问题。
  2. 除此以外,在复制/整理阶段也需要暂停用户线程,这里主要是为了避免GC线程在复制或者移动对象过程中和用户线程新产生的对象在进行内存空间分配时产生线程安全问题。

  用户线程的暂停会带来应用程序响应的延迟(这就是我们需要调优的目标之一),而延迟的高低则取决于标记的速度和复制/整理的速度。这两者的处理速度又会受到待处理对象数量的多寡影响,而在垃圾收集基于一致性快照的前提下,对象数量的多寡则取决于内存空间的大小。

三、调优目标

  正所谓:成也萧何,败也萧何。JVM提供的内存自动化管理能力确实给我们的开发带来了很多的便利,让开发者能够更专注于应用程序本身逻辑的设计与实现,但带来便利的同时也带来了一些新的问题。对于一个应用程序而言,一般会比较关注两个指标——响应速度和吞吐量。前者在交互型应用程序中更受关注,而后者则是在批任务处理程序中更受重视,所以在不同的系统中对于这两个指标的侧重会各有不同。而在JVM当中,这两个指标的好坏都会受到垃圾收集过程中停顿时间的影响。

响应速度,即指用户程序执行单个用户请求的时间。用户程序执行用户请求的效率会受到分配资源的情况的影响,而这里所说的资源会包含计算资源(CPU时间)存储资源(内存),有时也会受到IO资源的影响,这里我们主要讨论前两者。从上面的分析可以看到,想要提升响应速度就需要在这一次请求执行过程中给予用户线程足够的CPU时间和内存,在JVM当中就意味着需要提升垃圾收集的效率提高对象内存分配的空间。后者会受到硬件资源的限制,这里我们更关注的是在硬件资源固定的情况下如何去优化前者。

吞吐量,即指在整个程序运行过程中用户程序运行时间在整个程序总运行时间中的占比,具体计算公式如下:

吞吐量=frac{用户程序运行时间}{用户程序运行时间 垃圾收集运行时间}

  从上面的公式中可以看到吞吐量的提升同样需要优化垃圾收集的效率,但需要注意的是在响应速度提升和吞吐量提升中对于垃圾收集效率提升的侧重点是不一样的。垃圾收集的效率主要受到两个方面的因素影响,一个是垃圾收集的频率,一个是单次垃圾收集的时间。

  对于响应速度而言,垃圾收集效率的判断会被限制在一次用户请求执行的生命周期内。对于相同的请求,用户线程的执行时间是一样的,只要能降低在这次请求中垃圾收集的时间,响应速度就可以得到提升。由于单次用户请求的执行时间不会很长,一次执行周期内可能发生的垃圾收集次数也相对较少,所以垃圾收集的效率提升就会侧重于减少垃圾收集的停顿时间。

  对于吞吐量而言,垃圾收集的效率则需要在整个应用程序运行的生命周期内进行判断。由于应用程序的运行时间较长,和整个应用程序的运行时间相比,单次垃圾收停顿时间的优化并不十分明显,但是整体收集频率的降低则会带来很明显的垃圾收集运行时间的降低。

四、调优内容

  在之前的内容中我们说过目前市面上经典的垃圾收集器都是基于分代回收理论实现的,垃圾收集器实际进行垃圾回收时会根据不同年龄代的区域分开回收,主要会有作用于新生代的Minor GC、作用于老年代的Major GC(只有CMS会进行该行为)、作用于整个新生代和部分老年代的Mixed GC(只有G1会进行该行为)以及作用于整个Java堆和方法区的Full GC。所以在进行实际调优时,我们也需要结合分代收集理论来进行相应内容的调节。

  经过之前的分析,我们可以了解到调优的目标有两个:为了提升响应速度需要减少垃圾收集的停顿时间以及为了提升吞吐量需要减少垃圾收集的频率。我们假定垃圾收集器的标记和回收速度一定,需要扫描标记的对象越多(或者说需要扫描标记的内存区域越大),垃圾收集的停顿时间也就越长。所以如果要减少垃圾收集的停顿时间就需要将对应内存区域的大小减少。但内存区域的大小决定了对象分配时是否有足够的空间进行分配,如果没有空间进行对象分配,JVM就会触发对应类型的GC进行内存空间的回收来保障对象的空间分配操作得到执行。这就意味着,内存空间越大,垃圾收集的频率就会越低,内存空间越少,垃圾收集的频率就会越高。这就带来了我们第一个需要调整的内容,即Java堆内存。

  结合内存分配的策略,我们可以知道对于Java堆内存,我们可以对新生代的大小、新生代中Eden区域与Survivor区域的比例以及老年代直接分配对象内存的准入大小进行调整。分代收集理论告诉我们,绝大多数的对象都是“朝生夕死”的,所以我们在基于优化响应速度或者吞吐量进行内存大小调节的同时,还需要尽量的保证“短命”对象(尤其是“短命”的大对象)都能够在新生代中分配到足够的空间,避免这类对象被误分配到老年代中,如果大量的此类对象进入到老年代,就会很容易引发Full GC致使垃圾收集效率降低。这就需要将新生代的大小或者Survivor的区域调大,再或者需要将老年代对象内存的准入大小调大。当然这种调整还是需要基于两种主要指标的优化来进行。

  除了调整内存,还可以通过调整对象年龄判断来进行垃圾收集效率的优化。在Survivor空间足够的情况下,可以适当调高晋升老年代的对象年龄判断,通过这一方式可以避免大量对象涌入到老年代中而造成频繁的Full GC。

  通过以上两种手段基本可以完成大部分情况下的调优处理,但是有些时候基于当前垃圾收集器的调优还是不能达到理想的状况。那么此时我们可以借鉴这样一句话:解决不了问题,那就解决提出问题的人。哈哈哈~开个玩笑,此时可以根据应用程序实际的运行情况变更使用的垃圾收集器,使用更加适合或者更加先进的垃圾收集器来解决我们的问题。

  如果这样还是不行,朋友,你可能需要看一看你的代码是不是写的有点问题了。

五、总结

  以上的一些分析和总结都是基于ParNew CMS垃圾收集器或者之前的经典的垃圾收集器,对于诸如G1、ZGC等新一代的垃圾收集器并不非常适用。这么说的原因主要在于随着垃圾收集器的不断进化,诸如上面提到的新生代大小的调节已经不需要人工进行处理,G1收集已经做到了可以根据应用程序运行时进行动态的调整以保证程序以最优的情况进行执行。这也是笔者在逛一些论坛或者看一些博客时,看到有人评论可能在未来的某一天JVM可以真正意义上实现内存的自动化管理,再也不需要人工调优。

  最后,疫情还在继续,希望大家出行注意防护,身体健康,每天保持好心情~~

0 人点赞