Java · 集合框架选型与 HashMap 底层

数据布局决定复杂度 — HashMap 是"数组定位 + 链表兜冲突 + 红黑树封底"的三层结构, 并发写只有 ConcurrentHashMap 一个正确答案

HashMap 一个桶的演化 (JDK 8) — 定位 / 冲突 / 树化 / 扩容 冲突 链长8 超载 ① 定位桶 table 数组 + 位运算取下标 table[8] 0 2 4 5 7 index = hash & (n-1) → 5 号桶 h = hashCode ^ (h >>> 16) 扰动函数: 高 16 位信息 混进低位; 否则小表只看 低几位 → 冲突暴增 n 必须是 2 的幂: n-1 全 1 → 均匀覆盖每个桶 O(1) 定位一个桶 默认容量 16, 传 10 也会规整到 16 定位到同一个桶? → 看 ② ② 链地址法 冲突节点在桶里挂成单链表 0 1 2 3 Node(k1, v1) next Node(k2, v2) next Node(k3, v3) JDK 8 尾插 (保持到达顺序) JDK 7 头插: 并发 resize 直接成环 成环后 get() 死循环, CPU 100% 链上逐个 equals 比对 → O(n) 链长一路涨到 8 → ③ hash 完全随机的业务其实很难到 8 ③ 树化 treeify 链长 ≥ 8 且容量 ≥ 64 容量不足 → 只扩容, 不树化 红黑树: 最长路径 ≤ 2 × 最短路径 查找 O(n) → O(log n) 节点删到 ≤ 6 退化回链表 (untreeify) 6 = 8 - 2: 防止在阈值附近反复横跳 TreeNode 占用是 Node 2 倍 — 树化是兜底, 不是目标 ④ resize 扩容 size > 0.75 × n → 容量 ×2 old table n=4 低位链 留在原桶 idx (hash & oldCap)==0 高位链 搬去 idx+oldCap (新增位=1) 1.8 不重算 hash: 只看新增那 1 位, 整条链按 (hash & oldCap) 拆两半 1.7 全量 rehash, 慢且头插有成环风险 单次 resize O(n), 均摊 O(1) 想装 1000 条: new HashMap(2048) (1000 / 0.75 = 1334 → 2048) 频繁扩容 = 反复搬家, 能免则免 并发写 Map — 三种选择的命运 HashMap 并发写 JDK 7: 头插 + 并发 resize → 链表成环 症状: get() 死循环, CPU 100% JDK 8: 改尾插不成环, 但并发 put 互相覆盖 → key 无声丢失, size 不准 — 比死循环更隐蔽, 几乎无法复现 结论: 有并发写 = 必出事 "低频写也照样丢" — 概率问题而已 Collections.synchronizedMap 每个方法都 synchronized(mutex) 一把全表锁: 读和读也互斥 吞吐 ≈ 单线程, 加了等于没加 迭代还要手动 synchronized(map), 否则照样 ConcurrentModificationException 结论: 遗留代码过渡用, 新代码禁用 JDK 1.0 时代的方案, 历史包袱 ConcurrentHashMap (JDK 8) 空桶: CAS 无锁直接插入 非空: synchronized 只锁桶首节点 锁粒度 = 1 个桶, 不同桶完全并行 计数: baseCount + CounterCell[] (LongAdder 同款分段思路), size 弱一致 结论: 并发 Map 的唯一正解 代价: key / value 都不许 null Legend 数组定位 链表/红黑树 推荐做法 丢数据/禁用 历史方案/开销

List 怎么选

  • • ArrayList: 动态数组, 扩容 1.5 倍, 随机访问 O(1)
  • • LinkedList: 定位 O(n) 吃掉插入优势, 只配当 Deque
  • • 已知数量就 new ArrayList(n), 一次到位零搬家
  • • 栈/队列用 ArrayDeque, 别碰 Vector 和 Stack

