Java System#exit 無法退出程序的問題探索

背景

有朋友碰到了一個情況:java.lang.System#exit無法退出應用程序。我聽到這種情況的時候是感覺很驚奇的,這函數還能不起作用?這就好奇不已了呀

接着,朋友繼續給出了他的場景描述:在Dubbo應用連接註冊中心的時候,如果連接(超時)失敗,期望調用System#exit退出應用程序,但是程序並沒有按期望退出,JVM進程還存在

與此同時,如果把執行System#exit的代碼放到另一個線程,程序可以按期望退出,JVM進程結束

用僞代碼描述如下:

Future<Object> future = 連接註冊中心的Future;
try {
    Object o = future.get(3, TimeUnit.SECONDS);
} catch (Exception e) {
	log.error("connect failed xxxx");
    System.exit(1); // 程序無法退出
}

-----------

Future<Object> future = 連接註冊中心的Future;
try {
    Object o = future.get(3, TimeUnit.SECONDS);
} catch (Exception e) {
	log.error("connect failed xxxx");
    new Thread(() -> System.exit(1)).start(); // 程序能按期望退出
}

朋友面臨的場景比僞代碼描述的情況複雜的多,但所面臨的本質問題是一樣的。更一般化地問題,在Dubbo的org.apache.dubbo.registry.zookeeper.ZookeeperRegistry#ZookeeperRegistry構造函數中,直接執行System.exit(1);程序無法退出,放在異步線程中執行卻可以按期望退出

即:

// org.apache.dubbo.registry.zookeeper.ZookeeperRegistry#ZookeeperRegistry

public ZookeeperRegistry(URL url, ZookeeperTransporter zookeeperTransporter) {
    super(url);
    System.exit(1); //JVM進程無法退出
    // ...(省略)
}

-----------
// org.apache.dubbo.registry.zookeeper.ZookeeperRegistry#ZookeeperRegistry

public ZookeeperRegistry(URL url, ZookeeperTransporter zookeeperTransporter) {
    super(url);
    new Thread(() -> {System.exit(1);}).start(); //JVM進程正常退出
    // ...(省略)
}

這就更令人驚奇了!

問題排查

要找出問題產生的原因,首先得有一些預備知識,否則會茫然無措,感覺無從下手

  1. java.lang.System#exit 方法是Java提供的能夠停止JVM進程的方法
  2. 該方法被觸發時,JVM會去調用Shutdown Hook(關閉勾子)方法,直到所有勾子方法執行完畢,纔會關閉JVM進程

由上述第2點猜測:是否存在死循環的勾子函數無法退出,以致JVM沒有去關閉進程?

舉個例子:

public static void main(String[] args) {
    Runtime.getRuntime().addShutdownHook(new Thread(() -> {
        while (true) {
            try {
                System.out.println("closing...");
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
            }
        }
    }));

    System.out.println("before exit...");
    System.exit(0); 
    System.out.println("after exit..."); //代碼不會執行
}

如上,在main方法裏先註冊了一個shutdown hook,該勾子函數是個死循環,永遠也不會退出,每3秒打印一次"closing…"

接着執行System.exit(0);方法,期望退出JVM進程

before exit...
closing...
closing...
closing...
closing...
closing...

...

結果是控制檯不斷打印"closing…",且JVM進程沒有退出

原因正是上述第二點儲備知識提到的:JVM會等待所有勾子執行完畢之後,才關閉進程。而示例中的shutdown hook 永遠也不會執行完畢,因此JVM進程也不會被關閉

儘管有了儲備知識,仍然很疑惑:如果存在死循環的shutdown hook,那麼System.exit無論是在主線程中調用,還是在異步線程中調用,都應該不會關閉JVM進程;反之,如果不存在死循環的shutdown hook,無論是在哪個線程調用,都應該關閉JVM進程。爲什麼在背景的僞代碼中,卻是因爲不同的調用線程執行System.exit,導致不一樣的結果呢?

這時候只好想辦法,看看shutdown hook們都在偷摸幹啥事,爲什麼未執行完畢,以致JVM進程不能退出

