Java 性能问题定位:从系统指标到应用排查

这篇笔记参考了阿里云相关博客中的排查思路,原文写得很细,我在此基础上按自己的使用习惯重新做了整理。

线上 Java 应用一旦出现响应变慢、资源飙升或进程异常,排查范围很容易越铺越大,下面会从业务代码、CPU、线程、内存、GC、磁盘 I/O、网络 I/O 和常用命令几个角度,把常见的定位方法串起来。

实际的性能问题很少只对应一个异常指标,更多时候是系统层、组件层与应用层互相影响后的结果;排查时可以从最贴近业务的应用层入手,拿到初步方向后,再回到系统指标中交叉验证。

性能优化工具图谱
├── 系统层
│   ├── CPU
│   │   ├── 🚩 CPU利用率(top/vmstat/sar/dstat)
│   │   ├── 🚩 CPU平均负载(top/uptime)
│   │   └── 上下文切换次数(pidstat/vmstat/dstat)
│   ├── 内存
│   │   ├── 全局内存使用
│   │   │   ├── 🚩 已用/剩余/可用内存(free/vmstat/sar)
│   │   │   └── 缓冲区/缓存(pcstat/cachestat/cachetop)
│   │   └── 进程内存使用
│   │       ├── 🚩 虚拟内存/常驻内存/共享内存(top/ps/pidstat)
│   │       ├── 🚩 SWAP 内存使用/换入换出速度(top/free/vmstat/sar)
│   │       ├── 缺页异常(ps/pidstat)
│   │       └── 内存分布(pmap/jmap)
│   ├── 磁盘
│   │   ├── 空间容量(df/du)
│   │   ├── 🚩 吞吐量/磁盘 I/O 使用率(iostat/dstat/sar)
│   │   └── 缓冲区/缓存(pcstat/cachestat/cachetop)
│   └── 网络
│       ├── 🚩 吞吐量(sar)
│       ├── 网络延迟(ping)
│       ├── 🚩 网络连接数/错误数(netstat/ss/sar)
│       └── 网络抓包(tcpdump/wireshark)
├── 组件层
│   ├── 数据库
│   │   ├── SQL 调优
│   │   ├── 🚩 索引调优
│   │   └── 连接池配置
│   ├── 网络 IO
│   │   ├── I/O 调度模型
│   │   ├── 序列化框架
│   │   └── 线程调度模型
│   ├── Web 容器
│   │   └── 线程池配置
│   └── 缓存/MQ……
└── 应用层
    ├── 线程
    │   ├── 死锁检查(jstack/arthas)
    │   ├── 🚩 线程状态分布(jstack/arthas)
    │   ├── 锁竞争分布(jstack/arthas)
    │   ├── 🚩 代码执行热点(jprofiler/zprofiler)
    │   ├── 🚩 占用 CPU 较重的线程(top + pidstat + jstack)
    │   └── 代码追踪(btrace/housemd/greys/arthas)
    ├── 内存
    │   ├── 内存分配
    │   │   ├── 常驻内存/虚拟内存(top)
    │   │   ├── 对象分配热点(jprofiler/zprofiler)
    │   │   ├── 🚩 堆内对象分布(jmap/zprofiler/MAT)
    │   │   ├── 类加载相关(jstat/greys/arthas)
    │   │   ├── 🚩 内存泄漏(gperf/MAT/zprofiler)
    │   │   └── 堆外内存(jmap + MAT + NMT + gdb + perf)
    │   └── 垃圾回收
    │       ├── GC 线程使用(jinfo)
    │       ├── 对象晋升年龄(gclog)
    │       ├── 🚩 GC 的频率和时间(jstat/gclog)
    │       ├── 垃圾回收器类型/JVM 参数(jinfo/jcmd)
    │       └── 🚩 堆大小设置及分区大小(jinfo/jstat)
    ├── 网络
    │   ├── 带宽使用
    │   ├── 流量异动
    │   └── 网络分区
    └── ★ 业务(日志、监控…)
        ├── 🚩 代码逻辑
        ├── 远程调用
        └── 架构设计

CPU 与线程

观察 CPU 时,最常看的指标是下面三项,常用工具包括 top、ps、uptime、vmstat 和 pidstat。

  • CPU利用率(CPU Utilization)
  • CPU 平均负载(Load Average)
  • 上下文切换次数(Context Switch)
top - 12:20:57 up 25 days, 20:49, 2 users, load average: 0.93, 0.97, 0.79

Tasks: 51 total, 1 running, 50 sleeping, 0 stopped, 0 zombie
%Cpu(s): 1.6 us, 1.8 sy, 0.0 ni, 89.1 id, 0.1 wa, 0.0 hi, 0.1 si, 7.3 st
KiB Mem : 8388608 total, 476436 free, 5903224 used, 2008948 buff/cache
KiB Swap: 0 total, 0 free, 0 used. 0 avail Mem

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND

