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: Metaspace或java.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 分钟这类固定数字直接判断。
垃圾回收器和应用负载差异很大,调优策略也不会完全相同,下面只保留几个通用方向。
选择适合的 GC 回收器:根据延迟、吞吐、堆规模与 JDK 版本选择,不要只因为某个收集器更新就直接替换。G1 常被用于取代已经废弃的 CMS,它在空间整理和参数管理上更现代;ZGC 在 Java 11 中作为实验性能力引入,后续版本仍在持续演进,是否采用应以当前 JDK 的支持状态、压测结果和生产约束为准。
合理设置堆内存:堆不能挤占系统、线程栈、Direct buffers 与其他进程需要的全部空间,具体比例要按部署方式和本机总资源测算;
-Xms与-Xmx是否设为一致,也要结合弹性需求和运行环境选择。对分代收集器来说,新生代与老年代占比、Eden 区和 Survivor 区比例、对象晋升年龄都会影响 GC 频率与耗时;G1 会通过自适应策略选择回收集合(CSet),人工固定新生代范围前更应该先看日志和压测。Young GC 过于频繁可能说明分配速率过高或新生代偏小,单次耗时过长则需要检查存活对象、扫描规模与暂停目标,不能只凭一个方向调大或调小。降低 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 做抽样并结合符号表,就能得到一段时间内比较稳定的代码热点。线上启用前还要评估采样开销与数据安全。
归根结底,性能排查不是看到某个指标越线就立刻调参数,而是从业务现象出发,把应用、组件与系统证据连成一条能够复现和验证的因果链,再决定需要改代码、调容量还是调整运行参数。