集合是最容易被“会用”两个字掩盖细节的部分。很多事故不是代码逻辑写得不对,而是对返回类型、重载规则、可变性和并发语义判断错了。这篇文章只记录我实际遇到或复盘过的坑。

不可变与可变集合

Arrays.asList 不是一个普通 ArrayList

现象

下面的代码在本地跑得很顺,一上线就抛 UnsupportedOperationException:

1
2
List<Integer> nums = Arrays.asList(1, 2, 3);
nums.add(4);

根因

Arrays.asList 返回的是 Arrays 内部的定长 ArrayList,它继承了 AbstractList,但没有实现 add、remove、clear 这些修改方法。调用它们时,会走 AbstractList 里直接抛异常的默认实现。

它允许 set 修改元素,但不允许改变列表长度。很多人只记得它是 List,就把它当成 java.util.ArrayList 使用。

正确写法

只要后续可能增删元素,就用真实可变列表:

1
2
List<Integer> nums = new ArrayList<>(Arrays.asList(1, 2, 3));
nums.add(4);

如果只是为了构造一个不再变化的列表,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
2
list.remove(int index)
list.remove(Object o)

当传入 int 时,Java 会匹配到按索引删除的版本;只有传入 Integer 对象时,才会按元素值删除。

1
2
3
4
5
6
List<Integer> nums = new ArrayList<>(Arrays.asList(2, 1, 3));

nums.remove(1); // 删除下标 1,也就是值 1
System.out.println(nums); // [2, 3]

nums.remove(Integer.valueOf(1)); // 当前没有值 1,什么也不删

正确写法

如果变量类型是 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
2
3
4
5
6
7
Iterator<Item> iterator = list.iterator();
while (iterator.hasNext()) {
Item item = iterator.next();
if (item.isExpired()) {
iterator.remove();
}
}

CopyOnWriteArrayList 虽然允许边遍历边删除,但它是写时复制,写操作成本高,只适合读多写极少的小集合,不要把它当成普通列表滥用。

HashMap 的几个隐蔽问题

不要用会变化的字段作为 key

现象

用户对象作为 Map 的 key,后来修改了用户名字,再用同一个对象查询却取不到值。

根因

HashMap 先用 hashCode 定位桶,再用 equals 比较。一旦 key 参与 hashCode 或 equals 的字段发生变化,它的 hash 就会变,查询时会被定位到另一个桶。

1
2
3
4
5
6
7
8
9
10
11
12
13
class User {
private Long id;
private String name;

// equals 和 hashCode 都包含 name
}

Map<User, String> cache = new HashMap<>();
User user = new User(1L, "Tom");
cache.put(user, "value");

user.setName("Jerry");
cache.get(user); // 大概率是 null

正确写法

HashMap 的 key 必须使用不可变对象,或者保证放入后绝不修改参与 hash 的字段。业务对象尽量只拿稳定唯一标识做 key:

1
2
Map<Long, String> cache = new HashMap<>();
cache.put(user.getId(), "value");

如果必须用对象做 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
2
3
Map<String, Integer> counter = new HashMap<>();
int current = counter.get("order"); // key 不存在时为 null
current += 1; // 自动拆箱,NPE

根因

Integer 是对象,int 是基本类型。赋值给 int 时会自动拆箱,而 null 拆箱必然抛 NPE。

正确写法

在拆箱前处理默认值:

1
2
int current = counter.getOrDefault("order", 0);
current += 1;

或者把 value 类型定义为 Map<String, Integer> 时,每次取值都先做判空。这个坑在订单统计、库存计数、点赞数这类场景尤其常见。

Long 和 Integer 的 == 比较

现象

两个值都在 100 的 Integer 用 == 比较是 true,值变成 200 后又变成 false。

根因

Integer 和 Long 对 -128 到 127 范围内的值有缓存,这个范围内的自动装箱可能返回同一对象,所以 == 偶然成立。超出范围后会创建新对象,== 比较的是引用地址。

1
2
3
4
5
6
7
Integer a = 100;
Integer b = 100;
System.out.println(a == b); // true

Integer c = 200;
Integer d = 200;
System.out.println(c == d); // false

正确写法

比较数值使用 equals,或者先拆箱成基本类型:

1
2
System.out.println(a.equals(b));
System.out.println(c.intValue() == d.intValue());

不要依赖 JVM 的缓存范围,它会随实现和配置变化。

排查清单

遇到集合相关异常时,先按这个顺序排查:

  1. 返回的集合是否由 Arrays.asList、List.of、Collections.emptyList 等不可变集合产生。
  2. remove、get、indexOf 传入的是下标还是元素。
  3. 是否在增强 for 中直接修改了集合。
  4. HashMap 的 key 是否在放入后发生过修改。
  5. 多线程读写的集合是否真的是线程安全实现。
  6. 包装类型是否在拆箱前可能是 null,数值比较是否误用了 ==。