恰好對Dubbo的源碼也略有研究,很容易就找到org.apache.dubbo.registry.zookeeper.ZookeeperRegistry#ZookeeperRegistry的構造函數,並在其中加上一行代碼,如下所示,改完之後重新編譯源碼,並引入自己的工程中進行Debug

注:本次使用的Dubbo版本爲2.7.6

// org.apache.dubbo.registry.zookeeper.ZookeeperRegistry#ZookeeperRegistry
public ZookeeperRegistry(URL url, ZookeeperTransporter zookeeperTransporter) {
    super(url);
    System.exit(1); // 新增加的一行代碼
    // ...(省略)
}

啓動工程,熟悉Dubbo的朋友應該會知道,應用啓動的過程中會去註冊中心(這兒是Zookeeper)註冊或者訂閱,因爲啓動的是消費者,因此應用會嘗試連接註冊中心Zookeeper,會走到ZookeeperRegistry的構造函數,由於構造函數第二行是新增的代碼System.exit(1);,按照背景的說法,JVM不會退出,且會卡死,這時候,藉助IDEA的"快照"功能,可以"拍"下Java線程棧的運行情況,功能上相當於執行jstack命令

image

image

從線程棧中看出一個可疑的線程:DubboShutdownHook

從名字上可以看出是一個Dubbo註冊的一個shutdown hook,其主要目的是爲了關閉連接、做一些資源的回收等工作

從圖中也可以看出,線程阻塞在org.apache.dubbo.registry.support.AbstractRegistryFactory第83行

public static void destroyAll() {
    if (!destroyed.compareAndSet(false, true)) {
        return;
    }

    if (LOGGER.isInfoEnabled()) {
        LOGGER.info("Close all registries " + getRegistries());
    }
    // Lock up the registry shutdown process
    LOCK.lock(); // 83行,DubboShutdownHook線程阻塞在此處
    try {
        for (Registry registry : getRegistries()) {
            try {
                registry.destroy();
            } catch (Throwable e) {
                LOGGER.error(e.getMessage(), e);
            }
        }
        REGISTRIES.clear();
    } finally {
        // Release the lock
        LOCK.unlock();
    }
}

從代碼中很顯然可以看出,因爲獲取不到鎖,因此線程阻塞在第83行,等待獲取鎖,也就是說,有別的線程持着這把鎖,但還沒釋放,DubboShutdownHook不得不等待着

通過IDEA,查看有哪些地方獲取了這把鎖,如下,找到了org.apache.dubbo.registry.support.AbstractRegistryFactory#getRegistry(org.apache.dubbo.common.URL)會獲取鎖

// org.apache.dubbo.registry.support.AbstractRegistryFactory

public Registry getRegistry(URL url) {
    // ...(省略)
    LOCK.lock(); // 獲取鎖
    try {
        // ...(省略)
        // 創建Registry,由於我們選用的註冊中心是Zookeeper,因此通過SPI選擇了ZookeeperRegistryFactory對ZookeeperRegistry進行創建,最終會調用到我們添加過一行System.exit的ZookeeperRegistry構造函數中
        
        registry = createRegistry(url); 
        
        // ...(省略)
    } finally {
        // Release the lock
        LOCK.unlock(); // 創建完registry,與註冊中心連上之後,纔會釋放鎖
    }
}
// org.apache.dubbo.registry.zookeeper.ZookeeperRegistryFactory

public Registry createRegistry(URL url) {
	// 調用修改過源碼的ZookeeperRegistry構造函數
    return new ZookeeperRegistry(url, zookeeperTransporter);
}

如此,System.exit無法退出JVM進程的問題總算真相大白了:

  1. Dubbo啓動過程中會先獲取鎖,然後創建registry與註冊中心進行連接,在ZookeeperRegistry中調用了java.lang.System#exit方法,程序轉而執行"喚起shutdown hook"的代碼並阻塞等待所有勾子函數執行完畢,而此時,之前持有的鎖並沒有釋放
  2. 所有勾子函數(每個勾子函數都對應一個線程)被喚醒並執行,其中有一個Dubbo的勾子函數在執行的過程中,需要獲取步驟1中的鎖,由於獲取鎖失敗,就阻塞等待着
  3. 由於1沒有釋放鎖的情況下等待2執行完,而2的執行需要等待1釋放鎖,這樣就形成了一個類似"死鎖"的場景,因此也就導致了程序卡死,而JVM進程還存活的現象。之所以稱爲"類似"死鎖,是因爲1中執行System.exit的線程,也即持有鎖的線程,永遠不會走到釋放鎖的代碼:一旦程序進入System.exit的世界裏,就像進了一個單向蟲洞,只能進不能出,如果勾子函數執行完畢,JVM進程接着就會被關閉,不會有機會再釋放鎖

