ShutdownHook - 優雅地停止服務

一、什麼是ShutdownHook?

在Java程序中可以通過添加關閉鉤子,實現在程序退出時關閉資源、平滑退出的功能。
使用Runtime.addShutdownHook(Thread hook)方法,可以註冊一個JVM關閉的鉤子,這個鉤子可以在以下幾種場景被調用:
1. 程序正常退出
2. 使用System.exit()
3. 終端使用Ctrl+C觸發的中斷
4. 系統關閉
5. 使用Kill pid命令幹掉進程


Runtime.java中相關方法源碼

public void addShutdownHook(Thread hook) {
    SecurityManager sm = System.getSecurityManager();
    if (sm != null) {
        sm.checkPermission(new RuntimePermission("shutdownHooks"));
    }
    ApplicationShutdownHooks.add(hook);
}

public boolean removeShutdownHook(Thread hook) {
    SecurityManager sm = System.getSecurityManager();
    if (sm != null) {
        sm.checkPermission(new RuntimePermission("shutdownHooks"));
    }
    return ApplicationShutdownHooks.remove(hook);
}

ApplicationShutdownHooks.java

class ApplicationShutdownHooks {
    /* The set of registered hooks */
    private static IdentityHashMap<Thread, Thread> hooks;
    static {
        try {
            Shutdown.add(1 /* shutdown hook invocation order */,
                false /* not registered if shutdown in progress */,
                new Runnable() {
                    public void run() {
                        runHooks();
                    }
                }
            );
            hooks = new IdentityHashMap<>();
        } catch (IllegalStateException e) {
            // application shutdown hooks cannot be added if
            // shutdown is in progress.
            hooks = null;
        }
    }


    private ApplicationShutdownHooks() {}

    /* Add a new shutdown hook.  Checks the shutdown state and the hook itself,
     * but does not do any security checks.
     */
    static synchronized void add(Thread hook) {
        if(hooks == null)
            throw new IllegalStateException("Shutdown in progress");

        if (hook.isAlive())
            throw new IllegalArgumentException("Hook already running");

        if (hooks.containsKey(hook))
            throw new IllegalArgumentException("Hook previously registered");

        hooks.put(hook, hook);
    }

    /* Remove a previously-registered hook.  Like the add method, this method
     * does not do any security checks.
     */
    static synchronized boolean remove(Thread hook) {
        if(hooks == null)
            throw new IllegalStateException("Shutdown in progress");

        if (hook == null)
            throw new NullPointerException();

        return hooks.remove(hook) != null;
    }

    /* Iterates over all application hooks creating a new thread for each
     * to run in. Hooks are run concurrently and this method waits for
     * them to finish.
     */
    static void runHooks() {
        Collection<Thread> threads;
        synchronized(ApplicationShutdownHooks.class) {
            threads = hooks.keySet();
            hooks = null;
        }

        for (Thread hook : threads) {
            hook.start();
        }
        for (Thread hook : threads) {
            try {
                hook.join();
            } catch (InterruptedException x) { }
        }
    }
}

二、java進程平滑退出的意義

很多時候,我們會有這樣的一些場景,比如說nginx反向代理若干個負載均衡的web容器,又或者微服務架構中存在的若干個服務節點,需要進行無間斷的升級發佈。
在重啓服務的時候,除非我們去變更nginx的配置,否則重啓很可能會導致正在執行的線程突然中斷,本來應該要完成的事情只完成了一半,並且調用方出現錯誤警告。
如果能有一種簡單的方式,能夠讓進程在退出時能執行完當前正在執行的任務,並且讓服務的調用方將新的請求定向到其他負載節點,這將會很有意義。
自己註冊ShutdownHook可以幫助我們實現java進程的平滑退出。


三、java進程平滑退出的思路

  1. 在服務啓動時註冊自己的ShutdownHook
  2. ShutdownHook在被運行時,首先不接收新的請求,或者告訴調用方重定向到其他節點
  3. 等待當前的執行線程運行完畢,如果五秒後仍在運行,則強制退出

四、如何屏敝第三方組件的ShutdownHook

我們會發現,有一些第三方組件在代碼中註冊了關閉自身資源的ShutdownHook,這些ShutdownHook對於我們的平滑退出有時候起了反作用。
比如dubbo,在static方法塊裏面註冊了自己的關閉鉤子,完全不可控。在進程退出時直接就把長連接給斷開了,導致當前的執行線程無法正常完成,源碼如下:

static {
    Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
        public void run() {
            if (logger.isInfoEnabled()) {
                logger.info("Run shutdown hook now.");
            }
            ProtocolConfig.destroyAll();
        }
    }, "DubboShutdownHook"));
}