HashMap 三张王牌

  • • hash 扰动 + 容量 2 的幂: 位运算 O(1) 定位桶
  • • 树化 8/64, 退化 6: 链表最坏也有 O(log n) 的底
  • • resize 高低位拆分: 不重算 hash 的搬家优化

并发只有一条路

  • • 并发写 Map: ConcurrentHashMap, 没有备选
  • • 读多写少小表: CopyOnWriteArrayList
  • • 迭代中删除: removeIf / 迭代器自己的 remove
  • • 复合变更: computeIfAbsent / merge 原子搞定

💡 一句话理解

集合选型的本质是"数据布局决定复杂度": ArrayList 是数组, 随机访问 O(1) 但中间插删要整体搬家; HashMap 是"数组 + 链表 + 红黑树"的三层结构 — hash 位运算定位桶 O(1), 冲突了沿链表逐个 equals, 链太长树化封底到 O(log n)。看懂"桶下标 = 扰动后的 hash 与上 (n-1)"这一行, 容量为什么必须是 2 的幂、扰动函数为什么存在、resize 为什么按高位拆两条链, 全部连成一条线。

并发维度上答案只有一个: 任何会被多个线程写的集合, 都必须换成并发容器。HashMap 并发写的结局是 JDK 7 的链表成环死循环或 JDK 8 的无声丢数据; synchronizedMap 是一把全表锁形同虚设; ConcurrentHashMap 用"CAS 插空桶 + synchronized 锁桶首节点"把锁粒度压到单个桶。选型时先问访问模式 (读多写少? 要有序? 键是枚举?), 再问并发度, 答案基本就出来了。

🧠 必知必会 必考 & 必会

ArrayList 扩容
add 时若满: 新容量 = 旧容量 + (旧容量 >> 1) 即 1.5 倍, Arrays.copyOf 整体搬迁; 默认容量 10 是懒分配 (首次 add 才建数组); 均摊后 add 仍是 O(1)。
ArrayList<Integer> a = new ArrayList<>();   // 懒分配: 首 add 才建 10 长数组
for (int i = 1; i <= 11; i++) a.add(i); // 第 11 次 add 触发扩容
// 关键: 10 → 10 + (10 >> 1) = 15 → 22 → 33, 均摊后 add 仍 O(1)
LinkedList 真实定位
双向链表: 头尾插删 O(1), 但"按下标插入"要先走 O(n) 定位; 理论 O(1) 插入被遍历成本吃掉。真实用途是当 Deque (队列/栈), 列表场景几乎全被 ArrayList + ArrayDeque 替代。
Deque<Integer> dq = new LinkedList<>(); // 真实定位: 当 Deque 用
dq.offerFirst(1); dq.offerLast(2);    // 头尾插删 O(1) → [1, 2]
dq.pollFirst();                          // → 1 出队, 剩 [2]
// 关键: get(i) 要从头部逐节点走 O(n), 按下标访问别用它
hash 扰动函数
(h = key.hashCode()) ^ (h >>> 16): 高 16 位异或进低 16 位。桶下标只取低 log2(n) 位, 小表时不扰动会让高位信息全部丢失, 冲突暴增。
static int hash(Object key) {
    int h;
    return (h = key.hashCode()) ^ (h >>> 16); // 关键: 高 16 位混进低 16 位
}
// n=16 时下标只取低 4 位, 不扰动 → 高位信息全丢, 冲突暴增
容量 2 的幂
hash & (n-1) 等价 hash % n 但免除法; n-1 二进制全 1 才能均匀取到每个桶。传 10 会被 tableSizeFor 规整到 16, 默认 16, 上限 2^30。
int hash = 0x25, n = 16;        // n 必须是 2 的幂
int idx = hash & (n - 1);       // → 5, 等价 hash % n 但免除了除法
// 关键: n-1 = 0b1111 全 1 → 均匀盖住每个桶; 传 10 会规整到 16
树化 8/64 退化 6
链长 ≥ 8 且 table ≥ 64 才 treeify, 容量不足优先扩容 (冲突多半是容量问题); 删除到 ≤ 6 退化回链表, 6 = 8 - 2 防止阈值附近反复横跳。理想 hash 下链到 8 的概率约千万分之六 — 树化是兜底不是常态。
// putVal 源码: 同一桶链长到 8 才尝试树化
if (binCount >= TREEIFY_THRESHOLD - 1) treeifyBin(tab, h);
// treeifyBin 先查容量: n < 64 → 只扩容不树化 (冲突多半是表太小)
// 关键: 删到 ≤ 6 退化回链表, 6 = 8 - 2 防阈值附近反复横跳
负载因子与 resize
0.75 是空间利用率与冲突率的折中; 超过阈值容量翻倍, 单次 O(n) 均摊 O(1); JDK 8 拆高低两条链, 不重算 hash, 只看新增那一个 bit。
Map<String, Integer> m = new HashMap<>(4); // 阈值 = 4 × 0.75 = 3
m.put("a", 1); m.put("b", 2); m.put("c", 3); m.put("d", 4);
// 第 4 次 put: ++size(4) > 3 → resize 到 8, 单次 O(n) 均摊 O(1)
// 关键: 拆链只看 (hash & oldCap)==0 ? 原桶 : idx+oldCap, 不重算 hash
equals/hashCode 契约
equals 相等 → hashCode 必须相等 (反向不要求); 违反 → "存得进取不出"。hashCode 相同只是同桶候选, 再用 equals 精筛。重写其中一个必须重写另一个。
// Bad 类只重写了 equals: 两个 new Bad("A") 是 equals 相等的
map.put(new Bad("A"), 1);
map.get(new Bad("A"));  // → null: hashCode 不同落进不同桶
// 关键: equals 涉及的字段必须全部进 hashCode (Objects.hash(...))
fail-fast
迭代器创建时记下 modCount, next() 每次校验, 不一致抛 ConcurrentModificationException; 目的是尽快暴露 bug 而非保证安全, 单线程删错照样中招。
List<Integer> list = new ArrayList<>(List.of(1, 2, 3, 4));
for (Integer x : list)
    if (x == 2) list.remove(Integer.valueOf(2)); // → CME
