对症下药:Tomcat停机过程分析与线程处理方法

高强 2018-01-29 15:13:25
作者介绍

高强,毕业于哈尔滨工业大学,目前在网易做着快乐的后台开发工作。


一个Tomcat进程本质上是一个JVM进程,其内部结构如下图所示:

 

(图片来自网络)

 

从上至下分别为Server、service、connector | Engine、host、context。


在实现中,Engine和host只是一种抽象,更核心的功能在context中实现。顶层的Server只能有一个,一个Server可以包含多个Service,一个Service可以包含多个Connector和一个Continer。Continer是对Engine、Host或者Context的抽象。不严格来说,一个Context对应一个Webapp。

 

当Tomcat启动时,主线程的主要工作概括如下:

public void start() {

    load();//config server and init it

    getServer().start();//start server and all continers belong to it

    Runtime.getRuntime().addShutdownHook(shutdownHook);// register the shutdown hook

    await();//wait here util the end of Tomcat Proccess

    stop();

}

 

  1. 通过扫描配置文件(默认为server.xml)来构建从顶层Server开始到Service、Connector等容器(其中还包含了对Context的构建)。

  2. 调用Catalina的start方法,进而调用Server的start方法。start方法将导致整个容器的启动。

 

Server、Service、Connector、Context等容器都实现了Lifecycle接口,同时这些组件保持了严格的、从上至下的树状结构。Tomcat只通过对根节点(Server)的生命周期管理就可以实现对所有树状结构中其它所有容器的管理。

 

  1. 将自己阻塞于await()方法。

    await()方法会等待一个网络连接请求,当有用户连接到对应端口并发送指定字符串(通常是’SHUTDOWN’)时,await()返回,主线程继续执行。

  2. 主线程执行stop()方法。

    stop()方法将会从Server开始调用所有其下容器的stop方法。stop()方法执行完后,主线程退出,如果没有问题,Tomcat容器此时运行终止。

 

值得注意的是,stop()方法自Service下面一层开始是异步执行的。代码如下:

 

protected synchronized void stopInternal(){

    /*other code*/

    Container children[] = findChildren();

    List> results = new ArrayList>();

    for (int i = 0; i < children.length; i++) {

        results.add(startStopExecutor.submit(new StopChild(children[i])));

    }

    boolean fail = false;

    for (Future result : results) {

        try {

            result.get();

        } catch (Exception e) {

            log.error(sm.getString("containerBase.threadedStopFailed"), e);

            fail = true;

        }

    }

    if (fail) {

        throw new LifecycleException(

                sm.getString("containerBase.threadedStopFailed"));

    }

    /*other code*/

}

 

在这些被关闭的children中,按照标准应该是Engine-Host-Context这样的层状结构,也就是说最后会调用Context的stop()方法。在Context的stopInternal方法中会调用这三个方法:

  • filterStop();

  • listenerStop();

  • ((Lifecycle) loader).stop();

 

(注:这只是其中的一部分,因为与我们分析的过程有关所以列出来了,其它与过程无关的方法未予列出。)

 

其中filterStop会清理我们在web.xml中注册的filter,listenerStop会进一步调用web.xml中注册的Listener的onDestory方法(如果有多个Listener注册,调用顺序与注册顺序相反)。而loader在这儿是WebappClassLoader,其中重要的操作(尝试停止线程、清理引用资源和卸载Class)都是在stop函数中做的。

 

如果我们使用的SpringWeb,一般web.xml中注册的Listener将会是:

    org.springframework.web.context.ContextLoaderListener

 

 

看ContextLoaderListener的代码不难发现,Spring框架通过Listener的contextInitialized方法初始化Bean,通过contextDestroyed方法清理Bean。

 

public class ContextLoaderListener extends ContextLoader implements ServletContextListener {

    public ContextLoaderListener() {

    }

    public ContextLoaderListener(WebApplicationContext context) {

        super(context);

    }

    public void contextInitialized(ServletContextEvent event) {

        this.initWebApplicationContext(event.getServletContext());

    }

    public void contextDestroyed(ServletContextEvent event) {

        this.closeWebApplicationContext(event.getServletContext());

        ContextCleanupListener.cleanupAttributes(event.getServletContext());

    }

}

 

在这儿有一个重要的事:我们的线程是在loader中被尝试停止的,而loader的stop方法在listenerStop方法之后,也就是说,即使loader成功终止了用户自己启动的线程,依然有可能在线程终止之前使用Sping框架,而此时Spring框架已经在Listener中关闭了!况且在loader的清理线程过程中只有配置了clearReferencesStopThreads参数,用户自己启动的线程才会被强制终止(使用Thread.stop()),而在大多数情况下,为了保证数据的完整性,这个参数不会被配置。也就是说,在WebApp中,用户自己启动的线程(包括Executors),都不会因为容器的退出而终止。

 

