美團(tuán)一面:為什么線程崩潰崩潰不會(huì)導(dǎo)致 JVM 崩潰

網(wǎng)上看到一個(gè)很有意思的美團(tuán)面試題:為什么線程崩潰崩潰不會(huì)導(dǎo)致 JVM 崩潰本冲,這個(gè)問(wèn)題我看了不少回答讶隐,但發(fā)現(xiàn)都沒(méi)答到根上鞍帝,所以決定答一答,相信大家看完肯定會(huì)有收獲舷暮,本文分以下幾節(jié)來(lái)探討

  • 線程崩潰态罪,進(jìn)程一定會(huì)崩潰嗎?
  • 進(jìn)程是如何崩潰的-信號(hào)機(jī)制簡(jiǎn)介下面。
  • 為什么在 JVM 中線程崩潰不會(huì)導(dǎo)致 JVM 進(jìn)程崩潰复颈。
  • openJDK 源碼解析。

線程崩潰沥割,進(jìn)程一定會(huì)崩潰嗎

一般來(lái)說(shuō)如果線程是因?yàn)榉欠ㄔL問(wèn)內(nèi)存引起的崩潰耗啦,那么進(jìn)程肯定會(huì)崩潰,為什么系統(tǒng)要讓進(jìn)程崩潰呢驯遇,這主要是因?yàn)樵谶M(jìn)程中芹彬,各個(gè)線程的地址空間是共享的,既然是共享叉庐,那么某個(gè)線程對(duì)地址的非法訪問(wèn)就會(huì)導(dǎo)致內(nèi)存的不確定性舒帮,進(jìn)而可能會(huì)影響到其他線程,這種操作是危險(xiǎn)的陡叠,操作系統(tǒng)會(huì)認(rèn)為這很可能導(dǎo)致一系列嚴(yán)重的后果玩郊,于是干脆讓整個(gè)進(jìn)程崩潰。


image.png

線程共享代碼段枉阵,數(shù)據(jù)段译红,地址空間,文件

非法訪問(wèn)內(nèi)存有以下幾種情況兴溜,我們以 C 語(yǔ)言舉例來(lái)看看:

針對(duì)只讀內(nèi)存寫入數(shù)據(jù)侦厚。

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

int main() {
   char *s = "hello world";
// 向只讀內(nèi)存寫入數(shù)據(jù),崩潰
   s[1] = 'H'; 
}

訪問(wèn)了進(jìn)程沒(méi)有權(quán)限訪問(wèn)的地址空間(比如內(nèi)核空間)拙徽。

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

int main() {
   int *p = (int *)0xC0000fff;
   // 針對(duì)進(jìn)程的內(nèi)核空間寫入數(shù)據(jù)刨沦,崩潰
   *p = 10; 
}

在 32 位虛擬地址空間中,p 指向的是內(nèi)核空間膘怕,顯然不具有寫入權(quán)限想诅,所以上述賦值操作會(huì)導(dǎo)致崩潰。

訪問(wèn)了不存在的內(nèi)存,比如:

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

int main() {
   int *a = NULL;
   *a = 1;     
}

以上錯(cuò)誤都是訪問(wèn)內(nèi)存時(shí)的錯(cuò)誤来破,所以統(tǒng)一會(huì)報(bào) Segment Fault 錯(cuò)誤(即段錯(cuò)誤)篮灼,這些都會(huì)導(dǎo)致進(jìn)程崩潰。

進(jìn)程是如何崩潰的-信號(hào)機(jī)制簡(jiǎn)介

那么線程崩潰后徘禁,進(jìn)程是如何崩潰的呢诅诱,這背后的機(jī)制到底是怎樣的,答案是信號(hào)晌坤,大家想想要干掉一個(gè)正在運(yùn)行的進(jìn)程是不是經(jīng)常用 kill -9 pid 這樣的命令逢艘,這里的 kill 其實(shí)就是給指定 pid 發(fā)送終止信號(hào)的意思,其中的 9 就是信號(hào)骤菠,其實(shí)信號(hào)有很多類型的它改,在 Linux 中可以通過(guò) kill -l查看所有可用的信號(hào)。

image.png

