使用foreach迭代ArrayList时的线程安全性

10

我有一个ArrayList,它在后台线程中被实例化并填充数据(我用它来存储Cursor数据)。同时,它可以在主线程上被访问并使用foreach迭代。所以这显然可能会导致抛出异常。

我的问题是,如何最好地使这个类成为线程安全的字段,而不需要每次复制它或使用标志?

class SomeClass {

    private final Context mContext;
    private List<String> mList = null;

    SomeClass(Context context) {
        mContext = context;
    }

    public void populateList() {
        new Thread(new Runnable() {
            @Override
            public void run() {
                mList = new ArrayList<>();

                Cursor cursor = mContext.getContentResolver().query(
                        DataProvider.CONTENT_URI, null, null, null, null);
                try {
                    while (cursor.moveToNext()) {
                        mList.add(cursor.getString(cursor.getColumnIndex(DataProvider.NAME)));
                    }
                } catch (Exception e) {
                    Log.e("Error", e.getMessage(), e);
                } finally {
                    if (cursor != null) {
                        cursor.close();
                    }
                }
            }
        }).start();
    }

    public boolean searchList(String query) { // Invoked on the main thread
        if (mList != null) {
            for (String name : mList) {
                if (name.equals(query) {
                    return true;
                }
            }
        }

        return false;
    }
}
4个回答

6
通常,在操作非线程安全的数据结构时并发操作是一个非常糟糕的想法。您无法保证实现不会在未来更改,这可能会严重影响应用程序的运行时行为,例如 java.util.HashMap 在同时修改时会导致无限循环。
要同时访问列表,Java 提供了 java.util.concurrent.CopyOnWriteArrayList。使用此实现将以多种方式解决您的问题:
  • 它是线程安全的,允许并发修改
  • 遍历列表快照不受并发添加操作的影响,允许并发添加和迭代
  • 它比同步更快
或者,如果不使用内部数组的副本是一个严格的要求(在您的情况下我无法想象),数组相当小,因为它只包含对象引用,这些引用可以在内存中高效地复制,那么您可以在映射上同步访问。 但是,这将需要正确初始化Map,否则您的代码可能会抛出NullPointerException,因为线程执行的顺序不能保证(您假设populateList()已经开始,因此列表被初始化)。 在使用同步块时,明智地选择受保护的块。如果您在同步块中拥有run()方法的整个内容,则读取器线程必须等待游标结果处理完成-这可能需要一段时间-因此您实际上失去了所有并发性。
如果您决定使用同步块,我会进行以下更改(我不声称它们完全正确):
初始化列表字段以便我们可以在其上同步访问:
private List<String> mList = new ArrayList<>(); //initialize the field

同步修改操作(添加)。不要在同步块内从光标读取数据,因为如果它是低延迟操作,则 mList 在该操作期间可能无法被读取,会阻塞所有其他线程相当长的一段时间。

//mList = new ArrayList<>(); remove that line in your code
String data = cursor.getString(cursor.getColumnIndex(DataProvider.NAME)); //do this before synchronized block!
synchronized(mList){
  mList.add(data);
}

读取迭代必须在同步块内进行,以避免在迭代过程中添加元素。
synchronized(mList){ 
  for (String name : mList) {
    if (name.equals(query) {
      return true;
    }
  }
}

因此,当两个线程操作列表时,一个线程可以添加单个元素或一次迭代整个列表。在这些代码部分中没有并行执行。
关于List的同步版本(即VectorCollections.synchronizedList()),它们可能不太高效,因为通过同步,您实际上会失去并行执行,因为一次只有一个线程可以运行受保护的块。此外,它们仍可能容易出现ConcurrentModificationException,甚至可能在单个线程中发生。如果在迭代器创建和迭代器应该继续之间修改了数据结构,则会抛出该异常。因此,这些数据结构无法解决您的问题。
我也不建议手动同步,因为错误风险太高(在错误或不同的监视器上进行同步,过大的同步块等)。
太长不看:使用java.util.concurrent.CopyOnWriteArrayList

我也更喜欢使用CopyOnWriteArrayList,但是OP说“不要复制”。也许他想保证“只有一个线程可以同时运行受保护的块”。 - beatngu13
2
“不复制”要求有点毫无意义。确实,CopyOnWriteArrayList 确实会复制内容,但这是实现细节的一部分。唯一的替代方案是使用同步块,在地图本身上进行同步,但这很容易出错,因为它初始化为null。使用Vector不能防止ConcurrentModificationException,因为它实际上与并发无关(当迭代器尝试继续进行但列表在迭代器创建后已被修改时抛出异常)。 - Gerald Mücke
谢谢您的解释。我想CopyOnWriteArrayList对我的情况更好(因为我在UI线程上访问该字段)。 - Nikolai
我不同意。仅仅使用CopyOnWriteArrayList是不够的。这可能会导致遍历不完整的列表。OP应该填充一个“影子”列表,如果使用CopyOnWriteArrayList,则使用原子addAll方法。 - Fildor

1
你可以使用 Vector,它是 ArrayList 的线程安全等价物。 编辑: 感谢Fildor的评论,现在我知道这不能避免使用多个线程时抛出ConcurrentModificationException

只有单个调用将被同步。 因此,例如,当另一个线程调用add时,无法调用一个add。 但是,更改列表将导致在另一个线程上进行迭代时抛出CME。 您可以阅读该主题上的迭代器文档。

也很有趣:

简而言之:不要使用Vector


仅仅使用向量(Vector)并不能避免出现ConcurrentModificationException异常。 - Fildor
@Fildor 只有在同一线程尝试迭代和修改时才会出现这种情况,不然我理解错了吗?我认为同步可以防止多个线程同时访问数据结构。 - beatngu13
3
是的,但这与CME无关。只有单个调用将被同步。例如,在另一个线程正在调用add时,不能调用另一个add。但是修改列表会导致在另一个线程上进行迭代时抛出CME。您可以阅读有关该主题的迭代器文档。我自己也曾陷入这个陷阱 - 我通过痛苦学习了。 - Fildor
@Fildor 谢谢你的澄清。我之前认为整个向量都被锁定了,而不仅仅是特定的方法。我不会删除这个答案,只是为了确保人们能够阅读到这个信息。 ;) - beatngu13
实际上整个实例都被锁定了。因此,每个方法都是“原子性的”。但是CME涉及状态。当添加或删除元素时,会改变状态。这就是迭代器抛出异常的原因,也是通过使列表实现同步来简单处理的问题所在。 - Fildor

1

使用 Collections.synchronizedList(new ArrayList<T>());

示例:

Collections.synchronizedList(mList);

不会对并发修改起到帮助作用。 - Fildor

1

Java同步块 http://www.tutorialspoint.com/java/java_thread_synchronization.htm

class SomeClass {

