集合-Map深入分析

2 阅读1小时+

概述

在 Java 集合框架中,Map 是独立于 Collection 的另一座基石,它并非简单的“数据容器”,而是对“键值映射”这一抽象概念的精确建模。从 Map 顶层契约到 SortedMap 的有序语义,再到 NavigableMap 的导航能力以及 ConcurrentMap 定义的并发约束,接口层次逐步叠加语义;而在实现侧,从 HashMap 的数组+链表+红黑树混合结构,到 LinkedHashMap 维护顺序的双向链表,从 TreeMap 基于红黑树的严格有序,到 ConcurrentHashMap 通过 synchronized + CAS 实现桶级细粒度锁,乃至 ConcurrentSkipListMap 无锁跳表,Map 分支展现了数据结构与并发设计在工程领域的最精妙结合。系列最后一篇,我们将深入这些核心实现类的底层原理,从扰动函数到扩容迁移,从多线程协作到弱引用清理,配合完整的流程图、Demo 代码与性能分析,彻底透析 Java Map 的全貌。


模块 1:Map 接口设计——键值映射的顶层契约

Map 接口将“键”映射到“值”,每个键至多映射到一个值,键不可重复。它与 Collection 接口的隔离出自清晰的设计哲学:Collection 存储单一元素,关注迭代、元素存在性;而 Map 存储键值对,关注通过键快速检索值。两者在 API 上无继承关系,但可通过 entrySet()keySet()values() 桥接视图。核心方法包括:

  • 基本操作:put(V), get(Object), remove(Object), containsKey(Object), containsValue(Object), size(), isEmpty()
  • 批量操作:putAll(Map), clear()
  • 视图操作:keySet(), values(), entrySet()

SortedMap 进一步要求键可排序,提供 firstKey(), lastKey(), subMap() 等。NavigableMap 扩展出更丰富的导航方法(lowerEntry, floorEntry, ceilingEntry, higherEntry 及降序视图)。ConcurrentMap 在并发环境下扩展了原子操作如 putIfAbsent, remove(Object, Object), replace(K, V, V), compute 等。实现类按照不同维度展开,形成下图所示层次:

classDiagram
    class Map~K,V~ {
        <<interface>>
        +put(K key, V value)
        +get(Object key)
        +remove(Object key)
        +size()
        +keySet()
        +values()
        +entrySet()
    }
    class SortedMap~K,V~ {
        <<interface>>
        +comparator()
        +firstKey()
        +lastKey()
        +subMap(K from, K to)
    }
    class NavigableMap~K,V~ {
        <<interface>>
        +lowerEntry(K key)
        +floorEntry(K key)
        +ceilingEntry(K key)
        +higherEntry(K key)
        +descendingMap()
    }
    class ConcurrentMap~K,V~ {
        <<interface>>
        +putIfAbsent(K key, V value)
        +remove(Object key, Object value)
        +replace(K key, V old, V new)
        +compute(K key, BiFunction)
    }
    class HashMap~K,V~
    class LinkedHashMap~K,V~
    class TreeMap~K,V~
    class Hashtable~K,V~
    class ConcurrentHashMap~K,V~
    class ConcurrentSkipListMap~K,V~
    class WeakHashMap~K,V~
    class IdentityHashMap~K,V~
    
    Map <|.. SortedMap
    SortedMap <|.. NavigableMap
    Map <|.. ConcurrentMap
    Map <|.. HashMap
    HashMap <|-- LinkedHashMap
    SortedMap <|.. TreeMap
    NavigableMap <|.. TreeMap
    NavigableMap <|.. ConcurrentSkipListMap
    ConcurrentMap <|.. ConcurrentHashMap
    ConcurrentMap <|.. ConcurrentSkipListMap
    Map <|.. Hashtable
    Map <|.. WeakHashMap
    Map <|.. IdentityHashMap

此图描绘了 Map 家族的核心继承与实现脉络:HashMap 作为通用主力,LinkedHashMap 在其基础上增加顺序维护。TreeMap 实现了 NavigableMap 提供红黑树排序,而 ConcurrentSkipListMap 在并发行列下提供有序映射。Hashtable 是早期同步实现,ConcurrentHashMap 则是现代高并发首选。WeakHashMapIdentityHashMap 则在特定场景下扮演专用角色。


模块 2:HashMap 深度剖析——数组+链表+红黑树的工程杰作

2.0 定义与适用场景

HashMap 是 Java 集合框架中对键值对存储最通用的实现,它基于哈希表的常数时间访问特性设计,目标是实现均摊 O(1) 的查找、插入和删除。作为 Map 接口的非同步实现,HashMap 专注于单线程或外部同步场景下的性能,在空间与时间之间以默认负载因子 0.75 取得平衡。

核心特征

  • 混合存储结构:数组 Node<K,V>[] table 作为主干,每个桶位置通过链表或红黑树解决哈希冲突。链表在长度为 8 且数组容量 ≥ 64 时转为红黑树,树节点数不足 6 时退化为链表。
  • 散列与索引:哈希值经 (h ^ (h >>> 16)) 扰动后,通过 (n-1) & hash 快速定位桶,容量恒定为 2 的幂以利用位运算。
  • 扩容机制:容量不足时扩容为原来的 2 倍,节点通过 e.hash & oldCap 的高低位拆分完成迁移,避免重新哈希。
  • 键和值约束:允许 null 键和 null 值,增删查改操作需正确重写 hashCode 与 equals

适用场景

  • 频繁随机存取、不关心迭代顺序的通用键值存储,如本地缓存、配置项映射、数据索引。
  • 元素量可预估时通过 new HashMap((int)(size/0.75f)+1) 消除多次扩容开销。
  • 需注意:并发写会破坏结构,仅限单线程或外部同步环境使用;自定义 Key 必须不可变且散列均匀。

2.1 Demo 代码

import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;

public class HashMapDemo {
    static class Person {
        int id;
        String name;
        Person(int id, String name) { this.id = id; this.name = name; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Person)) return false;
            Person p = (Person) o;
            return id == p.id && name.equals(p.name);
        }

        @Override
        public int hashCode() {
            return id * 31 + name.hashCode();
        }
    }

    public static void main(String[] args) {
        Map<Person, String> map = new HashMap<>();
        Person p1 = new Person(1, "Alice");
        Person p2 = new Person(2, "Bob");
        map.put(p1, "Engineer");
        map.put(p2, "Designer");
        // 遍历
        for (Map.Entry<Person, String> entry : map.entrySet()) {
            System.out.println(entry.getKey().name + " -> " + entry.getValue());
        }
        // 自定义对象不重写 hashCode/equals 将无法取出
        Map<Person, String> badMap = new HashMap<>();
        badMap.put(new Person(1, "Alice"), "Engineer");
        System.out.println(badMap.get(new Person(1, "Alice"))); // 若未重写则为null
    }
}

2.2 底层原理

存储结构

HashMap 在 JDK 8 中基于 Node<K,V>[] table 数组存储,每个数组位置称为桶(bucket)。当哈希冲突发生时,桶内元素以链表形式链接;当链表长度 ≥ TREEIFY_THRESHOLD(8)且数组容量 ≥ MIN_TREEIFY_CAPACITY(64)时,链表转为红黑树;删除导致树节点减少到 UNTREEIFY_THRESHOLD(6)时,树退化为链表。每个 Node 包含 final int hash, final K key, V value, Node<K,V> next

flowchart TD
    subgraph HashMap内部
        direction TB
        table["Node<K,V>[] table"]
        table --> b0["桶[0]"]
        table --> b1["桶[1]"]
        table --> b2["桶[2]"]
        table --> bN["桶[n-1]"]
    end

    b0 --> n0["Node A (hash,key,value,next)"]
    n0 --> n0next["Node B (next...)"]
    n0next --> n0next2["..."]
    
    b1 --> n1_null["null"]
    
    b2 --> tn_root["TreeNode (root, red=false)"]
    tn_root --> tn_left["TreeNode (left)"]
    tn_root --> tn_right["TreeNode (right)"]
    tn_left --> tn_leftchild["TreeNode..."]
    tn_right --> tn_rightchild["TreeNode..."]

图解说明

  • table 是主数组,容量始终为 2 的幂,每个槽位称为一个桶。
  • 桶索引通过 (n-1) & hash 计算。桶为空时内容为 null
  • 桶内为一个单链表时,首节点为 Node 类型。Node 持有 hashkeyvalue 和指向下一节点的 next 引用。
  • 当链表长度 ≥ 8 且数组容量 ≥ 64 时,链表转换为红黑树,桶首节点变为 TreeNodeTreeNode 继承自 Node,新增 parentleftrightprev 及颜色标志,构成一棵平衡二叉树。但是,树节点仍然通过 next 保留链表顺序,以便于迭代和退化。
  • 无论链表还是树,桶首节点都统一视为 Node,通过 instanceof TreeNode 判断类型进行分支处理。

插入流程(put)

flowchart TD
    A["put(K key V value)"] --> B["计算 hash = key.hashCode ^ (h >>> 16)"]
    B --> C{"数组 table 是否为空或长度为0?"}
    C -->|"是"| D["resize 初始化或扩容"]
    C -->|"否"| E["计算索引 i = (n-1) & hash"]
    E --> F{"桶 i 是否为空?"}
    F -->|"是"| G["直接创建新节点放入桶i"]
    F -->|"否"| H["遍历桶内节点"]
    H --> I{"首个节点 key 是否匹配?"}
    I -->|"是"| J["记录该节点 e"]
    I -->|"否"| K{"是否为 TreeNode?"}
    K -->|"是"| L["调用 putTreeVal 插入红黑树"]
    K -->|"否"| M["沿链表遍历"]
    M --> N{"找到匹配 key?"}
    N -->|"是"| J
    N -->|"否"| O["链至末尾 检查链表长度"]
    O --> P{"长度 >= TREEIFY_THRESHOLD - 1?"}
    P -->|"是"| Q["treeifyBin 尝试树化"]
    P -->|"否"| R["e = null"]
    J --> S{"e 不为 null?"}
    S -->|"是"| T["覆盖旧值 返回旧值"]
    S -->|"否"| U["modCount++ size++"]
    U --> V{"size > threshold?"}
    V -->|"是"| W["resize 扩容"]
    V -->|"否"| X["结束 返回null"]
    T --> X
    G --> U

流程解读:插入时先对 key 的 hashCode() 进行扰动(高16位与低16位异或),使哈希分布更均匀。随后通过 (n-1) & hash 得到桶索引(等价于 hash % n,因 n 为2的幂)。若桶空则直接放入;否则遍历桶内结构。链表遍历中一旦发现相同 key 则替换旧值,若未找到则追加到链表末尾(尾插法,JDK 8 改为尾插以避免并发扩容时死循环)。链表长达到阈值8时尝试树化(还需检查数组容量,若小于64则优先扩容)。插入后 size 自增,若超过阈值 thresholdcapacity * loadFactor) 则触发扩容。

  1. 扰动计算put 首先调用 hash(key),实现为 (h = key.hashCode()) ^ (h >>> 16)。高16位与低16位异或,目的是将高位特征混合进低位,使后续索引定位时,即使表容量较小(仅利用低几位),也能让高位差异影响索引,降低冲突概率。这是兼顾速度与质量的散列扰动函数。

  2. 数组初始化检查:若 table 为 null 或长度为0,调用 resize() 进行首次懒初始化。resize() 根据初始容量和负载因子计算出 threshold,避免构造时就占用内存。

  3. 索引定位:使用 (n - 1) & hash 替代取模。因为容量 n 恒为2的幂,n-1 的低位全为1,按位与等价于 hash % n 但效率更高。

  4. 桶空直接插入:若索引位置为 null,直接调用 newNode(hash, key, value, null) 创建节点并放入,然后跳转到步骤7(增加计数)。这是最快路径。

  5. 桶非空——遍历内部结构

    • 首先检查桶首节点 p 的哈希和键是否与待插入的键相等(p.hash == hash && (k = p.key) == key || key != null && key.equals(k)))。若匹配,则记录该节点 e = p
    • 若不匹配且首节点是 TreeNode 实例,说明当前桶已树化,调用 putTreeVal(this, tab, hash, key, value) 在红黑树中插入或覆盖。树化路径涉及从根遍历比较,最终将新节点放置为叶子,再调用 balanceInsertion 维持红黑树平衡,期间可能改变节点颜色、左旋或右旋。
    • 若为链表,则进入 for 循环,遍历链表节点,同时计数 binCount。若在遍历过程中遇到相同键的节点,中断循环并记录 e;否则一直遍历到链表尾部,调用 p.next = newNode(...) 尾插新节点。尾插法(JDK 8)避免并发扩容时逆序成环。接着检查 binCount >= TREEIFY_THRESHOLD - 1(即链表长度达到8),则调用 treeifyBin(tab, hash) 尝试将链表转为红黑树。treeifyBin 会再次检查数组总容量:若 tab.length < MIN_TREEIFY_CAPACITY(64),则不树化,而是优先扩容 resize(),因为短期扩容本身就能有效缓解冲突。
  6. 存在旧值处理:若 e 不为 null,说明找到了已有键,取出旧值 oldValue,用新值覆盖 e.value,调用 afterNodeAccess(e)(HashMap 中空实现,供 LinkedHashMap 使用),并返回旧值。此时不修改结构计数。

  7. 新增节点后续操作:若 e 为 null(即插入了新节点),增加修改计数 modCount++,元素个数 size++。然后调用 afterNodeInsertion(evict)(HashMap 空实现)。最后判断 size > threshold,若成立则调用 resize() 扩容。扩容操作可能触发数据迁移,整个 put 完成。

删除流程(remove)

