likes
comments
collection
share

从源码出发,探究Thread.sleep(0)的另类用法

作者站长头像
站长
· 阅读数 30

思考,输出,沉淀。用通俗的语言陈述技术,让自己和他人都有所收获。 作者:毅航😜


Thread.sleepJava中一个简单而常用的方法,其主要用于控制线程的暂停和执行。在多线程编程中,合理使用 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)重载版本会调用sleepnative从而实现对线程的控制。

而在 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),直到指定的时间过去。操作系统的调度器会选择其他线程来运行。一旦睡眠时间到期,线程会被重新调度执行。线程间状态的流转可具体参考下图所示的程状态图

从源码出发,探究Thread.sleep(0)的另类用法

源码中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线程的高优先级主要是为了确保垃圾回收过程及时进行,防止内存耗尽等问题。垃圾回收机制严格遵循引用关系进行对象的标记和清除,不会随意释放关键资源。具体来看包括如下手段:

  1. 引用计数: 在标记阶段,gc会遍历对象引用关系,标记所有可达的对象。只有那些不可达的对象才会被认为是垃圾,进入清除阶段。关键资源(如正在使用的文件句柄、网络连接等)由于仍被应用程序引用,不会被标记为垃圾。
  2. 安全点和暂停: 在进行某些垃圾回收操作(如标记和整理)时,gc 线程可能会暂停应用线程(Stop-the-World)。这种暂停是协调和安全的,不会导致应用程序错误地释放资源。应用线程只有在安全点(Safe Point)上被暂停,而这些安全点是 JVM内部精心选择的代码位置,确保线程被暂停时处于安全状态。
  3. 同步和锁: 在多线程环境中,资源的使用通常由锁(Lock)或其他同步机制保护。即使 gc 线程优先级较高,锁机制会确保资源的正确使用和管理,避免因线程切换导致的资源错误释放。

简单来看,如果要想让gc线程执行,其实需要满足两点,一个是其他线程让出cpu调度权,另一个则是到达安全点

了解了 Java 中的gc机制后,我们在回过头来看rocketmq代码,这里其实我们需要对比着来看。 warmMappedFile最开始的代码为:

从源码出发,探究Thread.sleep(0)的另类用法

对比我们之前贴出的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安全点主要用于在某些全局操作(如垃圾回收)期间安全地暂停所有应用线程。线程只能在到达安全点时被暂停,以确保线程在暂停时处于一致和安全的状态。因此安全点的实现通常依赖于以下机制:

  1. 字节码插桩:JVM 在特定的字节码指令(如方法调用、循环跳转等)处插入安全点检查代码。
  2. 同步操作:在进入和退出同步块时,线程会检查是否需要进入安全点。
  3. 本地方法调用:调用本地方法时,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"); // 可能触发安全点检查
    // 其他本地代码逻辑
}

在上面的示例中,调用 FindClassJNI 函数时,JVM 可能会进行安全点检查。

除此之外,当 native 方法返回到 Java 代码时,JVM 需要确保线程在返回到 Java 代码之前再次处于一致状态。这包括:

  • 恢复线程的本地变量和堆栈。
  • 更新线程的执行状态。

这些操作确保当线程离开 native 方法时,可以安全地继续执行 Java 代码,或者在必要时进入安全点。

总的来看,调用 native 方法可以间接进入安全点。 虽然 native 方法本身不直接与安全点机制交互,但在进入和离开 native 方法时,JVM 会进行必要的操作以确保线程处于一致和安全的状态。这些操作可以确保 JVM 在需要进行全局操作(如垃圾回收)时,能够安全地暂停和恢复线程。

回到本文,正如一开始我们介绍的那样。对于Threadsleep方法而言其在调用时最终会调用sleepnative方法。因此调用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
评论
请登录