    private final Context mContext;
    private List<String> mList = null;

    SomeClass(Context context) {
        mContext = context;
    }

    public void populateList() {
        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized(SomeClass.this){
                    mList = new ArrayList<>();

                    Cursor cursor = mContext.getContentResolver().query(
                            DataProvider.CONTENT_URI, null, null, null, null);
                    try {
                        while (cursor.moveToNext()) {
                            mList.add(cursor.getString(cursor.getColumnIndex(DataProvider.NAME)));
                        }
                    } catch (Exception e) {
                        Log.e("Error", e.getMessage(), e);
                    } finally {
                        if (cursor != null) {
                            cursor.close();
                        }
                    }
                }
            }
        }).start();
    }

    public boolean searchList(String query) { // Invoked on the main thread
    synchronized(SomeClass.this){
            if (mList != null) {
                for (String name : mList) {
                    if (name.equals(query) {
                        return true;
                    }
                }
            }

            return false;
        }
    }
}

你确定吗,同步块在同一个监视器上进行同步吗? - Gerald Mücke
你在runnable上是正确的,应该精确到类的确切点。我现在相当确定这个对象(更新后)应该可以工作了。 - Karol Żygłowicz
1
将整个run()方法放入同步块中会阻塞访问相当长的时间...假设您迭代1000个元素,每个元素需要1秒钟来检索,则应用程序在大约16分钟内无响应。 - Gerald Mücke

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接