flowchart TD
    A[remove key] --> B[计算 hash, 定位桶 i]
    B --> C{桶 i 为空?}
    C -->|是| D[返回 null]
    C -->|否| E[遍历桶内结构]
    E --> F{首个节点 key 匹配?}
    F -->|是| G[记录该节点]
    F -->|否| H{节点类型?}
    H -->|TreeNode| I[调用 getTreeNode 查找]
    H -->|链表| J[沿链表查找匹配节点]
    G --> K{找到节点?}
    I --> K
    J --> K
    K -->|否| D
    K -->|是| L{是 TreeNode?}
    L -->|是| M[removeTreeNode 删除, 可能转换为链表]
    L -->|否| N[从链表中摘除节点]
    M --> O[更新计数, modCount++]
    N --> O
    O --> P[返回被删除的 value]

流程解读:删除操作先定位桶索引,再遍历桶内结构找到目标节点。红黑树则使用 removeTreeNode 处理,若删除后节点过少则转为链表;链表则直接修改 next 指针。最后更新 modCountsize,返回旧值。

  1. 定位与首节点检查:通过 hash(key) 和 (n-1)&hash 定位桶下标,取出首节点。若首节点为 null,直接返回 null,表示键不存在。

  2. 匹配首节点:比对首节点的哈希和键,若相等则记录节点 node

  3. 遍历查找:若不相等,则根据首节点类型分路:

    • 若 first instanceof TreeNode,调用 getTreeNode(hash, key) 在红黑树中递归查找键。
    • 若为链表,调用 do-while 循环遍历,依次匹配各节点,直到找到或链表结束。
  4. 执行删除:若未找到节点,返回 null。否则分为两种情形:

    • 树节点:调用 removeTreeNode(tab, movable)。该方法从红黑树中移除指定节点(可能先交换后继,调整指针),再调用 balanceDeletion 修复红黑树平衡,最后检查树结构是否需要退化为链表(若树中节点数小于 UNTREEIFY_THRESHOLD (6))。
    • 链表节点:从链表中摘除,即设置前一节点的 next 指向被删节点的 next(或直接修改桶首指针)。无需平衡操作。
  5. 更新元数据:结构修改导致 modCount 递增,size 减1。调用 afterNodeRemoval(node)(空实现,供 LinkedHashMap 用,从顺序链表中移除)。最后返回被删节点的值。

查询流程(get)

flowchart TD
    A[get key] --> B[计算 hash, 定位桶 i]
    B --> C{桶 i 为空?}
    C -->|是| D[返回 null]
    C -->|否| E[检查首个节点 key 是否匹配]
    E -->|是| F[返回其 value]
    E -->|否| G{首节点是 TreeNode?}
    G -->|是| H[调用 getTreeNode 查找红黑树]
    G -->|否| I[沿链表遍历查找]
    I --> J{找到?}
    J -->|是| F
    J -->|否| D
    H --> K{找到?}
    K -->|是| F
    K -->|否| D

流程解读:查询无修改操作,无需锁(在线程安全版本中可无锁读)。通过 hash 和索引快速定位桶,然后根据桶首节点判断是红黑树还是链表,按照相应结构查找即可。平均时间复杂度为 O(1),极端冲突下退化为 O(log n)(红黑树)或 O(n)(链表)。

  1. 散列与定位:同样计算 hash(key) 并 (n-1)&hash 得到桶索引。若桶为 null,直接返回 null

  2. 首节点匹配:检查桶首节点,若哈希相等且键相等(引用相等或 equals 为 true),则直接返回 first.value。这是最常见且最快的命中路径。

  3. 树/链表查找:若首节点不匹配且其 next 不为 null

    • 若 first instanceof TreeNode,调用 getTreeNode(hash, key)。该方法通过 root.find(hash, key, null) 遍历红黑树,利用二叉搜索树性质(比较哈希值,若相等再用 equals 比较键)快速定位,平均时间复杂度 O(log n)。
    • 否则为链表,do-while 循环依次比对每个节点的键,直到匹配或链表结束。
  4. 返回结果:找到匹配节点返回其值,否则返回 null。注意:若 HashMap 允许 null 值,仍需区分“key 不存在”和“value 为 null”两种情况,可通过 containsKey 辅助判断,但 get 本身不区分。

扩容机制

size > threshold 时触发 resize()。新容量为旧容量的2倍。数据迁移采用高低位拆分:因新数组容量也是2的幂,每个桶的链表中的元素通过 e.hash & oldCap 判定挂载到原索引还是“原索引+oldCap”。

flowchart TD
    A[resize 开始] --> B[计算新容量 newCap = oldCap << 1]
    B --> C[创建新数组 newTab]
    C --> D[遍历旧数组每个桶 j]
    D --> E{桶 j 非空?}
    E -->|否| Z[继续下一桶]
    E -->|是| F{桶中只有一个节点?}
    F -->|是| G[重新计算索引放入 newTab]
    F -->|否| H{是 TreeNode?}
    H -->|是| I[调用 split 拆分红黑树]
    H -->|否| J[链表拆分为两链]
    J --> K[遍历链表, 通过 e.hash & oldCap 判定]
    K --> L[一链留在原索引 j, 一链移至 j+oldCap]
    L --> Z
    G --> Z
    I --> Z
    Z --> M[所有桶完成?]
    M -->|否| D
    M -->|是| N[设置 table = newTab, 更新 threshold]

流程解读e.hash & oldCap 利用了容量为2的幂的特性:若结果为0,则该节点在新数组的索引不变;若不为0,则索引变为 j + oldCap。这样无需重新计算哈希,仅通过位运算分流。对于红黑树,同样按照此规则拆成两棵树,若节点数不足则退化链表。

  1. 容量与阈值计算resize() 首先计算新容量 newCap = oldCap << 1(2倍),同时新的阈值 newThr = oldThr << 1。若到达最大容量 MAXIMUM_CAPACITY (1<<30),则不再扩容,阈值设为 Integer.MAX_VALUE。若旧容量为0(初始化),则按初始容量和负载因子计算初始阈值。

  2. 建新数组:分配 Node[newCap] 作为 newTab

  3. 数据迁移:遍历旧数组的每一个桶。

    • 空桶:跳过。
    • 单节点桶e.next == null,直接计算 newTab[e.hash & (newCap-1)] = e,将节点放入新位置。注意:虽然节点在旧数组中索引为 j,但由于容量变化,新索引可能变为 j 或 j+oldCap,这里重新计算即可。
    • 树节点桶:调用 TreeNode.split(this, newTab, j, oldCap)。该方法将红黑树节点按 (e.hash & oldCap) == 0 拆分为两条链(与原链表拆分逻辑一致),然后分别检查每条链的节点数:若节点数 <= UNTREEIFY_THRESHOLD (6),则转换为普通链表;否则重新构造成红黑树。最后将处理结果放入新数组的索引 j 和 j+oldCap 处。
    • 链表桶:核心在于利用 e.hash & oldCap 高效拆分。遍历链表,根据这个比特位将节点分配到两个子链表 loHead/loTail(低链,可留在原索引)和 hiHead/hiTail(高链,迁移至原索引 + oldCap)。位移后,将两个链表的头节点分别放入 newTab[j] 和 newTab[j+oldCap]。这一操作完全避免对每个元素重新计算哈希模运算,仅利用扩容前后的容量特征位,性能极高。
  4. 完成迁移:所有桶处理完毕后,将 table 引用指向 newTabthreshold 更新为新阈值。旧数组随后可被 GC 回收。

树化与链化阈值设计

  • 树化阈值 8:基于泊松分布理论。当负载因子为 0.75 时,桶中节点数 k 的概率约为 (exp(-0.5) * pow(0.5, k) / k!),k=8 时概率小于千万分之一。因此,链表长度达到 8 是极小概率事件,树化只作为极端冲突下的兜底优化,避免恶意哈希攻击导致 O(n) 复杂度。
  • 链化阈值 6:从红黑树退化为链表的阈值设为 6,加入迟滞(hysteresis),防止在 7~8 之间频繁转换引起结构性抖动。
  • 树化前置条件:链表达到 8 并非立即树化,会首先检查 table.length >= MIN_TREEIFY_CAPACITY (64),若数组尚小,优先通过扩容分散元素,而非贸然树化,因为小容量下扩容更经济高效。

2.3 性能分析

  • 时间复杂度

    • 理想散列下,put/get/remove 均摊为 O(1)。常数因子主要由哈希计算、一次数组访问及极少次数的链表/树遍历决定。当 loadFactor=0.75 时,平均链表长度约为 0.75,查找最多一两次比较。
    • 冲突严重时,单桶链表逐步增长至 O(n)。JDK 8 引入红黑树后,退化至 O(log n),最坏情况 n 为桶内节点数,若恶意构造冲突可使复杂度达到 O(log N)(N 为总元素数),大大优于 O(N)。但仍需注意,树化需满足数组容量 ≥ 64,小表优先扩容阻止树化,避免树化开销。
    • 扩容迁移成本 O(N),但均摊到每次插入依然为 O(1)。扩容时所有元素重新分布,单次扩容可能造成短暂停顿,对延迟敏感系统需预设容量。
  • 空间消耗

    • 主数组 Node[] table 占用 sizeof(Node*)*capacity 字节,外加每个 Node 对象至少 32 字节(12 字节对象头 + 4 字节 int hash + 引用 + 指针,压缩开启下约为 2432 字节),链表时额外指针 next;红黑树节点 TreeNode 约为 Node 的两倍,包含 left/right/parent/prev 及 boolean color,约 4856 字节。
    • 负载因子 0.75 意味着约 25% 的数组槽位为空,以空间换时间。若已知最终大小且不希望扩容抖动,可通过 new HashMap(expectedSize / 0.75f + 1) 设定初始容量。
    • 与 TreeMap 相比,HashMap 通常占用更少内存(无 parent/颜色等),但冲突多且树化后会暂时接近 TreeMap 空间开销。
  • 哈希冲突影响

    • 冲突率随元素数接近 capacity * loadFactor 增加。当链表长度达到 8 的概率极低(泊松分布千分之一以下),一旦达到说明散列质量差或遭遇恶意碰撞(如精心构造的 key 使得 hashCode 相同)。此时树化保证性能不雪崩,但树化本身有开销(创建 TreeNode,建立树结构),因此应优先保证良好的 hashCode 实现。
    • 扩容会平均分散冲突,但会造成内存浪费和短暂停顿。在空间充裕时,可略微降低负载因子(如 0.5)以减少冲突,代价是更大内存占用。
  • 并发吞吐

    • 非线程安全,无并发设计。多线程并发 put 可能导致数据丢失、size 不准确甚至链表成环(JDK 7)。任何并发访问必须外部同步,或使用并发版 Map。

2.4 注意事项

  1. 线程不安全导致的数据丢失与死循环
    JDK 8 中虽然改为尾插法解决了扩容链表死循环,但 put 过程中多线程仍可能出现数据覆盖、size 计数器异常等问题。例如两个线程同时执行 put,可能都读到 size,然后各自 ++ 并回写,导致少计数;且可能同时向同一空桶执行 newNode,造成丢失。任何并发修改必须使用 ConcurrentHashMap 或外部同步,不可仅凭无异常就认为安全。

  2. 自定义对象作 Key 的 hashCode/equals 契约
    必须严格满足:相等对象必须具有相同的哈希码;尽可能使不相等对象有不同哈希码。若 equals 重写而 hashCode 未重写,会导致两个 equals 为 true 的对象散列到不同桶,从而无法取出。反之,必须保证 equals 判定相等的 key 不会出现在 map 中多次。常用 IDE 生成方法可满足要求。注意 hashCode 计算中使用的字段应是不可变的,否则修改 key 的状态会导致槽位错误,对象“丢失”。

  3. 初始容量与负载因子调优

    • 若能预估存储量 n,设置 new HashMap((int)(n/0.75f)+1) 可避免多次扩容,尤其在数据批量导入时显著提升性能。
    • 延迟敏感型应用(如服务实时请求)可适当调低负载因子至 0.5~0.6,减少冲突和扩容频率,但需权衡内存开销。
    • 切勿设置过小的负载因子(如 0.1),导致巨大内存占用;也不宜设置过大(如 0.99),可能导致大量冲突和退化。
  4. 序列化与克隆的深拷贝问题
    HashMap 的克隆为浅拷贝,内部数组被复制,但每个节点仍引用相同的 key 和 value 对象。修改克隆后的值可能影响原 Map(若 value 是可变对象)。序列化恢复后与原始对象不再有关系。

  5. 遍历稳定性
    迭代器是 fail-fast 的,不支持并发修改。即使在单线程内,遍历时调用 map.remove(key) 也会抛出 ConcurrentModificationException,应使用迭代器的 remove() 方法或 removeIf


模块 3:LinkedHashMap 深度剖析——维护顺序的哈希表

3.0 定义与适用场景

LinkedHashMap 在 HashMap 的高效散列能力之上,增加了一条贯穿所有节点的双向链表,用以精确维护映射的迭代顺序。通过 accessOrder 参数可以控制顺序为插入顺序(默认)或访问顺序(LRU 语义),其设计目标是在不显著牺牲性能的前提下,提供可预测的顺序视图及轻量级缓存淘汰能力。

核心特征

  • 节点增强LinkedHashMap.Entry 继承 HashMap.Node,额外增加 before 与 after 指针,形成独立于哈希桶的双向链表。
  • 顺序维护:新节点插入时自动接至双向链表尾部;若设置为访问顺序模式,任何 get 命中都会将该节点移至尾部。
  • LRU 支持:重写 removeEldestEntry() 方法后,可在每次插入新元素时自动淘汰双向链表头部的“最老”节点,轻松实现固定容量缓存。
  • 迭代性能:遍历直接沿双向链表进行,无需跳过空桶,全量迭代效率高于 HashMap

适用场景

  • 需要保持数据插入顺序的场合,如用户操作日志、配置项保留原序。
  • 实现简单 LRU 内存缓存:设定 accessOrder=true 并重写淘汰条件,适合本地临时缓存。
  • 需警惕:Value 对象若强引用 Key,即使缓存淘汰仍会阻止 Key 被 GC 回收;非线程安全,并发访问须外部同步。

