概述
在 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 则是现代高并发首选。WeakHashMap 和 IdentityHashMap 则在特定场景下扮演专用角色。
模块 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持有hash、key、value和指向下一节点的next引用。 - 当链表长度 ≥ 8 且数组容量 ≥ 64 时,链表转换为红黑树,桶首节点变为
TreeNode。TreeNode继承自Node,新增parent、left、right、prev及颜色标志,构成一棵平衡二叉树。但是,树节点仍然通过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 自增,若超过阈值 threshold(capacity * loadFactor)
则触发扩容。
-
扰动计算:
put首先调用hash(key),实现为(h = key.hashCode()) ^ (h >>> 16)。高16位与低16位异或,目的是将高位特征混合进低位,使后续索引定位时,即使表容量较小(仅利用低几位),也能让高位差异影响索引,降低冲突概率。这是兼顾速度与质量的散列扰动函数。 -
数组初始化检查:若
table为null或长度为0,调用resize()进行首次懒初始化。resize()根据初始容量和负载因子计算出threshold,避免构造时就占用内存。 -
索引定位:使用
(n - 1) & hash替代取模。因为容量n恒为2的幂,n-1的低位全为1,按位与等价于hash % n但效率更高。 -
桶空直接插入:若索引位置为
null,直接调用newNode(hash, key, value, null)创建节点并放入,然后跳转到步骤7(增加计数)。这是最快路径。 -
桶非空——遍历内部结构:
- 首先检查桶首节点
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(),因为短期扩容本身就能有效缓解冲突。
- 首先检查桶首节点
-
存在旧值处理:若
e不为null,说明找到了已有键,取出旧值oldValue,用新值覆盖e.value,调用afterNodeAccess(e)(HashMap 中空实现,供 LinkedHashMap 使用),并返回旧值。此时不修改结构计数。 -
新增节点后续操作:若
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 指针。最后更新 modCount 和 size,返回旧值。
-
定位与首节点检查:通过
hash(key)和(n-1)&hash定位桶下标,取出首节点。若首节点为null,直接返回null,表示键不存在。 -
匹配首节点:比对首节点的哈希和键,若相等则记录节点
node。 -
遍历查找:若不相等,则根据首节点类型分路:
- 若
first instanceof TreeNode,调用getTreeNode(hash, key)在红黑树中递归查找键。 - 若为链表,调用
do-while循环遍历,依次匹配各节点,直到找到或链表结束。
- 若
-
执行删除:若未找到节点,返回
null。否则分为两种情形:- 树节点:调用
removeTreeNode(tab, movable)。该方法从红黑树中移除指定节点(可能先交换后继,调整指针),再调用balanceDeletion修复红黑树平衡,最后检查树结构是否需要退化为链表(若树中节点数小于UNTREEIFY_THRESHOLD(6))。 - 链表节点:从链表中摘除,即设置前一节点的
next指向被删节点的next(或直接修改桶首指针)。无需平衡操作。
- 树节点:调用
-
更新元数据:结构修改导致
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)(链表)。
-
散列与定位:同样计算
hash(key)并(n-1)&hash得到桶索引。若桶为null,直接返回null。 -
首节点匹配:检查桶首节点,若哈希相等且键相等(引用相等或
equals为true),则直接返回first.value。这是最常见且最快的命中路径。 -
树/链表查找:若首节点不匹配且其
next不为null:- 若
first instanceof TreeNode,调用getTreeNode(hash, key)。该方法通过root.find(hash, key, null)遍历红黑树,利用二叉搜索树性质(比较哈希值,若相等再用equals比较键)快速定位,平均时间复杂度 O(log n)。 - 否则为链表,
do-while循环依次比对每个节点的键,直到匹配或链表结束。
- 若
-
返回结果:找到匹配节点返回其值,否则返回
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。这样无需重新计算哈希,仅通过位运算分流。对于红黑树,同样按照此规则拆成两棵树,若节点数不足则退化链表。
-
容量与阈值计算:
resize()首先计算新容量newCap = oldCap << 1(2倍),同时新的阈值newThr = oldThr << 1。若到达最大容量MAXIMUM_CAPACITY (1<<30),则不再扩容,阈值设为Integer.MAX_VALUE。若旧容量为0(初始化),则按初始容量和负载因子计算初始阈值。 -
建新数组:分配
Node[newCap]作为newTab。 -
数据迁移:遍历旧数组的每一个桶。
- 空桶:跳过。
- 单节点桶:
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]。这一操作完全避免对每个元素重新计算哈希模运算,仅利用扩容前后的容量特征位,性能极高。
-
完成迁移:所有桶处理完毕后,将
table引用指向newTab,threshold更新为新阈值。旧数组随后可被 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 字节),链表时额外指针56 字节。next;红黑树节点TreeNode约为Node的两倍,包含 left/right/parent/prev 及 boolean color,约 48 - 负载因子 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 注意事项
-
线程不安全导致的数据丢失与死循环
JDK 8 中虽然改为尾插法解决了扩容链表死循环,但put过程中多线程仍可能出现数据覆盖、size 计数器异常等问题。例如两个线程同时执行put,可能都读到size,然后各自++并回写,导致少计数;且可能同时向同一空桶执行newNode,造成丢失。任何并发修改必须使用ConcurrentHashMap或外部同步,不可仅凭无异常就认为安全。 -
自定义对象作 Key 的 hashCode/equals 契约
必须严格满足:相等对象必须具有相同的哈希码;尽可能使不相等对象有不同哈希码。若 equals 重写而 hashCode 未重写,会导致两个 equals 为 true 的对象散列到不同桶,从而无法取出。反之,必须保证 equals 判定相等的 key 不会出现在 map 中多次。常用 IDE 生成方法可满足要求。注意hashCode计算中使用的字段应是不可变的,否则修改 key 的状态会导致槽位错误,对象“丢失”。 -
初始容量与负载因子调优
- 若能预估存储量
n,设置new HashMap((int)(n/0.75f)+1)可避免多次扩容,尤其在数据批量导入时显著提升性能。 - 延迟敏感型应用(如服务实时请求)可适当调低负载因子至 0.5~0.6,减少冲突和扩容频率,但需权衡内存开销。
- 切勿设置过小的负载因子(如 0.1),导致巨大内存占用;也不宜设置过大(如 0.99),可能导致大量冲突和退化。
- 若能预估存储量
-
序列化与克隆的深拷贝问题
HashMap的克隆为浅拷贝,内部数组被复制,但每个节点仍引用相同的 key 和 value 对象。修改克隆后的值可能影响原 Map(若 value 是可变对象)。序列化恢复后与原始对象不再有关系。 -
遍历稳定性
迭代器是 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> 增加了 before 和 after 两个引用,构成一个贯穿所有节点的双向链表。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 复用 HashMap 的 put 方法,但重写了 newNode 和 afterNodeInsertion 等钩子。
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 将新插入的节点挂在链表末尾,确保顺序维护。
- 委托 HashMap.put:
LinkedHashMap并未重写put方法,完全使用父类HashMap的put。父类在完成键值对插入或替换后,会调用三个后置钩子:afterNodeAccess(访问回调)、afterNodeInsertion(插入回调)、afterNodeRemoval(删除回调)。LinkedHashMap就是靠重写这三个方法实现顺序维护。 - 步骤一:获取新节点:
HashMap.put内部在创建新节点时,会调用newNode(int hash, K key, V value, Node<K,V> e)或newTreeNode。LinkedHashMap重写了这些方法,返回的是扩展后的LinkedHashMap.Entry节点,该节点包含before和after引用。新节点创建后,LinkedHashMap就立即把该节点链接到内部双向链表的末尾(linkNodeLast)。这一步实际上是在put执行过程中就已经完成节点加入双向链表,因此顺序已初步建立。 - 步骤二:afterNodeInsertion 淘汰检查:插入完成后,
HashMap.put调用afterNodeInsertion(evict)。evict在初始构造阶段为false,防止过早淘汰。LinkedHashMap在此钩子内检查removeEldestEntry(first)方法的返回值。如果用户子类重写该方法并在容量超出时返回true,则执行淘汰:通过双向链表的头节点(head)获取最老的映射,然后调用removeNode(hash(key), key, null, false, true)将其从 HashMap 中删除。需要注意,淘汰时同时从数组和双向链表中移除该节点(通过最终的afterNodeRemoval钩子)。 - 最终形态:节点已插入 HashMap 并正确维护在双向链表尾部(或头部被淘汰),确保迭代顺序符合要求。
删除流程(remove)
flowchart TD
A["remove(key)"] --> B["HashMap.remove 执行 定位并移除节点"]
B --> C["钩子 afterNodeRemoval 被调用"]
C --> D["从双向链表中摘除该节点: before.after = after; after.before = before"]
D --> E["结束"]
流程解读:删除节点后,afterNodeRemoval 将节点从维护顺序的双向链表中脱离,不影响数组位置结构,仅维护顺序链表完整。
- 委托 HashMap.remove:
LinkedHashMap未重写remove,直接使用父类逻辑,成功删除节点后,HashMap.removeNode会在返回前调用afterNodeRemoval(node)。 - 从双向链表摘除:重写的
afterNodeRemoval获取该节点的before、after指针,执行标准双向链表删除操作: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 将该节点从双向链表中移至尾部,实现最近访问的元素位于尾部,最久未访问的位于头部。
-
调用父类 get:
LinkedHashMap重写了get方法以触发访问顺序维护。它直接调用父类HashMap.getNode进行查找(或调用getOrDefault)。若找到节点e,则进入访问后置逻辑。 -
accessOrder 检查:若构造时传入
accessOrder = true(默认为false即插入顺序),则调用afterNodeAccess(e)。该方法的行为取决于该节点是否已经是双向链表尾部:- 若节点已是尾部(
last == e),则无需操作。 - 否则,先将其从链表中摘除(同上删除操作),然后通过
linkNodeLast将该节点链接到链表末尾,使其成为最新的访问节点。 - 这一移动操作在
HashMap.get返回之前完成,因此可见性能稍有影响(需要维护链表),但实现了 LRU 特性。
- 若节点已是尾部(
-
返回结果:最后返回查找到的
value。若accessOrder为 false,则直接返回结果,不移动节点,保证迭代顺序为插入顺序。
3.3 性能分析
-
时间复杂度:
- 所有基本操作保持 O(1),与 HashMap 一致。额外开销在于操作后维护双向链表:插入时追加到尾部(O(1) 指针调整),访问时(若 accessOrder=true)移动节点到尾部也是 O(1),删除时摘除节点 O(1)。因此常数因子稍大,但仍在纳秒级差异。
- 迭代遍历
entrySet()时,直接沿双向链表顺序访问,无需遍历数组,速度快于 HashMap(HashMap 迭代需跳过空桶)。
-
空间消耗:
- 各节点为
LinkedHashMap.Entry,比HashMap.Node多出两个引用(before、after),每个节点增加约 8 字节(开启指针压缩)到 16 字节(未压缩)。整体空间比 HashMap 略高约 15%~20%。 - 双向链表仅维护节点顺序,无额外数组。
- 各节点为
-
并发:非线程安全,限制同 HashMap。
3.4 注意事项
-
accessOrder 的陷阱
accessOrder只能在构造函数中设定,实例化后无法更改。默认false为插入顺序。- 开启
accessOrder=true时,get操作会修改双向链表结构(移动节点),因此如果有并发读取,必须外部同步,否则可能破坏链表一致性。即便单线程,频繁 get 也会产生写操作,影响 CPU 缓存局部性,性能敏感性场景需评估。
-
LRU 缓存实现细节
- 重写
removeEldestEntry时,通常按size() > MAX_ENTRIES判断。该方法在put或putAll的每次插入新节点时都可能调用(具体取决于afterNodeInsertion的evict参数)。 - 注意:当
MAX_ENTRIES为 0 时,可能立即逐出刚插入的节点;注意逻辑边界。 - 内存泄漏风险:若作为缓存的 value 对象强引用了 key,会导致即使缓存淘汰后,由于 key 已被删,但 value 内仍引用旧 key,使旧 key 无法被 GC(如果 value 生命周期比缓存长)。应避免 value 持有对 key 的强引用,或使用弱引用设计。
- 重写
-
与 HashMap 的继承关系导致的可变性
LinkedHashMap继承HashMap,因此clone()、序列化等行为需确保附带双向链表状态。克隆时会调用父类浅拷贝,然后重建双向链表。如果子类添加了额外字段,需自行处理克隆。 -
迭代中删除的顺序一致性
使用迭代器删除元素时,双向链表和哈希表同时更新,顺序保持正确。但如果通过map.keySet().remove(key)删除,同样会触发afterNodeRemoval,顺序得到维护。
模块 4:TreeMap 深度剖析——基于红黑树的有序映射
4.0 定义与适用场景
TreeMap 是 NavigableMap 的标准实现,底层使用红黑树来维护键的严格排序。它完全依赖 Comparator 或键自身的 Comparable 接口确定位置,所有基本操作时间复杂度均为 O(log n),并提供了丰富的范围查询和近邻导航方法,是为有序映射需求设计的专用容器。
核心特征
- 红黑树结构:每个
Entry包含左子、右子、父节点引用及颜色标记,树通过变色和旋转始终保持平衡,高度不超过 2log₂(n)。 - 排序与导航:支持自然顺序或自定义
Comparator;提供subMap、headMap、tailMap、floorKey、ceilingKey等范围与近邻查询方法。 - 键约束:键不可为
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, parent 和 color 字段。树整体满足二叉搜索树性质,并通过旋转和变色维持平衡。
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 处理“红-红”冲突等各种违反红黑树约束的情况,通过变色和旋转将树重新调整平衡。
-
空树处理:若根节点
root == null,说明树为空,将新节点创建为根节点,颜色设为黑色(满足红黑树根节点必黑性质),随后递增size、modCount,返回null。 -
递归查找插入位置:从根节点开始,沿着树向下遍历。比较器选择顺序:若构造 TreeMap 时传入了
Comparator,则使用comparator.compare(key, t.key);否则强制将 key 转为Comparable,调用compareTo进行比较。比较结果cmp:cmp < 0:进入左子树。cmp > 0:进入右子树。cmp == 0:找到完全相同的键(逻辑上相等,不要求引用相同),此时调用t.setValue(value)覆盖旧值,返回旧值,结束插入。
-
插入新节点:若一直到达叶子下方仍未发现相同键,则以当前遍历的父节点
parent为基准,根据最后一次比较结果将新节点挂载为左孩子或右孩子。新节点颜色默认设为红色,因为插入红色节点不违反“黑高一致”性质,只可能违反“不连续红”性质,修复代价较小。 -
修复红黑树平衡:调用
fixAfterInsertion(x)。从新节点x开始向上回溯,只要x不为空、不是根、且父节点是红色(即出现双重红色),则进入修复循环。根据父节点是祖父节点的左孩子还是右孩子分对称两套逻辑,主要涉及:- 叔叔节点为红色:将父、叔染黑,祖父染红,然后将
x指向祖父,继续向上修复。 - 叔叔节点为黑色且当前节点与父节点不是同侧(即 LR 或 RL 情况):先旋转父节点,转换为同侧情况(LL 或 RR)。
- 同侧情况(LL 或 RR) :将父节点染黑,祖父染红,围绕祖父进行右旋或左旋,完成后退出循环。
最后确保根节点始终为黑色。
- 叔叔节点为红色:将父、叔染黑,祖父染红,然后将
-
更新元数据:插入完成后递增
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 从删除位置向上修复红黑树平衡,可能涉及旋转和变色。
-
查找待删除节点:
getEntry(key)按比较器或自然顺序查找节点,若未找到直接返回null。 -
确定实际删除节点:如果待删除节点
p同时存在左右孩子,则寻找其中序后继s(即右子树的最左节点)。将s的键值拷贝到p中(保留位置、颜色等结构不变),然后将p指针指向s,从而将问题转化为删除最多只有一个孩子的后继节点s。这样保证实际移出树的节点replacement最多只有一个子节点。 -
获取替代子节点:
replacement = (p.left != null ? p.left : p.right)。即若p有左孩子则替代为左孩子(最多一个孩子),否则右孩子。 -
从树中摘除节点
p:- 若
replacement不为空(即有一个孩子),则将替代节点的父指针指向p.parent,并在p.parent中相应位置用replacement替换p。同时将p的左右及父指针置空,使其脱离树。 - 若
replacement为空且p.parent == null,则说明p是唯一节点,直接将root置null。 - 若
replacement为空但有父节点,则将父节点中对应儿子指针置空,即简单摘除叶子。
- 若
-
修复平衡:若删除的节点
p是黑色,可能破坏黑高平衡,需要调用fixAfterDeletion(x),其中x是替代节点或(若替代为空)临时使用p本身。修复逻辑围绕“当前路径少一个黑色”展开,通过兄弟节点的颜色和子节点颜色,分多种情况进行染色和旋转操作,使树重新满足红黑树性质。 -
收尾:将
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)。
-
空树直接返回 null。
-
遍历查找:从根节点
p开始循环,比较key与p.key:- 通过
comparator或键的Comparable计算比较结果cmp。 cmp < 0:转向左子节点。cmp > 0:转向右子节点。cmp == 0:找到目标,返回p.value。
若p变为null,说明键不存在,返回null。
- 通过
-
复杂度:由于红黑树高度始终 O(log n),查询最坏比较次数约 2*log(n)。
4.3 性能分析
-
时间复杂度:
put/get/remove均为 O(log n),其中n为当前元素数量。红黑树高度最多为 2*log₂(n),每次比较可能需要调用comparator.compare()或Comparable.compareTo(),常数因子高于哈希表(特别是 key 比较较昂贵时)。- 范围操作如
subMap、headMap、tailMap等返回视图,其遍历耗时 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一致,但非强制(集合行为可能不一致)。
- 比较器效率直接影响 TreeMap 性能。如果比较器实现中有复杂的逻辑或未缓存比较结果,每次比较都可能成为瓶颈。建议比较器简单高效,且保持与
4.4 注意事项
-
Key 必须可比较
- 缺省使用 key 的
Comparable自然顺序,若 key 未实现Comparable,在第一次 put 时抛出ClassCastException。提供Comparator可绕过,且此时允许 key 为null(取决于比较器是否处理 null)。自然顺序下 key 绝不可为null。
- 缺省使用 key 的
-
比较器一致性
- 虽然 TreeMap 不要求
compare与equals一致,但若不一致,TreeMap 的行为可能违反 Map 接口的常规约定(例如map.containsKey(k)使用compare而非equals,可能导致 equals 相等的两个对象被视为不同 key 而共存)。强烈建议保持 compare 与 equals 一致。
- 虽然 TreeMap 不要求
-
非线程安全
与 HashMap 相同,并发修改会破坏树结构,导致永久性数据丢失或死循环。迭代器为 fail-fast。 -
大 value 的序列化
若 value 是大型对象,TreeMap 序列化将递归树遍历,可正常工作;但若比较器中引用了非序列化对象,反序列化会失败。 -
自定义 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状态机协调多线程共同参与数据迁移,将扩容耗时分散。 - 原子复合操作:提供
compute、merge、computeIfAbsent等方法,在持有桶锁期间执行函数体,确保复合操作原子性。 - 计数分散:通过
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 扩容。
-
散列与自旋:
spread(hashCode)执行扰动:(h ^ (h >>> 16)) & HASH_BITS(确保结果非负)。随后进入for自旋循环,保证竞争下可重试。 -
数组初始化:如果
table为 null,初始化通过initTable()进行。该方法使用sizeCtl字段作为状态:sizeCtl为负表示其他线程正在初始化或扩容,当前线程调用Thread.yield()让出时间片;否则 CAS 将sizeCtl设为 -1,成功后创建新数组,并设置sizeCtl = n - (n >>> 2)(0.75倍容量作为下次扩容阈值)。CAS 竞争失败的线程同样自旋让出,直到初始化完成。 -
空桶 CAS 插入:用
tabAt(tab, i)获取桶首节点。若为 null,尝试casTabAt(tab, i, null, new Node<K,V>(hash, key, value))。若 CAS 成功,跳出循环;失败则继续自旋。这一步骤实现了无锁快速插入。 -
ForwardingNode 检测与协助扩容:若桶首节点
f.hash == MOVED,表明当前数组正在扩容,且该桶已被迁移。此时执行helpTransfer(tab, f),当前线程加入扩容工作,协助迁移其他未完成的桶。扩容完成或获取新数组后,继续自旋重试插入。 -
桶级加锁插入:若桶非空且非 ForwardingNode,使用
synchronized(f)锁住桶的首节点(链表头/树根)。进入同步块后首先再次检查tabAt(tab, i) == f,防止在获取锁之前桶首节点被其他线程改变。- 若
f.hash >= 0(普通链表),遍历链表,计算binCount,查找相同 key 的节点。若找到则记录旧值,用新值覆盖;若未找到则在链表尾部追加节点,并检查是否需树化(链表长度 >= 8 且数组容量 >= 64,由treeifyBin处理)。 - 若
f instanceof TreeBin(红黑树),调用TreeBin.putTreeVal进行树的插入或覆盖。树的并发操作通过内部的读写锁(LockSupport)配合 CAS 保证同步,但此处外层已经有synchronized,所以不会并发修改同一棵树。
- 若
-
释放锁并增加计数:同步块执行完毕后自动释放锁。若插入的是新节点,调用
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[返回旧值]
流程解读:与插入类似,若遇到扩容则先协助。加锁后确保删除操作的原子性,并在移除节点后调整树/链表结构,最后更新计数。
-
前置检查:定位桶索引。若桶为空,直接返回
null。 -
扩容协助:若首节点为
ForwardingNode(hash==MOVED),调用helpTransfer协助扩容,结束后重新自旋查找桶再次尝试删除。 -
同步块内删除:锁住桶首节点,再次检查首节点未改变。在链表或树中搜索匹配节点:
- 链表:通过
pred(前驱节点) 和当前节点的比较,找到待删节点后,使pred.next跨过该节点。 - 树:调用
TreeBin.removeTreeNode进行删除,并可能退化为链表。
找到并删除节点后,记录旧值。
- 链表:通过
-
计数更新与返回:释放锁后,调用
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 方法在新数组中查找,实现对迁移过程的透明。由于不参与锁,并发读效率极高。
-
无锁可见性:
tabAt通过Unsafe.getObjectVolatile读取数组元素,确保获取到最新写入的桶首节点,这是无锁读的关键。 -
首节点匹配:先检查首节点的 hash 和 key 是否与请求相等(
equals),命中则直接返回。此为最快路径。 -
特殊节点路由:若首节点 hash < 0,可能是以下两种情况:
ForwardingNode(hash==MOVED):说明桶正在扩容或被迁移,调用ForwardingNode.find(h, k),该方法会转向新数组进行查找,对读完全透明。TreeBin(hash==TREEBIN):调用TreeBin.find(h, k)在红黑树中查找。TreeBin内部的读线程通过volatile变量和链表保证无锁访问,即使在树旋转时也能安全遍历。
-
链表遍历:若首节点 hash >= 0 且 key 不匹配,沿 next 指针遍历链表逐个匹配。
-
返回结果:找到匹配节点则返回其值,否则返回 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 替换为新数组。
- 扩容触发:当
addCount发现总元素数超过sizeCtl阈值时,调用transfer(tab, null)。一个线程(通常是触发扩容的)首先通过 CAS 将sizeCtl设置为一个较大的负数(高16位包含扩容标记,低16位记录参与线程数 + 2),表示它发起了扩容。 - 发起线程初始化新数组:确认是自己发起扩容后,创建新数组
nextTable,容量为旧表 2 倍。随后进入transfer主循环,从旧表末尾向前逐步分配迁移区间。每个线程负责迁移一段连续的桶 (stride步长,最小16),通过transferIndex指针 CAS 竞争获取。 - 迁移数据:线程对自己负责的桶执行迁移。对于链表,采用与
HashMap相同的e.hash & oldCap高低位拆分;对于树,调用TreeBin.split拆分并可能转化链表。迁移完成后,将桶位置替换为ForwardingNode,其nextTable引用指向新数组,这样其他线程读操作可以直接转查新数组。 - 协助线程加入:当其他线程执行
put/remove/get发现某个桶是ForwardingNode(hash==MOVED)时,调用helpTransfer。该方法会检查sizeCtl确认扩容仍在进行,然后尝试 CAS 增加参与线程数,加入transfer循环,分配剩余桶进行迁移。这样实现了多线程并行扩容,避免单线程瓶颈。 - 扩容完成:当所有桶被迁移完毕,最后一个退出的线程(或发起线程)负责将
table设置为nextTable,并更新sizeCtl为新的扩容阈值(nextTable.length * 0.75)。整个过程对读写影响极小。
sizeCtl 状态
- 负值表示正在初始化或扩容:-1 为初始化;其他负值
-(1+活跃线程数)。 - 正值为下一次扩容的阈值。
计数机制
内部使用 baseCount 及 CounterCell 数组分散并发修改,避免 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无额外同步字段。 - 额外的
sizeCtl、CounterCell数组(默认大小为 2 的幂,常规场景很小)用于分散计数,占用可忽略。 - 扩容期间临时存在
nextTable双倍数组,以及 ForwardingNode 占位节点,内存峰值为 2 倍旧表。可通过预设合理初始容量避免频繁扩容。
- 数据结构与 HashMap 相近,但节点均用
-
并发吞吐:
- 读扩展性线性度极高,几乎不受写影响(
volatile读 + 缓存一致性开销)。 - 写扩展性强,但热点 key 会导致同一桶频繁加锁竞争,此时可考虑使用
compute或merge等原子方法减少锁持有时间,或通过更高层的设计分散 key。 - Java 8 对
synchronized进行了锁升级优化(偏向锁、轻量级锁),桶级同步的开销在低竞争下可忽略,高竞争下会自动膨胀为重量级锁,但比 JDK 7 的 Segment 锁仍有更低竞争概率。 - 与
Hashtable对比:在 16 线程并发 put 测试中,ConcurrentHashMap可达到Hashtable的数十倍吞吐量。
- 读扩展性线性度极高,几乎不受写影响(
-
计数器的弱一致性:
size()和isEmpty()的结果为近似值,不能用于精确并发控制。实现采用分而治之的计数,避免了全局热点,但也因此允许短暂的不一致。
7.5 注意事项
-
绝对不允许 null 键或值
如果 put 时 key 或 value 为 null,会立即抛出NullPointerException。设计意图是避免并发环境下二义性:get(key)返回 null 无法区分是不存在还是值为 null。因此需要用占位对象表示 null。 -
size() 是粗略值
在多线程并发修改时,size()返回的是某个快照的近似结果,可能大于或小于实际瞬时元素数。切勿依赖size()做精确逻辑判断(如实现缓存逐出阈值),应改用mappingCount()返回 long 也可以,但仍是弱一致。 -
原子操作避免递归锁死锁
compute、computeIfAbsent、merge等方法在持有桶锁期间执行提供的函数。如果函数内部再次尝试修改 同一个 key 导致需要获取同一个桶锁,由于synchronized不可重入,会导致死锁。例如:java
map.compute("A", (k, v) -> { map.put("A", 2); // 死锁,同一桶锁 return v+1; });解决:避免在原子操作内修改映射中的任何 key(尤其是同一 key)。如果必须级联修改,使用多步操作,释放锁后再次获取。
-
弱一致性迭代器
迭代器keySet().iterator()等不会抛出ConcurrentModificationException,它们遍历的是某个时刻的元素快照或视图,可能看不到遍历过程中新加入的数据,也可能看到已经删除的数据,这是设计权衡。因此,执行迭代期间不能假定集合是稳定的。 -
扩容期间的内存与CPU
如果你预设超大容量(如数千万),扩容仍可能引起较长时间的迁移。可通过new ConcurrentHashMap(expectedSize)设定合适的初始容量,并考虑让 JVM 有充足堆空间。 -
序列化注意
序列化时不会保留内部哈希桶状态,而是序列化 key-value 对;反序列化后重建哈希表,所以性能可能稍低于原始表。
模块 8:ConcurrentSkipListMap 深度剖析——基于跳表的高并发有序映射
8.0 定义与适用场景
ConcurrentSkipListMap 是为高并发环境下仍需要键排序而设计的映射实现,底层采用跳表数据结构,通过多层索引和纯 CAS 操作实现全无锁并发。它在提供 TreeMap 级别有序能力的同时,克服了红黑树在并发控制上的结构锁难题,使读写均能无阻塞进行,是有序高并发场景的专用利器。
核心特征
- 跳表结构:底层为有序单链表,上层按概率生成多层索引,期望层数为 O(log n),平均查找复杂度 O(log n)。
- 无锁并发:读操作完全无锁;写操作通过 CAS 插入数据节点和索引节点,删除采用“标记-清除”两阶段策略。
- 有序导航:完整实现
NavigableMap接口,支持subMap、floorKey等范围与近邻查询。 - 值约束:不允许
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 实现原子性。
- 查找前驱:
doPut从最高层头索引开始,向右寻找待插入 key 的前驱节点,同时记录每一层的前驱和下一节点到路径b和n中。这个过程与查找类似,但额外记录遍历路径用于后续 CAS 插入。 - 底层 CAS 插入数据节点:在底层(Level 0)建立一个新的数据节点
z,并尝试通过 CAS 将它链入前驱b之后,即b.casNext(n, z)。如果 CAS 失败,说明其他线程并发修改了底层链表,需要重新读取前驱并重试,直到成功或将已存在的相同 key 节点更新值(通过 CAS 设置 value)。 - 随机生成索引层:插入成功后,使用随机数生成器(
ThreadLocalRandom)计算一个层级rnd & 0x80000001,满足一定概率条件(约 50%)则生成索引层。从 Level 1 开始,逐级向上创建Index节点,将数据节点链接到该层的索引链表中。每一层的插入同样通过 CAS 和重试完成,并可能因为竞争导致索引层级创建失败(概率性重试或放弃)。 - 扩展最高层级:如果生成的随机层级高于当前最高层级,需要创建新的头索引节点,并 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;物理删除将其前后指针重连,并删除所有索引层引用。两步操作确保并发读读到逻辑删除节点时理解其已失效。
- 查找目标节点:通过
findPredecessor和后续遍历定位待删除的数据节点。若未找到,直接返回 null。 - 逻辑删除:调用节点的
casValue(oldVal, null)将其 value 设为 null,这是一个标记。若该节点已经被其他线程逻辑删除,则重新查找或返回 null。这一步成功即意味着该数据在语义上已删除。 - 物理删除:逻辑删除后,调用
addCount(-1L)递减尺寸,然后执行物理清理。物理删除尝试从底层链表中摘除该数据节点(通过调整前驱的 next 指针),并逐层清理索引链表中指向该节点的 Index 节点。所有清理均通过 CAS 实现,失败会重试,但不影响正确性(因为逻辑删除已经完成)。 - 并发读可见性:读操作遇到 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 大于目标值,则下降到下一层继续,最终到达底层精确匹配。由于全程无锁,读效率极高。
- 从最高层开始:获取
head.node作为当前节点,记录层级。通过水平移动(right指针)找到该层小于目标 key 的最大节点(即前驱)。比较使用Comparator或Comparable。 - 下降与水平移动循环:一旦当前层的下一个节点 key 大于目标或已到右边界,则通过
down指针下降到下一层索引。下降后,从前驱节点继续向右查找。此过程重复直至到达底层(Level 0)。 - 底层数据查找:在底层链表中,从最后找到的前驱节点开始向右遍历,比较 key,一旦相等且 value 不为 null(非逻辑删除),返回 value;若遇到 null value,跳过继续;若 key 大于目标,则表明不存在,返回 null。
- 无锁读取:整个过程无任何锁或 CAS,所有节点读取均通过
volatile语义(或 unsafe 数据读取)保证可见性,实现极高的读并发能力。
8.3 并发分析
- 锁策略:完全无锁,通过 CAS 实现写操作原子性。
- 读完全无锁:不涉及任何同步,极低延迟。
- 竞争特点:索引插入时可能因并发冲突导致 CAS 失败并重试,但概率收敛下性能优异。
- 对比 TreeMap:
ConcurrentSkipListMap以空间换时间与并发度,而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 注意事项
- Key 必须可比较
与 TreeMap 类似,Key 必须在Comparator或Comparable上有全序关系,且不允许 null(除非比较器支持 null)。 - 内存占用较高
数据量百万级别且内存受限时,避免使用。可用TreeMap+ 外部锁(如ReadWriteLock)在低并发或读多写少时换取更低内存。 - 允许 null Value,但不允许 null Key
这一点与ConcurrentHashMap不同,后者二者均禁止。允许 null value 可表示特殊语义,但同样会带来get返回 null 的二义性问题(不存在或值为 null)。使用containsKey检查。 - 迭代器为弱一致性
与 ConcurrentHashMap 类似,不抛 ConcurrentModificationException。范围遍历可能看到部分已删除元素。 - 批量操作
putAll等操作并不会整体原子,而是逐个调用put,中途可能被其他线程观察到部分插入。若需原子地批量添加,应使用外部锁或事务性包装。 - 排序稳定性的特殊风险
如果 key 在放入后发生了影响比较结果的变化(例如修改了可变字段),跳表结构不会自动调整,定位将错误。确保作为 key 的对象不可变(或只使用不可变字段进行比较)。
模块 9:WeakHashMap 与 IdentityHashMap 深度剖析
9.1 WeakHashMap
定义与适用场景
WeakHashMap 将映射的键包装为弱引用,其内部 Entry 继承 WeakReference<Object>,使得键对象在外部不再有强引用时,可以由 GC 自动回收,随后通过引用队列清除对应映射条目。它的设计意图是实现“键对象生命周期由外部决定”的自动清理映射。
核心特征
- 弱引用键:
Entry本身是一个弱引用,构造函数中将键作为弱引用目标,使键的存留不再阻止其被 GC。 - 惰性清理机制:
expungeStaleEntries()在大多数方法(如getTable、size)中被调用,遍历引用队列并移除已回收键对应的条目。 - 空值允许:允许
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
}
}
底层原理
WeakHashMap 的 Entry 继承 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 注意事项
- Value 绝不能强引用 Key
这是最常见的内存泄漏场景。例如map.put(key, value)中,value 对象内部若持有 key 的强引用,即使外部 key 被置 null,由于 value 存活导致弱引用 key 不会入队,GC 无法回收 key 及其关联 value。始终保证 value 对象不引用 key(或使用WeakReference包装)。 - GC 时机不确定性
被弱引用的 key 只有在 GC 发现仅被弱引用可达时才会回收并放入引用队列。因此WeakHashMap中的条目不会在 key 失效后立即移除,依赖于 GC 发生,而 GC 时机由 JVM 决定。不应依赖它来做即时资源清理。 - expungeStaleEntries 触发点
该方法在getTable()、size()、resize()等绝大多数方法中调用,但若长期不访问 map,失效条目会一直占据内存。可周期性调用size()或遍历触发清理。 - 迭代中删除条目
若使用迭代器遍历,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 注意事项
- 使用
==比较,不可依赖equals
例如new String("key")两个不同引用可被视为不同的 key,即使内容完全相同。这个特性适用于需要区分对象身份的场合(如序列化、代理、JVM 内部使用),不可用于常规业务逻辑。 - 不依赖 hashCode
IdentityHashMap使用System.identityHashCode(obj)计算散列,该值基于对象内存地址(或 JVM 提供的唯一标识),与类自身的hashCode实现无关。因此即使对象的hashCode返回常数,也不会引发哈希冲突。 - 不支持 fail-fast 迭代器
与大部分集合不同,IdentityHashMap的迭代器(通过entrySet()等)不是 fail-fast,并发修改仅会导致不确定视图,不会抛异常。仍需注意并发安全。 - 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
}
}
最佳实践:始终重写 hashCode 和 equals。
陷阱 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 尾插法避免逆序和死循环。
追问 1:e.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 键/值 | 结构 |
|---|---|---|---|---|---|---|---|
| HashMap | O(1) | O(1) | O(1) | 无序 | 否 | 允许/允许 | 数组+链表/红黑树 |
| LinkedHashMap | O(1) | O(1) | O(1) | 插入/访问顺序 | 否 | 允许/允许 | HashMap + 双向链表 |
| TreeMap | O(log n) | O(log n) | O(log n) | 排序 | 否 | 不允许/允许 | 红黑树 |
| Hashtable | O(1) (锁竞争) | O(1) (锁竞争) | O(1) (锁竞争) | 无序 | 是(全表锁) | 不允许/不允许 | 数组+链表 |
| ConcurrentHashMap | O(1) (无锁) | O(1) (桶锁) | O(1) (桶锁) | 无序 | 是(桶锁) | 不允许/不允许 | 数组+链表/红黑树 |
| ConcurrentSkipListMap | O(log n) (无锁) | O(log n) (无锁) | O(log n) (无锁) | 排序 | 是(无锁) | 不允许/允许 | 跳表 |
| WeakHashMap | O(1) | O(1) | O(1) | 无序 | 否 | 允许/允许 | 数组+链表(弱引用) |
| IdentityHashMap | O(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。