從Runtime.java和ApplicationShutdownHooks.java的源碼中,我們看到並沒有一個可以遍歷操作shutdownHook的方法。
Runtime.java僅有的一個removeShutdownHook的方法,對於未寫線程名的匿名類來說,無法獲取對象的引用,也無法分辨出彼此。
ApplicationShutdownHooks.java不是public的,類中的hooks也是private的。
只有通過反射的方式才能獲取並控制它們。定義ExcludeIdentityHashMap類來幫助我們阻止非自己的ShutdownHook注入

class ExcludeIdentityHashMap<K,V> extends IdentityHashMap<K,V> {

    public V put(K key, V value) {
        if (key instanceof Thread) {
            Thread thread = (Thread) key;
            if (!thread.getName().startsWith("My-")) {
                return value;
            }
        }
        return super.put(key, value);
    }
}

通過反射的方式注入自己的ShutdownHook並清除其他Thread

String className = "java.lang.ApplicationShutdownHooks";
Class<?> clazz = Class.forName(className);
Field field = clazz.getDeclaredField("hooks");
field.setAccessible(true);

Thread shutdownThread = new Thread(new Runnable() {
    @Override
    public void run() {
        // TODO
    }
});
shutdownThread.setName("My-WebShutdownThread");
IdentityHashMap<Thread, Thread> excludeIdentityHashMap = new ExcludeIdentityHashMap<>();
excludeIdentityHashMap.put(shutdownThread, shutdownThread);

synchronized (clazz) {
    IdentityHashMap<Thread, Thread> map = (IdentityHashMap<Thread, Thread>) field.get(clazz);
    for (Thread thread : map.keySet()) {
        Log.info("found shutdownHook: " + thread.getName());
        excludeIdentityHashMap.put(thread, thread);
    }

    field.set(clazz, excludeIdentityHashMap);
}

五、實現服務的平滑退出

對於一般的微服務來說,有這幾種任務的入口:Http請求、dubbo請求、RabbitMQ消費、Quartz任務

5.1 Http請求

測試發現Jetty容器在stop的時候不能實現平滑退出,springboot默認使用的tomcat容器可以,以下是部分代碼示例:

EmbeddedWebApplicationContext embeddedWebApplicationContext = (EmbeddedWebApplicationContext) applicationContext;
EmbeddedServletContainer embeddedServletContainer = embeddedWebApplicationContext.getEmbeddedServletContainer();
if (embeddedServletContainer instanceof TomcatEmbeddedServletContainer) {
    Connector[] connectors = tomcatEmbeddedServletContainer.getTomcat().getService().findConnectors();

    for (Connector connector : connectors) {
        connector.pause();
    }

    for (Connector connector : connectors) {
        Executor executor = connector.getProtocolHandler().getExecutor();
        if (executor instanceof ThreadPoolExecutor) {
            try {
                ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor;
                threadPoolExecutor.shutdown();
                if (!threadPoolExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
                    log.warn("Tomcat thread pool did not shutdown gracefully within 5 seconds. Proceeding with forceful shutdown");
                }
            } catch (InterruptedException e) {
                log.warn("TomcatShutdownHook interrupted", e);
            }
        }
    }
}

5.2 dubbo請求

嘗試了許多次,看了相關的源碼,dubbo不支持平滑退出;解決方法只有一個,那就是修改dubbo的源碼,以下兩個地址有詳細介紹:
http://frankfan915.iteye.com/blog/2254097
https://my.oschina.net/u/1398931/blog/790709

5.3 RabbitMQ消費

以下是SpringBoot的示例,不使用Spring原理也是一樣的

RabbitListenerEndpointRegistry rabbitListenerEndpointRegistry = applicationContext.getBean(
        RabbitListenerConfigUtils.RABBIT_LISTENER_ENDPOINT_REGISTRY_BEAN_NAME,
        RabbitListenerEndpointRegistry.class);
Collection<MessageListenerContainer> containers = rabbitListenerEndpointRegistry.getListenerContainers();
for (MessageListenerContainer messageListenerContainer : containers) {
    messageListenerContainer.stop();
}

5.4 Quartz任務

quartz也比較簡單

Scheduler scheduler = applicationContext.getBean(Scheduler.class);
scheduler.shutdown(true);

六、爲何重啓時有時會有ClassNotFoundException

springboot通過java -jar example.jar的方式啓動項目,在使用腳本restart的時候,首先覆蓋舊的jar包,然後stop舊線程,啓動新線程,這樣就可能會出現此問題。因爲在stop的時候,ShutdownHook線程被喚醒,在其執行過程中,某些類(尤其是匿名類)還未加載,這時候就會通知ClassLoader去加載;ClassLoader持有的是舊jar包的文件句柄,雖然新舊jar包的名字路徑完全一樣,但是ClassLoader仍然是使用open着的舊jar包文件,文件已經找不到了,所以類加載不了就ClassNotFound了。

如何解決呢?也許有更優雅的方式,但是我沒有找到;但是我們可以簡單地把順序調整一下,先stop、再copy覆蓋、最後start,這樣就OK了。

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