那麼,爲什麼在異步線程中執行System.exit,卻能夠正常退出JVM?

那是因爲:"喚起shutdown hook"並阻塞等待所有勾子函數執行完畢的線程是其它線程(此處假設是線程A),該線程在阻塞時並未持有任何鎖,而主線程會繼續往下執行並接着釋放鎖。一旦鎖釋放,Shutdown hook就有機會持有該鎖,並且執行其它資源的回收操作,等到所有的shutdown hook執行完畢,A線程就能從阻塞中返回並執行halt方法關閉JVM,因此能夠正常退出JVM進程

深入學習

以上是對java.lang.System#exit 無法退出程序問題的分析,來龍去脈已經闡述清楚,受益於對Dubbo源碼的瞭解以及正確的排查思路和排查手段,整個問題排查過程其實並沒有花太多時間,但可以趁着這個機會,把java.lang.System#exit系統學習一下,或許會對以後問題排查、基礎組件設計提供一些思路

System#exit
// java.lang.System

public static void exit(int status) {
    Runtime.getRuntime().exit(status);
}

Terminates the currently running Java Virtual Machine. The argument serves as a status code; by convention, a nonzero status code indicates abnormal termination.
This method calls the exit method in class Runtime. This method never returns normally.
The call System.exit(n) is effectively equivalent to the call:
Runtime.getRuntime().exit(n)

這個方法實現非常簡單,是Runtime#exit的一個簡便寫法,其作用是用來關閉JVM進程,一旦調用該方法,永遠也不會從該方法正常返回:執行完該方法後JVM進程就直接關閉了。入參status取值分兩類:0值與非0值,0值意味着正常關閉,非0值意味着異常關閉。傳入0值[有可能]會去執行所有的finalizer方法,非0值則一定不會執行(都不正常了,還執行啥finalizer呢?)。這兒提及[有可能]是因爲,默認並不會執行finalizers,需要調用java.lang.Runtime#runFinalizersOnExit方法開啓,而該方法早被JDK標識爲Deprecated,因此通常情況下是不會開啓的

// java.lang.Runtime

@Deprecated
public static void runFinalizersOnExit(boolean value) {
    SecurityManager security = System.getSecurityManager();
    if (security != null) {
        try {
            security.checkExit(0);
        } catch (SecurityException e) {
            throw new SecurityException("runFinalizersOnExit");
        }
    }
    Shutdown.setRunFinalizersOnExit(value);
}

接着看java.lang.Runtime#exit,可以看到,最終調用的是Shutdown.exit(status);,該方法是個包級別可見的方法,外部不可見

// java.lang.Runtime

public void exit(int status) {
    SecurityManager security = System.getSecurityManager();
    if (security != null) {
        security.checkExit(status);
    }
    Shutdown.exit(status);
}
// java.lang.Shutdown

static void exit(int status) {
    // ...(省略)
    synchronized (Shutdown.class) {
        /* Synchronize on the class object, causing any other thread
         * that attempts to initiate shutdown to stall indefinitely
         */
        // 執行shutdown序列
        sequence();
        // 關閉JVM
        halt(status);
    }
}
// java.lang.Shutdown

private static void sequence() {
    // ...(省略)
    runHooks();
    // ...(省略)
}
// java.lang.Shutdown

private static void runHooks() {
    for (int i=0; i < MAX_SYSTEM_HOOKS; i++) {
        try {
            Runnable hook;
            synchronized (lock) {
                // 這個鎖很重要,目的是通過Happens-Before保證內存的可見性
                currentRunningHook = i;
                hook = hooks[i];
            }
            if (hook != null) hook.run(); //執行勾子函數
        } catch(Throwable t) {
            if (t instanceof ThreadDeath) {
                ThreadDeath td = (ThreadDeath)t;
                throw td;
            }
        }
    }
}

