3

采取以下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 发生之前 reads;如果不是一个ConcurrentLinkedQueue列表是一个简单的ArrayListor LinkedList,即使它已被声明为finalwrites也不需要在 .之前发生reads

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

4

5 回答 5

5

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

两个动作可以通过happens-before关系排序。如果一个动作发生在另一个动作之前,那么第一个动作对第二个动作可见并在第二个动作之前排序。如果我们有两个动作 x 和 y,我们写 hb(x, y) 来表示 x 发生在 y 之前。

  • 如果 x 和 y 是同一线程的操作,并且 x 在程序顺序中位于 y 之前,则为 hb(x, y)。
  • 从对象的构造函数的末尾到该对象的终结器(第 12.6 节)的开头有一条发生前边缘。
  • 如果动作 x 与后续动作 y 同步,那么我们也有 hb(x, y)。
  • 如果 hb(x, y) 和 hb(y, z),则 hb(x, z)。

应该注意的是,两个动作之间存在之前发生的关系并不一定意味着它们必须在实现中以该顺序发生。如果重新排序产生与合法执行一致的结果,则不是非法的。

然后进行两次讨论:

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

在您的实例中,线程检查

if (!initialized)

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

请注意,您的论点

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

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

于 2011-02-19T19:10:34.493 回答
4

维基百科建议您应该使用volatile关键字。

于 2011-02-19T17:05:23.353 回答
3

在这种情况下,使用ConcurrentLinkedQueue并不能保证不存在数据竞争。它的 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);

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

于 2011-02-19T17:17:10.587 回答
0

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

于 2011-02-19T17:42:55.093 回答
0

首先,是并发队列的错误使用。它适用于多个线程放入队列并从队列轮询的情况。你想要的是初始化一次,然后保持只读的东西。一个简单的列表 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

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

于 2011-02-19T21:02:30.603 回答