3.1 Demo 代码

import java.util.LinkedHashMap;
import java.util.Map;

public class LinkedHashMapDemo {
    public static void main(String[] args) {
        // 插入顺序
        LinkedHashMap<Integer, String> map = new LinkedHashMap<>(16, 0.75f, false);
        map.put(3, "C");
        map.put(1, "A");
        map.put(2, "B");
        System.out.println("插入顺序: " + map.keySet()); // [3,1,2]

        // 访问顺序 LRU 简易实现
        LinkedHashMap<Integer, String> lru = new LinkedHashMap<Integer, String>(16, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<Integer, String> eldest) {
                return size() > 3;
            }
        };
        lru.put(1, "a"); lru.put(2, "b"); lru.put(3, "c");
        lru.get(1);
        lru.put(4, "d");
        System.out.println("LRU: " + lru.keySet()); // [3,1,4]  2被淘汰
    }
}

3.2 底层原理

存储结构

LinkedHashMap 继承自 HashMap,其内部定义 Entry<K,V> 增加了 beforeafter 两个引用,构成一个贯穿所有节点的双向链表。accessOrder 字段决定链表是按插入顺序还是访问顺序维护。

flowchart TD
    subgraph HashMap结构
        table2["Node[] table"]
        table2 --> b0_2["桶[0]"]
        b0_2 --> e1["Entry E1"]
        e1 --> e1_next["Entry E2 (next)"]
        table2 --> b1_2["桶[1]"]
        b1_2 --> e3["Entry E3"]
    end

    subgraph 双向链表维护顺序
        head["head"] --> e1
        e1 -->|after| e3
        e3 -->|before| e1
        e3 -->|after| e1_next["E2"]
        e1_next -->|before| e3
        tail["tail"] --> e1_next
    end

图解说明

  • LinkedHashMap 完全复用 HashMap 的数组加桶结构,但其节点类型为 LinkedHashMap.Entry,它继承自 HashMap.Node,额外增加 before 和 after 两个引用。
  • 所有条目通过 before/after 形成一个贯穿全表的双向链表。head 指向链表头(最早插入或最久未访问的节点),tail 指向链表尾(最新插入或最近访问的节点)。
  • 插入新节点时,在放入哈希桶的同时,链接到双向链表的尾部。若开启访问顺序(accessOrder=true),每次 get 命中都将该节点移到尾部。
  • 该双向链表独立于哈希桶的单链表,因此既可按哈希索引快速访问,又可维持插入或访问顺序进行高效迭代。

插入流程(put)

LinkedHashMap 复用 HashMapput 方法,但重写了 newNodeafterNodeInsertion 等钩子。

flowchart TD
    A["put(K,V)"] --> B["HashMap.put 执行完成"]
    B --> C["钩子 afterNodeInsertion 被调用"]
    C --> D{"可能触发 removeEldestEntry?"}
    D -->|"是"| E["移除最老节点 链表头"]
    D -->|"否"| F["双向链表链接新节点到尾部"]
    E --> F
    F --> G["结束"]

流程解读afterNodeInsertion(boolean evict) 在插入后调用。如果 evict 为 true 且 removeEldestEntry(eldest) 返回 true(默认 false,用户可重写实现 LRU 淘汰),则移除双向链表头部节点。之后 linkNodeLast 将新插入的节点挂在链表末尾,确保顺序维护。

  1. 委托 HashMap.putLinkedHashMap 并未重写 put 方法,完全使用父类 HashMap 的 put。父类在完成键值对插入或替换后,会调用三个后置钩子:afterNodeAccess(访问回调)、afterNodeInsertion(插入回调)、afterNodeRemoval(删除回调)。LinkedHashMap 就是靠重写这三个方法实现顺序维护。
  2. 步骤一:获取新节点HashMap.put 内部在创建新节点时,会调用 newNode(int hash, K key, V value, Node<K,V> e) 或 newTreeNodeLinkedHashMap 重写了这些方法,返回的是扩展后的 LinkedHashMap.Entry 节点,该节点包含 before 和 after 引用。新节点创建后,LinkedHashMap 就立即把该节点链接到内部双向链表的末尾(linkNodeLast)。这一步实际上是在 put 执行过程中就已经完成节点加入双向链表,因此顺序已初步建立。
  3. 步骤二:afterNodeInsertion 淘汰检查:插入完成后,HashMap.put 调用 afterNodeInsertion(evict)evict 在初始构造阶段为 false,防止过早淘汰。LinkedHashMap 在此钩子内检查 removeEldestEntry(first) 方法的返回值。如果用户子类重写该方法并在容量超出时返回 true,则执行淘汰:通过双向链表的头节点(head)获取最老的映射,然后调用 removeNode(hash(key), key, null, false, true) 将其从 HashMap 中删除。需要注意,淘汰时同时从数组和双向链表中移除该节点(通过最终的 afterNodeRemoval 钩子)。
  4. 最终形态:节点已插入 HashMap 并正确维护在双向链表尾部(或头部被淘汰),确保迭代顺序符合要求。

删除流程(remove)

flowchart TD
    A["remove(key)"] --> B["HashMap.remove 执行 定位并移除节点"]
    B --> C["钩子 afterNodeRemoval 被调用"]
    C --> D["从双向链表中摘除该节点: before.after = after; after.before = before"]
    D --> E["结束"]

流程解读:删除节点后,afterNodeRemoval 将节点从维护顺序的双向链表中脱离,不影响数组位置结构,仅维护顺序链表完整。

  1. 委托 HashMap.removeLinkedHashMap 未重写 remove,直接使用父类逻辑,成功删除节点后,HashMap.removeNode 会在返回前调用 afterNodeRemoval(node)
  2. 从双向链表摘除:重写的 afterNodeRemoval 获取该节点的 beforeafter 指针,执行标准双向链表删除操作:before.after = after,若 after 非空则 after.before = before。同时,若删除的是链表头节点,则更新 head 指向;若删除的是末尾节点,则更新 tail 指向。此操作将节点从顺序维护链表中安全移除,不影响 HashMap 数组结构。

查询流程(get)

flowchart TD
    A[get key] --> B[HashMap.get 查找返回]
    B --> C{accessOrder == true?}
    C -->|是| D[钩子 afterNodeAccess, 将节点移至链表尾部]
    C -->|否| E[不做额外操作]
    D --> E
    E --> F[返回 value]

流程解读:若构造时指定 accessOrder=true(访问顺序),每次 get 命中后通过 afterNodeAccess 将该节点从双向链表中移至尾部,实现最近访问的元素位于尾部,最久未访问的位于头部。

  1. 调用父类 getLinkedHashMap 重写了 get 方法以触发访问顺序维护。它直接调用父类 HashMap.getNode 进行查找(或调用 getOrDefault)。若找到节点 e,则进入访问后置逻辑。

  2. accessOrder 检查:若构造时传入 accessOrder = true(默认为 false 即插入顺序),则调用 afterNodeAccess(e)。该方法的行为取决于该节点是否已经是双向链表尾部:

    • 若节点已是尾部(last == e),则无需操作。
    • 否则,先将其从链表中摘除(同上删除操作),然后通过 linkNodeLast 将该节点链接到链表末尾,使其成为最新的访问节点。
    • 这一移动操作在 HashMap.get 返回之前完成,因此可见性能稍有影响(需要维护链表),但实现了 LRU 特性。
  3. 返回结果:最后返回查找到的 value。若 accessOrder 为 false,则直接返回结果,不移动节点,保证迭代顺序为插入顺序。

3.3 性能分析

  • 时间复杂度

    • 所有基本操作保持 O(1),与 HashMap 一致。额外开销在于操作后维护双向链表:插入时追加到尾部(O(1) 指针调整),访问时(若 accessOrder=true)移动节点到尾部也是 O(1),删除时摘除节点 O(1)。因此常数因子稍大,但仍在纳秒级差异。
    • 迭代遍历 entrySet() 时,直接沿双向链表顺序访问,无需遍历数组,速度快于 HashMap(HashMap 迭代需跳过空桶)。
  • 空间消耗

    • 各节点为 LinkedHashMap.Entry,比 HashMap.Node 多出两个引用(beforeafter),每个节点增加约 8 字节(开启指针压缩)到 16 字节(未压缩)。整体空间比 HashMap 略高约 15%~20%。
    • 双向链表仅维护节点顺序,无额外数组。
  • 并发:非线程安全,限制同 HashMap。

3.4 注意事项

  1. accessOrder 的陷阱

    • accessOrder 只能在构造函数中设定,实例化后无法更改。默认 false 为插入顺序。
    • 开启 accessOrder=true 时,get 操作会修改双向链表结构(移动节点),因此如果有并发读取,必须外部同步,否则可能破坏链表一致性。即便单线程,频繁 get 也会产生写操作,影响 CPU 缓存局部性,性能敏感性场景需评估。
  2. LRU 缓存实现细节

    • 重写 removeEldestEntry 时,通常按 size() > MAX_ENTRIES 判断。该方法在 put 或 putAll 的每次插入新节点时都可能调用(具体取决于 afterNodeInsertion 的 evict 参数)。
    • 注意:当 MAX_ENTRIES 为 0 时,可能立即逐出刚插入的节点;注意逻辑边界。
    • 内存泄漏风险:若作为缓存的 value 对象强引用了 key,会导致即使缓存淘汰后,由于 key 已被删,但 value 内仍引用旧 key,使旧 key 无法被 GC(如果 value 生命周期比缓存长)。应避免 value 持有对 key 的强引用,或使用弱引用设计。
  3. 与 HashMap 的继承关系导致的可变性
    LinkedHashMap 继承 HashMap,因此 clone()、序列化等行为需确保附带双向链表状态。克隆时会调用父类浅拷贝,然后重建双向链表。如果子类添加了额外字段,需自行处理克隆。

  4. 迭代中删除的顺序一致性
    使用迭代器删除元素时,双向链表和哈希表同时更新,顺序保持正确。但如果通过 map.keySet().remove(key) 删除,同样会触发 afterNodeRemoval,顺序得到维护。


模块 4:TreeMap 深度剖析——基于红黑树的有序映射

4.0 定义与适用场景

TreeMap 是 NavigableMap 的标准实现,底层使用红黑树来维护键的严格排序。它完全依赖 Comparator 或键自身的 Comparable 接口确定位置,所有基本操作时间复杂度均为 O(log n),并提供了丰富的范围查询和近邻导航方法,是为有序映射需求设计的专用容器。

核心特征

  • 红黑树结构:每个 Entry 包含左子、右子、父节点引用及颜色标记,树通过变色和旋转始终保持平衡,高度不超过 2log₂(n)。
  • 排序与导航:支持自然顺序或自定义 Comparator;提供 subMapheadMaptailMapfloorKeyceilingKey 等范围与近邻查询方法。
  • 键约束:键不可为 null(除非比较器显式支持),因为所有操作依赖比较操作。
  • 结构保证:不会出现退化为链表的情况,最坏情形依然能保证对数复杂度。

适用场景

  • 需要全局有序输出或频繁范围查询的场景,如价格区间筛选、时间窗口查询、字典序遍历。
  • “查找附近元素”需求,如获取排名前 N、查找最接近的上下界。
  • 使用时应保持 Comparator 与 equals 一致,避免逻辑悖论;树节点空间开销大于哈希结构,非线程安全。

4.1 Demo 代码

import java.util.*;

public class TreeMapDemo {
    public static void main(String[] args) {
        TreeMap<Integer, String> map = new TreeMap<>();
        map.put(3, "C");
        map.put(1, "A");
        map.put(2, "B");
        System.out.println("自然排序: " + map); // {1=A, 2=B, 3=C}
        // 自定义 Comparator 降序
        TreeMap<Integer, String> descMap = new TreeMap<>(Comparator.reverseOrder());
        descMap.putAll(map);
        System.out.println("降序: " + descMap); // {3=C, 2=B, 1=A}
        // 范围操作
        System.out.println("subMap 2-4: " + map.subMap(2, 4)); // {2=B, 3=C}
    }
}

4.2 底层原理

存储结构

TreeMap 基于红黑树实现,内部节点 Entry<K,V> 包含 left, right, parentcolor 字段。树整体满足二叉搜索树性质,并通过旋转和变色维持平衡。

flowchart TD
    root["root: Entry<K,V> (black)"]
    root --> left1["Entry left (red)"]
    root --> right1["Entry right (red)"]
    left1 --> left1L["Entry (black/null)"]
    left1 --> left1R["Entry (black/null)"]
    right1 --> right1L["Entry (black/null)"]
    right1 --> right1R["Entry (black/null)"]
    
    Entry["Entry 节点字段"]
    Entry --- field["K key, V value, Entry left, Entry right, Entry parent, boolean color"]

图解说明

  • TreeMap 无数组,直接维护一棵红黑树,根节点存储在 root 字段中。
  • 每个 Entry 包含键、值、左子节点、右子节点、父节点以及颜色(红/黑)。所有节点的链接满足二叉搜索树性质:左子树键小于父键,右子树键大于父键,并根据红黑规则保持平衡。
  • 插入、删除和查找均从根开始,利用 Comparator 或 Comparable 的比较结果向下遍历。
  • 由于没有哈希桶,空间消耗来自更多的引用指针(父子左右),同等数据量下内存占用通常高于 HashMap

插入流程(put)

flowchart TD
    A[put K,V] --> B{root == null?}
    B -->|是| C[新建 Entry 作为根, 染黑]
    B -->|否| D[从根节点开始比较]
    D --> E[根据 comparator 或 Comparable 比较]
    E --> F{找到相同 key?}
    F -->|是| G[替换 value, 返回旧值]
    F -->|否| H[到达叶节点位置, 新建红色节点插入]
    H --> I[fixAfterInsertion 修复红黑树性质]
    I --> J[可能进行左旋/右旋/变色]
    J --> K[size++, modCount++]
    K --> L[返回 null]
    G --> L

