【问题标题】:Understanding collections concurrency and Collections.synchronized*了解集合并发和 Collections.synchronized*
【发布时间】:2014-11-14 11:13:40
【问题描述】:

我昨天了解到,多年来我一直在错误地使用具有并发性的集合。

每当我创建一个需要由多个线程访问的集合时,我都会将它包装在 Collections.synchronized* 方法之一中。然后,每当 mutating 集合时,我也会将它包装在一个同步块中(我不知道我为什么要这样做,我一定以为我在某处读过它)。

但是,在更仔细地阅读 API 之后,似乎您在 迭代 集合时需要同步块。来自 API 文档(用于地图):

当迭代任何集合视图时,用户必须在返回的地图上手动同步:

这是一个小例子:

List<O> list = Collections.synchronizedList(new ArrayList<O>());
...
synchronized(list) {
   for(O o: list) { ... }
}

所以,鉴于此,我有两个问题:

  • 为什么这甚至是必要的?我能想到的唯一解释是他们使用默认迭代器而不是托管线程安全迭代器,但他们本可以创建一个线程安全迭代器并修复这个混乱,对吧?

  • 更重要的是,这是为了什么?通过将迭代放在同步块中,您可以防止多个线程同时迭代。但是另一个线程可能会在迭代时改变列表,那么同步块如何帮助呢?无论是否同步,都不会在其他地方改变列表与迭代有关吗?我错过了什么?

感谢您的帮助!

【问题讨论】:

  • 我已回滚您最近的编辑。请不要编辑问题以将答案包含在其中。让问题成为问题,让答案成为答案。
  • @chris-hayes:罗杰!

标签: java collections concurrency


【解决方案1】:

为什么这甚至是必要的?我能想到的唯一解释是 他们使用默认迭代器而不是托管线程安全 迭代器,但他们本可以创建一个线程安全的迭代器并修复 这乱七八糟的,对吧?

迭代一次只处理一个元素。为了使Iterator 是线程安全的,他们需要制作集合的副本。否则,对底层 Collection 的任何更改都会影响您以不可预测或未定义的结果进行迭代的方式。

更重要的是,这是为了什么?通过把迭代 在同步块中,您正在阻止多个线程 同时迭代。但是另一个线程可能会改变列表 在迭代时,同步块如何在那里提供帮助? 不会在其他地方改变列表与迭代 是否同步?我错过了什么?

synchronizedList(List) 返回的对象的方法通过在实例上同步来工作。因此,当您位于 List 上的 synchronized 块内时,没有其他线程可以从同一个 List 添加/删除。

【讨论】:

  • 你说的一切都有道理,尤其是“在实例上同步”,这似乎很明显,但我没有意识到。而且我认为您需要在迭代而不是突变上进行同步,因为当您同步突变时可能正在进行迭代,而相反的方式不太可能是真的(因为突变比迭代更快)。再次感谢!
  • @rjcarr 您正在同步迭代和变异。列表本身只是开箱即用地进行变异同步(synchronizedList 方法的全部目的)。归结为:在迭代列表时改变列表是不安全的,但是列表在返回迭代器后无法锁定自身。因此,您要么必须自己锁定列表,要么使用副本。 (实际上,对于一个小清单,我更喜欢一份副本。麻烦少得多。)
【解决方案2】:

基本情况

Collections.synchronizedList() 返回的对象的所有方法都同步到列表对象本身。每当从一个线程调用一个方法时,调用它的任何方法的所有其他线程都会被阻塞,直到第一次调用完成。

到目前为止一切顺利。

Iterare necessest est

但这并不能阻止另一个线程在您调用next()Iterator 之间修改集合。如果发生这种情况,您的代码将失败并显示ConcurrentModificationException。但是,如果您也在 synchronized 块中进行迭代,并且您在同一个对象(即列表)上进行同步,这将阻止其他线程调用列表上的任何 mutator 方法,它们必须等到您的迭代线程释放列表对象的监视器。 关键是 mutator 方法是 synchronized 与您的迭代器块相同的对象,这就是阻止它们的原因。

我们还没有走出困境……

请注意,尽管上述保证了基本的完整性,但并不能保证始终正确的行为。您的代码的其他部分可能会做出在多线程环境中不成立的假设:

List<Object> list = Collections.synchronizedList( ... );
...
if (!list.contains( "foo" )) {
   // there's nothing stopping another thread from adding "foo" here itself, resulting in two copies existing in the list
   list.add( "foo" );
}
...
synchronized( list ) { //this block guarantees that "foo" will only be added once
  if (!list.contains( "foo" )) {
     list.add( "foo" );
  }
}

线程安全的迭代器?

