线程崩溃,进程一定会崩溃吗
5.7 线程崩溃了,进程也会崩溃吗?

很多同学就好奇 ,为什么 C/C++ 语言里,线程崩溃后,进程也会崩溃,而 Java 语言里却不会
呢?
刚好看到朋友(公众号:码海 )写了一篇:「美团面试题:为什么 线程崩溃崩溃不会导致 JVM
崩溃?」
我觉得写的很好,所以分享给大家一起拜读拜读,本文分以下几节来探讨:
线程崩溃,进程一定会崩溃吗
进程是如何崩溃的-信号机制简介
为什么 在 JVM 中线程崩溃不会导致 JVM 进程崩溃
openJDK 源码解析
线程崩溃,进程一定会崩溃吗
一般来说如果线程是因为非法访问内存引起的崩溃,那么进程肯定会崩溃,为什么 系统要让进程
崩溃呢,这主要是因为在进程中,各个线程的地址 空间是共享的,既然是共享,那么某个线程对
地址 的非法访问就会导致内存的不确定性,进而可能会影响到其他线程,这种操作是危险的,操
作系统会认为这很可能导致一系列严重的后果,于是干脆让整个进程崩溃

线程共享代码段,数据段,地址 空间,文件非法访问内存有以下几种情况,我们以 C 语言举例来
看看 。
- 、针对只读内存写入数据
2、访问了进程没有权 限访问的地址 空间(比如内核空间)
#include <stdio.h>
#include <stdlib.h>
int main () {
char *s = "hello world" ;
// 向只读内存写入数据,崩溃
s[1] = 'H' ;
}
#include <stdio.h>
#include <stdlib.h>
int main () {cc
在 32 位虚拟地址 空间中,p 指向的是内核空间,显然不具有写入权限,所以上述赋值操作会 导致
崩溃
3、访问了不 存在的内存,比如:
以上错误都是访问内存时的错误,所以统一会报 Segment Fault 错误(即段错误),这些都会导致
进程崩溃
进程是如何崩溃的-信号机制简介
那么线程崩溃后,进程是如何崩溃的呢,这背后的机制到 底是怎样的,答案是信号。
大家想想 要干掉一个正在运行的进程是不是经常用 kill -9 pid 这样的命令,这里的 kill 其实就是给
指定 pid 发送终止信号的意思,其中的 9 就是信号。
其实信号有很多类型的,在 Linux 中可以通过 kill -l 查看所有可用的信号:

int *p = (int *)0xC0000fff ;
// 针对进程的内核空间写入数据,崩溃
*p = 10 ;
}
#include <stdio.h>
#include <stdlib.h>
int main () {
int *a = NULL ;
*a = 1;
}c
当然了发 kill 信号必须具有一定的权限,否则任意进程都可以通过发信号来终止其他进程,那显然
是不合理的,实际上 kill 执行的是系统调用,将控制权转移给了内核(操作系统),由内核来给指
定的进程发送信号
那么发个信号进程怎么就崩溃了呢,这背后的原理到底是怎样的?
其背后的机制如下:
CPU 执行正常的进程指令
调用 kill 系统调用向进程发送信号(假设为 11 ,即 SIGSEGV ,一般非法访问内存报的都是这个
错误)
进程收到操作系统发的信号,CPU 暂停当前程序运行,并将控制权转交给操作系统
操作系统根据情况执行相应的信号处理程序(函数),一般执行完信号处理程序逻辑后会让进程
退出
注意上面的第五步,如果进程没有注册自己的信号处理函数,那么操作系统会执行默认的信号处
理程序(一般最后会让进程退出),但如果注册了,则会执行自己的信号处理函数,这样的话就给
了进程一个垂死挣扎的机会,它收到 kill 信号后 ,可以调用 exit() 来退出,但也可以使 用sigsetjmp ,siglongjmp 这两个 函数来恢复进程的执行
如代码所示:注册信号处理函数后,当收到 SIGSEGV 信号后 ,先执行相关的逻辑再退出
另外当进程接收信号之后也可以不定义自己的信号处理函数,而是选择忽略信号,如下
// 自定义信号处理函数示例
#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!也就是说虽然给进程发送了 kill 信号,但如果进程自己定义了 信号处理函数或者无视信号就有机 会
逃出生天,当然了 kill -9 命令例 外,不管进程是否定义了 信号处理函数,都会⻢上被干掉。
说到这大家是否想起了一道经典面试题:如何让正在运行的 Java 工程的优雅停机?
通过上面的介绍大家不难发现,其实是 JVM 自己定义了 信号处理函数,这样当发送 kill pid 命令
(默认会传 15 也就是 SIGTERM )后,JVM 就可以在信号处理函数中执行一些资源清 理之后再调
用 exit 退出。
这种场景显 然不能用 kill -9 ,不然一下把进程干掉了资源就来不及清除了。
为什么 线程崩溃不会导致 JVM 进程崩溃
现在我们再来看看 开头这个问题,相信你 多少会心中有数,想想 看在 Java 中有哪些是常⻅的由于
非法访问内存而产生的 Exception 或 error 呢,常⻅的是大家熟悉的 StackoverflowError 或者 NPE
(NullPointerException ),NPE 我们都了解,属于是访问了不 存在的内存。
但为什么 栈溢出(Stackoverflow )也属于非法访问内存呢,这得简单聊一下进程的虚拟空间,也
就是前面提到的共享地址 空间。
现代操作系统为了 保护进程之间不受影响,所以使 用了虚拟地址 空间来隔离进程,进程的寻址都
是针对虚拟地址 ,每个进程的虚拟空间都是一样的,而线程会共用进程的地址 空间。
以 32 位虚拟空间,进程的虚拟空间分布如下:

#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
int main (void ) {
// 忽略信号
signal (SIGSEGV , SIG_IGN );
// 产生一个 SIGSEGV 信号
raise (SIGSEGV );
printf ("正常结束 ");
}那么 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 longjm
// 这段代码里会调用 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 ) { // 针对栈溢出 JVM 的内
stub = SharedRuntime :: continuation_for_implicit_exception (thread , pc , SharedR
}
}从以上代码我们可以知道以下信息:
- 发生 stackoverflow 还有空指针错误,确实都发送了 SIGSEGV ,只是虚拟机不选择退出,而是自
己内部作了额外的处理,其实是恢复了线程的执行,并抛出 StackoverflowError 和 NPE ,这就
是为什么 JVM 不会崩溃且我们能捕获这两个 错误/异常的原因
- 如果针对 SIGSEGV 等信号,在以上的函数中 JVM 没有做额外的处理,那么最终会走到
report_and_die 这个方法,这个方法主要做的事情是生成 hs_err_pid_xxx.log crash 文件(记录了一些堆栈信息或错误),然后退出
至此我相信大家明白了为什么 发生了 StackoverflowError 和 NPE 这两个 非法访问内存的错误,
JVM 却没有崩溃。
原因其实就是虚拟机内部定义了 信号处理函数,而在信号处理函数中对这两者做了额外的处理以
让 JVM 不崩溃,另一方面也可以看出如果 JVM 不对信号做额外的处理,最后会自己退出并产生
}
}
if (sig == SIGSEGV &&
!MacroAssembler :: needs_explicit_null_check ((intptr_t )info -> si_addr )) {
// 此处会做空指针检查
stub = SharedRuntime :: continuation_for_implicit_exception (thread , pc , SharedRuntime
}
// 如果是栈溢出或者空指针最终会返回 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
}
crash 文件 hs_err_pid_xxx.log (可以通过 -XX:ErrorFile=/var/ log /hs_err.log 这样的方式指定),这个文件记录了虚拟机崩溃的重要原因。
所以也可以说,虚拟机是否崩溃只要看它是否会产生此崩溃日志文件
总结
正常情况下,操作系统为了 保证系统安全,所以针对非法内存访问会发送一个 SIGSEGV 信号,而
操作系统一般会调用默认的信号处理函数(一般会让相关的进程崩溃)。
但如果进程觉得"罪不致死",那么它也可以选择自定义一个信号处理函数,这样的话它就可以做一
些自定义的逻辑,比如记录 crash 信息等有意义的事。
回过头来看为什么 虚拟机会针对 StackoverflowError 和 NullPointerException 做额外处 理让线程恢
复呢,针对 stackoverflow 其实它 采用了一种栈回溯的方法保证线程可以一直执行下去,而捕获空
指针错误主要是这个错误实在太普遍了。
为了 这一个很常⻅的错误而让 JVM 崩溃那线上的 JVM 要宕机多少次,所以出于工程健壮性的考
虑,与其直接让 JVM 崩溃倒不如让线程起死回生,并且将这两个 错误/异常抛给用⼾来处理。