java.lang.Shutdown#runHooks有兩個點需要注意,第一點MAX_SYSTEM_HOOKS(hooks)這個並不是我們註冊的shutdown hooks,而是按順序預定義的系統關閉勾子,目前JDK源碼(JDK8)預定義了三個:

  • Console restore hook

  • Application hooks

  • DeleteOnExit hook

其中,Application hooks纔是我們應用程序中主動註冊的shutdown hook。在java.lang.ApplicationShutdownHooks類初始化時,會執行static代碼塊,並在其中註冊了Application hooks

// java.lang.ApplicationShutdownHooks

class ApplicationShutdownHooks {
    /* The set of registered hooks */
    // 這個纔是我們應用程序代碼中註冊的shutdown hook
    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;
        }
    }

其次要注意的點是,給hook變量賦值的時候進行了加鎖

Runnable hook;
synchronized (lock) {
    currentRunningHook = i;
    hook = hooks[i];
}

一般而言,給局部變量賦值是不需要加鎖的,因爲局部變量是棧上變量,而線程棧之間數據是隔離的,不會出現線程安全的問題,因此不需要靠加鎖來保證數據併發訪問的安全性。而此處加鎖也並非爲了解決線程安全問題,其真正的目的在於,通過Happens-Before規則來保證hooks的內存可見性:An unlock on a monitor happens-before every subsequent lock on that monitor。如果不加鎖,有可能導致從hooks數組中讀取到的值並不是內存中最新的變量值,而是一箇舊值

上面是讀取hooks數組給hook變量賦值,爲了滿足HB(Happens-Before)原則,需要確保寫操作中同樣對hooks變量進行了加鎖,因此我們看一下寫hooks數組的地方,如下:

// java.lang.Shutdown

static void add(int slot, boolean registerShutdownInProgress, Runnable hook) {
    synchronized (lock) {
    		// ...(省略)
        hooks[slot] = hook;
    }
}

操作確實加了鎖,這樣才能讓接下來的操作的加鎖行爲滿足HB原則

由於篇幅原因,就不展開具體的HB介紹,相信瞭解過HB原則的朋友一下就能明白其中的原理

這個點個人感覺很有意思,因爲鎖的作用不單是爲了保證線程安全,還可以用來做爲內存通信、保證內存可見性的手段,因此可以當作面試的一個點,當下次面試官問到:你寫的代碼中用過鎖(synchronized)嗎?什麼場景用到鎖?都集羣部署了,單機鎖還有意義嗎? 我們就可以回答:爲了保證內存的可見性,balabalaba

所以你瞧,這個點其實也給我們設計基礎組件帶來很大的啓發,synchronized在當今集羣、分佈式環境下並非一無是處,總有合適的地方在等待着它發揮光和熱

注:JDK源碼中真處處是寶藏,很多地方隱藏着巧妙而不可缺少的設計

在給hook變量賦值之後,就執行 if (hook != null) hook.run();,其中會執行到Application hooks,即上面提到的在ApplicationShutdownHooks類初始化時註冊的勾子,勾子內部調用了java.lang.ApplicationShutdownHooks#runHooks方法

// java.lang.ApplicationShutdownHooks

Shutdown.add(1 /* shutdown hook invocation order */,
    false /* not registered if shutdown in progress */,
    new Runnable() {
        public void run() {
            runHooks();
        }
    }
);
// java.lang.ApplicationShutdownHooks

static void runHooks() {
    Collection<Thread> threads;
    synchronized(ApplicationShutdownHooks.class) {
        threads = hooks.keySet(); // hooks纔是應用程序真正註冊的shutdown hook
        hooks = null;
    }
		// 每一個shutdown hook都對應一個thread,由此可見是併發執行關閉勾子函數
    for (Thread hook : threads) {
        hook.start();
    }
    for (Thread hook : threads) {
        while (true) {
            try {
                hook.join(); // 死等到hook執行完畢
                break;
            } catch (InterruptedException ignored) {
                // 即便被喚醒都不搭理,接着進行下一輪循環,繼續死等
            }
        }
    }
}

