ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

JDK21 Process Reaper TLS 栈溢出循环故障深度分析

2026/8/6 21:01:45 拓冰建站 浏览量
JDK21 Process Reaper TLS 栈溢出循环故障深度分析

JDK21 Process Reaper TLS 栈溢出循环故障深度分析

 

在JDK21的上下文中,TLS是线程局部存储(Thread Local Storage)的缩写,用于描述多线程环境中每个线程独有的数据存储区域。当JVM创建小栈线程(如process reaper线程)时,glibc/Linux会将静态TLS数据分配在栈相关区域,可能减少实际可用栈空间。

 

 

JDK21 Process Reaper TLS 栈溢出循环故障深度分析

 

1. 问题背景

在毕昇JDK21 上,ProcessBuilder.start() 创建子进程后,JDK 会创建内部 process reaper 线程等待子进程退出并回收退出状态。该线程不是普通默认栈 Java 线程,而是 JDK 内部为了节省资源创建的小栈线程。

在当前毕昇JDK21 源码中,process reaper 默认栈大小为 128KB:

// src/java.base/share/classes/java/lang/ProcessHandleImpl.java
private static final long REAPER_DEFAULT_STACKSIZE = 128 * 1024;

当 JVM 运行在 JNI/嵌入式场景中,主可执行文件或依赖库可能声明较大的静态 TLS。glibc/Linux 会将静态 TLS 分配在新线程栈相关区域,导致线程实际可用栈空间小于 JDK 请求的栈大小。对于默认只有 128KB 的 process reaper,TLS 增大到某个临界值后,就可能触发栈溢出。

注:此问题为JDK21 特有,JDK8 中不存在此问题(详见第 2.2 节说明)。

2. 场景描述与定位过程

2.1 复现用例

ProcessLauncher.java

import java.io.*;

public class ProcessLauncher {
    public static void main(String[] args) throws Exception {
        System.out.println("[Java] 主线程启动,先创建一个子进程激活 process reaper...");

        ProcessBuilder pb = new ProcessBuilder("sleep", "2");
        Process p = pb.start();

        Thread.sleep(100);

        int exitCode = p.waitFor();
        System.out.println("[Java] 子进程结束,退出码: " + exitCode);
    }
}

ProcessReaperTLSOverflow.c

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

#ifdef _WIN32
__declspec(thread)
#else
__thread
#endif
char giant_tls_buffer[64 * 1024];

void touch_tls() {
    for (int i = 0; i < sizeof(giant_tls_buffer); i++) {
        giant_tls_buffer[i] = (char)(i % 256);
    }
    printf("[C] 已初始化 TLS 数据,大小: %zu 字节\n", sizeof(giant_tls_buffer));
}

int main(int argc, char **argv) {
    JavaVM *jvm;
    JNIEnv *env;
    JavaVMInitArgs vm_args;
    JavaVMOption options[4];
    int opt_count = 0;

    touch_tls();

    printf("[C] 启动 JVM (JDK 21),即将激活 process reaper 线程...\n");

    options[opt_count++].optionString = "-Djava.class.path=.";
    options[opt_count++].optionString = "-Xss1m";
    options[opt_count++].optionString = "-Xmx64m";

    vm_args.version = JNI_VERSION_21;
    vm_args.nOptions = opt_count;
    vm_args.options = options;
    vm_args.ignoreUnrecognized = JNI_TRUE;

    jint res = JNI_CreateJavaVM(&jvm, (void**)&env, &vm_args);
    if (res != JNI_OK) {
        fprintf(stderr, "[C] 无法创建 JVM,错误码: %d\n", res);
        return 1;
    }
    printf("[C] JVM 创建成功\n");

    jclass cls = (*env)->FindClass(env, "ProcessLauncher");
    if (cls == NULL) {
        fprintf(stderr, "[C] 找不到 ProcessLauncher 类\n");
        goto cleanup;
    }

    jmethodID mid = (*env)->GetStaticMethodID(env, cls, "main", "([Ljava/lang/String;)V");
    if (mid == NULL) {
        fprintf(stderr, "[C] 找不到 main 方法\n");
        goto cleanup;
    }

    (*env)->CallStaticVoidMethod(env, cls, mid, NULL);

cleanup:
    (*jvm)->DestroyJavaVM(jvm);
    return 0;
}

