Java线程在循环中执行剩余操作会阻塞所有其他线程


123

以下代码段执行两个线程,一个是每秒记录一个简单的计时器,第二个是执行余数运算的无限循环:

public class TestBlockingThread {
    private static final Logger LOGGER = LoggerFactory.getLogger(TestBlockingThread.class);

    public static final void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            int i = 0;
            while (true) {
                i++;
                if (i != 0) {
                    boolean b = 1 % i == 0;
                }
            }
        };

        new Thread(new LogTimer()).start();
        Thread.sleep(2000);
        new Thread(task).start();
    }

    public static class LogTimer implements Runnable {
        @Override
        public void run() {
            while (true) {
                long start = System.currentTimeMillis();
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    // do nothing
                }
                LOGGER.info("timeElapsed={}", System.currentTimeMillis() - start);
            }
        }
    }
}

得到以下结果:

[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1004
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1003
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=13331
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1006
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1003
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1004
[Thread-0] INFO  c.m.c.concurrent.TestBlockingThread - timeElapsed=1004

我不明白为什么无限任务会阻塞所有其他线程13.3秒。我试图更改线程优先级和其他设置,但没有任何效果。

如果您有解决此问题的任何建议(包括调整OS上下文切换设置),请告诉我。


8
@Marthin不是GC。是准时制。与-XX:+PrintCompilation我一起运行时,扩展延迟结束时,我得到以下信息:TestBlockingThread :: lambda $ 0 @ 2(24字节)跳过了编译:微小的无限循环(在不同的层重试)
Andreas

4
它在我的系统上复制,唯一的变化是我将日志调用替换为System.out.println。似乎是调度程序的问题,因为如果在Runnable的while(true)循环中引入1ms的睡眠,则另一个线程中的暂停就会消失。
JJF

3
并非我建议这样做,但是如果使用禁用 JIT -Djava.compiler=NONE,则不会发生。
Andreas

3
您可以为一种方法禁用JIT。请参阅禁用Java JIT以获取特定的方法/类?
安德烈亚斯

3
此代码中没有整数除法。请修正您的标题和问题。
洛恩侯爵,

Answers:


94

经过所有的解释(感谢Peter Lawrey),我们发现此暂停的主要来源是很少到达循环内的安全点,因此需要很长时间才能停止所有线程以进行JIT编译的代码替换。

但是我决定更深入地了解为什么很少达到安全点。我发现有些困惑,为什么while在这种情况下循环的后跳不是“安全的”。

因此,我竭尽全力-XX:+PrintAssembly寻求帮助

-XX:+UnlockDiagnosticVMOptions \
-XX:+TraceClassLoading \
-XX:+DebugNonSafepoints \
-XX:+PrintCompilation \
-XX:+PrintGCDetails \
-XX:+PrintStubCode \
-XX:+PrintAssembly \
-XX:PrintAssemblyOptions=-Mintel

经过一番调查,我发现在对Lambda C2编译器进行第三次重新编译后,它完全放弃了循环内的安全点轮询。

更新

在概要分析阶段i,从未发现变量等于0。这就是为什么通过C2推测性地优化该分支,从而将循环转换为类似

for (int i = OSR_value; i != 0; i++) {
    if (1 % i == 0) {
        uncommon_trap();
    }
}
uncommon_trap();

请注意,最初的无限循环已通过计数器重塑为规则的有限循环!由于进行了JIT优化以消除有限计数循环中的安全点轮询,因此该循环中也没有安全点轮询。

一段时间后,将其i包装回0,并使用了不常见的陷阱。该方法已取消优化,并在解释器中继续执行。在重新编译过程中获得新知识C2认识到了无限循环并放弃了编译。该方法的其余部分在解释器中使用适当的安全点进行。

有一个伟大的必读博客文章“Safepoints:含义,副作用和管理费用”Nitsan Wakart覆盖safepoints和这一具体问题。

众所周知,在很长的循环中消除安全点是一个问题。错误JDK-5014723(感谢Vladimir Ivanov)解决了此问题。

该解决方法将一直可用,直到最终修复该错误为止。

  1. 您可以尝试使用-XX:+UseCountedLoopSafepoints(这导致整体性能下降,并可能导致JVM崩溃 JDK-8161147)。使用C2编译器后,继续在后跳处保持安全点,原始暂停将完全消失。
  2. 您可以使用显式禁用有问题的方法的编译
    -XX:CompileCommand='exclude,binary/class/Name,methodName'

  3. 或者,您可以通过手动添加安全点来重写代码。例如Thread.yield(),在周期结束时拨打电话甚至更改int ilong i(感谢Nitsan Wakart)也会解决暂停问题。


7
这是对如何解决问题的真正答案。
安德烈亚斯

警告:请勿-XX:+UseCountedLoopSafepoints在生产环境中使用,因为它可能会使JVM崩溃。到目前为止,最好的解决方法是将长循环手动拆分为较短的循环。
apangin '16

@apangin aah。得到它了!谢谢:)所以这就是c2删除安全点的原因!但是我没有得到的另一件事是接下来的事情。据我所知,循环展开(?)后没有安全点了,看来没有办法做stw。所以会发生某种超时并进行去优化吗?
vsminkov

