阿里面试原题 java面试直接过06 | 集合底层——HashMap、ConcurrentHashMap、CopyOnWriteArrayList

第 06 期:集合底层------HashMap、ConcurrentHashMap、CopyOnWriteArrayList

关键词:哈希散列、负载因子、树化阈值、弱一致迭代器、写时复制、CAS/桶锁、帮助迁移(help transfer)

1) 面试原题

  1. 解释 HashMap 的底层结构、扩容与树化机制。为什么有 负载因子 0.75
  2. ConcurrentHashMap 如何在不使用全局锁的情况下实现并发安全?为何不允许 null 键/值?
  3. CopyOnWriteArrayList 是如何做到读无锁的?适用于哪些场景,代价是什么?
  4. 如果让你在读多写少写多读多两种场景做集合选型,你如何权衡?

2) 第一性拆解(约束 → 成本 → 原语 → 结论)

约束(Constraints)

  • 集合需要在 时间(查找/插入)空间(内存占用/碎片) 之间权衡。
  • 并发场景下必须避免 数据竞争长时间阻塞 ,同时保持 可伸缩性
  • 大多数键具有良好的分布,但哈希碰撞总会发生;热点桶需要退化处理。

成本模型(Cost Model)

  • 哈希表 :平均 O(1) 查找,碰撞时链表 O(k) 或树 O(log k);扩容涉及 重新散列 的整体成本。
  • 锁/屏障:互斥带来上下文切换与队列等待;CAS 在高冲突下自旋浪费 CPU。
  • 写时复制(COW) :每次写入都会 复制整块数组,读几乎零开销,但写入成本与内存瞬时占用都很高。

最小原语(Primitives)

  • 数组 + 链表/红黑树 (HashMap 桶结构)、扰动函数 (spread)、负载因子阈值
  • CAS + 桶级锁 (ConcurrentHashMap,JDK 8+)、sizeCtl /ForwardingNode /help transfer(并发扩容)。
  • ReentrantLock + 数组复制 (CopyOnWriteArrayList 的写时复制),弱一致迭代器(snapshot)。

可验证结论(Check)

  • 通过构造高碰撞键观察 HashMap 的 链表→树化扩容行为
  • 在并发写读下对比 CHM 与 Collections.synchronizedMap(new HashMap<>()) 的吞吐差异。
  • 在读多写少场景验证 COWAL 的迭代性能与写入成本。

3) 总览:三者的核心取舍

  • HashMap :单线程高性能,允许 null,迭代 fail-fast;并发不安全。
  • ConcurrentHashMap :并发安全、弱一致迭代 、不允许 null;在高并发下优于全局锁方案。
  • CopyOnWriteArrayList :读无锁、迭代基于快照;写入开销大 ,适用于读远多于写的配置类/监听器列表。

4) HashMap 底层设计(JDK 8+)

4.1 结构与散列

  • 底层为 Node<K,V>[] table,桶(bin)冲突时使用 链表 ,当桶内结点数超过 树化阈值(默认 8) 且容量达到 最小树化容量(默认 64) 时转为 红黑树 ;当树结点数低于 反树化阈值(默认 6) 时退回链表。
  • 扰动函数(hash = key.hashCode()) ^ (hash >>> 16),减少高位信息丢失导致的桶集中。
  • 负载因子 :默认 0.75,在空间与查找冲突率之间折中;阈值 threshold = capacity * loadFactor 触发扩容。

4.2 插入/查找/扩容

  • 插入:定位桶 → 桶空则放入 → 冲突则链表遍历或树插入;当链表长度超过阈值,且容量≥64,树化
  • 查找:定位桶 → 链表/树内查找;红黑树保证近似 O(log k)。
  • 扩容:容量翻倍,元素按新掩码分布到 原位或原位+旧容量 桶(因为 2 的幂容量,散列重分布可优化为"低位不变/高位位翻转")。
  • 允许 null 键与值(null 键固定落在桶 0)。

4.3 迭代与并发风险

  • 迭代器为 fail-fast :并发结构性修改(非迭代器自身的 remove)会抛 ConcurrentModificationException
  • 并发下使用 HashMap 可能导致数据丢失或在旧版本出现链表环(JDK 7 的 rehash 竞争风险),因此不要在并发下使用

4.4 常见坑

  • equals/hashCode 契约:不符合将导致查找失败或桶分布异常。
  • 可变键 :将对象作键但其参与 equals/hashCode 的字段发生改变,会导致"找不回去"。
  • 高碰撞攻击:不可信输入(例如键都落在同一哈希值)会导致退化到 O(n),需防御(限流或改用树化 + 验证)。

