为什么微不足道的、看似无关的代码更改对 Java 代码的性能影响如此之大,而在基准测试中却如此?

Fra*_*ani 0 optimization performance jvm microbenchmark jmh

我多次注意到,微小的、看似无关的代码更改可以改变一段 Java 代码的性能特征,有时甚至是戏剧性的。

这发生在 JMH 和手动基准测试中。

例如,在这样的类中:

class Class<T> implements Interface {
    private final Type field;

    Class(ClassBuilder builder) {
        field = builder.getField();
    }

    @Override
    void method() { /* ... */ }
}
Run Code Online (Sandbox Code Playgroud)

我做了这个代码更改:

class Class<T> implements Interface {
    private static Class<?> instance;

    private final Type field;

    Class(Builder builder) {
        instance = this;
        field = builder.getField();
    }

    @Override
    void method() { /* ... */ }
}
Run Code Online (Sandbox Code Playgroud)

和性能发生了巨大变化。

这只是一个例子。在其他情况下,我注意到了同样的事情。

我无法确定是什么原因造成的。我在网上搜索,但没有找到任何信息。

对我来说,它看起来完全无法控制。也许这与编译后的代码在内存中的布局有关?

我不认为这是由于错误共享(见下文)。


我正在开发一个自旋锁:

class SpinLock {
    @Contended // Add compiler option: --add-exports java.base/jdk.internal.vm.annotation=<module-name> (if project is not modular, <module-name> is 'ALL-UNNAMED')
    private final AtomicBoolean state = new AtomicBoolean();

    void lock() {
        while (state.getAcquireAndSetPlain(true)) {
            while (state.getPlain()) { // With x86 PAUSE we avoid opaque load
                Thread.onSpinWait();
            }
        }
    }

    void unlock() {
        state.setRelease(false);
    }
}

class AtomicBoolean {
    private static final VarHandle VALUE;

    static {
        try {
            VALUE = MethodHandles.lookup().findVarHandle(AtomicBoolean.class, "value", boolean.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    private boolean value;

    public boolean getPlain() {
        return value;
    }

    public boolean getAcquireAndSetPlain(boolean value) {
        return (boolean) VALUE.getAndSetAcquire(this, value);
    }

    public void setRelease(boolean value) {
        VALUE.setRelease(this, value);
    }
}
Run Code Online (Sandbox Code Playgroud)

我的手卷基准报告171.26ns ± 43%和 JMH 基准报告avgt 5 265.970 ± 27.712 ns/op
当我像这样改变它时:

class SpinLock {
    @Contended
    private final AtomicBoolean state = new AtomicBoolean();
    private final NoopBusyWaitStrategy busyWaitStrategy;

    SpinLock() {
        this(new NoopBusyWaitStrategy());
    }

    SpinLock(NoopBusyWaitStrategy busyWaitStrategy) {
        this.busyWaitStrategy = busyWaitStrategy;
    }

    void lock() {
        while (state.getAcquireAndSetPlain(true)) {
            busyWaitStrategy.reset(); // Will be inlined
            while (state.getPlain()) {
                Thread.onSpinWait();
                busyWaitStrategy.tick(); // Will be inlined
            }
        }
    }

    void unlock() {
        state.setRelease(false);
    }
}

class NoopBusyWaitStrategy {
    void reset() {}

    void tick() {}
}
Run Code Online (Sandbox Code Playgroud)

我的手卷基准报告184.24ns ± 48%和 JMH 基准报告avgt 5 291.285 ± 20.860 ns/op。尽管两个基准的结果不同,但它们都在增加。
这是 JMH 基准:

public class SpinLockBenchmark {
    @State(Scope.Benchmark)
    public static class BenchmarkState {
        final SpinLock lock = new SpinLock();
    }

    @Benchmark
    @Fork(value = 1, warmups = 1, jvmArgsAppend = {"-Xms8g", "-Xmx8g", "-XX:+AlwaysPreTouch", "-XX:+UnlockExperimentalVMOptions", "-XX:+UseEpsilonGC", "-XX:-RestrictContended"})
    @OutputTimeUnit(TimeUnit.NANOSECONDS)
    @BenchmarkMode(Mode.AverageTime)
    @Threads(6)
    public void run(BenchmarkState state) {
        state.lock.lock();
        state.lock.unlock();
    }
}
Run Code Online (Sandbox Code Playgroud)

你有什么想法?
没有运行时的语言也会发生这种情况吗?

apa*_*gin 6

您的“微不足道”更改看起来并不那么微不足道。

你添加了 busyWaitStrategy.tick()在热循环中call,这会导致额外的加载、比较和 [non-taken] 条件分支。

即使该方法什么都不做,JLS 也需要NullPointerExceptionnull对象上调用方法时抛出。因此,JVM 需要加载该字段并检查它是否为空。尽管该字段已声明final,但 HotSpot JVM并未将其视为常量。并且由于Thread.onSpinWait,现场负载不会被提升到循环之外,因为它用作membar(参见讨论线程)。

借助-XX:+PrintAssembly,我们确实可以在编译后的代码中看到这个空指针检查:

    pause                     ;*invokestatic onSpinWait {reexecute=0 rethrow=0 return_oop=0}
                              ; - bench.SpinLock::lock@28 (line 23)
                              ; - bench.SpinLockBenchmark::run@4 (line 17)
                              ; - bench.generated.SpinLockBenchmark_run_jmhTest::run_avgt_jmhStub@17
 >> cmp     r12d,dword ptr [r10+10h]
 >> je      1a01286f719h      ;*invokevirtual tick {reexecute=0 rethrow=0 return_oop=0}
                              ; - bench.SpinLock::lock@35 (line 24)
                              ; - bench.SpinLockBenchmark::run@4 (line 17)
                              ; - bench.generated.SpinLockBenchmark_run_jmhTest::run_avgt_jmhStub@17
    mov     r8d,dword ptr [r10+0ch]  ;*getfield state {reexecute=0 rethrow=0 return_oop=0}
                              ; - bench.SpinLock::lock@19 (line 22)
                              ; - bench.SpinLockBenchmark::run@4 (line 17)
                              ; - bench.generated.SpinLockBenchmark_run_jmhTest::run_avgt_jmhStub@17
Run Code Online (Sandbox Code Playgroud)

此外,@Contended注释似乎被滥用了。据我了解代码,目的是保护AtomicBoolean 对象免受错误共享,而不是引用。因此,将AtomicBoolean.value字段或整个AtomicBoolean类标记为更有意义@Contended.

对于微基准测试结果的调查,我建议使用 JMH 内置分析器:-prof perfasm-prof perfnorm(顺便说一句,这是 JMH 超过手动框架的另一个原因)。perfasm将显示汇编代码 - 占用最多 cpu 周期的特定指令。perfnorm将输出性能计数器统计信息,例如每个周期的指令、缓存未命中、错误预测的分支等。