一、问题现场
最近接到了一个线上问题处理任务,要对一个多年前上线目前仍承载重要业务的老系统因为最近频繁发生崩溃的问题进行排查解决。最终定位到问题发生的原因其实不复杂,但是往往因为粗暴的开发模式容易导致简单的问题酿成大祸。不仅从技术上,也从流程思考系统稳定性实践方法。
1、业务背景
在接到任务后,我没有马上申请项目代码权限直接翻代码提交记录,而是先通过驻场的运维同事以及维护系统的研发同学了解系统建设背景与使用现状。
该系统服务于某省级政府单位,运行在专用网络中,承载着全省用户使用与上级部门考核的业务。开发在多年前比我工作时间都长,至今仍在服役。
一次停服超过半小时就会给用户业务带来严重影响。最近扩大了可访问用户群体,某功能的每日使用次数达到了 1.6W,这在单部门政府信息系统中的使用量是比较大的。
考虑到该系统的架构设计与业务属性,最初上线时直接分配了一台 64G 内存的高配 CentOS 物理机独立部署。与许多政府信息系统类似,这也是典型的大单体应用,一直以单节点单实例方式部署。
系统的监控设施比较简陋,部署、运行状态监控、日志记录等都要远程到服务器上完成。近些年系统问题修复都是通过替换 class 文件实现的。系统配置了看门狗,得益于服务器的性能,在服务宕机后能在两分钟内快速拉起。
进一步的,该系统以 Java 技术栈为主混合使用了多种编程语言开发,涉及大量外部服务调用。以熟悉的 Java 语言了解到,JDK 版本为 1.8,Spring 为 4.0,以 war 包形式部署在 Tomcat 中。
涉及数据分析、远程调用与数据库读写操作,部分 I/O 密集型任务由于流量较大且属于后台任务延迟要求不高,大量采用了 Java 多线程来提升系统性能。
2、问题表象
在对业务背景有所了解后,可以直面问题现场了。
通过运维同学人工巡检发现,服务部署根目录下最近频繁生成 hs_err_pid 文件,以前虽偶有生成估计三两个月一次,但没有影响业务,关注度并不高。目前发生频率明显升高,风险应对等级提高。
毕竟业务不可用最长时间为半小时,而系统能在两分钟内重启完成,老旧系统改造大家都比较谨慎。
hs_err_pid 文件是 JVM 崩溃时生成的 FATAL 日志,是非常严重的问题。还好业务可用性要求不高,不然这种故障是要被投诉的。
3、紧急处理
对线上系统是要有敬畏之心的,再没进一步加剧系统故障前,抓紧排查问题之外,还要约束运维手段。
首先,停止所有对线上系统的变更,在修复完崩溃问题之前停止系统缺陷修复,包括操作系统层面的配置。考虑到没有远程监控,提高人工巡检速率,由一天一次改为每两小时一次,发现宕机没有自动拉起服务后果断人工介入启动服务。
接下来,就是惊心动魄的问题排查阶段。
二、问题排查
1、FATAL 日志分析
最开始直接比对了多个 FATAL 日志,可基本确认是 OOM(Out of Memory Error)引起的。
hs_err_pid 提供了一些常用解决思路,比如扩大物理内存、减少 JVM 堆内存分配、减少 Java 线程数等。
# There is insufficient memory for the Java Runtime Environment to continue.
# Native memory allocation (mmap) failed to map 12288 bytes for committing reserved memory.
# Possible reasons:
# The system is out of physical RAM or swap space
# In 32 bit mode, the process size limit was hit
# Possible solutions:
# Reduce memory load on the system
# Increase physical memory or swap space
# Check if swap backing store is full
# Use 64 bit Java on a 64 bit OS
# Decrease Java heap size (-Xmx/-Xms)
# Decrease number of Java threads
# Decrease Java thread stack sizes (-Xss)
# Set larger code cache with -XX:ReservedCodeCacheSize=
# This output file may be truncated or incomplete.
#
# Out of Memory Error (os_linux.cpp:2640), pid=19209, tid=0x00007f9b5cc8c700
#
# JRE version: Java(TM) SE Runtime Environment (8.0_131-b11) (build 1.8.0_131-b11)
# Java VM: Java HotSpot(TM) 64-Bit Server VM (25.131-b11 mixed mode linux-amd64 compressed oops)
# Core dump written. Default location: /...
继续往下看,能够发现一些端倪。运行中的线程 ID 打印数量惊人,每个 FATAL 日志都有六万多个运行中的线程。而且有大量使用默认线程池名称的线程。
0x00007fa6c8097800 JavaThread "pool-61390-thread-1" [_thread_blocked, id=8386, stack(0x00007f9b5dc9d000,0x00007f9b5dd9e000)]
0x00007fa7941cf800 JavaThread "pool-61389-thread-1" [_thread_blocked, id=8372, stack(0x00007f9b5dd9e000,0x00007f9b5de9f000)]
0x00007fa8a8091000 JavaThread "pool-61388-thread-1" [_thread_blocked, id=8367, stack(0x00007f9b5e7a8000,0x00007f9b5e8a9000)]
0x00007fa4d036c000 JavaThread "pool-61386-thread-1" [_thread_blocked, id=8363, stack(0x00007f9b5e2a3000,0x00007f9b5e3a4000)]
由此,可以将问题排查方向导引到了线程池使用上。
大量创建后没有及时关闭的线程池消耗光了堆内存,在 GC 再无法释放充足的内存后,导致 OOM,JVM 最终崩溃。
2、线程 dump 分析
进一步检查 JVM 启动参数配置,开启了 OOM 自动做 Heap Dump 的开关。
-XX:+HeapDumpOnOutOfMemoryError
结合定时抓取的 Heap Dump(记得切换到服务启动的账户,如果不是,使用 root 都不能成功 dump),一同做 Thread Dump 比较。
jmap -dump:format=b,file=./dumpfile.hprof ${pid}
借助 JProfiler,Memory Analyzer(MAT)等分析工具可以快速了解线程使用情况。
于是分析出:线程数虽时间快速增长,存在大量使用默认线程池名的线程,并且每个线程池仅创建了一个线程。形如这种:
Thread pool-1195-thread-1:
at sun.misc.Unsafe.park(boolean, long)
at java.util.concurrent.locks.LockSupport.park(java.lang.Object) (line: 175)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await() (line: 2039)
at java.util.concurrent.LinkedBlockingQueue.take() (line: 442)
at java.util.concurrent.ThreadPoolExecutor.getTask() (line: 1067)
at java.util.concurrent.ThreadPoolExecutor.runWorker(java.util.concurrent.ThreadPoolExecutor$Worker) (line: 1127)
at java.util.concurrent.ThreadPoolExecutor$Worker.run() (line: 617)
at java.lang.Thread.run() (line: 748)
Thread pool-1199-thread-1:
at sun.misc.Unsafe.park(boolean, long)
at java.util.concurrent.locks.LockSupport.park(java.lang.Object) (line: 175)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await() (line: 2039)
at java.util.concurrent.LinkedBlockingQueue.take() (line: 442)
at java.util.concurrent.ThreadPoolExecutor.getTask() (line: 1067)
at java.util.concurrent.ThreadPoolExecutor.runWorker(java.util.concurrent.ThreadPoolExecutor$Worker) (line: 1127)
at java.util.concurrent.ThreadPoolExecutor$Worker.run() (line: 617)
at java.lang.Thread.run() (line: 748)
Thread pool-1200-thread-1:
at sun.misc.Unsafe.park(boolean, long)
at java.util.concurrent.locks.LockSupport.park(java.lang.Object) (line: 175)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await() (line: 2039)
at java.util.concurrent.LinkedBlockingQueue.take() (line: 442)
at java.util.concurrent.ThreadPoolExecutor.getTask() (line: 1067)
at java.util.concurrent.ThreadPoolExecutor.runWorker(java.util.concurrent.ThreadPoolExecutor$Worker) (line: 1127)
at java.util.concurrent.ThreadPoolExecutor$Worker.run() (line: 617)
at java.lang.Thread.run() (line: 748)
筛选统计,thread-1
都在三万多个,也就是创建了大概三万多个线程池触发 OOM。
这似乎存在某种关联,放开的业务功能每天的使用量是 1.6W,默认线程池名称的线程数是三万多,发生 OOM 的时间是不到两天。
于是,可以大胆猜测该功能存在无用线程池的问题,下面可以分析代码了。
3、多线程代码分析
翻开这个功能的访问入口,感觉离真相很近了。
在该功能的 HTTP REST 端点处,直接创建了没关闭的线程池,写法是这样的:
Executors.newFixedThreadPool(1).submit(()->{
try {
// do something
} catch (Exception e) {
// log error
}
});
这是一段用于将访问记录写入数据库的操作,为了避免主线程阻塞考虑异步写入。但是由于误用线程池,这样做的后果就变成了,每一次被访问都会创建一个只有一个线程的使用默认名的线程池,并且从未关闭。
这个功能每天 1.6W 访问量就会创建 1.6W 个线程池,最终达到内存上限造成 OOM,以前访问量比较低发生错误的周期就比较长。
三、修复测试
1、代码修复
定位到了多线程代码产生问题的原因后,问题的处理也就不难了。
将异步写入访问日志的线程池注入到 Spring 容器中,所有请求共享该线程资源。考虑到项目基于 xml 配置 bean 的习惯,这里也使用 xml 注入线程池。效果与构造函数 new ThreadPoolTaskExecutor 效果一样。
并指定了线程池名为 operateLogExecutor。效果与 CustomizableThreadFactory 或 Guava 的 ThreadFactoryBuilder#setNameFormat 一样。
调用时就变成了这样:
@Autowired
private ThreadPoolTaskExecutor operateLogExecutor;
// ...
operateLogExecutor.submit(()->{
try {
// do something
} catch (Exception e) {
// log error
}
});
2、性能测试
功能验证无误后,再通过压测工具测试下接口性能,验证修复方案。
使用 JMeter 等压测工具模拟功能访问请求,通过 JConsole 等监控工具观察线程数量以及堆内存大小变化。
监控生效需要添加 JVM 启动参数(线上添加启动参数需要检查远程监控端口是否被占用):
-Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9001 -Djava.rmi.server.hostname=127.0.0.1 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false
JConsole 的唤醒命令就是:
jconsole 127.0.0.1:9001
修复前,可以看到线程数量是与请求数量呈线性正相关。
暂停压测,启用修复后代码,线程数量始终稳定在较低水位,内存占用也在较低水位。
由此可以基本验证前面的假设,也能对代码的修复质量有所保证。
3、部署验证
充分测试后上线部署,观察运行一小时、两小时、一天、一周后的 Heap Dump 状态。最终发现线程数稳定在四百多,没有随着业务的大量的使用显著增加,也没有出现以前动辄上万的线程数。
四、回顾总结
1、线程池使用规范
很多软件厂商都有对线程池的使用规范,比如阿里巴巴 Java 开发手册可做参考:
- 获取单例对象需要保证线程安全, 其中的方法也要保证线程安全
- 创建线程或线程池时请指定有意义的线程名称, 方便出错时回溯
- 线程资源必须通过线程池提供, 不允许在应用中自行显式创建线程
- 线程池不允许使用 Executors 去创建, 而是通过 ThreadPoolExecutor 的方式
2、线上 JVM 监控
-
对线上环境保持敬畏、对软件系统保持敬畏,部署前充分测试、线上环境权限最小化、变更最小化
-
没有监控系统,可考虑 JDK 自带的 JConsole 等工具提供监控手段,及时了解 CPU、内存、线程、类等使用情况
-
添加 -XX:+HeapDumpOnOutOfMemoryError 和 -XX:HeapDumpPath 参数,在 OOM 能够及时排查问题现场
最后这个参数只捕获与堆内存空间有关的 OOM 实例,比如 JVM 无法在对堆上为 Java 对象分配足够的内存,GC 也无法释放足够的空间,表明应用程序的内存超过了堆的可以内存;
超出 GC 开销限制,也就是 JVM 花费很多时间进行垃圾回收,却只有少量内存被回收,表明垃圾收集器正努力释放内存,需要程序优化释放更多堆空间;
请求的数组大小超过 JVM 限制,JVM 无法为新数组分配内存。