[7] 性能调优和系统参数
[7] 性能调优和系统参数

在高性能硬件上部署程序,目前主要有两种方式:
  • 通过 64 位 JDK 来使用大内存;
  • 使用若干个 32 位虚拟机建立逻辑集群来利用硬件资源。

1. 使用 64 位 JDK 管理大内存

堆内存变大后,虽然垃圾收集的频率减少了,但每次垃圾回收的时间变长。 如果堆内存为 14 G,那么每次 Full GC 将长达数十秒。如果 Full GC 频繁发生,那么对于一个网站来说是无法忍受的。
对于用户交互性强、对停顿时间敏感的系统,可以给 JVM 分配超大堆的前提是有把握把应用程序的 Full GC 频率控制得足够低,至少要低到不会影响用户使用。
可能面临的问题:
  • 内存回收导致的长时间停顿;
  • 现阶段,64 位 JDK 的性能普遍比 32 位 JDK 低;
  • 需要保证程序足够稳定,因为这种应用要是产生堆溢出几乎就无法产生堆转储快照(因为要产生超过 10GB 的 Dump 文件),哪怕产生了快照也几乎无法进行分析;
  • 相同程序在 64 位 JDK 消耗的内存一般比 32 位 JDK 大,这是由于指针膨胀,以及数据类型对齐补白等因素导致的。

2. 使用 32 位 JVM 建立逻辑集群

在一台物理机器上启动多个应用服务器进程,每个服务器进程分配不同端口, 然后在前端搭建一个负载均衡器,以反向代理的方式来分配访问请求。
考虑到在一台物理机器上建立逻辑集群的目的仅仅是为了尽可能利用硬件资源,并不需要关心状态保留、热转移之类的高可用性能需求, 也不需要保证每个虚拟机进程有绝对的均衡负载,因此使用无 Session 复制的亲合式集群是一个不错的选择。 我们仅仅需要保障集群具备亲合性,也就是均衡器按一定的规则算法(一般根据 SessionID 分配) 将一个固定的用户请求永远分配到固定的一个集群节点进行处理即可。
可能遇到的问题:
  • 尽量避免节点竞争全局资源,如磁盘竞争,各个节点如果同时访问某个磁盘文件的话,很可能导致 I/O 异常;
  • 很难高效利用资源池,如连接池,一般都是在节点建立自己独立的连接池,这样有可能导致一些节点池满了而另外一些节点仍有较多空余;
  • 各个节点受到 32 位的内存限制;
  • 大量使用本地缓存的应用,在逻辑集群中会造成较大的内存浪费,因为每个逻辑节点都有一份缓存,这时候可以考虑把本地缓存改成集中式缓存。

3. 调优案例分析与实战

3.1 场景描述

一个小型系统,使用 32 位 JDK,4G 内存,测试期间发现服务端不定时抛出内存溢出异常。 加入 -XX:+HeapDumpOnOutOfMemoryError(添加这个参数后,堆内存溢出时就会输出异常日志), 但再次发生内存溢出时,没有生成相关异常日志。

3.2 分析

在 32 位 JDK 上,1.6G 分配给堆,还有一部分分配给 JVM 的其他内存,直接内存最大也只能在剩余的 0.4G 空间中分出一部分, 如果使用了 NIO,JVM 会在 JVM 内存之外分配内存空间,那么就要小心「直接内存」不足时发生内存溢出异常了。

3.3 直接内存的回收过程

直接内存虽然不是 JVM 内存空间,但它的垃圾回收也由 JVM 负责。
垃圾收集进行时,JVM 虽然会对直接内存进行回收, 但是直接内存却不能像新生代、老年代那样,发现空间不足了就通知收集器进行垃圾回收, 它只能等老年代满了后 Full GC,然后「顺便」帮它清理掉内存的废弃对象。 否则只能一直等到抛出内存溢出异常时,先 catch 掉,再在 catch 块里大喊 「System.gc()」。 要是虚拟机还是不听,那就只能眼睁睁看着堆中还有许多空闲内存,自己却不得不抛出内存溢出异常了。