2.2 当前环境与定位过程

当前环境配置:

  • 架构:Linux/aarch64
  • C 运行时:glibc 2.34
  • JDK 版本:毕昇JDK21 aarch64
  • 复现 TLS 大小:64KB

本文中的 TLS 临界值是基于上述环境的实测结果。不同架构、glibc 版本、JDK 构建参数、线程保护页配置或 native 依赖库的静态 TLS 布局不同,具体阈值可能变化,但触发机制一致。

C 侧通过 __thread 声明较大的静态 TLS,并通过 JNI 创建 JVM。示例中 TLS 为 64KB:

__thread char giant_tls_buffer[64 * 1024];

Java 侧通过 ProcessBuilder("sleep", "2").start() 激活 process reaper 线程:

ProcessBuilder pb = new ProcessBuilder("sleep", "2");
Process p = pb.start();
int exitCode = p.waitFor();

64KB TLS 下稳定观察到:

Exception: java.lang.StackOverflowError thrown from the UncaughtExceptionHandler in thread "process reaper"

基于该现象,定位过程主要分为三步:

  1. 确认 process reaper 的栈大小来源:毕昇JDK21 在 ProcessHandleImpl 中默认给 reaper 线程显式设置 128KB 小栈;只有设置 -Djdk.lang.processReaperUseDefaultStackSize=true 时才会改用默认 Java 线程栈。
  2. 确认 Linux/glibc TLS 对小栈线程的影响:HotSpot/Linux 有 AdjustStackSizeForTLS 机制可以补偿静态 TLS,但该选项默认关闭,因此默认情况下 128KB reaper 小栈不会额外加上静态 TLS 大小。
  3. 确认循环失败路径:process reaper 由 cached thread pool 管理,worker 因 StackOverflowError 异常退出后,ThreadPoolExecutor 会补充 worker;新 worker 仍使用同样的小栈,因此可能反复创建、反复溢出。

对应源码与根因分析放在第 3 章,这里仅展示定位思路。

为确认临界点,对 TLS 大小做了扫描。当前环境结果如下:

TLS 大小结果
0KB-57KB 正常退出
58KB 开始循环失败,8 秒超时,输出 Caused by: java.lang.StackOverflowError
59KB 循环失败,反复输出 Exception in thread "process reaper" java.lang.StackOverflowError
60KB及以上 稳定触发 reaper uncaught-handler 栈溢出
64KB 稳定复现当前问题

结论:在当前毕昇JDK21 aarch64 运行环境和该 demo 条件下,58KB 静态 TLS 开始进入循环失败区间,60KB 起稳定表现为 process reaper uncaught-handler 栈溢出,64KB 稳定复现

注:JDK8 中不存在此问题,JDK8 虽然将 process reaper 栈声明为更小的 32KB,但 HotSpot/Linux 在线程创建时会强制将栈大小抬升至 min_stack_allowed(包含 yellow/red/shadow pages 等保护区需求),实际 pthread 栈远大于 32KB,不会落入当前 58KB TLS 起触发的危险区间。

3. 源码与根因分析

3.1 process reaper 固定小栈

// bishengjdk-21/src/java.base/share/classes/java/lang/ProcessHandleImpl.java
private static final long REAPER_DEFAULT_STACKSIZE = 128 * 1024;

final long stackSize = Boolean.getBoolean("jdk.lang.processReaperUseDefaultStackSize")
        ? 0 : REAPER_DEFAULT_STACKSIZE + debugDelta;

Thread t = InnocuousThread.newSystemThread("process reaper", grimReaper,
        stackSize, Thread.MAX_PRIORITY);

