Java中的+=运算符是否线程安全?

51

我发现了以下的Java代码。

for (int type = 0; type < typeCount; type++)
    synchronized(result) {
        result[type] += parts[type];
    }
}

其中resultparts都是double[]类型。

我知道基本类型的操作是线程安全的,但我不确定+=是否也是。如果上述的synchronized是必要的,那么可能有更好的类来处理这样的操作吗?


8
该操作不是“原子性”的,因此可能需要进行外部同步。 - Mick Mnemonic
15
你似乎对所谓的基本操作有些困惑(我猜你是指读取和写入)。读取原始类型,比如int,的确是原子性的,但这并不意味着它是线程安全的。线程安全还涉及到可见性。因此,即使线程A将整数的值原子性地设置为42,也不能保证在线程B之后执行原子读取时该值是可见的。 - Janus Varmarken
1
@JanusVarmarken:关于“读取基本类型(如int)是原子性的”,这是正确的。但是对于long和double类型不是,详见https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.7。 - ruakh
synchronized放在循环周围可能会有很大帮助 - 目前,您每次迭代循环都会进入和离开临界区,这很可能会完全支配此代码片段的运行时。 - Luaan
@assylias:这并不足以证明原子性,实际上至少需要两次读取,甚至可能更多(读取地址、读取偏移量、加载值,以及这些操作都要进行两次)。即使是整个表达式或完整的循环,语言标准仍然可以强制执行原子性。 - Sebastian Mach
4个回答

67

不行。 += 操作不是线程安全的。 为了使涉及对共享字段或数组元素赋值的任何表达式都是线程安全的,需要锁定和/或适当的“happens-before”关系链。

(使用 volatile 声明的字段存在“happens-before”关系......但仅在读写操作时。 += 操作由读取和写入组成。这些操作是单独原子的,但顺序却不是。大多数使用 = 的赋值表达式都涉及一个或多个读取(在右侧)和一个写入。连续的赋值表达式也不是原子的。)

有关完整说明,请阅读 JLS 17.4 或者 Brian Goetz 等人编写的《Java并发编程实战》相关章节。

据我所知,基本类型的基本操作是线程安全的.....

实际上,这是错误的前提:

  • 考虑数组的情况
  • 考虑到表达式通常由一系列操作组成,而一系列原子操作不能保证是原子的。

对于 double 类型,还存在其他问题。 JLS (17.7) 说明了这一点:

"对于 Java 编程语言内存模型,对非易失性 long 或 double 值的单个写入被视为两个单独的写入:一个写入每个 32 位半部分。这可能导致线程从一个写入中看到 64 位值的第一个 32 位,并从另一个写入中看到第二个 32 位。"

"

对于volatile类型的long和double变量,它们的读写操作总是原子性的。

"

在这种情况下(当您正在更新double[]时),没有避免同步锁或原始互斥锁的替代方法。

如果您有一个int[]long[],您可以将它们替换为AtomicIntegerArrayAtomicLongArray,并利用这些类的无锁更新。 但是没有AtomicDoubleArray类,甚至没有AtomicDouble类。

更新 - 有人指出Guava提供了一个AtomicDoubleArray类,因此那就是一个选择。实际上是一个不错的选择。)

避免“全局锁”和大量争用问题的一种方法可能是将数组分成虚拟区域,每个区域都有自己的锁。这样,一个线程只需要阻塞另一个线程,如果它们正在使用数组的相同区域。(单个写入者/多个读取者锁也可以帮助解决问题...如果绝大部分访问都是读取操作。)

"

6
Guava提供了一个AtomicDoubleArray类,可以用来解决JDK中缺少相应类的问题。此外,Java 8还引入了DoubleAdder类,它基本上是一个原子double - Mick Mnemonic
1
@StephenC 我在我的多线程程序中使用了 += 操作符,用于处理 volatile 数据成员。这样做会引起问题吗? - Arya
2
@Arya 很确定是这样的。 "volatile" 告诉 JVM 从远程内存中读取/写入最新版本的值,而不是使用本地缓存版本。请参见:字段可以声明为 volatile,在这种情况下,Java 内存模型确保所有线程看到变量的一致值 只要各个线程只 读取 单个其他线程写入的值,那就没问题。一旦它们以交错的方式读取/修改/写入值,所有赌注仍然都是关闭的。安全起见,请进行防御性编程! - David Tonhofer
2
@Arya - 是的,会有影响。非常微妙的影响,比如偶尔的丢失更新。 - Stephen C
1
通过使用Double.toLongbits及其补充,您可以使用AtomicLongArray(我认为guava在幕后使用它)。 - ratchet freak
显示剩余4条评论

7
尽管Java中没有AtomicDoubleAtomicDoubleArray,但您可以根据AtomicLongArray轻松创建自己的。
static class AtomicDoubleArray {
    private final AtomicLongArray inner;

    public AtomicDoubleArray(int length) {
        inner = new AtomicLongArray(length);
    }

    public int length() {
        return inner.length();
    }

    public double get(int i) {
        return Double.longBitsToDouble(inner.get(i));
    }

    public void set(int i, double newValue) {
        inner.set(i, Double.doubleToLongBits(newValue));
    }

    public void add(int i, double delta) {
        long prevLong, nextLong;
        do {
            prevLong = inner.get(i);
            nextLong = Double.doubleToLongBits(Double.longBitsToDouble(prevLong) + delta);
        } while (!inner.compareAndSet(i, prevLong, nextLong));
    }
}

如您所见,我使用Double.doubleToLongBitsDouble.longBitsToDoubleDoubles存储为AtomicLongArray中的Longs。它们在位数上具有相同的大小,因此不会丢失精度(除了- NaN,但我认为这不重要)。
在Java 8中,实现add可以更加简单,因为您可以使用AtomicLongArrayaccumulateAndGet方法,该方法已添加到java 1.8中。
更新:看起来我几乎重新实现了guava的AtomicDoubleArray

6

即使是普通的“double”数据类型在32位JVM中也不是线程安全的(因为它不是原子性的),因为在Java中它占用了8个字节(涉及2个32位操作)。


这是我第一次听说这个。请解释一下为什么双精度数据类型的线程安全性取决于JVM或操作系统。 - Sharon Ben Asher
1
你可以查看这个链接:https://dev59.com/Imox5IYBdhLWcg3wVC1y - developer
1
@sharonbn http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.7 @VA31 这个回答没有提到其他基本类型,更不用说+=操作了。看起来它应该是一个评论而不是一个回答。 - Dioxin
4
他是正确的。JLS(Java语言规范)也这样说。请看我的回答。 - Stephen C

3

正如已经解释过的那样,这段代码不是线程安全的。在Java-8中避免同步的一种可能的解决方案是使用新的DoubleAdder类,该类能够以线程安全的方式维护双精度数的总和。

在并行化之前创建DoubleAdder对象数组:

DoubleAdder[] adders = Stream.generate(DoubleAdder::new)
                             .limit(typeCount).toArray(DoubleAdder[]::new);

然后像这样在并行线程中累加总和:
for(int type = 0; type < typeCount; type++) 
    adders[type].add(parts[type]);
}

并行子任务完成后,最终获得结果:

double[] result = Arrays.stream(adders).mapToDouble(DoubleAdder::sum).toArray();

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