2755 字
约 9 分钟
22
S04-A07 · Java 集合选型:语义、性能与陷阱

HashMap 存了一百万条然后 OOM,通常不是因为"HashMap 太占内存",而是因为没人算过它要占多少。选型出错的代价往往隔很久才显现:某个接口在数据量上万后突然变慢、某处偶发 ConcurrentModificationException、某个缓存悄悄泄漏。这篇只给判据,不逐个讲 API。


1. 第一问不是性能,是语义

语义需求 候选 判据
保持插入顺序 ArrayList、LinkedHashMap、LinkedHashSet 是否需要可复现的输出
需要按 key 排序遍历 TreeMap、TreeSet、ConcurrentSkipListMap 比较器是否稳定
去重 HashSet、LinkedHashSet、TreeSet 是否同时要顺序
允许 null 键值 HashMap、LinkedHashMap 并发容器一律不允许
多线程读写 并发容器或外部加锁 见第 6 节
迭代期间可修改 CopyOnWriteArrayList、ConcurrentHashMap 写少读多才成立

性能是第二问。语义选错,性能再好也是错的;语义选对,性能差还能换实现补。反过来不成立。

2. ArrayList 与 LinkedList 的真实差异

操作 ArrayList LinkedList
按下标取 O(1),连续内存 O(n),需遍历节点
尾部追加 均摊 O(1),扩容时复制 O(1),但要分配节点
中间插入 O(n) 移动元素 定位 O(n) + 改指针 O(1)
迭代 顺序访问,缓存友好 指针跳转,缓存不友好
内存 一个数组 + 冗余容量 每元素一个节点,含两个引用与对象头

"LinkedList 插入快"只在已经持有节点引用时成立。按索引插入要先走 O(n),而这一段比 ArrayList 的数组移动慢得多——节点散布在堆上,每次跳转都可能缺页。

// 已持有迭代器位置时的插入:LinkedList 才有优势
ListIterator<String> it = list.listIterator();
while (it.hasNext()) {
    if (predicate(it.next())) it.add("x");   // 摊还 O(1)
}

随机访问的下限是内存层次,不是算法:ArrayList 的顺序遍历通常比 LinkedList 快数倍,量级随数据规模与 JVM 版本变化,按实测核对。

3. HashMap 的扩容与初始容量估算

HashMap 的容量是 2 的幂,负载因子默认 0.75。元素数超过 容量 × 0.75 触发扩容,容量翻倍并重新分布——一次扩容要把所有元素重新计算桶位置。

元素数 默认构造的扩容次数 建议初始容量
100 3~4 次 128
1,000 6~7 次 2,048
100,000 十余次 262,144
// 常用估算:capacity = (int) (expected / 0.75f) + 1,再由 HashMap 向上取 2 的幂
int expected = 1_000_000;
Map<Long, Order> m = new HashMap<>((int) (expected / 0.75f) + 1);

注意两个版本差异:JDK 8 之后链表长度超过 8 且容量 ≥ 64 时转红黑树,退化查询从 O(n) 变成 O(log n);Map.of() 与 Map.copyOf() 生成的是不可变实现,元素数有上限且不接受 null。容量按 JDK 版本行为核对。

能预知规模却不给容量,是最常见也最容易修的一处浪费。

4. TreeMap 与 LinkedHashMap 的场景

需求 选择 说明
按 key 有序遍历、范围查询 TreeMap subMap/headMap 是视图,不是拷贝
最近最少使用淘汰 LinkedHashMap + removeEldestEntry 访问顺序模式
保持插入顺序输出 LinkedHashMap 序列化、快照、可复现测试
需要并发且有序 ConcurrentSkipListMap 无锁读、跳表实现
class Lru<K, V> extends LinkedHashMap<K, V> {
    private final int cap;
    Lru(int cap) { super(cap, 0.75f, true); this.cap = cap; }   // true = 访问顺序
    @Override protected boolean removeEldestEntry(Map.Entry<K, V> e) {
        return size() > cap;
    }
}

这个 LRU 只在单线程成立:LinkedHashMap 的访问顺序本身是一次写操作,并发访问会改坏结构而不报错。

5. HashSet 与 TreeSet:比较器一致性

TreeSet 用比较器(或 compareTo)判等,HashSet 用 equals/hashCode 判等。两者不一致时,同一个元素在两个集合里的"是否重复"结论不同。

情况 HashSet 行为 TreeSet 行为
equals 相等、compareTo 不等 视为重复 视为不同元素
compareTo 返回 0,equals 为 false 视为不同 视为重复,后者被丢弃
hashCode 未覆写 桶分散,去重失效 不受影响(若 compareTo 正确)
// 反例:BigDecimal 的 equals 区分 1.0 与 1.00,compareTo 不区分
Set<BigDecimal> a = new HashSet<>();   // 两个都进
Set<BigDecimal> b = new TreeSet<>();   // 只进一个

TreeSet 还会把"比较器认为相等"的元素静默丢弃,不抛异常。集合变小了却没有任何报错,这是最难发现的一类数据丢失。

6. 并发容器的四个与各自的读写权衡