2
我之前的评论不正确。现在很清楚会发生什么。在概要分析阶段i从不为0,因此将循环推测性地转换为类似于for (int i = osr_value; i != 0; i++) { if (1 % i == 0) uncommon_trap(); } uncommon_trap();常规有限计数循环的形式。一旦i回绕到0,将捕获不常见的陷阱,对该方法进行优化,并在解释器中进行。在使用新知识进行重新编译期间,JIT会识别无限循环并放弃编译。该方法的其余部分在具有适当安全点的解释器中执行。
apangin '16

1
您可以将ia设为long而不是int,这将使循环“无计数”并解决问题。
Nitsan Wakart '16

64

简而言之,除了i == 0到达循环时,循环中没有安全点。编译此方法并触发要替换的代码时,需要将所有线程置于安全点,但这会花费很长时间,不仅锁定运行代码的线程,而且锁定JVM中的所有线程。

我添加了以下命令行选项。

-XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime -XX:+PrintCompilation

我还修改了代码以使用浮点,这似乎需要更长的时间。

boolean b = 1.0 / i == 0;

我在输出中看到的是

timeElapsed=100
Application time: 0.9560686 seconds
  41423  280 %     4       TestBlockingThread::lambda$main$0 @ -2 (27 bytes)   made not entrant
Total time for which application threads were stopped: 40.3971116 seconds, Stopping threads took: 40.3967755 seconds
Application time: 0.0000219 seconds
Total time for which application threads were stopped: 0.0005840 seconds, Stopping threads took: 0.0000383 seconds
  41424  281 %     3       TestBlockingThread::lambda$main$0 @ 2 (27 bytes)
timeElapsed=40473
  41425  282 %     4       TestBlockingThread::lambda$main$0 @ 2 (27 bytes)
  41426  281 %     3       TestBlockingThread::lambda$main$0 @ -2 (27 bytes)   made not entrant
timeElapsed=100

