執行緒崩潰為什麼不會導致 JVM 崩潰

2022-06-15 15:03:10

大家好,我是坤哥

網上看到一個很有意思的據說是美團的面試題:為什麼執行緒崩潰崩潰不會導致 JVM 崩潰,這個問題我看了不少回答,但都沒答到根本原因,所以決定答一答,相信大家看完肯定會有收穫,本文分以下幾節來探討

  1. 執行緒崩潰,程序一定會崩潰嗎
  2. 程序是如何崩潰的-訊號機制簡介
  3. 為什麼在 JVM 中執行緒崩潰不會導致 JVM 程序崩潰
  4. openJDK 原始碼解析

執行緒崩潰,程序一定會崩潰嗎

一般來說如果執行緒是因為非法存取記憶體引起的崩潰,那麼程序肯定會崩潰,為什麼系統要讓程序崩潰呢,這主要是因為在程序中,各個執行緒的地址空間是共用的,既然是共用,那麼某個執行緒對地址的非法存取就會導致記憶體的不確定性,進而可能會影響到其他執行緒,這種操作是危險的,作業系統會認為這很可能導致一系列嚴重的後果,於是乾脆讓整個程序崩潰

執行緒共享程式碼段,資料段,地址空間,檔案
執行緒共用程式碼段,資料段,地址空間,檔案

非法存取記憶體有以下幾種情況,我們以 C 語言舉例來看看

  1. 針對唯讀記憶體寫入資料

    #include <stdio.h>
    #include <stdlib.h>

    int main() {
       char *s = "hello world";
     s[1] = 'H'// 向唯讀記憶體寫入資料,崩潰
    }
  2. 存取了不屬於程序地址空間的記憶體

    #include <stdio.h>
    #include <stdlib.h>

    int main() {
       int *p = (int *)0xC0000fff;
     *p = 10// 針對不屬於程序的核心空間寫入資料,崩潰
    }

    在 32 位虛擬地址空間中,p 指向的是核心空間,顯然不具有寫入許可權,所以上述賦值操作會導致崩潰

  3. 存取了不存在的記憶體,比如

    #include <stdio.h>
    #include <stdlib.h>

    int main() {
       int *a = NULL;
       *a = 1;     // 存取了不存在的記憶體
    }

以上錯誤都是存取記憶體時的錯誤,所以統一會報 Segment Fault 錯誤(即段錯誤),這些都會導致程序崩潰

程序是如何崩潰的-訊號機制簡介

那麼執行緒崩潰後,程序是如何崩潰的呢,這背後的機制到底是怎樣的,答案是訊號,大家想想要幹掉一個正在執行的程序是不是經常用 kill -9 pid 這樣的命令,這裡的 kill 其實就是給指定 pid 傳送終止訊號的意思,其中的 9 就是訊號,其實訊號有很多型別的,在 Linux 中可以通過 kill -l檢視所有可用的訊號

當然了發 kill 訊號必須具有一定的許可權,否則任意程序都可以通過發訊號來終止其他程序,那顯然是不合理的,實際上 kill 執行的是系統呼叫,將控制權轉移給了核心(作業系統),由核心來給指定的程序傳送訊號

那麼發個訊號程序怎麼就崩潰了呢,這背後的原理到底是怎樣的?

其背後的機制如下

  1. CPU 執行正常的程序指令
  2. 呼叫 kill 系統呼叫向程序傳送訊號
  3. 程序收到作業系統發的訊號,CPU 暫停當前程式執行,並將控制權轉交給作業系統
  4. 呼叫 kill 系統呼叫向程序傳送訊號(假設為 11,即 SIGSEGV,一般非法存取記憶體報的都是這個錯誤)
  5. 作業系統根據情況執行相應的訊號處理程式(函數),一般執行完訊號處理程式邏輯後會讓程序退出

注意上面的第五步,如果程序沒有註冊自己的訊號處理常式,那麼作業系統會執行預設的訊號處理程式(一般最後會讓程序退出),但如果註冊了,則會執行自己的訊號處理常式,這樣的話就給了程序一個垂死掙扎的機會,它收到 kill 訊號後,可以呼叫 exit() 來退出,但也可以使用 sigsetjmp,siglongjmp 這兩個函數來恢復程序的執行

// 自定義訊號處理常式範例

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
// 自定義訊號處理常式,處理自定義邏輯後再呼叫 exit 退出
void sigHandler(int sig) {
  printf("Signal %d catched!\n", sig);
  exit(sig);
}
int main(void) {
  signal(SIGSEGV, sigHandler);
  int *p = (int *)0xC0000fff;
  *p = 10// 針對不屬於程序的核心空間寫入資料,崩潰
}