119680 admin 20 0 600908 72332 5768 S 2.3 0.9 52:32.61 obproxy
65877 root 20 0 93528 4936 2328 S 1.3 0.1 449:03.61 alisentry_cli

top 第一行会显示当前时间、系统运行时长和已登录用户数,load average 后面的三个数字,分别对应过去 1 分钟、5 分钟、15 分钟的平均负载(Load Average);这里统计的是单位时间内处于可运行状态的进程,也就是正在使用或等待 CPU 的 R 状态进程,加上处于不可中断状态的 D 状态进程,可以理解为平均活跃进程数,因此 CPU 平均负载并不等同于 CPU 使用率。

第三行展示的是 CPU 利用率,各列的完整含义可以通过 man 查看;这个百分比反映了采样时间内 CPU 的平均使用情况,粗略计算为 CPU 利用率 = 1 - CPU 空闲时间 / CPU 总时间,它不是某个瞬间的精确值。查看进程列表时还要留意 top 的多核计数方式,各 CPU 核心的数值可能会被累加,因此一台 8 核机器上的单进程利用率上限可以显示到 800%,也可以使用 htop 获得更直观的视图。

上下文切换次数可以通过 vmstat 查看,下面的命令会每隔 1 秒输出一组数据:

$ vmstat 1

procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
0 0 0 504804 0 1967508 0 0 644 33377 0 1 2 2 88 0 9

输出里的 cs(context switch)表示每秒发生的上下文切换次数,按场景还可以分成中断、线程和进程上下文切换;不论属于哪一种,次数过多都会让 CPU 把时间花在寄存器、内核栈、虚拟内存等现场的保存与恢复上,真正留给进程执行的时间就会变少。vmstat 中的 us、sy 分别代表用户态与内核态 CPU 利用率,定位问题时也很有参考价值。

vmstat 给出的是全系统汇总值,如果还要区分每个进程的自愿与非自愿上下文切换,就需要使用 pidstat,它也能显示单个进程在用户态、内核态分别占用了多少 CPU。

发现 CPU 指标异常后,可以按下面几个方向继续判断。

1)CPU 利用率:系统或应用进程的 CPU 利用率若在一段时间内一直很高,例如单个 core 持续超过 80%,就值得继续追踪;Java 应用可以多次使用 jstack dump 线程栈并寻找反复出现的热点代码,非 Java 应用则能直接借助 perf 做 CPU 采样。Java 应用使用 perf 时还需要完成符号表映射,不能拿原始采样结果直接下结论。

2)CPU 平均负载:负载接近机器能够承受的范围时,说明系统可能出现了瓶颈,但单个时间点不够说明问题,更有价值的是监控曲线与 1 分钟、5 分钟、15 分钟三个窗口之间的关系。大文件加载也可能造成瞬时升高,三个数值接近通常表示近期负载较平稳;短窗口明显高于长窗口,才更像负载正在上升,需要结合 CPU 数量、R 状态和 D 状态进程继续确认。

3)CPU 上下文切换:这个指标很难给出一条适用于所有机器的经验线,从几十到几万都有可能,基线会受到 CPU 性能和应用工作方式影响;更需要关注的是它是否相对自身历史水平出现数量级增长,例如非自愿上下文切换突然大幅增加,往往说明参与竞争 CPU 的线程过多。

三项指标之间会互相牵动,例如频繁的 CPU 上下文切换本身就可能推高平均负载,所以不能把某一列数值单独拿出来判断,后面还会结合具体场景说明。

CPU 上出现的部分异动通常能在线程层面找到痕迹,不过线程问题不一定都会直接表现为 CPU 升高;下面几项线程指标可以通过 JDK 自带的 jstack 直接或间接拿到:

  • 应用当前的线程总数
  • RUNNABLE、WAITING、BLOCKED 等线程状态的分布
  • 锁的使用情况,包括死锁与锁竞争分布

观察线程时,可以重点看三种异常:

1)线程总数是否过多,线程一多,上下文切换会变得频繁,线程栈也会继续占用内存,不过“多少算多”仍要结合应用类型与机器配置判断;

2)线程状态分布是否异常,WAITING 或 BLOCKED 数量明显偏多时,可能是线程池设置过大,也可能是锁竞争激烈,需要结合应用内部的锁使用情况一起分析;

3)把线程栈与 CPU 利用率对照起来,看是否有少数线程长期消耗大量 CPU。

内存与堆

内存排查会同时涉及系统与 Java 进程,常用工具有 top、free、vmstat、pidstat,以及 JDK 自带的诊断工具,关注点主要包括:

  • 系统的剩余内存、已用内存、可用内存以及缓存/缓冲区
  • 普通进程与 Java 进程的虚拟内存、常驻内存、共享内存
  • 进程的主缺页异常与次缺页异常数量
  • Swap 换入换出量及相关参数配置
  • JVM 堆分配、JVM 启动参数和 GC 情况