注意:要替换代码,必须在安全点停止线程。但是,这里似乎很少达到这样的安全点(可能仅在i == 0将任务更改为

Runnable task = () -> {
    for (int i = 1; i != 0 ; i++) {
        boolean b = 1.0 / i == 0;
    }
};

我看到了类似的延迟。

timeElapsed=100
Application time: 0.9587419 seconds
  39044  280 %     4       TestBlockingThread::lambda$main$0 @ -2 (28 bytes)   made not entrant
Total time for which application threads were stopped: 38.0227039 seconds, Stopping threads took: 38.0225761 seconds
Application time: 0.0000087 seconds
Total time for which application threads were stopped: 0.0003102 seconds, Stopping threads took: 0.0000105 seconds
timeElapsed=38100
timeElapsed=100

小心地将代码添加到循环中,会导致更长的延迟。

for (int i = 1; i != 0 ; i++) {
    boolean b = 1.0 / i / i == 0;
}

得到

 Total time for which application threads were stopped: 59.6034546 seconds, Stopping threads took: 59.6030773 seconds

但是,将代码更改为使用总是有安全点的本机方法(如果它不是内在函数)

for (int i = 1; i != 0 ; i++) {
    boolean b = Math.cos(1.0 / i) == 0;
}

版画

Total time for which application threads were stopped: 0.0001444 seconds, Stopping threads took: 0.0000615 seconds

注意:添加if (Thread.currentThread().isInterrupted()) { ... }到循环中会增加一个安全点。

注意:这是在16核计算机上发生的,因此不会缺少CPU资源。


1
所以这是一个JVM错误,对不对?其中“错误”表示实施问题的严重质量,并不违反规范。
usr

1
由于缺乏安全点,@ vsminkov能够使世界停顿几分钟,听起来像应该将其视为错误。运行时负责引入安全点,以避免长时间等待。
Voo

1
@Voo,但是另一方面,在每次回跳中保持安全点可能会花费很多cpu周期,并且会导致整个应用程序的性能显着下降。但我同意你的看法。在那种特殊情况下,保持安全点似乎合法
vsminkov

9
@Voo很好... 关于性能优化,我总是记得这张图:D
vsminkov

1
.NET确实在此处插入了安全点(但是.NET生成的代码很慢)。一种可能的解决方案是对循环进行分块。分为两个循环,使内部循环不检查1024个元素的批次,而外部循环驱动批次和安全点。从概念上将开销减少1024倍,实践中更少。
usr

26

找到了为什么的答案。它们被称为安全点,并且最著名的是由于GC而发生的Stop-The-World。

请参阅本文:在JVM中记录世界停顿

不同的事件可能导致JVM暂停所有应用程序线程。这种暂停称为世界停止(STW)暂停。触发STW暂停的最常见原因是垃圾收集(github中的示例),但是JIT操作不同(示例),偏向锁吊销(示例),某些JVMTI操作以及更多其他操作也要求停止应用程序。

可以安全地停止应用程序线程的点称为惊喜安全。此术语也经常用于指代所有STW暂停。

启用GC日志或多或少是常见的。但是,这不会捕获所有安全点上的信息。要获得全部信息,请使用以下JVM选项:

-XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime

如果您想知道显式引用GC的命名,请不要惊慌-启用这些选项会记录所有安全点,而不仅仅是垃圾收集暂停。如果您运行下面的示例(github中的源代码),并带有上面指定的标志。

阅读HotSpot术语表,它定义了以下内容:

安全点

程序执行期间的一点,所有GC根都是已知的,并且所有堆对象的内容都一致。从全局角度来看,GC可以运行之前,所有线程都必须在安全点处阻塞。(在特殊情况下,运行JNI代码的线程可以继续运行,因为它们仅使用句柄。在安全点期间,它们必须阻塞而不是加载句柄的内容。)从本地角度来看,安全点是一个显着点在代码块中,执行线程可能会为GC阻塞。大多数呼叫站点都可以用作安全点。在每个安全点都有强大的不变式,在非安全点可以忽略不计。在安全点之间优化了已编译的Java代码和C / C ++代码,但在安全点之间则没有那么优化。JIT编译器在每个安全点发出一个GC映射。VM中的C / C ++代码使用风格化的基于宏的约定(例如TRAPS)来标记潜在的安全点。

运行上面提到的标志,我得到以下输出:

Application time: 0.9668750 seconds
Total time for which application threads were stopped: 0.0000747 seconds, Stopping threads took: 0.0000291 seconds
timeElapsed=1015
Application time: 1.0148568 seconds
Total time for which application threads were stopped: 0.0000556 seconds, Stopping threads took: 0.0000168 seconds
timeElapsed=1015
timeElapsed=1014
Application time: 2.0453971 seconds
Total time for which application threads were stopped: 10.7951187 seconds, Stopping threads took: 10.7950774 seconds
timeElapsed=11732
Application time: 1.0149263 seconds
Total time for which application threads were stopped: 0.0000644 seconds, Stopping threads took: 0.0000368 seconds
timeElapsed=1015

注意第三个STW事件:
停止的总时间: 10.7951187秒
停止线程所花费的时间: 10.7950774秒

JIT本身几乎没有花费时间,但是一旦JVM决定执行JIT编译,它便进入STW模式,但是由于要编译的代码(无限循环)没有调用站点,因此从未达到安全点。

当JIT最终放弃等待并断定代码处于无限循环中时,STW结束。


“安全点-程序执行过程中所有GC根都已知且所有堆对象内容都一致的点” -为什么在仅设置/读取局部值类型变量的循环中这不是正确的?
BlueRaja-Danny Pflughoeft

@ BlueRaja-DannyPflughoeft我试图回答这个问题,我的答案
vsminkov

5

在遵循了注释线程并独自进行了一些测试之后,我相信暂停是由JIT编译器引起的。为什么JIT编译器要花费这么长时间,这超出了我的调试能力。

但是,由于您仅询问如何防止这种情况,所以我有一个解决方案:

将您的无限循环放入可以从JIT编译器中排除的方法中

public class TestBlockingThread {
    private static final Logger LOGGER = Logger.getLogger(TestBlockingThread.class.getName());

    public static final void main(String[] args) throws InterruptedException     {
        Runnable task = () -> {
            infLoop();
        };
        new Thread(new LogTimer()).start();
        Thread.sleep(2000);
        new Thread(task).start();
    }

    private static void infLoop()
    {
        int i = 0;
        while (true) {
            i++;
            if (i != 0) {
                boolean b = 1 % i == 0;
            }
        }
    }

使用以下VM参数运行程序:

-XX:CompileCommand = exclude,PACKAGE.TestBlockingThread :: infLoop(用您的软件包信息替换PACKAGE)

您应该收到一条类似这样的消息,以指示该方法何时进行JIT编译:
###不包括编译:静态
阻塞。


1
编译i == 0
器用的

@PeterLawrey但为什么循环while循环结束不是一个安全点?
vsminkov '16

@vsminkov似乎有一个安全点,if (i != 0) { ... } else { safepoint(); }但这很少见。即。如果退出/中断循环,您将获得几乎相同的时间。
彼得·劳瑞

@PeterLawrey经过一番调查后,我发现在循环回跳处设置安全点是一种常见的做法。我很好奇在这种情况下有什么区别。也许我很天真,但我看不出为什么后跳不是“安全的”
vsminkov

@vsminkov我怀疑JIT看到一个安全点在循环中,因此不要在末尾添加一个。
彼得·劳瑞
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.