當(dāng)然了發(fā) kill 信號(hào)必須具有一定的權(quán)限商乎,否則任意進(jìn)程都可以通過(guò)發(fā)信號(hào)來(lái)終止其他進(jìn)程央拖,那顯然是不合理的,實(shí)際上 kill 執(zhí)行的是系統(tǒng)調(diào)用鹉戚,將控制權(quán)轉(zhuǎn)移給了內(nèi)核(操作系統(tǒng))鲜戒,由內(nèi)核來(lái)給指定的進(jìn)程發(fā)送信號(hào)。

那么發(fā)個(gè)信號(hào)進(jìn)程怎么就崩潰了呢抹凳,這背后的原理到底是怎樣的遏餐?

其背后的機(jī)制如下:

  • CPU 執(zhí)行正常的進(jìn)程指令。
  • 調(diào)用 kill 系統(tǒng)調(diào)用向進(jìn)程發(fā)送信號(hào)赢底。
  • 進(jìn)程收到操作系統(tǒng)發(fā)的信號(hào)失都,CPU 暫停當(dāng)前程序運(yùn)行,并將控制權(quán)轉(zhuǎn)交給操作系統(tǒng)幸冻。
  • 調(diào)用 kill 系統(tǒng)調(diào)用向進(jìn)程發(fā)送信號(hào)(假設(shè)為 11粹庞,即 SIGSEGV,一般非法訪問(wèn)內(nèi)存報(bào)的都是這個(gè)錯(cuò)誤)洽损。
  • 操作系統(tǒng)根據(jù)情況執(zhí)行相應(yīng)的信號(hào)處理程序(函數(shù))庞溜,一般執(zhí)行完信號(hào)處理程序邏輯后會(huì)讓進(jìn)程退出。

注意上面的第五步碑定,如果進(jìn)程沒(méi)有注冊(cè)自己的信號(hào)處理函數(shù)流码,那么操作系統(tǒng)會(huì)執(zhí)行默認(rèn)的信號(hào)處理程序(一般最后會(huì)讓進(jìn)程退出),但如果注冊(cè)了延刘,則會(huì)執(zhí)行自己的信號(hào)處理函數(shù)旅掂,這樣的話就給了進(jìn)程一個(gè)垂死掙扎的機(jī)會(huì),它收到 kill 信號(hào)后访娶,可以調(diào)用 exit() 來(lái)退出,但也可以使用 sigsetjmp觉阅,siglongjmp 這兩個(gè)函數(shù)來(lái)恢復(fù)進(jìn)程的執(zhí)行崖疤。

// 自定義信號(hào)處理函數(shù)示例

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
// 自定義信號(hào)處理函數(shù)秘车,處理自定義邏輯后再調(diào)用 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; // 針對(duì)不屬于進(jìn)程的內(nèi)核空間寫入數(shù)據(jù),崩潰
}

// 以上結(jié)果輸出: Signal 11 catched!

// 以上結(jié)果輸出: Signal 11 catched!</pre>

如代碼所示:注冊(cè)信號(hào)處理函數(shù)后劫哼,當(dāng)收到 SIGSEGV 信號(hào)后叮趴,先執(zhí)行相關(guān)的邏輯再退出。

另外當(dāng)進(jìn)程接收信號(hào)之后也可以不定義自己的信號(hào)處理函數(shù)权烧,而是選擇忽略信號(hào)眯亦,如下:

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

int main(void) {
  // 忽略信號(hào)
  signal(SIGSEGV, SIG_IGN);

  // 產(chǎn)生一個(gè) SIGSEGV 信號(hào)
  raise(SIGSEGV);

  printf("正常結(jié)束");
}

也就是說(shuō)雖然給進(jìn)程發(fā)送了 kill 信號(hào),但如果進(jìn)程自己定義了信號(hào)處理函數(shù)或者無(wú)視信號(hào)就有機(jī)會(huì)逃出生天般码,當(dāng)然了 kill -9 命令例外妻率,不管進(jìn)程是否定義了信號(hào)處理函數(shù),都會(huì)馬上被干掉