// 关键: next() 校验 modCount, 单线程删错照样中招
fail-safe 与弱一致
CopyOnWriteArrayList 迭代的是写入瞬间的不可变快照; ConcurrentHashMap 迭代器弱一致 — 不抛 CME 但可能看不到迭代期间的修改, 语义要自己兜。
List<Integer> cow = new CopyOnWriteArrayList<>(List.of(1, 2));
for (Integer x : cow) cow.add(3); // 不抛 CME, 迭代仍是 [1, 2] 快照
// 关键: CHM 迭代器同理弱一致 — 不抛 CME 但可能漏看迭代中的修改
CHM 的 JDK 8 实现
空桶 CAS 无锁插入; 有节点 synchronized 锁首节点 (粒度=单桶); size 用 baseCount + CounterCell 分段累加 (LongAdder 思路); 扩容时多线程还能协助搬桶; key/value 均禁 null。
ConcurrentHashMap<String, Integer> m = new ConcurrentHashMap<>();
m.put("k", null); // → NullPointerException: key/value 均禁 null
// 关键: 空桶 CAS 无锁插入; 有节点 synchronized 锁首节点 (粒度=单桶)
// size = baseCount + CounterCell[] 分段累加 (LongAdder 思路)
LinkedHashMap
在 HashMap 之上多维护一条双向链表: accessOrder=false 插入序, true 访问序; 重写 removeEldestEntry 即成 LRU。accessOrder 模式连 get 都改结构, 并发风险更大。
Map<String, Integer> m = new LinkedHashMap<>(16, 0.75f, true);
m.put("a", 1); m.put("b", 2); m.get("a"); // 访问序: get 也算访问
m.keySet();             // → [b, a]: a 被访问后移到链尾 (最新)
// 关键: accessOrder=true + 重写 removeEldestEntry 即成 LRU
TreeMap / TreeSet
红黑树按键有序: 增删查 O(log n); floorKey/ceilingKey/subMap 天然支持范围查询; key 必须 Comparable 或构造时给 Comparator。
TreeMap<Integer, String> m = new TreeMap<>();
m.put(10, "a"); m.put(20, "b"); m.put(30, "c");
m.floorKey(25);            // → 20: ≤ 25 的最大 key, O(log n)
m.subMap(10, true, 20, true); // → {10=a, 20=b} 范围视图
EnumSet / EnumMap
按 ordinal 用位向量/数组实现: 无 hash 计算、无冲突、缓存友好, 比 HashSet/HashMap 又快又省; 限定 key 是同一枚举类 — 状态机/标志位的最佳载体。
enum Perm { READ, WRITE, ADMIN }
EnumSet<Perm> rw = EnumSet.of(Perm.READ, Perm.WRITE); // 位向量
EnumMap<Perm, String> label = new EnumMap<>(Perm.class);
label.put(Perm.ADMIN, "管理员"); // 关键: 无 hash 无冲突, 数组直取
不可变集合
List.of/Map.of (JDK 9+) 真不可变且省内存, 元素禁 null; Arrays.asList 是定长视图 (可 set 不可 add); unmodifiableList 只是只读视图, 包装的原表仍可被改。
List<String> a = List.of("x", "y");
a.set(0, "z");           // → UnsupportedOperationException
List<Integer> b = Arrays.asList(1, 2);
b.set(0, 9); b.add(3);     // set → [9, 2]; add 抛异常 (定长)
// 关键: unmodifiableList 只是只读视图, 原表仍可被改