关于线程安全迭代器的问题,确实有一个列表实现,叫做CopyOnWriteArrayList。它非常有用,但正如 API 文档中所指出的那样,它仅限于少数用例,特别是当您的列表很少被修改但迭代如此频繁(并且被如此多的线程)迭代时,同步迭代会导致严重的瓶颈。如果您使用不当,它会极大地降低应用程序的性能,因为列表的每一次修改都会创建一个全新的副本。

【讨论】:

    【解决方案3】:

    在返回的列表上同步是必要的,因为内部操作在 mutex 上同步,而该互斥体是 this,即同步集合本身。

    这里有一些relevant code from CollectionsSynchronizedCollection 的构造函数,同步集合层次结构的根。

        SynchronizedCollection(Collection<E> c) {
            if (c==null)
                throw new NullPointerException();
            this.c = c;
            mutex = this;
        }
    

    (还有另一个构造函数采用互斥体,用于从subList 等方法初始化同步的“视图”集合。)

    如果您在同步列表本身上进行同步,那么确实会阻止另一个线程在您迭代列表时对其进行变异。

    存在同步集合本身的必要性,因为如果您在其他任何东西上进行同步,那么您所想象的可能会发生 - 另一个线程在您迭代集合时改变集合,因为锁定的对象是不同的。

    【讨论】:

      【解决方案4】:

      Sotirios Delimanolis answered 您的第二个问题“这是为了什么?”有效。我想放大他对你第一个问题的回答:

      为什么这甚至是必要的?我能想到的唯一解释是他们使用默认迭代器而不是托管线程安全迭代器,但他们本可以创建一个线程安全迭代器并修复这个混乱,对吧?

      有几种方法可以实现“线程安全”迭代器。与软件系统的典型情况一样,存在多种可能性,并且它们在性能(活性)和一致性方面提供了不同的权衡。在我的脑海中,我看到了三种可能性。

      1.锁定 + 快速失败

      这是 API 文档的建议。如果您在迭代时锁定同步包装器对象(并且系统中的其余代码编写正确,以便突变方法调用也都经过同步包装器对象),则可以保证迭代看到内容的一致视图的集合。每个元素将只被遍历一次。当然,缺点是其他线程在迭代时无法修改甚至读取集合。

      这种情况的一种变体是使用读写器锁来允许在迭代期间进行读取但不允许写入。然而,迭代本身可以改变集合,所以这会破坏读者的一致性。您必须编写自己的包装器才能做到这一点。

      如果在迭代期间没有使用锁并且其他人修改了集合,或者如果使用了锁并且有人违反了锁定策略,那么快速失败就会发挥作用。在这种情况下,如果迭代检测到集合已从其下方变异出来,则会抛出 ConcurrentModificationException

      2。写入时复制

      这是CopyOnWriteArrayList 所采用的策略。此类集合上的迭代器不需要锁定,它在迭代器期间始终会显示一致的结果,并且永远不会抛出ConcurrentModificationException。但是,写入总是会复制整个数组,这可能会很昂贵。或许更重要的是,一致性的概念被改变了。集合的内容可能在您迭代它时发生了变化——更准确地说,当你在过去的某个时间迭代它的状态快照时——所以你可能做出的任何决定现在可能已经过时了。

      3.弱一致

      ConcurrentLinkedDeque 和类似的集合采用此策略。该规范包含weakly consistent 的定义。这种方法也不需要任何锁定,并且迭代永远不会抛出ConcurrentModificationException。但是一致性属性非常弱。例如,您可能会尝试通过迭代ConcurrentLinkedDeque 并将遇到的每个元素添加到新创建的List 来复制它的内容。但是当你迭代它时,其他线程可能正在修改双端队列。特别是,如果一个线程在您已经迭代的“后面”删除一个元素,然后在您正在迭代的位置“前面”添加一个元素,则迭代可能会同时观察到删除的元素和添加的元素。因此,该副本将有一个在任何时间点都不实际存在的“快照”。你得承认这是一个相当薄弱的一致性概念。

      最重要的是,没有一个简单的概念可以让迭代器线程安全,从而“解决这个混乱”。有几种不同的方法——可能比我在这里解释的更多——它们都涉及不同的权衡。任何一项政策都不可能在所有情况下对所有项目都“做正确的事”。

      【讨论】:

      • 感谢您的解释。我现在看到我的期望太天真了,如果没有别的,我现在更好地理解了这个问题。
      猜你喜欢
      • 1970-01-01
      • 2012-11-30
      • 2011-06-30
      • 2017-08-04
      • 1970-01-01
      • 2015-11-26
      • 1970-01-01
      • 2013-10-24
      • 1970-01-01
      相关资源
      最近更新 更多