【问题标题】:Java: Is volatile access necessary when threads set different cells of an array?Java:当线程设置数组的不同单元时,是否需要 volatile 访问?
【发布时间】:2020-04-28 23:17:43
【问题描述】:

考虑以下代码:

public static void main(String[] args) throws InterruptedException {
    int nThreads = 10;
    MyThread[] threads = new MyThread[nThreads];

    AtomicReferenceArray<Object> array = new AtomicReferenceArray<>(nThreads);

    for (int i = 0; i < nThreads; i++) {
        MyThread thread = new MyThread(array, i);
        threads[i] = thread;
        thread.start();
    }

    for (MyThread thread : threads)
        thread.join();

    for (int i = 0; i < nThreads; i++) {
        Object obj_i = array.get(i);
        // do something with obj_i...
    }
}

private static class MyThread extends Thread {

    private final AtomicReferenceArray<Object> pArray;
    private final int pIndex;

    public MyThread(final AtomicReferenceArray<Object> array, final int index) {
        pArray = array;
        pIndex = index;
    }

    @Override
    public void run() {
        // some entirely local time-consuming computation...
        pArray.set(pIndex, /* result of the computation */);
    }

}

每个 MyThread 完全在本地计算某些东西(无需与其他线程同步)并将结果写入其特定的数组单元。主线程一直等到所有 MyThreads 都完成,然后检索结果并对其进行处理。

使用AtomicReferenceArraygetset 方法提供了一种内存排序,可保证主线程将看到My​​Threads 写入的结果。

但是,由于每个数组单元只被写入一次,并且没有 MyThread 必须看到任何其他 MyThread 写入的结果,我想知道这些强排序保证是否真的必要,或者以下代码是否具有普通数组单元访问,将保证始终产生与上述代码相同的结果:

public static void main(String[] args) throws InterruptedException {
    int nThreads = 10;
    MyThread[] threads = new MyThread[nThreads];

    Object[] array = new Object[nThreads];

    for (int i = 0; i < nThreads; i++) {
        MyThread thread = new MyThread(array, i);
        threads[i] = thread;
        thread.start();
    }

    for (MyThread thread : threads)
        thread.join();

    for (int i = 0; i < nThreads; i++) {
        Object obj_i = array[i];
        // do something with obj_i...
    }
}

private static class MyThread extends Thread {

    private final Object[] pArray;
    private final int pIndex;

    public MyThread(final Object[] array, final int index) {
        pArray = array;
        pIndex = index;
    }

    @Override
    public void run() {
        // some entirely local time-consuming computation...
        pArray[pIndex] = /* result of the computation */;
    }

}

一方面,在普通模式访问下,编译器或运行时可能会在主线程的最后循环中优化对array 的读取访问,并将Object obj_i = array[i]; 替换为Object obj_i = null;(隐式初始化数组),因为数组不是从该线程内修改的。另一方面,我在某处读到Thread.join 使加入线程的所有更改对调用线程可见(这将是明智的),因此Object obj_i = array[i]; 应该看到由i-th MyThread 分配的对象引用.

那么,后面的代码会产生与上面相同的结果吗?

【问题讨论】:

  • 是否有充分的理由“手动”执行此操作(即扩展 Thread 并担心如何收集结果),而不是仅使用 Callable&lt;Object&gt;s,以及将结果放入主线程的数组中?
  • 在这个简单的例子中,没有。但是,可能有更复杂的情况,Callable 不够用。这更多是关于内存模型的问题,以代码作为具体示例。

标签: java arrays multithreading java-memory-model


【解决方案1】:

那么,后面的代码会产生与上面相同的结果吗?

是的。

您读到的关于Thread.join 的“某处”可能是JLS 17.4.5(Java 内存模型的“先发生顺序”位):

线程中的所有操作都发生在任何其他线程从该线程上的join() 成功返回之前。

因此,您对单个元素的所有写入都将在最终的 join() 之前发生。

话虽如此,我强烈建议您寻找其他方法来构建您的问题,而无需您担心代码在此详细级别上的正确性(请参阅我的other answer)。

【讨论】:

    【解决方案2】:

    这里似乎更简单的解决方案是使用 Executor 框架,该框架通常隐藏了有关线程和结果存储方式的不必要细节。

    例如:

    ExecutorService executor = ...
    
    List<Future<Object>> futures = new ArrayList<>();
    for (int i = 0; i < nThreads; i++) {
      futures.add(executor.submit(new MyCallable<>(i)));
    }
    executor.shutdown();
    
    for (int i = 0; i < nThreads; ++i) {
      array[i] = futures.get(i).get();
    }
    
    for (int i = 0; i < nThreads; i++) {
        Object obj_i = array[i];
        // do something with obj_i...
    }
    

    其中MyCallable 类似于您的MyThread

    private static class MyCallable implements Callable<Object> {
    
        private final int pIndex;
    
        public MyCallable(final int index) {
            pIndex = index;
        }
    
        @Override
        public Object call() {
            // some entirely local time-consuming computation...
            return /* result of the computation */;
        }
    
    }
    

    这会产生更简单且更明显正确的代码,因为您不必担心内存一致性:这是由框架处理的。它还为您提供了更大的灵活性,例如在比工作项更少的线程上运行它,重用线程池等。

    【讨论】:

    • 既然你将MyThread重命名为MyCallable,那么你也可以将nThreads重命名为nCallables。更好的是,将它们重命名为 MyTasknTasks
    【解决方案3】:

    当多个线程访问同一内存位置时,需要原子操作来确保存在内存屏障。没有内存屏障,线程之间就没有发生之前的关系,并且不能保证主线程会看到其他线程所做的修改,因此会出现数据冲突。所以你真正需要的是写和读操作的内存屏障。您可以使用 AtomicReferenceArray 或公共对象上的同步块来实现。

    在读取操作之前,您在第二个程序中有Thread.join。那应该消除数据竞争。如果没有join,则需要显式同步。

    【讨论】:

    • 内存屏障是否隐含波动性?
    • 可变变量通过内存屏障访问。如果我正确理解了您的问题,那就相反了。
    • 正如我所写,我在某处读到Thread.join 使调用线程可以看到连接线程的所有更改,这可能意味着它发出了适当的内存屏障。当然,我可以使用VarHandle 自己发出该障碍,这将减少问题是否需要明确这样做。
    • 我错过了加入你的代码。你是对的,join 将删除代码中的比赛
    猜你喜欢
    • 2016-07-25
    • 1970-01-01
    • 1970-01-01
    • 2020-11-15
    • 2011-08-28
    • 1970-01-01
    • 2014-05-16
    • 1970-01-01
    • 2011-03-03
    相关资源
    最近更新 更多