【Java基础】集合框架: HashMap核心原理:JDK1.7 vs 1.8+ 区别、数据结构、哈希函数、扩容机制、put/get全流程、红黑树转换阈值(附《思维导图》+《面试高频考点清单》)
·
文章目录

HashMap核心原理:JDK1.7 vs 1.8+ 系统性知识体系
一、整体概述
HashMap是Java集合框架中最常用的键值对存储结构,基于哈希表实现,允许null键和null值,线程不安全。JDK1.8对其进行了重大重构,解决了1.7中链表过长导致的性能退化问题,同时优化了哈希冲突处理和扩容机制。
| 特性 | JDK1.7 | JDK1.8+ |
|---|---|---|
| 核心数据结构 | 数组+单向链表 | 数组+单向链表+红黑树 |
| 哈希冲突解决 | 链地址法(头插法) | 链地址法(尾插法)+红黑树 |
| 插入方式 | 头插法 | 尾插法 |
| 扩容时链表顺序 | 反转 | 保持原顺序 |
| 并发问题 | 死循环、数据丢失 | 数据覆盖、丢失 |
| 平均时间复杂度 | O(1)~O(n) | O(1)~O(logn) |
二、核心数据结构对比
1. JDK1.7:数组+单向链表
- 底层实现:
Entry[] table数组,每个元素是单向链表的头节点 - Entry节点结构:
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; int hash; // ... } - 问题:当哈希冲突严重时,链表会变得很长,get操作时间复杂度退化为O(n)
2. JDK1.8+:数组+单向链表+红黑树
- 底层实现:
Node[] table数组,链表长度超过阈值时转换为红黑树 - Node节点结构(链表节点):
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; // ... } - TreeNode节点结构(红黑树节点):
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; // 颜色标记 // ... } - 优势:红黑树的查找、插入、删除时间复杂度均为O(logn),解决了链表过长的性能问题
三、哈希函数对比
1. JDK1.7 哈希函数
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// 多次扰动:4次位运算+5次异或
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
- 特点:
- 使用
hashSeed随机种子(可通过JVM参数配置) - 对key的hashCode进行9次扰动,目的是让高位参与运算,减少哈希冲突
- 对String类型有特殊优化
- 使用
2. JDK1.8+ 哈希函数
static final int hash(Object key) {
int h;
// 1次位运算+1次异或
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 特点:
- 简化为2次扰动:将hashCode的高16位与低16位进行异或
- 去掉了
hashSeed随机种子 - null键的哈希值固定为0,因此null键总是存放在数组第0个位置
- 设计思想:
- 数组长度总是2的幂,计算数组下标时使用
(n-1) & hash - 高16位参与运算可以让高位信息也影响下标计算,减少哈希冲突
- 1.8的扰动函数虽然简单,但配合红黑树已经足够应对大部分场景
- 数组长度总是2的幂,计算数组下标时使用
四、数组下标计算
两个版本的数组下标计算方式相同:
// n是数组长度,总是2的幂
int index = hash & (n - 1);
- 为什么用&而不是%:位运算效率远高于取模运算
- 为什么数组长度必须是2的幂:保证
n-1的二进制表示全是1,这样hash & (n-1)的结果才能均匀分布在[0, n-1]范围内
五、扩容机制对比
1. 核心参数
| 参数 | 含义 | 默认值 |
|---|---|---|
initialCapacity |
初始容量 | 16 |
loadFactor |
负载因子 | 0.75f |
threshold |
扩容阈值 | capacity * loadFactor |
2. JDK1.7 扩容机制
- 触发条件:当
size >= threshold且当前插入位置已有元素时 - 扩容大小:原容量的2倍(
newCapacity = oldCapacity * 2) - 扩容过程:
- 创建新数组,长度为原数组的2倍
- 遍历原数组的每个链表
- 对每个Entry重新计算哈希值和数组下标
- 使用头插法将Entry插入到新数组的对应位置
- 致命问题:
- 头插法会导致链表顺序反转
- 多线程并发扩容时可能形成环形链表,导致get操作时死循环
3. JDK1.8+ 扩容机制
- 触发条件:当
size >= threshold时(无论当前插入位置是否有元素) - 扩容大小:原容量的2倍
- 优化点:
- 不需要重新计算每个节点的哈希值
- 利用
hash & oldCap的结果判断节点在新数组中的位置:- 结果为0:新下标 = 原下标
- 结果为1:新下标 = 原下标 + 原容量
- 使用尾插法插入节点,保持链表原顺序
- 红黑树处理:
- 扩容时会对红黑树进行拆分
- 如果拆分后树的节点数小于等于6,则退化为链表
- 并发问题:
- 尾插法避免了环形链表和死循环
- 但仍然存在数据覆盖、丢失等问题,因此HashMap仍然线程不安全
六、put方法全流程对比
1. JDK1.7 put流程
- 判断table数组是否为空,若为空则初始化
- 计算key的哈希值和数组下标
- 遍历该下标处的链表:
- 若key已存在(hash相等且equals返回true),则更新value并返回旧value
- 若key不存在,则继续
- 判断是否需要扩容(size >= threshold且当前位置已有元素)
- 若需要扩容,则创建新数组并将原数组元素转移到新数组
- 使用头插法将新Entry插入到链表头部
- size加1,返回null
2. JDK1.8+ put流程
1. 判断table数组是否为空,若为空则调用resize()初始化
2. 计算key的哈希值和数组下标i
3. 若table[i] == null,则直接创建新Node插入,转步骤8
4. 若table[i] != null:
a. 若第一个节点的key与待插入key相同(hash相等且equals返回true),则直接覆盖value
b. 若第一个节点是TreeNode,则调用putTreeVal()插入红黑树
c. 若第一个节点是普通Node,则遍历链表:
i. 若找到相同key的节点,则覆盖value
ii. 若遍历到链表尾部仍未找到,则创建新Node插入尾部
iii. 插入后判断链表长度是否 >= 8,若是则调用treeifyBin()尝试转换为红黑树
5. 若步骤4中找到了相同key的节点,则返回旧value
6. 若步骤4中未找到相同key的节点,则size加1
7. 判断size是否 >= threshold,若是则调用resize()扩容
8. 返回null
七、get方法全流程对比
1. JDK1.7 get流程
- 若key为null,则遍历table[0]处的链表查找
- 否则计算key的哈希值和数组下标
- 遍历该下标处的链表,找到hash相等且key.equals()返回true的节点
- 返回节点的value,若未找到则返回null
2. JDK1.8+ get流程
- 计算key的哈希值和数组下标i
- 若table[i] == null,则返回null
- 若table[i]的key与待查找key相同,则直接返回该节点的value
- 若table[i]是TreeNode,则调用getTreeNode()在红黑树中查找
- 若table[i]是普通Node,则遍历链表查找
- 找到则返回value,否则返回null
八、红黑树转换与退化阈值
1. 链表转红黑树阈值
- 链表长度阈值:
TREEIFY_THRESHOLD = 8 - 数组容量阈值:
MIN_TREEIFY_CAPACITY = 64 - 转换条件:当链表长度 >= 8 且 数组容量 >= 64时,才会将链表转换为红黑树
- 为什么是8:
- 根据泊松分布,在负载因子0.75的情况下,链表长度达到8的概率约为0.00000006
- 红黑树的节点占用空间比普通Node大,因此只有在链表足够长时才转换
2. 红黑树退化为链表阈值
- 阈值:
UNTREEIFY_THRESHOLD = 6 - 触发时机:扩容时红黑树拆分后,若节点数 <= 6,则退化为链表
- 为什么是6:
- 避免频繁在链表和红黑树之间转换
- 当节点数为6时,链表的平均查找长度(3.5)已经优于红黑树的平均查找长度(log2(6)≈2.6),但考虑到红黑树的额外开销,此时退化为链表更划算
九、其他重要区别
1. 插入方式
- JDK1.7:头插法
- 优点:实现简单,不需要遍历链表到尾部
- 缺点:扩容时链表反转,并发时可能形成环形链表
- JDK1.8+:尾插法
- 优点:保持链表原顺序,避免并发扩容时的环形链表问题
- 缺点:需要遍历链表到尾部才能插入
2. 遍历方式
- JDK1.7:使用
Iterator遍历,基于EntrySet - JDK1.8+:新增
forEach方法,支持Lambda表达式
3. 性能
- JDK1.7:最坏情况下O(n)
- JDK1.8+:最坏情况下O(logn),在哈希冲突严重时性能提升明显
4. 并发问题
- JDK1.7:死循环、数据丢失、数据覆盖
- JDK1.8+:数据丢失、数据覆盖(解决了死循环问题)
十、常见面试考点
- HashMap的底层数据结构:1.7是数组+链表,1.8是数组+链表+红黑树
- HashMap的工作原理:通过key的hashCode计算哈希值,再通过哈希值计算数组下标,然后在对应位置的链表或红黑树中查找
- 为什么数组长度必须是2的幂:保证
n-1的二进制全是1,使哈希结果均匀分布 - JDK1.8对HashMap做了哪些优化:引入红黑树、简化哈希函数、尾插法、扩容优化
- HashMap的扩容机制:触发条件、扩容大小、1.7和1.8的区别
- 红黑树的转换阈值:链表转红黑树是8,红黑树退化为链表是6
- HashMap为什么线程不安全:1.7的死循环,1.8的数据覆盖
- HashMap和HashTable的区别:线程安全、null键值、性能、父类
- HashMap和ConcurrentHashMap的区别:线程安全实现方式、性能、锁粒度
HashMap面试版:必背核心要点+高频题标准答案
一、面试必背核心要点(3分钟速记版)
1. 核心数据结构
- JDK1.7:数组+单向链表(Entry数组)
- JDK1.8+:数组+单向链表+红黑树(Node数组)
- 核心解决:1.7链表过长导致O(n)性能退化,1.8红黑树将最坏时间复杂度降至O(logn)
2. 哈希函数
- 1.7:9次扰动(4次位运算+5次异或)+ hashSeed随机种子,String特殊优化
- 1.8:简化为2次扰动(
h = key.hashCode() ^ (h >>> 16)),去掉hashSeed - 共同:null键哈希值固定为0,存于数组第0位;下标计算
hash & (n-1)(n为数组长度)
3. 扩容机制(核心考点)
| 维度 | JDK1.7 | JDK1.8+ |
|---|---|---|
| 触发条件 | size≥threshold 且 当前插入位置有元素 | size≥threshold(无条件) |
| 扩容大小 | 原容量×2(始终保持2的幂) | 原容量×2 |
| 节点迁移 | 重新计算hash+头插法→链表反转 | 无需重算hash,hash & oldCap判断新位置+尾插法→保持原顺序 |
| 并发问题 | 头插法导致环形链表死循环 | 解决死循环,仍存在数据覆盖/丢失 |
4. 红黑树转换阈值(必考)
- 链表转红黑树:链表长度≥8 且 数组容量≥64(缺一不可)
- 红黑树退链表:扩容拆分后节点数≤6
- 设计原因:泊松分布下链表长度达8的概率≈0.000006;6-8的缓冲区间避免频繁转换
5. put流程核心差异
- 1.7:先判断扩容→再头插(扩容后插入新数组)
- 1.8:先插入→再判断扩容(插入原数组后再扩容);插入后检查链表长度,≥8则尝试树化
6. 插入方式
- 1.7:头插法(实现简单,无需遍历尾部)
- 1.8:尾插法(解决扩容死循环,需遍历到尾部)
二、高频面试题标准答案(直接背诵)
1. HashMap的底层实现原理?
标准回答:
HashMap基于哈希表实现,采用链地址法解决哈希冲突。
- JDK1.7:底层是Entry数组+单向链表,通过key的hashCode计算数组下标,冲突时用链表存储。
- JDK1.8+:底层是Node数组+单向链表+红黑树,当链表长度≥8且数组容量≥64时,链表转换为红黑树,将最坏查找时间复杂度从O(n)优化到O(logn)。
2. JDK1.8对HashMap做了哪些重大优化?
标准回答:
- 数据结构优化:引入红黑树,解决链表过长的性能退化问题
- 哈希函数优化:简化为高16位异或低16位,减少计算开销
- 插入方式优化:头插法改尾插法,解决并发扩容时的环形链表死循环
- 扩容机制优化:无需重新计算每个节点的哈希值,通过
hash & oldCap直接判断新位置(原下标或原下标+原容量) - put流程优化:先插入后扩容,避免不必要的扩容操作
3. 为什么HashMap的数组长度必须是2的幂?
标准回答:
- 提高计算效率:数组下标计算使用
hash & (n-1),位运算远快于取模运算hash % n - 保证哈希均匀分布:当n是2的幂时,
n-1的二进制表示全为1,此时hash & (n-1)的结果能均匀分布在[0, n-1]范围内,减少哈希冲突 - 简化扩容计算:1.8中扩容时可通过
hash & oldCap直接判断节点新位置,无需重新计算哈希值
4. HashMap的扩容过程是怎样的?1.7和1.8有什么区别?
标准回答:
- 共同:当元素个数size达到扩容阈值(容量×负载因子0.75)时,扩容为原容量的2倍
- 1.7扩容:
- 创建新数组,长度为原数组2倍
- 遍历原数组每个链表,对每个Entry重新计算哈希值和下标
- 用头插法将Entry插入新数组,导致链表顺序反转
- 多线程并发时易形成环形链表,引发死循环
- 1.8扩容:
- 创建新数组,长度为原数组2倍
- 遍历原数组每个节点,无需重新计算哈希值
- 通过
hash & oldCap判断:结果为0则留在原下标,结果为1则移到原下标+原容量 - 用尾插法插入,保持链表原顺序,解决了死循环问题
- 红黑树拆分后节点数≤6时,退化为链表
5. 为什么链表转红黑树的阈值是8,退化为链表是6?
标准回答:
- 阈值8的原因:根据泊松分布,在负载因子0.75的情况下,链表长度达到8的概率约为0.00000006,几乎不可能发生。因此只有在极端哈希冲突情况下才会转换为红黑树,平衡了时间和空间开销(红黑树节点占用空间比普通Node大)
- 阈值6的原因:设置6-8的缓冲区间,避免频繁在链表和红黑树之间转换。当节点数为6时,链表的平均查找长度(3.5)与红黑树的平均查找长度(log₂6≈2.6)差距不大,但红黑树有额外的空间和维护开销,此时退化为链表更划算
6. HashMap为什么线程不安全?1.7和1.8的并发问题有什么不同?
标准回答:
HashMap没有任何同步机制,多线程并发操作时会出现数据不一致问题:
- JDK1.7:
- 并发扩容时,头插法会导致链表反转,形成环形链表,get操作时陷入死循环
- 并发插入时可能导致数据丢失
- JDK1.8+:
- 解决了环形链表死循环问题(尾插法)
- 并发插入时,若两个线程同时计算到同一个数组下标,后插入的线程会覆盖先插入的线程的数据
- 并发扩容时仍可能出现数据丢失
7. HashMap和HashTable的区别?
标准回答:
| 维度 | HashMap | HashTable |
|---|---|---|
| 线程安全 | 不安全 | 安全(方法加synchronized) |
| 性能 | 高 | 低(全表锁,并发效率差) |
| null键值 | 允许1个null键,多个null值 | 不允许任何null键或null值 |
| 父类 | AbstractMap | Dictionary |
| 迭代器 | 快速失败(fail-fast) | 快速失败 |
| 初始容量 | 16 | 11 |
| 扩容方式 | ×2 | ×2+1 |
8. HashMap和ConcurrentHashMap的区别?
标准回答:
- 线程安全:HashMap不安全;ConcurrentHashMap线程安全
- 锁机制:
- JDK1.7 ConcurrentHashMap:分段锁(Segment),锁粒度是段
- JDK1.8+ ConcurrentHashMap:CAS+synchronized,锁粒度是数组头节点,性能更高
- 性能:HashMap单线程性能最高;ConcurrentHashMap并发性能远高于HashTable
- null键值:HashMap允许null键和null值;ConcurrentHashMap不允许任何null键或null值
- 数据结构:两者1.8后都是数组+链表+红黑树,但ConcurrentHashMap多了辅助扩容机制
9. HashMap的负载因子为什么是0.75?
标准回答:
负载因子是容量和时间开销的平衡:
- 负载因子过高(如1.0):数组填满才扩容,哈希冲突概率大幅增加,链表变长,查找性能下降
- 负载因子过低(如0.5):数组只用一半就扩容,空间利用率低,频繁扩容影响性能
- 0.75是经过大量测试得出的最优值,在空间利用率和查找性能之间取得了最佳平衡。同时0.75×16=12是整数,计算方便。
10. HashMap中key的hashCode()和equals()方法有什么作用?
标准回答:
- hashCode():计算key的哈希值,用于确定元素在数组中的下标。如果两个key的hashCode不同,一定是不同的key
- equals():解决哈希冲突问题。如果两个key的hashCode相同(哈希冲突),需要通过equals()判断是否是同一个key
- 约定:如果两个对象通过equals()返回true,那么它们的hashCode()必须相同;但hashCode()相同,equals()不一定返回true
- 注意:如果重写了equals()方法,必须重写hashCode()方法,否则会导致HashMap无法正确工作
更多推荐


所有评论(0)