为什么两个单独的循环比一个循环更快?

23

我想了解Java对连续for循环进行何种优化。更具体地说,我正在尝试检查是否执行了循环合并优化。

理论上,我原本期望这种优化不会自动执行,并期望确认合并版本是否比具有两个循环的版本更快。

然而,在运行基准测试后,结果显示两个分开的(且连续的)循环比一个完成所有工作的单个循环更快。

我已经尝试使用JMH创建基准测试,并得到了相同的结果。

我使用了javap命令,它显示具有两个循环的源文件生成的字节码实际上对应于执行两个循环(没有展开循环或其他优化)。

被测量代码为BenchmarkMultipleLoops.java

private void work() {
        List<Capsule> intermediate = new ArrayList<>();
        List<String> res = new ArrayList<>();
        int totalLength = 0;

        for (Capsule c : caps) {
            if(c.getNumber() > 100000000){
                intermediate.add(c);
            }
        }

        for (Capsule c : intermediate) {
            String s = "new_word" + c.getNumber();
            res.add(s);
        }

        //Loop to assure the end result (res) is used for something
        for(String s : res){
            totalLength += s.length();
        }

        System.out.println(totalLength);
    }
< p > 对于< code >BenchmarkSingleLoop.java < /code > 进行测量的代码:< /p >
private void work(){
        List<String> res = new ArrayList<>();
        int totalLength = 0;

        for (Capsule c : caps) {
            if(c.getNumber() > 100000000){
                String s = "new_word" + c.getNumber();
                res.add(s);
            }
        }

        //Loop to assure the end result (res) is used for something
        for(String s : res){
            totalLength += s.length();
        }

        System.out.println(totalLength);
    }

下面是Capsule.java的代码:

public class Capsule {
    private int number;
    private String word;

    public Capsule(int number, String word) {
        this.number = number;
        this.word = word;
    }

    public int getNumber() {
        return number;
    }

    @Override
    public String toString() {
        return "{" + number +
                ", " + word + '}';
    }
}

caps是一个有2000万个元素的ArrayList<Capsule>,在初始时这些元素被填充成以下形式:

private void populate() {
        Random r = new Random(3);

        for(int n = 0; n < POPSIZE; n++){
            int randomN = r.nextInt();
            Capsule c = new Capsule(randomN, "word" + randomN);
            caps.add(c);
        }
    }

测量之前,执行热身阶段。

我针对每个基准测试运行了10次,或者换句话说,每个基准测试都会执行work()方法10次,并呈现完成的平均时间(以秒为单位)。在每次迭代之后,GC将与几个休眠同时执行:

  • MultipleLoops:4.9661秒
  • SingleLoop:7.2725秒

运行在一个Intel i7-7500U(Kaby Lake)上的OpenJDK 1.8.0_144。

为什么多循环版本比单循环版本更快,即使它必须遍历两个不同的数据结构?

更新1:

如评论中所建议的,如果我改变实现来计算字符串生成时的totalLength,避免创建res列表,则单循环版本变得更快。

但是,那个变量仅仅是为了在创建结果列表后执行一些工作,以避免在没有对其进行任何处理的情况下丢弃元素。

换句话说,目的是生成最终列表。但这个建议有助于更好地理解正在发生的事情。

结果:

  • MultipleLoops:0.9339秒
  • SingleLoop:0.66590005秒

更新2:

这是我用于JMH基准测试的代码链接:https://gist.github.com/FranciscoRibeiro/2d3928761f76e4f7cecfcfcdf7fc96d5

结果:

  • MultipleLoops:7.397秒
  • SingleLoop:8.092秒


2
我猜这是因为分支预测。https://dev59.com/iWgu5IYBdhLWcg3wkH6P#11227902 - Johannes Kuhn
是的,您可以查看JIT编译器生成的汇编代码。请参考以下链接:https://dev59.com/53I_5IYBdhLWcg3wJfkM 和 / 或 https://dev59.com/YIzda4cB1Zd3GeqPiSPK#48599683。 - Peter Cordes
4
刚试图重现你的结果,但没有成功。对我而言,两个循环大约具有相同的性能,请参见 https://gist.github.com/l-wi/20a923af1ee707e885e087e136af0bfe。你(或我)的基准代码可能有缺陷,是否可能呢? - lwi
6
请提供一个MCVE,其中MCVE的重点是“完整”的C代码示例。您说:“我已经尝试使用JMH创建基准测试,并获得了相同的结果。”那么您应该用基于JMH的版本替换问题中的代码以及相应的结果。目前有人说他们无法复现您的结果,但我们/他们不知道他们是否正在运行与您相同的基准测试。 - Stephen C
1
先尝试进行SingleLoops测试。可能是您首先运行的测试在您运行第二个测试期间创建了GC工作。 - Matt Timmermans
显示剩余12条评论
2个回答

2
我调查了这个“现象”,似乎得到了一个答案。
让我们将.jvmArgs("-verbose:gc")添加到JMH的OptionsBuilder中。1次迭代的结果如下:

单循环:[Full GC(Ergonomics)[PSYoungGen:2097664K->0K(2446848K)][ParOldGen:3899819K->4574771K(5592576K)] 5997483K->4574771K(8039424K),[Metaspace:6208K->6208K(1056768K)],5.0438301秒][Times:user=37.92 sys=0.10,real=5.05秒]
4.954 s/op

多循环:[Full GC(Ergonomics)[PSYoungGen:2097664K->0K(2446848K)][ParOldGen:3899819K->4490913K(5592576K)] 5997483K->4490913K(8039424K),[Metaspace:6208K->6208K(1056768K)],3.7991573秒][Times:user=26.84 sys=0.08,real=3.80秒]
4.187 s/op

