当前位置: 首页 > news >正文

HashMap 的底层结构详解:原理、put和get示例


HashMap 的底层结构详解

在 Java 中,HashMap 是基于哈希表实现的键值对存储结构,其核心设计目标是高效的数据存取(理想情况下时间复杂度为 O(1))。以下是其底层结构的详细解析:


1. 基本结构:数组 + 链表/红黑树

HashMap 的底层是一个 Node<K,V>[] table 数组,每个数组元素称为 桶(Bucket)。每个桶中存储以下两种数据结构之一:

  • 链表:默认结构,用于解决哈希冲突(同一哈希值的键值对按链表顺序存储)。
  • 红黑树(Java 8+):当链表长度超过阈值(默认为 8)时,链表转换为红黑树,以提高查询效率。
// HashMap 的节点定义(链表节点)
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;    // 哈希值
    final K key;
    V value;
    Node<K,V> next;    // 指向下一个节点的指针
}

2. 哈希函数与索引计算

HashMap 通过哈希函数将键(Key)映射到数组索引,具体步骤如下:

  1. 计算键的哈希值
    int hash = key.hashCode();  // 调用键的 hashCode() 方法
    
  2. 扰动函数优化
    Java 8 通过高 16 位异或低 16 位,减少哈希冲突。
    hash = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    
  3. 计算桶索引
    index = (table.length - 1) & hash;  // 等价于 hash % table.length(当 length 是 2 的幂时)
    

示例

  • 假设 table.length = 16(二进制 10000),则 table.length - 1 = 15(二进制 1111)。
  • 哈希值 hash = 356(二进制 101100100),则 index = 356 & 15 = 4

3. 哈希冲突处理

当不同键的哈希值映射到同一索引时,称为 哈希冲突HashMap 采用 链地址法 解决冲突:

  • 链表模式:新节点插入链表尾部(Java 8 前为头部)。
  • 红黑树模式(Java 8+):当链表长度 ≥ 8 且数组长度 ≥ 64 时,链表转为红黑树;当节点数 ≤ 6 时,红黑树退化为链表。

链表转红黑树的阈值

  • 链表长度 ≥ TREEIFY_THRESHOLD(默认 8)。
  • 数组长度 ≥ MIN_TREEIFY_CAPACITY(默认 64)。

4. 扩容机制(Rehashing)

HashMap 的容量和负载因子(Load Factor)决定了扩容行为:

  • 初始容量:默认 16DEFAULT_INITIAL_CAPACITY)。
  • 负载因子:默认 0.75DEFAULT_LOAD_FACTOR)。
  • 扩容阈值阈值 = 当前容量 × 负载因子。当元素数量超过阈值时,触发扩容。

扩容流程

  1. 新容量为旧容量的 2 倍(保证容量始终为 2 的幂)。
  2. 重新计算所有键的哈希值和索引,分配到新数组中。
  3. 链表或红黑树节点按新索引重新分布。

示例

  • 初始容量 16,负载因子 0.75,阈值 12。当插入第 13 个元素时,触发扩容至 32。

5. 红黑树的优势
  • 时间复杂度优化:链表的查询时间复杂度为 O(n),而红黑树为 O(log n)。
  • 平衡性:红黑树通过颜色标记和旋转操作,保持近似平衡,插入/删除/查找操作高效。
  • 适用场景:哈希冲突严重时(如大量键的哈希值相同),红黑树显著提升性能。

6. 线程安全性

HashMap 非线程安全,多线程并发修改可能导致数据不一致或死循环(Java 7 链表头插法问题)。

  • 替代方案
    • ConcurrentHashMap:分段锁或 CAS 机制实现线程安全。
    • Collections.synchronizedMap():包装为同步集合。

7. 核心参数与默认值

参数默认值说明
DEFAULT_INITIAL_CAPACITY16初始容量(必须是 2 的幂)
DEFAULT_LOAD_FACTOR0.75负载因子(空间与时间的权衡)
TREEIFY_THRESHOLD8链表转红黑树的阈值
UNTREEIFY_THRESHOLD6红黑树退化为链表的阈值
MIN_TREEIFY_CAPACITY64链表转红黑树的最小数组长度

