13

我已经有这个问题很长一段时间了,试图阅读大量资源并了解正在发生的事情 - 但我仍然无法很好地理解为什么事情会这样。

简而言之,我正在尝试测试 a在竞争环境中的CAS表现与非环境中的表现。synchronized我已经提出了这个JMH测试:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@State(Scope.Benchmark)
public class SandBox {

    Object lock = new Object();

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder().include(SandBox.class.getSimpleName())
                .jvmArgs("-ea", "-Xms10g", "-Xmx10g")
                .shouldFailOnError(true)
                .build();
        new Runner(opt).run();
    }

    @State(Scope.Thread)
    public static class Holder {

        private long number;

        private AtomicLong atomicLong;

        @Setup
        public void setUp() {
            number = ThreadLocalRandom.current().nextLong();
            atomicLong = new AtomicLong(number);
        }
    }

    @Fork(1)
    @Benchmark
    public long sync(Holder holder) {
        long n = holder.number;
        synchronized (lock) {
            n = n * 123;
        }

        return n;
    }

    @Fork(1)
    @Benchmark
    public AtomicLong cas(Holder holder) {
        AtomicLong al = holder.atomicLong;
        al.updateAndGet(x -> x * 123);
        return al;
    }

    private Object anotherLock = new Object();

    private long anotherNumber = ThreadLocalRandom.current().nextLong();

    private AtomicLong anotherAl = new AtomicLong(anotherNumber);

    @Fork(1)
    @Benchmark
    public long syncShared() {
        synchronized (anotherLock) {
            anotherNumber = anotherNumber * 123;
        }

        return anotherNumber;
    }

    @Fork(1)
    @Benchmark
    public AtomicLong casShared() {
        anotherAl.updateAndGet(x -> x * 123);
        return anotherAl;
    }

    @Fork(value = 1, jvmArgsAppend = "-XX:-UseBiasedLocking")
    @Benchmark
    public long syncSharedNonBiased() {
        synchronized (anotherLock) {
            anotherNumber = anotherNumber * 123;
        }

        return anotherNumber;
    }

}

结果:

Benchmark                                           Mode  Cnt     Score      Error  Units
spinLockVsSynchronized.SandBox.cas                  avgt    5   212.922 ±   18.011  ns/op
spinLockVsSynchronized.SandBox.casShared            avgt    5  4106.764 ± 1233.108  ns/op
spinLockVsSynchronized.SandBox.sync                 avgt    5  2869.664 ±  231.482  ns/op
spinLockVsSynchronized.SandBox.syncShared           avgt    5  2414.177 ±   85.022  ns/op
spinLockVsSynchronized.SandBox.syncSharedNonBiased  avgt    5  2696.102 ±  279.734  ns/op

在非共享情况下CAS速度要快得多,这是我所期望的。但在共同的情况下,事情是相反的——这是我无法理解的。我不认为这与偏向锁定有关,因为这会在线程持有锁定 5 秒(AFAIK)之后发生,而这不会发生,测试就是证明。

老实说,我希望只是我的测试有问题,并且有jmh专业知识的人会出现并指出我这里的错误设置。

4

4 回答 4

35

主要的误解是假设您正在比较“<code>CAS 与synchronized”。鉴于 JVM 实现的复杂程度synchronized,您将CAS使用基于算法AtomicLong的性能与CAS用于实现的基于算法的性能进行比较synchronized

与 类似Lock,对象监视器的内部信息基本上包括一个int状态,告诉它是否已被拥有以及它的嵌套频率、对当前所有者线程的引用和等待能够获取它的线程队列。昂贵的方面是等待队列。将线程放入队列,将其从线程调度中移除,并最终在当前所有者释放监视器时将其唤醒,这些操作可能会花费大量时间。

但是,在无竞争的情况下,当然不涉及等待队列。获取监视器包括CAS将状态从“未拥有”(通常为零)更改为“拥有,获得一次”(猜测典型值)。如果成功,线程可以继续执行关键操作,然后释放,这意味着只需写入具有必要内存可见性的“unowned”状态并唤醒另一个阻塞线程(如果有的话)。

由于等待队列的成本要高得多,因此即使在竞争的情况下,实现通常也会尝试通过执行一定量的旋转来避免它,在CAS退回到使线程入队之前进行多次重复尝试。如果所有者的关键操作像单次乘法一样简单,那么监视器很可能已经在旋转阶段被释放。请注意,这synchronized是“不公平的”,允许旋转线程立即继续,即使已经有排队的线程等待更长时间。

