最近在排查一个线上服务异常问题时,遇到了典型的OOM(OutOfMemoryError)引发的Tomcat假死现象。整个过程涉及到内存分析、线程状态检查、Tomcat底层原理等多个技术点,值得记录下来与大家分享。
我们的系统在使用easyexcel处理大文件上传时,出现了Java heap space的OOM错误。通过dump文件分析和日志追踪,很快定位到问题根源:开发同学在使用easyexcel解析文件时,没有进行分页处理,导致一次性加载了整个大文件到内存中。
重要提示:easyexcel官方文档明确建议对大文件进行分片读取,这正是为了避免此类内存问题。实际开发中,对于任何可能处理大文件的场景,都必须考虑内存使用情况。
OOM发生后,系统开始频繁进行Full GC,但由于内存压力过大,GC无法有效回收内存,最终抛出OutOfMemoryError。这里需要区分两个重要概念:
用生活化的比喻来说:内存溢出就像一个人胃口突然变大,吃光了冰箱里的食物(内存),但消化后(GC后)食物空间又回来了;内存泄漏则像是食物被吃掉后,包装袋却永远留在冰箱里占用空间。
在OOM发生后,我们观察到一个奇怪的现象:虽然文件解析完成后内存确实被回收了,Full GC也停止了,但应用的HTTP请求仍然持续报错,错误信息是"connection reset by peer"。
从监控数据看,Tomcat的请求线程数和连接数在崩溃前后没有明显波动。这引出了两个核心问题:
首先检查系统的连接状态。通过以下命令查看指定端口(8100)的连接情况:
bash复制netstat -tlnp | grep 8100
netstat -anp | grep 8100
输出中我们发现了一个关键参数:backlog值为101。backlog表示TCP连接等待队列的长度,对应Tomcat的acceptCount参数(默认100)。当并发连接数超过backlog+1时,新连接就会被操作系统拒绝,表现为"connection reset by peer"错误。
接下来我们使用Arthas工具检查Tomcat线程状态:
bash复制thread -n 10 # 查看CPU占用最高的10个线程
thread 1 # 查看特定线程的堆栈
thread --all | grep http # 筛选HTTP相关线程
结果显示Tomcat的工作线程都处于WAITING状态,阻塞在TaskQueue.take()方法上。这说明线程池的任务队列是空的,工作线程都在等待新任务。
这就形成了一个矛盾的现象:
要理解这个矛盾现象,必须深入Tomcat的线程模型。Tomcat支持多种I/O模型(BIO/NIO/AIO/APR),现代版本默认使用NIO(非阻塞I/O)。
Tomcat的NIO实现基于Reactor模式,具体来说是多Reactor多线程模型:
这种设计可以用少量线程处理大量连接,是高性能服务器的常见架构。
在Spring Boot中,有两个重要的Tomcat线程参数:
properties复制server.tomcat.min-spare-threads=10 # 最小工作线程数
server.tomcat.max-threads=200 # 最大工作线程数
与JDK线程池不同,Tomcat的线程池会先创建min-spare-threads个核心线程,当任务数超过核心线程数时,会继续创建线程直到max-threads。只有超过max-threads后,任务才会进入队列等待。
通过arthas的线程检查,我们发现一个关键现象:正常情况下应该存在的Acceptor和Poller线程,在故障时消失了!这解释了为什么会出现连接队列满但工作线程闲置的矛盾现象。
查看Tomcat源码(NioEndpoint类),Acceptor线程虽然捕获了异常,但对于OOM这样的Error,选择重新抛出,导致线程终止。这意味着:
更棘手的是,Acceptor线程在抛出OOM前没有记录日志(最新Tomcat版本也是如此)。这使得问题排查更加困难。为了捕获这类异常,可以设置全局异常处理器:
java复制Thread.setDefaultUncaughtExceptionHandler((t, e) -> {
if (t.getName().equals("http-nio-8100-Acceptor")) {
log.error("Tomcat Acceptor error", e);
}
});
基于以上分析,我们采取以下措施解决问题并预防类似情况:
优化文件处理:对easyexcel使用分片读取模式,避免大文件一次性加载
java复制// 正确的分片读取方式
EasyExcel.read(file, new AnalysisEventListener() {
// 每读取一定数量后处理
@Override
public void invoke(Object data, AnalysisContext context) {
// 处理数据
if (needClear()) {
context.readSheetHolder().clear();
}
}
}).sheet().doRead();
JVM参数调整:添加OOM时自动dump内存的配置
bash复制-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump
代码审查重点:
监控增强:
bash复制# 监控Tomcat关键线程
watch -n 5 'ps -eLf | grep tomcat | grep -E "Acceptor|Poller" | wc -l'
# 监控连接队列
watch -n 5 'netstat -anp | grep 8100 | grep ESTABLISHED | wc -l'
容量规划:
properties复制server.tomcat.accept-count=500 # 适当增大等待队列
server.tomcat.max-threads=500 # 根据CPU核心数调整
这次故障排查给我几个重要启示:
OOM的影响比想象中严重:不仅影响当前请求,可能导致整个容器不可用。对于关键业务系统,应该:
Tomcat的线程模型理解很重要:了解Acceptor/Poller/Worker的分工,才能快速定位类似"有连接但无处理"的问题
防御性编程的必要性:
监控的全面性:除了常规的CPU、内存监控,还需要关注:
在实际生产环境中,这类问题往往不是单一因素导致,而是多个小问题叠加的结果。这就要求我们:
最后分享一个实用技巧:对于Java应用,可以定期使用如下命令检查关键线程状态,形成健康检查习惯:
bash复制# 检查Tomcat线程
jstack <pid> | grep -A 10 'Acceptor|Poller'
# 检查连接状态
ss -antp | grep <port>