【问题标题】:Double-Check Idiom using booleans使用布尔值仔细检查习语
【发布时间】:2011-02-19 17:00:18
【问题描述】:

取下面的java代码:

public class SomeClass {
  private boolean initialized = false;
  private final List<String> someList; 

  public SomeClass() {
    someList = new ConcurrentLinkedQueue<String>();
  }

  public void doSomeProcessing() {
    // do some stuff...
    // check if the list has been initialized
    if (!initialized) {
      synchronized(this) {
        if (!initialized) {
          // invoke a webservice that takes a lot of time
          final List<String> wsResult = invokeWebService();
          someList.addAll(wsResult);
          initialized = true;
        }
      } 
    }
    // list is initialized        
    for (final String s : someList) {
      // do more stuff...
    }
  }
}

诀窍是doSomeProcessing 仅在特定条件下被调用。初始化列表是一个非常昂贵的过程,可能根本不需要。

我已经阅读了关于为什么双重检查习语被破坏的文章,当我看到这段代码时我有点怀疑。但是,这个例子中的控制变量是一个布尔值,所以据我所知需要一个简单的写指令。

另外,请注意 someList 已被声明为 final 并保留对并发列表的引用,其 writes happen-before reads;如果列表不是ConcurrentLinkedQueue,而是简单的ArrayListLinkedList,即使它已被声明为finalwrites 也不需要发生之前 reads

那么,上面给出的代码是否没有数据竞争?

【问题讨论】:

  • 我更喜欢最简单的代码,避免同步块可以节省 2 微秒。但是,如果列表长度适中,使用 ConcurrentLinkedQueue 而不是 ArrayList 您可能会失去更多。更简单的代码通常也运行得更快。 ;)
  • 仅供参考,在 Java5 之前的 Java 版本中,双重检查习语被破坏了。只要您将测试变量设置为 volatile,它就不再损坏。
  • When is assignment operation atomic in Java? 的可能重复项(无论是布尔值还是引用都无关紧要。)
  • @meriton:我看到了另一个问题。 helper = new Helper(); 语句可以分解为几个语句(分配内存,将这个新分配的地址分配给helper,然后实际运行构造函数代码),而initialized = true; 语句是一个简单的写... i想想……
  • 是的,分配初始化是原子的,就像分配助手一样。但是添加到列表(就像构造 Helper 实例一样)不是,并且允许 JVM 重新排序这些指令,即可以在列表(完全)附加之前分配初始化,就像可以在Helper 的构造函数已完成执行,因此另一个线程可能会在其完全初始化之前使用 someList(或 helper)。

标签: java multithreading concurrency synchronization memory-model


【解决方案1】:

好的,让我们获取 Java 语言规范。第17.4.5节defines happens-before如下:

两个动作可以由一个排序 发生之前的关系。如果一个 动作发生在另一个之前,然后 第一个可见并已订购 在第二个之前。如果我们有两个 动作 x 和 y,我们将 hb(x, y) 写入 表示 x 发生在 y 之前。

  • 如果 x 和 y 是相同的动作 线程和 x 在程序中位于 y 之前 顺序,然后是 hb(x, y)。
  • 有一个 从 a 的末尾开始发生在边缘之前 对象的构造函数开始 为此的终结器(§12.6) 目的。
  • 如果一个动作 x 与以下动作同步 y,那么我们也有 hb(x, y)。
  • 如果 hb(x, y) 和 hb(y, z),然后是 hb(x, z)。

需要注意的是,存在 发生之前的关系 两个动作之间不 必然暗示他们必须 以该顺序发生在 执行。如果重新排序 产生与 a 一致的结果 合法执行,不违法。

然后进行两次讨论:

更具体地说,如果两个操作共享发生在之前的关系,则对于它们不共享发生在之前的关系的任何代码,它们不一定必须以该顺序发生。例如,一个线程中的写入与另一个线程中的读取处于数据竞争中,这些读取可能会出现乱序。

在您的实例中,线程检查

if (!initialized)

可能会在看到添加到 someList 的所有写入之前看到 initialized 的新值,因此可以使用部分填充的列表。

注意你的论点

另外,请注意 someList 已被声明为 final 并保留对并发列表的引用,其 writes happen-before reads

无关紧要。是的,如果线程从列表中读取了一个值,我们可以得出结论,在写入该值之前,他也看到了发生的任何事情。但是如果它不读取值怎么办?如果列表显示为空怎么办?并且即使它读取了一个值,也并不意味着后续的写入已经执行,因此列表可能看起来不完整。

【讨论】:

  • 是的。你是对的......我已经阅读了几次规范,但是你在引用的规范之间的简短解释很有意义......谢谢
