为什么在 CopyOnWriteArrayList 中需要 setArray() 方法调用

2022-09-03 14:37:06

在 中,在下面的方法中:CopyOnWriteArrayList.javaset(int index, E element)

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();
    }
}

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

问题2:如果我们最终进入 else,这意味着我们没有修改元素数组,那么为什么我们需要刷新变量数组的值呢?


答案 1

此代码使用深度 Java Memory Model voodoo,因为它混合了锁和易失性。

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

这些其他代码路径确实使用易失性读取和写入,特别是对字段的读取和写入。该方法对此字段执行易失性读取,方法方法对此字段执行易失性写入。arraygetArraysetArray

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

下面是一个示例:

// 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) 设置的值,即中间字符串 。(在本例中,我们使用实习字符串的标识语义。假设这是真的,那么内存模型保证在第(4)行读取的值将是1,由行(1)设置。这是因为在 (2) 处的易失性写入,以及每个较早的写操作,都发生在 (3) 行的易失性读取之前,以及每个后续读取之前。"x"

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

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

编辑 2022-07-13

Holger在评论中提出了一个有趣的问题:

如果到那时,线程1确实,第一个元素已经是“x”,我们正在谈论的场景,那么线程2不能假设证明线程1确实执行了,因为无论线程2的读取是否在线程1的写入之后,条件总是满足的。因此,如果元素没有变化,则此处(1)和(4)之间没有发生之前的关系。冗余 setArray 调用也没有强制实施内存可见性,因为读取器线程可能在写入之前读取了数组引用。list.set(0, "x");list.get(0) == "x"list.set(0, "x");

的确,仅单独查看此代码,无法保证 at (2) 在 at (3) 之前执行。但是,这些是对变量的操作,因此,它们是同步操作。在 JMM 下,同步操作具有总顺序。也就是说,它们将按某种顺序发生,但我们不知道是哪一个。操作可以按顺序 (2)->(3) 或 (3)->(2) 发生;没有其他可能性。setgetvolatile

如果顺序是 (3)->(2), 则 Holger 是正确的,不存在发生前关系,随后的读取(如 (4) 可能会得到一个过时的值。

但是,如果顺序为 (2)->(3),则存在发生前关系,并且读取处 (4) 保证在 (1) 处看到写音。

但是,这难道不是毫无意义,因为我们不能保证同步操作的执行顺序吗?为了建立这种顺序,我们通常会在线程之间使用一些同步操作,这将提供必要的内存可见性保证。这难道不会使(2)处的无条件易失性写入变得无用吗?

不一定。系统外部有一些机制,例如计时器、网络消息或用户交互,它们可以清楚地在某些操作之间建立顺序,但不能建立内存可见性。例如,假设线程 1 频繁执行其操作(例如,每秒一次),而线程 2 执行其操作(例如,每分钟一次)。我们的应用程序可能希望线程 2 获取一些最新值,但不一定是绝对的最新值。线程 1 重复执行的易失性写入(以及线程 2 重复执行的相应易失性写入)可确保线程 2 看到线程 1 的第 59 次或第 60 次更新。如果线程 1 未执行任何易失性写入,则线程 2 可能会看到任意旧的值。

这是一个非常狭窄的边缘情况,但我认为它确定了无条件执行其易失性写入的必要性。CopyOnWriteArrayList::set


答案 2

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


CopyOnWriteArrayList具有在 Javadoc 中指定的内存一致性保证:

内存一致性影响:与其他并发集合一样,线程中的操作在将对象放入另一个线程中之后的发生之前操作。CopyOnWriteArrayListCopyOnWriteArrayList

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

正如JLS中的Java内存模型规范所述:

对易失性字段 (§8.3.1.4) 的写入发生在每次后续读取该字段之前

因此,写入(使用 ) 方法对于确保从列表中读取的其他线程现在与调用该方法的线程具有发生之前(或更确切地说,发生之后)关系是必要的,即使方法中的元素已经与该位置的列表中已有的元素相同(使用)。arraysetArraysetset==

更新的解释

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

  1. 将对象放入CopyOnWriteArrayList
  2. 将和 object 放入(大概是在线程 A 上,尽管 Javadoc 可以更清楚地了解这一点)CopyOnWriteArrayList
  3. 从线程 B 访问 [读取] 元素CopyOnWriteArrayList

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

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

此对 setArray 的调用可确保对来自线程 A 的字段进行易失性写入。由于线程 B 将在字段 上执行易失性读取,因此在线程 A 和线程 B 之间创建了一个发生之前的关系,如果 else 分支不存在,则不会创建这种关系。arrayarray