jdk.lang.processReaperUseDefaultStackSize 未设置时,毕昇JDK21 固定使用 REAPER_DEFAULT_STACKSIZE + debugDelta。release 构建中 debugDelta 为 0,因此默认 reaper 栈为 128KB。

stackSize 会继续传递到 Thread 构造流程:

// bishengjdk-21/src/java.base/share/classes/jdk/internal/misc/InnocuousThread.java
public static Thread newSystemThread(String name, Runnable target,
                                     long stackSize, int priority) {
    if (System.getSecurityManager() == null) {
        return createThread(name, target, stackSize, null, priority);
    }
    ...
}

private InnocuousThread(ThreadGroup group, Runnable target, String name,
                        long stackSize, ClassLoader tccl) {
    super(group, target, name, stackSize, false);
    ...
}

3.2 TLS 栈补偿默认关闭

HotSpot/Linux 创建线程时有 TLS 补偿逻辑,但只有 AdjustStackSizeForTLS 为 true 时才会把 glibc 静态 TLS 大小加入线程栈:

// bishengjdk-21/src/hotspot/os/linux/os_linux.cpp
if (AdjustStackSizeForTLS) {
  stack_adjust_size += get_static_tls_area_size(&attr);
} else if (os::Linux::adjustStackSizeForGuardPages()) {
  stack_adjust_size += guard_size;
}

stack_adjust_size = align_up(stack_adjust_size, os::vm_page_size());
if (stack_size <= SIZE_MAX - stack_adjust_size) {
  stack_size += stack_adjust_size;
}

pthread_attr_setstacksize(&attr, stack_size);

该选项默认关闭:

// bishengjdk-21/src/hotspot/os/linux/globals_linux.hpp
product(bool, AdjustStackSizeForTLS, false,
        "Increase the thread stack size to include space for glibc static thread-local storage (TLS) if true")

因此默认情况下,reaper 请求的 128KB 栈不会额外补偿主程序或依赖库引入的静态 TLS。静态 TLS 增大后,reaper 的实际可用栈空间被压缩。

3.3 worker 异常退出后补线程

// bishengjdk-21/src/java.base/share/classes/java/util/concurrent/ThreadPoolExecutor.java
catch (Throwable ex) {
    afterExecute(task, ex);
    throw ex;
}

...

if (runStateLessThan(c, STOP)) {
    ...
    addWorker(null, false);
}

process reaper 由 cached thread pool 管理。worker 执行过程中发生 StackOverflowError 时,异常会导致 worker 异常退出;线程池随后补充 worker。新 worker 仍由同一个 thread factory 创建,因此继续使用 128KB 小栈。

根因链路:

毕昇JDK21 process reaper 默认请求 128KB 小栈
  -> Linux/glibc 静态 TLS 占用线程栈相关空间
  -> AdjustStackSizeForTLS 默认关闭,128KB 不补偿 TLS
  -> reaper 实际可用栈不足
  -> reaper 线程 StackOverflowError
  -> ThreadPoolExecutor 补充同样小栈的 worker
  -> 再次触发 StackOverflowError,形成循环失败

实测中,57KB TLS 正常退出;58KB 起进入循环失败区间;60KB 及以上稳定出现:

StackOverflowError thrown from the UncaughtExceptionHandler in thread "process reaper"

4. 解决方案

4.1 运行时规避方案一:使用默认 Java 线程栈

启动参数:

-Djdk.lang.processReaperUseDefaultStackSize=true

该参数会让毕昇JDK21 ProcessHandleImplstackSize 变为 0:

final long stackSize = Boolean.getBoolean("jdk.lang.processReaperUseDefaultStackSize")
        ? 0 : REAPER_DEFAULT_STACKSIZE + debugDelta;

stackSize=0 时,reaper 不再使用 128KB 小栈,而是使用默认 Java 线程栈。

4.2 运行时规避方案二:开启 TLS 栈补偿

启动参数:

-XX:+AdjustStackSizeForTLS

该参数使 HotSpot/Linux 在线程创建时把静态 TLS size 补到 requested stack size 上。该参数会影响全局线程栈保留大小,需要评估内存影响。