专家视角看Java线程线程退出时的资源拆解工程
Java线程线程退出时的资源拆解工程
- 前言
- 线程退出时如何清理 JNI Handles、注销安全点(Safepoint)并回收栈内存
- 1. 清理 JNI Handles:解除与 Native 世界的绑定
- 2. 注销安全点(Safepoint):从 VM 活跃名单中抹除
- 3. 回收栈内存:释放物理资源
- A. 解除警戒页(Guard Pages)
- B. 释放 C++ 管理对象
- C. 物理栈的回笼(The munmap Moment)
- 总结:线程退出的全路径清单
- 深度思考
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
线程退出时如何清理 JNI Handles、注销安全点(Safepoint)并回收栈内存
OpenJDK 8的底层实现中,线程的退出(Exit)是一场比启动更为复杂的“资源拆解”工程。当 Java 层的run()方法执行结束,执行流回到 C++ 世界时,JVM 必须确保该线程占用的所有非堆内存(Native Memory)、元数据关联以及内核资源被彻底回收,否则会引发严重的内存泄漏或导致系统在下一次安全点(Safepoint)同步时挂起。线程的退出过程被称为**“葬礼协议(Funeral Protocol)”**。这不仅是 Java 对象的销毁,更是 Native 资源的彻底交接。整个过程由hotspot/src/share/vm/runtime/thread.cpp中的JavaThread::exit函数总控。
以下从底层源码视角分析 JNI 句柄清理、安全点注销及栈内存回收的完整路径。
1. 清理 JNI Handles:解除与 Native 世界的绑定
每个 Java 线程在执行 JNI 调用时,都会产生局部引用(Local References),这些引用存储在线程私有的JNIHandleBlock中。
- 源码定位与逻辑:在
hotspot/src/share/vm/runtime/thread.cpp的JavaThread::exit方法中,可以看到对 Handle 的清理:
// 源码示意if(active_handles()!=NULL){JNIHandleBlock*block=active_handles();set_active_handles(NULL);JNIHandleBlock::release_block(block);}源码逻辑:在
JavaThread::exit内部,JVM 会调用active_handles()->zap()或直接释放块。- 句柄块(Handle Block)结构:JNI 局部引用并不直接存在于 Java 堆,而是存在于 Native 堆的一系列 Block 中。如果线程退出时不释放,这些指向 Java 对象的“根”就会永远存在,导致 Java 对象无法被 GC 回收。
release_block的动作:它会将当前线程占用的JNIHandleBlock归还到全局的空闲列表(Free List)中,以便后续新创建的线程复用,避免频繁的malloc开销。- 物理动作:
- 遍历当前线程持有的所有
JNIHandleBlock。 - 将块内的所有句柄位置清零(Zap)。
- 将这些 Block 归还给全局的空闲块列表(Free List),供其他线程复用。
- 遍历当前线程持有的所有
- 意义:这确保了线程死亡后,其曾经持有的 JNI 引用不会阻碍下一次垃圾回收(GC)对相关对象的扫描。
- 物理动作:
清理机制:
- JVM 维护一个
_active_handles指针。在线程退出时,它会遍历并释放这些JNIHandleBlock。 - 重要细节:如果线程是非正常退出(如由于致命错误),JVM 会通过
JNIHandleBlock::release_block确保这些分配在 Native 堆上的句柄块被归还给全局空闲列表(Free List),防止内存泄漏。
- JVM 维护一个
深度解析:这也是为什么即便你在 JNI 中忘记调用
DeleteLocalRef,线程退出时也会作为最后一道防线进行强制清理。
2. 注销安全点(Safepoint):从 VM 活跃名单中抹除
这是确保 JVM 性能最关键的一步。一个已经“死亡”的线程绝不能出现在下一次 GC 的等待名单中。也是线程退出过程中最危险的阶段。如果一个线程已经停止运行,但仍留在 JVM 的全局线程列表(Threads List)中,当 VM Thread 发起安全点请求时,它会陷入永无止境的等待,导致著名的Safepoint Timeout。
核心函数:
Threads::remove(JavaThread* p)。在JavaThread::exit的中后期,线程会申请Threads_lock锁并调用注销逻辑:// 源码位置:hotspot/src/share/vm/runtime/thread.cppThreads::remove(this);操作流程:
- 竞争全局锁:进入
Threads::remove必须持有Threads_lock锁。 - 移出全局列表:JVM 维护着一个
ThreadsList。remove操作会将当前JavaThread对象从全局的ThreadsList链表中移除。 - 减少计数:递减
_number_of_threads计数器。
- 竞争全局锁:进入
对 Safepoint 的影响:
- 在
SafepointSynchronize::begin()中,VM 线程会遍历ThreadsList来下达暂停指令。 - 注销意义:一旦
Threads::remove完成,该线程对 VM 来说就是“不可见”的,这意味着该线程已经完成了它的“安全使命”,不再需要响应任何同步请求。GC 发生时,VM 将不再等待该线程报告其状态,避免了因为僵尸线程导致的“长停顿(Long STW)”。
- 在
3. 回收栈内存:释放物理资源
线程栈(Thread Stack)是在 start 时通过 mmap 或 pthread_create 申请的 Native 内存,通常为 1MB。栈内存的回收分为两个维度:JVM 层面的JVM 逻辑保护位的解除和OS 层面的系统物理栈的释放。
A. 解除警戒页(Guard Pages)
- 源码逻辑:调用
os::remove_stack_guard_pages()。 - 原理:在 Linux 上,JVM 通过
mprotect将栈底设为不可访问以探测溢出。退出时,必须通过系统调用撤销这些内存页的特殊权限,将其归还为普通的、可分配的内存状态。它通过mprotect(addr, size, PROT_READ|PROT_WRITE)将 Yellow/Red Zone 恢复为可读写状态。
B. 释放 C++ 管理对象
- JavaThread 析构:执行
delete thread。这会触发JavaThread及其内部OSThread的析构函数,释放分配在 C++ 堆上的元数据。
C. 物理栈的回笼(The munmap Moment)
- 关键点:由于 HotSpot 在创建线程时使用了
PTHREAD_CREATE_DETACHED标志(在os_linux.cpp的os::create_thread中设置)。 - 内核行为:
- 线程执行完
java_start并返回,最终触发pthread_exit。这意味着当pthread_exit被调用且线程函数返回时,内核会自动回收该线程的所有资源,包括它的物理栈空间。 - OS 自动回收:由于是
DETACHED状态,Linux 内核收到退出信号后,会自动执行munmap系统调用,释放该线程当初申请的那 1MB(默认值)虚拟内存。 - RSS 归还:此时,该栈对应的物理内存页(RSS)被标记为空闲,交还给操作系统内核调度。
- 线程执行完
总结:线程退出的全路径清单
| 步骤 | 执行主体 | 关键方法 (OpenJDK 8) | 物理/逻辑意义 |
|---|---|---|---|
| 1. 状态宣告 | JavaThread | set_thread_state(_thread_exiting) | 告诉 VM:我要走了,别在安全点等我。 |
| 2. Java 收尾 | JavaThread | ensure_join(this) | 唤醒所有在该线程对象上join()的守候者。 |
| 3. JNI 清理 | JavaThread | JNIHandleBlock::release_block | 释放所有局部引用,断开与 Java 堆的隐形连接。 |
| 4. 注销身份 | VM (Global) | Threads::remove(this) | 移出全局列表,彻底脱离 Safepoint 管控。 |
| 5. 内存去保护 | OS 抽象层 | os::remove_stack_guard_pages | 恢复栈底警戒页的读写权限。 |
| 6. 物理自毁 | OS 内核 | pthread_exit/munmap | 操作系统回收 1MB 栈空间及内核 Task 数据结构。 |
深度思考
我们需要关注到一个细节:JavaThread对象的销毁时间点。
在thread.cpp中,你会发现delete thread是在线程的“最后时刻”发生的。如果一个线程在执行JavaThread::exit时被外部通过SIGKILL强行终止,那么Threads::remove可能还没执行完,这会导致 JVM 在下一次进入 Safepoint 时产生极短的混乱甚至崩溃(Crash)。
因此,HotSpot 严禁在生产环境下使用kill -9针对特定 Java 线程(LWP),因为这会破坏上述精密有序的“葬礼协议”。
