【问题标题】:Do we need to synchronize access to an array when the array variable is volatile?当数组变量是 volatile 时,我们是否需要同步对数组的访问?
【发布时间】:2016-07-25 04:58:27
【问题描述】:

我有一个包含对数组的 volatile 引用的类:

private volatile Object[] objects = new Object[100];

现在,我可以保证,只有一个线程(称为writer)可以写入数组。例如,

objects[10] = new Object();

所有其他线程只会读取writer 线程写入的值。

问题:为了保证内存的一致性,我需要同步这样的读写吗?

我想,是的,我应该这样做。因为如果 JVM 在写入数组时提供某种内存一致性保证,那么从性能的角度来看,这将没有用处。但我不确定。在文档中没有发现任何有用的东西。

【问题讨论】:

  • 看看这个类似的问题stackoverflow.com/questions/27120914/…
  • 如果您对多线程问题的了解足够深入,那么您可能应该使用正确的集合,而不是裸数组。
  • IMO,这里最好的答案是@Boann 的答案。他们都是正确的,但是 Boann 的回答很简单,并且正确,并且直接回答了你的问题。
  • @jameslarge 除了部分错误。
  • 更多详情请参考stackoverflow.com/questions/3519664/…

标签: java arrays multithreading volatile


【解决方案1】:

您可以使用AtomicReferenceArray:

final AtomicReferenceArray<Object> objects = new AtomicReferenceArray<>(100);

// writer
objects.set(10, new Object());

// reader
Object obj = objects.get(10);

这将确保读/写操作的原子更新和发生前的一致性,就像数组的每个项目都是volatile一样。

【讨论】:

  • 或同步,或信号量,或任何其他满足 JVM 内存模型的东西。
  • @EJP 当然,但包括所有可能选项的答案将太大。我试图适应 OP 的模型(如果我正确理解问题,他们现在使用数组并需要对其项目进行原子更新)。
  • 这样一个集合的存在隐含表明对 OP 问题的答案是“否”。
  • @Raedwald 你说得有道理!除此之外,AtomicXxx类还有一个强大的特性——支持CAS operations,可以实现各种无锁算法。
【解决方案2】:
private volatile Object[] objects = new Object[100];

这样,您只能将 objects 引用设为 volatile。不是关联的数组实例内容。

问题:为了保证内存的一致性,是否需要同步这样的读写?

是的。

如果 JVM 在写入数组时提供某种内存一致性保证,从性能的角度来看,这将没有用

考虑使用 CopyOnWriteArrayList 之类的集合(或您自己的数组包装器,在 mutators 和 read 方法中实现一些 Lock)。

Java 平台也有Vector(因设计缺陷而过时)和synchronized List(在许多情况下很慢),但我不建议使用它们。

PS: One more good idea 来自 @SashaSalauyou

【讨论】:

  • 此链接提供更多详细信息stackoverflow.com/questions/32096419/…
  • 一个问题。你为什么提到Vector,因为它被认为是一个遗留类?我想只是作为一个例子......
  • Vector 只是一个众所周知的例子,是的。现在它被认为是过时的
  • Vector 是一个糟糕的例子,Collections.synchronizedList 也是如此。一个是过时的,一个是非常幼稚的实现。看看java.util.concurrent 包。这个答案全错了,真的。
  • 答案的前两部分和使用 COWL 或自己的数组包装器的建议是什么“全错了”,并在 mutators 和 read 方法中实现了一些 Lock 实现?
【解决方案3】:

JLS § 17.4.5 – Happens-before Order:

两个动作可以通过 happens-before 关系进行排序。如果一个动作在另一个动作之前发生,那么第一个动作对第二个动作可见并排在第二个动作之前。

[...]

每次后续读取该字段时都会写入volatile 字段发生之前

happens-before 关系非常强。这意味着如果线程 A 写入 volatile 变量,而任何线程 B 稍后读取该变量,则线程 B 保证可以看到 volatile 变量本身的更改,以及所有其他更改 线程 A 在设置 volatile 变量之前创建,包括任何其他对象,无论它们是否为 volatile

然而,这还不够!