8. 性能优化建议

  1. 合理初始化容量:避免频繁扩容。
    // 预计存储 1000 个元素,负载因子 0.75,初始容量设为 2048(2^11)
    Map<String, Object> map = new HashMap<>(2048);
    
  2. 优化键的 hashCode():减少哈希冲突。
  3. 避免在多线程环境中直接使用 HashMap:选择 ConcurrentHashMap

9. 插入示例(put)

我们通过一个具体的示例逐步分析 HashMap 的底层结构变化,包括 数组、链表、红黑树 的形态。假设初始容量为 8,负载因子 0.75,阈值 6(8 * 0.75 = 6),当元素数量超过 6 时触发扩容。以下是详细步骤:

示例代码

HashMap<String, Integer> map = new HashMap<>(8);  // 初始容量 8
map.put("a", 1);
map.put("b", 2);
map.put("c", 3);
map.put("d", 4);
map.put("e", 5);
map.put("f", 6);
map.put("g", 7);     // 触发扩容
map.put("h", 8);
map.put("i", 9);
map.put("j", 10);
map.put("k", 11);    // 触发链表转红黑树

步骤 1:初始化数组(容量 8)

Node<K,V>[] table = new Node[8];

数组初始状态(索引 0~7 均为空):

索引: 0   1   2   3   4   5   6   7
     ↓   ↓   ↓   ↓   ↓   ↓   ↓   ↓
    null null null null null null null null

步骤 2:插入前 6 个元素(不触发扩容)

2.1 插入 a=1
  • 计算 a 的哈希值:假设 hash("a") = 97
  • 计算索引:index = (8-1) & 97 = 1(97 二进制是 11000017111,按位与后为 1)。
  • 插入到索引 1:
索引: 0   1       2   3   4   5   6   7
     ↓   ↓       ↓   ↓   ↓   ↓   ↓   ↓
    null [a=1]  null null null null null null
2.2 插入 b=2
  • 假设 hash("b") = 98,索引 98 & 7 = 2
索引: 0   1       2       3   4   5   6   7
     ↓   ↓       ↓       ↓   ↓   ↓   ↓   ↓
    null [a=1]  [b=2]   null null null null null
2.3 插入 c=3f=6
  • 假设哈希值均匀分布到不同索引:
索引: 0   1       2       3   4   5   6   7
     ↓   ↓       ↓       ↓   ↓   ↓   ↓   ↓
    null [a=1]  [b=2]  [c=3][d=4][e=5][f=6] null

此时元素数量 6,达到阈值 6,但尚未触发扩容(Java 中实际在插入第 7 个元素时扩容)。


步骤 3:插入 g=7(触发扩容)

3.1 扩容前
  • 插入 g=7,假设 hash("g") = 103,索引 103 & 7 = 7
索引: 0   1       2       3   4   5   6   7
     ↓   ↓       ↓       ↓   ↓   ↓   ↓   ↓
    null [a=1]  [b=2]  [c=3][d=4][e=5][f=6][g=7]
  • 元素数量 7 > 6,触发扩容。
3.2 扩容后
  • 新容量为 16(旧容量 8 * 2)。
  • 重新计算所有元素的索引(index = hash & 15)。

假设哈希值重新计算后的分布:

索引: 0   1   2   3   4   5   6   7   8   9   10  11  12  13  14  15
     ↓   ↓   ↓   ↓   ↓   ↓   ↓   ↓   ↓   ↓   ↓   ↓   ↓   ↓   ↓   ↓
    null [a=1][b=2] ... [d=4] ... [f=6][g=7] ... ... ... ... ... ... 

(具体分布取决于哈希值,这里假设均匀分布)


步骤 4:插入 h=8k=11(触发链表转红黑树)

4.1 插入 h=8j=10

假设 h=8i=9j=10 的哈希值均映射到索引 1:

索引 1:
↓
[a=1] → [h=8] → [i=9] → [j=10]  // 链表长度 4
4.2 插入 k=11
  • 假设 k=11 的哈希值也映射到索引 1,链表长度变为 5
索引 1:
↓
[a=1] → [h=8] → [i=9] → [j=10] → [k=11]  // 链表长度 5
  • 当链表长度 ≥ TREEIFY_THRESHOLD(默认 8)且数组长度 ≥ MIN_TREEIFY_CAPACITY(默认 64)时,链表转为红黑树。
  • 当前数组长度为 16 < 64,不会转红黑树。
4.3 继续插入更多元素到同一索引

假设继续插入 3 个元素到索引 1,链表长度达到 8

索引 1:
↓
[a=1] → [h=8] → [i=9] → [j=10] → [k=11] → [l=12] → [m=13] → [n=14]

此时数组长度为 16 < 64,仍不会转红黑树。

4.4 触发数组扩容到 64
  • 当元素数量超过 16 * 0.75 = 12 时,数组扩容到 32 → 64。
  • 再次插入元素到索引 1,链表长度 ≥ 8,且数组长度 ≥ 64,触发链表转红黑树:
索引 1:
↓
红黑树节点(原链表已转换)

HashMap 结构总结

操作数组长度链表/红黑树状态
初始化8空数组
插入前 6 个元素8各索引分布链表(长度 1)
插入第 7 个元素16扩容后重新分布链表
插入多个哈希冲突的元素16 → 64链表长度增长,最终转为红黑树(条件满足时)

关键点图解

1. 链表结构
索引 1:
↓
Node1("a",1) → Node2("h",8) → Node3("i",9) → ... 
2. 红黑树结构
索引 1:
↓
          Node2("h",8)
         /            \
Node1("a",1)        Node4("j",10)
                        \
                        Node5("k",11)

注意事项

  1. 哈希冲突:不同键的哈希值映射到同一索引时形成链表。
  2. 扩容代价:扩容需重新计算哈希和复制数据,初始化时应预估容量。
  3. 红黑树转换条件:链表长度 ≥8 数组长度 ≥64。
  4. 退化条件:红黑树节点数 ≤6 时退化为链表。

通过这个示例,可以直观看到 HashMap 的动态变化过程,理解其高性能背后的设计机制。


10. 查询示例(get)

当调用 map.get("i") 时,HashMap 会按照以下步骤查找键 "i" 对应的值。我们基于你提供的示例(初始容量为 8,插入多个键后触发扩容和可能的链表转红黑树)逐步分析:

步骤 1:计算键 "i" 的哈希值

  1. 调用 hashCode()
    首先调用 "i".hashCode() 计算原始哈希值。假设 "i".hashCode() 返回 105
  2. 扰动函数优化(Java 8+):
    为了减少哈希冲突,HashMap 会对哈希值进行高 16 位和低 16 位的异或操作:
    hash = 105 ^ (105 >>> 16);  // 示例值,实际值可能不同
    
    假设最终哈希值为 hash = 123456(仅示例,具体值取决于实际扰动结果)。

步骤 2:确定桶的索引

  1. 计算索引
    根据当前数组长度 n,计算索引:
    index = (n - 1) & hash;
    
    • 扩容前(数组长度为 8):
      index = 7 & 123456 = 0;  // 假设计算后索引为 0
      
    • 扩容后(数组长度为 16):
      index = 15 & 123456 = 1;  // 假设计算后索引为 1
      
    • 最终索引取决于当前数组长度。假设此时数组已扩容到 16,索引为 1。

步骤 3:遍历链表或红黑树

假设在插入过程中,键 "a""h""i""j""k" 的哈希值均映射到索引 1,且链表已转换为红黑树(当链表长度 ≥8 且数组长度 ≥64 时)。