4. 性能参数

本节来源:
建议最后再看这部分。

4.1 建议的性能参数

取消偏向锁:-XX:-UseBiasedLocking
JDK 6 开始默认打开的偏向锁,会尝试把锁赋给第一个访问它的线程,取消同步块上的 synchronized 原语。如果始终只有一条线程在访问它,就成功略过同步操作以获得性能提升。
但一旦有第二条线程访问这把锁,JVM就要撤销偏向锁恢复之前的状态,如果打开安全点日志,可以看到不少 RevokeBiasd 的纪录,像 GC 一样 STW 的干活,虽然只是很短的停顿,但对于多线程并发的应用,取消掉它反而有性能的提升,所以 Cassandra 就取消了它。
在新的 JDK 15 时偏向锁已被默认取消。

加大 Integer Cache:-XX:AutoBoxCacheMax=20000
Integer i = 3; 这语句有着 int 自动装箱成 Integer 的过程,JDK 默认只缓存 -128 ~ +127 的 IntegerLong,超出范围的数字就要即时构建新的 Integer 对象。可以根据实际情况,比如设为 20000 后,一些应用的 QPS 有足足 4% 的提升。为什么是 20000 呢,因为 -XX:+AggressiveOpts 里也是这个值。这个参数仅限于 HotSpot VM Server 模式下。

启动时访问并置零内存页面:-XX:+AlwaysPreTouch
启动时就把真实的物理内存分配给 JVM,可能令得 JVM 启动时慢上一点,但后面运行时会更流畅,如果没有此参数,则 JVM 启动的时候,分配的只是虚拟内存,当真正使用的时候才会分配物理内存,导致代码运行的时候,实时分配物理内存,代码运行速度变慢。ElasticSearch 和 Cassandra 都打开了它。

SecureRandom 生成加速:-Djava.security.egd=file:/dev/./urandom
此江湖偏方原因是 Tomcat 的 SecureRandom 显式使用 SHA1PRNG 算法时,初始因子默认从 /dev/random 读取会存在堵塞。额外效果是 SecureRandom 的默认算法也变成更合适的SHA1了。

4.2 可选的性能参数

禁用统计共享:-XX:+PerfDisableSharedMem
Cassandra 家的一个参数,一直没留意,直到发生高 I/O 时的 JVM 停顿。原来 JVM 经常会默默的在 /tmp/hperf 目录写上一点 statistics 数据,如果刚好遇到 PageCache 刷盘,把文件阻塞了,就不能结束这个 STW 的安全点了。
禁止 JVM 写 statistics 数据的代价,是 jps 和 jstat 用不了,只能用 JMX,而 JMX 取新老年代的使用百分比还真没 jstat 方便,VJTools 的 vjmxcli 弥补了这一点。

关闭热度衰减:-XX:-UseCounterDecay
禁止 JIT 编译器的方法调用计数器热度衰减。默认情况下,每次 GC 时会对调用计数器进行「砍半」的操作,导致有些方法一直「温热」,永远都达不到触发 C2 编译器其1万次计数的阀值。

关闭分层编译:-XX:-TieredCompilation
分层编译是 JDK 8 后默认打开的比较骄傲的功能,先以 C1 编译器编译,PGO 采样足够后再切换 C2 编译器编译。
但根据实测,性能最终略降 2%,可能是因为有些方法 C1 编译后 C2 不再编译了。应用启动时的偶发服务超时也多了,可能是忙于编译。所以我们将它禁止了,但记得利用前面的 -XX:-UseCounterDecay,避免有些温热的方法永远都要解释执行。

4.3 不建议的性能参数