元素赋值objects[10] = new Object(); 不是变量objects 的写入。它只是读取变量以确定它指向的数组,然后写入另一个变量,该变量包含在位于内存中其他位置的数组对象中。仅通过读取volatile 变量就不会建立 happens-before 关系,因此代码是不安全的。

正如@DimitarDimitrov 指出的那样,您可以通过对objects 变量进行虚拟写入来解决这个问题。每对操作——作者线程的objects = objects;重新分配加上读者线程的foo = objects[x];查找——定义了一个更新的happens-before关系,因此将“发布”所有作者线程对读者线程所做的最新更改。这可行,但需要纪律,而且不优雅。

但是有一个更微妙的问题:即使读取线程看到数组元素的更新值仍然不能保证它正确看到该元素引用的对象的字段,因为以下可以订购:

  1. Writer 创建了一些对象foo
  2. 作家集objects[x] = foo;
  3. 阅读器检查objects[x] 并看到对新对象foo 的引用(它可以这样做,但不能保证这样做,因为没有happens-before 关系尚未)。
  4. 作家做objects = objects;

不幸的是,这并没有定义正式的 happens-before 关系,因为 volatile 变量 read (3) 出现在 volatile 变量 write (4) 之前。虽然读者可以偶然看到objects[x] 是对象foo,但这并不意味着foo字段是安全发布的,因此读者理论上可能会看到新对象,但值错误!为了解决这个问题,您使用此技术在线程之间共享的对象需要具有所有字段 finalvolatile 或以其他方式同步。如果对象都是Strings,例如,你会没事的,但否则,这太容易出错了。 (感谢@Holger 指出这一点。)


这里有一些不那么不稳定的替代品:

  • AtomicReferenceArray 这样的并发数组类的存在是为了提供数组,其中每个元素 的行为就像volatile。这更容易正确使用,因为它确保如果读取器看到更新后的数组元素值,它也会正确地看到该元素引用的对象。

  • 您可以将所有对数组的访问包装在synchronized 块中,在某个共享对象上同步:

    // writer
    synchronized (aSharedObject) {
        objects[x] = foo;
    }
    
    // reader
    synchronized (aSharedObject) {
        bar = objects[x];
    }
    

    volatile 一样,使用synchronized 会创建happens-before 关系。 (线程在释放对象的同步锁之前所做的一切在任何其他线程获取同一对象的同步锁之前发生。)如果你这样做,你的数组不需要是@987654355 @。

  • 考虑一下数组是否真的是你需要的。您还没有说这些编写器和读取器线程的用途,但是如果您想要某种生产者-消费者队列,那么您真正需要的类是BlockingQueueExecutor。您应该查看 Java 并发类,看​​看其中一个是否已经满足您的需求,因为如果有,它肯定比volatile 更容易正确使用。

