从源码出发,探究Thread.sleep(0)的另类用法
思考,输出,沉淀。用通俗的语言陈述技术,让自己和他人都有所收获。 作者:毅航😜
Thread.sleep
是Java
中一个简单而常用的方法,其主要用于控制线程的暂停和执行。在多线程
编程中,合理使用 Thread.sleep
可以有效地管理线程资源,提高系统的并发性能。
在日常编程中,你调用sleep()
方法时的初衷大抵是期待程序可以停顿休眠指定时间,以等待其他程序执行完毕。但你可曾在你的代码中可曾写过sleep(0)
这样的骚
代码?没用过也别着急,今天我们便以rocketmq
源码中sleep(0)
为例深入分析sleep(0)
在gc
方面的作用。
重识Thread.sleep
众所周知,sleep
方法是Thhead
的内部方法,其在Thhead
中有如下两种形式的重载
:
public static native void sleep(long millis) throws InterruptedException;
public static void sleep(long millis, int nanos) throws InterruptedException
而其中sleep(long millis, int nanos)
的重载
版本会调用sleep
的native
从而实现对线程的控制。
而在 Java
中,native
方法通常不是由 Java
语言编写的,而是使用其他编程语言(如 C
或 C++
)编写的,并通过 Java Native Interface (JNI)
与 Java
程序进行交互。native
方法通常用于访问底层操作系统功能或已有的非 Java
库,这些功能可能无法通过纯Java
代码实现。
回到一开始的sleep
方法,知晓了sleep
的实现后,下面便通过一个简单的例子来对sleep
的使用进行简单介绍:
public class SleepExample {
public static void main(String[] args) {
Runnable task = () -> {
try {
System.out.println("Task started");
Thread.sleep(2000); // 休眠2秒
System.out.println("Task finished after 2 seconds");
} catch (InterruptedException e) {
System.out.println("Task was interrupted");
}
};
Thread thread = new Thread(task);
thread.start();
}
}
在上述代码中,控制台会首先输出Task started
停顿2秒
后继续执行并输出Task was interrupted
。事实上,当调用 Thread.sleep
方法时,当前线程会进入阻塞
状态(TIMED_WAITING
),直到指定的时间过去。操作系统的调度器会选择其他线程来运行。一旦睡眠时间到期,线程会被重新调度执行。线程间状态的流转可具体参考下图所示的程状态图
:
源码中sleept(0)
的骚操作
熟悉了Java
中的sleep
方法后,接下来我们便来看看在源码中sleep
是如何被玩坏的。如下代码出自rocketmq
org.apache.rocketmq.store.logfile.DefaultMappedFile
public void warmMappedFile(FlushDiskType type, int pages) {
// .....省略无关代码
for (long i = 0, j = 0; i < this.fileSize; i += DefaultMappedFile.OS_PAGE_SIZE, j++) {
byteBuffer.put((int) i, (byte) 0);
// force flush when flush disk type is sync
if (type == FlushDiskType.SYNC_FLUSH) {
if ((i / OS_PAGE_SIZE) - (flush / OS_PAGE_SIZE) >= pages) {
flush = i;
mappedByteBuffer.force();
}
}
// prevent gc
// if (j % 1000 == 0) {
// log.info("j={}, costTime={}", j, System.currentTimeMillis() - time);
// time = System.currentTimeMillis();
// try {
// Thread.sleep(0);
// } catch (InterruptedException e) {
// log.error("Interrupted", e);
// }
// }
}
// .....省略无关代码
}
可以看到代码中的18-26
行虽然现在已经被注释。但如果你拉取rocketmq
源码,你会发现因为这个类在第一次提交的时候就已经包含了sleep(0)
这个逻辑。 看到此,相信你一定会对此处Thread.sleep(0)
的写法感到好奇,迫切想知道其究竟何意。
接下来,不妨跟着笔者
的思路我们一起来看一看此处sleep(0)
的骚操作
到底何意。
最开始,笔者
本想从git
提交记录中发现一些端倪,但遗憾的是:"该类在提交时提交的代码也非常多,因此git
提交记录上是看不出任何东西的。所以只能从仅有的prevent gc
入手来分析"。
其实这里的prevent
具有误导性,prevent
通常被译为阻止、阻挠、防止
之意。按着这个意思来理解那此处的逻辑似乎就是阻止GC
之意。如果这样理解其实也就陷入掉入注释的一个陷阱
。为什么这样说呢?这里我们先简单介绍下Java
中gc
的机制。
在Java
中垃圾回收(gc
)线程的优先级和垃圾回收机制是决定 Java
应用程序性能和资源管理的重要因素。通常Java
虚拟机中的垃圾回收线程的优先级通常较高,但具体优先级会因 JVM
实现和配置不同而有所差异。一般来说,gc
线程的优先级高于普通应用线程,以确保垃圾回收能够及时进行,从而避免内存耗尽的情况。
你可能会疑惑:“如果gc
线程具有较高优先级,那很有可能出现gc
释放垃圾由于其他程序执行线程的情况,进而导致线程
关键资源被释放从而出现程序崩溃的问题。”
事实上,虽然垃圾回收gc
线程通常具有较高优先级,以确保及时进行内存回收,但 JVM
的设计和垃圾回收机制已经考虑到应用程序的稳定性和资源管理,防止出现由于gc
线程的高优先级导致关键资源被错误释放的情况。
因此gc
线程的高优先级主要是为了确保垃圾回收过程及时进行,防止内存耗尽等问题。垃圾回收机制严格遵循引用关系进行对象的标记和清除,不会随意释放关键资源。具体来看包括如下手段:
- 引用计数: 在标记阶段,
gc
会遍历对象引用关系,标记所有可达的对象。只有那些不可达的对象才会被认为是垃圾,进入清除阶段。关键资源(如正在使用的文件句柄、网络连接等)由于仍被应用程序引用,不会被标记为垃圾。 - 安全点和暂停: 在进行某些垃圾回收操作(如标记和整理)时,
gc
线程可能会暂停应用线程(Stop-the-World)
。这种暂停是协调和安全的,不会导致应用程序错误地释放资源。应用线程只有在安全点(Safe Point)
上被暂停,而这些安全点是JVM
内部精心选择的代码位置,确保线程被暂停时处于安全状态。 - 同步和锁: 在多线程环境中,资源的使用通常由锁(
Lock
)或其他同步机制保护。即使gc
线程优先级较高,锁机制会确保资源的正确使用和管理,避免因线程切换导致的资源错误释放。
简单来看,如果要想让gc
线程执行,其实需要满足两点,一个是其他线程让出cpu
调度权,另一个则是到达安全点
。
了解了 Java
中的gc
机制后,我们在回过头来看rocketmq
代码,这里其实我们需要对比着来看。 warmMappedFile
最开始的代码为:
对比我们之前贴出的warmMappedFile
方法,不难发现如今的版本在循环中使用了long
类型而非原先的int
类型。这里其实又涉及到一个《深入理解JVM虚拟机》也曾提及的知识点:
HotSpot
虚拟机为了避免安全点过多带来过重的负担,对循环还有一项优化措施,认为循环次数较少的话,执行时间应该也不会太长,所以使用int
类型或范围更小的数据类型作为索引值的循环默认是不会被放置安全点的。这种循环被称为可数循环(Counted Loop)
,相对应地,使用long
或者范围更大的数据类型作为索引值的循环就被称为不可数循环(Uncounted Loop)
,将会被放置安全点。
想必看到此,你大概已经猜出来其实rocketmq
中一开始使用Thread.sleep(0)
的初衷其实并不是如注释所写的那样阻止gc
。而是为了保证每1000
次迭代就有一次运行 gc
。换言之,此处Thread.sleep(0)
就是为了能促进JVM
中垃圾回收机制的正常运行。
看到此可能你还是会有疑惑。那为什么sleep
便能促进JVM
执行gc
呢?不知你还曾记得笔者
之前提到的 如果要想让gc
线程执行,其实需要满足两点。一个是其他线程让出cpu
调度权,另一个则是到达安全点
。 sleep
调用让出cpu
调度权这个是众所周知的,如果sleep
调用还能确保程序运行到所谓的安全点
,那么调用sleep
便能促进gc
的执行。
因此,此刻我们其实已经把问题转变为探究sleep
调用到底能否确保程序进入安全点
。而在Java
中安全点
主要用于在某些全局操作(如垃圾回收)期间安全地暂停所有应用线程。线程只能在到达安全点时被暂停,以确保线程在暂停时处于一致和安全的状态。因此安全点的实现通常依赖于以下机制:
- 字节码插桩:JVM 在特定的字节码指令(如方法调用、循环跳转等)处插入安全点检查代码。
- 同步操作:在进入和退出同步块时,线程会检查是否需要进入安全点。
- 本地方法调用:调用本地方法时,JVM 也可能将其视为安全点。
具体来看,native
方法是通过 JNI
调用的非 Java
方法,这些方法通常用 C/C++
实现。调用 native
方法时,JVM
会做一些处理,以确保线程在进入和退出 native
方法时处于一致状态。这可能包括进入安全点。总的来看:
在调用 native
方法之前,JVM 会进行一些必要的操作,以确保线程在进入 native
方法之前达到一个一致的状态。这些操作可能包括:
- 刷新线程的本地变量到堆栈。
- 更新线程的执行状态。
这些操作确保当线程进入 native
方法时,JVM
其他组件(如 gc
)可以安全地知道线程的状态。进一步, native
方法本身并不直接与 JVM
的安全点机制交互。然而,如果 native
方法通过 JNI
调用返回 Java
代码,或者通过 JNI
调用 JVM
提供的某些服务(如对象分配、锁操作等),这些调用可能会间接触发安全点检查。例如:
JNIEXPORT void JNICALL Java_NativeExample_nativeMethod(JNIEnv *env, jobject obj) {
// 本地代码逻辑
jclass cls = (*env)->FindClass(env, "java/lang/String"); // 可能触发安全点检查
// 其他本地代码逻辑
}
在上面的示例中,调用 FindClass
等 JNI
函数时,JVM
可能会进行安全点检查。
除此之外,当 native
方法返回到 Java
代码时,JVM
需要确保线程在返回到 Java
代码之前再次处于一致状态。这包括:
- 恢复线程的本地变量和堆栈。
- 更新线程的执行状态。
这些操作确保当线程离开 native
方法时,可以安全地继续执行 Java
代码,或者在必要时进入安全点。
总的来看,调用 native
方法可以间接进入安全点。 虽然 native
方法本身不直接与安全点机制交互,但在进入和离开 native
方法时,JVM
会进行必要的操作以确保线程处于一致和安全的状态。这些操作可以确保 JVM
在需要进行全局操作(如垃圾回收)时,能够安全地暂停和恢复线程。
回到本文,正如一开始我们介绍的那样。对于Thread
的sleep
方法而言其在调用时最终会调用sleep
的native
方法。因此调用Thread.sleep
的调用能保证程序进入到的安全点,与此同时sleep
方法调用线程进入阻塞
状态,从而可让出cpu
,从而使得gc
线程有机会抢占调度权,执行垃圾清理工作。这便是rocketmq
源码中sleep(0)
全部的秘密。
总结
执行 Thread.sleep(0)
看似让休眠了0
秒,但是执行 sleep
方法后,不仅会休眠,还会让CPU
重新分配。因此Thread.Sleep(0)
的作用可以看做是:程序主动触发操作系统立刻重新进行一次CPU
竞争的操作。竞争的结果也许是当前线程仍然获得CPU控制权,也许会换成别的线程获得CPU控制权。这就是为什么rocketmq
源码中在一开始版本中在warmMappedFile
方法内部大循环里面经常会写一句Thread.Sleep(0)
的原因。因为这样就给了其他线程获得CPU控制权的权力,这样些功能就不会假死在那里。
不难看出一句简单的Thread.Sleep(0)
其背后不止蕴含了Java
的相关知识,还更蕴涵了JVM
,操作系统
等相关知识,看似一句简单的Thread.Sleep(0)
其实并不简单!
(ps:当然如果你对技术有其他更深刻的见地,欢迎加我💚: Yi_hang0830 进行相关技术的讨论、交流😎。)
转载自:https://juejin.cn/post/7377925587039436841