遍历时改元素到底会不会出事?我把几种情况都试了一遍 学完 ArrayList 和 LinkedList 的区别之后我本来觉得 List 已经没啥难的了。不就是往里加东西、取东西、删东西嘛。但真开始写代码的时候我发现根本不是这么回事。最让我头疼的一个问题是我能不能一边遍历 List一边改它我一开始觉得这有什么不能的。结果写了个 for-each 循环里面调了 remove程序直接崩了给我抛了个什么 ConcurrentModificationException。我盯着那个异常看了半天心想我明明只有一个线程在跑哪来的并发修改后来我才搞明白这事儿比我想的复杂得多。而且除了遍历时能不能改元素List 在多线程下的安全问题也是一堆坑。这篇把我踩过的、看到别人踩过的坑都记下来。遍历时能不能修改元素——得看你怎么遍历这个问题我最早是在写一个过滤列表的小功能时遇到的。列表里存了一堆名字我想把以A开头的删掉。很自然地写了 for-eachListStringnamesnewArrayList();names.add(Alice);names.add(Bob);names.add(Anna);names.add(Tom);for(Stringname:names){if(name.startsWith(A)){names.remove(name);// 抛异常了}}跑起来直接崩。我当时心想remove 是 List 自己的方法啊怎么遍历的时候就不让用了查了一圈才明白原因。for-each 看着简单底层其实是用了迭代器。你写for (String name : names)编译器帮你翻译成Iterator的 hasNext 和 next 调用。但你在循环体里调的是list.remove()不是iterator.remove()。迭代器自己维护了一个期望的修改次数你直接调 list 的 remove 改了集合迭代器不知道等到下一次 next 的时候一对比发现次数对不上就抛异常了。我当时的第一反应是那你就不能忍一下吗不就改了个集合嘛。后来明白了这个机制其实是为了保护你。试想一个场景你在遍历一个列表删了中间某个元素后面的元素往前窜了一位。你这边正一个一个往后数呢拿到第 5 个元素——但第 5 个元素已经被前一个删除操作往前推了一位其实已经变成原来的第 6 个了。如果你完全没意识到这个变化后面的逻辑就全乱了。Java 的选择是宁可让你程序崩溃也不让你在懵懵懂懂中拿到错误的数据。这个设计思路叫 fail-fast直译过来是快速失败。不是帮你修问题而是尽早让你知道有问题。知道原因之后就好办了。类似的场景我自己总结了这么几条经验普通 for 循环——改值可以删也行但要注意索引如果你用的是最原始的 for 循环靠下标访问那是可以改的for(inti0;ilist.size();i){list.set(i,list.get(i)*2);// 没问题只改值不改结构}set方法不改变集合的结构不会触法那个检查。但如果要删也是可以的不过索引会变你得小心处理for(inti0;ilist.size();i){if(list.get(i).startsWith(A)){list.remove(i);i--;// 删完之后索引回退一位不然会跳过一个元素}}我第一次写的时候忘了那个i--结果删了一个元素之后下一个元素被跳过了排查了好久才发现。后来我就不爱用这种方式了太容易出错。for-each——只适合读不适合改for-each 写起来最简单但它的限制也最明确只能读不能改结构。不管是 add 还是 remove都会抛 ConcurrentModificationException。唯一可以放心做的是改元素的值——但注意不是通过 list.set而是如果你存的是对象改对象的内部属性是没问题的。因为对象引用没变集合的结构也没变。ListStringBuilderlistnewArrayList();// ... 加一些 StringBuilder 进去for(StringBuildersb:list){sb.append(!);// 可以改的是对象内部不是集合结构}不过说实话这种写法我后来也尽量少用因为代码读起来容易让人困惑。Iterator——遍历时删元素的正规方式如果你需要在遍历的时候删元素用迭代器是标准做法IteratorStringitnames.iterator();while(it.hasNext()){Stringnameit.next();if(name.startsWith(A)){it.remove();// 迭代器自己的 remove安全}}关键点是it.remove()而不是list.remove()。迭代器的 remove 方法会同步更新它内部维护的修改计数所以不会触发那个检查。我当时搞懂这个的时候有一种哦原来是这样的感觉。回头想想for-each 不让删其实是一种保护——它怕你不知道自己在干什么。ListIterator——遍历时还能改和加Iterator 只能删。如果你想在遍历的时候替换元素可以用 ListIteratorListIteratorStringlitnames.listIterator();while(lit.hasNext()){Stringnamelit.next();if(name.equals(Bob)){lit.set(Robert);// 替换当前元素}if(name.equals(Tom)){lit.add(Tommy);// 在当前元素之后插入}}set替换当前元素add在当前位置后面插入新元素——这些都是 ListIterator 提供的能力而且不会抛异常。我刚开始觉得这个类有点多余后来发现它其实挺有用的尤其是需要对列表做精细控制的时候。不过日常写代码用到它的场景确实不多。CopyOnWriteArrayList——随便改但要注意代价还有一个特殊情况就是前面那篇提到的 CopyOnWriteArrayList。它的设计是写时复制所以遍历的时候不管你怎么 add、remove都不会抛 ConcurrentModificationException。CopyOnWriteArrayListStringlistnewCopyOnWriteArrayList();// ... 加一些元素for(Stringname:list){if(name.startsWith(A)){list.remove(name);// 这次不会抛异常了}}但有一个问题你遍历的时候拿到的迭代器是快照式的。它指向的是你开始遍历那一刻的数组。即使你中途改了列表比如删了元素你遍历的还是老数据。这是个很微妙的点如果不了解这个机制可能会写出看起来对、实际上错的代码。我举个例子你就明白了。假设列表里有 [“A”, “B”, “C”]你用 for-each 遍历它在遍历到第一个元素的时候另一个线程把 “B” 删了。如果你用的是普通 ArrayList那遍历的可能是 [“A”, “C”]或者直接抛异常。但 CopyOnWriteArrayList 不会——它只管继续遍历你开始遍历那一刻的数组也就是 [“A”, “B”, “C”]“B” 虽然已经被删了但你遍历的时候它还在。这种弱一致性在某些场景下是可以接受的但在对实时性要求很高的场景就得慎重了。快速删除指定下标的元素——不同 List 差距很大除了遍历时删元素还有一个常见的需求就是根据下标删元素。不同的 List 实现删同一个位置的元素背后的代价天差地别。ArrayList——取决于删哪里ArrayList 底层是数组删除中间某个元素之后后面的所有元素都要往前挪list.remove(2);// 删掉下标为2的元素后面的全部往前移如果删的是最后一个元素O(1) 搞定。但如果删的是中间某个位置时间复杂度就是 O(n) 了因为要搬移 n 个元素。我有个同事曾经在一个循环里反复删除 ArrayList 的第一个元素处理了上万条数据程序卡得要命。后来改成用 LinkedList 或者反过来从尾部删一下子就快了。LinkedList——也不一定快你可能觉得 LinkedList 是链表删除应该快。答案是看情况。linkedList.remove(2);// 得先从头遍历到下标2的位置LinkedList 的 remove(int index) 需要先遍历到那个位置这个遍历是 O(n)。找到位置之后改指针才是 O(1)。所以如果你按下标删中间的元素它其实也不快。但如果你已经站在那个节点上了比如说通过迭代器定位到的那删除就是 O(1)。所以 LinkedList 真正擅长的是持有一个节点的引用然后删它的场景而不是按下标删。我当时也搞混过以为 LinkedList 的所有删除都快。后来自己写了一段小代码验证了一下才发现被网上很多文章误导了。LinkedList 在头尾的操作确实是 O(1)但随机位置的删除跟 ArrayList 一样要 O(n)——一个是遍历花时间一个是搬移花时间。头尾删除倒是很快因为 LinkedList 维护了 first 和 last 指针直接改就行了。CopyOnWriteArrayList——删一次复制一次CopyOnWriteArrayList 的删除就更重了。每次删除都会复制整个数组除了被删的那个元素生成一个新数组替换掉老的。copyOnWriteList.remove(1);// 复制整个数组去掉下标1的元素所以它最不适合频繁写的场景。但好处是删除的同时其他线程正在读不会受影响——读的还是老数组不会读到一半被改掉。这几种 List 的删除性能差异其实从底层结构就能推导出来。我后来养成了一个习惯如果代码里要频繁按下标删元素先想清楚用的是哪个 List 实现值不值得换一个。ArrayList 不是线程安全的——它到底在哪里不安全前面几篇提到过 ArrayList 不是线程安全的。但不安全这三个字太抽象了。我当时想知道的是到底哪里不安全怎么个不安全法后来我看了源码才明白。ArrayList 的 add 方法核心代码其实就几行publicbooleanadd(Ee){ensureCapacityInternal(size1);// 第一步确保容量够elementData[size]e;// 第二步和第三步合在了一行returntrue;}虽然看起来只有两行但本质上是三步1. 判断数组要不要扩容ensureCapacityInternal 2. 在 size 位置设值elementData[size] e 3. size 加 1size而且 size 本身也不是原子的。那一行elementData[size] e其实是先拿 size 的值做下标把元素放进去然后再把 size 加 1。两个线程同时执行这个顺序一旦交错问题就来了。还有一个更容易出问题的场景是扩容的时候。假设 ArrayList 的容量是 10已经装了 10 个元素。线程 A 调 add发现要扩容开始创建新数组、复制元素。但在这个过程中线程 B 也调了 add它发现 size10、容量10也要扩容。两个线程各自创建新数组各自复制最后只有一个线程的新数组生效另一个线程复制的东西全丢了。问题一部分值为 null假设 ArrayList 当前有 9 个元素数组容量是 10。线程 A 和线程 B 同时执行 add。线程 A 先检查发现 size9容量10不用扩容。然后 CPU 切走了。线程 B 也检查同样发现不用扩容。线程 A 回来把值设在了 index9 的位置。但还没来得及执行 size。线程 B 也回来了也把值设在了 index9 的位置——把线程 A 的值覆盖了。然后两个线程各自 sizesize 从 9 变成了 11。index10 的位置就永远是 null 了。我第一次读到这个场景的时候觉得这简直像多米诺骨牌一步错步步错。问题二索引越界异常线程 A 发现 size9容量10不用扩容。线程 A 被暂停。线程 B 也开始 add发现 size9容量10也不用扩容。线程 B 设完值之后 sizesize 变成了 10。这时候线程 A 回来它要继续设值。但它之前知道 size9所以它往 index9 设。但这时候 size 已经被线程 B 改成 10 了数组里 index9 也有值了线程 B 设的。关键是如果再来一个线程 C或者线程 A 拿到的信息更旧一些——本来数组容量是 10size 被加到了 10这时候再往 index10 设值就直接越界了。问题三size 和实际元素数量对不上这个是最隐蔽的。size 看起来是一条语句但实际上分三步1. 读取 size 的当前值 2. 把值加 1 3. 把新值写回去两个线程同时读到 size9各自加 1各自写回 10。本来应该加两次变成 11结果只变成了 10。所以多线程同时 add 之后你查 size 发现只有 95但明明 add 了 100 次。这种 bug 非常难排查因为不是每次都会出现偶尔出现一次你还不确定是不是代码写错了。我后来在公司的代码里就遇到过类似的问题。一个统计系统多线程往 ArrayList 里加数据跑了一段时间发现数据总是少几条。排查了好久最后才发现是 ArrayList 的线程安全问题。改成 CopyOnWriteArrayList 之后就没再出现了。把 ArrayList 变成线程安全的方法如果你需要在多线程环境下用 List有三种常见的做法。第一种Collections.synchronizedList 包装ListStringlistCollections.synchronizedList(newArrayList());这个方法返回一个包装后的 List它的每个方法都被同步块保护起来了。用起来跟普通 List 一样ListStringlistCollections.synchronizedList(newArrayList());list.add(a);list.add(b);synchronized(list){// 遍历的时候还得自己加锁for(Strings:list){System.out.println(s);}}注意一个容易踩的坑遍历的时候你还是得手动加 synchronized。因为包装类只保护了单个方法的原子性但遍历是多次调用 hasNext 和 next这中间如果有其他线程改了集合还是会出问题。我第一次用的时候就吃了这个亏以为包装了就万事大吉了结果遍历的时候还是抛了 ConcurrentModificationException。第二种CopyOnWriteArrayList这个前面已经讲了不少了。它适用于读多写少的场景CopyOnWriteArrayListStringlistnewCopyOnWriteArrayList();list.add(a);list.add(b);读操作完全不加锁写操作复制数组。如果你的业务场景是配置信息读得多、改得少或者事件监听器列表偶尔增删、经常遍历那用这个就对了。但如果你的代码里写操作很频繁比如每秒都要 add 好几次那千万别用 CopyOnWriteArrayList——每次 add 都复制整个数组性能会非常难看。第三种Vector不太推荐了Vector 是 JDK 1.0 就有的老前辈它的每个公开方法都用 synchronized 修饰了VectorStringvectornewVector();vector.add(a);vector.add(b);用法跟 ArrayList 一模一样而且自带线程安全。但现在的代码里基本看不到 Vector 了原因有两个第一它的同步策略太粗暴。每个方法都加锁性能不好而且在某些场景下比如复合操作这种每个方法各自加锁的方式并不能真正保证线程安全你还需要自己加锁。第二它是个历史遗留类。Java 官方文档都推荐用 ArrayList Collections.synchronizedList 或者 CopyOnWriteArrayList 来替代它。所以新代码里基本上不会用 Vector 了。三种方法怎么选学完之后我自己整理了一下选择思路。如果你的代码是单线程环境啥都不用改直接用 ArrayList。这是最普遍的情况。如果是多线程环境分两种情况如果你主要是在读偶尔写一下比如配置信息、缓存白名单用 CopyOnWriteArrayList。如果你读写都比较频繁或者不确定场景用 Collections.synchronizedList 包装一下然后记得遍历的时候手动加锁。我自己后来写多线程代码的时候大部分情况用的都是 CopyOnWriteArrayList因为我遇到的多线程场景基本都是数据初始化进去后面各个线程频繁读取几乎不修改。另外说一个我踩过的坑不管用哪种方式如果多个线程同时遍历一个没有被保护好的 List哪怕你只是读、不写也可能出问题。因为读的过程中如果有另一个线程在写读到的数据可能是不一致的。这个事情让我意识到多线程编程最怕的不是代码写得不对而是你觉得自己写对了。最后写这篇的时候我一直在回想自己当初对着 ConcurrentModificationException 不知所措的那个下午。说实话List 用起来很简单但真要把它用对、用好需要考虑的事情不少。遍历的时候能不能改、怎么删才高效、多线程下安不安全——这些问题不是面试才会考的是写真实代码的时候一定会碰到的。我自己学到的一个经验是知道一个类怎么用和知道它什么时候不能用同样重要。ArrayList 写起来顺手但如果你在多线程下不加处理地用它或者在大数据量下反复在中间位置插入删除迟早会出事。