5) ConcurrentHashMap(JDK 8+)工作原理

5.1 设计演进

  • JDK 7 使用 Segment 分段锁;JDK 8 改为 Node[] + 桶级锁/ CAS + 并发扩容,减少锁粒度并提升伸缩性。

5.2 写入与桶级锁

  • 初次插入:CAS 初始化 table;桶为空时使用 CAS 放置首节点;桶非空时对桶头节点(或 TreeBin)进行 synchronized 桶级锁 以串行化更新。
  • 计算类方法(如 compute/computeIfAbsent/merge)在桶级锁保护下执行,避免 ABA 与不变量破坏。

5.3 并发扩容(transfer)

  • 扩容由 sizeCtl 控制并发度;出现 ForwardingNode 表示该桶正在迁移,其他线程会 help transfer
  • 重分布遵循 HashMap 的"原位/原位+旧容量"规则,迁移过程中读操作通过 转发节点 寻址到新表,保证弱一致。

5.4 读取与迭代

  • 读取无锁(除非遇到正在迁移的桶需跟随指针),迭代器为 弱一致 :能看到一部分新写入,但不抛 ConcurrentModificationException
  • 不允许 null 键/值:避免歧义(无法区分"键不存在"与"键存在但值为 null"),也是并发计算方法的前提。

5.5 大小与统计

  • size() 可能是近似值(并发下),JDK 8+ 进行了分布式计数;若需要准确性,遍历或使用 mappingCount()(JDK 8 没有该方法,JDK 11+ 提供 mappingCount() 返回 long)。

5.6 常见坑与建议

  • 高冲突键 下,computeIfAbsent 可能成为热点;可引入 分区(striping)分层缓存
  • forEach 等批处理 API 在大表上可能与迁移竞争,注意任务切分与批量大小。

6) CopyOnWriteArrayList(COWAL)机制与取舍

6.1 读路径

  • 读操作直接基于 快照数组无锁 ,迭代器遍历的是 创建迭代器那一刻的数组副本 ,因此遍历期间的修改 不可见且不抛异常

6.2 写路径

  • add/remove/set 等写操作会在 独占锁(ReentrantLock) 下复制出 新数组 ,修改后 原子地替换 引用。
  • 代价:写入是 O(n) + 复制内存;频繁写入会导致 大量短期垃圾瞬时内存峰值

6.3 适用场景

  • 读远多于写:如监听器列表、全局白名单/黑名单、配置快照。
  • 迭代需要 稳定视图,并且能够容忍写入成本。

6.4 常见坑

  • 大列表或频繁写入下,COWAL 会产生显著 GC 压力与延迟抖动;应改用 CopyOnWriteArrayList 之外的结构(如 ConcurrentHashMap + 有序视图,或 ImmutableList + 引用切换)。

7) 代码示例(可运行/可改造)

7.1 HashMap:高碰撞与树化观察

java 复制代码
import java.util.*;

public class HashMapCollisionDemo {
    static class BadKey {
        final int id;
        BadKey(int id) { this.id = id; }
        @Override public int hashCode() { return 42; } // 故意制造高碰撞
        @Override public boolean equals(Object o) {
            return (o instanceof BadKey) && ((BadKey) o).id == id;
        }
    }
    public static void main(String[] args) {
        Map<BadKey, Integer> m = new HashMap<>();
        for (int i = 0; i < 100; i++) m.put(new BadKey(i), i);
        System.out.println("size=" + m.size());
        // 可在调试器中观察桶长度变化与是否树化(需要自行打印内部结构或借助 JFR/可视化)
    }
}

7.2 并发:CHM vs 全局锁 HashMap

java 复制代码
import java.util.*;
import java.util.concurrent.*;

public class MapThroughputCompare {
    static final int THREADS = 32, OPS = 200_000;

    public static void main(String[] args) throws Exception {
        compare(new ConcurrentHashMap<>());
        compare(Collections.synchronizedMap(new HashMap<>()));
    }
    static void compare(Map<Integer, Integer> map) throws Exception {
        ExecutorService es = Executors.newFixedThreadPool(THREADS);
        long t0 = System.nanoTime();
        for (int i = 0; i < THREADS; i++) {
            es.submit(() -> {
                ThreadLocalRandom rnd = ThreadLocalRandom.current();
                for (int j = 0; j < OPS; j++) {
                    int k = rnd.nextInt(100_000);
                    map.put(k, k);
                    map.get(k);
                }
            });
        }
        es.shutdown(); es.awaitTermination(1, TimeUnit.MINUTES);
        long t1 = System.nanoTime();
        System.out.printf("%s took %.2f ms, size=%d%n", map.getClass().getSimpleName(), (t1 - t0) / 1e6, map.size());
    }
}

