为什么即使在单线程情况下也不使用同步 ArrayList?

Why not to use synchronized ArrayList even in single thread cases?

我有 运行 下面的代码来测量将元素添加到 ArrayList 与它的同步版本之间的时间和性能差异,令人惊讶的是没有发现任何显着差异!重要的是,我的意思是,差异并没有给你任何信息,让你可以更喜欢一个而不是另一个!

我的问题是,如果在性能方面它们几乎相同,那么为什么它们甚至首先提供非同步版本?为什么不在多线程和单线程情况下都使用同步版本?

仅供参考,可以看出我没有预热 JVM,而且我知道垃圾收集很可能 运行 在迭代之间释放旧数组,但我不认为很重要,因为我们对这两种情况都有它,而且即使你 运行 它只进行一次迭代你也会得到相同的结果!

    int size = 100_000_000;
    long totalTime = 0;
    for(int j=0; j<20; j++) {
        List<Integer> l1 = new ArrayList<>(size);
        //List<Integer> l1 = Collections.synchronizedList(new ArrayList<>(size));

        long t1 = System.nanoTime();

        IntStream.range(0, size).sequential().forEach(i -> l1.add(i));

        long t2 = System.nanoTime();
        totalTime += t2-t1;
    }
    System.out.println("time (ms):" + TimeUnit.NANOSECONDS.toMillis(totalTime/20));

synchronization,实际使用的时候,真的很费钱。然而,hotspot 在意识到互斥量实际上并没有做任何有用的事情并消除它方面相当不错。这就是您所看到的。

那么,为什么 ArrayList 不是开箱即用的同步/为什么不是建议 'use Vector, not ArrayList'?许多不同的原因:

  1. 最重要的带回家的原因(其余只是历史特殊性):因为同步列表几乎没有用。见下文。

  2. 现代 JVM 非常擅长消除 synchronized 什么都不做的地方。这就是为什么您很难使用简单的计时代码来查看任何差异的原因。但情况并非总是如此。 ArrayList 是在 java 1.2 中引入的。 Vector(具有不同 API 的同步数组列表)早于:1.0。引入 ArrayList 有两个不同的原因:部分是为了清理 API,部分是因为 'synchronize it!' 很慢。 现在 它不再慢了,但是 Java 1.2 是 23 years old。 Re运行 你在 java 1.2 上的代码,如果你能在任何地方找到它并向我报告:)

  3. 有关 Vector 的所有内容均已弃用、过时且不合用。其中一部分只是 'because'。 23 年前,出于多种原因,'use ArrayList, not Vector' 的建议是正确的。包括“因为它更快”(即使今天不再如此)。现在使用 ArrayList 而不是 Vector 的原因主要是:“因为 ArrayList 是每个人都熟悉的东西,Vector 不是,在罗马时像罗马人一样,不要无缘无故地搅局”。这以各种实用的方式出现:名称 'Vector' 现在在 java 生态系统中被重用,用于完全不同的东西(访问不完全是 64 位的硬件寄存器,巴拿马项目的一部分),例如。


为什么同步列表几乎没用?

非同步 ('thread safe') 实现完全中断;规范说:任何事情都可能发生。同步('thread safe')实现不会完全中断;取而代之的是,您会得到一系列选项中的一个,但无法保证哪些选项的可能性更大或更小。 不过,这并不比完全混乱更有用!例如,如果我编写以下代码:

List a = new Vector<String>();
Thread x = new Thread(() -> a.add("Hello"));
Thread y = new Thread(() -> a.add("World"));
x.start();
y.start();
x.join();
y.join();
System.out.println(a);

那么本应用打印[Hello, World]是合法的,但是本应用打印[World, Hello]也是合法的。 没有办法知道,并且 VM 可以随时 return 一个,或者总是 return 另一个,或者掷硬币,或者成功取决于月相。矢量是同步的,这对我来说仍然没用。没有人愿意编写需要处理排列组合爆炸的算法!!

然而,对于 ArrayList,它不是 'thread safe',它变得更糟。这里有更多的排列方式。 JVM 可以在不违反规范的情况下执行其中任何一项:

  • [你好,世界]
  • [世界,你好]
  • [你好]
  • [世界]
  • [null, 你好]
  • [世界,世界]
  • []
  • [WhatTheHeckReally]
  • 暂停,通过扬声器系统播放 macarena,然后崩溃。

一切正常 - 规范说行为未指定。实际上,前4个都是完全可能的。

避免这种混乱是好事,但同步 Vector 提供的排列只是..不那么糟糕。但仍然很糟糕,所以谁在乎呢?您希望此代码 100% 可靠:您希望代码每次都做同样的事情(除非我 想要 随机性,但随后使用 java.util.Random 其具有明确拼写的规范了解它是如何随机的。线程可以是非随机的,所以如果你必须有随机性,你也不能使用它)。

为了使事情可靠,操作需要由对象本身完成(您调用一个方法,这是您的线程与它进行的唯一交互),或者,您需要外部锁。

例如,如果我想将“1”放入散列映射中用于尚未存在的键,并在存在时递增数字,此代码不起作用:

Map<String, Integer> myMap = Collections.synchronizedMap(new HashMap<>());

