最近咱們在測試把 APM 平臺遷移到 ES APM,有同窗反饋了一個有意思的現象,部署在 docker 中 jar 包項目,在新版 APM 裏進程啓動完就退出了,被 k8s 中無限重啓。html
這篇文章寫了一下排查的思路,主要包含了下面這些內容。java
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
關於這個問題,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
翻譯過來也就是致使 JVM 的退出只有下面這 2 種狀況:app
第二種狀況固然不符合咱們的狀況,那嫌疑就放在了第一個上面,也就是換了新版本的 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 進程何時會退出你能夠比較完整的打出來,那就很棒了。