🏭 生产实战 real world

场景 1 · 热点配置本地缓存: LRU 上限千条防穿透

热点用户资料直接打 DB 会被流量打穿, 本地挡一层 LRU; 手写双向链表容易错, LinkedHashMap 十行搞定。

// 热点数据本地缓存: LRU 上限 1000, 防穿透/防雪崩的第一道闸
private static final int MAX = 1000;
private final Map<String, UserVO> cache =
    new LinkedHashMap<>(16, 0.75f, true) {  // true = accessOrder, LRU 语义
      @Override protected boolean removeEldestEntry(Map.Entry<String, UserVO> e) {
        return size() > MAX;             // 插入后自动淘汰"最久未访问"那条
      }
    };

public UserVO get(String uid, Function<String, UserVO> loader) {
  return cache.computeIfAbsent(uid, loader); // 单线程读; 多线程要包 synchronizedMap
}

要求过期时间与并发的话升级 Caffeine, 但"LRU + 上限"的最小实现就是它。

场景 2 · 读多写少的白名单/监听表: CopyOnWriteArrayList

白名单只在发版时刷新 (每周一次写), 读 QPS 5 万 — COW 读零锁, 迭代还是天然快照。

// 写频率极低 + 读极多: COW 每次读只是拿引用, 无锁无快照拷贝
private final List<Pattern> whitelist = new CopyOnWriteArrayList<>();

public boolean allowed(String url) {
  for (Pattern p : whitelist) {          // 迭代的是写入瞬间的不可变快照
    if (p.matcher(url).find()) return true;  // 迭代中刷新也不会抛 CME
  }
  return false;
}

public void refresh(List<String> patterns) {
  List<Pattern> next = patterns.stream().map(Pattern::compile).toList();
  whitelist.clear();                    // 注意: 每次写都整表复制, 表必须小 (百级以内)
  whitelist.addAll(next);
}

场景 3 · 高并发计数/限流: ConcurrentHashMap + LongAdder

AtomicLong 在高竞争下 CAS 全在空转, LongAdder 分段累加把热点拆掉。

// 网关按 API 计数: 每 key 独立 LongAdder, 单 key 内部再 Cell 分散竞争
private final ConcurrentHashMap<String, LongAdder> counters = new ConcurrentHashMap<>();