流程解读:插入时从根节点递归比较,依据排序规则找到合适的叶节点位置。新插入节点默认为红色,随后调用 fixAfterInsertion 处理“红-红”冲突等各种违反红黑树约束的情况,通过变色和旋转将树重新调整平衡。

  1. 空树处理:若根节点 root == null,说明树为空,将新节点创建为根节点,颜色设为黑色(满足红黑树根节点必黑性质),随后递增 sizemodCount,返回 null

  2. 递归查找插入位置:从根节点开始,沿着树向下遍历。比较器选择顺序:若构造 TreeMap 时传入了 Comparator,则使用 comparator.compare(key, t.key);否则强制将 key 转为 Comparable,调用 compareTo 进行比较。比较结果 cmp

    • cmp < 0:进入左子树。
    • cmp > 0:进入右子树。
    • cmp == 0:找到完全相同的键(逻辑上相等,不要求引用相同),此时调用 t.setValue(value) 覆盖旧值,返回旧值,结束插入。
  3. 插入新节点:若一直到达叶子下方仍未发现相同键,则以当前遍历的父节点 parent 为基准,根据最后一次比较结果将新节点挂载为左孩子或右孩子。新节点颜色默认设为红色,因为插入红色节点不违反“黑高一致”性质,只可能违反“不连续红”性质,修复代价较小。

  4. 修复红黑树平衡:调用 fixAfterInsertion(x)。从新节点 x 开始向上回溯,只要 x 不为空、不是根、且父节点是红色(即出现双重红色),则进入修复循环。根据父节点是祖父节点的左孩子还是右孩子分对称两套逻辑,主要涉及:

    • 叔叔节点为红色:将父、叔染黑,祖父染红,然后将 x 指向祖父,继续向上修复。
    • 叔叔节点为黑色且当前节点与父节点不是同侧(即 LR 或 RL 情况):先旋转父节点,转换为同侧情况(LL 或 RR)。
    • 同侧情况(LL 或 RR) :将父节点染黑,祖父染红,围绕祖父进行右旋或左旋,完成后退出循环。
      最后确保根节点始终为黑色。
  5. 更新元数据:插入完成后递增 size 和 modCount,返回 null

删除流程(remove)

flowchart TD
    A[remove key] --> B[定位要删除的节点 p]
    B --> C{节点 p 有两个子节点?}
    C -->|是| D[找到后继节点 s, 拷贝 s 值到 p, 实际删除 s]
    C -->|否| E[直接删除 p]
    D --> F[记录实际删除节点 replacement]
    E --> F
    F --> G{replacement 不为空?}
    G -->|是| H[替换父节点链接]
    G -->|否| I{父节点不为空?}
    I -->|是| J[设置父节点对应子链接为 null]
    I -->|否| K[树只一个节点, root=null]
    H --> L[fixAfterDeletion 修复平衡]
    J --> L
    K --> M[size--, modCount++]
    L --> M
    M --> N[返回旧值]

流程解读:删除节点若有两个子节点,用其中序后继替换,然后删除后继节点(其最多一个子节点)。删除后通过 fixAfterDeletion 从删除位置向上修复红黑树平衡,可能涉及旋转和变色。

  1. 查找待删除节点getEntry(key) 按比较器或自然顺序查找节点,若未找到直接返回 null

  2. 确定实际删除节点:如果待删除节点 p 同时存在左右孩子,则寻找其中序后继 s(即右子树的最左节点)。将 s 的键值拷贝到 p 中(保留位置、颜色等结构不变),然后将 p 指针指向 s,从而将问题转化为删除最多只有一个孩子的后继节点 s。这样保证实际移出树的节点 replacement 最多只有一个子节点。

  3. 获取替代子节点replacement = (p.left != null ? p.left : p.right)。即若 p 有左孩子则替代为左孩子(最多一个孩子),否则右孩子。

  4. 从树中摘除节点 p

    • 若 replacement 不为空(即有一个孩子),则将替代节点的父指针指向 p.parent,并在 p.parent 中相应位置用 replacement 替换 p。同时将 p 的左右及父指针置空,使其脱离树。
    • 若 replacement 为空且 p.parent == null,则说明 p 是唯一节点,直接将 root 置 null
    • 若 replacement 为空但有父节点,则将父节点中对应儿子指针置空,即简单摘除叶子。
  5. 修复平衡:若删除的节点 p 是黑色,可能破坏黑高平衡,需要调用 fixAfterDeletion(x),其中 x 是替代节点或(若替代为空)临时使用 p 本身。修复逻辑围绕“当前路径少一个黑色”展开,通过兄弟节点的颜色和子节点颜色,分多种情况进行染色和旋转操作,使树重新满足红黑树性质。

  6. 收尾:将 p 的所有引用置空便于 GC,减小 size、递增 modCount,返回旧值。

查询流程(get)

flowchart TD
    A[get key] --> B[root 为 null?]
    B -->|是| C[返回 null]
    B -->|否| D[从根节点开始比较]
    D --> E[根据 comparator/Comparable 比较]
    E --> F{key 相等?}
    F -->|是| G[返回该节点 value]
    F -->|否| H{小于当前节点?}
    H -->|是| I[进入左子树]
    H -->|否| J[进入右子树]
    I --> D
    J --> D

流程解读:查询与二叉搜索树标准查找一致,比较键值后决定向左或向右递归,直到找到或碰到 null 为止。时间复杂度 O(log n)。

  1. 空树直接返回 null

  2. 遍历查找:从根节点 p 开始循环,比较 key 与 p.key

    • 通过 comparator 或键的 Comparable 计算比较结果 cmp
    • cmp < 0:转向左子节点。
    • cmp > 0:转向右子节点。
    • cmp == 0:找到目标,返回 p.value
      若 p 变为 null,说明键不存在,返回 null
  3. 复杂度:由于红黑树高度始终 O(log n),查询最坏比较次数约 2*log(n)。

4.3 性能分析

  • 时间复杂度

    • put/get/remove 均为 O(log n),其中 n 为当前元素数量。红黑树高度最多为 2*log₂(n),每次比较可能需要调用 comparator.compare() 或 Comparable.compareTo(),常数因子高于哈希表(特别是 key 比较较昂贵时)。
    • 范围操作如 subMapheadMaptailMap 等返回视图,其遍历耗时 O(k),其中 k 为范围内元素数,视图本身不预计算。
    • firstKey/lastKey 为 O(log n)(需找到最左/最右叶节点),但 firstEntry() / lastEntry() 可 O(1) 通过内部指针?TreeMap 未维护最小最大引用,因此也为 O(log n)。
  • 空间消耗

    • 每个 Entry 包含 key, value, left, right, parent, color(boolean),对象头开销较大,约 40~56 字节。
    • 无哈希表数组,但树节点引用多,同等元素量内存显著高于 HashMap。若数据量极大且无需顺序,优先 HashMap。
  • 比较器性能影响

    • 比较器效率直接影响 TreeMap 性能。如果比较器实现中有复杂的逻辑或未缓存比较结果,每次比较都可能成为瓶颈。建议比较器简单高效,且保持与 equals 一致,但非强制(集合行为可能不一致)。

4.4 注意事项

  1. Key 必须可比较

    • 缺省使用 key 的 Comparable 自然顺序,若 key 未实现 Comparable,在第一次 put 时抛出 ClassCastException。提供 Comparator 可绕过,且此时允许 key 为 null(取决于比较器是否处理 null)。自然顺序下 key 绝不可为 null
  2. 比较器一致性

    • 虽然 TreeMap 不要求 compare 与 equals 一致,但若不一致,TreeMap 的行为可能违反 Map 接口的常规约定(例如 map.containsKey(k) 使用 compare 而非 equals,可能导致 equals 相等的两个对象被视为不同 key 而共存)。强烈建议保持 compare 与 equals 一致
  3. 非线程安全
    与 HashMap 相同,并发修改会破坏树结构,导致永久性数据丢失或死循环。迭代器为 fail-fast。

  4. 大 value 的序列化
    若 value 是大型对象,TreeMap 序列化将递归树遍历,可正常工作;但若比较器中引用了非序列化对象,反序列化会失败。

  5. 自定义 Comparator 的序列化
    TreeMap 的 Comparator 若未实现 Serializable,当 TreeMap 序列化时将抛出 NotSerializableException,可通过 writeReplace 等方式处理。


模块 5:Map 并发容器演进概述

Java Map 的并发支持经历了从粗犷到精细的演进:

  • Hashtable (JDK 1.0):全方法 synchronized,锁粒度极粗,并发吞吐极低。
  • Collections.synchronizedMap:装饰器模式,所有方法同步在同一个互斥体上,竞争依旧严重。
  • ConcurrentHashMap (JDK 5/Java 7 分段锁):引入 Segment 分段锁,将整个哈希表分成16段,写操作仅锁对应段,读无锁,并发度提升至16。
  • ConcurrentHashMap (JDK 8):放弃分段锁,采用 synchronized 对桶首节点加锁 + CAS 操作,锁粒度细化到桶级别,同时利用红黑树优化冲突,引入多线程协作扩容,实现更高伸缩性。
  • ConcurrentSkipListMap:基于跳表的无锁有序映射,全程 CAS,读完全无锁,写无锁竞争极低,适合高并发有序场景。

该演进的核心思想是从“全表锁”到“分段锁”再到“节点级锁”与“无锁化”,同时不断优化数据迁移和并发度。


模块 6:Hashtable 深度剖析——全方法锁的历史遗产

6.0 定义与适用场景

Hashtable 是 Java 早期提供的线程安全 Map 实现,通过对所有公开方法添加 synchronized 关键字来保证线程安全性,形成全表互斥锁。其设计思路简单直接,但锁粒度过粗导致并发度恒为 1,在现代高并发场景下已完全失去竞争力,仅作为遗留 API 存在。

核心特征

  • 全表锁:每个公开方法均同步在 this 上,同一时刻只允许一个线程进行读写操作。
  • 底层结构:数组加链表,无红黑树优化;默认初始容量 11,扩容为 2n+1,索引计算通过取模实现。
  • 空值限制:不允许 null 键和 null 值,否则抛出 NullPointerException
  • 迭代器Enumeration 和 Iterator 均为 fail-fast,但在同步块外遍历可能读到中间状态。

适用场景

  • 新项目中已无适合场景,应全部迁移至 ConcurrentHashMap
  • 遗留系统维护中如需继续使用,应评估替换可行性,注意其不允许 null 的行为与 HashMap 不同。

6.1 Demo 代码

import java.util.Hashtable;

public class HashtableDemo {
    public static void main(String[] args) {
        Hashtable<String, Integer> table = new Hashtable<>();
        table.put("a", 1);
        table.put("b", 2);
        System.out.println(table);
    }
}

6.2 底层原理

Hashtable 使用数组+链表结构(无红黑树),默认初始容量11,加载因子0.75。所有公开方法都用 synchronized 修饰,保证同一时刻只有一个线程能操作 Hashtable。扩容公式为 newCapacity = oldCapacity * 2 + 1(容量不强制2的幂),迁移时重新计算哈希并放入新数组。不允许 null 键和值。

flowchart TD
    subgraph Hashtable
        ht_table["Entry[] table"]
        ht_table --> h0["桶[0]"]
        ht_table --> h1["桶[1]"]
        ht_table --> hN["桶[n-1]"]
    end
    h0 --> eA["Entry A (hash,key,value,next)"]
    eA --> eAnext["Entry B (next)"]
    h1 --> null1["null"]
    hN --> eC["Entry C (next...)"]

图解说明

  • Hashtable 结构类似早期 HashMap,仅使用 Entry[] 数组加单链表。
  • 默认初始容量为 11,扩容后容量变为 2n+1,不是 2 的幂。
  • 索引计算采用 (hash & 0x7FFFFFFF) % table.length,效率低于位与。
  • 无红黑树,冲突严重时性能退化至 O(n)。
  • 所有公开方法均用 synchronized 修饰,访问同一 Hashtable 实例会竞争同一把锁。

6.3 并发分析

  • 锁粒度:整个 Hashtable 实例锁,同一时间仅一个线程能读写。
  • 并发度:1。
  • 竞争热点:任何操作都竞争同一把锁,高并发下线程频繁阻塞,CPU 上下文切换剧烈,吞吐量极低。

6.4 性能分析

  • 时间复杂度:单线程下 put/get/remove 理论 O(1)(链表平均长度较短),但同步开销使常数因子远高于 HashMap。多线程下,由于全表锁,所有操作串行化,吞吐量不随线程数增加而提高,甚至会因上下文切换而下降,成为系统瓶颈。
  • 空间消耗:结构与旧式 HashMap 相似,无红黑树,仅有链表节点,空间稍低于 JDK 8 HashMap(无树节点)。默认初始容量 11,扩容为 2n+1,不是 2 的幂,索引计算通过 (hash & 0x7FFFFFFF) % tab.length,取模操作比位与慢。
  • 并发吞吐:极低,不适合任何并发环境。

6.5 注意事项

  • 绝对不应用于新项目。遗留系统维护中应尽快迁移至 ConcurrentHashMap
  • 不允许 null 键和值,调用时会显式抛 NullPointerException,这与 HashMap 允许 null 不同,迁移时需额外处理。
  • 迭代器同样是 fail-fast,且在同步块外遍历可能面临数据不一致,但不会抛异常(因为 Hashtable 的 Enumeration 和 Iterator 在 synchronized 块外部调用可能读到中间状态,而非 fail-fast)。

模块 7:ConcurrentHashMap(Java 8)深度剖析——synchronized + CAS 的高并发设计

7.0 定义与适用场景