free 可以查看系统内存和 Swap 分区的整体使用情况,top 则能把范围收缩到具体进程,例如用 RES 观察 Java 进程的常驻内存;两者配合,已经可以覆盖不少基础内存指标,下面是一份 free 输出:

$free -h

          total        used        free      shared  buff/cache   available
Mem: 125G 6.8G 54G 2.5M 64G 118G

Swap: 2.0G 305M 1.7G

各列的基础含义不再逐项展开,这里主要看 Swap 与 buff/cache。

Swap 会把本地文件或一块磁盘空间当作内存的补充,并通过换出、换入移动页面,由于整个过程需要读写磁盘,延迟远高于物理内存。ElasticSearch、Hadoop 等 Java 应用通常都不希望关键堆页面频繁进入 Swap,因为 JVM 做 GC 时要遍历仍在使用的堆内存,被换出的页面可能在此时触发额外磁盘 I/O;观察到 Swap 上升后,还要把缓存占用、swappiness 阈值、匿名页和文件页的活跃情况放在一起判断。

buff/cache 汇总了缓存与缓冲区,cache 主要面向文件,保存从磁盘读取或即将写回磁盘的临时数据,cachestat 可以查看全系统缓存命中情况,cachetop 则能按进程观察;buffer 更靠近块设备,用于暂存直接读写磁盘的数据。free 把两项合并显示,想分开看时可以使用 vmstat,同时还能看到 Swap 的换入换出量。

把这些指标放回线上场景,常见内存问题大致有:

  • 系统剩余或可用内存不足,背后可能是单个进程占用过高,也可能是机器容量本来就不够
  • 内存回收异常,例如进程占用持续走高的内存泄漏,或 GC 频率出现明显变化
  • 读取、写入大文件造成缓存膨胀,或缓存命中率一直很低
  • 缺页异常太多,引发了频繁 I/O 读取
  • Swap 分区使用量异常升高

发现内存指标异常后,可以这样逐步缩小范围:

  • 用 free 和 top 查看全局内存、Swap、缓存/缓冲区以及进程占用,先判断问题更接近进程内存、缓存/缓冲区还是 Swap
  • 继续观察一段时间的趋势,vmstat 可以确认内存是否持续增长,jmap 能定时统计对象分布,cachetop 则有助于找到缓存升高的来源
  • 有了类型和趋势以后,再结合业务流量、发布变更与 Java 应用内部状态做详细分析

举个例子,free 显示缓存/缓冲区占用不大,可以暂时排除这部分影响,再用 vmstat 或 sar 看各进程的内存变化趋势;如果某个 Java 进程持续走高,就接着用 jmap、VisualVM 或 heap dump 观察对象分配,再通过 jstat 检查 GC 后的内存能否回落,并结合业务场景,把结论收敛到内存泄漏、GC 参数不合理或业务代码异常等方向。

磁盘与文件

磁盘问题通常要和文件系统放在一起分析,下面不再刻意区分两者;常用工具是 iostat 与 pidstat,前者查看整个系统,后者可以把 I/O 活动定位到具体进程,主要指标包括:

  • 磁盘 I/O 利用率,也就是设备用于处理 I/O 的时间比例
  • 磁盘吞吐量,表示每秒读写的数据规模,常用单位为 KB
  • I/O 响应时间,包含请求在队列中的等待时间与设备实际处理时间
  • IOPS(Input/Output Per Second),即每秒完成或发出的 I/O 请求数
  • I/O 平均等待队列长度,持续过长往往意味着请求已经开始排队

使用 iostat 的输出界面如下:

$iostat -dx

Linux 3.10.0-327.ali2010.alios7.x86_64 (loginhost2.alipay.em14) 10/20/2019 x86_64 (32 CPU)

Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
sda 0.01 15.49 0.05 8.21 3.10 240.49 58.92 0.04 4.38 2.39 4.39 0.09 0.07

iostat 输出中的 %util 表示磁盘 I/O 利用率,在存在并行 I/O 的设备与统计口径下,需要结合设备类型理解这个数值;rkB/s、wkB/s 是每秒读取和写入的数据量,也就是吞吐量。r_await、w_await 分别给出读写请求的平均响应时间,svctm 已被废弃,不宜再拿它判断性能;r/s 与 w/s 可以合在一起观察 IOPS,aqu-sz 则反映平均等待队列长度。

pidstat 中不少字段和 iostat 类似,主要差别是它能实时展示每个进程产生的 I/O。