【讨论】:

  • objects = objects; 这样的虚拟写入不够,因为它不能保证读取线程将读取objects 数组引用之后 那写。由于数组引用在写入之前和之后是相同的,因此读取线程可以在写入之前读取数组引用,但在将引用写入数组之后,如果对象具有可变状态,则会产生数据竞争。
  • @Holger 天哪!你说得对!我已经编辑了答案。
  • 这种objects = objects我见过好几次了。 IMO 它非常丑陋,因为它不允许制作容器final。无法理解为什么这个成语如此受欢迎......
  • @Sasha Salauyou:这是围绕volatile 变量的众多误解之一。看起来,很多开发人员都在努力变得聪明,而且大多数时候他们甚至没有检查这些“聪明的技巧”是否获得了任何性能优势,更不用说正确性了……根据经验,如果一个写有没有可观察到的效果(比如再次写入旧值时,它无法与读者建立起之前发生的关系。如果它通常不能建立这种关系,JVM 可以完全忽略它。跨度>
  • @Holger 根据经验,如果写入没有可观察到的效果(例如再次写入旧值时,它就无法与读者建立发生前的关系 i> - 这不是真的 :) 即使 volatile 写入使用该字段的当前值,编译器也无法消除内存一致性副作用,并且会出现 happens-before同步顺序中该写入和后续读取之间的边缘。请查看此处的 cmets 部分:jeremymanson.blogspot.com/2009/06/volatile-arrays-in-java.html 其中一位 JMM 作者对此进行了解释。
【解决方案4】:

是的,您需要同步对 volatile 数组元素的访问。

其他人已经讨论了如何使用CopyOnWriteArrayListAtomicReferenceArray 来代替,所以我将转向一个稍微不同的方向。我还建议阅读 JMM 的主要贡献者之一杰里米·曼森 (Jeremy Manson) 的 Volatile Arrays in Java

现在,我可以保证只有一个线程(称为 writer)可以写入数组,例如如下:

您是否可以提供单一作者保证与volatile 关键字没有任何关系。我认为您没有想到这一点,但我只是澄清一下,以免其他读者产生错误的印象(我认为这句话可以构成数据竞赛双关语)。

所有其他线程将只读取写入器线程写入的值。

是的,但是就像您的直觉正确引导您一样,这仅适用于对数组的引用的值。 这意味着除非您正在编写对 volatile 变量的数组引用,否则您将无法获得 volatile 写入-读取合约的写入部分。

这意味着要么你想做类似的事情

objects[i] = newObj;
objects = objects;

这在许多方面都是丑陋和可怕的。 或者您想在每次编写器进行更新时发布一个全新的数组,例如

Object[] newObjects = new Object[100];

// populate values in newObjects, make sure that newObjects IS NOT published yet

// publish newObjects through the volatile variable
objects = newObjects;

这不是一个很常见的用例。

请注意,与不提供volatile-write 语义的设置数组元素不同,获取数组元素(使用newObj = objects[i];)确实提供volatile-read 语义,因为您正在取消引用数组:)

因为从性能的角度来看,如果 JVM 在写入数组时提供某种内存一致性保证,这将无用。但我不确定。

正如您所暗示的那样,确保volatile 语义所需的内存隔离将非常昂贵,如果您将虚假共享添加到混合中,情况会变得更糟。

在文档中没有找到任何有用的东西。

您可以放心地假设数组引用的volatile 语义与非数组引用的volatile 语义完全相同,考虑到数组(甚至是原始数组)仍然存在,这一点也不奇怪对象。

【讨论】:

  • objects = objects; 这样的伪写是不够的。读取线程可以读取objects[i] = newObj;objects = objects; 语句之间的数组引用,此时没有发生之前的关系。认为执行volatile 读取的线程将等到另一个线程执行其volatile 写入似乎是一个常见的错误。通过volatile 变量发布只有在应用程序可以处理读取器读取旧值的可能性时才有效,例如在“发布全新数组”解决方案中。
  • 获取数组元素(使用 newObj = objects[i];)确实提供了 volatile-read 语义 为什么?我们只能说objects 引用了实际值,而不是过时的值。但objects[i] 参考并非如此。
  • @Holger 这样的假写 not 怎么够?我会冒昧地说,在大多数并发程序中,读取不仅仅是对写入的反应。因此,即使您使用AtomicReferenceArray,您也可以在它更新特定元素之前暂停您的写入线程,并且您的读取线程读取在某些上下文中是陈旧值的内容。唯一的区别是上面的例子允许实际的数据竞争(是否可以归类为良性是另一个问题)。我认为您对常见错误的看法是正确的,尽管我不明白它与我的答案有何关系。
  • @St.Antario 它确实为objects 数组提供了volatile-read 语义。稍微简化一下,这个读取 同步 它看到的最后一个 volatile 写入,而 volatile 写入 happens-before volatile 读取。如果您一直在使用“发布全新数组”方法,则可以保证看到 objects[i] 的值不早于在看到 volatile 写入之前写入的值。
  • @Dimitar Dimitrov:如果您有另一个同步操作来保证编写器已完成,则该虚拟写入已过时。如果没有该附加操作,则虚拟写入无效。它只为 subsequent 读取建立起之前的关系,但没有可观察到的效果,您永远无法判断读取是否是后续的。请注意潜在读取旧(但正确)值和读取不一致(例如部分写入、乱序写入)值之间的区别。只有发布新数组才能保证值一致。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-08-30
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多