ConcurrentHashMap 是现代 Java 并发编程中键值对存储的第一选择。它在 Java 8 中经历彻底重构,摒弃了旧的 Segment 分段锁,代之以桶级 synchronized 与 CAS 无锁操作的混合策略,实现读操作完全无锁、写操作仅锁定冲突桶,并发度理论上可扩展至数组容量大小,旨在满足高并发环境下的极致吞吐要求。

核心特征

  • 无锁读:依赖 volatile 语义及 Unsafe 获取数组元素,读线程不被任何写操作或扩容阻塞。
  • 桶级写锁:空桶通过 CAS 直接插入,减少锁开销;非空桶则对桶首节点加 synchronized,锁粒度细化至单桶。
  • 多线程协作扩容:引入 ForwardingNode 占位已迁移桶,利用 sizeCtl 状态机协调多线程共同参与数据迁移,将扩容耗时分散。
  • 原子复合操作:提供 computemergecomputeIfAbsent 等方法,在持有桶锁期间执行函数体,确保复合操作原子性。
  • 计数分散:通过 baseCount + CounterCell[] 分散自增热度,size() 返回的是一个弱一致性的快照值。

适用场景

  • 高并发共享存储,如全局会话缓存、实时统计计数器、分布式配置本地副本。
  • 需要原子化更新单个键的场景,如并发累加、条件插入、映射合并,避免外部加锁。
  • 必须注意:不允许 null 键和值;原子方法内递归修改同一键将产生死锁;size() 不可用于精确并发控制。

7.1 Demo 代码

import java.util.concurrent.ConcurrentHashMap;

public class ConcurrentHashMapDemo {
    public static void main(String[] args) {
        ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
        map.put("key1", 1);
        map.putIfAbsent("key1", 2); // 已存在,不替换
        map.compute("key2", (k, v) -> v == null ? 10 : v + 10); // 原子计算
        map.merge("key3", 1, Integer::sum); // 若不存在则1,否则累加
        System.out.println(map);
    }
}

7.2 底层原理

存储结构

HashMap 类似的 Node<K,V>[] table,桶内链表/红黑树。新增 ForwardingNode 标识扩容状态,sizeCtl 控制初始化和扩容。采用 synchronized 锁住桶的首节点,并配合 CAS 实现无锁操作。

flowchart TD
    subgraph ConcurrentHashMap
        chmTable["volatile Node[] table"]
        chmTable --> cB0["桶[0]"]
        chmTable --> cB1["桶[1]"]
        chmTable --> cB2["桶[2]"]
    end

    cB0 --> nNode["普通 Node (volatile next)"]
    nNode --> nNext["Node (next)"]
    
    cB1 --> treeBin["TreeBin (hash=TREEBIN)"]
    treeBin --> treeRoot["TreeNode root"]
    treeRoot --> tLeft["TreeNode left"]
    treeRoot --> tRight["TreeNode right"]
    
    cB2 --> fwdNode["ForwardingNode (hash=MOVED)"]
    fwdNode --> nextTable["指向新数组 nextTable"]

图解说明

  • 核心数组 table 是 volatile 修饰的,确保读可见性。使用 Unsafe.getObjectVolatile 进行数组元素访问。
  • 普通 Node:桶内为链表时,首节点为普通 Node,其 next 和 value 字段均为 volatile
  • TreeBin:当桶内元素过多树化后,桶首节点为 TreeBin(hash 为 TREEBIN)。TreeBin 持有红黑树的根 root,并提供一个简单的读写锁机制,保证树操作并发安全。查找操作可不加锁直接通过 TreeBin.find() 完成。
  • ForwardingNode:在扩容过程中,迁移完成的桶放置 ForwardingNode(hash 为 MOVED),其内部引用新数组 nextTable。任何对该桶的读/写都会发现此节点,从而转向新数组或协助扩容。
  • 并发计数通过 baseCount 和 CounterCell[] 分散热点。sizeCtl 控制初始化和扩容状态。

插入流程(put)

flowchart TD
    A["put(K,V)"] --> B["计算 hash spread扰动"]
    B --> C["进入自旋循环"]
    C --> D{"数组 table 是否初始化?"}
    D -->|"否"| E["CAS 设置 sizeCtl 初始化tab"]
    D -->|"是"| F["计算索引 i = (n-1) & hash"]
    F --> G{"桶 i 为空?"}
    G -->|"是"| H["CAS 尝试放入新节点 成功则跳出"]
    G -->|"否"| I{"首节点 f 是 ForwardingNode?"}
    I -->|"是"| J["helpTransfer 协助扩容"]
    I -->|"否"| K["synchronized 锁住 f"]
    K --> L["再次检查 f 是否为头节点"]
    L --> M["遍历桶内 查找或新增"]
    M --> N{"旧值存在?"}
    N -->|"是"| O["替换 返回旧值"]
    N -->|"否"| P["新增节点 树化判断"]
    P --> Q["释放锁"]
    Q --> R["addCount 更新 size 若需扩容则触发 transfer"]
    H --> R
    O --> R
    J --> C

流程解读ConcurrentHashMap.put 先通过 spread 扰动哈希。若桶空则 CAS 尝试放置新节点,避免加锁。若桶非空且首节点为 ForwardingNode,说明正在扩容,当前线程会加入协助迁移。否则对桶首节点加 synchronized,在同步块内完成插入。插入后调用 addCount 累加计数,若总数超过阈值则触发 transfer 扩容。

  1. 散列与自旋spread(hashCode) 执行扰动:(h ^ (h >>> 16)) & HASH_BITS(确保结果非负)。随后进入 for 自旋循环,保证竞争下可重试。

  2. 数组初始化:如果 table 为 null,初始化通过 initTable() 进行。该方法使用 sizeCtl 字段作为状态:sizeCtl 为负表示其他线程正在初始化或扩容,当前线程调用 Thread.yield() 让出时间片;否则 CAS 将 sizeCtl 设为 -1,成功后创建新数组,并设置 sizeCtl = n - (n >>> 2)(0.75倍容量作为下次扩容阈值)。CAS 竞争失败的线程同样自旋让出,直到初始化完成。

  3. 空桶 CAS 插入:用 tabAt(tab, i) 获取桶首节点。若为 null,尝试 casTabAt(tab, i, null, new Node<K,V>(hash, key, value))。若 CAS 成功,跳出循环;失败则继续自旋。这一步骤实现了无锁快速插入。

  4. ForwardingNode 检测与协助扩容:若桶首节点 f.hash == MOVED,表明当前数组正在扩容,且该桶已被迁移。此时执行 helpTransfer(tab, f),当前线程加入扩容工作,协助迁移其他未完成的桶。扩容完成或获取新数组后,继续自旋重试插入。

  5. 桶级加锁插入:若桶非空且非 ForwardingNode,使用 synchronized(f) 锁住桶的首节点(链表头/树根)。进入同步块后首先再次检查 tabAt(tab, i) == f,防止在获取锁之前桶首节点被其他线程改变。

    • 若 f.hash >= 0(普通链表),遍历链表,计算 binCount,查找相同 key 的节点。若找到则记录旧值,用新值覆盖;若未找到则在链表尾部追加节点,并检查是否需树化(链表长度 >= 8 且数组容量 >= 64,由 treeifyBin 处理)。
    • 若 f instanceof TreeBin(红黑树),调用 TreeBin.putTreeVal 进行树的插入或覆盖。树的并发操作通过内部的读写锁(LockSupport)配合 CAS 保证同步,但此处外层已经有 synchronized,所以不会并发修改同一棵树。
  6. 释放锁并增加计数:同步块执行完毕后自动释放锁。若插入的是新节点,调用 addCount(1L, binCount)。该方法通过 baseCount 和 CounterCell 数组完成分散累加。累加后检查总大小是否超过 sizeCtl 阈值,若超过则调用 transfer(tab, null) 发起扩容,或在扩容进行中帮助迁移。

删除流程(remove)

flowchart TD
    A[remove key] --> B[计算 hash, 定位桶 i]
    B --> C{桶空?}
    C -->|是| D[返回 null]
    C -->|否| E{首节点是 ForwardingNode?}
    E -->|是| F[helpTransfer]
    E -->|否| G[synchronized 锁住首节点]
    G --> H[遍历找到节点并删除]
    H --> I[释放锁]
    I --> J[addCount 减计数]
    J --> K[返回旧值]

流程解读:与插入类似,若遇到扩容则先协助。加锁后确保删除操作的原子性,并在移除节点后调整树/链表结构,最后更新计数。

  1. 前置检查:定位桶索引。若桶为空,直接返回 null

  2. 扩容协助:若首节点为 ForwardingNode (hash==MOVED),调用 helpTransfer 协助扩容,结束后重新自旋查找桶再次尝试删除。

  3. 同步块内删除:锁住桶首节点,再次检查首节点未改变。在链表或树中搜索匹配节点:

    • 链表:通过 pred(前驱节点) 和当前节点的比较,找到待删节点后,使 pred.next 跨过该节点。
    • :调用 TreeBin.removeTreeNode 进行删除,并可能退化为链表。
      找到并删除节点后,记录旧值。
  4. 计数更新与返回:释放锁后,调用 addCount(-1L, -1) 递减总计数。注意 addCount 同样负责触发扩容检查,删除操作一般不会触发扩容,但保持统一。最后返回旧值,若未找到节点返回 null

查询流程(get)

flowchart TD
    A[get key] --> B[计算 hash, 索引 i]
    B --> C[读指针 tabAt 获取桶首节点]
    C --> D{首节点为空?}
    D -->|是| E[返回 null]
    D -->|否| F{首节点 key 直接匹配?}
    F -->|是| G[返回其 value]
    F -->|否| H{首节点 hash < 0?}
    H -->|是| I[可能是 ForwardingNode 或 TreeNode]
    I --> J[调用 Node.find 查找]
    H -->|否| K[遍历链表查找]
    K --> L{找到?}
    L -->|是| G
    L -->|否| E
    J --> M{找到?}
    M -->|是| G
    M -->|否| E

流程解读:读操作完全无锁,依赖 volatile 读取保证内存可见性。当发现 ForwardingNode 时通过其 find 方法在新数组中查找,实现对迁移过程的透明。由于不参与锁,并发读效率极高。

  1. 无锁可见性tabAt 通过 Unsafe.getObjectVolatile 读取数组元素,确保获取到最新写入的桶首节点,这是无锁读的关键。

  2. 首节点匹配:先检查首节点的 hash 和 key 是否与请求相等(equals),命中则直接返回。此为最快路径。

  3. 特殊节点路由:若首节点 hash < 0,可能是以下两种情况:

    • ForwardingNode(hash==MOVED):说明桶正在扩容或被迁移,调用 ForwardingNode.find(h, k),该方法会转向新数组进行查找,对读完全透明。
    • TreeBin(hash==TREEBIN):调用 TreeBin.find(h, k) 在红黑树中查找。TreeBin 内部的读线程通过 volatile 变量和链表保证无锁访问,即使在树旋转时也能安全遍历。
  4. 链表遍历:若首节点 hash >= 0 且 key 不匹配,沿 next 指针遍历链表逐个匹配。

  5. 返回结果:找到匹配节点则返回其值,否则返回 null。由于读无锁且不阻塞,即使并发写或扩容也能返回一致性的数据(部分场景可能读到旧值或迁移中的过渡状态,但不会引发死循环或内存违规)。

多线程协作扩容

sequenceDiagram
    participant ThreadA
    participant CHMap as ConcurrentHashMap
    participant TableOld
    participant TableNew
    participant ThreadB

    ThreadA->>CHMap: put 触发 addCount 发现 size>threshold
    ThreadA->>CHMap: CAS 竞争 sizeCtl 成为扩容发起线程
    ThreadA->>TableNew: 创建新数组 nextTable (2倍大小)
    ThreadA->>CHMap: transfer 从后往前分配迁移区间
    ThreadA->>CHMap: 将当前桶设为 ForwardingNode (指向 nextTable)
    Note over ThreadA,CHMap: 迁移数据...
    ThreadB->>CHMap: put/remove/get 访问到 ForwardingNode
    ThreadB->>CHMap: 调用 helpTransfer, 进入协助迁移
    ThreadB->>CHMap: 分配一段待迁移桶, 执行数据迁移
    ThreadB-->>CHMap: 迁移完成后继续自己的操作
    ThreadA-->>CHMap: 迁移全部完成, 设置 table = nextTable, sizeCtl = 新阈值

流程解读:扩容时,一个线程负责初始化新数组,并通过 ForwardingNode 标记已迁移的桶。sizeCtl 高16位记录扩容标记,低16位记录参与线程数。其他线程检测到 ForwardingNode 自动通过 helpTransfer 加入迁移任务,取一段桶进行迁移,每个线程处理步长 stride(默认最小16)。所有桶迁移完成后,table 替换为新数组。

  1. 扩容触发:当 addCount 发现总元素数超过 sizeCtl 阈值时,调用 transfer(tab, null)。一个线程(通常是触发扩容的)首先通过 CAS 将 sizeCtl 设置为一个较大的负数(高16位包含扩容标记,低16位记录参与线程数 + 2),表示它发起了扩容。
  2. 发起线程初始化新数组:确认是自己发起扩容后,创建新数组 nextTable,容量为旧表 2 倍。随后进入 transfer 主循环,从旧表末尾向前逐步分配迁移区间。每个线程负责迁移一段连续的桶 (stride 步长,最小16),通过 transferIndex 指针 CAS 竞争获取。
  3. 迁移数据:线程对自己负责的桶执行迁移。对于链表,采用与 HashMap 相同的 e.hash & oldCap 高低位拆分;对于树,调用 TreeBin.split 拆分并可能转化链表。迁移完成后,将桶位置替换为 ForwardingNode,其 nextTable 引用指向新数组,这样其他线程读操作可以直接转查新数组。
  4. 协助线程加入:当其他线程执行 put/remove/get 发现某个桶是 ForwardingNode(hash==MOVED)时,调用 helpTransfer。该方法会检查 sizeCtl 确认扩容仍在进行,然后尝试 CAS 增加参与线程数,加入 transfer 循环,分配剩余桶进行迁移。这样实现了多线程并行扩容,避免单线程瓶颈。
  5. 扩容完成:当所有桶被迁移完毕,最后一个退出的线程(或发起线程)负责将 table 设置为 nextTable,并更新 sizeCtl 为新的扩容阈值(nextTable.length * 0.75)。整个过程对读写影响极小。