上面的hooks纔是應用程序真正註冊的shutdown hook,由源碼可以看出,每一個hook都對應着一個thread,且調用了它們的start方法,即開啓thread,意味着shutdown hook是併發無序地執行

接着,喚起shutdown hook的線程,會通過死循環和join死等到所有關閉勾子都執行完畢,且忽略任何喚醒異常。也即是說,如果勾子們不執行完,喚醒線程是不會離開的

等所有的Application hooks執行完畢,接下來會執行DeleteOnExit hook(如果存在),等所有system hooks執行完畢,也基本意味着sequence方法執行完畢,接下來就執行halt方法關閉JVM虛擬機

synchronized (Shutdown.class) {
    sequence();
    halt(status);
}

這裏額外還有一個知識點,上文只是提了一嘴,可能會容易忽略,此處拿出來解釋一下:執行java.lang.System#exit永遠也不會從該方法正常返回,也即是說,即便System#exit後邊跟着的是finally,也不會執行 。一不注意就容易掉坑裏

try {
    // ...
    System.exit(0);
} finally {
    // 這裏的代碼永遠執行不到
}
java.lang.Runtime#addShutdownHook

聊完System#exit方法,接着來聊聊註冊shutdown hook的方法。該方法本身實現上很簡單,如下示:

// java.lang.Runtime
public void addShutdownHook(Thread hook) {
    // ...(省略)
    ApplicationShutdownHooks.add(hook);
}

// java.lang.ApplicationShutdownHooks
static synchronized void add(Thread hook) {
    // ...(省略)
    hooks.put(hook, hook);
}

需要注意的是,註冊的關閉勾子會在以下幾種時機被調用到

  • 程序正常退出

    • 最後一個非守護線程執行完畢退出時

    • System.exit方法被調用時

  • 程序響應外部事件

    • 程序響應用戶輸入事件,例如在控制檯按ctrl+c(^+c)

    • 程序響應系統事件,如用戶註銷、系統關機等

除此之外,shutdown hook是不會被執行的

Shutdown hook存在的意義之一,是能夠幫助我們實現優雅停機,而優雅停機的意義是:應用的重啓、停機等操作,不影響業務的連續性

以Dubbo Provider的視角爲例,優雅停機需要滿足兩點基本訴求:

  1. Consumer不應該請求到已經下線的Provider
  2. 在途請求需要處理完畢,不能被停機指令中斷

Dubbo註冊了Shutdown hook,JVM在收到操作系統發來的關閉指令時,會執行關閉勾子

  1. 在勾子中停止與註冊中心的連接,註冊中心會通知Consumer某個Provider已下線,後續不應該再調用該Provider進行服務。此行爲是斷掉上游流量,滿足第一點訴求

  2. 接着,勾子執行Protocol(Dubbo相關概念)的註銷邏輯,在其中判斷server(Dubbo相關概念)是否還在處理請求,在超時時間內等待所有任務處理完畢,則關閉server。此行爲是處理在途請求,滿足第二點述求

因此,一種優雅停機的整體方案如下:

$pid = ps | grep xxx // 查找要關閉的應用
kill $pid // 發出關閉應用指令
sleep for a period of time // 等待一段時間,讓應用程序執行shutdown hook進行現場的保留跟資源的清理工作

$pid = ps | grep xxx // 再次查找要關閉的應用,如果還存在,就需要強行關閉應用
if($pid){kill -9 $pid} // 等待一段時間之後,應用程序仍然沒有正常停止,則需要強行關閉應用

總結

本文從現實問題出發,尋找了System#exit無法退出應用程序的真相,這個過程中分享了一些排查問題思路跟手段;接着,對System#exit進行了整體深入的學習,閱讀了其核心部分的源碼,明白了關閉勾子其執行過程跟原理,其中還提到JDK巧妙的設計:通過鎖來觸發Happens-Before規則,達到了內存可見性的目的。再接着,分享了關閉勾子的註冊過程,瞭解了這些關閉勾子會被調用或觸發的時機。最後,闡述了關閉勾子的意義及重要性:優雅停機,並以Dubbo Provider進行舉例,分享了Dubbo優雅停機的原理,加深對關閉勾子的理解

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