判断磁盘是否异常,不能只盯一个数字,可以结合下面几种现象:

  • 磁盘 I/O 利用率长期处在高位,同时响应时间相对设备平时基线明显增大,通常说明 I/O 已经接近瓶颈
  • SSD 与机械磁盘的延迟范围并不相同,同一种设备在云盘、本地盘和不同负载下也会有差别,因此要优先使用当前机器的历史基线
  • %util 很高而 rkB/s、wkB/s 不大时,可能存在大量小块随机读写,可以借助 strace 或 blktrace 检查访问是否连续;随机读写更应关注 IOPS,顺序读写则更看重吞吐量
  • avgqu-sz 持续升高,说明越来越多 I/O 请求在队列中等待,是否已经异常还要结合设备并发能力和历史基线判断

网络

“网络”覆盖应用层、传输层、网络层和网络接口层,能够观测的指标非常多,这里把范围收在应用更常接触的部分,主要看:

  • 网络带宽,即链路理论或配置上的最大传输速率
  • 网络吞吐,也就是单位时间内实际成功传输的数据量
  • 网络延时,从请求发出到收到远端响应所花的时间
  • 网络连接数与错误数

应用层常见的网络瓶颈大致有:

  • 集群或机器所在机房的网络带宽已经饱和,应用 QPS/TPS 无法继续提高
  • 接口传输了过多数据,造成吞吐和带宽占用突然上升
  • 网络连接出现异常、错误或数量耗尽
  • 集群之间发生网络分区

带宽与网络吞吐通常从整个应用的监控曲线中观察,某一时段若明显偏离历史基线,就需要检查是否出现了网络瓶颈;收缩到单机后,可以使用 sar 查看网络接口和进程吞吐。

ping 或 hping3 可以帮助确认基础连通性、网络分区与大致时延,不过应用更关心整条调用链花了多长时间,这部分要结合中间件埋点输出的 trace 日志,把各环节延迟逐段拆开。

netstat、ss 与 sar 能提供网络连接数和错误数,连接过多会同时消耗文件描述符与内核缓存,因此单机能够支撑的连接数量必然存在上限。

工具总结

分析 CPU、内存和磁盘时,top、vmstat、pidstat 等工具会反复出现,可以按用途做一个简单归纳:

  • CPU:top、vmstat、pidstat、sar、perf、jstack、jstat
  • 内存:top、free、vmstat、cachetop、cachestat、sar、jmap
  • 磁盘:top、iostat、vmstat、pidstat、du/df
  • 网络:netstat、sar、dstat、tcpdump
  • 应用:profiler、dump 分析

这里不少工具偏向系统层,到了应用层,除了 JDK 自带的一系列诊断能力,也可以使用 gceasy.io 分析 GC 日志,或用 fastthread.io 辅助分析线程 dump 日志,但敏感生产数据是否适合上传,需要先按实际安全要求判断。

排查 Java 线上异常与代码瓶颈时,阿里开源的 Arthas 很实用,这里只列出与本文相关的能力。

Arthas 面向线上应用的实时诊断,适合在应用仍在运行时继续分析和定位,它提供的方法调用追踪对“慢查询”等问题也很有帮助,常用功能包括:

  • 获取线程、锁和 CPU 利用率等统计
  • 查看类加载、动态类加载与方法加载信息
  • 追踪调用栈和统计调用耗时
  • 观察方法调用参数与返回结果
  • 查看系统配置和应用配置
  • 反编译已加载类

工具终究只是获得证据的手段,熟悉常用参数已经能覆盖多数排查场景,没有必要把大量时间花在背命令上,重点仍是让指标、代码与业务现象互相印证。

拿到异常指标并初步确定瓶颈方向后,下一步才是确认原因与选择调优办法,下面继续按照 CPU、内存、网络和磁盘组织常见场景。


性能瓶颈、故障的分析

1 代码相关

遇到性能问题,可以先检查它是否和业务代码直接相关,这并不是让人立刻通读整个项目,而是借助日志、变更记录和热点代码,先排除一批低级错误;如果问题能在应用内部解决,通常比直接调整系统参数更容易控制影响范围。

例如先看业务日志是否突然出现大量报错,应用层与框架层的不少异常都会留下痕迹,日志级别配置错误还可能让线上持续写入海量内容;再检查主要执行逻辑,for 循环使用不当、NPE、正则表达式或数学计算等问题,有时只改很少的代码就能消除主要瓶颈。

性能优化不一定一上来就要引入缓存、异步化或 JVM 调优,复杂现象背后也可能只是一个简单错误,“二八原则”在这里仍然适用;熟悉常见代码陷阱,会让 CPU、内存、JVM 指标更容易落到具体实现上。

下面整理一些较常见、也容易引起性能问题的编码细节。

一是正则表达式可能消耗大量 CPU,贪婪模式等写法还会引起回溯,因此要谨慎使用字符串的 split()、replaceAll() 等方法,重复执行的正则表达式应当预编译。

二是 String.intern() 在低版本 JDK(Java 1.6 以及之前)中可能带来方法区(永久代)内存溢出;到了更高版本 JDK,如果 string pool 过小而缓存字符串又很多,同样会产生不小的性能开销。

