Java使用int和long的for循环

3

背景

我正在阅读一份技术文档,主要介绍在fori循环中使用int和long的区别:

场景A(使用int的fori循环)

public class SafePoint {

    public static AtomicInteger num = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Runnable runnable=()->{
            for (int i = 0; i < 1000000000; i++) {
                num.getAndAdd(1);
            }
            System.out.println(Thread.currentThread().getName()+"end!");
        };

        Thread t1 = new Thread(runnable);
        Thread t2 = new Thread(runnable);
        t1.start();
        t2.start();
        Thread.sleep(1000);
        System.out.println("num = " + num);
    }

}

期望结果:

线程1结束!
线程0结束!
num = 2000000000

场景B(长for循环)

public class SafePoint {

    public static AtomicInteger num = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Runnable runnable=()->{
            for (long i = 0; i < 1000000000; i++) {
                num.getAndAdd(1);
            }
            System.out.println(Thread.currentThread().getName()+"end!");
        };

        Thread t1 = new Thread(runnable);
        Thread t2 = new Thread(runnable);
        t1.start();
        t2.start();
        Thread.sleep(1000);
        System.out.println("num = " + num);
    }

}

期望结果:
num = 55406251(或小于2000000000的某个随机数)
Thread-1结束!
Thread-0结束!

他提出的最重要的概念是安全点: 由于未计算循环而在long fori循环中有安全点,但int fori循环中没有安全点。因此,在int fori循环中,睡眠需要等待两个线程完成,然后进行GC,因为当线程仍在int fori循环中时没有安全点。

问题:

我采纳了他的想法并尝试在本地机器上再现它们,但失败了: 基本上无论我使用int还是long,结果都与第二个类似。num首先被打印出来。

经过仔细思考后,只能归结于我使用的JVM:java 11 corretto。

根据技术文档的想法,这基本上意味着在Java 11中,安全点存在于计算和未计算的循环中

相关链接:

技术文档试图解释和如何在fori循环中进行GC:为什么Thread.sleep(0)可以防止火箭MQ中的gc?


实际上这可能是JVM供应商之间的差异,例如Amazon的Correcto可能没有像这样的安全点。 - Thomas
@Thomas 那么,在RabbitMQ中使用这种GC方式实际上并不明智,因为它会因供应商而异:https://dev59.com/16_la4cB1Zd3GeqPyMix - Stan
@Thomas 不是的。问题不在安全点上,而是在垃圾回收实现上。 - rzwitserloot
1
原因是JDK 10+中实现的Loop Strip Mining技术。它允许JIT编译器将一个大的计数循环分解成两个逻辑嵌套的循环,其中内部循环没有安全点轮询指令,而外部循环则有。这里有一个很好的描述该技术的文章。 - apangin
2个回答

4
这个答案中解释了println延迟的原因。简单来说,HotSpot JIT在计数循环内部消除了安全点轮询,因此JVM无法在循环运行时达到安全点。
为了从循环中消除安全点轮询,必须满足两个条件:
1. 循环是计数的,即由整数计数器变量确保有限次迭代;和 2. 禁用-XX:UseCountedLoopSafepoints选项。
这些条件取决于JVM版本和命令行标志。
JDK 8中,使用int变量的for循环被认为是计数的,而使用long变量的循环则不是。除非通过-XX:+UseCountedLoopSafepoints显式启用,否则UseCountedLoopSafepoints始终处于关闭状态。
JDK 10以来,JIT编译器获得了Loop Strip Mining功能。这种优化大大减少了紧密循环中安全点轮询的开销,因此-XX:+UseCountedLoopSafepoints默认情况下启用1。这就是为什么在JDK 11中不会观察到延迟的原因。
1除非用户选择并行或串行GC。由于这些垃圾收集器不重视低暂停时间,JVM更喜欢吞吐量,因此不启用UseCountedLoopSafepoints。它仍然可以通过-XX:+UseCountedLoopSafepoints手动启用。