JVM在GC方面花费了大量CPU时间。每2次测试运行一次,JVM必须执行Full GC(将600Mb移动到OldGen并收集前几个周期的1.5Gb垃圾)。两种垃圾收集器做了相同的工作,但多循环测试用例的应用程序时间减少了约25%。如果将POPSIZE减小到10_000_000或在bh.consume()之前添加Thread.sleep(3000),或者将-XX:+UseG1GC添加到JVM参数中,则多次循环提速效果消失。我再次运行.addProfiler(GCProfiler.class)。主要区别如下:

多循环:gc.churn.PS_Eden_Space 374.417 ± 23 MB/sec

单循环:gc.churn.PS_Eden_Space 336.037 MB/sec ± 19 MB/sec

我认为,在这种特定情况下,我们看到了加速,因为老式的Compare and Swap GC算法具有在多个测试运行中使用额外“无意义”周期的CPU瓶颈,以从早期运行中回收垃圾。如果您有足够的RAM,则可以通过@Threads(2)更容易地重现它。如果您尝试对Single_Loop测试进行分析,则会看到这种情况:

profiling


在发布问题后的一段时间里,我进行了一些使用不同GC算法的实验:Serial GC、Parallel GC和G1GC。Parallel GC似乎对SingleLoop的性能影响最大。使用G1GC或Serial GC可以缩短Multiple和Single之间的差距,但我所能达到的最好效果是使SingleLoop的表现几乎与MultipleLoops相同。你的实验似乎指向了同样的方向,即GC是“罪魁祸首”。谢谢! - Francisco Ribeiro
也许我没有表达清楚。我们主要通过比较和交换垃圾回收(同时也是并行的)来提高性能,这会欺骗JMH使用多个CPU核心,即使您明确设置@Thread = 1。感谢您的有趣问题,祝您好运;) - Anton Kot

1
为了了解底层发生的情况,您可以添加JMX行为以分析运行中的应用程序,位于JAVA_HOME\bin中的jvisualvm中。 在内存中有20M大小的胶囊列表时,它会耗尽内存,visualvm进入非响应状态。我将胶囊列表大小减小到200k和100M至1M以进行测试。观察visualvm上的行为后,单个循环执行完成比多个循环更快。也许这不是正确的方法,但您可以进行实验。

LoopBean.java

import java.util.List;
public interface LoopMBean {
    void multipleLoops();
    void singleLoop();
    void printResourcesStats();
}

Loop.java

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class Loop implements LoopMBean {

    private final List<Capsule> capsules = new ArrayList<>();

    {
        Random r = new Random(3);
        for (int n = 0; n < 20000000; n++) {
            int randomN = r.nextInt();
            capsules.add(new Capsule(randomN, "word" + randomN));
        }
    }

    @Override
    public void multipleLoops() {

        System.out.println("----------------------Before multiple loops execution---------------------------");
        printResourcesStats();

        final List<Capsule> intermediate = new ArrayList<>();
        final List<String> res = new ArrayList<>();
        int totalLength = 0;

        final long start = System.currentTimeMillis();

        for (Capsule c : capsules)
            if (c.getNumber() > 100000000) {
                intermediate.add(c);
            }

        for (Capsule c : intermediate) {
            String s = "new_word" + c.getNumber();
            res.add(s);
        }

        for (String s : res)
            totalLength += s.length();

        System.out.println("multiple loops=" + totalLength + " | time taken=" + (System.currentTimeMillis() - start) + " milliseconds");

        System.out.println("----------------------After multiple loops execution---------------------------");
        printResourcesStats();

        res.clear();
    }

    @Override
    public void singleLoop() {

        System.out.println("----------------------Before single loop execution---------------------------");
        printResourcesStats();

        final List<String> res = new ArrayList<>();
        int totalLength = 0;

        final long start = System.currentTimeMillis();

        for (Capsule c : capsules)
            if (c.getNumber() > 100000000) {
                String s = "new_word" + c.getNumber();
                res.add(s);
            }

        for (String s : res)
            totalLength += s.length();

        System.out.println("Single loop=" + totalLength + " | time taken=" + (System.currentTimeMillis() - start) + " milliseconds");
        System.out.println("----------------------After single loop execution---------------------------");
        printResourcesStats();

        res.clear();
    }

    @Override
    public void printResourcesStats() {
        System.out.println("Max Memory= " + Runtime.getRuntime().maxMemory());
        System.out.println("Available Processors= " + Runtime.getRuntime().availableProcessors());
        System.out.println("Total Memory= " + Runtime.getRuntime().totalMemory());
        System.out.println("Free Memory= " + Runtime.getRuntime().freeMemory());
    }
}

LoopClient.java

import javax.management.MBeanServer;
import javax.management.ObjectName;
import java.lang.management.ManagementFactory;

public class LoopClient {

    void init() {

        final MBeanServer mBeanServer = ManagementFactory.getPlatformMBeanServer();
        try {
            mBeanServer.registerMBean(new Loop(), new ObjectName("LOOP:name=LoopBean"));
        } catch (Exception e) {
            e.printStackTrace();
        }

    }

    public static void main(String[] args) {

        final LoopClient client = new LoopClient();
        client.init();
        System.out.println("Loop client is running...");
        waitForEnterPressed();
    }

    private static void waitForEnterPressed() {
        try {
            System.out.println("Press  to continue...");
            System.in.read();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

执行以下命令:

java -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9999 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false LoopClient

你可以添加-Xmx3072M额外选项来快速增加内存,以避免OutOfMemoryError错误。

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