优化参数集合:-XX:+AggressiveOpts
一些还没默认打开的优化参数集合,-XX:AutoBoxCacheMax 是其中的一项。但如前所述,关键系统里不建议打开。虽然通过 -XX:+AggressiveOpts-XX:-AggressiveOpts 的对比,目前才改变了三个参数,但为了避免以后某个版本的 JDK 里默默改变更多激进的配置,还是不要打开了。

JIT 编译器的相关参数
方法调用多少次之后开始请求即时编译器编译的阀值、内联函数大小的阀值等等,不要乱改。

切换 JVM 为服务器模式:-server
在 64 位多核的 linux 中,想设成 -client 都不行,所以写了也是白写。

5. 内存参数和 GC 参数

5.1 GC 策略

为了稳健,8G 以下的堆还是 CMS 好,G1 现在虽然是默认了,但其实在小堆里的表现也没有比 CMS 好,还是 JDK 11 的 ZGC 引人期待。

CMS 相关参数
  • -XX:+UseConcMarkSweepGC
  • -XX:CMSInitiatingOccupancyFraction=75
  • -XX:+UseCMSInitiatingOccupancyOnly
因为我们的监控系统会通过 JMX 监控内存达到 90% 的状况,所以设置让它 75% 就开始跑了,早点开始也能减少 Full GC 等意外情况(概念重申:这种主动的 CMS GC,和 JVM 的老年代、永久代、堆外内存完全不能分配内存了再去强制 Full GC 是不同的概念)。
为了让这个设置生效,还要设置 -XX:+UseCMSInitiatingOccupancyOnly,否则 75% 只被用来做开始的参考值,后来还是 JVM 自己算。

对象分代年龄晋升阀值:-XX:MaxTenuringThreshold=2
这是改动效果最明显的一个参数了。对象在 Survivor 区最多熬过多少次 Young GC 后晋升到年老代,JDK 8 里 CMS 默认是 6,其他如 G1 是 15。
Young GC 是最大的应用停顿来源,而 YGC 后存活对象的多少又直接影响停顿的时间,所以如果清楚 Young GC 的执行频率和应用里大部分临时对象的最长生命周期,可以把它设的更短一点,让其不是临时对象的新生代对象赶紧晋升到老年代,别呆着。
-XX:+PrintTenuringDistribution 观察下,如果后面几代的大小总是差不多,证明过了某个年龄后的对象总能晋升到老年代,就可以把晋升阈值设小,比如 JMeter 里 2 就足够了。

显式并发 GC:-XX:+ExplicitGCInvokesConcurrent
让 Full GC 时使用 CMS 算法,不是全程停顿,必选。
但不要加禁用显式 GC 参数:-XX:+DisableExplicitGC
但像 R 大说的,System GC 是保护机制(如堆外内存满时清理它的堆内引用对象),禁了 system.gc() 未必是好事,只要没用什么特别烂的类库,真有人调了总有调的原因,所以不应该加这个烂大街的参数。

-XX:+ParallelRefProcEnabled-XX:+CMSParallelInitialMarkEnabled
并行地处理 Reference 对象,如弱引用,默认为 false,除非在 GC log 里出现 Reference 处理时间较长的日志,否则效果不会很明显,但我们总是要 JVM 尽量的并行,所以设了也就设了。同理还有 -XX:+CMSParallelInitialMarkEnabled,JDK 8 已默认开启,但小版本比较低的 JDK 7 甚至还不支持。

ParGCCardsPerStrideChunk
Linkined 的黑科技,2016 版不建议打开,后来发现有些场景的确能减少 YGC 时间,详见《难道他们说的都是真的》,简单说就是影响 YGC 时扫描老年代的时间,默认值 256 太小了,但 32K 也未必对,需要自己试验。
  • -XX:+UnlockDiagnosticVMOptions
  • -XX:ParGCCardsPerStrideChunk=1024

5.2 可选的 GC 参数

