YUKIPEDIA's blog

一个普通的XMUER

《Summer Pockets》久島鴎推し


线程崩溃后为什么不会导致 JVM 崩溃?

目录

要弄明白这个问题,我们从以下几点进行分析:

  1. 线程崩溃后,进程一定会崩溃吗?
  2. 进程是如何崩溃的?——信号机制简介
  3. 为什么在 JVM 中线程崩溃不会导致 JVM 进程崩溃?

线程崩溃后,进程一定会崩溃吗?

一般来说如果线程是因为非法访问内存引起的崩溃,那么进程肯定会崩溃。这主要是因为在进程中,各个线程的地址空间是共享的,既然是共享,那么某个线程对地址的非法访问就会导致内存的不确定性,进而会影响到其他线程,可能导致一系列严重的后果,于是操作系统干脆让整个进程崩溃。

img

在一个进程中,所有的线程共享代码段、数据段、地址空间,文件非法访问内存有以下几种情况:

  1. 针对只读内存写入数据
#include<stdio.h>
#includ<stdlib.h>

int main() {
	char *s = "hello world";
	// 向只读内存写入数据,导致崩溃
	s[1] = 'H';
}
  1. 访问了进程没有权限访问的地址空间(比如内核空间)
#include<stdio.h>
#includ<stdlib.h>

int main() {
	int *p = (int *)0xC0000fff;
	// 针对进程的内核空间写入数据,导致崩溃
	*p = 10;
}

在 32 位虚拟地址空间中,p 指向的是内核空间

  1. 访问了不存在的内存
#include<stdio.h>
#includ<stdlib.h>

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

以上错误都是访问内存时的错误,会统一报 Segment Fault 错误,这些都会导致进程崩溃

进程是如何崩溃的?——信号机制简介

既然线程崩溃后,进程也会崩溃,那进程到底是如何崩溃的呢?其背后的机制是信号

如果我们想要杀掉一个正在运行的进程,常常会用到 kill -9 pid 这样的命令,这里的 kill 其实就是给指定 pid 发送终止信号的意思,其中 9 就是信号

在 Linux 中可以通过 kill -l 查看所有可用的信号:

img

当然发送 kill 信号需要一定权限,否则任何进程都可以通过发信号来终止其他进程,这显然是不合理的。实际上,kill 执行的是系统调用,将控制权转移给了操作系统内核,由内核来给指定的进程发送信号。

信号背后的机制如下:

  1. CPU 执行正常的进程指令
  2. 调用 kill 系统调用向进程发送信号
  3. 进程收到操作系统发的信号,CPU 暂停当前程序运行,并将控制权转交给操作系统
  4. 操作系统根据情况执行相应的信号处理程序(函数)
  5. 一般执行完信号处理程序逻辑后会让进程退出

注意第五步,如果进程没有注册自己的信号处理函数,那么操作系统会执行默认的信号处理程序(一般最后会让进程退出),但如果注册了,则会执行进程自己的信号处理函数,这样就给了进程一个垂死挣扎的机会,它收到 kill 信号后,可以调用 exit() 来退出,也可以使用其他函数来恢复进程的执行

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

// 进程自定义的信号处理函数
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 命令例外,不管进程是否定义了信号处理函数,都会马上被干掉

通过这部分的介绍,再回到标题的问题:线程崩溃后为什么不会导致 JVM 崩溃?

其实是 JVM 自己定义了信号处理函数,这样当发送 kill pid 命令(默认传入 15 也就是 SIGTERM)后,JVM 就可以在信号处理函数中执行一些资源清理之后再调用 exit 退出。

这种场景显然不能用 kill -9,不然进程被干掉后资源就来不及清除了。

为什么在 JVM 中线程崩溃不会导致 JVM 进程崩溃?

这个问题在第二个部分已经得到了解答,那么在 Java 中有哪些是常见的由于非法访问内存而产生的 exception 或 error 呢?比如 StackoverflowError 或者 NPE(NullPointerException),NPE 就是访问了不存在的内存。

但为什么栈溢出也是非法访问内存呢?这就需要回到操作系统中进程的虚拟空间,也就是共享地址空间

现代操作系统为了保护进程之间不受影响,使用了虚拟地址空间来隔离进程,进程的寻址都是针对虚拟地址,每个进程的虚拟空间都是一样的,而线程会共用进程的地址空间

以 32 位操作系统为例,其进程的虚拟空间分布如下:

img

那么栈溢出是如何发生的?

进程每调用一个函数,都会分配一个栈帧,然后在栈帧里会分配函数里定义的各种局部变量。

假设我们调用了一个无限递归的函数,那么操作系统就会持续分配栈帧,但 stack 的大小是有限的,如果无限递归下去,stack 会很快被分配完,此时再调用函数试图继续分配超出 stack 大小的内存,就会发生栈溢出。

img

根据上面的阐述,我们就搞清楚了为什么栈溢出空指针一样,都算非法访问内存。