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"
基于该现象,定位过程主要分为三步:
- 确认
process reaper的栈大小来源:毕昇JDK21 在ProcessHandleImpl中默认给 reaper 线程显式设置 128KB 小栈;只有设置-Djdk.lang.processReaperUseDefaultStackSize=true时才会改用默认 Java 线程栈。 - 确认 Linux/glibc TLS 对小栈线程的影响:HotSpot/Linux 有
AdjustStackSizeForTLS机制可以补偿静态 TLS,但该选项默认关闭,因此默认情况下 128KB reaper 小栈不会额外加上静态 TLS 大小。 - 确认循环失败路径:
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 ProcessHandleImpl 中 stackSize 变为 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 上。该参数会影响全局线程栈保留大小,需要评估内存影响。