在多线程程序中更新共享资源 TL;DR更长的答案

问题描述

谁能解释一下下面程序的输出

public class Datarace extends Thread {
    static ArrayList<Integer> arr = new ArrayList<>();

    public void run() {
        Random random = new Random();
        int local = random.nextInt(10) + 1;
        arr.add(local);
    }

    public static void main(String[] args) {
        Datarace t1 = new Datarace();
        Datarace t2 = new Datarace();
        Datarace t3 = new Datarace();
        Datarace t4 = new Datarace();

        t1.start();
        t2.start();
        t3.start();
        t4.start();

        try {
            t1.join();
            t2.join();
            t3.join();
            t4.join();
        
        } catch (InterruptedException e) {
            System.out.println("interrupted");
        }

        System.out.println(Datarace.arr);

    }
}

输出

  • [8,5]
  • [9,2,8]
  • [2]

我无法理解输出中不同数量的值。我希望主线程要么等到所有线程都完成执行,因为我在 try-catch 块中加入它们,然后输出四个值,每个线程一个,或者在中断的情况下打印到控制台。这两种情况都没有在这里真正发生。

如果这是由于多线程中的数据竞争导致的,它在这里如何发挥作用?

解决方法

主要问题是多个线程同时添加到同一个共享 ArrayList ArrayList 不是线程安全的。从 source 可以阅读:

请注意,此实现不是同步的。
如果多个线程 同时访问一个 ArrayList 实例,并且至少有一个 线程在结构上修改列表,它必须是同步的 外部。 (结构修改是任何添加或 删除一个或多个元素,或显式调整后备数组的大小; 仅仅设置元素的值不是结构性的 修改。)这通常是通过同步一些 自然封装列表的对象。如果不存在这样的对象, 该列表应该使用 Collections.synchronizedList 进行“包装” 方法。这最好在创建时完成,以防止意外 对列表的非同步访问:

每次调用时都在代码中

arr.add(local);

add 方法实现中,将更新跟踪数组 size 的变量。下面显示了 addArrayList 方法的相关部分:

private void add(E e,Object[] elementData,int s) {
    if (s == elementData.length)
        elementData = grow();
    elementData[s] = e;
    size = s + 1; // <-- 
}

其中变量字段 size 是:

/**
 * The size of the ArrayList (the number of elements it contains).
 *
 * @serial
 */
private int size;

请注意,add 方法 synchronized 和变量 size 都没有用 volatile 子句标记。因此,适用于竞争条件

因此,因为您没有 ensure mutual exclusion 对该 ArrayList 的访问(例如,ArrayList 的调用与 同步 子句),并且因为 ArrayList 不能确保 size 变量原子地更新,每个线程可能会看到(或不)最后更新的值那个变量。因此,线程可能会看到 size 变量的过时值,并将元素添加到其他线程之前已添加的位置。在极端中,所有线程可能最终都会将一个元素添加到同一位置(例如,作为您的输出之一[2])。

上述竞争条件导致undefined behavior,因此原因:

System.out.println(DataRace.arr);

在代码的不同执行中输出不同数量的元素。

要使 ArrayList 线程安全或替代,请查看以下 SO 线程:How do I make my ArrayList Thread-Safe?,其中展示了 Collections.synchronizedList().CopyOnWriteArrayList 的使用其他。

确保对 arr 结构的访问互斥的示例:

public void run() {
    Random random = new Random();
    int local = random.nextInt(10) + 1;
    synchronized (arr) {
        arr.add(local);
    }
}

或:

static final List<Integer> arr = Collections.synchronizedList(new ArrayList<Integer>());

  public void run() {
      Random random = new Random();
      int local = random.nextInt(10) + 1;
      arr.add(local);
  }
,

TL;DR

ArrayList 不是 Thread-Safe。因此,它在竞争条件下的行为是未定义的。改用 synchronizedCopyOnWriteArrayList

更长的答案

ArrayList.add 最终调用这个私有方法:

    private void add(E e,int s) {
        if (s == elementData.length)
            elementData = grow();
        elementData[s] = e;
        size = s + 1;
    }

当两个线程在“同一”时间到达同一点时,它们将具有相同的大小(s),并且都将尝试在同一位置添加一个元素并将大小更新为{{1 }},因此可能会保留第二个的结果。 如果达到 s + 1 的大小限制,并且必须达到 ArrayList,则会创建一个新的更大的数组并复制内容,可能会导致 grow() 所做的任何其他更改丢失(多个线程可能会尝试concurrently)。

此处的替代方案是使用 monitors - 又名 grow,或使用线程安全的替代方案,例如 synchronized

,

我认为有很多类似或密切相关的问题。例如见this

基本上这种“意外”行为的原因是因为 ArrayList 不是线程安全的。您可以尝试 List<Integer> arr = new CopyOnWriteArrayList<>(),它会按预期工作。当我们想要频繁地执行读操作并且写操作的次数相对较少时,推荐使用这种数据结构。有关详细说明,请参阅 What is CopyOnWriteArrayList in Java - Example Tutorial

另一种选择是使用 List<Integer> arr = Collections.synchronizedList(new ArrayList<>())

您也可以使用 Vector,但不推荐使用(请参阅 here)。 这篇文章也很有用 - Vector vs ArrayList in Java