根据上面的分析,造成异常的主要原因就是
线程没有及时终止。所以解决办法的关键就是
如何在容器终止之前,优雅地终止用户启动的线程。
创建自己的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的检测间隔)至少要控制在最大阻塞时间内,以留出足够时间做终止以后的记录工作。