說(shuō)到這大家是否想起了一道經(jīng)典面試題:如何讓正在運(yùn)行的 Java 工程的優(yōu)雅停機(jī)板祝,通過(guò)上面的介紹大家不難發(fā)現(xiàn)宫静,其實(shí)是 JVM 自己定義了信號(hào)處理函數(shù),這樣當(dāng)發(fā)送 kill pid 命令(默認(rèn)會(huì)傳 15 也就是 SIGTERM)后券时,JVM 就可以在信號(hào)處理函數(shù)中執(zhí)行一些資源清理之后再調(diào)用 exit 退出孤里。這種場(chǎng)景顯然不能用 kill -9,不然一下把進(jìn)程干掉了資源就來(lái)不及清除了

為什么線程崩潰不會(huì)導(dǎo)致 JVM 進(jìn)程崩潰

現(xiàn)在我們?cè)賮?lái)看看開(kāi)頭這個(gè)問(wèn)題橘洞,相信你多少會(huì)心中有數(shù)捌袜,想想看在 Java 中有哪些是常見(jiàn)的由于非法訪問(wèn)內(nèi)存而產(chǎn)生的 Exception 或 error 呢,常見(jiàn)的是大家熟悉的 StackoverflowError 或者 NPE(NullPointerException),NPE 我們都了解炸枣,屬于是訪問(wèn)了不存在的內(nèi)存虏等。

但為什么棧溢出(Stackoverflow)也屬于非法訪問(wèn)內(nèi)存呢,這得簡(jiǎn)單聊一下進(jìn)程的虛擬空間抛虏,也就是前面提到的共享地址空間博其。

現(xiàn)代操作系統(tǒng)為了保護(hù)進(jìn)程之間不受影響,所以使用了虛擬地址空間來(lái)隔離進(jìn)程迂猴,進(jìn)程的尋址都是針對(duì)虛擬地址慕淡,每個(gè)進(jìn)程的虛擬空間都是一樣的,而線程會(huì)共用進(jìn)程的地址空間沸毁,以 32 位虛擬空間峰髓,進(jìn)程的虛擬空間分布如下:

image.png

那么 stackoverflow 是怎么發(fā)生的呢,進(jìn)程每調(diào)用一個(gè)函數(shù)息尺,都會(huì)分配一個(gè)棧楨携兵,然后在棧楨里會(huì)分配函數(shù)里定義的各種局部變量,假設(shè)現(xiàn)在調(diào)用了一個(gè)無(wú)限遞歸的函數(shù)搂誉,那就會(huì)持續(xù)分配棧幀徐紧,但 stack 的大小是有限的(Linux 中默認(rèn)為 8 M,可以通過(guò) ulimit -a 查看),如果無(wú)限遞歸很快棧就會(huì)分配完了并级,此時(shí)再調(diào)用函數(shù)試圖分配超出棧的大小內(nèi)存拂檩,就會(huì)發(fā)生段錯(cuò)誤,也就是 stackoverflowError嘲碧。

image.png

好了稻励,現(xiàn)在我們知道了 StackoverflowError 怎么產(chǎn)生的,那問(wèn)題來(lái)了愈涩,既然 StackoverflowError 或者 NPE 都屬于非法訪問(wèn)內(nèi)存望抽, JVM 為什么不會(huì)崩潰呢,有了上一節(jié)的鋪墊履婉,相信你不難回答煤篙,其實(shí)就是因?yàn)?JVM 自定義了自己的信號(hào)處理函數(shù),攔截了 SIGSEGV 信號(hào)谐鼎,針對(duì)這兩者不讓它們崩潰舰蟆,怎么證明這個(gè)推測(cè)呢,我們來(lái)看下 JVM 的源碼來(lái)一探究竟狸棍。

openJDK 源碼解析

HotSpot 虛擬機(jī)目前使用范圍最廣的 Java 虛擬機(jī)身害,據(jù) R 大所述, Oracle JDK 與 OpenJDK 里的 JVM 都是 HotSpot VM草戈,從源碼層面說(shuō)塌鸯,兩者基本上是同一個(gè)東西,OpenJDK 是開(kāi)源的唐片,所以我們主要研究下 Java 8 的 OpenJDK 即可丙猬,地址如下:https://github.com/AdoptOpenJDK/openjdk-jdk8u,有興趣的可以下載來(lái)看看费韭。