三是异常堆栈的构造本身有成本,错误位置已经很明确时,可以减少没有价值的重复堆栈输出;同一位置若持续抛出大量相同异常,JIT 还可能直接抛出预先编译且类型匹配的异常,此时完整堆栈可能会看不到。

四是尽量避免引用类型与基础类型之间没有必要的拆装箱,类型保持一致,可以少掉高频自动装箱产生的对象和开销。

五是根据场景选择 Stream API,复杂的集合变换或确实适合并行的操作可以借它简化代码,但简单循环、小数据量或不适合并行的任务,显式迭代可能更直接,是否更快仍要通过基准测试确认。

六是结合业务场景使用 ThreadPoolExecutor 明确创建线程池,按任务类型设置线程数和队列大小,减少资源耗尽风险;线程采用统一命名后,后续看 dump 也更容易定位。

七是按照访问模式选择并发容器,例如读远多于写的列表可以考虑 CopyOnWriteArrayList,普通高并发键值访问常用 ConcurrentHashMap,需要有序或范围查询时再考虑 ConcurrentSkipListMap;如果使用 Hashtable 或“Map + 锁”来追求更强约束,也要确认锁粒度和实际一致性需求,不要只按容器名称套用。

八是减少不必要的锁竞争,可以缩小锁粒度、在合适的循环中做锁粗化,也可以通过读写锁等方式缩短独占持有时间;对一致性要求没那么高的统计场景,可以考虑用 LongAdder 代替 AtomicLong 计数,并用 ThreadLocalRandom 代替多线程共享的 Random。

代码层还能继续列出很多细节,不过其中不少方法都能归到几种通用取舍中:

  • 空间换时间,用内存或磁盘减少更昂贵的 CPU 计算与网络请求,缓存就是常见例子
  • 时间换空间,牺牲一部分 CPU 或处理轮次,降低瞬时内存与网络资源占用
  • 通过并行化、异步化和池化调整任务执行方式,但这些方案都需要新的容量与失败处理边界

2 CPU 相关

CPU 利用率高并不必然等于故障,真正判断计算资源是否健康时,还要把平均负载、任务状态、响应时间和业务吞吐放在一起看。

2.1 CPU 利用率高、平均负载高

CPU 利用率与平均负载同时偏高,常见于 CPU 密集型应用,此时大量线程处于可运行状态,I/O 占比不高,容易消耗 CPU 的场景包括:

  • 正则操作
  • 数学运算
  • 序列化/反序列化
  • 反射操作
  • 死循环或者不合理的大量循环
  • 基础/第三方组件缺陷

排查高 CPU 时,可以多次使用 jstack 打印线程栈,例如连续采集 5 次以上,再寻找反复处于 RUNNABLE 的相同调用路径;也可以采用基于事件、采样或埋点的 Profiling,生成一段时间内的 on-CPU 火焰图,热点位置通常会更直观。

另一个方向是频繁 GC,包括 Young GC、Old GC 和 Full GC,它们同样可能把 CPU 利用率与负载一起推高;可以用 jstat -gcutil 持续输出 GC 次数和耗时,再通过 free 或 top 对照机器可用内存,确认问题是否伴随内存紧张。

CPU 本身的计算能力也可能已经成为瓶颈,vmstat 能进一步拆开利用率来源;us 较高说明用户态进程占用了更多 CPU,应重点检查应用代码热点,sy 较高则要关注内核线程与系统调用。us + sy 长期接近机器上限,并且业务响应时间也在恶化时,才更能说明 CPU 资源可能不足,具体阈值仍要结合历史基线判断。

2.2 CPU 利用率低、平均负载高

CPU 利用率不高而平均负载偏高,说明应用没有把主要时间花在计算上,这种组合常见于 I/O 密集型进程;平均负载会统计 R 状态与 D 状态进程,CPU 没有忙起来时,就要重点检查是否有大量任务因磁盘 I/O 等原因停在不可中断的 D 状态,网络等待则还需结合具体调用与线程状态分析。

验证时可以运行 vmstat 1,持续观察 %wa(iowait),它表示 CPU 时间中用于等待 I/O 的比例;数值相对历史基线明显升高时,可能是大量随机访问、绕过系统缓存的直接磁盘访问,也可能是设备本身已经达到瓶颈。再结合 iostat 或 dstat 查看读写请求、响应时间和队列长度,例如 %wa 上升的同时磁盘读请求明显增大,就可以继续追查读 I/O 来源。

耗时较长的网络调用也会让应用线程堆积,例如 MySQL 慢查询或 RPC 接口长时间不返回;这类问题需要结合上下游依赖关系、线程状态与中间件埋点的 trace 日志综合分析,不能只看本机 CPU 指标。

2.3 CPU 上下文切换次数变高

