Java面试宝典:HashMap底层原理


目录
九、与 HashTable、ConcurrentHashMap 的对比
4. HashMap 和 ConcurrentHashMap 的区别?
方案1:使用 Collections.synchronizedMap
HashMap 基于数组+链表/红黑树实现,通过哈希函数(扰动高位优化分布)计算键的索引位置,用链表解决哈希冲突(Java 8 后链表超8转红黑树优化查询效率),动态扩容时容量翻倍并重哈希(负载因子默认0.75),非线程安全(多线程推荐 ConcurrentHashMap)。其设计核心是空间换时间,通过高效哈希计算、冲突管理和扩容策略实现 O(1) 平均操作复杂度,需注意键对象的 hashCode() 和 equals() 正确实现以保障性能。
一、HashMap 的核心设计思想
HashMap 是 Java 中最经典的哈希表实现,其设计目标是高效存储和查找键值对。它的底层通过以下技术实现:
-
哈希函数:快速定位键的存储位置。
-
数组+链表/红黑树:解决哈希冲突。
-
动态扩容:保证性能不因数据量增长而急剧下降。
二、数据结构:数组 + 链表/红黑树
HashMap 的底层数据结构是一个 Node<K,V>[] 数组,每个数组元素称为一个 桶(Bucket)。每个桶可以存储一个链表或红黑树。
// HashMap 核心静态内部类 Node
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 哈希值(避免重复计算)
final K key; // 键
V value; // 值
Node<K,V> next; // 链表的下一个节点
}