我们知道,JVM自行退出的原因主要有两个:

  • 调用了System.exit()方法

  • 所有非守护线程都退出

 

而Tomcat中没有在stop执行结束时主动调用System.exit()方法,所以如果有用户启动的非守护线程,并且用户没有与容器同步关闭线程的话,Tomcat不会主动结束!这个问题暂且搁置,下面说说停机时遇到的各种问题。
 

IllegalStateException
在使用Spring框架的Webapp中,Tomcat退出时Spring框架的关闭与用户线程结束之间有严重的同步问题。在这段时间里(Spring框架关闭,用户线程结束前),会发生很多不可预料的问题。这些问题中最常见的就是IllegalStateException了。发生这样的异常时,标准代码如下:

 

public void run(){

    while(!isInterrupted()) {

        try {

            Thread.sleep(1000);

            GQBean bean = SpringContextHolder.getBean(GQBean.class);

            /*do something with bean...*/

        } catch (Exception e) {

            e.printStackTrace();

        }

    }

}

 

这种错误很容易复现,也很常见,不用多说。

 

ClassNotFound/NullPointerException

 

这种错误不常见,分析起来也比较麻烦。

 

在前面的分析中我们确定了两件事:

 

  1. 用户创建的线程不会随着容器的销毁而停止。

  2. ClassLoader在容器的停止过程中卸载了加载过的Class。

 

很容易确定这又是由线程没有结束引起的。

 

  • 当ClassLoader卸载完毕,用户线程尝试去load一个Class时,报ClassNotFoundException或者NoClassDefFoundError。

  • 在ClassLoader卸载过程中,因为Tomcat没有对停止容器进行严格的同步,此时如果尝试load一个Class可能会导致NullPointerException,原因如下:

 

//part of load class code, may be executed in user thread

protected ResourceEntry findResourceInternal(...){

    if (!started) return null;

    synchronized (jarFiles) {

        if (openJARs()) {

            for (int i = 0; i < jarFiles.length; i++) {

                jarEntry = jarFiles[i].getJarEntry(path);

                    if (jarEntry != null) {

                    try {

                        entry.manifest = jarFiles[i].getManifest();

                    } catch (IOException ioe) {

                        // Ignore

                    }

                    break;

                }

            }

        }

    }

    /*Other statement*/

}

 

从代码中可以看到,对jarEntry的访问进行了非常谨慎的同步操作。在其它对jarEntry的使用处都有非常谨慎的同步,除了在stop中没有:

 

// loader.stop() must be executed in stop thread

public void stop() throws LifecycleException {

    /*other statement*/

    length = jarFiles.length;

    for (int i = 0; i < length; i++) {

        try {

            if (jarFiles[i] != null) {

                jarFiles[i].close();

            }

        } catch (IOException e) {

            // Ignore

        }

        jarFiles[i] = null;

    }

    /*other statement*/

}

 

可以看到,上面两段代码中,如果用户线程进入同步代码块后(此时会导致线程缓存区的刷新),started变为false,跳过了更新jarFiles或者此时jarFiles[0]还未被置空,等到从openJARs返回后,stop正好执行过jarFiles[0] = null, 便会触发NullPointerException。

 

这个异常非常难以理解,原因就是为什么会触发loadClass操作,尤其是在代码中并没有new一个类的时候。事实上有很多时候都会触发对一个类的初始化检查。(注意是类的初始化,不是类实例的初始化,两者天差地别)


如下情况将会触发类的初始化检查:

 

  • 当前线程中第一次创建此类的实例

  • 当前线程中第一次调用类的静态方法

  • 当前线程中第一次使用类的静态成员

  • 当前线程中第一次为类静态成员赋值

 

(注:如果此时类已经初始化完毕,将直接返回,如果此时类还没有初始化,将执行类的初始化操作)

 

当在一个线程中发生上面这些情况时就会触发初始化检查(一个线程中最多检查一次),检查这个类的初始化情况之前必然需要获得这个类,此时需要调用loadClass方法。

 

一般有如下模式的代码容易触发上述异常:

 

try{

    /**do something **/

}catch(Exception e){

    //ExceptionUtil has never used in the current thread before

    String = ExceptionUtil.getExceptionTrace(e);

    //or this, ExceptionTracer never appears in the current thread before

    System.out.println(new ExceptionTracer(e));

    //or other statement that triggers a call of loadClass

    /**do other thing**/

}

 

 


 

根据上面的分析,造成异常的主要原因就是线程没有及时终止。所以解决办法的关键就是如何在容器终止之前,优雅地终止用户启动的线程

 

创建自己的Listener作为终止线程的通知者

 

