Tomcat停機過程分析及線程處理方法

工作中經常遇到因爲Tomcat shutdown時自己創建的線程因沒有及時停止而引起的各種莫名其妙的報錯,這篇文章將通過對Tomcat停機過程的梳理討論產生這些錯誤的原因,同時提出了兩個可行的解決辦法。

Tomcat停機過程分析

一個Tomcat進程本質上是一個JVM進程,其內部結構如下圖所示:(圖片來自網絡)

從上至下分別爲Server、service、connnector | 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<Future<Void>> results = new ArrayList<Future<Void>>();
    for (int i = 0; i < children.length; i++) {
        results.add(startStopExecutor.submit(new StopChild(children[i])));
    }
    boolean fail = false;
    for (Future<Void> 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將會是:

<listener>
    <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>

看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不會主動結束!這個問題暫且擱置,下面說說停機時遇到的各種問題。

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

所以最直接的想法就是建立一種對這些組件的管理模塊,具體做法分爲兩種:

  1. 對於具體Thread類,爲使用者提供一個父類,所有創建的線程均爲這個父類的子類。父類重寫isInterrupted方法。使用者使用時需要檢測線程當前終止狀態。
while(!isInterrupted()){
    /**do some thing**/
}
  1. 對於Executors等組件,使用專門定製的註冊器,使用者保證在創建一個對應組件後立即將組件註冊到對應註冊器上。在Listener監聽到容器銷燬事件時調用註冊器上的停止方法。

創建自己的Listener的優點是可以主動在監聽到事件時阻塞銷燬進程,爲用戶線程做清理工作爭取些時間,因爲此時Spring還沒有銷燬,程序的狀態一切正常。

缺點就是對代碼侵入性大,並且依賴於使用者的編碼。

使用Spring提供的TaskExecutor

爲了應對在webapp中管理自己的線程的目的,Spring提供了一套TaskExcutor的工具。其中的ThreadPoolTaskExecutor與Java5中的ThreadPoolExecutor非常類似,只是生命週期會被Spring管理,Spring框架停止時,Executor也會被停止,用戶線程會收到中斷異常。同時Spring還提供了ScheduledThreadPoolExecutor,對於定時任務或者要創建自己線程的需求可以用這個類。對於線程管理,Spring提供了非常豐富的支持, 具體可以看這裏 。

使用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的檢測間隔)至少要控制在最大阻塞時間以內以留出足夠時間做終止以後的記錄工作。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章