三、哈希函数:如何计算键的位置
HashMap 通过哈希函数将键映射到数组索引。核心步骤:
-
计算键的哈希码(
key.hashCode())。 -
对哈希码进行扰动处理(避免高位信息丢失)。
-
对数组长度取模(实际用位运算优化)。
// Java 8 中的哈希函数实现
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 计算索引位置(假设数组长度为n)
int index = (n - 1) & hash(key);
为什么需要扰动函数?
如果直接使用 hashCode(),高位信息可能丢失(例如数组长度为16时,只有低4位有效)。通过 ^ (h >>> 16) 将高位信息混合到低位。
四、哈希冲突的解决:链表与红黑树
当两个不同的键计算出相同的索引时,发生哈希冲突。HashMap 的解决方案:
1. 链表法
-
冲突的键值对以链表形式存储在同一个桶中。
-
插入时,新节点插入链表头部(Java 7)或尾部(Java 8)。
2. 红黑树法(Java 8优化)
-
当链表长度超过阈值(默认8)时,链表转换为红黑树。
-
当红黑树节点数小于阈值(默认6)时,红黑树退化为链表。
// Java 8 中的树化条件
if (binCount >= TREEIFY_THRESHOLD - 1) // 阈值8
treeifyBin(tab, hash);
五、动态扩容机制
HashMap 的容量是动态调整的,核心参数:
-
容量(capacity):数组的长度(默认16)。
-
负载因子(loadFactor):扩容阈值比例(默认0.75)。
-
阈值(threshold):容量 × 负载因子。
扩容触发条件
if (++size > threshold)
resize();
扩容过程
-
创建新数组(容量为原来的2倍)。
-
重新计算所有键值对的位置(利用高位掩码优化)。
-
迁移数据到新数组。
// Java 8 中的扩容逻辑(部分代码)
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
for (Node<K,V> e : oldTab) {
while (e != null) {
Node<K,V> next = e.next;
int newIndex = (newCap - 1) & e.hash; // 重新计算索引
e.next = newTab[newIndex];
newTab[newIndex] = e;
e = next;
}
}
为什么扩容是2倍?
为了保持数组长度为2的幂,这样 (n-1) & hash 等效于 hash % n,但位运算更高效。
六、核心方法源码分析
1. put() 方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1. 如果数组为空,先初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. 计算索引位置,如果该位置为空,直接插入新节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 3. 如果键已存在,直接覆盖值
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4. 如果是红黑树节点,调用红黑树的插入方法
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 5. 遍历链表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 链表长度超过8,转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 6. 更新值并返回旧值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 7. 检查是否需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
2. get() 方法
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 1. 检查第一个节点
if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
// 2. 如果是红黑树,调用红黑树查找
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 3. 遍历链表
do {
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
七、线程安全性问题
HashMap 不是线程安全的,多线程操作可能导致以下问题:
-
数据覆盖:两个线程同时插入导致数据丢失。
-
死循环(Java 7中更常见):扩容时链表成环。
示例:Java 7 扩容死循环
// 线程1执行到此处挂起
Entry<K,V> next = e.next;
// 线程2完成扩容,链表顺序反转
// 线程1恢复执行后,可能导致循环链表
e.next = newTable[i];
newTable[i] = e;
e = next;
八、性能优化与使用建议
- 初始化容量:预估数据量,避免频繁扩容。
- 键的设计:重写
hashCode()和equals(),保证哈希分布均匀。 - 负载因子调整:根据查询和插入的优先级选择(默认0.75是平衡值)。
new HashMap<>(initialCapacity);
九、与 HashTable、ConcurrentHashMap 的对比
| 特性 | HashMap | HashTable | ConcurrentHashMap |
|---|---|---|---|
| 线程安全 | 否 | 是(全表锁) | 是(分段锁/CAS) |
| 允许 null 键/值 | 是 | 否 | 否 |
| 性能 | 高 | 低 | 高 |
| 扩容机制 | 2倍 | 2倍+1 | 分段扩容 |
十、实战代码示例
1. 自定义对象作为键
class Student {
private String id;
private String name;
@Override
public int hashCode() {
return Objects.hash(id, name);
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Student student = (Student) obj;
return Objects.equals(id, student.id) && Objects.equals(name, student.name);
}
}
// 使用示例
HashMap<Student, Integer> studentScores = new HashMap<>();
2. 模拟 HashMap 扩容
public class HashMapResizeDemo {
public static void main(String[] args) {
// 初始容量4,负载因子0.75,阈值3
HashMap<String, Integer> map = new HashMap<>(4, 0.75f);
map.put("A", 1);
map.put("B", 2);
map.put("C", 3); // 触发扩容
System.out.println("扩容后容量:" + map.size());
}
}
十一、HashMap 在 Java 8 后的优化总结
-
红黑树引入:链表长度 >8 时转换为红黑树。
-
哈希计算优化:简化扰动函数。
-
扩容算法改进:避免重新计算哈希(利用高位掩码)。
十二、常见面试题解答
1. HashMap 的哈希函数如何设计?
HashMap 的哈希函数设计目标是 减少哈希冲突 并 高效计算索引,其实现分为两步:
步骤1:扰动函数处理哈希码
-
Java 8 中,哈希值通过键的
hashCode()与 高16位异或 得到:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
-
作用:将高位信息混合到低位,避免数组长度较小时(如16),哈希码的高位无法参与索引计算,导致冲突增多。
步骤2:计算索引位置
-
索引公式:
index = (n - 1) & hash(n是数组长度)。 -
原理:当
n是2的幂时,(n-1) & hash等效于hash % n,但位运算效率更高。
关键点:
-
扰动函数优化了哈希码的分布性。
-
位运算替代取模提升性能。
2. 为什么链表长度超过8要转红黑树?
设计依据:
-
泊松分布统计:在负载因子为0.75时,哈希冲突导致链表长度达到8的概率约为千万分之一(具体值见源码注释)。
-
性能权衡:
-
链表查询复杂度:O(n),长度过长时性能差。
-
红黑树查询复杂度:O(log n),空间占用略高但性能更优。
-
退化为链表的条件:
-
当红黑树节点数小于6时,退化为链表,避免频繁转换。
源码注释示例:
// 链表长度超过此阈值时树化
static final int TREEIFY_THRESHOLD = 8;
// 红黑树节点数小于此阈值时退化为链表
static final int UNTREEIFY_THRESHOLD = 6;
关键点:
-
树化是极端情况下的保护措施,平衡时间与空间成本。
3. HashMap 的初始容量设置多少合适?
原则:
-
避免频繁扩容(扩容触发条件:
size > capacity * loadFactor)。 -
根据预估数据量计算初始容量:
initialCapacity = (expectedSize / loadFactor) + 1;
关键点:
-
预估数据量,公式计算初始容量,减少扩容次数。
4. HashMap 和 ConcurrentHashMap 的区别?
| 特性 | HashMap | ConcurrentHashMap |
|---|---|---|
| 线程安全 | 非线程安全 | 线程安全(分段锁/CAS + synchronized) |
| Null键/值 | 允许 | 不允许 |
| 性能 | 单线程高效 | 高并发下性能更优 |
| 锁粒度 | 无锁 | Java 7:分段锁;Java 8:桶级别锁 + CAS |
| 迭代器 | 快速失败(Fail-Fast) | 弱一致性(Weakly Consistent) |
Java 8 的优化:
-
ConcurrentHashMap 放弃分段锁,改用
synchronized锁单个桶头节点,结合 CAS 实现无锁化操作,降低内存开销。
关键点:
-
线程安全机制不同,ConcurrentHashMap 支持高并发且不允许 null 值。
5. 如何保证 HashMap 的线程安全?
方案1:使用 Collections.synchronizedMap
-
通过同步代码块包裹所有方法,锁住整个表:
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
方案2:使用 ConcurrentHashMap
-
分段锁(Java 7)或桶级别锁(Java 8),高并发下性能更好:
ConcurrentHashMap<String, Integer> concurrentMap = new ConcurrentHashMap<>();
方案3:手动加锁(不推荐)
-
使用
synchronized或ReentrantLock控制访问:
synchronized(map) {
map.put(key, value);
}
-
缺点:实现复杂且易出错。
关键点:
-
推荐使用
ConcurrentHashMap,兼顾线程安全和性能。

浙公网安备 33010602011771号