一次 JVM 進程退出的緣由分析

最近咱們在測試把 APM 平臺遷移到 ES APM,有同窗反饋了一個有意思的現象,部署在 docker 中 jar 包項目,在新版 APM 裏進程啓動完就退出了,被 k8s 中無限重啓。html

這篇文章寫了一下排查的思路,主要包含了下面這些內容。java

  • 一個 JVM 進程何時會退出
  • 守護線程、非守護線程
  • 從源碼角度看 JVM 退出的過程

APM 底層就是使用一個 javaagent 的 jar 包來作字節碼改寫,那爲何二者會有這麼大的差別呢?我一開始想固然認爲是新版的 ES APM 代碼有毒,致使服務起不來。後面我在本地非 docker 環境中跑了一次,發現沒有任何問題,暫時排除了新版 APM 的問題。接下來看看代碼是怎麼寫的。linux

@SpringBootApplication(scanBasePackages = ["com.masaike.**"])
open class MyRpcServerApplication

fun main(args: Array<String>) {
    runApplication<MyRpcServerApplication>(*args)
    logger.info("#### ClassRpcServerApplication start success")
    System.`in`.read()
}
複製代碼

在以前的文章《關於 /dev/null 差點直播吃鞋的一個小問題》中,咱們分析過容器中的 stdin 指向 /dev/null/dev/null 是一個特殊的設備文件,全部接收到的數據都會被丟棄。有人把 /dev/null 比喻爲 「黑洞」,從 /dev/null 讀數據會當即返回 EOF, System.in.read() 調用會直接退出。這篇文章的連接在這裏:mp.weixin.qq.com/s/lYajWCb-o…docker

因此執行 main 函數之後,main 線程就退出了,新舊 APM 都同樣。接下來就是要弄清楚一個常見的問題:一個 JVM 進程何時會退出。bash

JVM 進程何時會退出

關於這個問題,Java 語言規範《12.8. Program Exit》小節裏有寫,連接在這裏:docs.oracle.com/javase/spec… ,我把內容貼在了下面。併發

A program terminates all its activity and exits when one of two things happens:oracle

  • All the threads that are not daemon threads terminate.
  • Some thread invokes the exit method of class Runtime or class System, and the exit operation is not forbidden by the security manager.

翻譯過來也就是致使 JVM 的退出只有下面這 2 種狀況:app

  • 全部的非 daemon 進程退出
  • 某個線程調用了 System.exit( ) 或 Runtime.exit() 顯式退出進程

第二種狀況固然不符合咱們的狀況,那嫌疑就放在了第一個上面,也就是換了新版本的 APM 之後,沒有非守護進程在運行,因此 main 線程一退出,整個 JVM 進程就退出了。ide

接下來咱們來驗證這個想法,方法就是使用 jstack,爲了避免讓接入了新版 APM 的 JVM 退出,先手動加上一個長 sleep。從新打包編譯運行鏡像,使用 jstack dump 出線程堆棧,能夠直接閱讀,或者使用「你假笨大神 PerfMa」公司的線程分析 XSheepdog 工具來分析。函數

能夠看到,新版 APM 裏,只有一個阻塞在 sleep 上的 main 線程是非守護線程,若是這個線程也退出了,那就是全部的非守護線程都退出了。這裏的 main 沒有退出仍是後來加了 sleep 致使的。

接下來對比一下舊版 APM,XSheepdog 分析結果以下所示。

能夠看到舊版 APM 裏有 5 個非守護線程,其中 4 個非守護線程正是舊版 APM 內部的常駐線程。

到這裏,緣由就比較清楚了,在 docker 環境中 System.in.read() 調用不會阻塞,會當即退出,main 線程會結束。在舊版裏,由於有常駐的非守護的 APM 處理線程在運行,全部整個 JVM 進程不會退出。在新版裏,由於沒有這些常駐的非守護線程,main 線程退出之後,就不存在非守護線程了,整個 JVM 就退出了。

源碼分析

接下的源碼分析如下面這段 Java 代碼爲例,

public class MyMain {
    public static void main(String[] args) {
        System.out.println("in main");
    }
}
複製代碼

接下來咱們來調試源碼看看,JVM 運行之後會進入 java.c 的 JavaMain 方法,

int JNICALL JavaMain(void * _args) {
    // ...
    /* Initialize the virtual machine */
    InitializeJVM();

    // 獲取 public static void main(String[] args) 方法
    mainID = (*env)->GetStaticMethodID(env, mainClass, "main",
                                       "([Ljava/lang/String;)V");
    // 調用 main 方法
    (*env)->CallStaticVoidMethod(env, mainClass, mainID, mainArgs);
    // main 方法結束之後接下來調用下面的代碼
    LEAVE();
}
複製代碼

JavaMain 方法內部就是作了 JVM 的初始化,而後使用 JNI 調用了入口類的 public static void main(String[] args) 方法,若是 main 方法退出,則會調用後面的 LEAVE 方法。

#define LEAVE() \
    do { \
        if ((*vm)->DetachCurrentThread(vm) != JNI_OK) { \
            JLI_ReportErrorMessage(JVM_ERROR2); \
            ret = 1; \
        } \
        if (JNI_TRUE) { \
            (*vm)->DestroyJavaVM(vm); \
            return ret; \
        } \
    } while (JNI_FALSE)
複製代碼

LEAVE 方法調用了 DestroyJavaVM(vm); 來觸發 JVM 退出,這個退出固然是有條件的。destroy_vm 的源碼以下所示。

能夠看到,JVM 會一直等待 main 線程成爲最後一個要退出的非守護線程,不然也沒有退出的必要。這使用了一個 while 循環等待條件的發生。若是本身是最後一個,就能夠準備整個 JVM 的退出了。

也能夠把代碼稍做修改,新建一個常駐的非守護線程 t,隔 3s 輪詢 /tmp/test.txt 文件是否存在。main 線程在 JVM 啓動後立刻就退出了。

public class MyMain {
    public static void main(String[] args) {
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                File file = new File("/tmp/test.txt");
                while(true) {
                    if (file.exists()) {
                        break;
                    }
                    System.out.println("not exists");
                    try {
                        TimeUnit.SECONDS.sleep(3);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }

            }
        });
        t.setDaemon(false);
        t.start();
        System.out.println("in main");
    }
}
複製代碼

這個例子中,main 函數退出時,會進入 destroy_vm 方法,在這個方法中,會 while 循環等待本身是最後一個非守護線程。若是非守護線程的數量大於 1,則一直阻塞等待,JVM 不會退出,以下所示。

另外值得注意的是,java 的 daemon 線程概念是本身設計的,在 linux 原生線程中,並無對應的特性。

小結

爲了保證程序常駐運行,Java 中可使用 CountDownLatch 等併發的類,等待不可能發生的條件。在 Go 中可使用一個 channel 等待數據寫入,但永遠不會有數據寫入便可。不要依賴外部的 IO 事件,好比本例中的讀取 stdin 等。

若是看完這篇文章,下次有人問起,Java 進程何時會退出你能夠比較完整的打出來,那就很棒了。

相關文章
相關標籤/搜索