7.3 COWAL:读多写少与迭代快照

java 复制代码
import java.util.*;
import java.util.concurrent.*;

public class CowalDemo {
    public static void main(String[] args) throws Exception {
        CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
        for (int i = 0; i < 10; i++) list.add(i);
        // 迭代快照:下面的写入对本次迭代不可见
        for (Integer v : list) {
            if (v == 5) list.add(999); // 写时复制,迭代仍遍历旧数组,不抛异常
            System.out.print(v + " ");
        }
        System.out.println("\nlist size after add=" + list.size());
    }
}

8) 选型与实践建议

  • 读多写少 :优先 CopyOnWriteArrayList(列表场景)或 ConcurrentHashMap + 定期生成不可变快照(Map 场景)。
  • 写多读多ConcurrentHashMap,并根据热点键/值进行 分区(striping)分层缓存批量操作
  • 有序需求LinkedHashMap(单线程)或 ConcurrentSkipListMap(并发,按键有序)。
  • 迭代稳定性 :需要强一致遍历用快照或不可变视图;接受弱一致可选 CHM 迭代。
  • 大对象/值复制 :避免 COWAL,用共享结构(如 List<WeakReference<T>> 或分段结构)降低复制成本。

9) 常见面试追问(准备好这几句)

  • HashMap 为什么在 8 个结点树化、6 个反树化、最小树化容量 64?
    • 平衡常见负载下的桶长度与树维护成本;低容量下宁可链表避免红黑树的额外指针与旋转开销。
  • CHM 为什么不允许 null
    • 并发语义下需区分"键不存在"与"键存在但值为 null",否则 computeIfAbsent 等 API 将出现歧义。
  • COWAL 为什么读无锁?
    • 迭代器持有的是数组快照引用,在写入复制时不会影响已有快照;因此读路径无需锁。

10) 速答卡(30 秒)

  • HashMap:数组 + 链表/红黑树,负载因子 0.75,扩容翻倍 + 原位/原位+旧容量;并发不安全,迭代 fail-fast。
  • ConcurrentHashMap :桶级锁 + CAS + 并发扩容(ForwardingNode/sizeCtl/help transfer),弱一致迭代,不允许 null
  • CopyOnWriteArrayList:读基于快照无锁,写入复制整数组,适合读多写少;写入成本与内存峰值高。

11) 作业(可验证、可评分)

  1. 构造 1e5 个高碰撞键,记录 HashMap 在不同容量下的桶最大长度与树化比例,解释观察到的拐点。
  2. 用上面的并发基准,对比 CHM 与全局锁 HashMap 的吞吐,调整 THREADS/OPS,写结论。
  3. 在 COWAL 上进行 1e5 次 add/remove 写入,记录 GC 日志与暂停,评估是否满足你的 SLO;给出替代方案。
  4. 复述"约束→成本→原语→结论"选择列表/Map 的方法论(200--300 字)。

12) 工程化清单

  • 键的 equals/hashCode 写单元测试;避免可变键。
  • 集合选择(HashMap/LinkedHashMap/ConcurrentHashMap/COWAL/SkipListMap)写进编码规约与模板。
  • 对读多写少的数据结构引入 快照发布 (不可变视图)与 定时刷新,避免误用 COWAL。
  • 在并发热点路径加入 可观测性(命中率、碰撞率、桶长度分布、扩容次数)。

注:示例基于 JDK 8+ 常见实现与行为。不同 JDK 版本可能在细节上略有差异,但整体取舍与语义一致。

相关推荐
小咕聊编程2 小时前
【含文档+PPT+源码】基于springboot的旅游路线推荐系统的设计与实现
spring boot·后端·旅游
超级神性造梦机器3 小时前
当“提示词工程”过时,谁来帮开发者管好 AI 的“注意力”?
前端·后端
弥金3 小时前
LangChain Chat Model
后端·openai·ai编程
吃饺子不吃馅3 小时前
小明问:要不要加入创业公司?
前端·面试·github
这可不简单3 小时前
前端面试题:请求层缓存与并发控制的完整设计(含原理拆解)
前端·javascript·面试
我是天龙_绍3 小时前
Spring Boot,整合 Spring MVC,实现第一个 API 接口
后端
Giant1003 小时前
针对 WebSocket 入门小白,结合这份聊天室代码,系统总结其基础知识点
后端
用户4099322502123 小时前
大表查询慢到翻遍整个书架?PostgreSQL分区表教你怎么“分类”才高效
后端·ai编程·trae