并发收集线程数
ParallelGCThreads = 8 + (Processor - 8) (5 / 8) ConcGCThreads = (ParallelGCThreads + 3) / 4
比如双CPU,六核,超线程就是 24 个处理器,小于 8 个处理器时,ParallelGCThreads 按处理器数量,大于时按上述公式 YGC 线程数 = 18, CMS GC 线程数 = 5。
CMS GC 线程数的公式太怪,也有人提议简单改为 YGC 线程数的 1/2。
一些不在乎停顿时间的后台辅助程序,比如日志收集的 logstash,建议把它减少到 2,避免在 GC 时突然占用太多 CPU 核,影响主应用。
而另一些并不独占服务器的应用,比如旁边跑着一堆 sidecar 的,也建议减少 YGC 线程数。
一个真实的案例,24 核的服务器,默认 18 条 YGC 线程,因为旁边有个繁忙的 Service Mesh Proxy 在跑着,这 18 条线程并不能 100% 的抢到 CPU,出现了不合理的慢 GC,把线程数降低到 12 条反而更快了。 所以那些贪心的把 YGC 线程数 = CPU 核数的,通常弄巧成拙。

关闭 CMS 类型卸载:-XX:-CMSClassUnloadingEnabled
在 CMS 中清理永久代中的过期的 Class 而不等到 Full GC,JDK 7 默认关闭而 JDK 8 打开。看自己情况,比如有没有运行动态语言脚本如 Groovy 产生大量的临时类。它有时会大大增加 CMS GC 的暂停时间。所以如果新类加载并不频繁,这个参数还是显式关闭的好。

开启 CMS 清除前重新标记:-XX:+CMSScavengeBeforeRemark
默认为关闭,在 CMS remake前,先执行一次 Minor GC 将新生代清掉,这样从老年代的对象引用到的新生代对象的个数就少了,STW 的 CMS remark 阶段就短一些。但如果打开了,会让一次 Minor GC 紧接着一次 CMS GC,使得停顿的总时间加长了。
又一个真实案例,CMS GC 的时间和新生代的使用量成比例,新生代较小时很快完成,新生代快满时 CMS GC 的停顿时间超过 2 秒,这时候就还是打开了划算。

5.3 不建议的 GC 参数

使用 ParNew 垃圾收集器:-XX:+UseParNewGC
用了 CMS 后,新生代的垃圾收集器默认就是它,不用自己设。

CMS Full GC 碎片压缩整理:-XX:CMSFullGCsBeforeCompaction
默认为 0,即每次 Full GC 都对老年代进行碎片整理压缩。Full GC 不同于老年代 75% 时触发的 CMS GC,只在老年代达到 100%,堆外内存满,老年代碎片过大无法分配空间给新晋升的大对象这些特殊情况里发生,所以设为每次都进行碎片整理是合适的,详见此贴里R大的解释

-XX:+GCLockerInvokesConcurrent
我们犯过的错,不是所有 Concurrent 字样的参数都是好参数,加上之后,原本遇上 JNI GCLocker 只需要补偿 YGC 就够的,变成要执行 YGC + CMS GC 了。

5.4 内存大小的设置

其实 JVM 除了显式设置的 -Xmx 堆内存,还有一堆其他占内存的地方(堆外内存,线程栈,永久代,二进制代码 cache),在容量规划的时候要留意。
关键业务系统的服务器上内存一般都是够的,所以尽管设得宽松点。

-Xmx-Xms
堆内存大小,2~4G 均可。

-Xmn-XX:NewSize-XX:NewRatio
JVM 默认新生代占堆大小的 1/3, 个人喜欢把对半分, 因为增大新生代能减少 GC 的频率,如果老生代里没多少长期对象的话,占 2/3 通常太多了。可以用 -Xmn 直接赋值 (等于 -XX:NewSize-XX:MaxNewSize 同值的缩写),或把 NewRatio 设为 1 来对半分。