上下文切换升高时,可以用 vmstat 看系统总量,再通过 pidstat 区分进程的自愿上下文切换(cswch)和非自愿上下文切换(nvcswch);前者通常来自应用线程主动改变状态,例如调用 sleep()、join()、wait(),或等待 Lock、synchronized,后者多是线程耗尽时间片或被调度器按优先级切走。

自愿上下文切换明显增多,通常意味着线程在等待 I/O、内存或其他资源;非自愿上下文切换偏高,则可能是应用线程数过多,CPU 时间片竞争激烈,线程频繁被强制调度,此时可以用 jstack 中的线程总数和状态分布加以验证。

3 内存相关

内存可以从系统和进程两个层面观察,Java 应用遇到的问题很多会落在进程内存里,但系统缓存、Swap 与其他进程的资源争用同样不能完全排除;Java 进程自身的内存管理主要解决对象如何分配、分配后又怎样回收,其中垃圾回收机制承担了核心工作。

垃圾回收能自动释放不可达对象,却不能保证应用永远不会发生内存泄漏,不合理的参数配置、仍被引用的无用对象和异常代码逻辑都会带来问题;例如最大堆内存设置不合适,就可能引发堆溢出或堆震荡,过多且缺少依据的 GC 参数也会增加维护难度。

下面按几种常见现象分别梳理。

3.1 系统内存不足

Java 应用通常会配置单机或集群内存水位监控,水位持续接近告警阈值时,说明系统内存可能存在风险;具体阈值要根据容器限制、缓存使用方式和历史基线设置,不能只机械套用某个固定百分比。

系统内存不足时,需要用 top 先确认 Java 应用是不是主要占用者,其中 RES 表示进程常驻内存,VIRT 表示虚拟地址空间,两者都不能直接当作 Java 堆大小;除了堆内存,Java 进程还会使用方法区/元空间、JIT 缓存等区域,整体组成大致如下:

Java 应用内存占用 = Heap(堆区)+ Code Cache(代码缓存区) + Metaspace(元空间)+ Symbol tables(符号表)+ Thread stacks(线程栈区)+ Direct buffers(堆外内存)+ JVM structures(其他的一些 JVM 自身占用)+ Mapped files(内存映射文件)+ Native Libraries(本地库)+ ...

jstat -gc 可以查看堆内各分区与元空间的使用情况,堆外内存则可以借助 NMT(Native Memory Tracking,HotSpot VM Java8 引入)统计;线程栈占用也容易被漏掉,虽然它采用按需提交等方式,不一定一开始就完整占用 -Xss 指定的全部空间,但线程数量过多仍会消耗大量内存,可以使用 jstackmem 脚本辅助统计整体线程占用。

系统内存不足时,可以沿着这条线排查:

  • 用 free 确认可用内存,再通过 vmstat 观察使用情况和增长趋势,同时从 top 中找出主要占用进程
  • 检查缓存/缓冲区,数值长期平稳时可以暂时放低优先级,持续升高则使用 pcstat、cachetop、slabtop 分析具体来源
  • 排除缓存/缓冲区影响后,若进程内存仍在不断增长,就要继续检查内存泄漏,具体方法见 3.3 节

3.2 Java 内存溢出

应用申请内存时已经没有足够空间,就可能抛出 OutOfMemoryError,不过它不只发生在 Java 堆,具体类型要根据错误信息区分,常见情况与排查方向如下:

  • java.lang.OutOfMemoryError: Java heap space:堆中已经无法继续分配对象,可能有对象被长期引用、Finalizer 对象积压或堆容量确实不足;应先排查泄漏,再判断是否需要增大堆内存
  • java.lang.OutOfMemoryError: GC overhead limit exceeded:大量时间花在 GC 上,却只能回收很少的堆空间,常见方向是内存泄漏或堆空间过小
  • java.lang.OutOfMemoryError: Metaspacejava.lang.OutOfMemoryError: PermGen space:检查动态类是否持续加载却没有卸载、字符串常量池化数量以及永久代/元空间配置
  • java.lang.OutOfMemoryError: unable to create new native Thread:创建线程时无法再申请足够本地内存或碰到操作系统限制,可以检查线程总量、单线程栈大小、系统空闲内存和进程/线程上限;它与 StackOverflowError 不同,后者通常来自方法调用层次过深,现有线程栈无法继续创建栈帧

OutOfMemoryError 还可能与 Swap、本地方法栈或超大数组分配等情况有关,这里不再逐项展开,实际排查时应以完整错误信息为准。

3.3 Java 内存泄漏

Java 内存泄漏通常不会立刻报错,它可能表现为应用运行一段时间后内存水位越来越高、响应逐渐变慢,直到进程出现“假死”或 OOM,因此比一次明确的内存溢出更难保留现场。

它可能导致系统可用内存不足、进程假死和 OOM,常用的排查入口主要有两个:

  • 定期使用 jmap 输出堆内对象统计,寻找数量或总大小持续增长的对象
  • 用 Profiler 对应用进行 Profiling,观察内存分配热点与对象保留关系

