于Linux云服务器环境里,发生内存溢出(OOM)问题时,常常致使服务进到假死状态,呈现出进程成活然而不能够回应API请求的状况。

本文会借助一个真实的案例,深入地剖析OOM致使Tomcat线程池出现异常的原理,并且给出一套完整的排查以及解决方案,助力运维人员迅速恢复线上的服务。

某业务线上的接口,当接收到异常的参数之时,就会没完没了地循环着去生成字符串,最终使得堆内存出现溢出的情况,也就是OOM。

出现OOM之后,借助命令ps -ef | grep java进行检查时,察觉到Java进程还在运行着,然而,所有的API请求通通都超时了,没有任何响应。

重启Docker容器后服务恢复,但问题根源未除。

二、环境复现与线程分析

于测试环境,此环境为CentOS 7.9 、Docker 20.10 、Tomcat 9.0.8 ,去复现该问题,借由jstack以及jmap来分析线程与内存状态。

1. OOM前的线程状态

"http-nio-9989-exec-1" #20 daemon prio=5 tid=0x123456 nid=0x7f3 running
"http-nio-9989-exec-2" #21 daemon prio=5 tid=0x123457 nid=0x7f4 waiting

2. OOM后的线程状态

"http-nio-9989-exec-103" #122 daemon prio=5 tid=0x223456 nid=0x8f3 waiting

核心业务线程丢失,仅剩少量非核心线程,导致请求无法处理。

3. Docker容器重启现象

在某些情形当中,OOM 将致使容器重新启动,去查看位于 /var/log/messages 处以及 Docker 日志,能够发觉 OOM Killer 进行了干预:

java invoked oom-killer: gfp_mask=0x201da, order=0, oom_score_adj=0

但更多时候进程存活却无响应,需深入Tomcat源码分析。

三、Tomcat NIO线程模型与异常处理

Tomcat 9.0采用NIO模型,核心组件包括:

Tomcat Acceptor线程OOM处理_Tomcat假死原因及解决方法_服务器Tomcat排查

Acceptor线程:负责接收新连接

Poller线程:轮询就绪事件

业务线程池:处理HTTP请求

氧气分子数量发生超出内存限制情况之际,当接收连接请求入口线程出现异常而退出之时,崭新的请求不能够被接纳收取,致使服务呈现出虚假停滞不动的状态咯。

但为何线程异常未被记录?

1. Acceptor线程异常分析

org.apache.tomcat.util.net.NioEndpoint 里面,Acceptor线程循环着去调用 serverSock.accept()

要是OOM在这个线程出现,所抛出的;OutOfMemoryError是属于Error的范畴,而不是Exception。

// Acceptor的run方法
try {
// 接收连接
} catch (Throwable t) {
// 这里只能捕获Exception,Error继续抛出
}

2. 线程未捕获异常处理器

//NioEndpoint$Acceptor#run
protected class Acceptor extends AbstractEndpoint.Acceptor {
    @Override
    public void run() {
        int errorDelay = 0;
        // Loop until we receive a shutdown command
        while (running) {
            //...忽略一些代码
            state = AcceptorState.RUNNING;
            try {
                //if we have reached max connections, wait
                countUpOrAwaitConnection();
                SocketChannel socket = null;
                try {
                    socket = serverSock.accept();
                } catch (IOException ioe) {
                }
                // Successful accept, reset the error delay
                errorDelay = 0;
                // Configure the socket
                if (running && !paused) {
                    // setSocketOptions() will hand the socket off to
                    // an appropriate processor if successful
                    if (!setSocketOptions(socket)) {
                        closeSocket(socket);
                    }
                } else {
                    closeSocket(socket);
                }
            } catch (Throwable t) {
                ExceptionUtils.handleThrowable(t);
                log.error(sm.getString("endpoint.accept.fail"), t);
            }
        }
        state = AcceptorState.ENDED;
    }
	//...忽略一些代码
}

Java线程当中如果未捕获异常,那么就会去调用Thread.getUncaughtExceptionHandler()

Tomcat默认未设置,导致异常被JVM吞没,日志无记录。

四、解决方案:配置全局异常捕获与OOM防护

1. 设置JVM参数启用OOM日志

//NioEndpoint#setSocketOptions
protected boolean setSocketOptions(SocketChannel socket) {
    // Process the connection
    try {
        //disable blocking, APR style, we are gonna be polling it
        socket.configureBlocking(false);
        Socket sock = socket.socket();
        socketProperties.setProperties(sock);
        NioChannel channel = nioChannels.pop();
        if (channel == null) {
            SocketBufferHandler bufhandler = new SocketBufferHandler(
                    socketProperties.getAppReadBufSize(),
                    socketProperties.getAppWriteBufSize(),
                    socketProperties.getDirectBuffer());
            if (isSSLEnabled()) {
                channel = new SecureNioChannel(socket, bufhandler, selectorPool, this);
            } else {
                channel = new NioChannel(socket, bufhandler);
            }
        } else {
            channel.setIOChannel(socket);
            channel.reset();
        }
        getPoller0().register(channel);
    } catch (Throwable t) {
        ExceptionUtils.handleThrowable(t);
        try {
            log.error("",t);
        } catch (Throwable tt) {
            ExceptionUtils.handleThrowable(tt);
        }
        // Tell to close the socket
        return false;
    }
    return true;
}