-XX:PermSize=128m-XX:MaxPermSize=512m(JDK 7)
-XX:MetaspaceSize=128m-XX:MaxMetaspaceSize=512m(JDK 8)
现在的应用有 Hibernate/Spring 这些闹腾的家伙 AOP 之后类都比较多,可以一开始就把初始值从 64M 设到 128M(否则第一次自动扩张会造成大约 3 秒的 JVM 停顿),并设一个更大的 Max 值以求保险。
JDK 8 的永久代几乎可用完机器的所有内存,同样设一个 128M 的初始值,512M 的最大值保护一下。

5.5 其他内存大小的设置

-Xss
在堆之外,线程占用栈内存,默认每条线程为 1M(以前是 256K)。存放方法调用出参入参的栈,局部变量,标量替换后掉局部变量等,有人喜欢把它设回 256k,节约内存并开更多线程,有人则会在遇到错误后把它再设大点,特别是有很深的 JSON 解析之类的递归调用时。

-XX:SurvivorRatio
新生代中每个存活区的大小,默认为 8,即 1/10 的新生代 1 / (SurvivorRatio + 2),有人喜欢设小点省点给新生代如 Cassandra,但要避免太小使得存活区放不下临时对象而被迫晋升到老生代,还是从 GC 日志里看实际情况了。

-XX:MaxDirectMemorySize
堆外内存的最大值,默认为 Heap 区总内存减去一个 Survivor 区的大小,详见 Netty 之堆外内存扫盲篇,如果肯定用不了这么多,也可以把它主动设小,来获得一个比较清晰内存占用预估值,特别是在容器里。

-XX:ReservedCodeCacheSize
JIT 编译器编译后二进制代码的存放区,满了之后就不再编译,对性能影响很大。JDK 7 默认不开分层编译 48M,开了 96M,而 JDK 8 默认开分层编译 240M。可以在 JMX 里看看 CodeCache 的占用情况,也可以用 VJTools 里的 vjtop 来看,JDK 7 下默认的 48M 可以设大点,不抠这么点。

6. 监控参数

JVM 输出的各种日志,如果未指定路径,通常会生成到运行应用的相同目录,为了避免有时候在不同的地方执行启动脚本,一般将日志路径集中设到一个固定的地方。

6.1 监控建议配置

-XX:+PrintCommandLineFlags
运维有时会对启动参数做一些临时的更改,将每次启动的参数输出到 stdout,将来有据可查。
打印出来的是命令行里设置了的参数以及因为这些参数隐式影响的参数,比如开了 CMS 后,-XX:+UseParNewGC 也被自动打开。

-XX:-OmitStackTraceInFastThrow
为异常设置 StackTrace 是个昂贵的操作,所以当应用在相同地方抛出相同的异常 N 次(两万?)之后,JVM 会对某些特定异常如 NPE,数组越界等进行优化,不再带上异常栈。此时,你可能会看到日志里一条条 NulPointException,而之前输出完整栈的日志早被滚动到不知哪里去了,也就完全不知道这 NPE 发生在什么地方,欲哭无泪。 所以,将它禁止吧,ElasticSearch 也这样干。

6.2 Crash 文件

-XX:ErrorFile
JVM crash 时,HotSpot VM 会生成一个 error 文件,提供 JVM 状态信息的细节。如前所述,将其输出到固定目录,避免到时会到处找这文件。文件名中的 %p 会被自动替换为应用的 PID:-XX:ErrorFile=${MYLOGDIR}/hs_err_%p.log

coredump
当然,更好的做法是生成 coredump,从 coredump 能够转出 Heap Dump 和 Thread Dump 还有 crash 的地方,非常实用。
在启动脚本里加上 ulimit -c unlimited 或其他的设置方式,如果有 root 权限,设一下输出目录更好:echo "/{MYLOGDIR}/coredump.%p" > /proc/sys/kernel/core_pattern
什么?你不知道 coredump 有什么用?看来你是没遇过 JVM Segment Fault 的幸福人。