我們只要研究 Linux 下的 JVM茧球,為了便于說(shuō)明,也方便大家查閱星持,我把其中關(guān)于信號(hào)處理的關(guān)鍵流程整理了下(忽略其中的次要代碼)抢埋。


image.png

可以看到,在啟動(dòng) JVM 的時(shí)候督暂,也設(shè)置了信號(hào)處理函數(shù)揪垄,收到 SIGSEGV,SIGPIPE 等信號(hào)后最終會(huì)調(diào)用 JVM_handle_linux_signal 這個(gè)自定義信號(hào)處理函數(shù)逻翁,再來(lái)看下這個(gè)函數(shù)的主要邏輯饥努。

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
  // 這段代碼里會(huì)調(diào)用 siglongjmp,主要做線程恢復(fù)之用
  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) {
// 針對(duì)棧溢出 JVM 的內(nèi)部處理
            stub = SharedRuntime::continuation_for_implicit_exception(thread, pc, SharedRuntime::STACK_OVERFLOW);
        }
      }
    }
  }

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


  // 如果是棧溢出或者空指針最終會(huì)返回 true八回,不會(huì)走最后的 report_and_die酷愧,所以 JVM 不會(huì)退出
  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 進(jìn)程不會(huì)退出
    return true;
  }

  VMError err(t, sig, pc, info, ucVoid);
  // 生成 hs_err_pid_xxx.log 文件并退出
  err.report_and_die();

  ShouldNotReachHere();
  return true; // Mute compiler

}

從以上代碼(注意看加粗的紅線字體部分)我們可以知道以下信息

  • 發(fā)生 stackoverflow 還有空指針錯(cuò)誤驾诈,確實(shí)都發(fā)送了 SIGSEGV,只是虛擬機(jī)不選擇退出伟墙,而是自己內(nèi)部作了額外的處理翘鸭,其實(shí)是恢復(fù)了線程的進(jìn)程,并拋出 StackoverflowError 和 NPE戳葵,這就是為什么 JVM 不會(huì)崩潰且我們能捕獲這兩個(gè)錯(cuò)誤/異常的原因。
  • 如果針對(duì) SIGSEGV 等信號(hào)汉匙,在以上的函數(shù)中 JVM 沒(méi)有做額外的處理拱烁,那么最終會(huì)走到 report_and_die 這個(gè)方法,這個(gè)方法主要做的事情是生成 hs_err_pid_xxx.log crash 文件(記錄了一些堆棧信息或錯(cuò)誤)噩翠,然后退出戏自。

至此我相信大家明白了為什么發(fā)生了 StackoverflowError 和 NPE 這兩個(gè)非法訪問(wèn)內(nèi)存的錯(cuò)誤,JVM 卻沒(méi)有崩潰伤锚。原因其實(shí)就是虛擬機(jī)內(nèi)部定義了信號(hào)處理函數(shù)擅笔,而在信號(hào)處理函數(shù)中對(duì)這兩者做了額外的處理以讓 JVM 不崩潰,另一方面也可以看出如果 JVM 不對(duì)信號(hào)做額外的處理屯援,最后會(huì)自己退出并產(chǎn)生 crash 文件 hs_err_pid_xxx.log(可以通過(guò) -XX:ErrorFile=/var/log/hs_err.log 這樣的方式指定)猛们,這個(gè)文件記錄了虛擬機(jī)崩潰的重要原因,所以也可以說(shuō)狞洋,虛擬機(jī)是否崩潰只要看它是否會(huì)產(chǎn)生此崩潰日志文件

總結(jié)

