【问题标题】:Why setArray() method call required in CopyOnWriteArrayList为什么在 CopyOnWriteArrayList 中需要调用 setArray() 方法
【发布时间】:2015-04-30 14:32:37
【问题描述】:

CopyOnWriteArrayList.java中,在方法@​​987654322@中 下面:

public E set(int index, E element) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        Object oldValue = elements[index];

        if (oldValue != element) {
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len);
            newElements[index] = element;
            setArray(newElements);
        } else {
            // Not quite a no-op; ensures volatile write semantics
            setArray(elements);----? Why this call required?
        }
        return (E)oldValue;
    } finally {
        lock.unlock();
    }
}

为什么需要调用setArray?我无法理解该方法调用上面写的评论。是不是因为我们没有使用同步块,我们必须手动刷新我们使用的所有变量?在上述方法中,他们使用可重入锁。如果他们使用了同步语句,他们还需要调用setArray 方法吗?我认为没有。

问题2:如果最后是else,说明我们没有修改elements数组,那为什么要flush变量数组的值呢?

【问题讨论】:

    标签: java collections locks java-memory-model


    【解决方案1】:

    此代码使用深度 Java 内存模型巫术,因为它混合了锁和易失性。

    不过,这段代码中的锁使用很容易省略。锁定在使用相同锁定的线程之间提供内存排序。具体来说,此方法末尾的解锁为获取相同锁的其他线程提供了happens-before语义。但是,通过此类的其他代码路径根本不使用此锁。因此,锁的内存模型含义与那些代码路径无关。

    那些其他代码路径确实使用易失性读写,特别是对array 字段。 getArray 方法对该字段进行易失性读取,setArray 方法对该字段进行易失性写入。

    此代码调用setArray 的原因即使它显然是不必要的,因为它为此方法建立了一个不变量,它总是 对该数组执行易失性写入。这与从该数组执行易失性读取的其他线程建立了发生前的语义。这很重要,因为 volatile 写入-读取语义适用于 volatile 字段本身的读取和写入之外的读取和写入。具体来说,在易失性写入之前写入其他(非易失性)字段发生在对同一易失性变量进行易失性读取之后从那些其他字段读取之前。有关说明,请参阅 JMM FAQ

    这是一个例子:

    // initial conditions
    int nonVolatileField = 0;
    CopyOnWriteArrayList<String> list = /* a single String */
    
    // Thread 1
    nonVolatileField = 1;                 // (1)
    list.set(0, "x");                     // (2)
    
    // Thread 2
    String s = list.get(0);               // (3)
    if (s == "x") {
        int localVar = nonVolatileField;  // (4)
    }
    

    假设第 (3) 行获取第 (2) 行设置的值,即内部字符串 "x"。 (为了这个例子,我们使用了内部字符串的标识语义。)假设这是真的,那么内存模型保证在第 (4) 行读取的值将是第 (1) 行设置的 1。这是因为 (2) 处的易失性写入,以及之前的每一次写入,都发生在第 (3) 行的易失性读取之前,以及之后的每一次读取。

    现在,假设初始条件是列表已经包含一个元素,即内部字符串"x"。进一步假设set() 方法的else 子句没有调用setArray。现在,根据列表的初始内容,第 (2) 行的 list.set() 调用可能会也可能不会执行易失性写入,因此第 (4) 行的读取可能会或可能不会有任何可见性保证!

    显然,您不希望这些内存可见性保证依赖于列表的当前内容。为了在所有情况下建立保证,set() 需要在所有情况下都进行 volatile 写入,这就是它调用 setArray() 的原因,即使它本身没有进行任何写入。

    【讨论】:

    • 你是说 unlock() 没有与其他线程建立“happens-before 语义”吗?
    • unlock() 总是设置一个 volatile 使这变得多余。 (见我的回答)
    • @PeterLawrey 为了解锁与另一个线程建立正确的内存语义,另一个线程必须使用相同的锁。与 volatile 类似:要建立排序,线程 1 必须执行 volatile 写入,线程 2 必须执行 同一个 volatile 变量的 volatile 读取,而不仅仅是任何 volatile 变量。
    • @PeterLawrey JMM(在 JLS 17.4 中指定,docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.4)描述了“同步操作”,它可以建立“同步”关系,从而建立发生前的语义。至关重要的是,仅指定对同一变量的易失性写入和读取以提供同步关系(JLS 17.4.4,第二个项目符号)。 JLS 不讨论围栏。另请注意,JMM 适用于内存重新排序,不仅因为处理器和缓存,还因为 JIT 编译器。
    • @PeterLawrey Fences 可用于实现内存排序语义。事实上,JEP 171 是关于实施的。正如您所注意到的,栅栏通常将排序应用于所有内存操作,而不仅仅是对 JMM 指定的同一易失性变量的操作。因此,栅栏的语义不同于 JMM 的语义,并且比 JMM 的语义更强。如果从else 子句中删除setArray,它可能会在许多(大多数?全部?)当前 JVM 实现中工作,但是JMM 下合法的未来JIT 优化可能会破坏它。因此,调用对于遵守 JMM 的语义是必要的。
    【解决方案2】:

    TLDR;对setArray的调用需要提供CopyOnWriteArrayList的Javadoc中指定的保证(即使列表的内容没有改变)


    CopyOnWriteArrayList 在 Javadoc 中指定了内存一致性保证:

    内存一致性效果:与其他并发集合一样, 在将对象放入 CopyOnWriteArrayList happen-before 在另一个线程中从CopyOnWriteArrayList 访问或删除该元素之后的操作。

    调用setArray 是强制执行此保证所必需的。

    正如Java Memory Model specification in the JLS 所说:

    对 volatile 字段的写入(第 8.3.1.4 节)发生之前每个后续 读取该字段。

    因此,写入array(使用setArray)方法是必要的,以确保从列表中读取的其他线程现在具有happens-before(或者更确切地说,happens-after)与调用set 方法的线程的关系,即使set 方法中的元素已经与列表中该位置的元素相同(使用==)。

    更新说明

    回到 Javadoc 中的保证。有这样的顺序(假设访问,而不是删除,作为最后一个操作 - 由于使用了lock,已经处理了删除,但访问不使用lock):

    1. 在将对象放入CopyOnWriteArrayList 之前,线程 A 中的操作
    2. 将对象放入CopyOnWriteArrayList(可能在线程A上,尽管Javadoc对此可能更清楚)
    3. 从线程 B 上的 CopyOnWriteArrayList 访问 [读取] 元素

    假设第 2 步将一个元素放入已经存在的列表中,我们看到代码进入了这个分支:

    } else {
        // Not quite a no-op; ensures volatile write semantics
        setArray(elements);
    }
    

    对 setArray 的调用确保线程 A 对字段 array 进行易失性写入。由于线程 B 将对字段 array 进行易失性读取,因此在线程之间创建了 happens-before 关系A 和线程 B,如果没有 else-branch,就不会创建。

    【讨论】:

    • 你是说unlock()后不做这个吗?
    • @PeterLawrey 我已经更新了我的答案。 unlock() 与写入和读取之间的同步无关,因为对该字段的读取访问不lock
    • 加一个用于引用规范。
    【解决方案3】:

    在 JDK 11 中,这个无用的操作已经从源代码中删除。请参阅下面的代码。

    //code from JDK 11.0.1
    public E set(int index, E element) {
        synchronized (lock) {
            Object[] es = getArray();
            E oldValue = elementAt(es, index);
    
            if (oldValue != element) {
                es = es.clone();
                es[index] = element;
                setArray(es);
            }
            return oldValue;
        }
    }
    

    【讨论】:

    • 请注意,该操作已在 JDK 主线中恢复,并且已向后移植到 JDK 11。请参阅 source code 和错误 JDK-8221120
    【解决方案4】:

    我相信是因为其他读取数组的方法没有获得锁,所以不能保证在排序之前发生。保持这种排序的方法是更新保证这种排序的 volatile 字段。 (这是它所指的写语义)

    【讨论】:

      【解决方案5】:

      AFAICS 不是必需的。这有两个原因。

      • 仅在执行写入时才需要写入语义,这不需要。
      • lock.unlock() 不可避免地在 finally 块中执行写语义。

      方法

      lock.unlock()
      

      总是打电话到

      private volatile int state;
      
      protected final void setState(int newState) {
          state = newState;
      }
      

      这使得在语义上已经发生了 setArray() 使得集合数组变得多余。您可能声称您不想依赖 ReentrantLock 的实现,但如果您担心 ReentrantLock 的未来版本不是线程安全的,那么您可能会遇到更大的问题。

      【讨论】:

      • 我仍然对这个问题感到困惑,不知道您是否可以详细说明“写语义”。我认为在那里调用setArray 一定有充分的理由,即使它没有效果,但也许满足了对 Java 规范的一些严格解释。
      • CopyOnWriteArrayList的内部一致性不需要,但是需要满足Javadoc给方法调用者的内存一致性保证(见我的回答)
      • @ErwinBolwidt 没有办法说内存“A”在保证之前发生了,但内存“B”没有。当您执行内存屏障(如解锁)时,这适用于所有内存。
      • @PeterLawrey 同意。我没有说它确实如此,所以我有点困惑你为什么这么说。
      • @PeterLawrey 也许混淆是关于易失性写入 - 易失性写入和读取仅在它们位于 same 字段时设置了发生前的关系(请参阅Java内存模型规范)——不只是针对任何易失性字段
      猜你喜欢
      • 2015-08-09
      • 2013-04-10
      • 2018-07-09
      • 2013-07-27
      • 2018-07-19
      • 2010-11-02
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多