程序员的自我修养之线程与栈

这篇文章是介绍一下线程与栈相关的话题,文章比较长,主要会聊聊下面这些话题:java

  • 进程与线程的本质区别,线程与内存共享
  • Linux pthread 与 Guard 区域
  • Hotspot 线程栈的 Guard 区域实现原理
  • 你可能没有怎么据说过的 Yellow-Zone、Red-Zone
  • Java StackOverflowError 的实现原理

为了讲清楚线程与栈的关系,咱们要从进程和线程之间的关系讲起,接下来开始第一部分。linux

第一部分:老生常谈之进程线程

网上不少文章都说,线程比较轻量级 lightweight,进程比较重量级,首先咱们来看看这二者到底的区别和联系在哪里。算法

clone 系统调用

在上层看来,进程和线程的区别确实有天壤之别,二者的建立、管理方式都很是不同。在 linux 内核中,不论是进程仍是线程都是使用同一个系统调用 clone,接下来咱们先来看看 clone 的使用。为了表述的方便,接下来暂时用进程来表示进程和线程的概念。数组

clone 函数的函数签名以下。bash

int clone(int (*fn)(void *),
          void *child_stack,
          int flags,
          void *arg, ...
          /* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );
复制代码

参数释义以下:ide

  • 第一个参数 fn 表示 clone 生成的子进程会调用 fn 指定的函数,参数由第四个参数 arg 指定
  • child_stack 表示生成的子进程的栈空间
  • flags 参数很是关键,正是这个参数区分了生成的子进程与父进程如何共享资源(内存、打开文件描述符等)
  • 剩下的参数,ptid、tls、ctid 与线程实现有关,这里先不展开

接下来咱们来看一个实际的例子,看看 flag 对新生成的「进程」行为的影响。函数

clone 参数的影响

接下来演示 CLONE_VM 参数对父子进程行为的影响,这段代码当运行时的命令行参数包含 "clone_vm" 时,给 clone 函数的 flags 会增长 CLONE_VM。代码以下。布局

static int child_func(void *arg) {
    char *buf = (char *)arg;
    // 修改 buf 内容
    strcpy(buf, "hello from child");
    return 0;
}

const int STACK_SIZE = 256 * 1024;
int main(int argc, char **argv) {
    char *stack = malloc(STACK_SIZE);

    int clone_flags = 0;
    // 若是第一个参数是 clone_vm,则给 clone_flags 增长 CLONE_VM 标记
    if (argc > 1 && !strcmp(argv[1], "clone_vm")) {
        clone_flags |= CLONE_VM;
    }
    char buf[] = "msg from parent";

    if (clone(child_func, stack + STACK_SIZE, clone_flags, buf) == -1) {
        exit(1);
    }
    sleep(1);
    printf("in parent, buf:\"%s\"\n", buf);
    return 0;
}
复制代码

上面的代码在 clone 调用时,将父进程的 buf 指针传递到 child 进程中,当不带任何参数时,CLONE_VM 标记没有被设置,表示不共享虚拟内存,父子进程的内存彻底独立,子进程的内存是父进程内存的拷贝,子进程对 buf 内存的写入只是修改本身的内存副本,父进程看不到这一修改。ui

编译运行结果以下。spa

$ ./clone_test

in parent, buf:"msg from parent"
复制代码

能够看到 child 进程对 buf 的修改,父进程并无生效。

再来看看运行时增长 clone_vm 参数时结果:

$ ./clone_test clone_vm

in parent, buf:"hello from child"
复制代码

能够看到此次 child 进程对 buf 修改,父进程生效了。当设置了 CLONE_VM 标记时,父子进程会共享内存,子进程对 buf 内存的修改也会直接影响到父进程。

讲这个例子是为后面介绍进程和线程的区别打下基础,接下来咱们来看看进程和线程的本质区别是什么。

进程与 clone

如下面的代码为例。

pid_t gettid() {
    return syscall(__NR_gettid);
}
int main() {
    pid_t pid;
    pid = fork();
    if (pid == 0) {
        printf("in child, pid: %d, tid:%d\n", getpid(), gettid());
    } else {
        printf("in parent, pid: %d, tid:%d\n", getpid(), gettid());
    }
    return 0;
}
复制代码

使用 strace 运行输出结果以下:

clone(child_stack=NULL,
flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD,
child_tidptr=0x7f75b83b4a10) = 16274
复制代码

能够看到 fork 建立进程对应 clone 使用的 flags 中惟一须要值得注意的 flag 是 SIGCHLD,当设置这个 flag 之后,子进程退出时,系统会给父进程发送 SIGCHLD 信号,让父进程使用 wait 等函数获取到子进程退出的缘由。

能够看到 fork 调用时,父子进程没有共享内存、打开文件等资源,这样契合进程是资源的封装单位这个说法,资源独立是进程的显著特征。接下来咱们来看看线程与 clone 的关系。

线程与 clone

这里以一段最简单的 C 代码来看看建立一个线程时,底层到底发生了什么,代码以下。

#include <pthread.h>
#include <unistd.h>
#include <stdio.h>

void *run(void *args) {
    sleep(10000);
}
int main() {
    pthread_t t1;
    pthread_create(&t1, NULL, run, NULL);
    pthread_join(t1, NULL);
    return 0;
}
复制代码

使用 gcc 编译上面的代码

gcc -o thread_test thread_test.c -lpthread
复制代码

而后使用 strace 执行 thread_test,系统调用以下所示。

mmap(NULL, 8392704, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0) = 0x7fefb3986000

clone(child_stack=0x7fefb4185fb0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, parent_tidptr=0x7fefb41869d0, tls=0x7fefb4186700, child_tidptr=0x7fefb41869d0) = 12629

mprotect(0x7fefb3986000, 4096, PROT_NONE) = 0
复制代码

比较重要的是下面这些 flags 参数:

标记 含义
CLONE_VM 共享虚拟内存
CLONE_FS 共享与文件系统相关的属性
CLONE_FILES 共享打开文件描述符表
CLONE_SIGHAND 共享对信号的处置
CLONE_THREAD 置于父进程所属的线程组中

能够看到,线程建立的本质是共享进程的虚拟内存、文件系统属性、打开的文件列表、信号处理,以及将生成的线程加入父进程所属的线程组中。

值得注意的是 mmap 申请的内存大小不是 8M 而是 8M + 4K

8392704 = 8 * 1024 * 1024 + 4096
复制代码

为何会多这 4K,咱们接下来的第二部分线程与栈中会详细阐述。

第二部分:线程与栈

前面内容中,咱们看到经过 strace 查看线程建立过程当中的 8M 的栈大小,实际上会分配多 4k 的空间,这是一个颇有意思的问题,咱们来详细看看。

线程与 Guard 区域

线程的栈是一个比较“奇怪”的产物,一方面线程的栈是线程独有,里面保存了线程运行状态、局部变量、函数调用等信息。另一方面,从资源管理的角度而言,全部线程的栈都属于进程的内存资源,线程和父进程共享资源,进程中其它线程天然能够修改任意线程的栈内存。

如下面的代码为例,这段代码建立了两个线程 t一、t2,对应的运行函数是 runnable1 和 runnable2。t1 线程将 buf 数组的地址复制给全局指针 p,t1 线程每隔 1s 打印一次 buf 数组的内容,t2 线程每隔 3s 修改一次 p 指针指向地址的内容。

#include <pthread.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
static char *p;
void *runnable1(void *args) {
    char buf[10] = {0};
    p = buf;
    while (1) {
        printf("buffer: %s\n", buf);
        sleep(1);
    }
}

void *runnable2(void *args) {
    int index = 0;
    while (1) {
        if (p) {
            strcpy(p, index++ % 2 == 0 ? "say hello" : "say world");
        }
        sleep(3);
    }
}
int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, runnable1, NULL);
    pthread_create(&t2, NULL, runnable2, NULL);

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}
复制代码

编译运行上面的代码,结果输出以下

$ ./thread_stack_test

buf:
buf:
buf:
buf: say hello
buf: say hello
buf: say hello
buf: say world
buf: say world
buf: say world
buf: say hello
buf: say hello
复制代码

能够看到线程 2 直接修改了线程 1 栈中数组的内容。这种行为是 linux 中彻底合法,不会报任何错误。若是能够这么随意的访问到其它线程的内容是一个很是危险的事情,好比栈越界,将会形成其它线程的数据错乱。

为了能减缓栈越界带来的影响,操做系统引入了 stack guard 的概念,就是给每一个线程栈多分配一页(4k)或多页内存,这片内存不可读、不可写、不可执行,只要访问就会形成段错误。

咱们以一个实际的例子来看栈越界,代码以下所示。

static void *thread_illegal_access(void *arg) {
    sleep(1);
    char p[1];
    int i;
    for (i = 0; i < 1024; ++i) {
        printf("[%d] access address: %p\n", i, &p[i * 1024]);
        p[i * 1024] = 'a';
    }
}
static void *thread_nothing(void *arg) {
    sleep(1000);
    return NULL;
}

int main() {
    pthread_t t1;
    pthread_t t2;

    pthread_create(&t1, NULL, thread_nothing, NULL);
    pthread_create(&t2, NULL, thread_illegal_access, NULL);

    char str[100];
    sprintf(str, "cat /proc/%d/maps > proc_map.txt", getpid());
    system(str);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}
复制代码

编译上面的 c 文件,使用 strace 执行,部分系统调用以下所示。

// thread 1
mmap(NULL, 8392704,
    PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK,
    -1, 0) = 0x7f228d615000

mprotect(0x7f228d615000, 4096, PROT_NONE) = 0

clone(child_stack=0x7f228de14fb0,                                                                                                     flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID,   parent_tidptr=0x7f228de159d0, tls=0x7f228de15700, child_tidptr=0x7f228de159d0) = 9696

// thread 2
mmap(NULL, 8392704,
    PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0) = 0x7f228ce14000
mprotect(0x7f228ce14000, 4096, PROT_NONE) = 0
clone(child_stack=0x7f228d613fb0,                                                                                                     flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID,   parent_tidptr=0x7f228d6149d0, tls=0x7f228d614700, child_tidptr=0x7f228d6149d0) = 9697

复制代码

在 linux 中,一个线程栈的默认大小是 8M(8388608),可是这里 mmap 分配的内存块大小倒是 8392704(8M+4k),这里多出来的 4k 就是 stack guard 大小。

分配了 8M+4k 的内存之后,随即便用 mprotect 将刚分配的内存块的 4k 地址的权限改成了 PROT_NONE, PROT_NONE 表示拒绝全部访问,不可读、不可写、不可执行。第二个线程建立的过程如出一辙,这里再也不赘述,两个线程的内存布局以下所示。

$ ./thread_test
[0] access address: 0x7ffff6feef0b
[1] access address: 0x7ffff6fef30b
[2] access address: 0x7ffff6fef70b
[3] access address: 0x7ffff6fefb0b
[4] access address: 0x7ffff6feff0b
[5] access address: 0x7ffff6ff030b
[1]    18133 segmentation fault  ./thread_test
复制代码

咱们能够看到最后 access 致使段错误的地址是 0x7ffff6ff030b,这个地址正好位于线程 1 的 guard 区域内。最后一个合法的范围还处于 t2 的线程栈的合法区域中,以下所示。

Java 线程栈溢出是如何处理的

前面介绍过,Linux 的线程经过 4k 的 Guard 区域实现了栈溢出的简单预防,只要读写 Guard 区域就会出现段错误。那有没有想过 Java 是如何处理栈溢出的呢?

Java 线程的栈溢出时,进程不会退出,StackOverflowError 异常还能够被捕获,程序能够继续运行,如下面的代码为例。

public class ThreadStackTest0 {
    private static void foo(int i) {
        foo(i + 1);
    }
    public static void main(String[] args) throws IOException {
        System.out.println("in main");
        new Thread(new Runnable() {
            @Override
            public void run() {
                foo(0);
            }
        }).start();
        System.in.read();
    }
}
复制代码

编译运行上面的代码,能够看到

$ javac ThreadStackTest0.java; java -cp . ThreadStackTest0

in main
Exception in thread "Thread-0" java.lang.StackOverflowError
        at ThreadStackTest0.foo(ThreadStackTest0.java:8)
        at ThreadStackTest0.foo(ThreadStackTest0.java:8)
        // ...

复制代码

首先解决第一个疑惑,Java 的普通线程有没有 Linux 原生线程的那种 4k 的 Guard 区域呢?

首先来讲答案,答案是没有的。Hotspot 源码中建立线程的代码在 os_linux.cpp 中,

bool os::create_thread(Thread* thread, ThreadType thr_type, size_t stack_size) {
  // ...
  // glibc guard page
  pthread_attr_setguardsize(&attr, os::Linux::default_guard_size(thr_type));
  // ...
}
复制代码

guard 区域的大小是由 os::Linux::default_guard_size 这个方法肯定的,这个方法的内部实现比较简单,判断线程的类型是否是普通的 Java 线程,若是是的话,guard 的大小设置为 0,若是不是则设置为 4k。

什么是 Java 的普通线程呢?除了用户手动 new Thread() 方式建立的 java 线程,其实还有很多 JVM 运行须要的额外的辅助线程,好比 GC 线程、编译线程、watcher 线程等。从源码调试的结果能够看到,对于 Java 线程,guard 区域大小被设置为 0,其余类型的线程都被设置为默认的 4k。

不要高兴的太早,没有 Linux 原生线程标准的 guard 区域,不表明 Java 线程没有本身实现。实际上 Hotspot 不光本身接管了 Guard 区域,它还实现了两个,一个叫 Yellow Zone,一个叫 Red Zone,以下所示。

java_thread_stack

其中 Yellow Zone 的默认大小为 8k,能够经过 -XX:StackYellowPages 来指定,Red Zone 的默认大小为 4k,能够经过 -XX:StackRedPages 来指定。 这 12k 的权限都是 PROT_NONE,也就是不可读不可写不可执行,读写这一块区域都会触发 Segmentation Fault(SIGSEGV),JVM 为了能本身处理栈溢出异常,它处理了 SIGSEGV 这个信号。

接下来介绍一下这两块区域:

  • Yellow zone:这一块区域是用来处理可恢复的栈溢出的,当栈溢出发生在这一块区域时,会把这 8k 的内存区域的权限改成可读可写,随后 JVM 会抛出 StackOverflowError 异常,StackOverflowError 这个异常应用层能够被捕获进行处理。当异常抛出处理完之后,这 8k 内存区域的权限又会恢复为不可读、不可写、不可执行的状态。
  • Red zone:这一块的区域是用来处理不可恢复的栈溢出的,算是线程栈最后的防线了。这个区域的栈溢出,JVM 会视为致命错误,进程会退出并生成 hs_err_pid.log 文件。当栈溢出在这个区域时,会首先把这 4k 的权限改成可读可写,以便留一些栈空间生成 hs_err_pid.log 文件。

完整的代码见 os_linux_x86.cpp,以下所示。

extern "C" JNIEXPORT int
JVM_handle_linux_signal(int sig, siginfo_t* info, void* ucVoid, int abort_if_unrecognized) {
    // Handle ALL stack overflow variations here
    if (sig == SIGSEGV) {
       address addr = (address) info->si_addr;

      // 检查发生段错误的地址是否是在栈内存的有效范围内 [stack_base-stack_size, stack_base]
      if (addr < thread->stack_base() &&
          addr >= thread->stack_base() - thread->stack_size()) {
        // stack overflow
        // 发生段错误的地址处于 yellow 区域
        if (thread->in_stack_yellow_zone(addr)) {
          // 先把 yellow zone 的 8k 权限改成可读可写,以便调用抛出 STACK_OVERFLOW 异常
          thread->disable_stack_yellow_zone();
          if (thread->thread_state() == _thread_in_Java) {
            // Throw a stack overflow exception.  Guard pages will be reenabled
            // while unwinding the stack.
            stub = SharedRuntime::continuation_for_implicit_exception(thread, pc, SharedRuntime::STACK_OVERFLOW);
          } else {
            // Thread was in the vm or native code.  Return and try to finish.
            return 1;
          }
        } else if (thread->in_stack_red_zone(addr)) { // 若是地址在 red zone
          // Fatal red zone violation.  Disable the guard pages and fall through
          // to handle_unexpected_exception way down below.
          // 先 disable 掉 red zone,把权限改成可读可写,方便留出 4k 的栈给生成 hs_err_pid.log 文件的代码使用
          thread->disable_stack_red_zone();
          tty->print_raw_cr("An irrecoverable stack overflow has occurred.");

          // This is a likely cause, but hard to verify. Let's just print // it as a hint. tty->print_raw_cr("Please check if any of your loaded .so files has " "enabled executable stack (see man page execstack(8))"); } else { } } } 复制代码

Java 线程栈的大小最小是多少?

这是一个比较有意思的问题,以前也没有怎么多想过,只知道默认的栈大小为 1M,那咱们随便试一下:

能够看到在个人 64 位 Centos7 系统上,这个值为栈大小最小要指定 228k,这个值怎么来的呢?咱们来看看源码。

os::Linux::min_stack_allowed = MAX2(os::Linux::min_stack_allowed,
            (size_t)(StackYellowPages+StackRedPages+StackShadowPages) * Linux::page_size() +
                    (2*BytesPerWord COMPILER2_PRESENT(+1)) * Linux::vm_default_page_size());
复制代码

其中 MAX2 函数表示取两个入参的最大值,os::Linux::min_stack_allowed 的值为 64k,StackYellowPages=2,StackRedPages=1,StackShadowPages=20,Linux::page_size() 的值为 4k,BytesPerWord=8,Linux::vm_default_page_size() 的值为 8k。

min_stack_allowed = max(64k, (2 + 1 + 20) * 4k + (2 * 8 + 1) * 8k)
    = max(64k, 228k) = 228k
复制代码

在 Mac 上 Xss 的最小值为 160k,它的计算规则有一点不太同样,源码以下:

os::Bsd::min_stack_allowed = MAX2(os::Bsd::min_stack_allowed,
            (size_t)(StackYellowPages+StackRedPages+StackShadowPages+
                    2*BytesPerWord COMPILER2_PRESENT(+1)) * Bsd::page_size());
复制代码

计算的过程也就是:

min_stack_allowed = (2 + 1 + 20 + 16 + 1) * 4k = 160k
复制代码

小结

这篇文章但愿你可以了解到下面这些知识:

  • 进程与线程的生成,底层都是由 clone 系统调用生成
  • 进程与线程的一大区别在于进程拥有各自独立的进程资源,线程则是共享进程的资源
  • linux 线程栈的默认大小为 8M,除了线程栈的内存,每一个线程还会额外多 4k 的 guard 区域防止栈溢出
  • JHotspot 的普通线程的 guard 区域大小为 0,不过本身接管了 Guard 区域的实现
  • Hotspot 经过 Yellow-Zone、Red-Zone 这两个区域和自定义 SIGSEGV 信号的处理实现了栈溢出的处理
  • JVM 的 XSS 最小值与平台相关,具体的算法能够参考上面的内容

有问题能够扫描下面的二维码关注个人公众号到联系我。

相关文章
相关标签/搜索