sizeCtl 状态

  • 负值表示正在初始化或扩容:-1 为初始化;其他负值 -(1+活跃线程数)
  • 正值为下一次扩容的阈值。

计数机制

内部使用 baseCountCounterCell 数组分散并发修改,避免 AtomicLong 单一竞争。addCount 时先 CAS baseCount,失败则随机选择一个 CounterCell 累加,以减少冲突。size() 通过累加 baseCount 与所有 CounterCell 的值获得。

7.3 并发分析

  • 锁粒度:桶级别,仅对冲突桶首节点加锁,其他桶可并发操作。
  • 读无锁:完全依赖 volatile 语义,不阻塞。
  • 并发度:理论上为数组容量大小,实际受 CPU 数量和任务竞争影响。
  • 扩容协作:多线程共同推进迁移,避免单线程瓶颈。
  • 对比 Java 7 分段锁:Java 8 弃用分段锁,改为桶锁 + CAS,解决 segments 数量固定导致的伸缩性局限,同时空间利用率更高(不再有 segment 数组开销)。

7.4 性能分析

  • 时间复杂度

    • 理想散列下,put/get/remove 均摊 O(1)。get 完全无锁,性能接近直接读取 volatile 变量;put 仅在桶冲突时加锁,且锁粒度细至单桶,多数情况下 CAS 直接插入空桶,代价极低。
    • 扩容时多线程并行迁移,每线程迁移步长 stride(最小 16)个桶,迁移整体成本均摊到多次操作中,不会出现长时间停顿。但若迁移线程过多且 CPU 竞争激烈,可能出现短暂自旋等待。
  • 空间消耗

    • 数据结构与 HashMap 相近,但节点均用 volatile 修饰或通过 Unsafe 访问,对象内存布局与 HashMap 基本一致。Node 和 TreeNode 无额外同步字段。
    • 额外的 sizeCtlCounterCell 数组(默认大小为 2 的幂,常规场景很小)用于分散计数,占用可忽略。
    • 扩容期间临时存在 nextTable 双倍数组,以及 ForwardingNode 占位节点,内存峰值为 2 倍旧表。可通过预设合理初始容量避免频繁扩容。
  • 并发吞吐

    • 读扩展性线性度极高,几乎不受写影响(volatile 读 + 缓存一致性开销)。
    • 写扩展性强,但热点 key 会导致同一桶频繁加锁竞争,此时可考虑使用 compute 或 merge 等原子方法减少锁持有时间,或通过更高层的设计分散 key。
    • Java 8 对 synchronized 进行了锁升级优化(偏向锁、轻量级锁),桶级同步的开销在低竞争下可忽略,高竞争下会自动膨胀为重量级锁,但比 JDK 7 的 Segment 锁仍有更低竞争概率。
    • 与 Hashtable 对比:在 16 线程并发 put 测试中,ConcurrentHashMap 可达到 Hashtable 的数十倍吞吐量。
  • 计数器的弱一致性

    • size() 和 isEmpty() 的结果为近似值,不能用于精确并发控制。实现采用分而治之的计数,避免了全局热点,但也因此允许短暂的不一致。

7.5 注意事项

  1. 绝对不允许 null 键或值
    如果 put 时 key 或 value 为 null,会立即抛出 NullPointerException。设计意图是避免并发环境下二义性:get(key) 返回 null 无法区分是不存在还是值为 null。因此需要用占位对象表示 null。

  2. size() 是粗略值
    在多线程并发修改时,size() 返回的是某个快照的近似结果,可能大于或小于实际瞬时元素数。切勿依赖 size() 做精确逻辑判断(如实现缓存逐出阈值),应改用 mappingCount() 返回 long 也可以,但仍是弱一致。

  3. 原子操作避免递归锁死锁
    computecomputeIfAbsentmerge 等方法在持有桶锁期间执行提供的函数。如果函数内部再次尝试修改 同一个 key 导致需要获取同一个桶锁,由于 synchronized 不可重入,会导致死锁。例如:

    java

    map.compute("A", (k, v) -> {
        map.put("A", 2); // 死锁,同一桶锁
        return v+1;
    });
    

    解决:避免在原子操作内修改映射中的任何 key(尤其是同一 key)。如果必须级联修改,使用多步操作,释放锁后再次获取。

  4. 弱一致性迭代器
    迭代器 keySet().iterator() 等不会抛出 ConcurrentModificationException,它们遍历的是某个时刻的元素快照或视图,可能看不到遍历过程中新加入的数据,也可能看到已经删除的数据,这是设计权衡。因此,执行迭代期间不能假定集合是稳定的。

  5. 扩容期间的内存与CPU
    如果你预设超大容量(如数千万),扩容仍可能引起较长时间的迁移。可通过 new ConcurrentHashMap(expectedSize) 设定合适的初始容量,并考虑让 JVM 有充足堆空间。

  6. 序列化注意
    序列化时不会保留内部哈希桶状态,而是序列化 key-value 对;反序列化后重建哈希表,所以性能可能稍低于原始表。


模块 8:ConcurrentSkipListMap 深度剖析——基于跳表的高并发有序映射

8.0 定义与适用场景

ConcurrentSkipListMap 是为高并发环境下仍需要键排序而设计的映射实现,底层采用跳表数据结构,通过多层索引和纯 CAS 操作实现全无锁并发。它在提供 TreeMap 级别有序能力的同时,克服了红黑树在并发控制上的结构锁难题,使读写均能无阻塞进行,是有序高并发场景的专用利器。

核心特征

  • 跳表结构:底层为有序单链表,上层按概率生成多层索引,期望层数为 O(log n),平均查找复杂度 O(log n)。
  • 无锁并发:读操作完全无锁;写操作通过 CAS 插入数据节点和索引节点,删除采用“标记-清除”两阶段策略。
  • 有序导航:完整实现 NavigableMap 接口,支持 subMapfloorKey 等范围与近邻查询。
  • 值约束:不允许 null 键(依赖比较操作),但允许 null 值。

适用场景

  • 高并发下的有序遍历和范围查询,如实时排行榜、时序事件窗口、高性能匹配引擎。
  • 读多写少且对延迟极度敏感的系统,无锁读取提供极低响应抖动。
  • 内存占用较高(索引节点约为数据节点的两倍),在内存受限或数据量极大时应权衡是否改用 TreeMap 加读写锁。

8.1 Demo 代码

import java.util.concurrent.ConcurrentSkipListMap;

public class ConcurrentSkipListMapDemo {
    public static void main(String[] args) {
        ConcurrentSkipListMap<Integer, String> map = new ConcurrentSkipListMap<>();
        map.put(3, "C");
        map.put(1, "A");
        map.put(2, "B");
        System.out.println("有序: " + map); // {1=A, 2=B, 3=C}
        System.out.println("floorEntry(2): " + map.floorEntry(2)); // 2=B
        System.out.println("higherKey(2): " + map.higherKey(2)); // 3
    }
}

8.2 底层原理

存储结构

ConcurrentSkipListMap 利用跳表(Skip List)实现,垂直层级随机生成,底层是数据节点(Node),上层为索引节点(Index),最高层头索引(HeadIndex)。所有数据节点按排序链接在底层,索引层形成快速通道,达到 O(log n) 的期望查找效率。

flowchart TD
    head["HeadIndex (level=2)"]
    head --> idx2_1["Index L2, right→"]
    idx2_1 --> idx2_2["Index L2"]
    idx2_1 --> down2_1["Index L1 (down)"]
    idx2_2 --> down2_2["Index L1 (down)"]
    down2_1 --> idx1_1["Index L1, right→"]
    idx1_1 --> idx1_2["Index L1"]
    idx1_2 --> idx1_3["Index L1"]
    down2_2 --> idx1_4["Index L1"]
    idx1_1 --> down1_1["Index down to data"]
    idx1_2 --> down1_2["Index down"]
    idx1_3 --> down1_3["Index down"]
    idx1_4 --> down1_4["Index down"]
    down1_1 --> data1["Node (key=1)"]
    down1_2 --> data2["Node (key=3)"]
    down1_3 --> data3["Node (key=5)"]
    down1_4 --> data4["Node (key=7)"]
    data1 --> data2_next["next→"]
    data2 --> data3_next["next→"]
    data3 --> data4_next["next→"]

图解说明

  • ConcurrentSkipListMap 由多层索引组成,最底层是一个有序单链表(Node),每个 Node 持有 volatile value 和 volatile next
  • 上层为 Index 节点,通过 down 引用连接下层的索引,通过 right 引用连接同层右侧索引。最高层由 HeadIndex 标记,包含当前最大层级 level
  • 插入新节点时会根据随机概率决定它出现在哪些索引层,概率约 50% 逐层递减,使得平均复杂度达到 O(log n)。
  • 无锁查找从最高层开始,水平跨越直到右节点键大于目标,随后下降一层,重复类似动作直至底层数据节点。
  • 删除采用两阶段策略:先 CAS 置 value=null(逻辑删除),之后再物理摘除底层节点及所有相关索引。

插入流程(put)

flowchart TD
    A[put K,V] --> B[doPut: 查找插入位置前驱, 记录路径]
    B --> C{CAS 尝试将新数据节点链入底层}
    C -->|失败| D[重试或发现 key 存在则更新]
    C -->|成功| E[随机决定是否生成索引层级]
    E --> F{随机数满足上升概率?}
    F -->|是| G[创建索引节点, CAS 链接入垂直链表]
    G --> H{到达最高层级?}
    H -->|否| E
    H -->|是| I[可能需要新建头索引]
    F -->|否| J[完成]

流程解读doPut 先通过底层跳跃查找前驱,并 CAS 将新节点链入。插入成功后,通过概率(通常是50%)自底向上创建索引节点,直到概率不满足或达到最高层。索引节点的插入同样使用 CAS,失败重试。此过程完全无锁,仅依赖 CAS 实现原子性。

  1. 查找前驱doPut 从最高层头索引开始,向右寻找待插入 key 的前驱节点,同时记录每一层的前驱和下一节点到路径 b 和 n 中。这个过程与查找类似,但额外记录遍历路径用于后续 CAS 插入。
  2. 底层 CAS 插入数据节点:在底层(Level 0)建立一个新的数据节点 z,并尝试通过 CAS 将它链入前驱 b 之后,即 b.casNext(n, z)。如果 CAS 失败,说明其他线程并发修改了底层链表,需要重新读取前驱并重试,直到成功或将已存在的相同 key 节点更新值(通过 CAS 设置 value)。
  3. 随机生成索引层:插入成功后,使用随机数生成器(ThreadLocalRandom)计算一个层级 rnd & 0x80000001,满足一定概率条件(约 50%)则生成索引层。从 Level 1 开始,逐级向上创建 Index 节点,将数据节点链接到该层的索引链表中。每一层的插入同样通过 CAS 和重试完成,并可能因为竞争导致索引层级创建失败(概率性重试或放弃)。
  4. 扩展最高层级:如果生成的随机层级高于当前最高层级,需要创建新的头索引节点,并 CAS 替换最高层级头。失败重试。这种概率化的层级布置使得跳表平均层数为 O(log n),获得期望对数复杂度。

删除流程(remove)

flowchart TD
    A[remove key] --> B[doRemove: 查找目标节点]
    B --> C[将目标节点的 value 字段 CAS 设为 null 标记逻辑删除]
    C --> D{value 已 null?}
    D -->|是| E[返回 null]
    D -->|否| F[CAS 设置 value 为 null 标记成功]
    F --> G[尝试物理删除: 移除索引节点和底层节点]
    G --> H[addCount 减小计数]
    H --> I[返回旧值]

流程解读:删除分两阶段:逻辑删除通过 CAS 将节点 value 设为 null;物理删除将其前后指针重连,并删除所有索引层引用。两步操作确保并发读读到逻辑删除节点时理解其已失效。

  1. 查找目标节点:通过 findPredecessor 和后续遍历定位待删除的数据节点。若未找到,直接返回 null。
  2. 逻辑删除:调用节点的 casValue(oldVal, null) 将其 value 设为 null,这是一个标记。若该节点已经被其他线程逻辑删除,则重新查找或返回 null。这一步成功即意味着该数据在语义上已删除。
  3. 物理删除:逻辑删除后,调用 addCount(-1L) 递减尺寸,然后执行物理清理。物理删除尝试从底层链表中摘除该数据节点(通过调整前驱的 next 指针),并逐层清理索引链表中指向该节点的 Index 节点。所有清理均通过 CAS 实现,失败会重试,但不影响正确性(因为逻辑删除已经完成)。
  4. 并发读可见性:读操作遇到 value 为 null 的数据节点时,会认为其已删除并跳过,继续查找后续节点。这保证在无锁环境下删除操作的一致性。

查询流程(get)

flowchart TD
    A["get(key)"] --> B["从顶层头索引开始"]
    B --> C["向右比较 如果右侧节点 key 小于目标 则向右移动"]
    C --> D{"右侧节点 key 大于目标或已到边界?"}
    D -->|"是"| E["向下进入下层索引"]
    D -->|"否"| C
    E --> F{"是否到达底层数据节点?"}
    F -->|"否"| C
    F -->|"是"| G["在底层数据节点继续比较 找到相等 key 返回值"]

