数据结构 | HashMap原理

目录

[1 HashMap 原理](#1 HashMap 原理)

[2 自测问题](#2 自测问题)

Q1:HashMap底层有哪些数据结构?

Q2:三种结构分别干什么?

Q3:存储时会有哪些冲突?

Q4:hash冲突常见吗?跟数组长度有关?

Q5:HashMap存储&扩容过程

HashMap存储过程

[为什么hash表中 数组的长度都是 16 32 64 这样的2的幂次方?](#为什么hash表中 数组的长度都是 16 32 64 这样的2的幂次方?)

Q6:关于hashcode()

Q7:HashMap线程安全吗?


1 HashMap 原理

先理解它解决什么问题

普通数组查找要一个个遍历,慢:

找"张三"的电话:李四, 王五, 张三, ... → 从头找,O(n)

HashMap 目标:给任意Key,O(1)时间找到Value。

核心结构

数组 + 链表 + 红黑树

底层是一个数组,每个格子叫"桶"(bucket):

index: 0 1 2 3 4 5 6 7

↓ ↓

"张三"→18 "李四"→25

"王五"→30 ← 同一个桶,用链表连接(hash冲突)

存入过程(put)

map.put("张三", 18);

第一步:算hash值

"张三".hashCode() = 某个整数,比如 123456

第二步:算数组下标

index = hash % 数组长度

= 123456 % 16 = 0 → 放到第0个桶

第三步:放入桶中

如果桶是空的 → 直接放

如果桶有东西 → 比较key是否相同

相同 → 覆盖value

不同 → 链表追加到后面(hash冲突)

查找过程(get)

map.get("张三");

  1. 算"张三"的hash → index=0

  2. 去第0个桶找

  3. 桶里可能有链表,逐个比较key

  4. 找到"张三" → 返回18

三个关键设计

  1. 初始容量 = 16,负载因子 = 0.75

存入元素数量 > 16 × 0.75 = 12 个时

→ 触发扩容:数组扩大为2倍(32)

→ 所有元素重新计算位置(rehash)

为什么0.75?太小(比如0.5)→ 频繁扩容浪费内存;太大(比如1.0)→

冲突多查找慢。0.75是时间和空间的平衡点。

0.75基于泊松分布的数学推导,在该负载因子下哈希冲突概率极低(桶内超过8个元素的概率小于千万分之一), 同时结合HashMap数组长度为2的幂次的特性,容量 × 0.75 = 扩容阈值

16 × 0.75 = 12 ✓ 整数
32 × 0.75 = 24 ✓ 整数
64 × 0.75 = 48 ✓ 整数

兼顾了内存利用率和查找性能。

  1. 链表转红黑树(Java 8新增)

同一个桶的链表长度 ≥ 8 时:

链表 → 红黑树

链表查找:O(n)

红黑树查找:O(log n)

防止极端情况下大量冲突导致性能退化

  1. 数组长度始终是2的幂次(16、32、64...)

正常取模:hash % length → 除法,慢

位运算: hash & (length-1) → 快10倍

length=16时,length-1=15=0b1111

hash & 0b1111 等价于 hash % 16

除法在CPU层面很慢
CPU执行指令的速度差异

加法 + → 1个时钟周期
位运算 & → 1个时钟周期
乘法 × → 3-5个时钟周期
除法 ÷ → 20-90个时钟周期 ← 慢很多

取模 % 本质上是除法(求余数),CPU要做大量计算。

为什么除法慢?

加法:直接进位,电路简单
1011

  • 0101
    ──────
    逐位处理,一步完成

除法:要反复试商,类似手算竖式除法
123456 ÷ 16 = ?
先试:16×7000=112000, 余11456
再试:16×700=11200, 余256
再试:16×16=256, 余0
→ 多轮计算才能得出结果

位运算为什么快

& 是按位与,每一位独立计算,一步完成:

hash = 123456 = 0b11110001001000000
length-1 = 15 = 0b00000000000001111

按位AND:每位对齐,0&任何=0,1&1=1
结果 = 0b00000000000000000 → 只保留最后4位

CPU对每一位同时计算,没有依赖关系,一个时钟周期搞定。


为什么 & (length-1) 等价于 % length

只在 length是2的幂次 时成立:

length = 16 = 0b10000
length-1= 15 = 0b01111 ← 低4位全是1,高位全是0

任何数 & 0b01111 = 保留最后4位
最后4位的范围 = 0~15

而任何数 % 16 的结果也是 0~15

所以两者等价

举例:
hash = 100 = 0b1100100
100 % 16 = 4
100 & 15 = 0b1100100 & 0b0001111 = 0b0000100 = 4 ✓

▎ 取模是除法,CPU需要多轮试商,慢。位运算每位独立同时计算,一步完成,快。HashMap把数组长度设计成2的幂次,就是为了把慢的除法换成快的位运算。

2 自测问题

Q1:HashMap底层有哪些数据结构?

数组+链表+红黑树

数组:主体骨架,每个格子是一个"桶"

01234567...

快速定位(O(1),直接按下标取)

链表:同一个桶里有多个元素时,用链表串起来

2 → "张三"→18 → "王五"→30 → null

红黑树:链表太长(≥8个)时,链表升级为红黑树

查找从O(n)提升到O(log n)

Q2:三种结构分别干什么?

三种结构各司其职:

数组:主体骨架,每个格子是一个"桶"

01234567...

快速定位(O(1),直接按下标取)

链表:同一个桶里有多个元素时,用链表串起来

2 → "张三"→18 → "王五"→30 → null

红黑树:链表太长(≥8个)时,链表升级为红黑树

查找从O(n)提升到O(log n)

Q3:存储时会有哪些冲突?

只有一种冲突:hash冲突(不同的key算出了相同的桶下标)

Q4:hash冲突常见吗?跟数组长度有关?

计算过程:

第1步:key → hashCode()

"张三".hashCode() = 774889

"王五".hashCode() = 890123

第2步:hashCode → 数组下标

下标 = hashCode & (length-1)

= 774889 & 15 = 9 → 张三放桶9

= 890123 & 15 = 11 → 王五放桶11 (不冲突)

如果两个key算出的下标相同 → 冲突

为什么跟数组长度有关:

数组长度=16,只有0~15共16个桶

不管有多少key,最终下标只能落在0~15

key越多,撞在一起的概率越大

数组长度=1024,有1024个桶

同样的key,分散空间更大,冲突更少

输入一组数据 → hash算法 → 固定的值

这说的是hash函数的特性:相同输入永远得到相同输出

"张三" → hash算法 → 774889 (每次都是774889,不会变)

"张三" → hash算法 → 774889 (再算一次,还是一样)

不同输入:

"张三" → 774889

"李四" → 328901 (不同key,不同结果)

这是HashMap能工作的基础------同一个key每次都能算出同一个桶,才能找回数据

总结

put("张三", 18)

hashCode() → 774889

& (16-1) → index = 9

去第9个桶

空桶?→ 直接存

有元素?→ key相同覆盖 / key不同挂链表

链表≥8?→ 升级红黑树

Q5:HashMap存储&扩容过程

HashMap存储过程

通过 key 计算一个 hash 值
将 hash 与数组的最大下标与运算 得到的目标存储下标

为什么hash表中 数组的长度都是 16 32 64 这样的2的幂次方?


初始长度: 16 数组的扩容倍数: 2 倍
1 : 二进制码都是满 1 的形式,方便与运算时 散射的范围可以覆盖数组的 每个下标
1111 & 1010 = 1010
1111 & 1100 = 1100 0000-1111
1010 & 1010 = 1010 1000 1010 0010 0000
1010 & 1100 = 1000
1010 & 1011 = 1010

核心原因:length-1 的二进制必须全是1

length = 16,length-1 = 15 = 0b 1111 ← 全是1 ✓
length = 32,length-1 = 31 = 0b 11111 ← 全是1 ✓
length = 64,length-1 = 63 = 0b 111111 ← 全是1 ✓

length = 10,length-1 = 9 = 0b 1001 ← 不全是1 ✗

为什么全是1好?

全是1的掩码,& 运算的结果能覆盖 0~length-1 每一个值:

1111 & 任意数 → 结果可以是 0000~1111(0到15,16种)

1001 & 任意数 → 结果只能是 0000、0001、1000、1001(只有4种!)
大量下标永远不会被用到,浪费严重,冲突剧增

1111 & 1010 = 1010 = 10 ✓ 在0~15范围内
1111 & 1100 = 1100 = 12 ✓ 在0~15范围内

1010 & 1010 = 1010 = 10 ✗ 部分结果会消失
1010 & 1100 = 1000 = 8
1010 & 1011 = 1010 = 10
→ 1001、0110等下标永远算不到,分布不均匀
2: 扩容时,只会多判断一位二进制码,分成两根链表,降低迁移时的时间 复杂度。
长度 16
1111 & 1110 0101 = 0101 5
1111 & 1011 0101 = 0101 5
长度 32 最大下标 31
1 1111 & 1100 0101 = 0 0101 5
1 1111 & 1011 0101 = 1 0101 16+5
长度 64 最大下标 63
1 1111 & 1101 0101 = 01 0101 21
1 1111 & 1011 0101 = 11 0101 32+21
扩容到 64 之后
11 1111 & 1101 0101 = 01 0101 21
11 1111 & 1011 0101 = 11 0101 32+21
扩容时只多判断一位(这是精髓)

这是 Java 8 HashMap 扩容的核心优化。

先看长度16的情况:

length=16,掩码 = 1111

两个元素,hashCode分别是:
A: 1110 0101
B: 1011 0101

计算下标:
1111 & 1110 0101 = 0101 = 5 → A在桶5
1111 & 1011 0101 = 0101 = 5 → B也在桶5(冲突,同一条链表)

扩容到32:

length=32,掩码 = 1 1111(多了一位)

重新计算:
1 1111 & 1110 0101 = 0 0101 = 5 → A还在桶5
1 1111 & 1011 0101 = 1 0101 = 21 → B去了桶21(16+5)

关键观察:

原来同在桶5,扩容后分开了:
A → 桶5 (位置不变)
B → 桶21 (= 原位置5 + 旧长度16)

怎么判断去哪个桶?
只看 hashCode 的第5位(新增的那一位):
A: 1110 0101 → 第5位是 0 → 留在原桶(5)
B: 1011 0101 → 第5位是 1 → 去新桶(5+16=21)

扩容到64,掩码 = 11 1111

11 1111 & 1101 0101 = 01 0101 = 21
11 1111 & 1011 0101 = 11 0101 = 53(32+21)

看第6位:
1101 0101 → 第6位是 0 → 留在桶21
1011 0101 → 第6位是 1 → 去桶53(21+32)

每次扩容,只需要看 hashCode 多出来的那一位是0还是1:

是0 → 位置不变
是1 → 位置 = 原位置 + 旧长度

Q6:关于hashcode()

Java 所有类都继承自 Object,Object 里有默认的 hashCode():

public class Object {

public native int hashCode(); // native = 调用C++底层实现

}

你写的任何类,不管有没有写 hashCode(),都自动有这个方法。

默认 hashCode() 怎么计算 默认实现是根据对象的内存地址计算出一个整数:

Object obj = new Object();

obj.hashCode(); // 比如返回 1829164700(每次new出来可能不同)

同一个对象每次调用结果相同,不同对象结果不同。

String 重写了 hashCode()

java 复制代码
 // String 的 hashCode 源码:
  public int hashCode() {
      int h = 0;
      for (char c : value) {
          h = 31 * h + c;   // 每个字符都参与计算
      }
      return h;
  }

举例:

"ab" 的计算过程:

h = 0

h = 31 × 0 + 'a' = 97

h = 31 × 97 + 'b' = 3105

→ hashCode = 3105

"ab" 不管在哪个对象,结果永远是 3105

这就是为什么:

String a = new String("ab");

String b = new String("ab");

a.hashCode() == b.hashCode() // true,内容相同结果相同

a == b // false,不是同一个对象

为什么乘以 31

31 = 2⁵ - 1

乘以31可以用位运算代替,更快:

n * 31 = (n << 5) - n

同时31是奇素数,用它做乘数能让hash值分布更均匀,减少冲突。

HashMap 对 hashCode 再加工

拿到 hashCode 后,HashMap 不直接用,还要再做一步扰动:

java 复制代码
 // HashMap 源码:
  static final int hash(Object key) {
      int h = key.hashCode();
      return h ^ (h >>> 16);   // 高16位 异或 低16位
  }

为什么?

hashCode = 0b 1010 1100 0011 1111 | 1001 0110 0111 0001

高16位 | 低16位

& (length-1) 只看低位,高位完全浪费:

0b...1001 0110 0111 0001

& 0b...0000 0000 0000 1111 (length=16时只看最后4位)

高16位信息完全没参与,不同key可能低位一样 → 冲突多

扰动后:高16位混入低16位

让更多信息参与最终下标计算 → 冲突更少

什么时候需要自己重写 hashCode?

当用自定义对象做 key 时

java 复制代码
  // 自定义类
  class User {
      String name;
      int age;
  }

  User a = new User("张三", 18);
  User b = new User("张三", 18);

  // 默认hashCode基于内存地址:
  a.hashCode() ≠ b.hashCode()  // 两个不同对象,地址不同

  map.put(a, "value");
  map.get(b);  // 找不到!因为hashCode不同,去了不同的桶

重写后:

java 复制代码
 @Override
  public int hashCode() {
      return Objects.hash(name, age);  // 根据内容计算
  }

  // 现在:
  a.hashCode() == b.hashCode()  // 内容相同,结果相同
  map.get(b);  // 能找到 ✓

默认 hashCode() → 基于内存地址,不同对象不同

String hashCode() → 基于字符内容,内容相同则相同(已重写)

自定义类 → 需要自己重写,否则用内存地址,当key时会出问题

HashMap 额外做了扰动(^ h>>>16),让hash分布更均匀

Q7:HashMap线程安全吗?

▎ 不安全。多线程同时put,可能导致数据丢失。Java 7还会出现死循环(链表成环)。

▎ 线程安全的替代:ConcurrentHashMap(分段锁/CAS,性能好)或 Hashtable(全表加锁,性能差,已淘汰)。

相关推荐
Dillon Dong3 小时前
【风电控制】TI TMS320F28379D 双CPU架构解析与任务分布设计
嵌入式硬件·算法·变流器·风电控制
一尘之中8 小时前
从C语言底层设计到系统架构评估:软件架构知识体系全景
学习·系统架构·ai写作
小羊在睡觉8 小时前
力扣84. 柱状图中最大的矩形
后端·算法·leetcode·golang·go
3DVisionary8 小时前
蓝光三维扫描:医疗制造的精度焦虑怎么解
人工智能·算法·制造·蓝光三维扫描·医疗制造·三维检测·义齿检测
好评笔记9 小时前
机器学习面试八股——常用损失函数
人工智能·深度学习·算法·机器学习·校招
weixin_468466859 小时前
全局与局部注意力机制新手实战指南
人工智能·python·深度学习·算法·自然语言处理·transformer·注意力机制
_日拱一卒9 小时前
LeetCode:994腐烂的橘子
java·数据结构·算法·leetcode·深度优先
星夜夏空999 小时前
FreeRTOS学习(4)——内存映射
数据库·学习·mongodb
珂朵莉MM10 小时前
第七届全球校园人工智能算法精英大赛-算法巅峰赛产业命题赛第3赛季优化题--束搜索
人工智能·算法
不羁的木木10 小时前
ArkWeb实战学习笔记05-综合实战:构建混合应用
笔记·学习·harmonyos