根据分析,项目中主要用到用户创建的线程,包括四种:

  • Thread

  • Executors

  • Timer

  • Scheduler

 

所以最直接的想法就是建立一种对这些组件的管理模块,具体做法分为两步:

 

  • 第一步:创建一个基于Listener的管理模块,并将上面提到的四种类型的类实例交由模块管理。

  • 第二步:在Listener监听到Tomcat停机时,触发其管理的实例对应的结束方法。比如Thread触发interrupt()方法,ExecutorService触发shutdown()或者shutdownNow()方法(依赖具体策略选择)等。

 

值得注意的是,对于用户创建的Thread需要响应Interrupt事件,即在isInterrupted()返回true或在捕获到InterruptException后,退出线程。事实上,创建不响应Interrupt事件的线程是一种非常不好的设计。

 

创建自己Listener的优点是可以主动在监听到事件时阻塞销毁进程,为用户线程做清理工作争取些时间,因为此时Spring还没有销毁,程序的状态一切正常。

 

缺点就是对代码侵入性大,并且依赖于使用者的编码。

 

使用Spring提供的TaskExecutor

 

为了应对在webapp中管理自己线程的目的,Spring提供了一套TaskExcutor的工具。其中的ThreadPoolTaskExecutor与Java5中的ThreadPoolExecutor非常类似,只是生命周期会被Spring管理,Spring框架停止时,Executor也会被停止,用户线程会收到中断异常。同时,Spring还提供了ScheduledThreadPoolExecutor,对于定时任务或者要创建自己线程的需求可以用这个类。对于线程管理,Spring提供了非常丰富的支持,具体可以看这里:

 

https://docs.spring.io/spring/docs/current/spring-framework-reference/integration.html#scheduling。

 

使用Spring框架的优点是对代码侵入性小,对代码依赖性也相对较小。

 

缺点是Spring框架不保证线程中断与Bean销毁的时间先后顺序,即如果一个线程在捕获InterruptException后,再通过Spring去getBean时,依然会触发IllegalSateException。同时使用者依然需要检查线程状态或者在Sleep中触发中断,否则线程依然不会终止。

 

其它需要提醒的

 

在上面的解决方法中,无论是在Listener中阻塞主线程的停止操作,还是在Spring框架中不响应interrupt状态,都能为线程继续做一些事情争取些时间。但这个时间不是无限的。在catalina.sh中,stop部分的脚本中我们可以看到(这里删繁就简体现一下):

 

#Tomcat停机脚本摘录

#第一次正常停止

eval "\"$_RUNJAVA\"" $LOGGING_MANAGER $JAVA_OPTS \

    -Djava.endorsed.dirs="\"$JAVA_ENDORSED_DIRS\"" -classpath "\"$CLASSPATH\"" \

    -Dcatalina.base="\"$CATALINA_BASE\"" \

    -Dcatalina.home="\"$CATALINA_HOME\"" \

    -Djava.io.tmpdir="\"$CATALINA_TMPDIR\"" \

    org.apache.catalina.startup.Bootstrap "$@" stop

#如果终止失败 使用kill -15

if [ $? != 0 ]; then

    kill -15 `cat "$CATALINA_PID"` >/dev/null 2>&1

#设置等待时间

SLEEP=5

if [ "$1" = "-force" ]; then

    shift

    #如果参数中有-force 将强制停止

    FORCE=1

fi

while [ $SLEEP -gt 0 ]; do

    sleep 1

    SLEEP=`expr $SLEEP - 1 `

done

#如果需要强制终止 kill -9

if [ $FORCE -eq 1 ]; then

    kill -9 $PID

fi

 

从上面的停止脚本可以看到,如果配置了强制终止(我们服务器默认配置了),你阻塞终止进程去做自己的事的时间只有5秒钟。这期间还有其它线程在做一些任务以及线程真正开始终止到发现终止的时间(比如从当前到下一次调用isInterrupted的时间),考虑到这些的话,最大阻塞时间应该更短。

 

从上面的分析中也可以看到,如果服务中有比较重要又耗时的任务,又希望保证一致性的话,最好的办法就是在阻塞的宝贵的5秒钟时间里记录当前执行进度,等到服务重启的时候检测上次执行进度,然后从上次的进度中恢复。

 

建议每个任务的执行粒度(两个isInterrupted的检测间隔)至少要控制在最大阻塞时间内,以留出足够时间做终止以后的记录工作。
 

参考资料
 

 

最新评论
访客 2023年08月20日

230721

访客 2023年08月16日

1、导入Mongo Monitor监控工具表结构(mongo_monitor…

访客 2023年08月04日

上面提到: 在问题描述的架构图中我们可以看到,Click…

访客 2023年07月19日

PMM不香吗?

访客 2023年06月20日

如今看都很棒

活动预告