记一次Tomcat假死问题分析

记一次Tomcat假死问题分析

1. 问题背景

线上环境因为有个接口内部在错误的参数下,会不断生成字符串,导致OOM,在OOM之后服务还能正常运行,但是发送的Api请求已经没有办法响应了。

2. 问题复现

模拟线上问题,在测试环境上进行复现,一段时间后服务会爆出OOM,但是不是每次都会导致Tomcat假死,有些情况下Tomcat还能正常访问。

情况一:核心线程丢失

OOM之前Tomcat线程情况

ID 线程名称 Group
125 http-nio-9989-Acceptor-0 main
126 http-nio-9989-AsyncTimeout main
123 http-nio-9989-ClientPoller-0 main
124 http-nio-9989-ClientPoller-1 main
113 http-nio-9989-exec-1 main

OOM之后Tomcat线程情况

ID 线程名称 Group
123 http-nio-9989-ClientPoller-0 main
1431 http-nio-9989-exec-103 main

情况二:服务重启

日志打印java.lang.OutOfMemoryError: Java heap space后,服务重启。

情况三:Tomcat后台线程丢失

只有后台线程丢失,但是Acceptor线程和Poller线程还存在

3. 假死情况

可以查看本人另外一篇文章【Tomcat之IO模型实现】

从Tomcat的NIO模型得知有几个组件,Acceptor、Poller、业务线程池。这三个组件情况如下:

  • Acceptor线程:该线程主要是监听连接(socket.accept()),如果该线程挂掉,那么及时操作系统层面TCP3次握手成功,但是业务上也办法获取到这个连接。默认情况下,只有1个Acceptor线程,可以通过acceptorThreadCount参数设置。
  • Poller线程:Acceptor获取到连接之后,会轮询从Poller列表中取一个Poller进行处理。如果Poller线程挂掉了,那么就没法处理读请求了。默认情况下,会有min(2,cpu核数)个Poller线程,可以通过pollerThreadCount参数设置。
  • 业务线程:Poller线程将读请求放到业务线程处理,如果业务线程阻塞(比如被某个网络IO阻塞),那么此刻的读请求还在业务线程池的队列中,没有被处理。默认情况下,最小线程为10,可以通过minSpareThreads参数设置,最大线程为200,可以通过maxThreads参数设置。

此时分析再结合复现的情况,如果核心线程挂掉,那确实存在假死情况。但是从事发现场来看,并没有发现Tomcat的Acceptor、Poller线程打印出OutofMemoryError的异常,及时将org.apache.tomcat和org.apache.catalina设置成Debug级别。因此需要深入源码分析。

4. 异常处理分析

4.1 Acceptor异常处理分析

Acceptor逻辑如下,就是在循环内不断地监听accept(),查看是否有新连接。

java 复制代码
//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;
    }
	//...忽略一些代码
}

其中setSocketOptions是往Poller中调用register方法,把这个Socket传递过去。getPoller0()方法会以轮询的策略获取一个Poller

java 复制代码
//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;
}

此处重点看一下ExceptionUtils.handleThrowable方法的逻辑,因为OutofMemoryError是VirtualMachineError的子类,所以这里会被直接抛出异常,。而OutofMemeoryError属于 uncheck exception,抛出uncheck exception就会导致线程终止,并且主线程和其他线程无法感知这个线程抛出的异常。如果线程代码(run方法之外)之外来捕获这个异常的话,可以通过Thread的setUncaughtExceptionHandler处理。

java 复制代码
//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
}

再看启动的时候,线程是否会设置uncaughtExceptionHandler,发现并没有设置,所以异常没法被正常打印到日志中。

java 复制代码
//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();
    }
}

小结:OutofMemoryError被捕获了,然后重新抛出,但是因为OutofMemoryError是uncheck exception,而线程没有设置uncaughtExceptionHandler,所以没法被打印。

4.2 增加全局异常捕获

在启动的时候,设置全局线程nncaughtException处理器。这里简单打印线程名称,并且抛出异常。

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

重新复现问题,发现Acceptor线程有打印异常情况。

不过,同时也发现,Poller线程有打印错误日志,但并不是全局处理器打印的。

下图为Arthas截图,发现仍然Poller线程仍然存在。因此再分析Poller的异常处理。

4.3 Poller异常处理

从上面的异常日志俩看,Poller线程是在处理PollerEvent中处理REGISTER事件时的抛出异常,查看相关代码。发现此处捕获的是Exception,而OutofMemoryError属于Error,所以此处不会被捕获到,并且会往上抛出。

java 复制代码
//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);
        }
    }
    //...
}

在Poller的events方法中,会循环调用PollerEvent的run方法,这里内部有捕获一个Throwable,而Error是继承Throwable所以OutofMemoryError会在这里被捕获,而且会打印日志,并且线程不会挂掉。

java 复制代码
//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;
}

而在Poller的循环中,发现也有ExceptionUtils.handleThrowable处理,如果在这里出现OutofMemoryError异常的话,那么Poller线程将会被终止。

java 复制代码
//NioEndpoint$Poller#run
public class Poller implements Runnable {
	public void run() {
		// Loop until destroy() is called
		while (true) {
			Boolean hasEvents = false;
			try {
				if (!close) {
					hasEvents = events();
					//....
				}
			}catch (Throwable x) {
				ExceptionUtils.handleThrowable(x);
				log.error("",x);
				continue;
			}
            //...
		}
	}
}

小结:Poller内部实现中,对于异常处理不同,有些地方能捕获异常并且Poller线程正常处理,有些地方没有捕获异常,可能会因为OutofMemoryError导致线程终止

5. 结论

当应用程序出现OOM的时候,Tomcat核心线程有可能会挂掉,导致接口接口无法正常访问,因此要尽量避免业务上出现OOM。此外,当出现OOM后应用无法访问时,可以试着排查一下,是不是tomcat的核心线程挂掉导致

6. 参考资料

  1. Tomcat 源码 8.5.x分支
  2. www.zhihu.com/question/36...
  3. bz.apache.org/bugzilla/sh...
  4. java多线程中的异常处理:blog.csdn.net/bluishglc/a...
相关推荐
喵叔哟14 分钟前
重构代码中引入外部方法和引入本地扩展的区别
java·开发语言·重构
尘浮生20 分钟前
Java项目实战II基于微信小程序的电影院买票选座系统(开发文档+数据库+源码)
java·开发语言·数据库·微信小程序·小程序·maven·intellij-idea
不是二师兄的八戒44 分钟前
本地 PHP 和 Java 开发环境 Docker 化与配置开机自启
java·docker·php
爱编程的小生1 小时前
Easyexcel(2-文件读取)
java·excel
带多刺的玫瑰1 小时前
Leecode刷题C语言之统计不是特殊数字的数字数量
java·c语言·算法
计算机毕设指导62 小时前
基于 SpringBoot 的作业管理系统【附源码】
java·vue.js·spring boot·后端·mysql·spring·intellij-idea
Gu Gu Study2 小时前
枚举与lambda表达式,枚举实现单例模式为什么是安全的,lambda表达式与函数式接口的小九九~
java·开发语言
Chris _data2 小时前
二叉树oj题解析
java·数据结构
牙牙7052 小时前
Centos7安装Jenkins脚本一键部署
java·servlet·jenkins
paopaokaka_luck2 小时前
[371]基于springboot的高校实习管理系统
java·spring boot·后端