public void record(String api) {
  counters.computeIfAbsent(api, k -> new LongAdder()).increment();
}

public long snapshot() {
  long total = 0;
  for (LongAdder v : counters.values()) total += v.sum(); // sum 弱一致, 监控足够
  return total;
}
// 压测对比: 32 线程打同一计数点, AtomicLong ~4M ops/s → LongAdder ~31M ops/s

场景 4 · TreeMap 有序路由表: 版本区间/IP 段匹配

灰度配置按"版本 ≥ x 生效"发布, 逐条 if 会随着配置膨胀 — floorEntry 一跳命中。

// IP 段归属: 网段起点转 long 存 TreeMap, floorEntry 找 <= ip 的最大起点
private final TreeMap<Long, String> ranges = new TreeMap<>();

public void load(Map<Long, String> cidrs) { ranges.putAll(cidrs); }

public String locate(long ip) {
  Map.Entry<Long, String> e = ranges.floorEntry(ip);  // O(log n), 不用全表扫
  return e == null ? "unknown" : e.getValue();
}
// 版本灰度同款: floorEntry(当前版本) 命中"最近的已发布"配置, 上线即生效

场景 5 · 批量导入预分配 ArrayList(n) 消除扩容拷贝

导出 10 万行: 默认 ArrayList 从 10 起步 1.5 倍爬, 中途 Arrays.copyOf 搬家十几轮, 全是白干的年轻代垃圾。

// 已知总量就直接给足: 一次分配到位, 中途零拷贝
List<Order> orders = new ArrayList<>(rows.size());

// 量化 (100k 对象, JFR): 懒分配多产生 ~1.4MB 临时数组, young GC 6 次 → 0 次
// 更优是流式: 边查边写出, 根本别把全表堆在堆里
try (PreparedStatement ps = conn.prepareStatement(SQL)) {
  ResultSet rs = ps.executeQuery();
  while (rs.next()) {
    orders.add(mapRow(rs));
  }
}

场景 6 · EnumMap 状态机转移表替代 Map<String,...>

订单状态流转用 Map<String, Map<String,String>> 做, 每次查询两次 hash + 字符串比对; 键本来就是枚举, EnumMap 是数组直取。

// EnumMap 底层是按 ordinal 索引的数组: 无 hash 计算, 无冲突, 缓存友好
enum State { NEW, PAID, SHIPPED, DONE, CLOSED }

private final Map<State, Map<Event, State> transitions = new EnumMap<>(State.class);

private void put(State s, Event e, State next) {
  transitions.computeIfAbsent(s, k -> new EnumMap<>(Event.class)).put(e, next);
}

public State next(State cur, Event ev) {
  State s = transitions.getOrDefault(cur, Map.of()).get(ev);
  if (s == null) throw new IllegalStateTransition(cur, ev); // 非法流转直接拒绝
  return s;
}

场景 7 · 去重: 业务键重写 equals/hashCode 的正确姿势

按"税号+地区"判重: 手写 equals 漏了 hashCode 就会出现"明明重复却查不到"。

// 用 record: equals/hashCode 按全部字段自动生成, 契约不会漏
public record TaxNo(String code, String region) {}

Set<TaxNo> seen = ConcurrentHashMap.newKeySet();
boolean first = seen.add(new TaxNo(code, region));  // false = 重复申报, 直接拒

// 手写时的底线: equals 涉及的字段必须全部进 hash
// Objects.hash(code, region) 一行生成, 别自己造魔数 hash

场景 8 · computeIfAbsent 做本地缓存单飞 (防击穿)

缓存失效瞬间同一 sku 上百并发同时穿透到 DB — computeIfAbsent 保证同 key 只放一个线程进去加载。

// 同 key 的写入在同一桶上串行化: 别的线程等结果, 不再重复打 DB
private final ConcurrentHashMap<String, Product> cache = new ConcurrentHashMap<>();

