Tomcat停機過程分析與執行緒處理方法

天府雲創發表於2018-02-26

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

Tomcat停機過程分析

一個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<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

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

  • 第一步:建立一個基於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的檢測間隔)至少要控制在最大阻塞時間內,以留出足夠時間做終止以後的記錄工作。

參考資料

  • Tomcat原始碼7.0.69
  • Tomcat啟動與停止服務原理http://blog.csdn.net/beliefer/article/details/51585006
  • Tomcat生命週期管理http://blog.csdn.net/beliefer/article/details/51473807
  • JVMs and kill signalshttp://journal.thobe.org/2013/02/jvms-and-kill-signals.html
  • Task Execution and Schedulinghttps://docs.spring.io/spring/docs/current/spring-framework-reference/integration.html#scheduling
  • 《Java併發程式設計的藝術》

相關文章