在Dockerfile或启动脚本中添加:

XX:+HeapDumpOnOutOfMemoryError 
XX:HeapDumpPath=/data/logs/heapdump.hprof 
XX:+ExitOnOutOfMemoryError

在出现OOM的时候,ExitOnOutOfMemoryError能够主动进行退出,随后会经由Docker自动重启来实现恢复。

服务器Tomcat排查_Tomcat假死原因及解决方法_Tomcat Acceptor线程OOM处理

2. 为Tomcat线程设置未捕获异常处理器

于Spring Boot启动类里添加,或者在ServletContextListener中添加:

//ExceptionUtils#handleThrowable
public static void handleThrowable(Throwable t) {
    if (t instanceof ThreadDeath) {
        throw (ThreadDeath) t;
    }
    if (t instanceof StackOverflowError) {
        // Swallow silently - it should be recoverable
        return;
    }
    if (t instanceof VirtualMachineError) {
        throw (VirtualMachineError) t;
    }
    // All other instances of Throwable will be silently swallowed
}

Thread.setDefaultUncaughtExceptionHandler((t, e) -> {
log.error("线程 [{}] 抛出未捕获异常", t.getName(), e);
// 发送告警
});

//AbstractEndpoint#startAcceptorThreads
protected final void startAcceptorThreads() {
    int count = getAcceptorThreadCount();
    acceptors = new Acceptor[count];
    for (int i = 0; i < count; i++) {
        acceptors[i] = createAcceptor();
        String threadName = getName() + "-Acceptor-" + i;
        acceptors[i].setThreadName(threadName);
        Thread t = new Thread(acceptors[i], threadName);
        t.setPriority(getAcceptorThreadPriority());
        t.setDaemon(getDaemon());
        t.start();
    }
}

3. 使用Docker资源限制

防止单个容器耗尽宿主机内存:

# docker-compose.yml
services:
app:
image: your-app:latest
deploy:
resources:
limits:
memory: 2G
reservations:
memory: 1G
memswap_limit: 2G
oom_kill_disable: false

4. 配置Tomcat连接器参数

server.xml中增加连接器容错:


Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
    @Override
    public void uncaughtException(Thread t, Throwable e) {
        logger.error("[Global Handler]thread-name:{},happen exp,", t.getName(), e);
    }
});

五、线上部署最佳实践

1. 云服务器配置(以阿里云为例)

选择计算型c6e实例,内存≥4G

挂载数据盘并分区,存放日志与dump文件

关闭Swap防止影响性能

2. 环境搭建步骤

Tomcat Acceptor线程OOM处理_服务器Tomcat排查_Tomcat假死原因及解决方法

# 安装Docker
curl -fsSL https://get.docker.com | bash
systemctl enable docker
# 配置Docker日志轮转
cat > /etc/docker/daemon.json <<EOF
{
"log-driver": "json-file",
"log-opts": {
"max-size": "100m",
"max-file": "3"
}
}
EOF
# 部署应用
docker build -t app:v1 .
docker run -d --memory=2g --name app app:v1

3. 监控与告警

使用Prometheus + Grafana监控:

Tomcat假死原因及解决方法_服务器Tomcat排查_Tomcat Acceptor线程OOM处理

JVM内存使用率(>85%告警)

Tomcat线程数(异常下降告警)

接口响应时间(>3s告警)

六、总结

有一种情况是服务出现假死现象,其本质在于,是内存溢出导致了Tomcat核心线程异常退出,并且这种异常没有被有效地记录下来。

经由配置JVM参数,以及线程异常处理器,还有Docker资源限制,能够切实有效地规避此类问题。

//NioEndpoint$Poller#events
public void run() {
    if (interestOps == OP_REGISTER) {
        try {
            socket.getIOChannel().register(
                    socket.getPoller().getSelector(), SelectionKey.OP_READ, socketWrapper);
        } catch (Exception x) {
            log.error(sm.getString("endpoint.nio.registerFail"), x);
        }
    }
    //...
}

日常运维中需重点关注:

1. 代码层防止内存泄漏(如无限拼接字符串)

2. 容器资源隔离与限制

3. 完善的监控告警机制

上边提及的方案已经放到生产环境当中进行验证,肯定地讲能保证就是出现了OOM这种情况,服务也能够凭借自动恢复措施迅速止住问题,防止出现长时间的好像死掉一样的状况。

//NioEndpoint$Poller#events
public boolean events() {
    boolean result = false;
    PollerEvent pe = null;
    for (int i = 0, size = events.size(); i < size && (pe = events.poll()) != null; i++ ) {
        result = true;
        try {
            pe.run();
            pe.reset();
            if (running && !paused) {
                eventCache.push(pe);
            }
        } catch ( Throwable x ) {
            log.error("",x);
        }
    }
    return result;
}