这看起来是正确的。您能否详细说明一下注释1:并行或串行GC在这里有什么影响吗?除非用户选择并行或串行GC。由于这些垃圾收集器不专注于低暂停时间,JVM更倾向于吞吐量,因此不启用UseCountedLoopSafepoints。但可以通过-XX:+UseCountedLoopSafepoints手动启用。 - Stan
1
@StanPeng GC选择仅影响“UseCountedLoopSafepoints”和“LoopStripMiningIter”选项的默认值。 [G1],[Shenandoah],ZGC和Epsilon GC将“UseCountedLoopSafepoints”的默认值更改为“true”,而Parallel和Serial GC则不会更改。 - apangin

-1

答案:这是因为JDK8默认使用并行GC,而JDK9+则使用G1 GC,这就解释了一切。

证明

在一台装有Eclipse Temurin OpenJDK的arm芯片Mac上,无论是版本1.8还是1.17,您都会得到以下行为:

1.17 1.8
int ~50m 2000m*
long ~50m ~50m

*) 这个回答出现了半分钟以上,解释是两个线程没有在一秒的时间内完成十亿次循环。

换句话说,正如您所描述的。鉴于这是temurin,问题不在于“amazon coretto”本身。

然而,如果我在1.17上运行int变量版本,并且使用以下方式:

/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home/bin/java -XX:+UseParallelGC SafePoint

它打印出2000m,就像temurin-8一样(并且在大约半分钟的时间内一直在运行)。

因此,这完全解释了差异:

  • 如果您使用int并使用Parallel GC运行此代码,则最终会得到2000m(需要一段时间,线程完成后才会发生打印代码)。
  • 否则(您使用G1 GC或使用long),您将获得约50m;“打印数字”代码在线程完成之前运行。

如果您没有明确选择要使用哪个垃圾收集实现,则在JDK8之前,默认使用并行GC,在JDK9开始时,您将获得G1收集器(也许在最近版本中是zgc或其他什么。无论如何,都不是并行收集器)。

因此,这显然与JVM注入安全点的方式无关,您可以通过要求JVM为您打印生成的机器代码(您可以在网络上搜索许多博客文章,精确说明如何执行此操作以及要查找的内容)来检查此问题 - 您将发现JDK17在这方面与JDK8做类似的事情。

不是的,相反,Parallel GC 在每个线程中命中安全点时会阻塞,而 G1 收集器则不会,这是一个更简单的解释。

有许多原因导致默认的 GC 实现已从并行收集器更改,这可能是其中之一。


1
并不是这样的。一个简单的测试表明,你的“证明”并没有证明任何事情,因为带有-XX:+UseG1GC的JDK 8仍然会挂起2秒钟。实际原因与GC无关。新的JDK版本确实在安全点轮询处理方面有所改进:特别是JDK 10中实现的Loop Strip Mining和JDK 16中的Long-indexed Counted Loops - apangin
@apangin 在JDK17上使用+UseParallelGC运行,这表明您在这里是错误的。 - rzwitserloot
当选择并行GC时,JVM默认情况下不会启用循环条带挖掘(符合人体工程学),但可以显式启用。 GC算法本身对JIT安全点指令没有影响。您在最后三段的解释完全错误:1)JVM注入安全点轮询的方式(而不是“安全点”)肯定已经随着[JDK-8186027]和[JDK-8223051]的更改而改变。 - apangin
不是这样,相反,Parallel GC会在每个线程命中safe point时阻塞,而G1收集器不会,这是一个简单得多的解释。 默认GC实现被改为非parallel collector的原因有很多,这可能是其中之一。您的意思是问题实际上是由Parallel GC引起的吗?您能否进一步解释一下Parallel GC如何导致主线程在睡眠后等待?答案可能不正确。但我仍然希望听到更多关于这个GC实现的想法,谢谢。 - Stan
我认为@apangin误解了答案,特别是他们的第二点。实际上是这样的:“你的观点毫无意义,因为{重复相同的观点}”,不确定该怎么理解,除了我可能应该更清楚地写出这个答案。 - rzwitserloot
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接