【解决方案2】:

Wikipedia 建议您使用volatile 关键字。

【讨论】:

  • 我也是这么想的,不过如果同步是在initialized 对象上,那岂不是也能保证可见性
  • @Johan,不是第一次在块外读取,但在这种情况下可能没有任何区别。
  • 它确实有所作为,因为它允许编译器重新排序同步块中的操作。特别是,它可能会在添加到 someList 之前分配 initialized。
  • 是的,我也想过这个问题……但是,解除对它的锁定会使initialized 的更改可见,对吧?另外,一旦你的initializedtrue,这意味着队列上的最后一次写入已经完成,最后一次写入happens-before 释放并将initialized 设置为true发生-before 发布,因此最后一次写入 happens-before 发布,因此一旦线程看到initialized 设置为true,则意味着列表已完全初始化(这是我的主张)。
  • 无论如何,我知道添加volatile 会起作用...问题是“这段代码没有数据竞争吗?”...我严重怀疑它是...
【解决方案3】:

使用ConcurrentLinkedQueue 并不能保证在这种情况下不存在数据竞争。 Its javadoc 说:

与其他并发集合一样,线程中的操作在将对象放入 ConcurrentLinkedQueue 之前发生在另一个线程中从 ConcurrentLinkedQueue 访问或删除该元素之后的操作。

即在以下情况下保证一致性:

// Thread 1
x = 42;
someList.add(someObject);

// Thread 2
if (someList.peek() == someObject) {
    System.out.println(x); // Guaranteed to be 42
}

因此,在这种情况下,x = 42; 不能用someList.add(...) 重新排序。但是,此保证不适用于相反的情况:

// Thread 1
someList.addAll(wsResult);
initialized = true;

// Thread 2
if (!initialized) { ... }
for (final String s : someList) { ... }

在这种情况下,initialized = true; 仍然可以使用 someList.addAll(wsResult); 重新排序。

因此,您在这里有一个常规的仔细检查习语,没有任何额外的保证,因此您需要按照 Bozho 的建议使用 volatile

【讨论】:

  • 但是,正如作者线程所见,队列中的最后一次写入 happens-beforeinitialized 设置为 true,这 happens-before this... 的 release 鉴于 happens-before 是可传递的,任何线程 acquires @987654333 上的监视器@,会看到列表的最新值和initialized...对吗?
  • @chahuistle:是的,但这就是双重检查惯用语的全部问题:在不获取监视器的情况下读取线程检查initialized,因此它对synchronized块内的操作重新排序很敏感。
  • @axtavt:这里的问题是,将initialized 设置为true 是在两个释放(最后一次写入列表和释放锁)之间,因此,我的主张是 initialized = true; 不能重新排序......你怎么看?
  • @chahuistle:关键是 release 之前的操作不能用那个 release 重新排序,但是 release 之后的操作 可以。
  • @John V.:实际上,同一个线程的动作之间总是存在发生前的关系。但是,这并不重要,因为与阅读线程没有发生之前的关系。来自 JLS:如果两个操作共享先发生关系,则它们不一定必须以该顺序发生在与它们不共享先发生关系的任何代码中。跨度>
【解决方案4】:

你可以检查 someList.isEmpty(),而不是初始化标志吗?

【讨论】:

  • 可以,但不正确,因为添加到 someList 的线程可能尚未添加所有字符串。
【解决方案5】:

首先,是并发队列的错误使用。它适用于多个线程放入队列并从队列轮询的情况。你想要的是初始化一次,然后保持只读的东西。一个简单的列表 impl 就可以了。

volatile ArrayList<String> list = null;

public void doSomeProcessing() {
    // double checked locking on list
    ...

假设,单纯为了脑力锻炼,我们想通过并发队列实现线程安全:

static final String END_MARK = "some string that can never be a valid result";

final ConcurrentLinkedQueue<String> queue = new ...

public void doSomeProcessing() 
    if(!queue.contains(END_MARK)) // expensive to check!
         synchronized(this)
            if(!queue.contains(END_MARK))
                  result = ...
                  queue.addAll(result);
                  // happens-before contains(END_MARK)==true
                  queue.add( END_MARK );

     //when we are here, contains(END_MARK)==true

     for(String s : queue)
         // remember to ignore the last one, the END_MARK

注意,在声明变量时,我使用了完整的类类型,而不是某些接口。如果有人争辩说应该声明接口List,这样“我可以把它改成任何List impl,我只有一个地方可以改”,他太天真了。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2017-01-22
    • 1970-01-01
    • 1970-01-01
    • 2019-06-30
    相关资源
    最近更新 更多