Java - Thread.sleep(0)的意义是什么?
前言
最近看到一篇文章:sleep(0),是关于RocketMQ
源码中写的一段代码,说是用于预防GC
的。里面还提到了几个关键词:
- 安全点。
- 可数/不可数循环。
STW
。navtive
方法。
趁此机会,对这方面做一个复习和查缺补漏。官方的源码截图如下:
一. 安全点和可数循环
我在这篇文章里面,有做过详细的介绍深入理解Java虚拟机系列(二)–垃圾收集器与内存分配策略
这里再来复习一下:
Java
虚拟机在进行GC
的时候,有着STW
的特性。即Stop The World
,停顿所有Java
执行进程。- 既然有
STW
的机制,那么GC
是随时发起的吗?并是不,Java
虚拟机利用了一个安全点机制Safepoint
,只有程序到达安全点的时候才能够进行GC
。
两者结合就是:没有到达安全点,不能够进行GC
操作。
那么安全点是如何选定的呢?安全点的选定基本上是以程序 “是否具有让程序长时间执行的特征” 为标准进行选定。例如:
- 方法调用。
- 循环跳转。
- 指令序列复用等代码。
同时安全点的个数还需要适中:
- 如果安全点太多:导致过于频繁的
GC
以增加运行时的负荷。 - 如果安全点太少:导致
GC
等待时间过长。
那么我们再来说下可数循环这个点。
1.1 可数循环
上面提到了一个安全点的问题。安全点太多导致增加负荷,太少则导致GC等待时间过长。因此,虚拟机针对循环操作,还做了一定的优化:
- 可数循环:如果循环次数比较少的话,执行时间应该不会太长,使用
int
类型或者范围更小的数据类型作为索引值,这种是不会被放置安全点的。 - 不可数循环:反之,如果使用
long
这样范围更大的类型作为索引的循环,就叫做不可数循环。此时循环过程中就会插入安全点。
也就是说:
- 可数循环:循环如果没有结束。程序就无法走到安全点,就无法
GC
。 - 不可数循环:单次循环体结束的时候,就可以进入安全点,无需等待整个循环跑完。
因此这里可以给个小结,如果希望你的程序STW
的时间太长,对于一些耗时的循环操作,如果你的索引类型是int
,你可以将其改成long
。
除了这种方式,还有什么方法可以创建出一个安全点呢 ?
1.2 Thread.sleep
Java
源码中对于安全点有着相关的注释,在程序进入安全点的时候,Java
线程可能处于五种不同的状态:
- 处于解释执行字节码的状态中。
- 正在执行
native
修饰的代码。即JNI
调用(Java Native Interface
)。 - 处于编译代码执行中。
- 线程本身处于
blocked
状态,比如线程等待锁。 - 处于转换状态的线程。
在这里,我们关注第二点,执行native
修饰的代码。我们看下Thread.sleep
的源码:
public class Thread implements Runnable {
public static native void sleep(long millis) throws InterruptedException;
}
一个线程在运行 native
方法后,返回到 Java
线程后,必须进行一次 safepoint
的检测。
1.3 安全点测试
直接看代码:
public class SafepointTest {
public static AtomicInteger num = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Runnable runnable = () -> {
for (int i = 0; i < 100000000; i++) {
num.getAndAdd(1);
}
};
Thread t1 = new Thread(runnable, "ThreadOne");
Thread t2 = new Thread(runnable, "ThreadTwo");
t1.start();
t2.start();
long start = System.currentTimeMillis();
System.out.println("***********主线程开始睡眠1秒***********");
Thread.sleep(1000);
long end = System.currentTimeMillis();
System.out.println("***********主线程恢复,耗时:+" + (end - start) + "***********");
System.out.println("最终的计数,num= " + num);
}
}
这段代码做的事情:
- 起两个线程去对
num
变量进行累加。 - 主线程睡眠1秒钟,然后唤醒,输出此时此刻主线程拿到的
num
是多少。 - 理论上,耗时应该和1秒钟差不多。
实际上,输出结果如下:
我们观测到的结果:主线程等待两个子线程执行完毕之后才输出。中间绝对有一个阻塞的过程。为啥主线程需要等待子线程执行完毕呢?
我们给代码加一个JVM
参数:-XX:+PrintSafepointStatistics
,打印安全点的相关日志。结果如下:
大概意思就是:
JVM
想要执行no vm operation
,期间有13个线程。正在运行(initially_running
)的是2个。- 需要等待这两个线程进入安全点,等待这 2 个线程进入安全点并阻塞耗费了 2231 毫秒。(注意:不同的机器性能,这个结果也不同的)
那么我们再设置一下相关的参数,设置线程进入安全点的超时时间为2000ms
(注意要小于你实际运行的时候,真实的大小)
-XX:+SafepointTimeout -XX:SafepointTimeoutDelay=2000
再次运行程序,结果如下:
这里可以佐证:主线程正在等待ThreadOne
和ThreadTwo
两个子线程。那么,为什么主线程应该等待呢?和安全点又有什么关系?
我这里看的是CoderW大佬的文章,总结下就是:
-
我们的循环代码,里面用的是
int
类型作为下标索引,因此他是一个可数循环。只有整个循环执行完毕,才会进入安全点。 -
在
JVM
正常运行的时候,如果设置了进入安全点的间隔,就会隔一段时间判断是否有代码缓存要清理,如果有,会进入安全点。 -
也就是周期性地停止
Java
线程,去做缓存清理。这个操作则依赖于安全点。 -
而这个间隔,由参数
XX:GuaranteedSafepointInterval
控制,默认是1秒。
那么我们把GuaranteedSafepointInterval
设置为3000会怎样?添加下面参数(顺序也不能错):
-XX:+UnlockDiagnosticVMOptions -XX:GuaranteedSafepointInterval=3000
结果如下:
同理,如果我们把int i
改成long i
,也会有一样的效果:
二. 总结
首先在总结下上面的案例,对于测试代码,出现主线程等待子线程的情况和安全点有关。
- 启动了两个子线程:
ThreadOne
和ThreadTwo
。并在可数循环做一个耗时的操作。 - 此时主线程进入休眠状态1秒钟。
JVM
有个参数GuaranteedSafepointInterval
,默认是1秒,1秒之后,JVM
就会尝试在安全点停止,以便让Java
线程进行缓存的清理。- 但是对于子线程而言,由于是可数循环,因此内部没有安全点检查。必须等待循环结束。
- 那么对于主线程而言,执行了
sleep
方法,由于它是native
修饰的,因此返回到Java
线程后,必须进行一次safepoint
的检测,进入安全点。 - 主线程发现安全点操作正在进行中(得等待子线程执行完毕,此时还没有安全点),于是把自己挂起,直到操作结束。
- 那么对于我们而言,观察的结果就是:主线程等待子线程循环完毕才输出。
因此当我们GuaranteedSafepointInterval
修改为3000
秒的时候(生产环境切忌不要随意改动),由于时间间隔大于循环所耗的时间,因此不会进入安全点检查。因此主线程睡眠1秒之后,发现没有安全点,因此可以正常的走后续流程。
例如,除了上文的改参数、改int
类型,还可以这么改:
public class SafepointTest {
public static AtomicInteger num = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Runnable runnable = () -> {
for (int i = 0; i < 100000000; i++) {
if (i % 1000 == 0) {
try {
Thread.sleep(0);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
num.getAndAdd(1);
}
};
Thread t1 = new Thread(runnable, "ThreadOne");
Thread t2 = new Thread(runnable, "ThreadTwo");
t1.start();
t2.start();
long start = System.currentTimeMillis();
System.out.println("***********主线程开始睡眠1秒***********");
Thread.sleep(1000);
long end = System.currentTimeMillis();
System.out.println("***********主线程恢复,耗时:+" + (end - start) + "***********");
System.out.println("最终的计数,num= " + num);
}
}
那么结合案例,Thread.sleep
这个方法。他的作用就显而易见了:
- 你可以理解为执行了它,就相当于插入了一个安全点。
- 这样就可以避免你的程序
GC
线程长时间等待。 也就是所谓的GC削峰。