public Product get(String sku) {
  return cache.computeIfAbsent(sku, this::loadFromDb);
  // 注意: loadFromDb 里绝不能再写这个 cache — 递归 compute 会卡死/抛 ISE
  // 加载慢的 key 要过期: 换 Caffeine (expireAfterWrite + 同 key 单飞)
}

场景 9 · 遍历时安全删除: removeIf / 迭代器 remove

摘除 30 天未活跃用户, 增强 for 里直接 list.remove 必抛 ConcurrentModificationException。

// 正确 1: removeIf, 内部用迭代器删除, 一行搞定
users.removeIf(u -> u.lastActive().isBefore(cutoff));

// 正确 2: 迭代器 remove — 每轮 next() 只能配一次 remove()
for (Iterator<User> it = users.iterator(); it.hasNext(); ) {
  if (it.next().lastActive().isBefore(cutoff)) it.remove();
}

// Map 同理: map.keySet().removeIf(k -> stale(k))

场景 10 · 不可变集合 List.of/Map.of 做常量表

错误码映射这种运行期不许改的表, 用可变集合等于埋雷 — 谁手滑 put 一条就污染全局。

// 任何 add/put 直接 UnsupportedOperationException — 越早炸越好
private static final Set<String> BLOCKED_IPS = Set.of("10.1.3.4", "10.1.3.9");
private static final Map<Integer, String> CODES =
    Map.of(400, "BAD_REQUEST", 404, "NOT_FOUND", 500, "SERVER_ERROR");

// 超过 10 对会编译不过 → Map.ofEntries(Map.entry(k, v), ...)
// 元素禁 null; JDK 9 前用 Collections.unmodifiableMap 包一层只读视图

⚠️ 编码注意与常见坑 pitfalls

