第1章:Map集合概述
1.1 Map接口体系架构
1.1.1 Map接口定义与核心方法
Map是Java集合框架中用于存储**键值对(Key-Value)**的接口,它提供了从键到值的映射关系。与Collection接口不同,Map存储的是成对的数据。
Map接口的核心特点
- 键值对映射: 每个元素由键(Key)和值(Value)组成
- 键唯一性: 每个键最多只能映射到一个值
- 值可重复: 不同的键可以映射到相同的值
- 无序性: 大多数Map实现不保证元素的顺序(TreeMap和LinkedHashMap除外)
核心方法概览
public interface Map<K, V> {
// 基本操作
V put(K key, V value); // 添加键值对
V get(Object key); // 根据键获取值
V remove(Object key); // 删除指定键的映射
boolean containsKey(Object key); // 是否包含指定键
boolean containsValue(Object value); // 是否包含指定值
// 集合视图
Set<K> keySet(); // 返回所有键的Set视图
Collection<V> values(); // 返回所有值的Collection视图
Set<Map.Entry<K, V>> entrySet(); // 返回所有键值对的Set视图
// 批量操作
void putAll(Map<? extends K, ? extends V> m); // 批量添加
void clear(); // 清空所有映射
// 查询操作
int size(); // 返回键值对数量
boolean isEmpty(); // 是否为空
}
1.1.2 Map vs Collection:设计哲学差异
存储方式差异
Collection接口:
- 存储单个元素
- 元素之间是独立的
- 通过索引或迭代器访问
Map接口:
- 存储键值对
- 通过键来访问值
- 键是访问值的唯一标识
使用场景对比
// Collection:存储学生姓名列表
List<String> studentNames = new ArrayList<>();
studentNames.add("张三");
studentNames.add("李四");
// Map:存储学生ID到姓名的映射
Map<Integer, String> studentMap = new HashMap<>();
studentMap.put(1001, "张三");
studentMap.put(1002, "李四");
// 通过ID快速查找姓名
String name = studentMap.get(1001); // "张三"
1.1.3 Map接口的继承体系
Map (接口)
├── SortedMap (接口) - 有序Map
│ └── NavigableMap (接口) - 可导航的有序Map
│ └── TreeMap (实现类) - 红黑树实现
│
├── HashMap (实现类) - 哈希表实现
│ └── LinkedHashMap (实现类) - 有序的HashMap
│
├── Hashtable (实现类) - 线程安全的哈希表(已淘汰)
│ └── Properties (实现类) - 配置文件专用
│
└── ConcurrentHashMap (实现类) - 线程安全的HashMap
主要实现类特点
| 实现类 | 数据结构 | 有序性 | 线程安全 | null键值 | 时间复杂度 |
|---|---|---|---|---|---|
| HashMap | 数组+链表/红黑树 | 无序 | 否 | 允许 | O(1)平均 |
| LinkedHashMap | 数组+链表+双向链表 | 有序 | 否 | 允许 | O(1)平均 |
| TreeMap | 红黑树 | 有序 | 否 | 不允许 | O(log n) |
| Hashtable | 数组+链表 | 无序 | 是 | 不允许 | O(1)平均 |
| ConcurrentHashMap | 数组+链表/红黑树 | 无序 | 是 | 不允许 | O(1)平均 |
1.2 Map集合分类
1.2.1 按有序性分类
无序Map
- HashMap: 不保证元素的顺序
- Hashtable: 不保证元素的顺序
- ConcurrentHashMap: 不保证元素的顺序
有序Map
- LinkedHashMap: 维护插入顺序或访问顺序
- TreeMap: 根据键的自然顺序或Comparator排序
// HashMap:无序
Map<String, Integer> hashMap = new HashMap<>();
hashMap.put("C", 3);
hashMap.put("A", 1);
hashMap.put("B", 2);
System.out.println(hashMap); // 输出顺序不确定
// LinkedHashMap:保持插入顺序
Map<String, Integer> linkedMap = new LinkedHashMap<>();
linkedMap.put("C", 3);
linkedMap.put("A", 1);
linkedMap.put("B", 2);
System.out.println(linkedMap); // {C=3, A=1, B=2}
// TreeMap:按键排序
Map<String, Integer> treeMap = new TreeMap<>();
treeMap.put("C", 3);
treeMap.put("A", 1);
treeMap.put("B", 2);
System.out.println(treeMap); // {A=1, B=2, C=3}
1.2.2 按线程安全分类
非线程安全
- HashMap: 性能最好,单线程场景首选
- LinkedHashMap: 有序的HashMap
- TreeMap: 有序的Map
线程安全
- Hashtable: 使用synchronized,性能较差,已淘汰
- ConcurrentHashMap: 使用CAS+synchronized,性能优秀,推荐使用
// 非线程安全:性能好
Map<String, Integer> map = new HashMap<>();
// 线程安全:使用ConcurrentHashMap(推荐)
Map<String, Integer> safeMap = new ConcurrentHashMap<>();
// 线程安全:使用Collections.synchronizedMap(不推荐)
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
1.2.3 按null值支持分类
允许null键值
- HashMap: 允许一个null键和多个null值
- LinkedHashMap: 允许一个null键和多个null值
不允许null键值
- TreeMap: 不允许null键(会抛出NullPointerException)
- Hashtable: 不允许null键和null值
- ConcurrentHashMap: 不允许null键和null值
// HashMap:允许null
Map<String, Integer> map = new HashMap<>();
map.put(null, 1); // 允许
map.put("key", null); // 允许
// TreeMap:不允许null键
Map<String, Integer> treeMap = new TreeMap<>();
// treeMap.put(null, 1); // 抛出NullPointerException
1.3 Map核心概念
1.3.1 Key-Value映射关系
Map的核心是键值对映射,每个键(Key)唯一对应一个值(Value)。
映射关系的特点
- 一对一映射: 一个键只能映射到一个值
- 键唯一性: 如果put相同的键,会覆盖旧值
- 值可重复: 不同的键可以映射到相同的值
Map<String, Integer> map = new HashMap<>();
map.put("A", 1);
map.put("B", 2);
map.put("A", 3); // 覆盖之前的A=1,现在A=3
System.out.println(map.get("A")); // 3
System.out.println(map.size()); // 2(只有A和B)
1.3.2 哈希冲突与解决策略
什么是哈希冲突?
当两个不同的键计算出相同的哈希值时,就发生了哈希冲突。
// 假设hashCode()计算出的值相同
String key1 = "Aa";
String key2 = "BB";
// 如果它们的hashCode()相同,就会发生冲突
解决哈希冲突的策略
1. 链地址法(拉链法)
- HashMap、Hashtable、ConcurrentHashMap使用
- 在冲突位置维护一个链表
- JDK8中,链表长度超过8时转为红黑树
2. 开放地址法
- 线性探测、二次探测等
- Java的Map实现不使用此方法
3. 再哈希法
- 使用多个哈希函数
- 复杂度较高,Java不使用
1.3.3 负载因子与扩容机制
负载因子(Load Factor)
负载因子是衡量哈希表填充程度的指标:
负载因子 = 元素数量 / 容量
默认负载因子:0.75
- 为什么是0.75?
- 空间与时间的权衡
- 0.75是经过大量测试得出的最佳值
- 太小:浪费空间,频繁扩容
- 太大:哈希冲突增多,性能下降
扩容机制
当元素数量超过 容量 × 负载因子 时,触发扩容:
// 默认容量16,负载因子0.75
// 当元素数量 > 16 × 0.75 = 12 时,触发扩容
// 扩容后容量变为 16 × 2 = 32
扩容特点:
- 容量翻倍(2倍扩容)
- 重新计算所有元素的位置
- 性能开销较大,应尽量避免频繁扩容
📊 本章总结
核心要点:
- Map存储键值对,键唯一,值可重复
- 主要实现类:HashMap、LinkedHashMap、TreeMap、ConcurrentHashMap
- 哈希冲突通过链地址法解决
- 负载因子0.75是空间与时间的最佳平衡点
选择建议:
- 一般场景:使用HashMap
- 需要有序:使用LinkedHashMap或TreeMap
- 需要线程安全:使用ConcurrentHashMap
- 需要排序:使用TreeMap
第2章:HashMap深度剖析
2.1 HashMap基础原理
2.1.1 数据结构演进
JDK7:数组 + 链表
JDK7中的HashMap使用数组+链表的结构:
数组索引: 0 1 2 3 4
↓ ↓ ↓ ↓ ↓
null 链表 null 链表 null
↓
Node1 -> Node2 -> Node3
特点:
- 数组存储链表的头节点
- 哈希冲突时,在对应位置形成链表
- 链表采用头插法(新节点插入链表头部)
JDK8:数组 + 链表/红黑树
JDK8中引入了红黑树优化:
数组索引: 0 1 2 3 4
↓ ↓ ↓ ↓ ↓
null 链表 null 红黑树 null
↓
Node1 -> Node2
优化点:
- 链表长度超过8时,转为红黑树
- 红黑树节点数小于6时,退化为链表
- 链表采用尾插法(新节点插入链表尾部)
为什么引入红黑树?
当哈希冲突严重时,链表会变得很长,查找性能从O(1)退化为O(n)。红黑树可以将查找性能保持在O(log n)。
2.1.2 核心参数详解
默认初始容量:16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
为什么是16?
- 2的幂次方,便于位运算优化
- 经过测试,16是一个平衡点
- 太小:频繁扩容
- 太大:浪费内存
为什么必须是2的幂?
// 计算数组下标:使用位运算,性能高
int index = (n - 1) & hash;
// 如果n是2的幂,n-1的二进制全是1
// 例如:16-1=15,二进制是1111
// 这样&运算可以均匀分布
负载因子:0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
为什么是0.75?
这是空间与时间的权衡:
-
0.75的数学依据:
- 泊松分布计算得出
- 在0.75时,哈希冲突的概率较低
- 空间利用率较高
-
如果设置为1.0:
- 空间利用率最高
- 但哈希冲突增多,性能下降
-
如果设置为0.5:
- 哈希冲突少,性能好
- 但空间浪费,频繁扩容
链表转红黑树阈值:8
static final int TREEIFY_THRESHOLD = 8;
为什么是8?
基于泊松分布的概率计算:
当负载因子为0.75时,链表长度为8的概率约为0.00000006
这意味着:
- 正常情况下,链表长度很少超过8
- 如果超过8,说明哈希冲突严重,需要红黑树优化
红黑树转链表阈值:6
static final int UNTREEIFY_THRESHOLD = 6;
为什么是6而不是8?
防止频繁转换:
- 如果阈值也是8,在8附近会频繁转换
- 设置为6,提供2的缓冲区间
- 避免在临界值附近频繁转换
最大容量:2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
为什么是2^30?
- int类型的最大值是2^31-1
- 2^30是最大的2的幂次方
- 保证容量始终是2的幂
2.2 HashMap核心方法实现
2.2.1 hash()方法:扰动函数
实现原理
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
扰动函数的作用:
将key的hashCode()进行扰动,减少哈希冲突。
为什么需要扰动?
问题:
- hashCode()可能分布不均匀
- 如果直接使用,低位可能相同,导致冲突
解决方案:
- 将高16位与低16位进行异或运算
- 让高位也参与计算,使分布更均匀
示例:
// 假设hashCode() = 0x12345678
int h = 0x12345678;
// 右移16位:0x00001234
int high = h >>> 16;
// 异或运算:0x12345678 ^ 0x00001234 = 0x1234444C
int hash = h ^ high;
// 这样高位和低位都参与了计算
2.2.2 put()方法完整流程
put()方法执行步骤
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
完整流程:
-
计算hash值
int hash = hash(key); -
定位数组下标
int index = (n - 1) & hash; -
检查数组位置
- 如果为空:直接插入
- 如果不为空:处理冲突
-
处理哈希冲突
- 如果是链表:遍历查找,找到则更新,否则插入
- 如果是红黑树:在树中查找或插入
-
检查是否需要扩容
- 如果元素数量 > 容量 × 负载因子:触发扩容
-
检查是否需要转红黑树
- 如果链表长度 >= 8:转为红黑树
流程图
put(key, value)
↓
计算hash值
↓
定位数组下标: (n-1) & hash
↓
数组位置是否为空?
├─ 是 → 直接插入Node
└─ 否 → 检查Node类型
├─ 链表 → 遍历查找/插入
└─ 红黑树 → 树中查找/插入
↓
检查是否需要扩容
↓
检查是否需要转红黑树
↓
完成
2.2.3 get()方法实现
get()方法流程
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
查找步骤:
- 计算hash值
- 定位数组下标
- 检查第一个节点
- 如果匹配:直接返回
- 如果不匹配:继续查找
- 在链表或红黑树中查找
时间复杂度:
- 最好情况:O(1) - 数组位置直接命中
- 平均情况:O(1) - 链表长度较短
- 最坏情况:O(log n) - 红黑树查找
2.2.4 resize()扩容机制
扩容触发条件
if (++size > threshold) {
resize();
}
触发条件:
size > capacity × loadFactor- 例如:16 × 0.75 = 12,当元素数量超过12时触发
扩容大小
newCap = oldCap << 1; // 容量翻倍
扩容特点:
- 容量变为原来的2倍
- 例如:16 → 32 → 64 → 128
JDK8扩容优化:高位低位链表拆分
JDK7的问题:
- 扩容时需要重新计算所有元素的位置
- 所有元素都要重新hash
JDK8的优化:
- 利用hash值的特性
- 将链表拆分为高位链表和低位链表
- 只需要判断hash值的某一位即可
优化原理:
// 假设原容量是16,扩容后是32
// 16的二进制:10000
// 32的二进制:100000
// 判断hash值的第5位(从右往左)
// 如果为0:在新数组的相同位置(低位)
// 如果为1:在新数组的 原位置+16 位置(高位)
int newIndex = (hash & oldCap) == 0 ? oldIndex : oldIndex + oldCap;
优势:
- 不需要重新计算hash值
- 只需要判断一位即可
- 性能提升明显
2.3 JDK7 vs JDK8实现差异
2.3.1 数据结构差异
| 特性 | JDK7 | JDK8 |
|---|---|---|
| 数据结构 | 数组+链表 | 数组+链表/红黑树 |
| 链表转树 | 不支持 | 长度>8时转红黑树 |
| 性能优化 | 无 | 红黑树优化查找 |
性能提升:
- 正常情况:性能相同
- 哈希冲突严重时:JDK8性能更好(O(log n) vs O(n))
2.3.2 插入方式差异
JDK7:头插法
// 新节点插入链表头部
newNode.next = table[index];
table[index] = newNode;
问题:
- 在多线程环境下可能导致死循环
- 扩容时链表顺序反转
JDK8:尾插法
// 新节点插入链表尾部
Node last = table[index];
while (last.next != null) {
last = last.next;
}
last.next = newNode;
优势:
- 避免死循环问题
- 保持插入顺序
2.3.3 扩容机制优化
JDK7扩容问题
- 需要重新计算所有元素的hash值
- 所有元素都要重新定位
- 性能开销大
JDK8优化策略
- 高位低位链表拆分
- 不需要重新计算hash值
- 只需要判断一位即可
- 性能提升明显
2.4 线程安全问题
2.4.1 JDK7死链问题详解
问题场景
在多线程环境下,两个线程同时进行扩容操作时,可能导致死循环。
问题原因
头插法导致的问题:
- 线程A和线程B同时检测到需要扩容
- 两个线程都开始扩容
- 在扩容过程中,链表被反转
- 形成循环链表,导致死循环
示例:
原链表:A -> B -> C
线程A扩容:C -> B -> A
线程B同时操作,形成循环:A -> B -> C -> A(死循环)
如何避免?
- 使用线程安全的Map:ConcurrentHashMap
- 使用Collections.synchronizedMap()
- 单线程环境下使用HashMap
2.4.2 JDK8数据覆盖问题
JDK8修复了死循环,但仍不安全
JDK8的问题:
-
数据覆盖:
// 线程A和线程B同时put相同的key // 可能只有一个线程的值被保存,另一个被覆盖 -
数据不一致:
// 线程A put,线程B get // 可能读到不一致的数据
为什么HashMap线程不安全?
- 没有同步机制: 多线程同时修改会导致数据不一致
- 非原子操作: put操作不是原子性的
- 可见性问题: 没有volatile保证可见性
如何保证线程安全?
方案1:使用ConcurrentHashMap(推荐)
Map<String, Integer> map = new ConcurrentHashMap<>();
方案2:使用Collections.synchronizedMap()
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
方案3:使用Hashtable(不推荐,性能差)
Map<String, Integer> map = new Hashtable<>();
📊 本章总结
核心要点:
- HashMap使用数组+链表/红黑树结构
- 默认容量16,负载因子0.75
- 链表长度>8转红黑树,<6退化为链表
- JDK8优化了扩容机制,性能更好
- HashMap线程不安全,多线程使用ConcurrentHashMap
关键参数记忆:
- 初始容量:16
- 负载因子:0.75
- 转树阈值:8
- 退链阈值:6
- 最大容量:2^30
第3章:ConcurrentHashMap深度剖析
3.1 JDK7分段锁实现
3.1.1 Segment数组结构
分段锁的设计思想
JDK7的ConcurrentHashMap使用**分段锁(Segment)**来实现线程安全:
ConcurrentHashMap
↓
Segment数组(16个Segment)
↓
每个Segment内部是一个HashMap
结构示意:
Segment[0] -> HashMap (锁0)
Segment[1] -> HashMap (锁1)
Segment[2] -> HashMap (锁2)
...
Segment[15] -> HashMap (锁15)
Segment继承ReentrantLock
static final class Segment<K,V> extends ReentrantLock implements Serializable {
// 每个Segment内部维护一个HashMap
transient volatile HashEntry<K,V>[] table;
// ...
}
特点:
- 每个Segment是一个独立的锁
- 不同Segment的操作可以并发进行
- 同一个Segment的操作需要加锁
3.1.2 核心方法实现
put()方法:分段加锁
public V put(K key, V value) {
Segment<K,V> s;
// 计算key属于哪个Segment
int hash = hash(key);
int j = (hash >>> segmentShift) & segmentMask;
s = (Segment<K,V>)UNSAFE.getObject(segments, (j << SSHIFT) + SBASE);
// 对Segment加锁
s.lock();
try {
// 在Segment内部的HashMap中put
// ...
} finally {
s.unlock();
}
}
执行流程:
- 计算key的hash值
- 确定key属于哪个Segment
- 对Segment加锁
- 在Segment内部的HashMap中操作
- 释放锁
get()操作的无锁实现
public V get(Object key) {
Segment<K,V> s;
HashEntry<K,V>[] tab;
int h = hash(key);
long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
// 使用volatile读,不需要加锁
if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null &&
(tab = s.table) != null) {
// 在Segment中查找
// ...
}
return null;
}
为什么get()不需要加锁?
- 使用volatile保证可见性
- 读操作不会修改数据
- 多线程读是安全的
size()方法:分段统计
public int size() {
final Segment<K,V>[] segments = this.segments;
int size;
boolean overflow;
long sum;
long last = 0L;
int retries = -1;
try {
for (;;) {
// 尝试不加锁统计
// 如果统计过程中有修改,重试
// ...
}
} finally {
// ...
}
}
特点:
- 先尝试不加锁统计
- 如果统计过程中有修改,重试
- 多次失败后,对所有Segment加锁统计
3.1.3 优缺点分析
分段锁的优势
- 并发度高: 不同Segment可以并发操作
- 锁粒度小: 只锁住一个Segment,不是整个Map
- 性能好: 在并发场景下性能优于Hashtable
分段锁的局限性
- 复杂度高: Segment数组增加了复杂度
- 内存占用: 每个Segment都需要维护一个HashMap
- 锁竞争: 如果所有操作都在同一个Segment,性能会下降
3.2 JDK8 CAS+synchronized优化
3.2.1 数据结构变化
抛弃Segment,使用Node数组
JDK8的ConcurrentHashMap抛弃了Segment,直接使用Node数组,结构更接近HashMap:
// JDK8的ConcurrentHashMap结构
transient volatile Node<K,V>[] table;
与HashMap的相似性:
- 都使用数组+链表/红黑树
- 都使用Node节点
- 结构几乎相同
关键区别:
- ConcurrentHashMap使用volatile保证可见性
- 使用CAS和synchronized保证线程安全
3.2.2 线程安全机制
CAS实现无锁化插入
CAS(Compare And Swap):
- 无锁的原子操作
- 性能优于synchronized
- 适合并发度高的场景
// 使用CAS尝试插入
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break;
CAS的优势:
- 无锁操作,性能好
- 适合并发插入
- 失败后可以重试
synchronized锁链表头/树根节点
当CAS失败时(位置已有节点),使用synchronized加锁:
synchronized (f) {
// f是链表头节点或红黑树根节点
// 在锁内进行操作
}
为什么只锁节点?
- 锁粒度更小
- 不同位置的节点可以并发操作
- 性能更好
volatile保证可见性
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val; // volatile保证可见性
volatile Node<K,V> next; // volatile保证可见性
}
volatile的作用:
- 保证多线程之间的可见性
- get()操作不需要加锁
- 性能优化
3.2.3 核心方法实现
put()方法流程
完整流程:
- 计算hash值
- 定位数组下标
- CAS尝试插入
- 如果位置为空:CAS插入
- 如果成功:完成
- 如果失败:继续
- synchronized加锁插入
- 锁住链表头或树根
- 在锁内插入或更新
- 检查是否需要转红黑树
- 检查是否需要扩容
流程图:
put(key, value)
↓
计算hash值
↓
定位数组下标
↓
位置是否为空?
├─ 是 → CAS插入 → 成功?
│ ├─ 是 → 完成
│ └─ 否 → synchronized加锁插入
└─ 否 → synchronized加锁插入
↓
检查转红黑树
↓
检查扩容
↓
完成
get()方法实现
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 检查第一个节点
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 在链表或红黑树中查找
// ...
}
return null;
}
为什么get()不需要加锁?
- volatile保证可见性: Node的val和next都是volatile
- 读操作安全: 读操作不会修改数据
- 性能优化: 无锁读操作性能好
size()方法实现
public int size() {
long n = sumCount();
return ((n < 0L) ? 0 :
(n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
(int)n);
}
final long sumCount() {
CounterCell[] as = counterCells; CounterCell a;
long sum = baseCount;
if (as != null) {
for (int i = 0; i < as.length; ++i) {
if ((a = as[i]) != null)
sum += a.value;
}
}
return sum;
}
LongAdder思想:
- baseCount: 基础计数
- CounterCell[]: 分段计数数组
- 多线程并发计数: 每个线程更新自己的CounterCell
- 最终统计: 将所有CounterCell的值相加
为什么是"近似准确"?
- 多线程并发更新时,统计可能有延迟
- 但最终会收敛到准确值
- 性能优于加锁统计
3.2.4 扩容机制
sizeCtl字段的作用和状态
private transient volatile int sizeCtl;
sizeCtl的含义:
- 正数: 表示扩容阈值(容量 × 负载因子)
- -1: 表示正在初始化
- 负数(-N): 表示有N-1个线程正在扩容
ForwardingNode节点的作用
static final class ForwardingNode<K,V> extends Node<K,V> {
final Node<K,V>[] nextTable;
ForwardingNode(Node<K,V>[] tab) {
super(MOVED, null, null, null);
this.nextTable = tab;
}
}
ForwardingNode的作用:
- 标记节点: 表示该位置的数据已经迁移到新数组
- 转发查找: get操作遇到ForwardingNode时,到新数组查找
- 协助扩容: 其他线程看到ForwardingNode时,可以协助扩容
多线程协助扩容(transfer)
扩容流程:
- 初始化新数组: 容量为原来的2倍
- 分配任务: 将数组分成多个段,每个线程负责一段
- 迁移数据: 将旧数组的数据迁移到新数组
- 标记完成: 迁移完成后,用ForwardingNode标记
协助扩容机制:
- 当线程发现正在扩容时,可以协助扩容
- 提高扩容效率
- 减少扩容时间
📊 本章总结
核心要点:
- JDK7使用分段锁,JDK8使用CAS+synchronized
- JDK8结构更简单,性能更好
- get()操作无锁,性能优秀
- size()使用LongAdder思想,近似准确
- 多线程可以协助扩容,提高效率
选择建议:
- 多线程场景:使用ConcurrentHashMap
- 高并发读:ConcurrentHashMap性能最好
- 高并发写:ConcurrentHashMap优于Hashtable
第4章:LinkedHashMap深度剖析
4.1 LinkedHashMap基础原理
4.1.1 数据结构
继承HashMap
LinkedHashMap继承自HashMap,所以它拥有HashMap的所有特性:
public class LinkedHashMap<K,V> extends HashMap<K,V> implements Map<K,V>
继承关系:
- 拥有HashMap的所有功能
- 在此基础上增加了顺序维护
双向链表维护顺序
LinkedHashMap在HashMap的基础上,增加了双向链表来维护顺序:
HashMap结构:
数组 + 链表/红黑树
LinkedHashMap结构:
数组 + 链表/红黑树 + 双向链表(维护顺序)
双向链表结构:
head ← → Node1 ← → Node2 ← → Node3 ← → tail
Entry节点的扩展
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after; // 双向链表的指针
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
特点:
- 继承HashMap的Node
- 增加了before和after指针
- 维护双向链表
4.1.2 顺序模式
插入顺序(默认)
Map<String, Integer> map = new LinkedHashMap<>();
map.put("C", 3);
map.put("A", 1);
map.put("B", 2);
// 迭代顺序:C -> A -> B(插入顺序)
特点:
- 按照元素插入的顺序维护
- 先插入的元素在前面
- 默认模式
访问顺序(accessOrder=true)
Map<String, Integer> map = new LinkedHashMap<>(16, 0.75f, true);
map.put("C", 3);
map.put("A", 1);
map.put("B", 2);
map.get("A"); // 访问A后,A移动到链表尾部
// 迭代顺序:C -> B -> A(访问顺序)
特点:
- 按照元素访问的顺序维护
- 最近访问的元素在链表尾部
- 适合实现LRU缓存
两种模式的区别
| 模式 | accessOrder | 维护顺序 | 应用场景 |
|---|---|---|---|
| 插入顺序 | false(默认) | 插入顺序 | 需要保持插入顺序 |
| 访问顺序 | true | 访问顺序 | LRU缓存 |
4.2 核心方法实现
4.2.1 put()方法
调用父类HashMap的put()
LinkedHashMap的put()方法直接调用父类HashMap的put():
public V put(K key, V value) {
return super.put(key, value);
}
维护双向链表
HashMap的put()方法在插入节点后,会调用以下方法(LinkedHashMap重写了):
// 节点插入后调用
void afterNodeInsertion(boolean evict) {
// 可能移除最老的节点(LRU缓存)
}
// 节点访问后调用
void afterNodeAccess(Node<K,V> e) {
// 在访问顺序模式下,将节点移到链表尾部
}
维护链表的逻辑:
- 插入新节点时,添加到链表尾部
- 访问节点时(accessOrder=true),移到链表尾部
- 删除节点时,从链表中移除
4.2.2 get()方法
访问顺序模式下的处理
public V get(Object key) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return null;
// 如果是访问顺序模式,将节点移到链表尾部
if (accessOrder)
afterNodeAccess(e);
return e.value;
}
afterNodeAccess()方法:
void afterNodeAccess(Node<K,V> e) {
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) {
// 将节点e移到链表尾部
// 1. 从原位置移除
// 2. 添加到链表尾部
}
}
作用:
- 在访问顺序模式下,访问节点后将其移到尾部
- 实现LRU(最近最少使用)策略
4.2.3 removeEldestEntry()方法
实现LRU缓存的关键
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false; // 默认不删除
}
何时调用?
在afterNodeInsertion()方法中:
void afterNodeInsertion(boolean evict) {
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
作用:
- 插入新节点后,检查是否需要删除最老的节点
- 重写此方法可以实现LRU缓存
4.3 LRU缓存实现
4.3.1 实现原理
重写removeEldestEntry()
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public LRUCache(int maxSize) {
super(16, 0.75f, true); // accessOrder=true
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxSize; // 超过容量时删除最老的节点
}
}
设置accessOrder=true
// 第三个参数设置为true,启用访问顺序模式
Map<String, Integer> cache = new LinkedHashMap<>(16, 0.75f, true);
完整的LRU缓存代码
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LRUCache(int capacity) {
// 初始容量16,负载因子0.75,访问顺序模式
super(16, 0.75f, true);
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
// 当元素数量超过容量时,删除最老的节点
return size() > capacity;
}
// 使用示例
public static void main(String[] args) {
LRUCache<String, Integer> cache = new LRUCache<>(3);
cache.put("A", 1);
cache.put("B", 2);
cache.put("C", 3);
System.out.println(cache); // {A=1, B=2, C=3}
cache.get("A"); // 访问A
cache.put("D", 4); // 添加D,B被删除(最老的)
System.out.println(cache); // {C=3, A=1, D=4}
}
}
4.3.2 应用场景
缓存系统
- Web缓存: 缓存最近访问的页面
- 数据库缓存: 缓存最近查询的结果
- 对象缓存: 缓存最近使用的对象
最近访问记录
- 浏览历史: 记录最近访问的页面
- 搜索历史: 记录最近搜索的关键词
- 操作历史: 记录最近的操作
性能优化
- 减少数据库查询: 缓存热点数据
- 提高响应速度: 快速访问缓存数据
- 节省内存: 自动淘汰不常用的数据
📊 本章总结
核心要点:
- LinkedHashMap继承HashMap,增加双向链表维护顺序
- 支持插入顺序和访问顺序两种模式
- 访问顺序模式适合实现LRU缓存
- 重写removeEldestEntry()可以实现自动淘汰
使用建议:
- 需要保持插入顺序:使用默认的LinkedHashMap
- 需要LRU缓存:设置accessOrder=true并重写removeEldestEntry()
第5章:TreeMap深度剖析
5.1 TreeMap基础原理
5.1.1 数据结构
红黑树实现
TreeMap使用红黑树作为底层数据结构:
红黑树特点:
- 自平衡二叉搜索树
- 保证最坏情况下的查找性能为O(log n)
- 插入、删除、查找都是O(log n)
红黑树结构:
Root
/ \
Left Right
/ \ / \
... ... ... ...
有序Map的特点
- 按键排序: 所有元素按照键的顺序排列
- 可导航: 提供导航方法(ceilingKey、floorKey等)
- 范围查询: 支持范围查询(subMap方法)
Entry节点的设计
static final class Entry<K,V> implements Map.Entry<K,V> {
K key;
V value;
Entry<K,V> left; // 左子节点
Entry<K,V> right; // 右子节点
Entry<K,V> parent; // 父节点
boolean color = BLACK; // 节点颜色(红/黑)
}
5.1.2 排序规则
自然排序(Comparable)
如果键实现了Comparable接口,使用自然排序:
// String实现了Comparable接口
TreeMap<String, Integer> map = new TreeMap<>();
map.put("C", 3);
map.put("A", 1);
map.put("B", 2);
// 自动按键的自然顺序排序:{A=1, B=2, C=3}
定制排序(Comparator)
可以通过Comparator指定排序规则:
// 按字符串长度排序
TreeMap<String, Integer> map = new TreeMap<>(
Comparator.comparing(String::length)
);
map.put("AAA", 1);
map.put("B", 2);
map.put("CC", 3);
// 排序结果:{B=2, CC=3, AAA=1}(按长度)
排序规则的优先级
- 如果提供了Comparator: 使用Comparator
- 如果键实现了Comparable: 使用自然排序
- 否则: 抛出ClassCastException
5.2 核心方法实现
5.2.1 put()方法
红黑树插入
public V put(K key, V value) {
Entry<K,V> t = root;
if (t == null) {
// 第一个节点作为根节点
root = new Entry<>(key, value, null);
size = 1;
modCount++;
return null;
}
// 在红黑树中查找插入位置
int cmp;
Entry<K,V> parent;
Comparator<? super K> cpr = comparator;
if (cpr != null) {
// 使用Comparator比较
do {
parent = t;
cmp = cpr.compare(key, t.key);
if (cmp < 0)
t = t.left;
else if (cmp > 0)
t = t.right;
else
return t.setValue(value); // 找到相同key,更新值
} while (t != null);
} else {
// 使用自然排序
// ...
}
// 插入新节点
Entry<K,V> e = new Entry<>(key, value, parent);
if (cmp < 0)
parent.left = e;
else
parent.right = e;
// 红黑树平衡调整
fixAfterInsertion(e);
size++;
modCount++;
return null;
}
平衡调整
插入节点后,需要进行红黑树平衡调整:
- 左旋: 调整不平衡的子树
- 右旋: 调整不平衡的子树
- 变色: 调整节点颜色
时间复杂度:O(log n)
5.2.2 get()方法
红黑树查找
public V get(Object key) {
Entry<K,V> p = getEntry(key);
return (p==null ? null : p.value);
}
final Entry<K,V> getEntry(Object key) {
if (comparator != null)
return getEntryUsingComparator(key);
if (key == null)
throw new NullPointerException();
@SuppressWarnings("unchecked")
Comparable<? super K> k = (Comparable<? super K>) key;
Entry<K,V> p = root;
while (p != null) {
int cmp = k.compareTo(p.key);
if (cmp < 0)
p = p.left;
else if (cmp > 0)
p = p.right;
else
return p;
}
return null;
}
查找流程:
- 从根节点开始
- 比较key与当前节点的key
- 小于:向左子树查找
- 大于:向右子树查找
- 等于:找到,返回
- 为null:未找到
时间复杂度:O(log n)
5.2.3 导航方法
ceilingKey():大于等于key的最小key
public K ceilingKey(K key) {
return keyOrNull(getCeilingEntry(key));
}
示例:
TreeMap<Integer, String> map = new TreeMap<>();
map.put(10, "A");
map.put(20, "B");
map.put(30, "C");
map.ceilingKey(15); // 返回20(大于等于15的最小key)
map.ceilingKey(20); // 返回20(等于20)
floorKey():小于等于key的最大key
public K floorKey(K key) {
return keyOrNull(getFloorEntry(key));
}
示例:
map.floorKey(25); // 返回20(小于等于25的最大key)
map.floorKey(20); // 返回20(等于20)
higherKey()和lowerKey()
map.higherKey(20); // 返回30(大于20的最小key)
map.lowerKey(20); // 返回10(小于20的最大key)
firstKey()和lastKey()
map.firstKey(); // 返回10(最小的key)
map.lastKey(); // 返回30(最大的key)
📊 本章总结
核心要点:
- TreeMap使用红黑树实现,保证O(log n)的性能
- 支持自然排序和定制排序
- 提供丰富的导航方法
- 适合需要有序性的场景
使用建议:
- 需要有序:使用TreeMap
- 需要范围查询:使用TreeMap
- 需要导航方法:使用TreeMap
- 只需要快速查找:使用HashMap
第6章:HashMap vs Hashtable对比
6.1 线程安全性差异
6.1.1 HashMap:非线程安全
特点:
- 性能最好
- 单线程场景首选
- 多线程环境下不安全
使用场景:
- 单线程环境
- 局部变量
- 线程封闭的场景
6.1.2 Hashtable:线程安全(synchronized)
实现方式:
public synchronized V put(K key, V value) {
// 所有方法都使用synchronized
}
public synchronized V get(Object key) {
// 所有方法都使用synchronized
}
特点:
- 所有方法都使用synchronized
- 锁住整个Hashtable对象
- 性能较差
问题:
- 锁粒度太大
- 并发性能差
- 已被ConcurrentHashMap替代
6.1.3 性能对比分析
| 场景 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 单线程 | 最快 | 慢 | 较快 |
| 多线程读 | 不安全 | 慢 | 最快 |
| 多线程写 | 不安全 | 慢 | 最快 |
6.2 null值处理差异
6.2.1 HashMap:允许null键值
Map<String, Integer> map = new HashMap<>();
map.put(null, 1); // 允许
map.put("key", null); // 允许
设计原因:
- 灵活性更高
- 某些场景需要null值
6.2.2 Hashtable:不允许null键值
Map<String, Integer> map = new Hashtable<>();
// map.put(null, 1); // 抛出NullPointerException
// map.put("key", null); // 抛出NullPointerException
设计原因:
- 早期设计,考虑不够完善
- 多线程环境下,null值处理复杂
6.3 性能对比
6.3.1 单线程性能
HashMap:
- 性能最好
- 无锁开销
- 推荐使用
Hashtable:
- 性能较差
- synchronized有开销
- 不推荐使用
6.3.2 多线程性能
HashMap:
- 不安全,不能使用
Hashtable:
- 安全但性能差
- 锁粒度太大
ConcurrentHashMap:
- 安全且性能好
- 推荐使用
6.3.3 为什么Hashtable被淘汰?
主要原因:
- 性能差: synchronized锁住整个对象,并发性能差
- 设计过时: 早期设计,没有考虑现代并发场景
- 有更好的替代: ConcurrentHashMap性能更好
- API设计: 方法命名不符合Java规范(没有驼峰命名)
替代方案:
- 单线程:使用HashMap
- 多线程:使用ConcurrentHashMap
6.4 继承体系差异
6.4.1 HashMap继承AbstractMap
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
特点:
- 继承AbstractMap
- 实现Map接口
- 符合Java集合框架设计
6.4.2 Hashtable继承Dictionary
public class Hashtable<K,V> extends Dictionary<K,V>
implements Map<K,V>, Cloneable, Serializable
特点:
- 继承Dictionary(已废弃)
- 早期设计,不符合现代Java规范
6.4.3 接口实现差异
相同点:
- 都实现Map接口
- 都支持基本的Map操作
不同点:
- HashMap继承AbstractMap(更现代)
- Hashtable继承Dictionary(已废弃)
📊 本章总结
核心要点:
- HashMap非线程安全,性能最好
- Hashtable线程安全但性能差,已淘汰
- 多线程场景使用ConcurrentHashMap
- HashMap允许null,Hashtable不允许
选择建议:
- 单线程:HashMap
- 多线程:ConcurrentHashMap
- 不要使用:Hashtable
第7章:Map集合性能优化与最佳实践
7.1 性能优化策略
7.1.1 合理设置初始容量
问题:频繁扩容影响性能
// 不好的做法:频繁扩容
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < 1000; i++) {
map.put("key" + i, i); // 可能触发多次扩容
}
优化:预分配容量
// 好的做法:预分配容量
Map<String, Integer> map = new HashMap<>(1000);
for (int i = 0; i < 1000; i++) {
map.put("key" + i, i); // 避免扩容
}
容量计算:
// 如果知道大概的元素数量
int expectedSize = 1000;
// 考虑负载因子,容量应该是 expectedSize / 0.75
int capacity = (int)(expectedSize / 0.75f) + 1;
Map<String, Integer> map = new HashMap<>(capacity);
7.1.2 选择合适的负载因子
默认负载因子:0.75
// 大多数场景使用默认值即可
Map<String, Integer> map = new HashMap<>(); // 负载因子0.75
特殊场景调整
// 如果内存充足,可以降低负载因子,减少冲突
Map<String, Integer> map = new HashMap<>(16, 0.5f);
// 如果内存紧张,可以提高负载因子,减少空间
Map<String, Integer> map = new HashMap<>(16, 1.0f);
建议:
- 一般场景:使用默认0.75
- 特殊场景:根据实际情况调整
7.1.3 实现良好的hashCode()
好的hashCode()实现
public class Student {
private String name;
private int age;
@Override
public int hashCode() {
int result = name != null ? name.hashCode() : 0;
result = 31 * result + age;
return result;
}
}
原则:
- 使用31作为乘数(经验值)
- 包含所有参与equals()的字段
- 保证相等的对象hashCode()相同
不好的hashCode()实现
// 不好的做法:所有对象hashCode()相同
@Override
public int hashCode() {
return 1; // 所有对象hashCode()相同,导致严重冲突
}
问题:
- 所有元素都在同一个位置
- 退化为链表,性能O(n)
7.1.4 选择合适的Map实现
选择指南
| 场景 | 推荐实现 | 原因 |
|---|---|---|
| 一般场景 | HashMap | 性能最好 |
| 需要有序 | LinkedHashMap或TreeMap | 维护顺序 |
| 需要排序 | TreeMap | 按键排序 |
| 多线程 | ConcurrentHashMap | 线程安全且性能好 |
| LRU缓存 | LinkedHashMap | 支持访问顺序 |
7.2 常见陷阱与注意事项
7.2.1 可变对象作为key的问题
问题示例
Map<List<String>, Integer> map = new HashMap<>();
List<String> key = new ArrayList<>();
key.add("A");
map.put(key, 1);
key.add("B"); // 修改key
// 现在无法通过key找到值了
Integer value = map.get(key); // 返回null
原因:
- 修改key后,hashCode()改变
- 无法找到原来的位置
- 导致内存泄漏
解决方案
- 使用不可变对象作为key: String、Integer等
- 如果必须使用可变对象: 确保不修改key
7.2.2 重写equals()必须重写hashCode()
问题示例
public class Student {
private String name;
@Override
public boolean equals(Object o) {
// 只重写了equals()
}
// 没有重写hashCode()
}
Map<Student, Integer> map = new HashMap<>();
Student s1 = new Student("张三");
Student s2 = new Student("张三");
map.put(s1, 1);
map.get(s2); // 返回null(因为hashCode()不同)
原因:
- equals()返回true,但hashCode()不同
- HashMap使用hashCode()定位,无法找到
解决方案
必须同时重写equals()和hashCode():
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Student student = (Student) o;
return Objects.equals(name, student.name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
7.2.3 并发场景下的选择
错误做法
// 多线程环境下使用HashMap(不安全)
Map<String, Integer> map = new HashMap<>();
// 多线程操作map,可能导致数据丢失或错误
正确做法
// 使用ConcurrentHashMap
Map<String, Integer> map = new ConcurrentHashMap<>();
// 线程安全,性能好
7.2.4 null值处理
HashMap允许null
Map<String, Integer> map = new HashMap<>();
map.put(null, 1); // 允许
ConcurrentHashMap不允许null
Map<String, Integer> map = new ConcurrentHashMap<>();
// map.put(null, 1); // 抛出NullPointerException
原因:
- 多线程环境下,null值处理复杂
- 无法区分"不存在"和"值为null"
7.3 最佳实践
7.3.1 根据场景选择Map实现
选择流程图:
需要Map?
↓
需要线程安全?
├─ 是 → ConcurrentHashMap
└─ 否 → 需要有序?
├─ 是 → 需要排序?
│ ├─ 是 → TreeMap
│ └─ 否 → LinkedHashMap
└─ 否 → HashMap
7.3.2 性能测试与调优
性能测试
// 测试不同Map实现的性能
long start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
map.put("key" + i, i);
}
long end = System.currentTimeMillis();
System.out.println("耗时:" + (end - start) + "ms");
调优建议
- 预分配容量: 避免频繁扩容
- 实现好的hashCode(): 减少冲突
- 选择合适的实现: 根据场景选择
- 避免不必要的操作: 减少性能开销
7.3.3 代码规范建议
推荐做法
// ✅ 预分配容量
Map<String, Integer> map = new HashMap<>(expectedSize);
// ✅ 使用接口类型
Map<String, Integer> map = new HashMap<>();
// ✅ 使用isEmpty()而不是size() == 0
if (map.isEmpty()) {
// ...
}
不推荐做法
// ❌ 不预分配容量
Map<String, Integer> map = new HashMap<>();
// ❌ 使用具体类型
HashMap<String, Integer> map = new HashMap<>();
// ❌ 使用size() == 0
if (map.size() == 0) {
// ...
}
📊 本章总结
核心要点:
- 合理设置初始容量,避免频繁扩容
- 实现良好的hashCode(),减少冲突
- 根据场景选择合适的Map实现
- 注意常见陷阱,避免错误
优化建议:
- 预分配容量
- 好的hashCode()实现
- 选择合适的实现类
- 避免可变对象作为key
第8章:Map集合大厂高频面试题精选
8.1 HashMap核心面试题(30道)
8.1.1 基础原理类(10道)
面试题1:请你说出HashMap的底层数据结构(JDK1.8前后)
答案:
JDK7:数组 + 链表
- 使用数组存储,每个数组位置是一个链表
- 哈希冲突时,在对应位置形成链表
- 链表采用头插法
JDK8:数组 + 链表/红黑树
- 基本结构与JDK7相同
- 当链表长度超过8时,转为红黑树
- 当红黑树节点数小于6时,退化为链表
- 链表采用尾插法
为什么引入红黑树?
- 当哈希冲突严重时,链表会变得很长
- 查找性能从O(1)退化为O(n)
- 红黑树可以将查找性能保持在O(log n)
面试题2:详细描述一次HashMap put(key, value)方法的执行流程
答案:
完整流程:
-
计算hash值
int hash = hash(key); // 扰动函数 -
定位数组下标
int index = (n - 1) & hash; // n是数组长度 -
检查数组位置
- 如果位置为空:创建新节点,直接插入
- 如果位置不为空:处理哈希冲突
-
处理哈希冲突
- 如果是链表:遍历查找,找到则更新值,否则插入尾部
- 如果是红黑树:在树中查找或插入
-
检查是否需要转红黑树
- 如果链表长度 >= 8:转为红黑树
-
检查是否需要扩容
- 如果元素数量 > 容量 × 负载因子:触发扩容
流程图:
put(key, value)
↓
计算hash值
↓
定位数组下标: (n-1) & hash
↓
数组位置是否为空?
├─ 是 → 直接插入Node
└─ 否 → 检查Node类型
├─ 链表 → 遍历查找/插入
└─ 红黑树 → 树中查找/插入
↓
检查是否需要转红黑树
↓
检查是否需要扩容
↓
完成
面试题3:HashMap的hash()方法(扰动函数)是怎么设计的?为什么要这样做?
答案:
实现代码:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
设计原理:
- 获取hashCode(): 调用key的hashCode()方法
- 右移16位: 将高16位右移到低16位
- 异或运算: 将高16位与低16位进行异或
为什么需要扰动?
- 问题: hashCode()可能分布不均匀,如果直接使用,低位可能相同,导致冲突
- 解决: 通过扰动,让高位也参与计算,使分布更均匀
- 效果: 减少哈希冲突,提高性能
示例:
// 假设hashCode() = 0x12345678
int h = 0x12345678;
int high = h >>> 16; // 0x00001234
int hash = h ^ high; // 0x1234444C
// 这样高位和低位都参与了计算
面试题4:HashMap如何根据key的hash值计算数组下标?为什么容量必须是2的幂?
答案:
计算方式:
int index = (n - 1) & hash;
为什么容量必须是2的幂?
- 位运算优化: 如果n是2的幂,n-1的二进制全是1
n = 16: 二进制 10000 n-1 = 15: 二进制 1111 - 均匀分布:
(n-1) & hash等价于hash % n,但位运算更快 - 性能提升: 位运算比取模运算快得多
如果不是2的幂会怎样?
- 使用tableSizeFor()方法,将容量调整为大于等于指定值的最小2的幂
- 例如:指定13,实际容量为16
面试题5:HashMap的get()方法是如何实现的?时间复杂度是多少?
答案:
实现流程:
- 计算hash值
- 定位数组下标
- 检查第一个节点
- 如果匹配:直接返回
- 如果不匹配:继续查找
- 在链表或红黑树中查找
时间复杂度:
- 最好情况: O(1) - 数组位置直接命中
- 平均情况: O(1) - 链表长度较短
- 最坏情况: O(log n) - 红黑树查找
代码示例:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
面试题6:HashMap的remove()方法实现原理
答案:
实现流程:
- 计算hash值,定位数组下标
- 在链表或红黑树中查找节点
- 找到后,从链表或红黑树中移除
- 如果红黑树节点数 < 6,退化为链表
- 更新size,返回被移除的值
时间复杂度: O(1)平均,O(log n)最坏(红黑树)
面试题7:HashMap的containsKey()和containsValue()有什么区别?
答案:
containsKey():
- 通过hash值快速定位,时间复杂度O(1)
- 在对应位置的链表或红黑树中查找
containsValue():
- 需要遍历所有元素,时间复杂度O(n)
- 性能较差,不推荐频繁使用
面试题8:HashMap的keySet()、values()、entrySet()有什么区别?
答案:
keySet(): 返回所有键的Set视图,修改会影响原Map
values(): 返回所有值的Collection视图,修改会影响原Map
entrySet(): 返回所有键值对的Set视图,修改会影响原Map
共同特点:
- 都是视图,不复制数据
- 修改视图会影响原Map
- 支持迭代和删除操作
面试题9:HashMap的clear()方法如何实现?
答案:
实现方式:
- 遍历数组,将所有位置置为null
- 将size重置为0
- 不改变容量
时间复杂度: O(n)
面试题10:HashMap的clone()方法是深拷贝还是浅拷贝?
答案:
浅拷贝:
- 只复制Map结构,不复制元素
- 新Map和原Map共享元素对象
- 修改元素会影响两个Map
深拷贝需要:
- 手动遍历并复制每个元素
- 或使用序列化/反序列化
8.1.2 关键参数类(8道)
面试题11:HashMap的默认初始容量、负载因子、最大容量是多少?
答案:
- 默认初始容量: 16
- 负载因子: 0.75
- 最大容量: 2^30 (1 << 30)
面试题12:负载因子为什么默认是0.75?如果设置为1会怎样?
答案:
为什么是0.75?
- 空间与时间的权衡
- 基于泊松分布计算得出
- 在0.75时,哈希冲突概率较低,空间利用率较高
如果设置为1:
- 空间利用率最高
- 但哈希冲突增多,性能下降
- 链表变长,查找变慢
面试题13:链表转红黑树的条件是什么?阈值为什么是8?
答案:
转树条件:
- 链表长度 >= 8
- 数组长度 >= 64(否则先扩容)
为什么是8?
- 基于泊松分布:当负载因子0.75时,链表长度8的概率约为0.00000006
- 正常情况下很少超过8
- 如果超过8,说明冲突严重,需要红黑树优化
面试题14:红黑树退化为链表的条件是什么?为什么是6?
答案:
退化条件: 红黑树节点数 < 6
为什么是6而不是8?
- 提供2的缓冲区间,防止频繁转换
- 避免在临界值8附近频繁转换
- 提高性能稳定性
面试题15:为什么初始容量必须是2的幂?如何保证?
答案:
为什么必须是2的幂?
- 使用位运算
(n-1) & hash代替取模,性能更好 - 保证均匀分布
如何保证?
- 使用tableSizeFor()方法
- 将容量调整为大于等于指定值的最小2的幂
面试题16:tableSizeFor()方法是做什么的?它是如何保证容量为2的幂的?
答案:
作用: 将容量调整为大于等于指定值的最小2的幂
实现原理:
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
原理: 通过位运算,将最高位之后的所有位都置为1,然后+1得到2的幂
面试题17:如果指定初始容量不是2的幂会怎样?
答案:
- HashMap会自动调用tableSizeFor()调整
- 调整为大于等于指定值的最小2的幂
- 例如:指定13,实际容量为16
面试题18:HashMap的最大容量为什么是2^30?
答案:
- int类型的最大值是2^31-1
- 2^30是最大的2的幂次方
- 保证容量始终是2的幂,便于位运算优化
8.1.3 扩容机制类(6道)
面试题19:HashMap什么时候会触发扩容?扩容的大小是多少?
答案:
触发条件:
- 元素数量 > 容量 × 负载因子
- 例如:16 × 0.75 = 12,当元素数量超过12时触发
扩容大小:
- 容量翻倍:newCap = oldCap << 1
- 例如:16 → 32 → 64 → 128
面试题20:描述一下resize()扩容的过程
答案:
扩容流程:
- 创建新数组,容量为原来的2倍
- 重新计算所有元素的位置
- 将元素迁移到新数组
- 更新threshold(扩容阈值)
JDK8优化:
- 高位低位链表拆分
- 不需要重新计算hash值
- 只需要判断hash值的某一位
面试题21:JDK1.8在扩容时做了什么优化?(高位低位链表拆分)
答案:
优化原理:
// 判断hash值的第5位(从右往左)
// 如果为0:在新数组的相同位置(低位)
// 如果为1:在新数组的 原位置+16 位置(高位)
int newIndex = (hash & oldCap) == 0 ? oldIndex : oldIndex + oldCap;
优势:
- 不需要重新计算hash值
- 只需要判断一位即可
- 性能提升明显
面试题22:为什么扩容是2倍,并且是2的幂次方?
答案:
为什么是2倍?
- 保证容量始终是2的幂
- 便于位运算优化
- 扩容后重新计算位置更简单
为什么是2的幂?
- 使用位运算代替取模
- 性能更好
- 分布更均匀
面试题23:扩容时如何重新计算元素位置?
答案:
JDK8优化方式:
- 不需要重新计算hash值
- 使用
(hash & oldCap) == 0判断 - 为0:位置不变(低位)
- 为1:位置 = 原位置 + oldCap(高位)
面试题24:扩容对性能的影响有多大?
答案:
影响:
- 扩容需要重新计算所有元素的位置
- 性能开销较大
- 应尽量避免频繁扩容
优化建议:
- 预分配容量
- 根据预期元素数量计算初始容量
8.1.4 线程安全类(6道)
面试题25:为什么说HashMap是线程不安全的?具体表现有哪些?
答案:
不安全的体现:
- 数据覆盖: 多线程put相同key,可能只有一个值被保存
- 数据丢失: 多线程put不同key,可能丢失数据
- 死循环(JDK7): 并发扩容可能导致死循环
- 数据不一致: get操作可能读到不一致的数据
原因:
- 没有同步机制
- 非原子操作
- 没有volatile保证可见性
面试题26:详细解释JDK1.7中HashMap并发扩容可能导致死循环的问题
答案:
问题场景:
- 两个线程同时进行扩容
- 在扩容过程中,链表被反转
- 形成循环链表,导致死循环
原因:
- 头插法导致链表反转
- 多线程并发操作
- 没有同步机制
解决方案:
- 使用ConcurrentHashMap
- 或使用Collections.synchronizedMap()
面试题27:JDK1.8修复了死循环问题,那HashMap就是线程安全的了吗?
答案:
不是:
- JDK8修复了死循环问题(改为尾插法)
- 但仍存在数据覆盖和数据不一致问题
- 仍然不是线程安全的
多线程场景应使用:
- ConcurrentHashMap(推荐)
- Collections.synchronizedMap()
面试题28:如何保证HashMap的线程安全?
答案:
方案1:使用ConcurrentHashMap(推荐)
Map<String, Integer> map = new ConcurrentHashMap<>();
方案2:使用Collections.synchronizedMap()
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
方案3:使用Hashtable(不推荐,性能差)
Map<String, Integer> map = new Hashtable<>();
面试题29:Collections.synchronizedMap()是如何实现线程安全的?
答案:
实现方式:
- 使用synchronized锁住整个Map对象
- 所有方法都加锁
- 性能较差,锁粒度太大
不推荐原因:
- 性能不如ConcurrentHashMap
- 锁粒度太大
面试题30:为什么HashMap在多线程环境下会出现数据丢失?
答案:
原因:
- put操作不是原子性的
- 多线程同时修改可能导致数据覆盖
- 没有同步机制保护
示例:
- 线程A和线程B同时put不同的key
- 可能只有一个被保存,另一个丢失
8.2 ConcurrentHashMap核心面试题(20道)
8.2.1 版本对比类(4道)
面试题31:对比JDK1.7和JDK1.8中ConcurrentHashMap的实现原理
答案:
JDK7:分段锁(Segment)
- 使用Segment数组,每个Segment是一个锁
- 不同Segment可以并发操作
- 结构复杂,内存占用大
JDK8:CAS + synchronized
- 抛弃Segment,直接使用Node数组
- 使用CAS实现无锁插入
- 使用synchronized锁节点
- 结构更简单,性能更好
面试题32:JDK1.7中的分段锁(Segment)机制是怎样的?有什么优缺点?
答案:
机制:
- Segment数组,每个Segment是一个ReentrantLock
- 不同Segment可以并发操作
- 同一个Segment需要加锁
优点:
- 并发度高
- 锁粒度小
缺点:
- 结构复杂
- 内存占用大
- 锁竞争可能影响性能
面试题33:为什么JDK1.8要抛弃Segment?
答案:
原因:
- Segment结构复杂,内存占用大
- 锁粒度仍然较大
- CAS + synchronized性能更好
- 结构更简单,更易维护
面试题34:JDK1.8相比JDK1.7有哪些性能提升?
答案:
性能提升:
- 结构更简单,内存占用更小
- CAS无锁插入,性能更好
- synchronized锁节点,锁粒度更小
- 多线程协助扩容,效率更高
8.2.2 JDK1.8实现类(8道)
面试题35:JDK1.8的ConcurrentHashMap是如何保证线程安全的?(CAS + synchronized)
答案:
机制:
- CAS实现无锁插入: 位置为空时,使用CAS插入
- synchronized锁节点: 位置不为空时,锁住链表头或树根
- volatile保证可见性: Node的val和next都是volatile
优势:
- 锁粒度小,性能好
- 不同位置的节点可以并发操作
面试题36:描述一下JDK1.8中ConcurrentHashMap的put()方法流程
答案:
流程:
- 计算hash值,定位数组下标
- CAS尝试插入(位置为空)
- 如果失败,synchronized加锁插入
- 检查是否需要转红黑树
- 检查是否需要扩容
面试题37:get()操作为什么不需要加锁?如何保证读到的是最新数据?(volatile)
答案:
为什么不需要加锁?
- Node的val和next都是volatile
- volatile保证可见性
- 读操作不会修改数据
如何保证最新数据?
- volatile保证多线程之间的可见性
- 读操作总是读到最新值
面试题38:size()方法是如何实现的?为什么说它是"近似准确"的?(LongAdder思想)
答案:
实现方式:
- baseCount + CounterCell[]
- 每个线程更新自己的CounterCell
- 最终统计时,将所有CounterCell相加
为什么是"近似准确"?
- 多线程并发更新时,统计可能有延迟
- 但最终会收敛到准确值
- 性能优于加锁统计
面试题39:解释一下sizeCtl字段的作用和它的几种状态
答案:
sizeCtl的含义:
- 正数: 表示扩容阈值(容量 × 负载因子)
- -1: 表示正在初始化
- 负数(-N): 表示有N-1个线程正在扩容
面试题40:ForwardingNode节点是什么?它在扩容中起什么作用?
答案:
ForwardingNode:
- 标记节点,表示该位置的数据已经迁移到新数组
- get操作遇到ForwardingNode时,到新数组查找
- 其他线程看到ForwardingNode时,可以协助扩容
面试题41:多线程是如何协助进行扩容(transfer)的?
答案:
协助机制:
- 当线程发现正在扩容时,可以协助扩容
- 将数组分成多个段,每个线程负责一段
- 提高扩容效率,减少扩容时间
面试题42:ConcurrentHashMap的remove()方法如何保证线程安全?
答案:
实现方式:
- 使用synchronized锁住节点
- 在锁内进行删除操作
- 保证线程安全
8.2.3 设计与对比类(8道)
面试题43:为什么ConcurrentHashMap不允许null键和null值?
答案:
原因:
- 多线程环境下,null值处理复杂
- 无法区分"不存在"和"值为null"
- 避免歧义
面试题44:ConcurrentHashMap和Collections.synchronizedMap(new HashMap<>())有什么区别?如何选择?
答案:
区别:
- ConcurrentHashMap:CAS + synchronized,性能更好
- synchronizedMap:synchronized锁整个Map,性能差
选择:
- 高并发场景:使用ConcurrentHashMap
- 低并发场景:可以使用synchronizedMap
面试题45:在极高并发的计数场景,用ConcurrentHashMap和LongAdder哪个更好?
答案:
LongAdder更好:
- 专门为高并发计数设计
- 性能优于ConcurrentHashMap
- 使用分段计数,减少竞争
面试题46:你知道ConcurrentHashMap的computeIfAbsent方法吗?它有什么需要注意的坑?(避免递归计算)
答案:
computeIfAbsent:
- 如果key不存在,计算value并放入
- 如果key存在,直接返回value
注意:
- 避免在计算函数中再次调用computeIfAbsent
- 可能导致死锁或性能问题
面试题47:ConcurrentHashMap的key和value可以为null吗?为什么?
答案:
不可以:
- key和value都不可以为null
- 原因同面试题43
面试题48:ConcurrentHashMap的迭代器是fail-fast还是fail-safe?
答案:
fail-safe:
- 迭代器不会抛出ConcurrentModificationException
- 弱一致性,可能读到旧数据
- 适合并发场景
面试题49:ConcurrentHashMap的并发度是如何控制的?
答案:
JDK8:
- 不再使用并发度参数
- 通过CAS和synchronized自然控制
- 性能更好
面试题50:如何选择合适的Map实现?(HashMap vs ConcurrentHashMap)
答案:
选择指南:
- 单线程:使用HashMap
- 多线程:使用ConcurrentHashMap
- 需要有序:使用LinkedHashMap或TreeMap
- 需要排序:使用TreeMap
8.3 LinkedHashMap面试题(8道)
面试题51:LinkedHashMap和HashMap的主要区别是什么?它是如何维护顺序的?
答案:
主要区别:
- LinkedHashMap继承HashMap,增加双向链表维护顺序
- HashMap无序,LinkedHashMap有序
如何维护顺序:
- 使用双向链表(before、after指针)
- 插入时添加到链表尾部
- 访问时(accessOrder=true)移到链表尾部
面试题52:accessOrder参数的作用是什么?
答案:
作用:
- 控制LinkedHashMap的顺序模式
- false(默认):插入顺序
- true:访问顺序(适合LRU缓存)
面试题53:如何用LinkedHashMap实现一个LRU(最近最少使用)缓存?请写出核心代码
答案:
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int capacity;
public LRUCache(int capacity) {
super(16, 0.75f, true); // accessOrder=true
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > capacity;
}
}
面试题54:LinkedHashMap的get()方法在访问顺序模式下有什么特殊处理?
答案:
特殊处理:
- 访问节点后,调用afterNodeAccess()
- 将节点移到链表尾部
- 实现LRU策略
面试题55:LinkedHashMap的迭代顺序是什么?
答案:
插入顺序模式(默认):
- 按照元素插入的顺序
访问顺序模式(accessOrder=true):
- 按照元素访问的顺序
- 最近访问的在尾部
面试题56:LinkedHashMap是线程安全的吗?
答案:
不是:
- LinkedHashMap不是线程安全的
- 多线程场景需要使用Collections.synchronizedMap()或ConcurrentHashMap
面试题57:LinkedHashMap和TreeMap在有序性上有何区别?
答案:
LinkedHashMap:
- 维护插入顺序或访问顺序
- 时间复杂度O(1)
TreeMap:
- 按键排序(自然顺序或Comparator)
- 时间复杂度O(log n)
面试题58:什么场景下应该使用LinkedHashMap?
答案:
适用场景:
- 需要保持插入顺序
- 需要实现LRU缓存
- 需要记录最近访问的数据
8.4 TreeMap面试题(8道)
面试题59:TreeMap的底层数据结构是什么?put和get的时间复杂度是多少?
答案:
数据结构: 红黑树(自平衡二叉搜索树)
时间复杂度:
- put:O(log n)
- get:O(log n)
面试题60:TreeMap的排序规则是如何确定的?(Comparable 或 Comparator)
答案:
排序规则:
- 如果提供了Comparator:使用Comparator
- 如果键实现了Comparable:使用自然排序
- 否则:抛出ClassCastException
面试题61:TreeMap和HashMap在有序性上有何本质区别?
答案:
TreeMap:
- 按键排序,有序
- 红黑树实现
- 时间复杂度O(log n)
HashMap:
- 无序
- 哈希表实现
- 时间复杂度O(1)
面试题62:你知道ConcurrentSkipListMap吗?它和TreeMap有什么区别?(并发有序Map)
答案:
ConcurrentSkipListMap:
- 线程安全的有序Map
- 跳表实现
- 时间复杂度O(log n)
区别:
- TreeMap:非线程安全
- ConcurrentSkipListMap:线程安全
面试题63:TreeMap是线程安全的吗?
答案:
不是:
- TreeMap不是线程安全的
- 多线程场景需要使用Collections.synchronizedMap()或ConcurrentSkipListMap
面试题64:TreeMap的key可以为null吗?
答案:
不可以:
- TreeMap的key不能为null
- 会抛出NullPointerException
面试题65:什么场景下应该使用TreeMap?
答案:
适用场景:
- 需要按键排序
- 需要范围查询
- 需要导航方法(ceilingKey、floorKey等)
面试题66:TreeMap的subMap()方法如何使用?
答案:
使用方式:
// 获取范围 [fromKey, toKey) 的子Map
SortedMap<K, V> subMap = treeMap.subMap(fromKey, toKey);
特点:
- 返回子Map视图
- 修改子Map会影响原Map
- 范围是左闭右开 [fromKey, toKey)
8.5 HashMap vs Hashtable对比面试题(6道)
面试题67:从线程安全、性能、null值、继承类等方面详细对比HashMap和Hashtable
答案:
| 特性 | HashMap | Hashtable |
|---|---|---|
| 线程安全 | 否 | 是(synchronized) |
| 性能 | 好 | 差 |
| null键值 | 允许 | 不允许 |
| 继承类 | AbstractMap | Dictionary(已废弃) |
| 推荐使用 | 是 | 否(已淘汰) |
面试题68:为什么Hashtable被淘汰了?
答案:
原因:
- 性能差:synchronized锁整个对象
- 设计过时:早期设计,不符合现代Java规范
- 有更好的替代:ConcurrentHashMap性能更好
- API设计:方法命名不符合Java规范
面试题69:Hashtable的默认初始容量和负载因子是多少?
答案:
- 默认初始容量: 11
- 负载因子: 0.75
面试题70:Hashtable的扩容机制是怎样的?
答案:
扩容机制:
- 触发条件:元素数量 > 容量 × 负载因子
- 扩容大小:newCapacity = oldCapacity * 2 + 1
- 不是2的幂,性能较差
面试题71:什么场景下还会使用Hashtable?
答案:
几乎不使用:
- Hashtable已被淘汰
- 单线程场景使用HashMap
- 多线程场景使用ConcurrentHashMap
面试题72:Hashtable和ConcurrentHashMap有什么区别?
答案:
主要区别:
- Hashtable:synchronized锁整个对象,性能差
- ConcurrentHashMap:CAS + synchronized,性能好
- ConcurrentHashMap是Hashtable的现代替代品
8.6 Map集合综合面试题(20道)
8.6.1 哈希冲突与Key设计类(8道)
面试题73:HashMap是如何解决哈希冲突的?
答案:
解决方式:链地址法(拉链法)
- 在冲突位置维护一个链表
- JDK8中,链表长度超过8时转为红黑树
- 性能从O(n)优化到O(log n)
面试题74:如果两个不同的key有相同的hashCode()会怎样?如果hashCode()相同,equals()不同呢?
答案:
hashCode()相同:
- 发生哈希冲突
- 在同一个位置的链表中存储
- 通过equals()区分
hashCode()相同,equals()不同:
- 两个key都存储在链表中
- get时通过equals()查找
- 性能可能下降
面试题75:为什么重写equals()方法时必须重写hashCode()方法?在HashMap中会产生什么后果?
答案:
原因:
- HashMap使用hashCode()定位,equals()比较
- 如果equals()返回true但hashCode()不同,无法找到元素
后果:
- 无法通过get()获取元素
- 可能存储重复的key
- 违反HashMap的约定
面试题76:可以使用可变对象作为HashMap的key吗?会有什么问题?
答案:
不推荐:
- 修改key后,hashCode()改变
- 无法找到原来的位置
- 导致内存泄漏
建议:
- 使用不可变对象作为key(String、Integer等)
面试题77:String、Integer这类包装类为什么适合作为HashMap的key?
答案:
原因:
- 不可变:修改后创建新对象,不影响原key
- 实现了equals()和hashCode()
- 性能好,分布均匀
面试题78:如何设计一个好的hashCode()方法?
答案:
原则:
- 使用31作为乘数(经验值)
- 包含所有参与equals()的字段
- 保证相等的对象hashCode()相同
- 尽量分布均匀
示例:
@Override
public int hashCode() {
int result = field1 != null ? field1.hashCode() : 0;
result = 31 * result + field2;
return result;
}
面试题79:哈希冲突对HashMap性能的影响有多大?
答案:
影响:
- 冲突少:性能O(1)
- 冲突多:性能O(n)或O(log n)
- 严重冲突:性能大幅下降
优化:
- 实现好的hashCode()
- 合理设置负载因子
- 预分配容量
面试题80:如何减少哈希冲突?
答案:
方法:
- 实现好的hashCode()方法
- 合理设置负载因子(默认0.75)
- 预分配容量,避免频繁扩容
- 使用扰动函数(HashMap已实现)
8.6.2 性能优化类(6道)
面试题81:如何优化HashMap的性能?(初始化容量、好的hashCode实现)
答案:
优化策略:
- 预分配容量:避免频繁扩容
- 实现好的hashCode():减少冲突
- 选择合适的负载因子:默认0.75
- 选择合适的Map实现:根据场景选择
面试题82:HashMap的get()操作时间复杂度一定是O(1)吗?在什么情况下会退化?
答案:
不一定:
- 最好情况:O(1) - 数组位置直接命中
- 平均情况:O(1) - 链表长度较短
- 最坏情况:O(log n) - 红黑树查找
退化情况:
- 哈希冲突严重,链表很长
- 转为红黑树后,O(log n)
面试题83:HashMap和Hashtable的null键null值支持有何不同?
答案:
HashMap:
- 允许一个null键和多个null值
Hashtable:
- 不允许null键和null值
面试题84:如何选择合适的Map实现类?
答案:
选择指南:
- 一般场景:HashMap
- 需要有序:LinkedHashMap或TreeMap
- 需要排序:TreeMap
- 多线程:ConcurrentHashMap
- LRU缓存:LinkedHashMap
面试题85:Map集合的性能测试与调优方法
答案:
测试方法:
- 使用JMH进行性能测试
- 测试不同场景下的性能
- 分析瓶颈
调优方法:
- 预分配容量
- 实现好的hashCode()
- 选择合适的实现类
- 避免不必要的操作
面试题86:大数据量场景下如何优化Map性能?
答案:
优化策略:
- 预分配容量,避免频繁扩容
- 实现好的hashCode(),减少冲突
- 使用合适的负载因子
- 考虑使用ConcurrentHashMap(多线程)
- 分批处理,避免一次性加载所有数据
8.6.3 源码与设计类(6道)
面试题87:手写一个简化版的HashMap(至少实现put和get)
答案:
public class SimpleHashMap<K, V> {
private Node<K, V>[] table;
private int size;
private static final int DEFAULT_CAPACITY = 16;
static class Node<K, V> {
final int hash;
final K key;
V value;
Node<K, V> next;
Node(int hash, K key, V value, Node<K, V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
public V put(K key, V value) {
if (table == null) {
table = new Node[DEFAULT_CAPACITY];
}
int hash = key.hashCode();
int index = (table.length - 1) & hash;
Node<K, V> node = table[index];
if (node == null) {
table[index] = new Node<>(hash, key, value, null);
size++;
return null;
}
while (node != null) {
if (node.key.equals(key)) {
V oldValue = node.value;
node.value = value;
return oldValue;
}
node = node.next;
}
table[index] = new Node<>(hash, key, value, table[index]);
size++;
return null;
}
public V get(K key) {
if (table == null) return null;
int hash = key.hashCode();
int index = (table.length - 1) & hash;
Node<K, V> node = table[index];
while (node != null) {
if (node.key.equals(key)) {
return node.value;
}
node = node.next;
}
return null;
}
}
面试题88:Map接口的设计模式有哪些?(模板方法、策略模式等)
答案:
设计模式:
- 模板方法模式: AbstractMap提供模板方法
- 策略模式: 不同的Map实现使用不同的策略
- 迭代器模式: entrySet()、keySet()返回迭代器
- 适配器模式: Collections.synchronizedMap()
面试题89:如何实现一个线程安全的Map?
答案:
方案:
- 使用ConcurrentHashMap(推荐)
- 使用Collections.synchronizedMap()
- 使用Hashtable(不推荐)
- 自己实现:使用synchronized或ReentrantLock
面试题90:Map集合的序列化机制是怎样的?
答案:
序列化:
- 实现Serializable接口
- 自定义writeObject()和readObject()
- 只序列化实际元素,不序列化空位置
面试题91:Map集合的迭代器实现原理
答案:
实现原理:
- entrySet()、keySet()、values()返回视图
- 视图内部使用迭代器遍历
- 修改视图会影响原Map
面试题92:Map集合的fail-fast机制是如何实现的?
答案:
实现方式:
- 使用modCount记录修改次数
- 迭代时检查modCount
- 如果modCount改变,抛出ConcurrentModificationException
注意:
- HashMap的迭代器是fail-fast
- ConcurrentHashMap的迭代器是fail-safe
文档完成!
本文档全面覆盖了Java Map集合框架的所有核心知识点,包含92道大厂高频面试题,适合系统学习和面试准备。