3.1 查找逻辑
  1. 定位到索引 1
    HashMap 会直接访问数组的索引 1。
  2. 判断数据结构
    • 如果该位置是链表,则从头节点开始遍历,逐个比较键是否等于 "i"
    • 如果该位置是红黑树,则调用红黑树的查找方法,根据键的哈希值和 equals() 方法匹配节点。
3.2 具体操作(以红黑树为例)
  1. 比较哈希值
    从红黑树的根节点开始,比较 "i" 的哈希值与当前节点的哈希值:

    • 如果哈希值小于当前节点,向左子树查找。
    • 如果哈希值大于当前节点,向右子树查找。
    • 如果哈希值相等,则进一步调用 equals() 方法比较键值。
  2. 调用 equals()
    当哈希值匹配时,调用 "i".equals(node.key) 确认键是否一致。

    • 若匹配,返回对应的值。
    • 若不匹配,继续遍历左右子树。

示例查找流程

假设索引 1 的红黑树结构如下(简化表示):

          [h=8] (hash=...)
         /      \
   [a=1]        [i=9]
                /    \
           [j=10]   [k=11]
  1. 从根节点 h=8 开始
    • "i" 的哈希值可能大于 h=8 的哈希值,向右子树查找。
  2. 找到节点 i=9
    • 哈希值匹配,调用 "i".equals(node.key),确认键一致。
  3. 返回 i=9 的值
    最终返回 9

关键点总结

步骤操作
计算哈希值扰动函数优化减少冲突,得到最终哈希值。
确定索引通过 (n-1) & hash 计算桶的位置,依赖当前数组长度。
遍历数据结构链表(O(n))或红黑树(O(log n))查找,依赖哈希冲突的严重程度。
键匹配先比较哈希值,再调用 equals() 确认键一致性。

注意事项

  1. 哈希冲突的影响
    哈希冲突越多,链表或红黑树的遍历成本越高,因此设计良好的 hashCode() 方法至关重要。
  2. 扩容与性能
    扩容会导致所有键重新哈希,但能减少后续操作的冲突概率。
  3. 红黑树优化
    当哈希冲突严重时(如大量键映射到同一索引),红黑树将查找时间从 O(n) 优化到 O(log n)。

通过这个示例,你可以看到 HashMap 如何高效地通过哈希计算、索引定位和数据结构遍历,快速找到目标键值对。


总结

HashMap 的底层结构是 数组 + 链表/红黑树,通过哈希函数快速定位键值对,使用链地址法解决冲突,并通过动态扩容和红黑树优化性能。理解其内部机制,有助于在实际开发中合理使用并规避潜在问题(如内存泄漏、线程安全问题)。

相关文章:

  • PMP项目管理—资源管理篇—3.获取资源
  • 文本处理Bert面试内容整理-BERT的基本原理是什么?
  • 03.购物单
  • IO进程思维导图和练习题
  • 防火墙虚拟系统
  • DeepSeek:大模型领域的创新力量
  • 自学嵌入式第27天------TCP和UDP,URL爬虫
  • 物联网感应层数据采集器实现协议转换 数据格式化
  • 在日常生活、工作中deepseek能帮我们解决哪些问题
  • 【JavaScript—前端快速入门】JavaScript 对象与函数
  • 计算机常用单词
  • 需求管理工具选型指南:Jama Connect +Jira vs Word/Excel+Jira
  • 网络安全检查漏洞内容回复 网络安全的漏洞
  • ArcGIS操作:13 生成最小外接矩阵
  • 基于STM32的环境监测系统(自制蓝牙APP)
  • Lua脚本使用教学指南:与Spring Boot项目集成示例
  • 动态内存分配
  • 网络编程——http
  • Linux网络 NAT、代理服务、内网穿透
  • 25年第四本【认知觉醒】
  • 中小网站公司做的推广怎么样/关键词你们都搜什么
  • 网站不备案做seo没用/seo搜索优化工具
  • 广东企业微信网站建设/培训课程设计方案
  • wordpress 评论倒叙/长沙seo培训班
  • wordpress的文章多重筛选/kj6699的seo综合查询
  • 建设网站后如何做后台/百度关键词优化查询