-XX:+HeapDumpOnOutOfMemoryError(可选)
OutOfMemory,JVM 快死掉的时候,输出 Heap Dump 到指定文件。不然开发很多时候还真不知道怎么重现错误。
路径只指向目录,JVM 会保持文件名的唯一性,叫 java_pid${pid}.hprof。因为如果指向文件,而文件已存在,反而不能写入。
  • -XX:+HeapDumpOnOutOfMemoryError
  • -XX:HeapDumpPath=${LOGDIR}/
但在容器环境下,输出 4G 的 Heap Dump,在普通硬盘上会造成 20 秒以上的硬盘 I/O 跑满,也是个十足的恶邻,影响了同一宿主机上所有其他的容器。

6.3 GC 日志

JDK 9 完全不一样了,这里还是写 JDK 7/8 的配置。

基本配置
  • -Xloggc:/dev/shm/gc-myapp.log
  • -XX:+PrintGCDateStamps
  • -XX:+PrintGCDetails
有人担心写 GC 日志会影响性能,但测试下来实在没什么影响,GC 问题是 Java 里最常见的问题,没日志怎么行。
后来又发现如果遇上高 I/O 的情况,GC 时操作系统正在 flush pageCache 到磁盘,也可能导致 GC log 文件被锁住,从而让 GC 结束不了。所以把它指向了 /dev/shm 这种内存中文件系统,避免这种停顿,详见 Eliminating Large JVM GC Pauses Caused by Background IO Traffic
用 PrintGCDateStamps 而不是 PrintGCTimeStamps,打印可读的日期而不是时间戳。

-XX:+PrintGCApplicationStoppedTime
这是个非常非常重要的参数,但它的名字没起好,其实除了打印清晰的完整的 GC 停顿时间外,还可以打印其他的 JVM 停顿时间,比如取消偏向锁,Class 被 agent redefine,code deoptimization 等等,有助于发现一些原来没想到的问题。如果真的发现了一些不知是什么的停顿,需要打印安全点日志找原因(见后)。

-XX:+PrintGCCause
打印产生 GC 的原因,比如 Allocation Failure 什么的,在 JDK 8 已默认打开,JDK 7 要显式打开一下。

-XX:+PrintPromotionFailure
打开了就知道是多大的新生代对象晋升到老生代失败从而引发 Full GC 的。

GC 日志滚动与备份
GC 日志默认会在重启后清空,有人担心长期运行的应用会把文件弄得很大,所以:
  • -XX:+UseGCLogFileRotation
  • -XX:NumberOfGCLogFiles=10
  • -XX:GCLogFileSize=1M
这些参数可以让日志滚动起来。但真正用起来重启后的文件名太混乱太让人头痛,GC 日志再大也大不到哪里去,所以我们没有加滚动,而且自行在启动脚本里对旧日志做备份。

6.4 安全点日志

  • -XX:+PrintSafepointStatistics
  • -XX: PrintSafepointStatisticsCount=1
  • -XX:+UnlockDiagnosticVMOptions
  • -XX:- DisplayVMOutput
  • -XX:+LogVMOutput
  • -XX:LogFile=/dev/shm/vm-myapp.log
如果 GC 日志里有非 GC 的 JVM 停顿时间,你得打出安全点日志来知道详情,详见 JVM 的 Stop The World,安全点,黑暗的地底世界

6.5 JMX

  • -Dcom.sun.management.jmxremote.port=7001
  • -Dcom.sun.management.jmxremote
  • -Dcom.sun.management.jmxremote.authenticate=false
  • -Dcom.sun.management.jmxremote.ssl=false
  • -Djava.rmi.server.hostname=127.0.0.1
以上设置,只让本地的 Zabbix 之类监控软件通过 JMX 监控 JVM,不允许远程访问。
如果应用忘记了加上述参数,又不想改参数重启服务,可以用 VJTools 的 vjmxcli 来救急,它能通过 PID 直接连入目标 JVM 打开 JMX。