...

String k = ...;
if (myMap.containsKey(k)) myMap.put(k, myMap.get(k) + 1);
else myMap.put(k, 1);

好像还好吧?不,坏了:

  • 线程 1 调用 myMap.containsKey 并看到答案是 false
  • 线程 1 恰好被抢占并冻结在那里,在 if 之后,put 之前。
  • 线程 2 运行s,并为同一键递增。它也发现 myMap,containsKey returning 错误。因此 运行s myMap.put(k, 1).
  • 线程 1 继续 运行ning,并且 运行s.. myMap.put(k, 1)
  • 地图现在包含 k = 1,即使 incrementFor(k) 是 运行 的两倍 。您的应用已损坏。

看到了吗?同步?在这里完全没用。你想要的是一把锁:

synchronized (something) {
  String k = ...;
  if (myMap.containsKey(k)) myMap.put(k, myMap.get(k) + 1);
  else myMap.put(k, 1);
}

这完全没问题 - 无论您如何同时尝试 运行ning incrementFor(k),它都会尽职尽责地计算每次调用,或者更好的是,我们要求地图执行此操作对我们来说,拥有一个仅 具有 增量函数或类似函数的地图。 HashMap 没有。我想 Collections.synchronizedList 可以 return 一个具有额外方法的对象,但顾名思义,该实现必须使用锁定,并且有更有效的方法来做到这一点。

这项任务最好用 ConcurrentHashMap 完成,并使用正确的方法:

ConcurrentHashMap<String, Integer> myMap = new ConcurrentHashMap<>();

...

myMap.merge(k, 1, (a, b) -> a + b);

这在 一个 调用中完成。 (如果 k 不在映射中,则合并与 .put(k, 1) 相同,但如果已经存在,则与 .put(k, RESULT) 相同,其中 RESULT 是 运行ning a + b 其中 a 是 'what was in the map' 而 'b' 是您要添加的值(因此,在本例中为 1)。

一个非同步的列表仍然可以搞乱一个调用,但是如果你的 'job' 涉及多个调用,一个同步的调用,例如Collections.synchronizedMapj.u.Vector 无法 安全地执行此操作。

最后,这就是为什么建议不要使用同步的东西 - 尽管它可能不是真正的性能问题,但这样做几乎没有意义。如果你确实有并发需求,那么内部同步不太可能对你有帮助,而且在它确实有帮助的情况下,java.util.concurrent 包中的一些更具体的类型可能会更快地完成它(因为当并发 IS 发生时,synchronized 绝对不是免费的)。

如果您得到奇怪的基准测试结果,您需要做的第一件事就是验证您的基准测试。由于多种原因,您的基准测试存在缺陷。

  • 没有适当的热身。这不仅是典型的 JIT 预热,而且在 JVM 启动的前几秒禁用了偏向锁定。
  • 迭代次数不足
  • 理论上可以通过消除死代码来优化代码

所以我使用 JMH 重写了您的基准测试:一个微型基准测试框架。

package com;

import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OperationsPerInvocation;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.IntStream;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
@OperationsPerInvocation(SyncArrayListBenchmark.OPERATIONS_PER_INVOCATION)
public class SyncArrayListBenchmark {

    public static final int OPERATIONS_PER_INVOCATION = 100_000_000;


    @Benchmark
    public int arrayList() {
        List<Integer> l1 = new ArrayList<>(OPERATIONS_PER_INVOCATION);

        IntStream.range(0, OPERATIONS_PER_INVOCATION).sequential().forEach(i -> l1.add(i));

        return l1.size();
    }

    @Benchmark
    public int synchronized_arrayList() {
        List<Integer> l1 = Collections.synchronizedList(new ArrayList<>(OPERATIONS_PER_INVOCATION));

        IntStream.range(0, OPERATIONS_PER_INVOCATION).sequential().forEach(i -> l1.add(i));

        return l1.size();
    }
}

结果运行JDK11:

Benchmark                                      Mode  Cnt  Score   Error  Units
SyncArrayListBenchmark.arrayList               avgt   25  4.986 ± 0.100  ns/op
SyncArrayListBenchmark.synchronized_arrayList  avgt   25  6.447 ± 0.104  ns/op

结果运行JDK17:

Benchmark                                      Mode  Cnt   Score   Error  Units
SyncArrayListBenchmark.arrayList               avgt   25   6.819 ± 0.300  ns/op
SyncArrayListBenchmark.synchronized_arrayList  avgt   25  10.374 ± 0.427  ns/op

结论:

如您所见,同步 ArrayList 的影响是显着的。

使用 JDK 11,即使使用偏向锁定,平均延迟也要高 29%。

对于 JDK17,同步 ArrayList 的影响甚至更糟,因为基准测试的平均延迟高出 52%。在 JDK15 中,偏向锁定已默认禁用,即将完全移除。所以这很可能是一个促成因素。

什么是'interesting'是JDK11的同步版本比17的非同步版本快,不知道是什么原因;可能与 GC 更改有关。

我把它留给 reader 作为练习。 JMH 有一些很棒的分析器。我要做的第一件事是摆脱分配,从而排除垃圾收集器。