Java 集合与常用 API 的坑:从线上事故到正确写法
集合是最容易被“会用”两个字掩盖细节的部分。很多事故不是代码逻辑写得不对,而是对返回类型、重载规则、可变性和并发语义判断错了。这篇文章只记录我实际遇到或复盘过的坑。
不可变与可变集合
Arrays.asList 不是一个普通 ArrayList
现象
下面的代码在本地跑得很顺,一上线就抛 UnsupportedOperationException:
1 | List<Integer> nums = Arrays.asList(1, 2, 3); |
根因
Arrays.asList 返回的是 Arrays 内部的定长 ArrayList,它继承了 AbstractList,但没有实现 add、remove、clear 这些修改方法。调用它们时,会走 AbstractList 里直接抛异常的默认实现。
它允许 set 修改元素,但不允许改变列表长度。很多人只记得它是 List,就把它当成 java.util.ArrayList 使用。
正确写法
只要后续可能增删元素,就用真实可变列表:
1 | List<Integer> nums = new ArrayList<>(Arrays.asList(1, 2, 3)); |
如果只是为了构造一个不再变化的列表,Java 9 以后可以直接用 List.of,它同样不可变,但语义更清楚。
不要给调用方返回 Collections.emptyList()
现象
服务接口在无数据时返回 Collections.emptyList(),调用方拿到后做了 list.add(...),然后线上报 UnsupportedOperationException。
根因
Collections.emptyList() 返回的是一个共享的不可变空列表。它适合作为“没有结果”的哨兵值,但不适合作为允许调用方继续修改的返回值。
正确写法
内部无数据时用空集合没问题,但要在接口契约里写清楚“返回集合可能不可变”。如果调用方要追加数据,应该返回:
1 | return new ArrayList<>(result); |
或者让调用方自行复制。关键不是绝对禁止空集合,而是不要让“不可变”这个隐藏属性成为别人的事故。
List 的删除与遍历
List.remove 的两个重载很容易用错
现象
想按对象删除一个 Integer,结果写成了 list.remove(1),删掉的是下标 1,而不是值为 1 的元素。
根因
List 有两个容易混淆的方法:
1 | list.remove(int index) |
当传入 int 时,Java 会匹配到按索引删除的版本;只有传入 Integer 对象时,才会按元素值删除。
1 | List<Integer> nums = new ArrayList<>(Arrays.asList(2, 1, 3)); |
正确写法
如果变量类型是 Integer,问题不大;如果手写了字面量,就要意识到它在走索引语义。更稳妥的是用 removeIf 表达业务条件:
1 | nums.removeIf(value -> value.equals(1)); |
增强 for 里删除元素
现象
遍历列表时发现某个元素需要删除,直接在 for (Item item : list) 里调用 list.remove(item),然后抛 ConcurrentModificationException。
根因
增强 for 背后仍然是 Iterator。Iterator 会检查集合的修改次数,只有通过它自己的 remove 修改,计数才会同步。直接调用集合的 remove 会让计数不一致,下一次 next 时发现“有人改了集合”,于是抛异常。
正确写法
优先用 removeIf:
1 | list.removeIf(item -> item.isExpired()); |
必须按元素删除时,用迭代器:
1 | Iterator<Item> iterator = list.iterator(); |
CopyOnWriteArrayList 虽然允许边遍历边删除,但它是写时复制,写操作成本高,只适合读多写极少的小集合,不要把它当成普通列表滥用。
HashMap 的几个隐蔽问题
不要用会变化的字段作为 key
现象
用户对象作为 Map 的 key,后来修改了用户名字,再用同一个对象查询却取不到值。
根因
HashMap 先用 hashCode 定位桶,再用 equals 比较。一旦 key 参与 hashCode 或 equals 的字段发生变化,它的 hash 就会变,查询时会被定位到另一个桶。
1 | class User { |
正确写法
HashMap 的 key 必须使用不可变对象,或者保证放入后绝不修改参与 hash 的字段。业务对象尽量只拿稳定唯一标识做 key:
1 | Map<Long, String> cache = new HashMap<>(); |
如果必须用对象做 key,要把参与 equals/hashCode 的字段设计为 final,并在文档里明确禁止修改。
并发读写 HashMap 不是线程安全
现象
多个线程同时 put 同一个 HashMap,偶尔出现数据丢失、死循环或 CPU 飙高。
根因
HashMap 本身没有同步保护。JDK 7 扩容时还可能形成环形链表,导致死循环;JDK 8 修复了环形链表,但并发 put 仍然可能覆盖数据或造成结构不一致。
正确写法
并发读写下使用 ConcurrentHashMap:
1 | Map<String, Object> cache = new ConcurrentHashMap<>(); |
ConcurrentHashMap 不允许 key 或 value 为 null。如果业务里依赖 null 表示“不存在”,要改用明确的哨兵值或 Optional。
如果只需要在初始化阶段一次性填充,之后只读,可以用 Collections.unmodifiableMap 包一层,避免后续误改。
装箱、拆箱与值比较
自动拆箱时最容易空指针
现象
从 Map<String, Integer> 里取出计数,直接参与加法,结果为 null 时抛 NullPointerException。
1 | Map<String, Integer> counter = new HashMap<>(); |
根因
Integer 是对象,int 是基本类型。赋值给 int 时会自动拆箱,而 null 拆箱必然抛 NPE。
正确写法
在拆箱前处理默认值:
1 | int current = counter.getOrDefault("order", 0); |
或者把 value 类型定义为 Map<String, Integer> 时,每次取值都先做判空。这个坑在订单统计、库存计数、点赞数这类场景尤其常见。
Long 和 Integer 的 == 比较
现象
两个值都在 100 的 Integer 用 == 比较是 true,值变成 200 后又变成 false。
根因
Integer 和 Long 对 -128 到 127 范围内的值有缓存,这个范围内的自动装箱可能返回同一对象,所以 == 偶然成立。超出范围后会创建新对象,== 比较的是引用地址。
1 | Integer a = 100; |
正确写法
比较数值使用 equals,或者先拆箱成基本类型:
1 | System.out.println(a.equals(b)); |
不要依赖 JVM 的缓存范围,它会随实现和配置变化。
排查清单
遇到集合相关异常时,先按这个顺序排查:
- 返回的集合是否由
Arrays.asList、List.of、Collections.emptyList等不可变集合产生。 remove、get、indexOf传入的是下标还是元素。- 是否在增强 for 中直接修改了集合。
- HashMap 的 key 是否在放入后发生过修改。
- 多线程读写的集合是否真的是线程安全实现。
- 包装类型是否在拆箱前可能是
null,数值比较是否误用了==。