容器 读 写 适合
ConcurrentHashMap 无锁,近似 O(1) 分段/CAS,弱一致迭代 高并发键值缓存、注册表
CopyOnWriteArrayList 无锁,快照 每次写全量复制 监听器列表、极少写
BlockingQueue 家族 阻塞取 阻塞放,可设容量 生产者消费者、任务队列
ConcurrentSkipListMap 无锁 跳表插入,比 CHM 慢 需要有序与并发
// 复合操作必须用原子方法,而不是 get 后再 put
map.computeIfAbsent(key, k -> new Counter()).inc();
// 反例:先 containsKey 再 put,两步之间可能被别人插入

ConcurrentHashMap 不允许 null 键值,size() 在并发下只是估计值,iterator 是弱一致的——迭代期间不抛 ConcurrentModificationException,但也可能看不到新元素。

7. Collections.unmodifiableX 只是视图

List<String> src = new ArrayList<>(List.of("a"));
List<String> view = Collections.unmodifiableList(src);
src.add("b");
view.size();   // 2 —— 视图跟着原集合变

unmodifiableList 阻止的是通过该引用修改,不是阻止别处修改。真正的不变需要拷贝:List.copyOf(src)、List.of(...)、Collections.unmodifiableList(new ArrayList<>(src))。

不可变集合的收益在并发与安全发布:构造完成后状态不再变化,可以被任意线程共享而不需要同步,也不用担心被调用方改坏。代价是每次"修改"都要新建,元素多时复制成本明显。

8. ConcurrentModificationException 的机制

for (String s : list) {
    if (s.isEmpty()) list.remove(s);   // CME
}

迭代器创建时记 modCount,每次 next() 比对,结构被外部修改就抛。它不是线程安全检测——单线程用错方法一样抛,多线程里往往抛不出来。

做法 结果
iterator.remove() 安全
list.removeIf(...) 安全,内部走迭代器
for (int i...) 倒序删除 可用,但易错
遍历 CopyOnWriteArrayList 安全,遍历的是快照
并发改 ArrayList 可能抛 CME,也可能得到错误结果或数组越界

最后一行才是重点:并发下 CME 是"运气好时的报警",没抛不代表没坏。

9. equals/hashCode 不一致的后果

症状 机制
contains 返回 false 但元素确实在 哈希不同,落到了别的桶
HashSet 出现"重复"元素 同上,去重失效
HashMap 取不到刚放的值 同上
内存只增不减 键对象不可变字段被改,桶位置失效,旧条目再也访问不到
remove 无效 同上,删除时找的是新哈希
// 作为键的对象必须不可变,或至少哈希相关字段不可变
record TenantId(String value) { }     // record 自动生成 equals/hashCode

10. 装箱与原始类型集合

结构 每元素开销 100 万 int 的粗略量级
int[] 4 字节 约 4 MB
ArrayList<Integer> 引用 4/8 字节 + Integer 对象(16 字节起) 数十 MB 量级
HashMap<Integer, X> 上述再加 Node 对象与表数组 上百 MB 量级
第三方原始集合(如 fastutil) 接近数组 与 int[] 同量级
// 数值密集场景:先考虑数组,再考虑原始类型集合,最后才是包装类型集合
int[] counts = new int[N];
// 需要 map 语义时:Int2IntOpenHashMap 之类,注意 `-1` 与 null 的语义差异

-128..127 的 Integer 有缓存,小范围测不出差异;超出缓存区间每次装箱都新建对象,基准要用大数值。

11. 流式操作与集合的选用

关注点 流 集合操作
求值时机 惰性,终止操作才执行 立即
短路 findFirst、anyMatch 可提前结束 需手写循环
复用 一次性,用过的流不能再消费 可反复遍历
调试 断点难放,栈深 直观
副作用 应避免,peek 不是给副作用用的 明确
// 短路的价值:百万条里找一个就停
boolean hit = list.stream().anyMatch(this::matches);
// 反例:先 filter 到 List 再判断非空,白跑一遍全量

12. 排错

现象 原因 处理
数据量上万后接口变慢 默认容量的 HashMap 反复扩容 按预期规模给初始容量
偶发 ConcurrentModificationException 遍历时结构被改 用 removeIf 或并发容器
contains 判断错 equals/hashCode 未同时覆写 用 record 或成对实现
缓存条目只增不减 键的哈希相关字段被修改 键改不可变
TreeSet 元素莫名少了 比较器把不同元素判为相等 检查 compareTo 与 equals 一致性
内存超预期 包装类型集合 + 大量节点 换原始类型集合或数组
有序遍历结果不稳定 用了 HashSet/HashMap 换 LinkedHashSet 或 TreeMap

13. 边界

场景 更直白的选择 理由
元素在千级以内 ArrayList + 线性查找 常数因子比复杂度更影响结果
性能不敏感 HashMap 默认构造 可读性优先
团队不熟悉并发容器 外部加锁的普通集合 用错的并发容器比不用更危险
需要持久化顺序 数据库 order by 集合不承担存储语义
只是临时聚合 流 + toList() 不必设计结构

14. 自测

  1. 集合选型的第一问为什么是语义而不是性能?
  2. LinkedList 的插入优势在什么前提下才成立?
  3. 预期存 100 万条,初始容量该给多少,依据是什么?
  4. Collections.unmodifiableList 与 List.copyOf 的区别是什么?
  5. compareTo 返回 0 但 equals 为 false,放进 TreeSet 会发生什么?
  6. 并发修改普通 ArrayList 时没抛 CME,能说明没问题吗?
S04-A07 · Java 集合选型:语义、性能与陷阱
https://www.clxhxhhr.top/posts/6203/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。