// 以上結果輸出: Signal 11 catched!

如程式碼所示:註冊訊號處理常式後,當收到 SIGSEGV 訊號後,先執行相關的邏輯再退出

另外當程序接收訊號之後也可以不定義自己的訊號處理常式,而是選擇忽略訊號,如下

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>

int main(void) {
  // 忽略訊號
  signal(SIGSEGV, SIG_IGN);

  // 產生一個 SIGSEGV 訊號
  raise(SIGSEGV);

  printf("正常結束");
}

也就是說雖然給程序傳送了 kill 訊號,但如果程序自己定義了訊號處理常式或者無視訊號就有機會逃出生天,當然了 kill -9 命令例外,不管程序是否定義了訊號處理常式,都會馬上被幹掉

說到這大家是否想起了一道經典面試題:如何讓正在執行的 Java 工程的優雅停機,通過上面的介紹大家不難發現,其實是 JVM 自己定義了訊號處理常式,這樣當傳送 kill pid 命令(預設會傳 15 也就是 SIGTERM)後,JVM 就可以在訊號處理常式中執行一些資源清理之後再呼叫 exit 退出。這種場景顯然不能用 kill -9,不然一下把程序幹掉了資源就來不及清楚了

為什麼執行緒崩潰不會導致 JVM 程序崩潰

現在我們再來看看開頭這個問題,相信你多少會心中有數,想想看在 Java 中有哪些是覺見的由於非法存取記憶體而產生的 Exception 或 error 呢,常見的是大家熟悉的 StackoverflowError 或者 NPE(NullPointerException),NPE 我們都瞭解,屬於是存取了不存在的記憶體

但為什麼棧溢位(Stackoverflow)也屬於非法存取記憶體呢,這得簡單聊一下程序的虛擬空間,也就是前面提到的共用地址空間

現代作業系統為了保護程序之間不受影響,所以使用了虛擬地址空間來隔離程序,程序的定址都是針對虛擬地址,每個程序的虛擬空間都是一樣的,而執行緒會共用程序的地址空間,以 32 位虛擬空間,程序的虛擬空間分佈如下

那麼 stackoverflow 是怎麼發生的呢,程序每呼叫一個函數,都會分配一個棧楨,然後在棧楨裡會分配函數裡定義的各種區域性變數,假設現在呼叫了一個無限遞迴的函數,那就會持續分配棧幀,但 stack 的大小是有限的(Linux 中預設為 8 M,可以通過 ulimit -a 檢視),如果無限遞迴顯然很快棧就會分配完了,此時再呼叫函數試圖分配超出棧的大小記憶體,就會發生段錯誤,也就是 stackoverflowError

好了,現在我們知道了 StackoverflowError 怎麼產生的,那問題來了,既然 StackoverflowError 或者 NPE 都屬於非法存取記憶體, JVM 為什麼不會崩潰呢,有了上一節的鋪墊,相信你不難回答,其實就是因為 JVM 自定義了自己的訊號處理常式,攔截了 SIGSEGV 訊號,針對這兩者不讓它們崩潰,怎麼證明這個推測呢,我們來看下 JVM 的原始碼來一探究竟

openJDK 原始碼解析

HotSpot 虛擬機器器目前使用範圍最廣的 Java 虛擬機器器,據 R 大所述, Oracle JDK 與 OpenJDK 裡的 JVM 都是 HotSpot VM,從原始碼層面說,兩者基本上是同一個東西,OpenJDK 是開源的,所以我們主要研究下 Java 8 的 OpenJDK 即可,地址如下:https://github.com/AdoptOpenJDK/openjdk-jdk8u,有興趣的可以下載來看看

我們就是研究 Linux 下的 JVM,為了便於說明,也方便大家查閱,我把其中關於訊號處理的關鍵流程整理了下(忽略其中的次要程式碼)

可以看到,在啟動 JVM 的時候,也設定了訊號處理常式,收到 SIGSEGV,SIGPIPE 等訊號後最終會呼叫 JVM_handle_linux_signal 這個自定義訊號處理常式,再來看下這個函數的主要邏輯