正常情況下弯淘,操作系統(tǒng)為了保證系統(tǒng)安全,所以針對(duì)非法內(nèi)存訪問(wèn)會(huì)發(fā)送一個(gè) SIGSEGV 信號(hào)吉懊,而操作系統(tǒng)一般會(huì)調(diào)用默認(rèn)的信號(hào)處理函數(shù)(一般會(huì)讓相關(guān)的進(jìn)程崩潰)庐橙,但如果進(jìn)程覺(jué)得"罪不致死",那么它也可以選擇自定義一個(gè)信號(hào)處理函數(shù)借嗽,這樣的話它就可以做一些自定義的邏輯态鳖,比如記錄 crash 信息等有意義的事,回過(guò)頭來(lái)看為什么虛擬機(jī)會(huì)針對(duì) StackoverflowError 和 NullPointerException 做額外處理讓線程恢復(fù)呢恶导,針對(duì) stackoverflow 其實(shí)它采用了一種椊撸回溯的方法保證線程可以一直執(zhí)行下去,而捕獲空指針錯(cuò)誤主要是這個(gè)錯(cuò)誤實(shí)在太普遍了甲锡,為了這一個(gè)很常見(jiàn)的錯(cuò)誤而讓 JVM 崩潰那線上的 JVM 要宕機(jī)多少次兆蕉,所以出于工程健壯性的考慮,與其直接讓 JVM 崩潰倒不如讓線程起死回生缤沦,并且將這兩個(gè)錯(cuò)誤/異常拋給用戶來(lái)處理虎韵。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市缸废,隨后出現(xiàn)的幾起案子包蓝,更是在濱河造成了極大的恐慌驶社,老刑警劉巖,帶你破解...
    沈念sama閱讀 210,914評(píng)論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件测萎,死亡現(xiàn)場(chǎng)離奇詭異亡电,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)硅瞧,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 89,935評(píng)論 2 383
  • 文/潘曉璐 我一進(jìn)店門份乒,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人腕唧,你說(shuō)我怎么就攤上這事或辖。” “怎么了枣接?”我有些...
    開(kāi)封第一講書人閱讀 156,531評(píng)論 0 345
  • 文/不壞的土叔 我叫張陵颂暇,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我但惶,道長(zhǎng)耳鸯,這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書人閱讀 56,309評(píng)論 1 282
  • 正文 為了忘掉前任膀曾,我火速辦了婚禮县爬,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘妓肢。我一直安慰自己捌省,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,381評(píng)論 5 384
  • 文/花漫 我一把揭開(kāi)白布碉钠。 她就那樣靜靜地躺著纲缓,像睡著了一般。 火紅的嫁衣襯著肌膚如雪喊废。 梳的紋絲不亂的頭發(fā)上祝高,一...
    開(kāi)封第一講書人閱讀 49,730評(píng)論 1 289
  • 那天,我揣著相機(jī)與錄音污筷,去河邊找鬼工闺。 笑死,一個(gè)胖子當(dāng)著我的面吹牛瓣蛀,可吹牛的內(nèi)容都是我干的陆蟆。 我是一名探鬼主播,決...
    沈念sama閱讀 38,882評(píng)論 3 404
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼惋增,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼叠殷!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起诈皿,我...
    開(kāi)封第一講書人閱讀 37,643評(píng)論 0 266
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤林束,失蹤者是張志新(化名)和其女友劉穎像棘,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體壶冒,經(jīng)...
    沈念sama閱讀 44,095評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡缕题,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,448評(píng)論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了胖腾。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片烟零。...
    茶點(diǎn)故事閱讀 38,566評(píng)論 1 339
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖咸作,靈堂內(nèi)的尸體忽然破棺而出瓶摆,到底是詐尸還是另有隱情,我是刑警寧澤性宏,帶...
    沈念sama閱讀 34,253評(píng)論 4 328
  • 正文 年R本政府宣布,位于F島的核電站状飞,受9級(jí)特大地震影響毫胜,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜诬辈,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,829評(píng)論 3 312
  • 文/蒙蒙 一酵使、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧焙糟,春花似錦口渔、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書人閱讀 30,715評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至悦穿,卻和暖如春攻礼,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背栗柒。 一陣腳步聲響...
    開(kāi)封第一講書人閱讀 31,945評(píng)論 1 264
  • 我被黑心中介騙來(lái)泰國(guó)打工礁扮, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人瞬沦。 一個(gè)月前我還...
    沈念sama閱讀 46,248評(píng)論 2 360
  • 正文 我出身青樓太伊,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親逛钻。 傳聞我的和親對(duì)象是個(gè)殘疾皇子僚焦,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,440評(píng)論 2 348

推薦閱讀更多精彩內(nèi)容