JVM调优–理论篇从理论层面介绍了如何对JVM调优。这里再写一篇WIKI,尝试记录下JVM参数使用的最佳实践,注意,这里重点介绍HotSpot VM的调参,其他JVM的调参可以类比,但不可照搬。
Java版本选择
基于Java开发应用时,第一个要考虑的问题,就是Java版本的选择。一般情况下,一个团队或一个组织使用相同的Java版本。由于现在Java版本的发布周期是半年一个版本,所以在实际应用中应选择发行版本,目前支持的发行版本有Java 11、Java 17、Java 21。当然,很大一部分厂商使用Java 8。尽管Java官方目前已不在直接支持Java 8的发行版本,但是Java 8因其广泛且长久的使用,已经非常稳定,且部分有能力的厂商还在维护Java 8,所以Java 8也是一个不错的选择。更多Java版本信息可以参考官网。注意,Java提供的版本均是收费版本,如果需要获取免费版本,可以获取OpenJDK。OpenJDK是Java语言的一个开源实现,OpenJDK采用GPL协议,主要组件包括Hotspot JVM、Java核心类库和javac编译器。OpenJDK具有开源免费、跨平台、高性能和多版本支持等特点,适用于Java应用程序开发、桌面应用程序等多种场景。可以从adoptium网站获取已编译的OpenJDK版本。
JVM类型选择
Java提供了Java Language and Virtual Machine Specifications,约定了JVM的实现细节,这样不同的厂商可以实现自己的JVM。目前,主流的JVM有:HotSpot VM、IBM JVM、Open JDK、GraalVM等。在选用JVM类型时,根据稳定性、高性能、安全性、可维护性、费用等方面组合考虑。如在Java 8中,通常使用Oracle的HotSpot JVM。如果期望使用免费版本,可以选择Open JDK。
JVM部署平台相关选择
JVM部署时,是选择32位JVM,还是64位JVM。HotSpot VM默认的执行位数取决于所安装的JDK版本和操作系统。在32位操作系统上,只能运行32位的JDK,因此HotSpot虚拟机也只能在32位模式下执行。而在64位操作系统上,可以选择安装32位或64位的JDK。如果安装了64位的JDK,那么HotSpot虚拟机将默认在64位模式下执行。推荐在64位操作上安装64位的JDK版本。注意,不同操作系统上,对应的版本不同,所以在选择JVM位数的同时,也要注意选择和待部署操作系统向匹配的版本。此外,针对不同的CPU架构,JVM的版本也不尽相同。在选择JVM执行位数时,还应考虑CPU架构。
JVM部署模式选择
JVM部署模式指的是将应用部署到单个JVM实例上,还是部署多个JVM实例上。目前应用的主流架构是微服务架构。对一个应用来说,常常需要拆分成多个独立的微服务。对于每个微服务来说,为了保证其高可用,通常需要多个实例。所以,对于一个大型的应用来说,是将其拆分成微服务单独部署。而对一个微服务,会对应多个服务实例,且每个每个服务部署在隔离的环境中,如Kubernetes的Pod中。JVM部署模式是架构层面需要考虑的事情,针对一个Java进程来说,无需考虑。
JVM运行模式
为Java应用选择JVM运行模式,就是指定以何种方式运行JVM,可选项有:client模式、server模式和混合模式。注意,混合模式是在Java 7及之后的版本才引入。推荐使用混合模式,这也是Java 8及之后的版本默认的运行模式。该模式可以很好地融合client模式和Server模式的主要特征。如无特殊说明,使用混合模式即可,除非有明确诉求需要使用client模式或server模式。选用server模式启动JVM的示例命令如下:
java -server -jar hello-world.jar
上述示例命令中,使用server模式启动了一个hello-world的Java应用。
垃圾收集器选择
JVM的一个重要职责是实现内存的自动化管理。而实现内存的自动化管理的关键就是垃圾收集器。垃圾收集主要关注的性能属性有:吞吐量、延迟、内存占用。其中:
(1) 吞吐量:是评价垃圾收集器能力的重要指标之一,指不考虑垃圾收集引起的停顿时间或内存消耗,垃圾收集器能支撑应用达到的最高性能指标。
(2) 延迟:也是评价垃圾收集器能力的重要指标,度量标准是缩短由于垃圾收集引起的停顿时间或完全消除因垃圾收集所引起的停顿,避免应用运行时发生抖动。
(3) 内存占用:垃圾收集器流畅运行所需要的内存数量。
这其中任何一个属性性能的提高几乎都是以另一个或两个属性性能的损失作代价的。换句话说,某一个属性上的性能提高总是牺牲另一个或两个属性。然而,对大多数应用来说,极少出现这三个属性的重要程度都同等的情况。很多时候,某一个或两个属性的性能要比另一个重要。哪个属性最重要,并将其映射到应用的系统需求,对应用而言非常重要。
同一个JVM中,可能包含多种垃圾收集器实现。在选择垃圾收集器时,要根据应用的需求,进行一个初步的选择。在HotSpot VM中,如果应用对停顿时间要求较为严格(即不希望因为垃圾收集而导致过多的暂停),那么可能需要选择那些停顿时间较短的垃圾收集器,如CMS收集器或G1收集器。相反,如果应用对停顿时间要求不那么严格,可以考虑使用串行收集器或并行收集器,它们在垃圾收集时可能会暂停所有工作线程。如果对较小的堆或较低配置的硬件,可能更适合使用简单的串行收集器或者并行收集器。对于大内存堆和多核CPU的环境,可以考虑使用G1等垃圾收集器,它们更适合大规模的堆和高配置的硬件。垃圾收集器的选择,要根据业务的特征,进行充分的测试。初步选择后,如果经过测试后,并不能达到预期,则要考虑换用垃圾收集器,并重新进行测试。
在进行垃圾收集验证时,一般从默认垃圾收集器开始。Java 8默认的垃圾收集器是Parallel Scavenge和Parallel Old。这是Oracle JDK 8中的默认设置,适用于大多数应用程序场景。但在某些特定场景下,可能需要选择其他更适合的垃圾收集器,如CMS收集器或G1收集器。指定CMS收集器或G1收集器的示例命令如下:
# 指定使用CMS垃圾收集器
java -XX:+UseConcMarkSweepGC hello-world.jar
# 指定使用G1垃圾收集器
java -XX:+UseG1GC -jar hello-world.jar
CMS收集器在Java 9之后已被标记为废弃(deprecated),并在后续的Java版本中被移除。因此,如果正在使用Java 9或更高版本,并且希望使用类似的低延迟垃圾收集器,应该考虑使用G1(Garbage-First)收集器,它是Java 9之后的默认收集器,并且也旨在提供可预测的停顿时间。G1收集器在Java 9及更高版本中通常是一个更好的选择,因为它提供了更好的性能和更灵活的垃圾收集配置选项。不过,具体的选择应该基于你的应用程序的需求和性能目标。
垃圾收集器的进一步优化
不同的垃圾收集器还提供了一系列参数,帮助进一步对垃圾收集器进行调优。这里以G1收集器为例,介绍下常用的调优命令。
java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -jar hello-world.jar
-XX:MaxGCPauseMillis参数用来设置垃圾收集的最大暂停时间,这样垃圾收集对延迟或响应的影响就变得可控。
垃圾收集报告
监控JVM的垃圾收集非常重要,因为垃圾收集对应用的吞吐量和延迟都有着深刻的影响。HotSpot VM报告垃圾收集数据几乎没有什么额外开销,推荐在测试环境启用该配置,但不建议在生产环境中使用。生成垃圾收集统计数据的命令行如下:
# Java 8及更早版本启用记录详细的GC日志
java -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:gc.log -jar hello-world.jar
# Java 9及更高版本启用记录详细的GC日志
java -Xlog:gc*:file=gc.log -jar hello-world.jar
上述命令中,-XX:PrintGCDetails参数用来打印比-verbose:gc参数更多有价值的垃圾收集信息。注意,在生产环境中启用-verbose:gc或-XX:PrintGCDetails参数,或其他详细的GC日志可能会产生大量的输出,这可能会对应用程序的性能产生轻微的影响。因此,通常建议仅在开发和测试阶段使用这些参数,以便在必要时进行调优和分析。此外,-XX:PrintGCDetails参数打印的日志包含时间戳前缀,这个时间是自JVM启动以来的秒数。为了生成标准时间戳,形如YYYY-MM-DD-T-HH-MM-SS.mmm-TZ,可以补充-XX:PrintGCDateStamps参数。为了更好的分析垃圾收集器的统计数据,还可以使用-Xloggc:<filename>将垃圾收集的统计数据直接输出到文件(filename是保存的文件名)。离线分析可以处理时间范围更广的垃圾收集数据,查找问题时也不会直接影响线上应用。
在Java 9及更高的版本,使用新的日志系统,称为统一日志记录(Unified Logging),它使用-Xlog参数来配置各种日志,包括垃圾收集(GC)日志。-Xlog:gc是启用与垃圾收集相关的所有日志条目的快捷方式。其中,gc:file=<filename>是指将GC日志输出到指定的文件中。
应用GC停止时间和应用GC并发时间
使用-XX:+PrintGCApplicationConcurrentTime和-XX:PrintGCApplicationStoppedTime,HotSpot VM可以报告应用在安全点操作之间的运行时间,以及阻塞Java线程的时间。利用这两个命令行选项观察安全点操作有助于理解和量化延迟对JVM的影响,也可以用来辨别是JVM安全点操作还是应用程序引入的延迟。命令使用示例如下:
java -XX:+PrintGCApplicationConcurrentTime -XX:+PrintGCApplicationStoppedTime -jar hello-world.jar
Java堆调优
HotSpot VM根据对象存活周期的不同,将内存划分三部分:老年代(Tenured Generation)和新生代(Young Generation)、永久代(Permanet Generation)。其中,老年代和新生代组合了堆空间,而永久代则在堆空间之外。在新生代中,存放新分配的对象,在老年代中,存放旧的或长期存活或大的对象,在永久代中,存放JVM加载的类元数据(如类的结构信息、常量池等)、类静态变量,等。在Java 8中,永久代被元空间所取代,元空间使用本地内存,可以动态扩展。HotSpot VM堆布局如下所示:
堆大小
-Xmx和-Xms设置堆的初始值和最大值。其中-Xmx设定了初始值及最小值,-Xmx设定了最大值。当-Xms指定的值小于-Xmx的值时,新生代及老年代空间大大小会根据应用的需求动态的扩展或缩减。对于吞吐量和延迟关注的Java应用应将-Xms和-Xmx设定为同一值。这样,就不会因为新生代和老年代空间的动态的扩展或缩减触发Full GC,从而降低应用的吞吐量和更长的延迟。如果-Xms指定的值比-Xmx的值大,那么会导致JVM在启动时就会因为无法满足内存分配要求而失败,并可能抛出一个错误。所以业务环境上,不可能存在-Xms值大于-Xmx值的情况。此外,如果堆的初始值设置过小,可能会导致应用在启动的时候进行扩容,从而延长启动时间。在需要优化启动时间的时候,可以观察启动期间是否有堆扩容动作。
HotSpot VM不指定Java堆的大小时,会根据物理机或虚拟机的物理内存或分配的物理内存的大小来计算默认的堆大小。具体的计算方式可能因JVM版本和操作系统而略有不同,但大致遵循如下规则:
(2) 对于64位JVM,默认的堆大小会根据物理内存的大小来计算。在物理内存小于192MB时,JVM的初始和最大堆大小通常为物理内存的一半。当物理内存大于192MB但小于1GB时,JVM的最大堆大小通常为物理内存的1/4。而当物理内存大于或等于1GB时,JVM的最大堆大小可能会根据JVM版本和操作系统的不同而有所变化,但通常会限制在一个合理的范围内,以避免过度消耗系统资源。
一般情况下,默认的堆大小不能够充分的利用物理内存,所以均需要手动设置JVM的堆大小。通常建议将JVM的最大堆大小设置为物理内存的1/4至1/2之间,但这只是一个大致的指导原则,具体设置需要根据实际情况进行调整。JVM除了堆空间会占用内存,栈空间、系统其他资源也会消耗内存,所以不建议把JVM的堆设置过大。应该在保证系统正常运行、满足业务需要、充分测试等前提下,合理设置JVM的堆大小。不建议JVM的堆最大值大于物理内存的80%。举例来说,对于物理内存为2GB的环境来说,如果当前环境,主要的业务进程就是Java应用,那么预留400MB的空间已经足够其他对象或进程使用,在经过充分测试后,可以设置Java堆的最大值为1600MB。
设置堆大小的示例命令如下:
java -Xmx1g -Xms512m -jar hello-world.jar
上述命令中,设置堆的最小值为512MB,堆的最大值为1GB。
新生代
在调优延迟或响应时间时,Minor GC的持续时间会影响应用的平均停滞时间,Minor GC的执行频率会影响平均停滞的发生频率。根据垃圾收集的统计数据、Minor GC的持续时间和频率可以确定新生代空间的大小。Minor GC需要的时间与新生代中可访问的对象数直接相关。通常情况下,新生代空间越小,Minor GC持续的时间越短。不考虑这对于Minor GC持续时间的影响,减少新生代空间又会增大Minor GC的频率。这是因为以同样的对象分配频率,较小的新生代空间在很短时间内就会被填满,增大新生代空间可以减少Minor GC的频率。
分析GC数据时,如果发现Minor GC的间隔时间较长,修正的方法是减少新生代空间。如果Minor GC频率太高,修正的方法是增加新生代空间。
计算平均持续时间和频率时,Minor GC的次数越多,平均持续时间及频率的统计也就越准确。另外,使用应用运行于稳定阶段时的Minor GC的值也是非常重要的。
调整新生代的示例命令如下:
# 新生代固定为256MB
java -Xms512m -Xmx1024m -Xmn256m -jar hello-world.jar
# 新生代动态调整,且最小值为128MB,最大值为256MB
java -Xms512m -Xmx1024m -XX:NewSize=128m -XX:MaxNewSize=256m -jar hello-world.jar
# 新生代固定比例,NewRatio=3,表示新生代和老年代的大小比例就是1:3,也即新生代占1/4
java -Xms512m -Xmx1024m -XX:NewRatio=3 -jar hello-world.jar
HotSpot VM中,支持同时设置-Xmn以及-XX:NewSize或-XX:MaxNewSize以及-XX:NewRatio。-Xmn的优先级最高,其次是-XX:NewSize、-XX:MaxNewSize,最后是-XX:NewRatio。注意,不建议同时使用功能有重叠的命令,这会给后面的维护带来不必要的误导。推荐使用-Xmn给新生代设置一个固定的大小。
老年代
在调优延迟或响应时间时,Full GC的持续时间会影响应用的最大停顿时间,Full GC的执行频率会影响应用的最大停滞发生的频率。发生于稳定态的Full GC的持续时间是应用的最差Full GC停顿时间。如果多个Full GC在稳定态发生,就按平均最差停顿时间计算。取样的数据越多,预测的结果越准确。
从老年代中减去活跃数据的大小可以得到可用老年代空间大小。需要多长时间才能填满老年代的空闲空间取决于新生代到老年代的提升率。提升率可以依据老年代空间占用的增长量以及每次Minor GC后新生代的空间占用计算得出。老年代的空间占用情况可以通过Minor GC之后Java堆的占用情况减去同一次Minor GC后新生代的空间占用得到。
如果预期或观测到Full GC的频率已经远不能达到应用的最差Full GC频率要求,就应该增大老年代空间的大小。这个方法可以帮助降低Full GC的评率。增大老年代空间时,注意保持新生代空间大小恒定。
如果修改老年代空间大小后,只观察到Full GC,很可能是老年代与新生代空间大小失去了平衡,导致应用只进行Full GC。这一情况通常源于即使老年代经过Full GC,仍不足以容纳所有从新生代提升的对象。标识老年代空间不够大的一个线索是每次Full GC之后,老年代几乎没有任何空间被回收,而新生代总有大量的对象占用空间。当老年代没有足够的空间接纳从新生代提升的对象时,这些对象机会被"退还"到新生代空间中。
JVM不支持直接调整老年代的大小,而是通过Java堆大小减去新生代的大小,间接的计算出老年代的大小。这里不在补充命令示例。
永久代或元空间
永久代用来存放JVM加载的类元数据(如类的结构信息、常量池等)、类静态变量,等。在Java 8及更高的版本中,永久代被元空间所取代,元空间使用本地内存,可以动态扩展。永久代空间大小设置示例命令如下:
java -XX:PermSize=64m -XX:MaxPermSize=256m -jar hello-world.jar
为了解决永久代的大小是有限的问题,Java 8引入了元空间。元空间使用本地内存(native memory)来存储类的元数据,其大小可以根据需要进行动态调整。这大大减少了类加载过程中的内存限制,并提高了应用程序的灵活性。元空间大小设置示例命令如下:
java -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m -jar hello-world.jar
默认情况下,元空间的大小是没有上限的,即它的大小受限于机器的物理内存。然而,为了避免因元空间无限增长而导致的内存溢出问题,需要设置一个合理的最大值。
调整Survivor空间的容量
调整Survivor空间容量一个应该谨记于心的重要原则:调整Survivor空间容量时,如果新生代空间不变,增大Survivor空间会减少Eden空间;而减少Eden空间会增加Minor GC的频率。因此,为了同时满足应用Minor GC频率的要求,就需要增大当前新生代的空间;即增大Survivor空间大小时,Eden空间的大小应保持不变。如果可以增大Minor GC的频率,则可以选择用一部分Eden空间来增大Survivor空间。如果内存足够,相对于减少Eden空间,增大新生代大小通常是更好的选择。保持Eden空间不变,Minor GC的频率就不会因为Survivor空间增大而发生变化。再次提醒,减少Eden空间会导致更频繁的Minor GC。因为调整Survivor空间是通过增加新生代实现,所以可以参考新生代的调整命令。此外,如果需要调整新生代中Eden和Survivor的比例,可以使用如下的示例命令:
java -Xms1024m -Xmx1024m -Xmn256m -XX:SurvivorRatio=8 -jar hello-world.jar
上述示例中,SurvivorRatio参数决定了Eden区与单个Survivor区之间的大小比例。这里设置SurvivorRatio为8,所以Eden区的大小将是单个Survivor区大小的8倍。
调整SurvivorRatio的值时,重点关注Survivor值缩小的场景。尽管调大Survivor空间会带来不必要的资源浪费,但是执行调大操作时,基本上都是发现Survivor不足时才去调大。所以应重点关注Survivor空间过小的场景。如果Survivor区太小,可能会导致对象在Survivor区之间频繁地复制(即Survivor溢出到老年代),这可能会增加Minor GC的频率和持续时间,并可能导致不必要的对象晋升到老年代。
调整晋升阈值
HotSpot VM在每次Minor GC时,都会计算晋升阈值以决定何时对一个对象进行提升。或者说,晋升阈值就是对象的年龄。一个对象的年龄就是它所经历的Minor GC次数。对象首次分配时,其年龄是0。下一次Minor GC之后,如果该对象还在新生代,其年龄加一,以此类推。新生代中年龄大于HotSpot VM计算出的晋升阈值的对象被提升到老年代。晋升阈值计算的依据是Minor GC之后新生代要容纳的可达对象需要的空间大小及目标Survivor空间占用的空间大小(如果Survivor空间过小,一次Minor GC可能会导致Survivor溢出,溢出的对象将直接提升到老年代)。可以使用-XX:MaxTenuringThreshold=<n>指定在对象年龄超过阈值后,将其提升到老年代。在Open JDK的版本中,MaxTenuringThreshold的默认值是15。但是,这个默认值可能会在不同的JVM实现或版本中有所不同。可以使用-XX:+PrintTenuringDistribution选项来启动JVM。这个选项会在每次Minor GC之后打印出Survivor区中对象的年龄分布信息,同时也会显示当前的MaxTenuringThreshold设置值。晋升阈值示例命令如下:
java -Xms1024m -Xmx1024m -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution -jar hello-world.jar
不建议将晋升阈值设置过小,这会导致最近分配的对象很快提升到老年代,同时造成老年代空间的迅速增大,引起频繁的Full GC。同时,也不建议将晋升阈值设置为远大于实际可能的最大值。这会造成对象长期存在于Survivor空间,直到最后溢出。一旦溢出,对象将会被全部提升到老年代,从而造成短期存在对象在长期存在对象之前被提升到老年代,严重影响对象老化机制的有效性。
禁用显式的垃圾收集
Java提供System.gc()接口,这样开发人员就可显式地出发垃圾收集。注意,这个方法并不会直接执行垃圾回收,而是向JVM发出一个建议,让JVM尝试回收不再使用的对象以释放内存。所以,调用System.gc()并不保证垃圾回收会立即执行,因为JVM有自己的垃圾回收策略和调度机制。JVM会根据其内部的算法和当前的系统状态来决定何时执行垃圾回收。
可以使用-XX:+DisableExplicitGC命令行选项通知HotSpot VM忽略显式的System.gc()调用。示例命令如下:
java -XX:+DisableExplicitGC -jar hello-world.jar
禁用显式的垃圾收集时应该慎重,它可能会对应用的性能造成较大的影响。还有可能出现这样场景,开发人员需要及时的对对象引用做处理,但与之对应的是垃圾收集却跟不上其节奏。建议除非有非常明确的理由,否则不要轻易地禁用显式的垃圾收集。与此同时,也建议只有在明确理由下才能在应用中使用System.gc()。
注意,禁用了System.gc(),JVM仍然会根据其内部算法和需要自动进行垃圾回收。这个参数只是禁用了对垃圾回收的显式请求,而不是禁用垃圾回收机制。
Java线程调优
Java是多线程语言,天然支持多线程处理。在进行多线程处理时,会遇到线程的堆栈大小不符合业务需求、线程竞争等问题。
Java线程的栈大小
对于运行在线程栈上的线程来说,会遇到栈溢出(StackOverflowError)的问题。除了死循环导致的栈溢出外,还有一种情况是线程栈过小,不符合业务的需求(递归深度或大量局部变量)。调整线程栈大小的示例命令如下:
java -Xss512k -jar hello-world.jar
Xss参数用来指定每个线程的虚拟机栈大小,直接影响线程虚拟机栈的栈帧数量,即方法调用的层级深度。Xss参数指定的大小理论上会影响JVM能够生成的最大线程数量,但实际上这个数量更多地受到操作系统的最大线程数配置、物理内存大小等因素的限制。不同Java版本中,Xss的默认值不同。
Java线程间锁竞争
Java支持多线程编程,对于多线程来说,无法保证线程之间不存在锁竞争或死锁等问题。在进行Java应用调优时,如果是多线程应用,且测试的场景使用到了多线程和锁,则有必要监控线程信息。可以使用JDK的jstack工具或主流的Java应用分析工具(如Arthas)抓取线程转储信息。
Java线程间锁竞争虽然不属于JVM调优范畴,但是和JVM架构息息相关,这里作为拓展内容做简单地讨论。
参考
《Java性能优化权威指南》 Charlie Hunt, Binu John 著, 柳飞, 陆明刚 译
https://segmentfault.com/a/1190000044547802 JVM 8 调优指南:如何进行JVM调优,JVM调优参数