如果您比较synchronized(lock){ n = n * 123; }不涉及排队时执行的基本操作和执行的基本操作al.updateAndGet(x -> x * 123);,您会注意到它们大致相当。主要区别在于该AtomicLong方法将在争用时重复乘法,而对于该synchronized方法,如果在旋转期间没有取得任何进展,则存在被放入队列的风险。

synchronized允许对同一对象上重复同步的代码进行锁粗化,这可能与调用该syncShared方法的基准循环相关。除非还有一种方法可以融合 的多个CAS更新,否则AtomicLong这会带来synchronized巨大的优势。(另见这篇文章涵盖了上面讨论的几个方面)

请注意,由于 的“不公平”性质synchronized,创建比 CPU 内核多得多的线程并不一定是个问题。在最好的情况下,“线程数减去内核数”线程最终进入队列,永远不会醒来,而其余线程在自旋阶段成功,每个内核上有一个线程。但同样,不在 CPU 内核上运行的线程不能降低AtomicLong更新的性能,因为它们既不能使其他线程的当前值无效,也不能进行失败的CAS尝试。

在任何一种情况下,当CAS对非共享对象的成员变量执行 ing 或在非共享对象上执行synchronized时,JVM 可能会检测到操作的本地性质并忽略大部分相关成本。但这可能取决于几个微妙的环境方面。


synchronized底线是原子更新和块之间没有简单的决定。更昂贵的操作使事情变得更加有趣,这可能会增加线程在竞争情况下排队的可能性synchronized,这可能使得在原子更新的竞争情况下必须重复操作是可以接受的。

于 2017-09-07T15:07:46.833 回答
5

您应该阅读、重新阅读并接受 @Holger 的出色答案,因为它提供的见解远比来自开发人员工作站的一组基准数据更有价值。

我调整了您的基准测试,使它们更像苹果对苹果,但如果您阅读@Holger 的回答,您就会明白为什么这不是一个非常有用的测试。我将包括我的更改和我的结果,只是为了展示结果如何从一台机器(或一个 JRE 版本)到另一台机器。

首先,我的基准测试版本:

@State(Scope.Benchmark)
public class SandBox {
    public static void main(String[] args) throws RunnerException {
        new Runner(
            new OptionsBuilder().include(SandBox.class.getSimpleName())
                                .shouldFailOnError(true)
                                .mode(Mode.AverageTime)
                                .timeUnit(TimeUnit.NANOSECONDS)
                                .warmupIterations(5)
                                .warmupTime(TimeValue.seconds(5))
                                .measurementIterations(5)
                                .measurementTime(TimeValue.seconds(5))
                                .threads(-1)
                                .build()
        ).run();
    }

    private long number = 0xCAFEBABECAFED00DL;
    private final Object lock = new Object();
    private final AtomicLong atomicNumber = new AtomicLong(number);

    @Setup(Level.Iteration)
    public void setUp() {
        number = 0xCAFEBABECAFED00DL;
        atomicNumber.set(number);
    }

    @Fork(1)
    @Benchmark
    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    public long casShared() {
        return atomicNumber.updateAndGet(x -> x * 123L);
    }

    @Fork(1)
    @Benchmark
    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    public long syncShared() {
        synchronized (lock) {
            return number *= 123L;
        }
    }

    @Fork(value = 1, jvmArgsAppend = "-XX:-UseBiasedLocking")
    @Benchmark
    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    public long syncSharedNonBiased() {
        synchronized (lock) {
            return number *= 123L;
        }
    }
}

然后是我的第一批结果:

# VM version: JDK 1.8.0_60, VM 25.60-b23

Benchmark                    Mode  Cnt     Score     Error  Units
SandBox.casShared            avgt    5   976.215 ± 167.865  ns/op
SandBox.syncShared           avgt    5  1820.554 ±  91.883  ns/op
SandBox.syncSharedNonBiased  avgt    5  1996.305 ± 124.681  ns/op

回想一下,您看到synchronized在高竞争下领先。在我的工作站上,原子版本的表现更好。如果您使用我的基准测试版本,您会看到什么结果?如果它们有很大的不同,至少我不会感到惊讶。

这是在几个月前的 Java 9 EA 版本下运行的另一组:

# VM version: JDK 9-ea, VM 9-ea+170