坑 1 · HashMap 并发写 resize — put 偶发丢 key 或 CPU 100%: JDK 7 头插在并发扩容时成环, get() 死循环; JDK 8 改尾插不成环, 但并发 put 仍互相覆盖丢数据。正解: ConcurrentHashMap, 没有第二个选项。
// 错: Map<String,Integer> m = new HashMap<>();  多线程并发 put
//     → JDK 7 resize 成环 get() 死循环; JDK 8 无声丢 key
// 对: Map<String,Integer> m = new ConcurrentHashMap<>();
坑 2 · 重写 equals 不重写 hashCode — map.put(k,v) 后 map.get(k) 返回 null, Set 里出现"相等"的重复元素: 相等对象落进不同桶。正解: Objects.hash(...) 覆盖所有 equals 字段, 或直接用 record。
// 错: class Point 只重写 equals → map.get(new Point(1,2)) → null
//     set.add(p1); set.add(p2); → size()==2, "相等"却重复
// 对: record Point(int x, int y) {} 或手写 Objects.hash(x, y)
坑 3 · 可变对象做 key 后改字段 — put 之后改了参与 hash 的字段, hash 漂移到别的桶, 这个 entry 永久取不出来还占着内存。正解: key 只用不可变类型; 状态变了就先 remove 旧 key 再 put。
// 错: map.put(user, v); user.setLevel(9); → hash 漂移, entry 永久取不出
// 对: key 用 String/Integer/record 等不可变类型;
//     状态必须变 → 先 remove 旧 key, 改完再 put 回去
坑 4 · 增强 for 里 remove — 抛 ConcurrentModificationException: 迭代器的 modCount 校验失败, 单线程也中招。正解: list.removeIf(...) 或迭代器自己的 it.remove()。
// 错: for (User u : users) if (stale(u)) users.remove(u);
//     → ConcurrentModificationException, 单线程也中招
// 对: users.removeIf(u -> stale(u));  或迭代器 it.remove()
坑 5 · Arrays.asList 的两副面孔 — add/remove 抛 UnsupportedOperationException (定长视图); Arrays.asList(intArr) 把整个数组当成一个元素 (泛型不吃基本类型)。正解: new ArrayList<>(Arrays.asList(...)); 基本类型先 Arrays.stream(arr).boxed()。
// 错: Arrays.asList("a","b").add("c"); → UnsupportedOperationException
//     Arrays.asList(new int[]{1,2}) → List<int[]>, 整个数组当一个元素
// 对: new ArrayList<>(Arrays.asList("a","b"));
//     基本类型: Arrays.stream(arr).boxed().toList()
坑 6 · subList 不是独立列表 — 它是原列表的视图: 原列表一改, 视图操作抛 CME; 直接序列化 subList 也会异常。正解: new ArrayList<>(list.subList(a, b)) 拷贝脱钩后再用。
// 错: List<Integer> v = list.subList(0, 2); list.add(9); v.get(0);
//     → ConcurrentModificationException (视图依赖原表的 modCount)
// 对: List<Integer> copy = new ArrayList<>(list.subList(0, 2));
坑 7 · 集合里的 null 陷阱 — remove(null)/contains(null) 在 ArrayList 能跑, 换到 CHM 等并发容器直接 NPE; map.get 返回 null 到底是"没有"还是"存了 null"说不清。正解: 入集合前 Objects.requireNonNull, 不给 null 留位置。
// 错: list.remove(null) 在 ArrayList 能跑, 换 chm.put("k", v) 遇 null → NPE
//     map.get(k) == null 分不清"不存在"还是"值就是 null"
// 对: Objects.requireNonNull(v) 入集合前挡掉; 判存在用 containsKey(k)
坑 8 · 迭代器外修改列表 — 循环外先拿了 iterator, 循环里又 list.add(...), 下一次 it.next() 照样抛 CME。正解: 修改收集到临时列表, 迭代完统一 removeAll/addAll; 或直接 removeIf。
// 错: Iterator<User> it = users.iterator(); 迭代中 users.add(x);
//     → 下一次 it.next() 照样抛 CME
// 对: 先收集 List<User> toAdd, 迭代完统一 users.addAll(toAdd)
坑 9 · CHM 不吃 null — map.put(k, null) 直接 NPE: 刻意设计, 否则 get 返回 null 无法区分"不存在"与"值是 null"的歧义。正解: 空值语义用 Optional 或哨兵对象; 判存在用 containsKey。
// 错: chm.put("k", null); → NullPointerException (刻意设计)
//     否则 get 返回 null 分不清"不存在"与"值是 null"
// 对: chm.put("k", Optional.empty()); 判存在用 containsKey(k)
坑 10 · CHM 上的 check-then-act — if (!m.containsKey(k)) m.put(k,v) 两步之间别人已经 put, 竞态依旧存在。正解: putIfAbsent / computeIfAbsent / compute 这些原子复合操作。
// 错: if (!m.containsKey(k)) m.put(k, v);
//     两步之间别人已经 put, 竞态依旧存在
// 对: m.putIfAbsent(k, v); / m.computeIfAbsent(k, key -> load(key));
坑 11 · Collections.sort 救不了 LinkedList — 排序要先 toArray, 而 get(i) 本身就是 O(n), 万级元素时遍历成本先爆。正解: 排序/随机访问场景换 ArrayList; 队列场景用 ArrayDeque。
// 错: LinkedList<User> l = ...; l.get(i) 从头逐节点走 O(n)
//     万级元素随机访问/排序的遍历成本先爆
// 对: 排序/随机访问换 ArrayList; 栈/队列用 ArrayDeque
坑 12 · 依赖 HashSet 迭代顺序 — 今天"碰巧有序", 换 JDK 版本/hash 算法/数据分布就变, 测试发现不了。正解: 插入序用 LinkedHashSet, 排序序用 TreeSet — 需要顺序就显式声明。
// 错: 依赖 new HashSet<>(List.of("b","a")) 的迭代顺序
//     → 今天碰巧有序, 换 JDK 版本/数据分布就变
// 对: 插入序 LinkedHashSet; 排序序 TreeSet — 显式声明
坑 13 · new HashMap(1000) 装到 750 还是扩容 — 1000 是 table 大小, resize 阈值是 1000 × 0.75 = 750。正解: 容量给 (expected/0.75)+1 再向上取 2 的幂 (1000 条给 2048), 或 Guava Maps.newHashMapWithExpectedSize(1000)。
// 错: new HashMap<>(1000);  1000 是 table 大小, 装到 750 就 resize
// 对: new HashMap<>(2048);  // 1000/0.75≈1334 → 规整到 2048
//     或 Guava Maps.newHashMapWithExpectedSize(1000)
坑 14 · CopyOnWriteArrayList 当大表用 — 每次写都全量复制: 万级元素每秒写百次, CPU 与 GC 直接起飞。正解: 只用于"小表 + 读多写极少" (监听器/白名单); 写多就换 CHM 聚合。
// 错: 万级大表用 COW 且每秒写百次
//     → 每次写全量复制一个新数组, CPU 与 GC 起飞
// 对: COW 只用于小表+读多写极少; 写多换 CHM 聚合
坑 15 · TreeMap key 不可比 — put 第二个元素抛 ClassCastException: TaxNo cannot be cast to java.lang.Comparable。正解: 构造时传 Comparator, 或让 key 实现 Comparable 且比较逻辑稳定。
// 错: new TreeMap<>().put(t1, "a"); .put(t2, "b");
//     → ClassCastException: TaxNo cannot be cast to Comparable
// 对: new TreeMap<>(Comparator.comparing(TaxNo::code));
坑 16 · 迭代器 remove 连按两次 — 一次 next() 只能配一次 remove(), 连续两次 it.remove() 抛 IllegalStateException。正解: 删完 continue 让下一轮 next() 先行; 批量删用 removeIf。
// 错: it.next(); it.remove(); it.remove();
//     → IllegalStateException: 一次 next() 只配一次 remove()
// 对: 删完让下一轮 next() 先行; 批量删直接 removeIf
坑 17 · Stream toMap 键冲突 — Collectors.toMap(k, v) 遇重复 key 直接抛 IllegalStateException: Duplicate key。正解: 带第三个参数显式给合并策略 toMap(k, v, (a, b) -> b)。
// 错: users.stream().collect(Collectors.toMap(User::id, User::name));
//     遇重复 id → IllegalStateException: Duplicate key
// 对: Collectors.toMap(User::id, User::name, (a, b) -> b);
坑 18 · Long/Integer 用 == 比较 — 缓存池 -128~127 内"碰巧相等" (同一对象), 超出范围变 false: 测试全绿, 上线翻车。正解: 包装类型一律 equals, 或先拆成基本类型再比。
Long a = 127L, b = 127L; a == b;  // → true (缓存池内同一对象)
Long x = 128L, y = 128L; x == y;  // → false, 缓存只到 127
// 对: x.equals(y); 或拆箱 x.longValue() == y.longValue()
坑 19 · 把 CHM 迭代当快照 — 弱一致迭代器不抛 CME 但可能看不到迭代期间的并发 put, 误判成"丢数据"。正解: 要一致性快照就 new ArrayList<>(map.values()) 拷一份; 复合变更走 compute 系列原子操作。
// 错: for (V v : chm.values()) 期间并发 put 看不到 → 误判"丢数据"
// 对: List<V> snap = new ArrayList<>(chm.values()); 拷贝快照再处理
坑 20 · keySet 上用 map.remove — 增强 for 遍历 map.keySet() 里调 map.remove(k), 同样改 modCount 抛 CME。正解: map.keySet().removeIf(...) / entrySet().removeIf(...), 或迭代器 remove。
// 错: for (K k : map.keySet()) if (stale(k)) map.remove(k);
//     → 同样改 modCount, 抛 ConcurrentModificationException
// 对: map.keySet().removeIf(k -> stale(k));  或 entrySet().removeIf(...)