JVM_handle_linux_signal(int sig,
                        siginfo_t* info,
                        void* ucVoid,
                        int abort_if_unrecognized) {

   // Must do this before SignalHandlerMark, if crash protection installed we will longjmp away
  // 這段程式碼裡會呼叫 siglongjmp,主要做執行緒恢復之用
  os::ThreadCrashProtection::check_crash_protection(sig, t);

  if (info != NULL && uc != NULL && thread != NULL) {
    pc = (address) os::Linux::ucontext_get_pc(uc);

    // Handle ALL stack overflow variations here
    if (sig == SIGSEGV) {
      // Si_addr may not be valid due to a bug in the linux-ppc64 kernel (see
      // comment below). Use get_stack_bang_address instead of si_addr.
      address addr = ((NativeInstruction*)pc)->get_stack_bang_address(uc);

      // 判斷是否棧溢位了
      if (addr < thread->stack_base() &&
          addr >= thread->stack_base() - thread->stack_size()) {
        if (thread->thread_state() == _thread_in_Java) {
            stub = SharedRuntime::continuation_for_implicit_exception(thread, pc, SharedRuntime::STACK_OVERFLOW);
        }
      }
    }
  }

  if (sig == SIGSEGV &&
               !MacroAssembler::needs_explicit_null_check((intptr_t)info->si_addr)) {
      // 此處會做空指標檢查
      stub = SharedRuntime::continuation_for_implicit_exception(thread, pc, SharedRuntime::IMPLICIT_NULL);
  }


  // 如果是棧溢位或者空指標最終會返回 true,不會走最後的 report_and_die,所以 JVM 不會退出
  if (stub != NULL) {
    // save all thread context in case we need to restore it
    if (thread != NULL) thread->set_saved_exception_pc(pc);

    uc->uc_mcontext.gregs[REG_PC] = (greg_t)stub;
    // 返回 true 代表 JVM 程序不會退出
    return true;
  }

  VMError err(t, sig, pc, info, ucVoid);
  // 生成 hs_err_pid_xxx.log 檔案並退出
  err.report_and_die();

  ShouldNotReachHere();
  return true// Mute compiler

}

從以上程式碼我們可以知道以下資訊

  1. 發生 stackoverflow 還有空指標錯誤,確實都傳送了 SIGSEGV,只是虛擬機器器不選擇退出,而是自己內部作了額外的處理,其實是恢復了執行緒的程序,並丟擲 StackoverflowError 和 NPE,這就是為什麼 JVM 不會崩潰且我們能捕獲這兩個錯誤/異常的原因

  2. 如果針對 SIGSEGV 等訊號,在以上的函數中 JVM 沒有做額外的處理,那麼最終會走到 report_and_die 這個方法,這個方法主要做的事情是生成 hs_err_pid_xxx.log crash 檔案(記錄了一些堆疊資訊或錯誤),然後退出

自此我相信大家已經明白了為什麼發生了 StackoverflowError 和 NPE 這兩個非法存取記憶體的錯誤,JVM 卻沒有崩潰的原因,原因其實就是虛擬機器器內部定義了訊號處理常式,而在訊號處理常式中對這兩者做了額外的處理以讓 JVM 不崩潰,另一方面也可以看出如果 JVM 不對訊號做額外的處理,最後會自己退出併產生 crash 檔案 hs_err_pid_xxx.log(可以通過 -XX:ErrorFile=/var/log/hs_err.log 這樣的方式指定),這個檔案記錄了虛擬機器器崩潰的重要原因,所以也可以說,虛擬機器器是否崩潰只要看它是否會產生此崩潰紀錄檔檔案

總結

正常情況下,作業系統為了保證系統安全,所以針對非法記憶體存取會傳送一個 SIGSEGV 訊號,而作業系統一般會呼叫預設的訊號處理常式(一般會讓相關的程序崩潰),但如果程序覺得"罪不致死",那麼它也可以選擇自定義一個訊號處理常式,這樣的話它就可以做一些自定義的邏輯,比如記錄 crash 資訊等有意義的事,回過頭來看為什麼虛擬機器器會針對 StackoverflowError 和 NullPointerException 做額外處理讓執行緒恢復呢,針對 stackoverflow 其實它採用了一種棧回溯的方法保證執行緒可以一直執行下去,而捕獲空指標錯誤主要是這個錯誤實在太普遍了,為了這一個很常見的錯誤而讓 JVM 崩潰那線上的 JVM 要宕機多少次,所以與其這次倒不如讓執行緒起死回生,並且將這兩個錯誤/異常拋給使用者來處理

巨人的肩膀

Segmentation Fault in Linux: https://www.cnblogs.com/kaixin/archive/2010/06/07/1753133.html

linux SIGSEGV 訊號捕捉,保證發生段錯誤後程式不崩潰: https://blog.csdn.net/work_msh/article/details/8470277

更多精品文章,歡迎大家掃碼關注「碼海」