之前分享这篇文章的时候进程和线程基础知识全家桶30 张图一套带走 (opens new window)提到说线程的一个缺点很多同学就好奇为什么 C/C 语言里线程崩溃后进程也会崩溃而 Java 语言里却不会呢刚好看到朋友写了一篇「美团面试题为什么线程崩溃崩溃不会导致 JVM 崩溃?」我觉得写的很好所以分享给大家一起拜读拜读本文分以下几节来探讨线程崩溃进程一定会崩溃吗进程是如何崩溃的-信号机制简介为什么在 JVM 中线程崩溃不会导致 JVM 进程崩溃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 执行的是系统调用将控制权转移给了内核操作系统由内核来给指定的进程发送信号那么发个信号进程怎么就崩溃了呢这背后的原理到底是怎样的其背后的机制如下CPU 执行正常的进程指令调用 kill 系统调用向进程发送信号假设为 11即 SIGSEGV一般非法访问内存报的都是这个错误进程收到操作系统发的信号CPU 暂停当前程序运行并将控制权转交给操作系统操作系统根据情况执行相应的信号处理程序函数一般执行完信号处理程序逻辑后会让进程退出注意上面的第五步如果进程没有注册自己的信号处理函数那么操作系统会执行默认的信号处理程序一般最后会让进程退出但如果注册了则会执行自己的信号处理函数这样的话就给了进程一个垂死挣扎的机会它收到 kill 信号后可以调用 exit() 来退出但也可以使用 sigsetjmpsiglongjmp 这两个函数来恢复进程的执行// 自定义信号处理函数示例 #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 或者 NPENullPointerException,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 (opens new window)有兴趣的可以下载来看看。我们只要研究 Linux 下的 JVM为了便于说明也方便大家查阅我把其中关于信号处理的关键流程整理了下忽略其中的次要代码。可以看到在启动 JVM 的时候也设置了信号处理函数收到 SIGSEGVSIGPIPE 等信号后最终会调用 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) { // 针对栈溢出 JVM 的内部处理 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 }从以上代码我们可以知道以下信息发生 stackoverflow 还有空指针错误确实都发送了 SIGSEGV只是虚拟机不选择退出而是自己内部作了额外的处理其实是恢复了线程的执行并抛出 StackoverflowError 和 NPE这就是为什么 JVM 不会崩溃且我们能捕获这两个错误/异常的原因如果针对 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 要宕机多少次所以出于工程健壮性的考虑与其直接让 JVM 崩溃倒不如让线程起死回生并且将这两个错误/异常抛给用户来处理。