Benchmark                    Mode  Cnt     Score     Error  Units
SandBox.casShared            avgt    5   979.615 ± 135.495  ns/op
SandBox.syncShared           avgt    5  1426.042 ±  52.971  ns/op
SandBox.syncSharedNonBiased  avgt    5  1649.868 ±  48.410  ns/op

这里的差异不那么显着。看到主要 JRE 版本之间的差异并不罕见,但谁能说您不会在次要版本中看到它们呢?

在一天结束时,结果很接近。很接近。synchronized自早期 Java 版本以来,性能已经取得了长足的进步。如果您不是在编写 HFT 算法或其他对延迟非常敏感的东西,那么您应该更喜欢最容易证明是正确的解决方案。synchronized它通常比无锁算法和数据结构更容易推理。如果您无法在应用程序中展示出可衡量的差异,那么synchronized您应该使用它。

于 2017-09-07T19:56:44.527 回答
3

请注意,与同步块相比,CAS 可以为您提供更细粒度的排序(非)保证,尤其是对于提供与 C++11 内存模型一致的排序选项的 java-9 varhandles 。

如果您只想从多个线程中保存一些统计信息,那么具有最宽松内存排序的读取-计算-更新循环(普通读取普通和弱 CAS)可能在弱排序平台上表现更好,因为它不会需要任何障碍,如果在 LL/SC 之上实施,CAS 就不必进行浪费的内部循环。此外,它还将为 JIT 提供更多自由来重新排序围绕这些原子的指令。compareAndExchange可以消除循环重复的额外读取。

另一个复杂因素也是衡量绩效的方式。所有的实现都应该有进度保证,即即使在争用的情况下也至少一次可以完成。因此,原则上,您可能会在尝试同时更新变量的多个线程上浪费 CPU 周期,但在 99% 延迟的度量上仍然会更好,因为原子操作不会诉诸于取消调度线程,而在最坏情况下的延迟会更糟,因为它们'不公平。因此,仅测量平均值可能无法说明全部情况。

于 2017-09-07T17:18:17.937 回答
0

首先,您正在编写的代码是 java,它将创建 java 字节码,该字节码转换为不同指令集(Arm vs powerpc vs X86 ...)上的不同原子操作,这些代码在不同供应商的实现上甚至在架构之间可能表现不同同一供应商(例如 intel core 2 duo 和 skylake)。所以真的很难回答你的问题!

本文指出,对于经过测试的 X86 架构,任何原子操作的一次执行都具有相似的性能(CAS、Fetch 和 add、swap 之间的差异非常小),而 CAS 可能会失败并需要执行多次。但是,在一个线程的情况下,它永远不会失败。

这个stackoverflow帖子指出:

每个对象都有一个与之关联的监视器。执行 monitorenter 的线程获得与 objectref 关联的监视器的所有权。如果另一个线程已经拥有与 objectref 关联的监视器,则当前线程等待直到对象被解锁,然后再次尝试获得所有权。如果当前线程已经拥有与 objectref 关联的监视器,它会增加监视器中的计数器,指示该线程进入监视器的次数。如果与 objectref 关联的监视器不属于任何线程,则当前线程将成为监视器的所有者,将此监视器的条目计数设置为 1。

让我们看一下 CAS 情况下的必要操作:

public final int updateAndGet(IntUnaryOperator updateFunction) {
    int prev, next;
    do {
        prev = get();
        next = updateFunction.applyAsInt(prev);
    } while (!compareAndSet(prev, next));
    return next;
}

取 x,乘 x,对 x 进行 Cas,检查 cas 是否成功

现在这在不满足的情况下是有效的,因为所需的操作数量最少。但是如果缓存线被争用,它不是很有效,因为所有线程都在积极旋转,而大多数线程都失败了。此外,我记得使用原子操作在竞争缓存线上旋转非常昂贵。

现在同步的重要部分是:

如果另一个线程已经拥有与 objectref 关联的监视器,则当前线程等待直到对象被解锁

这取决于如何实现这种等待。

同步方法可以在无法获取监视器后让线程随机休眠一段时间,而且不是使用原子操作来检查监视器是否空闲,它可以通过简单的读取来完成(这更快,但我不能' t找到一个链接来证明它)。

我敢打赌,同步中的等待是以一种智能的方式实现的,并针对与上述方法之一或类似方法发生争用的情况进行了优化,因此在竞争情况下它更快。

权衡是在非竞争情况下它更慢。

我仍然承认我没有证据。

于 2017-09-07T13:17:31.667 回答