堆内存持续增长时,可以在确保生产影响可控的前提下 dump 一份快照,单份快照虽然只是瞬时状态,但仍能用于分析大对象、引用链与 GC Roots;如果能在不同时间点安全取得多份样本,趋势会更清楚。

3.4 垃圾回收相关

GC(垃圾回收,下同)指标能反映 Java 进程的内存使用是否健康,重点包括 GC Pause(不同资料中也会写成 MinorGC、MajorGC、Full GC 等具体事件)的次数、频率、耗时,以及每轮回收前后的内存变化;jstat 可以给出累计统计,进一步判断原因仍要分析 GC 日志。FGC/FGCT 等列的实际含义会受到垃圾回收器和 JDK 版本影响,例如 CMS 的并发回收中也包含初始标记、重新标记等 Stop-the-World 阶段,阅读时要结合当前收集器,不能只凭列名推断完整过程。

是否需要 GC 调优,取决于应用对响应时间、吞吐量和系统资源的具体要求;GC 频率或耗时相对历史基线大幅上升、GC Pause 已经影响延迟目标、Full GC 频繁出现,都说明值得继续分析,但不能脱离服务等级目标,仅靠 500ms 或 1 分钟这类固定数字直接判断。

垃圾回收器和应用负载差异很大,调优策略也不会完全相同,下面只保留几个通用方向。

  1. 选择适合的 GC 回收器:根据延迟、吞吐、堆规模与 JDK 版本选择,不要只因为某个收集器更新就直接替换。G1 常被用于取代已经废弃的 CMS,它在空间整理和参数管理上更现代;ZGC 在 Java 11 中作为实验性能力引入,后续版本仍在持续演进,是否采用应以当前 JDK 的支持状态、压测结果和生产约束为准。

  2. 合理设置堆内存:堆不能挤占系统、线程栈、Direct buffers 与其他进程需要的全部空间,具体比例要按部署方式和本机总资源测算;-Xms-Xmx 是否设为一致,也要结合弹性需求和运行环境选择。对分代收集器来说,新生代与老年代占比、Eden 区和 Survivor 区比例、对象晋升年龄都会影响 GC 频率与耗时;G1 会通过自适应策略选择回收集合(CSet),人工固定新生代范围前更应该先看日志和压测。Young GC 过于频繁可能说明分配速率过高或新生代偏小,单次耗时过长则需要检查存活对象、扫描规模与暂停目标,不能只凭一个方向调大或调小。

  3. 降低 Full GC 频率:Full GC 或老年代 GC 频繁发生时,要检查对象是否被长期持有、晋升是否过快、大对象是否直接进入老年代,以及各区域配置是否合适;heap dump、GC 日志与业务代码需要互相对照,只有确认对象保留原因以后,调整参数才有依据。

为了拿到调优证据,还可以按 JDK 版本配置 GC 日志与诊断参数,旧版本中常见的 -XX:+PrintGCApplicationStoppedTime-XX:+PrintSafepointStatistics-XX:+PrintTenuringDistribution 可用于观察 GC Pause、安全点耗时和对象晋升年龄,-XX:+PrintFlagsFinal 能查看最终生效参数;较新 JDK 的日志参数体系已经发生变化,使用前要以对应版本文档为准。

4 磁盘 I/O 和网络 I/O

磁盘 I/O 出现异常时,可以按下面的顺序缩小范围:

  • 从 %wa(iowait)、%util、await、队列长度与吞吐等指标判断磁盘是否偏离平时状态,单看 %util 高只能说明 I/O 活动较重,还不能直接认定存在瓶颈
  • 使用 pidstat 定位具体进程,并观察读写数据量与速率
  • 结合 lsof + 进程号 查看异常进程打开的目录、块设备、动态库、网络套接字等对象,再回到业务代码中查找 I/O 来源;仍不清楚时,可以继续用 perf 等工具做 trace

%wa(iowait)升高并不等于磁盘一定存在瓶颈,它反映的是 CPU 观察到的 I/O 等待占比,如果应用在这段时间本来就以 I/O 为主,这个数值上升可能符合预期,仍要结合设备延迟、队列和业务响应判断。

网络 I/O 瓶颈常见于下面几种情况:

  • 单次传输对象过大,请求响应会变慢,频繁分配大对象还可能增加 GC 压力
  • 网络 I/O 模型不适合当前负载,应用整体 QPS 偏低且响应时间较长
  • RPC 线程池或数据库连接池配置不合理,可以用 jstack 观察线程分布;大量线程处于 TIMED_WAITING 或 WAITING/BLOCKED,或等待同一把连接池锁时,需要重点检查资源是否不足
  • RPC 超时时间与真实下游延迟不匹配,造成请求堆积或失败数量上升