流程解读:跳表查找从最高层开始水平前进,直到右侧节点 key 大于目标值,则下降到下一层继续,最终到达底层精确匹配。由于全程无锁,读效率极高。

  1. 从最高层开始:获取 head.node 作为当前节点,记录层级。通过水平移动(right 指针)找到该层小于目标 key 的最大节点(即前驱)。比较使用 Comparator 或 Comparable
  2. 下降与水平移动循环:一旦当前层的下一个节点 key 大于目标或已到右边界,则通过 down 指针下降到下一层索引。下降后,从前驱节点继续向右查找。此过程重复直至到达底层(Level 0)。
  3. 底层数据查找:在底层链表中,从最后找到的前驱节点开始向右遍历,比较 key,一旦相等且 value 不为 null(非逻辑删除),返回 value;若遇到 null value,跳过继续;若 key 大于目标,则表明不存在,返回 null。
  4. 无锁读取:整个过程无任何锁或 CAS,所有节点读取均通过 volatile 语义(或 unsafe 数据读取)保证可见性,实现极高的读并发能力。

8.3 并发分析

  • 锁策略:完全无锁,通过 CAS 实现写操作原子性。
  • 读完全无锁:不涉及任何同步,极低延迟。
  • 竞争特点:索引插入时可能因并发冲突导致 CAS 失败并重试,但概率收敛下性能优异。
  • 对比 TreeMapConcurrentSkipListMap 以空间换时间与并发度,而 TreeMap 无并发能力。

8.4 性能分析

  • 时间复杂度

    • get/put/remove 期望 O(log n),每个操作需在多层索引中水平移动和下降,常数因子相较于 TreeMap 可能稍大(因为节点分散,缓存局部性较差)。但优势是完全无锁,读性能不会受写影响,切换开销极低。
    • 范围操作 subMap 等视图高效,顺序遍历时直接沿底层链表移动,与元素数成线性关系。
  • 空间消耗

    • 每个数据节点和索引节点都是独立对象。索引节点大约有 50% 概率提升到第1层,25% 到第2层,以此类推,总索引节点数约为数据节点数(1 + 0.5 + 0.25 + ... ≈ 2 倍),内存消耗约为数据节点的两倍。每个 Index 节点包含 right, down, 以及 node 引用,约 24~32 字节。
    • 内存开销显著高于 TreeMap(后者每个 Entry 约 40 字节),是典型以空间换并发的结构。
  • 并发吞吐

    • 读操作绝对无锁且不阻塞,可线性扩展至大量线程。
    • 写操作通过 CAS 实现,无锁竞争只发生在同位置并发插入/删除时,重试次数有限。插入时的索引层创建也是 CAS 循环,并发度高但也可能导致极少部分线程必须重试。在极高并发同 key 写入时,重试率上升,但整体吞吐依然远胜同步树。

8.5 注意事项

  1. Key 必须可比较
    与 TreeMap 类似,Key 必须在 Comparator 或 Comparable 上有全序关系,且不允许 null(除非比较器支持 null)。
  2. 内存占用较高
    数据量百万级别且内存受限时,避免使用。可用 TreeMap + 外部锁(如 ReadWriteLock)在低并发或读多写少时换取更低内存。
  3. 允许 null Value,但不允许 null Key
    这一点与 ConcurrentHashMap 不同,后者二者均禁止。允许 null value 可表示特殊语义,但同样会带来 get 返回 null 的二义性问题(不存在或值为 null)。使用 containsKey 检查。
  4. 迭代器为弱一致性
    与 ConcurrentHashMap 类似,不抛 ConcurrentModificationException。范围遍历可能看到部分已删除元素。
  5. 批量操作
    putAll 等操作并不会整体原子,而是逐个调用 put,中途可能被其他线程观察到部分插入。若需原子地批量添加,应使用外部锁或事务性包装。
  6. 排序稳定性的特殊风险
    如果 key 在放入后发生了影响比较结果的变化(例如修改了可变字段),跳表结构不会自动调整,定位将错误。确保作为 key 的对象不可变(或只使用不可变字段进行比较)。

模块 9:WeakHashMap 与 IdentityHashMap 深度剖析

9.1 WeakHashMap

定义与适用场景

WeakHashMap 将映射的键包装为弱引用,其内部 Entry 继承 WeakReference<Object>,使得键对象在外部不再有强引用时,可以由 GC 自动回收,随后通过引用队列清除对应映射条目。它的设计意图是实现“键对象生命周期由外部决定”的自动清理映射。

核心特征

  • 弱引用键Entry 本身是一个弱引用,构造函数中将键作为弱引用目标,使键的存留不再阻止其被 GC。
  • 惰性清理机制expungeStaleEntries() 在大多数方法(如 getTablesize)中被调用,遍历引用队列并移除已回收键对应的条目。
  • 空值允许:允许 null 键和 null 值。

适用场景

  • 缓存与外部对象生命周期绑定的场景,如类加载器相关的元数据、UI 组件关联的辅助信息。
  • 需要自动清理、避免手动管理的临时映射。
  • 必须注意:Value 绝不能强引用 Key,否则会导致弱引用失效,键无法被 GC 回收;清理触发依赖后续 map 访问,不可用于即时资源释放。

Demo 代码

import java.util.WeakHashMap;

public class WeakHashMapDemo {
    public static void main(String[] args) throws InterruptedException {
        WeakHashMap<Object, String> map = new WeakHashMap<>();
        Object key = new Object();
        map.put(key, "value");
        System.out.println("Before GC: " + map.size()); // 1
        key = null;
        System.gc();
        Thread.sleep(500);
        System.out.println("After GC: " + map.size()); // 0
    }
}

底层原理

WeakHashMapEntry 继承 WeakReference,将 key 包装为弱引用。当 key 失去外部强引用被 GC 回收后,Entry 的 key 引用变为 null。其内部 expungeStaleEntries 方法在每次 getTable(多数操作触发)时清理被回收的条目。这使 WeakHashMap 适合用作缓存,自动移除不再使用的对象。

flowchart TD
    subgraph WeakHashMap
        whTable["Entry[] table"]
        whTable --> wb0["桶[0]"]
        wb0 --> weakEntry["WeakEntry (extends WeakReference<Key>)"]
        weakEntry --> weakEntry_val["value"]
        weakEntry --> weakEntry_next["next Entry"]
    end
    
    weakEntry -.->|弱引用| keyObj["Key 对象"]
    keyObj -.->|GC 回收| refQueue["ReferenceQueue"]
    refQueue --> expunge["expungeStaleEntries 清理"]

图解说明

  • WeakHashMap 的 Entry 继承自 WeakReference<Object>,并通过构造函数将键包装为弱引用。
  • 当键对象只剩下弱引用可达时,JVM 垃圾回收器会回收该对象,并将对应的 Entry 放入 ReferenceQueue
  • WeakHashMap 在 getTable() 被调用时执行 expungeStaleEntries(),遍历引用队列,将失效节点的键置 null,从哈希桶中清除,并断开 value 引用,使得 value 也可被 GC。
  • 空间开销与 HashMap 相似,但增加了弱引用处理和引用队列。

WeakHashMap 性能分析

  • 时间复杂度:基本操作同 HashMap O(1),但由于每次访问 getTable() 时可能触发 expungeStaleEntries() 清理过期条目,清理需遍历引用队列并同步删除,单次清理成本与失效条目数成正比。若大量弱引用集中失效,某次操作滞后可能引起突发停顿。
  • 空间消耗:Entry 为 WeakReference<Object> 的子类,稍大于普通 Node。此外维护引用队列,开销可接受。
  • 并发:非线程安全。

WeakHashMap 注意事项

  1. Value 绝不能强引用 Key
    这是最常见的内存泄漏场景。例如 map.put(key, value) 中,value 对象内部若持有 key 的强引用,即使外部 key 被置 null,由于 value 存活导致弱引用 key 不会入队,GC 无法回收 key 及其关联 value。始终保证 value 对象不引用 key(或使用 WeakReference 包装)。
  2. GC 时机不确定性
    被弱引用的 key 只有在 GC 发现仅被弱引用可达时才会回收并放入引用队列。因此 WeakHashMap 中的条目不会在 key 失效后立即移除,依赖于 GC 发生,而 GC 时机由 JVM 决定。不应依赖它来做即时资源清理。
  3. expungeStaleEntries 触发点
    该方法在 getTable()size()resize() 等绝大多数方法中调用,但若长期不访问 map,失效条目会一直占据内存。可周期性调用 size() 或遍历触发清理。
  4. 迭代中删除条目
    若使用迭代器遍历,GC 可能在迭代过程中突然回收键并触发并发清理,导致迭代器抛出 ConcurrentModificationException。建议避免在迭代中对 map 进行可能触发 GC 的活动,或使用 ConcurrentHashMap 等替代。

9.2 IdentityHashMap

定义与适用场景

IdentityHashMap 是专为区分对象身份而非逻辑相等而设计的特殊映射。它使用 == 进行键比较,以 System.identityHashCode() 代替对象特有的 hashCode(),内部采用紧凑的线性探测数组直接存储键值对,彻底摒弃 Entry 对象,实现极低的内存开销。

核心特征

  • 引用相等语义:键比较依据 ==,即使两个对象在逻辑上通过 equals 判定相等,只要引用不同即可作为不同键存在。
  • 无 Entry 对象:键和值交替存储于一个 Object[] 数组中,偶数索引存键,奇数索引存值,通过线性探测解决冲突。
  • 散列独立:散列值来自 System.identityHashCode(),不受对象自身重写的 hashCode() 方法影响。
  • 内存高效:避免每个映射项的额外对象头,是所有 Map 中内存占用最低的实现之一。

适用场景

  • 需要基于对象身份进行区分的底层操作,如序列化过程中的对象图追踪、代理对象映射。
  • 适合 hashCode() 实现可能不稳定或不唯一,但仍需建立映射的类。
  • 高负载情况下线性探测可能导致性能退化,应根据预估数据量设置合适的初始容量;非线程安全

Demo 代码

import java.util.IdentityHashMap;

public class IdentityHashMapDemo {
    public static void main(String[] args) {
        IdentityHashMap<String, String> map = new IdentityHashMap<>();
        String s1 = new String("key");
        String s2 = new String("key");
        map.put(s1, "first");
        map.put(s2, "second");
        System.out.println("size: " + map.size()); // 2, 因引用不同
    }
}

底层原理

IdentityHashMap 使用 == 比较键,而非 equals。内部数据结构是简单的数组,键值对交替存储(不创建 Entry 对象),采用线性探测解决冲突。put 时从起始索引遍历,遇 null 空位即插入。因此它允许重复的“逻辑相等”对象(只要引用不同),且不受 hashCode 实现影响。常用于涉及引用语义的特殊场景(如序列化、代理)。

flowchart TD
    subgraph IdentityHashMap
        iTable["Object[] table"]
        iTable --> s0["索引0: key1"]
        iTable --> s1["索引1: value1"]
        iTable --> s2["索引2: key2"]
        iTable --> s3["索引3: value2"]
        iTable --> s4["索引4: null"]
        iTable --> s5["索引5: null"]
    end
    s0 -.->|线性探测| s2

图解说明

  • IdentityHashMap 非常特殊,它直接使用一个 Object[] 数组,不创建任何 Entry 对象。键和值交替存放:偶数索引存键,奇数索引存对应值。
  • 散列使用 System.identityHashCode(key),冲突时采用线性探测,步长为 2(跳过一对键值)。
  • 比较键时使用 == 而非 equals,因此只有引用相同的对象才视为同一个键。这使得 IdentityHashMap 内存占用极低,且不受 hashCode() 实现影响。
  • 默认容量 64,负载因子 2/3,扩容将数组长度翻倍并重新探测。

IdentityHashMap 性能分析

  • 时间复杂度:基本操作 O(1),负载因子低时效率高;但因其使用线性探测解决冲突,当填充程度接近容量时,探测长度增加,退化至 O(n)。默认容量为 64,负载因子 2/3,因此正常情况下探测长度极短。
  • 空间消耗:内部直接用 Object[] 交替存储 key 和 value,无 Entry 对象,内存非常紧凑。对每个键值对占用两个引用(8 或 16 字节)加上数组自身开销,是所有 Map 中最省内存的实现之一。但为了维持负载因子,最多只能存储 capacity * 2/3 个映射,有数组空间浪费。
  • 并发:非线程安全。

IdentityHashMap 注意事项

  1. 使用 == 比较,不可依赖 equals
    例如 new String("key") 两个不同引用可被视为不同的 key,即使内容完全相同。这个特性适用于需要区分对象身份的场合(如序列化、代理、JVM 内部使用),不可用于常规业务逻辑。
  2. 不依赖 hashCode
    IdentityHashMap 使用 System.identityHashCode(obj) 计算散列,该值基于对象内存地址(或 JVM 提供的唯一标识),与类自身的 hashCode 实现无关。因此即使对象的 hashCode 返回常数,也不会引发哈希冲突。
  3. 不支持 fail-fast 迭代器
    与大部分集合不同,IdentityHashMap 的迭代器(通过 entrySet() 等)不是 fail-fast,并发修改仅会导致不确定视图,不会抛异常。仍需注意并发安全。
  4. key 为 null 的兼容性
    它允许 key 和 value 为 null,且可将 null key 与自身相等(==),但其 put/get 对 null 的处理自有特殊路径(使用 NULL_KEY 占位符),一般不造成问题。

模块 10:实战陷阱与最佳实践(附完整 Demo)

陷阱 1:自定义对象 Key 未重写 hashCode/equals

import java.util.HashMap;
import java.util.Map;

public class Trap1_WrongKey {
    static class BadKey {
        int id;
        BadKey(int id) { this.id = id; }
    }

    public static void main(String[] args) {
        Map<BadKey, String> map = new HashMap<>();
        BadKey k1 = new BadKey(1);
        map.put(k1, "data");
        System.out.println(map.get(new BadKey(1))); // null! 因为没有重写equals和hashCode
    }
}

最佳实践:始终重写 hashCodeequals

