列表的 Java 线程安全性

2022-09-04 20:15:23

我有一个List,它可以在线程安全上下文中使用,也可以在非线程安全的上下文中使用。会是哪一个,是不可能提前确定的。

在这种特殊情况下,每当列表进入非线程安全上下文时,我都会使用

Collections.synchronizedList(...)

但是我不想包装它,如果不进入非线程安全的上下文。例如,因为列表很大并且使用密集。

我读过关于Java的文章,它的优化策略对多线程很严格 - 如果你没有正确同步你的代码,它不能保证在线程间上下文中正确执行 - 它可以显着地重新组织代码,在一个线程的上下文中提供一致性(参见 http://java.sun.com/docs/books/jls/third_edition/html/memory.html#17.3)。例如,

op1;op2;op3;

可以重组为

op3;op2;op1;

,如果它产生相同的结果(在单线程上下文中)。

现在我想知道,如果我

  1. 在通过syncedList包装它之前填充我的列表,

  2. 然后把它包起来,

  3. 然后由不同的线程使用

, - 是否有可能,不同的线程将看到此列表仅部分填充或根本没有填充?JVM可能会将(1)推迟到(3)之后吗?有没有一种正确而快速的方法使(大)List成为非线程安全,从而成为线程安全?


答案 1

当您通过线程安全方式(例如使用同步块、易失性变量或 an )将列表提供给另一个线程时,可以保证第二个线程在传输时的状态(或任何较晚的状态,但不是更早的状态)中看到整个列表。AtomicReference

如果以后不更改它,则也不需要同步列表。


编辑(在一些评论之后,备份我的声明):

我假设以下内容:

  • 我们有一个易失性变量。list

    volatile List<String> list = null;
    
  • 线程 A:

    1. 创建列表 L 并用元素填充 L。
    2. 设置为指向 L(这意味着将 L 写入列表list)
    3. 不对 L 进行进一步的修改。

    示例源:

    public void threadA() {
       List<String> L = new ArrayList<String>();
       L.add("Hello");
       L.add("World");
       list = l;
    }
    
  • 线程 B:

    1. 从读取 Klist
    2. 循环访问 K,打印元素。

    示例源:

    public void threadB() {
         List<String> K = list;
         for(String s : K) {
             System.out.println(s);
         }
    }
    
  • 所有其他线程都不接触列表。

现在我们有这个:

  • 线程 A 中的操作 1-A 和 2-A 按程序顺序排序,因此 1 先于 2。
  • 线程 B 中的操作 1-B 和 2-B 按程序顺序排序,因此 1 先于 2。
  • 线程 A 中的操作 2-A 和线程中的操作 1-B 按同步顺序排序,因此 2-A 位于 1-B 之前,因为

    对易失性变量 (§8.3.1.4) v 的写入与任何线程对 v 的所有后续读取同步(其中后续根据同步顺序定义)。

  • 先发生后发生是各个线程的程序订单和同步顺序的传递闭包。所以我们有:

    1-A 发生之前 2-A 发生-在 1-B 发生之前-在 2-B 之前

    因此1-A发生在2-B之前。

  • 最后

    如果一个操作发生在另一个操作之前,则第一个操作对第二个操作可见并排序。

因此,我们的迭代线程确实可以看到整个列表,而不仅仅是它的某些部分。因此,使用单个易失性变量传输列表就足够了,在这种简单的情况下,我们不需要同步。


关于线程A的程序顺序,还有一次编辑(在这里,因为我比评论中有更多的格式自由)。(我还在上面添加了一些示例代码。

从 JLS(部分程序顺序):

在每个线程 t 执行的所有线程间操作中,t 的程序顺序是一个总顺序,它反映了根据 t 的线程内语义执行这些操作的顺序。

那么,线程 A 的线程内语义是什么?

上面的一些段落

内存模型确定在程序中的每个点都可以读取哪些值。每个线程的单独操作必须由该线程的语义控制,但每次读取看到的值由内存模型确定。当我们提到这一点时,我们说程序服从线程内语义。线程内语义是单线程程序的语义,允许根据线程内读取操作看到的值完全预测线程的行为。为了确定线程 t 在执行中的操作是否合法,我们只需评估线程 t 的实现,因为它将在单线程上下文中执行,如本规范的其余部分所定义。

本规范的其余部分包括第 14.2 节(块):

块是通过按从第一个到最后一个(从左到右)的顺序执行每个局部变量声明语句和其他语句来执行的。

因此,程序顺序确实是在程序源代码中给出语句/表达式的顺序。

因此,在我们的示例源中,内存操作创建了一个新的 ArrayList添加了“Hello”添加了“World”,并分配到 list(前三个由更多子操作组成)确实按照这个程序的顺序排列

(VM 不必按此顺序执行操作,但此程序顺序仍会影响“发生前”顺序,从而影响其他线程的可见性。


答案 2

如果您填写列表,然后将其包装在同一线程中,那么您将是安全的。

但是,有几件事要记住:

  1. Collections.synchronizedList()只保证你一个低级的螺纹安全。复杂的操作,如仍然需要自定义同步代码。if ( !list.contains( elem ) ) list.add( elem );
  2. 如果任何线程都可以获得对原始列表的引用,则即使此保证也是无效的。确保不会发生这种情况。
  3. 首先获得正确的功能,然后您可以开始担心同步太慢。我很少遇到Java同步速度是一个重要因素的代码。

更新:我想从JLS中添加一些摘录,希望能澄清一些问题。

如果 x 和 y 是同一线程的操作,并且 x 在程序顺序上位于 y 之前,则 hb(x, y)。

这就是为什么填充列表然后将其包装在同一线程中是一个安全选项的原因。但更重要的是:

这对程序员来说是一个非常有力的保证。程序员不需要对重新排序进行推理来确定他们的代码是否包含数据竞跑。因此,在确定其代码是否正确同步时,他们不需要对重新排序进行推理。一旦确定代码是否正确同步,程序员就不必担心重新排序会影响他或她的代码。

信息很明确:确保按照编写代码的顺序执行的程序不包含数据争用,并且不必担心重新排序。