多线程读取时覆盖List是否线程安全?
问题
我在类里定义了一个静态List<Book>,内容通过API接口加载文件数据得到,同时存了这个List最后刷新/检查更新的时间戳。所有外部调用的类方法都只对这个List做读取操作,List里的Book对象也只会被读取,不会修改。
每次调用访问List的方法时,我会检查时间戳,如果超出设定时长就去检查API对应的文件有没有变更,要是有变更就启动异步线程重新加载文件,加载完成后直接覆盖原List。
我本来觉得只有读取操作不会有线程安全问题,目前也没发现异常,但大部分访问方法都会遍历List,所以我有个疑问:当线程正在遍历List的时候,另一个线程完成重载并覆盖了List会发生什么?遍历是复制了整个List再遍历,还是逐个索引加载元素?
我知道指定时长后首次调用访问方法的线程会拿到旧List,遍历完才会完成重载,这是可以接受的。
附上代码:
private static List<Book> bookList = new ArrayList<>(); private static long timestamp = 0; static { try { bookList = loadBookList(); timestamp = System.currentTimeMillis(); } catch (Exception e) { log.error("Failed initial load of bookList : " + e.getMessage()); } } public static String getShortFromName(String name) throws UnknownBookException { asyncListCheck(); for (Book book: bookList) { if (book.getName().equalsIgnoreCase(name)) { return book.getShort(); } } throw new UnknownObjException(MSG_ERR_NOBOOK); } [...] More methods that access the list private static void asyncListCheck() { new Thread(() -> { try { if (isOldList() && fileWasModified()) { bookList = loadBookList(); timestamp = System.currentTimeMillis(); } } catch (Exception e) { log.error("Failed reload of bookList : " + e.getMessage()); } }).start(); } public static List<Book> loadBookList() throws Exception { // Make API call List<Object> objList = // Results end up in here List<Book> list = new ArrayList<>(); for (Object obj: objList) { Map<String, Object> bookMap = (Map<String, Object>) obj; String name = String.valueOf(bookMap.get("name")); [...] more of this Book book = new Book(); [...] set all the fields list.add(book); } return list; }
另外我觉得用Collections.synchronizedList(list)没意义,因为我是直接覆盖整个List。我也可以先清空List再重新填充,但这好像没必要?
回答
核心问题:遍历中的List被覆盖会发生什么?
完全不会影响当前正在进行的遍历。原因很直白:
- 遍历开始时,线程已经拿到了
bookList当前指向的旧ArrayList实例的引用,后续所有遍历操作都是针对这个旧实例的。 - 异步线程覆盖的只是静态变量
bookList的引用,让它指向新的ArrayList,但旧实例本身还在内存里,直到遍历它的线程用完之前,都不会被GC回收。 - 你用的增强for循环,本质是通过迭代器遍历,迭代器绑定的是旧List实例,所以全程只会遍历旧数据,不会突然切换到新List。
简单说:遍历线程用旧List,覆盖线程换新引用,两者互不干扰,不会抛出并发修改异常,也不会出现遍历到一半数据突变的情况。
关于线程安全的补充
你的实现本身是线程安全的,两点依据:
- 引用赋值是原子操作:Java中对引用类型的赋值不会出现半完成状态,其他线程要么看到旧引用,要么看到新引用,不会看到中间的无效状态。
- 元素不可变:Book对象只会被读取不会修改,新加载的List里的Book都是新创建的实例,旧List的Book也不会被改动,不存在元素内部的并发问题。
关于synchronizedList和清空填充的疑问
- 确实没必要用
Collections.synchronizedList:这个工具类是用来保护同一个List实例的读写/写写并发的,而你每次都是直接替换整个引用,根本不会在同一个实例上做修改,所以完全没用。 - 也没必要清空原List再填充:直接替换引用更高效,还能避免在同一个List实例上修改可能带来的潜在问题(哪怕你现在都是只读,这个习惯也更安全)。
可优化的小细节
当前实现有个小瑕疵:bookList和timestamp的赋值顺序。现在是先替换List再更新时间戳,如果有线程在这两个操作之间访问,会拿到新List但旧时间戳,可能导致不必要的重复检查。可以把顺序反过来:
timestamp = System.currentTimeMillis(); bookList = loadBookList();
这样能保证新List和新时间戳的一致性,避免中间状态的问题。
内容的提问来源于stack exchange,提问作者Patrick