陷阱 2:多线程并发修改 HashMap 导致问题

import java.util.HashMap;
import java.util.Map;

public class Trap2_HashMapConcurrent {
    static Map<Integer, String> map = new HashMap<>();

    public static void main(String[] args) throws InterruptedException {
        Runnable r = () -> {
            for (int i = 0; i < 1000; i++) map.put(i, "v" + i);
        };
        Thread t1 = new Thread(r), t2 = new Thread(r);
        t1.start(); t2.start();
        t1.join(); t2.join();
        System.out.println(map.size()); // 可能小于2000, 甚至引发死循环(JDK7)
    }
}

最佳实践:使用 ConcurrentHashMap 或外部同步。

陷阱 3:遍历中使用 map.remove() 触发 ConcurrentModificationException

import java.util.*;

public class Trap3_FailFast {
    public static void main(String[] args) {
        Map<Integer, String> map = new HashMap<>();
        for (int i = 0; i < 5; i++) map.put(i, "v");
        for (Integer key : map.keySet()) {
            if (key == 3) map.remove(key); // ConcurrentModificationException
        }
    }
}

最佳实践:使用 Iterator.remove()removeIf

陷阱 4:TreeMap 比较器不一致致元素丢失

import java.util.Comparator;
import java.util.TreeMap;

public class Trap4_TreeMapComparator {
    static class Data {
        int id;
        Data(int id) { this.id = id; }
    }

    public static void main(String[] args) {
        // 比较器与 equals 不一致
        TreeMap<Data, String> map = new TreeMap<>(Comparator.comparingInt(d -> d.id));
        Data d1 = new Data(1);
        map.put(d1, "a");
        map.put(new Data(1), "b"); // 认为是相同 key 覆盖
        System.out.println(map.size()); // 1, 但可能逻辑期望为2,遇到注意
    }
}

陷阱 5:ConcurrentHashMap compute 递归修改死锁

import java.util.concurrent.ConcurrentHashMap;

public class Trap5_CHMComputeDeadlock {
    static ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

    public static void main(String[] args) {
        map.put("A", 1);
        map.compute("A", (k, v) -> {
            map.put("A", 2); // 同一 key 递归修改,死锁
            return v + 1;
        });
    }
}

最佳实践:避免在原子操作内修改同一 key。

陷阱 6:Hashtable 高并发性能灾难

使用 ConcurrentHashMap 取代,已阐述。

陷阱 7:WeakHashMap Value 强引用 Key 泄漏

import java.util.Map;
import java.util.WeakHashMap;

public class Trap7_WeakHashMapLeak {
    static class BigObject { Object data = new byte[1024*1024]; }

    public static void main(String[] args) {
        Map<Object, Object> cache = new WeakHashMap<>();
        Object key = new Object();
        cache.put(key, key); // value 强引用了 key!
        key = null;
        System.gc();
        // key 本应被回收,但 value 又引用了它,导致无法回收
    }
}

最佳实践:Value 绝不应持有 key 的强引用。


模块 11:面试高频专题深度解析

(正文其余模块不涉及面试内容,全部集中于此。)

11.1 HashMap 数据结构与 put 流程

题目背景:理解 HashMap 底层存储及插入过程是核心考点。

标准回答:JDK8 中 HashMap 采用数组+链表+红黑树。put 时先扰动哈希(高16位异或低16位),通过 (n-1)&hash 定位桶。桶空则直接新建节点;桶非空则遍历链表/树,找到相同 key 则替换,否则尾插入。链表长度超过8且数组容量≥64时转为红黑树,插入后若 size 超过阈值则扩容。

追问 1:扰动函数为什么这样设计?
:混合高16位和低16位,增加低位的随机性,降低冲突,且操作高效。

追问 2:为什么容量必须为2的幂?
:方便使用位与替代取模,同时扩容时的 e.hash & oldCap 高效拆分链表。

追问 3:树化阈值为什么是8?
:基于泊松分布,在负载因子0.75下链表长度达到8的概率极低(约千万分之一),树化是防御性设计。

追问 4:JDK 7 与 JDK 8 头插与尾插的区别及影响?
:JDK 7 头插法在多线程扩容时可能造成循环链表导致死循环;JDK 8 改为尾插法,解决了死循环,但仍无法保证线程安全。

加分回答:可提及红黑树转换时的最小树化容量64,以及6的链化迟滞阈值。

11.2 HashMap 扩容机制

题目背景:考察扩容触发、拆分算法及并发历史问题。

标准回答:当 size > capacity * loadFactor 时触发扩容,新容量为旧容量两倍。利用高位 e.hash & oldCap 判断节点留在原索引还是迁移到 原索引+oldCap。JDK 8 尾插法避免逆序和死循环。

追问 1e.hash & oldCap 为什么能拆分?
:容量2次幂下,新索引仅取决于哈希在 oldCap 位上的值,0则原位置,1则+oldCap。

追问 2:JDK 7 扩容为何会死循环?
:头插法翻转了链表顺序,多线程同时扩容时可能形成环。

追问 3:sizeCtl 在 ConcurrentHashMap 中的作用?
:sizeCtl 既表示扩容阈值,负值表示初始化或扩容状态并记录线程数。

加分回答:可扩展到红黑树的 split 过程,以及元素重新分布保证均匀。

11.3 ConcurrentHashMap 线程安全演进

题目背景:理解从分段锁到节点锁的变迁动机。

标准回答:JDK 7 采用 Segment 分段锁,默认16段,写锁段,读无锁。JDK 8 放弃分段锁,改用 synchronized 锁住桶首节点 + CAS,将锁粒度细化到桶级,并发度更高,内存占用更小。

追问 1:为什么放弃分段锁?
:分段数固定,伸缩性受限;每个 Segment 需要额外内存;桶锁可进一步减少碰撞。

追问 2:JDK 8 中 size 的实现?
:baseCount + CounterCell 分散竞争,size 为非精确的瞬时值。

追问 3:JDK 8 synchronized 是否性能不如 ReentrantLock?
:JDK 8 对 synchronized 进行了大量优化(锁升级),性能已不输 ReentrantLock。

加分回答:描述 ForwardingNode 和 helpTransfer 实现多线程协同扩容。

11.4 ConcurrentHashMap 多线程协作扩容

题目背景:着重考察扩容期间的并发协作。

标准回答:线程触发扩容后设置 sizeCtl,其他线程检测到 ForwardingNode 后调用 helpTransfer 协助迁移。通过步长 stride 将待处理桶分配给各线程,迁移完成后新数组替换老数组。

追问 1:ForwardingNode 的作用?
:标识桶正在/已完成迁移,并提供对新数组的查找方法,让读操作无缝转移。

追问 2:transfer stride 如何确定?
:根据 CPU 数目动态计算,最小16。

追问 3:helpTransfer 如何加入?
:读取 sizeCtl 判定扩容状态,CAS 增加线程数,然后领取区间迁移。

加分回答:描述迁移过程同时兼容新写入,写入线程在遇到 ForwardingNode 时转向新数组。

11.5 LinkedHashMap 实现 LRU 缓存

题目背景:考察顺序维护钩子与 LRU 落地。

标准回答:通过 accessOrder=true 打开访问顺序,重写 removeEldestEntry 在插入后检查是否需要淘汰最老节点。双向链表维护顺序,头为最老,尾为最新。

追问 1:accessOrder 如何影响 get 操作?
:get 触发 afterNodeAccess 将访问节点移到链表尾部。

追问 2:removeEldestEntry 默认返回值及触发时机?
:默认 false;在 afterNodeInsertion 中判断,返回 true 则移除头节点。

追问 3:钩子方法还有哪些?
afterNodeRemoval 用于节点删除时从双向链表取消链接。

加分回答:可提及其与 LRU 结合时注意 value 避免强引用 key。

11.6 TreeMap vs ConcurrentSkipListMap

题目背景:评价有序映射选型。

标准回答:TreeMap 基于红黑树,操作 O(log n),非线程安全。ConcurrentSkipListMap 基于跳表,均摊 O(log n),无锁实现,支持高并发有序操作,但空间开销大。

追问 1:红黑树和跳表的区别?
:红黑树是自平衡搜索树,插入删除需旋转,并发实现困难;跳表通过多层索引和概率平衡,易于实现无锁并发。

追问 2:高并发场景选择哪个?
:必须 ConcurrentSkipListMap。

追问 3:时间复杂度对比?
:均 O(log n),但跳表常数因子稍大。

加分回答:说明跳表随机层级期望,以及 ConcurrentSkipListMap 的 mark 删除机制。

11.7 HashSet 与 HashMap 的关系

题目背景:揭示 Set 集合的底层委托。

标准回答:HashSet 内部维护一个 HashMap,元素作为 key 存储,value 为统一的 PRESENT 哑元对象(static final)。add 委托为 map.put(e, PRESENT)。

追问 1:为什么使用 PRESENT 而不是 null?
:null 值在 HashMap 表示无映射,使用 PRESENT 可区分。

追问 2:其他 Set 实现也是如此吗?
:LinkedHashSet 继承 HashSet 内部使用 LinkedHashMap;TreeSet 基于 TreeMap。

加分回答:CopyOnWriteArraySet 基于 CopyOnWriteArrayList,不走 Map。

11.8 WeakHashMap GC 回收与内存泄漏陷阱

题目背景:弱引用与自动清理机制。

标准回答:Entry 继承 WeakReference,当 key 没有外部强引用被 GC 后,WeakHashMap 通过 expungeStaleEntries 清理无效条目。注意 value 强引用 key 会导致泄漏。

追问 1:expungeStaleEntries 何时触发?
:多数操作方法调用 getTable 时检查并清理。

追问 2:如何避免 Value 强引用 key?
:确保 value 不持有 key 的强引用,必要时使用 WeakReference。

加分回答:引用队列的配合,以及与其他引用类型的区别。

11.9 IdentityHashMap 的用途与实现

题目背景:引用等价性的特殊 Map。

标准回答:使用 == 比较键,不依赖 hashCode。常用于序列化中的对象图谱,或因可能篡改 equals 而需要引用语义的场景。内部通过线性探测数组实现。

追问 1:为什么不使用 equals?
:场景需要区分不同引用,哪怕是逻辑相等的对象(如代理对象)。

追问 2:为什么线性探测?
:简化实现且不依赖 Entry 对象,通过散列求模+i探测。

加分回答:适用于拓扑排序、序列化保存对象状态等。

11.10 Map 遍历方式与 fail-fast 机制

题目背景:迭代安全。

标准回答:遍历中若用 map.remove(key) 会修改 modCount,而迭代器检测到 modCount 改变则抛出 ConcurrentModificationException。解决方式为 Iterator.remove()ConcurrentHashMap 等并发容器(无 fail-fast 机制,提供弱一致性遍历)。

追问 1:modCount 如何工作?
:任何结构修改方法都会递增 modCount;迭代器生成时记录 expectedModCount,每次 next 检查是否相等。

追问 2:ConcurrentHashMap 为什么不抛 ConcurrentModificationException?
:它的迭代器基于弱一致性,快照某个时点条目。

加分回答:使用 forEach 或 stream 也可能涉及修改冲突,需注意。

11.11 各种 Map 选型决策树

题目背景:综合应用决策。

标准回答:先判断是否需要线程安全→是否需要排序→是否需访问顺序→是否弱引用或引用相等→最后选定具体类。

追问 1:读多写少高并发选择?
:ConcurrentHashMap。

追问 2:需要保持插入顺序且线程安全?
:可使用 Collections.synchronizedMap(new LinkedHashMap<>(...)) 或 ConcurrentHashMap 但丢失顺序。

追问 3:允许 null 键/值?
:HashMap 允许 null 键值;ConcurrentHashMap 和 Hashtable 不允许。

加分回答:结合空间和时间复杂度微调构造参数。


模块 12:时间复杂度总结与 Map 选型决策树

时间复杂度总结表

实现类get()put()remove()有序性线程安全null 键/值结构
HashMapO(1)O(1)O(1)无序允许/允许数组+链表/红黑树
LinkedHashMapO(1)O(1)O(1)插入/访问顺序允许/允许HashMap + 双向链表
TreeMapO(log n)O(log n)O(log n)排序不允许/允许红黑树
HashtableO(1) (锁竞争)O(1) (锁竞争)O(1) (锁竞争)无序是(全表锁)不允许/不允许数组+链表
ConcurrentHashMapO(1) (无锁)O(1) (桶锁)O(1) (桶锁)无序是(桶锁)不允许/不允许数组+链表/红黑树
ConcurrentSkipListMapO(log n) (无锁)O(log n) (无锁)O(log n) (无锁)排序是(无锁)不允许/允许跳表
WeakHashMapO(1)O(1)O(1)无序允许/允许数组+链表(弱引用)
IdentityHashMapO(1)O(1)O(1)无序允许/允许线性探测数组

Map 选型决策树

flowchart TD
    A[开始选择 Map] --> B{需要线程安全?}
    B -->|是| C{需要排序?}
    C -->|是| CSLM[ConcurrentSkipListMap]
    C -->|否| CHM[ConcurrentHashMap]
    B -->|否| D{需要排序?}
    D -->|是| TM[TreeMap]
    D -->|否| E{需要访问或插入顺序?}
    E -->|是| LHM[LinkedHashMap]
    E -->|否| F{需要弱引用回收?}
    F -->|是| WHM[WeakHashMap]
    F -->|否| G{需要引用相等比较?}
    G -->|是| IDHM[IdentityHashMap]
    G -->|否| HM[HashMap]

决策树解释:依据线程安全需求首先分流。安全分支若含排序则选 ConcurrentSkipListMap,否则 ConcurrentHashMap。非安全分支,先考虑排序需要(TreeMap),再考虑特定顺序(LinkedHashMap),继而特殊语义如弱引用清理(WeakHashMap)或引用等价性(IdentityHashMap),最终默认 HashMap