线程堆栈快照对 Java 应用很有价值,不只是线程池配置问题,CPU 飙高、应用响应缓慢或锁竞争异常时,都可以先采集多份线程栈寻找重复出现的调用路径。

5 有用的一行命令

下面保留一些常用的一行命令,方便现场快速收集线索;执行前要确认系统环境、命令参数与进程范围,尤其不能把示例中的 $pid 或批量操作直接照搬到生产机器。

1)查看系统当前网络连接数

netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'

2)查看堆内对象的分布 Top 50(定位内存泄漏)

jmap –histo:live $pid | sort-n -r -k2 | head-n 50

3)按照 CPU/内存的使用情况列出前10 的进程

#内存
ps axo %mem,pid,euser,cmd | sort -nr | head -10
#CPU
ps -aeo pcpu,user,pid,cmd | sort -nr | head -10

4)显示系统整体的 CPU利用率和闲置率

grep "cpu " /proc/stat | awk -F ' ' '{total = $2 + $3 + $4 + $5} END {print "idle \t used\n" $5*100/total "% " $2*100/total "%"}'

5)按线程状态统计线程数(加强版)

jstack $pid | grep java.lang.Thread.State:|sort|uniq -c | awk '{sum+=$1; split($0,a,":");gsub(/^[ \t]+|[ \t]+$/, "", a[2]);printf "%s: %s\n", a[2], $1}; END {printf "TOTAL: %s",sum}';

6)查看最消耗 CPU 的 Top10 线程机器堆栈信息

show-busy-java-threads 脚本可以辅助排查 Java 的高 CPU 问题,例如 top 中 us 偏高时,它会找出 Java 进程里 CPU 占用较多的线程并打印线程栈,便于继续定位到方法调用;项目地址为 https://github.com/oldratlee/useful-scripts/,实际使用前应先核对脚本内容与权限。

7)火焰图生成(需要安装 perf、perf-map-agent、FlameGraph 这三个项目):

# 1. 收集应用运行时的堆栈和符号表信息(采样时间30秒,每秒99个事件);
sudo perf record -F 99 -p $pid -g -- sleep 30; ./jmaps

# 2. 使用 perf script 生成分析结果,生成的 flamegraph.svg 文件就是火焰图。
sudo perf script | ./pkgsplit-perf.pl | grep java | ./flamegraph.pl > flamegraph.svg

8)按照 Swap 分区的使用情况列出前 10 的进程

for file in /proc/*/status ; do awk '/VmSwap|Name|^Pid/{printf $2 " " $3}END{ print ""}' $file; done | sort -k 3 -n -r | head -10

9)JVM 内存使用及垃圾回收状态统计

#显示最近一次或当前正在发生的垃圾收集诱因
jstat -gccause $pid

#显示各个代的容量及使用情况
jstat -gccapacity $pid

#显示新生代容量及使用情况
jstat -gcnewcapacity $pid

#显示老年代容量
jstat -gcoldcapacity $pid

#显示垃圾收集信息(间隔1秒持续输出)
jstat -gcutil $pid 1000

10)其他日常命令

下面第一条会向所有匹配到的 java 进程发送 kill -9,具有破坏性,只能在明确进程范围并确认允许强制终止时使用,不适合作为普通排查命令直接执行。

# 快速杀死所有的 java 进程
ps aux | grep java | awk '{ print $2 }' | xargs kill -9

# 查找/目录下占用磁盘空间最大的top10文件
find / -type f -print0 | xargs -0 du -h | sort -rh | head -n 10

虽然上文把瓶颈分到系统、组件和应用三个层面,真实运行时它们却会不断互相影响:系统给应用提供资源,资源逐渐接近上限后,应用与组件的吞吐、延迟等指标会开始恶化;反过来,不合理的应用设计又会更快耗尽系统资源。因此,结论不能来自单层指标,而要从不同角度的证据中找到能够互相解释的部分。

实际排查可以从应用层的高频指标和近期变更开始,先抓住最可疑的方向,再到系统层验证;许多瓶颈到了系统层会同时表现为多个变量异常,例如 GC 出现问题时,JDK 工具能直接看到回收频率和耗时,而系统层可能同时出现 CPU、内存指标变化,如果反过来只盯系统曲线,很容易被多个现象带乱方向。

如果应用层和系统层都呈现出多项异常,单靠离散指标仍无法收敛,可以再使用 ZProfiler、JProfiler 等工具对应用进行 Profiling;Profiling 会在应用运行期间,通过事件(Event-based)、统计抽样(Sampling Statistical)或植入附加指令(Byte-Code instrumentation)等方式收集信息,例如对 CPU 做抽样并结合符号表,就能得到一段时间内比较稳定的代码热点。线上启用前还要评估采样开销与数据安全。

归根结底,性能排查不是看到某个指标越线就立刻调参数,而是从业务现象出发,把应用、组件与系统证据连成一条能够复现和验证的因果链,再决定需要改代码、调容量还是调整运行参数。