在上一章中,我们深入探索了Collection家族的核心成员,了解了它们的特性和应用场景。🌹
今天,让我们踏上探索Map家族的旅程。我们将从源码层面解析HashMap的演进,深入ConcurrentHashMap的并发机制,探讨TreeMap的红黑树实现,以及其他Map实现的独特之处。这不仅是对数据结构的探讨,更是对Java设计智慧的致敬。🌹
如有描述不准确之处,欢迎大家指出交流。🌹
文章目录
Map接口家族
HashMap:最常用的键值对集合
ConcurrentHashMap:并发编程中的核心类
LinkedHashMap:有序的HashMap
TreeMap:排序的Map实现
Hashtable:线程安全的Map实现
Properties:特殊的Map类型
WeakHashMap:弱引用Map
EnumMap:枚举类型专用Map
IdentityHashMap:使用引用相等的Map
在Map接口的众多实现中,我们将重点关注以下三个最重要的实现:
-
HashMap: 作为最常用的Map实现,它采用数组+链表+红黑树的数据结构,在面试中经常被问到。
-
ConcurrentHashMap: 作为并发编程中的核心类,它通过分段锁等机制实现了高效的线程安全。通过与HashMap的对比学习,可以深入理解Java并发编程的设计理念。
-
TreeMap: 基于红黑树实现的有序Map,是唯一可以按键排序的Map实现,在需要排序的场景下具有重要作用。
本文将深入剖析这三个关键实现的原理、特性及最佳实践。
HashMap:最常用的键值对集合
【引入问题】
“为什么HashMap是最受欢迎的Map实现?它解决了什么问题?”
【浅层回答】
- HashMap提供了键值对的存储方式,可以通过键快速查找值
- 它的查询效率很高,时间复杂度为O(1)
- 不要求键值对有序,也不保证顺序不变
- 允许null键和null值
- 非线程安全
【深入探讨】
1. 数据结构演进
JDK1.7和1.8的HashMap在实现上有很大的区别,主要体现在数据结构和操作流程上:
1.1 JDK1.7:数组 + 链表
-
存储结构:Entry数组 + 链表
-
插入元素:
- 通过key.hashCode()获取hashCode,再进行扰动计算得到hash值
- 将hash值与数组长度-1进行按位与运算,得到数组下标
- 遍历对应位置的链表,如果key存在则更新值,不存在则用头插法插入新节点
- 插入后如果元素个数超过阈值(capacity * loadFactor),则触发扩容
-
查询元素:
- 通过key计算hash值,与数组长度-1进行按位与运算定位数组位置
- 在链表中通过equals()方法逐个比对key,找到目标元素
-
扩容过程:
- 创建一个容量是原来2倍的新数组
- 遍历原数组中的每个位置的链表
- 对每个元素重新计算在新数组中的位置:hash & (newCapacity - 1)
- 使用头插法将元素迁移到新数组,这可能导致链表顺序颠倒
1.2 JDK1.8:数组 + 链表 + 红黑树
// 节点结构
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
}
// 红黑树节点结构
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent;
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev;
boolean red;
}
-
插入元素:
- 对key的hashCode()进行扰动计算:(h = key.hashCode()) ^ (h >>> 16)
- 通过(n - 1) & hash计算数组下标,n为数组长度
- 如果目标位置为空,直接放入新节点
- 如果该位置是红黑树节点,按照红黑树的方式插入
- 如果是链表,则遍历链表:
- 找到相同的key则更新值
- 未找到则在链表尾部插入新节点
- 插入后如果链表长度达到8,则检查是否需要树化
- 判断是否需要扩容
-
查询元素:
- 计算hash值,通过(n - 1) & hash定位数组位置
- 判断节点类型:
- 如果是链表节点,遍历链表比对key
- 如果是红黑树节点,按照红黑树的查找方式进行查找(时间复杂度O(logn))
-
扩容过程:
- 创建一个容量翻倍的新数组
- 遍历原数组,对于每个节点:
- 如果是单节点,直接计算新位置
- 如果是红黑树,则拆分红黑树
- 如果是链表,则根据(hash & oldCap)将链表分成两部分,分别放入新数组的不同位置
- 节点迁移时保持原有顺序,避免链表倒置
2. 容量与扩容机制
2.1 初始容量和负载因子
- 默认初始容量为16,必须是2的幂
- 默认负载因子为0.75
- 为什么是0.75?这是空间和时间成本的一个折中:
- 较高的负载因子会减少空间开销,但增加查找成本
- 较低的负载因子会增加空间开销,但减少查找成本
- 0.75提供了一个理想的折中点
- 为什么是0.75?这是空间和时间成本的一个折中:
2.2 为什么容量必须是2的幂?
- 计算数组下标时使用 (n-1) & hash 操作
- 当n为2的幂时,n-1的二进制表示全为1,如:
n = 16 时: 15 的二进制:0000 1111 hash: 1101 0101 & 运算结果: 0000 0101 (5) - 这样可以保证计算出的下标均匀分布在0到(n-1)之间
- 如果n不是2的幂,某些位置可能永远不会被用到
2.3 扩容触发条件
- 当前元素个数超过阈值(capacity * loadFactor)
- 链表长度超过8且数组长度小于64时
2.4 扩容过程的优化(JDK1.8)
- 原理:扩容后,元素要么在原位置,要么在原位置+旧容量的位置
- 判断依据:hash & oldCap
例如,oldCap = 16时: 10100:hash & 16 = 0,元素位置不变 10116:hash & 16 = 16, 新位置 = 原位置 + 16 - 这种设计避免了重新计算hash值,提高了扩容效率
3. hash冲突解决
3.1 链表法
- 发生冲突时,将新元素链接到已有元素之后
- JDK1.7使用头插法:
- 新元素插入到链表头部
- 实现简单,但在并发环境下可能导致循环链表
- JDK1.8改用尾插法:
- 新元素插入到链表尾部
- 保持了元素的插入顺序
- 避免了并发环境下的循环链表问题
3.2 树化条件
- 链表长度达到8(为什么是8?)
- 根据泊松分布,链表长度达到8的概率约为0.00000006
- 这个阈值既能避免频繁树化,又能在必要时提升性能
- 数组长度达到64
- 小于64时优先选择扩容而不是树化
- 因为在数组较小时,扩容的成本比树化更低
3.3 退化条件
- 红黑树节点数小于6时会退化为链表
- 为什么是6而不是8?
- 设置一个缓冲区间(6-8)避免频繁的链表-树-链表转换
- 类似于TCP的拥塞控制,这种滞后性有助于提高性能
3.4 hash算法优化
JDK1.8的hash计算:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 将高16位与低16位异或
- 充分利用了hash值的所有位
- 减少了hash冲突的概率
4. 常见性能陷阱
4.1 不当的初始容量设置
-
容量太小导致频繁扩容
- 示例:预计存入1000个元素,但初始容量使用默认16
- 后果:需要多次扩容(16 -> 32 -> 64 -> 128 -> 256 -> 512 -> 1024)
- 解决:根据预估数据量设置合适的初始容量:initialCapacity = 预估容量 / 0.75
-
容量设置过大浪费内存
- 示例:预计存100个元素,但设置了初始容量10000
- 后果:大量的数组空间被浪费
- 建议:适度预留空间即可,避免过度预分配
4.2 自定义对象作为key的问题
- hashCode()实现不当
class User {
private String name;
private int age;
@Override
public int hashCode() {
return 1; // 错误示例:所有对象hash值都是1
}
}
- 导致所有元素都在同一个桶中
- 链表过长,退化成O(n)的查找性能
- equals()与hashCode()不一致
class Point {
private int x, y;
@Override
public boolean equals(Object obj) {
if (obj instanceof Point) {
Point other = (Point) obj;
return x == other.x && y == other.y;
}
return false;
}
@Override
public int hashCode() {
return x; // 错误示例:忽略了y值
}
}
- 可能导致相等的对象具有不同的hash值
- 或不相等的对象具有相同的hash值
4.3 并发环境的问题
- 并发插入导致的数据丢失
// 错误示例
HashMap<String, Integer> map = new HashMap<>();
// 线程1和线程2同时执行
map.put("key", map.get("key") + 1);
- 并发环境下的扩容问题
- JDK1.7中可能形成环形链表导致死循环
- 应该使用ConcurrentHashMap替代
【实践建议】
1. 初始容量的选择
// 好的实践
int expectedSize = 1000;
// 设置初始容量为 expectedSize / 0.75 + 1
Map<String, String> map = new HashMap<>((int)(expectedSize / 0.75) + 1);
2. equals()和hashCode()的正确实现
class User {
private String name;
private int age;
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof User)) return false;
User user = (User) obj;
return age == user.age && Objects.equals(name, user.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
3. 线程安全的替代方案
- 使用Collections.synchronizedMap()
Map<String, String> synchronizedMap = Collections.synchronizedMap(new HashMap<>());
- 使用ConcurrentHashMap
Map<String, String> concurrentMap = new ConcurrentHashMap<>();
- 场景选择:
- 读多写少:ConcurrentHashMap
- 写多读少:synchronizedMap
- 单线程:HashMap
- 需要保证线程安全且有null键值:synchronizedMap
- 需要保证线程安全但不允许null:ConcurrentHashMap
ConcurrentHashMap:并发编程中的核心类
【引入问题】
“为什么需要ConcurrentHashMap?它相比HashMap和HashTable有什么优势?”
【浅层回答】
- ConcurrentHashMap是线程安全的HashMap实现
- 相比HashTable的粗粒度锁,它使用了分段锁提升并发性能
- 不允许null键和null值
- 读操作不需要加锁,性能较好
- 写操作仅锁定当前操作的Segment,不影响其他Segment
【深入探讨】
1. 数据结构演进
ConcurrentHashMap在不同JDK版本中的实现有较大变化:
1.1 JDK1.7:Segment + HashEntry数组
-
存储结构:
- Segment数组 + HashEntry数组 + 链表
- Segment继承自ReentrantLock,每个Segment独立加锁
- 默认16个Segment,支持16个线程并发写
-
并发控制:
- 读操作不加锁,通过volatile保证可见性
- 写操作对相应的Segment加锁
- size()需要统计所有Segment,可能需要重试
1.2 JDK1.8:Node数组 + CAS + synchronized
-
存储结构:
- Node数组 + 链表 + 红黑树
- 去除了Segment,直接用Node数组保存数据
- 同HashMap一样,链表长度超过8会转为红黑树
-
并发控制:
- 使用synchronized锁定链表或树的首节点
- 使用CAS操作实现无锁并发
- 使用volatile保证可见性
- size()方法使用baseCount+counterCells计数
2. 关键源码分析
2.1 重要的成员变量
// 存储数据的数组,使用volatile保证可见性
transient volatile Node<K,V>[] table;
// 基础计数值,在没有竞争时使用
private transient volatile long baseCount;
// 扩容相关的标识符
private transient volatile int sizeCtl;
// 用于辅助统计元素个数的数组
private transient volatile CounterCell[] counterCells;
2.2 put操作的实现
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 不允许null键和null值
if (key == null || value == null) throw new NullPointerException();
// 计算hash值
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 初始化table
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 目标位置为空,CAS插入
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break;
}
// 正在扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
// 更新或插入节点
else {
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
// 链表操作
}
else if (f instanceof TreeBin) {
// 红黑树操作
}
}
}
}
}
addCount(1L, binCount);
return null;
}
3. 并发控制机制
3.1 CAS操作详解
ConcurrentHashMap大量使用CAS(Compare And Swap)操作来确保线程安全,主要体现在以下几个关键场景:
- 初始化table数组:
private final Node<K,V>[] ini

最低0.47元/天 解锁文章

被折叠的 条评论
为什么被折叠?



