HashMap底层采用数组+链表/红黑树结构,通过hashCode()与位运算定位下标,哈希冲突时链表存储,长度≥8且数组≥64时转红黑树;null键固定存table[0];扩容触发rehash;非线程安全,多线程需用ConcurrentHashMap。

HashMap底层用数组+链表/红黑树存储键值对
HashMap不是简单把key和value塞进一个盒子,而是先算key.hashCode(),再通过位运算((n - 1) & hash)映射到数组下标。这个数组叫table,初始长度是16,每次扩容翻倍。
真正存数据的是Node节点,每个节点含hash、key、value、next四个字段。当多个key算出相同下标(哈希冲突),就用next指针连成链表;如果链表长度≥8且table长度≥64,链表会转成红黑树,避免退化成O(n)查找。
- 注意
hashCode()必须与equals()保持一致:两个equals()为true的对象,hashCode()必须相等 -
null键被特殊处理——它总被放在table[0]位置,且只允许一个 - 扩容时所有节点要重新计算下标,所以
put()在触发扩容时会有明显延迟
put()过程包含哈希计算、寻址、冲突处理、扩容判断
调用map.put(k, v)时,实际执行的是putVal()方法,核心步骤如下:
- 如果
table为空或长度为0,先调用resize()初始化数组 - 根据
key的hashCode()和当前table.length算出数组索引i - 若
table[i]为空,直接新建Node放入;否则遍历该位置的链表或树 - 遇到相同
key(hash相等且equals()为true),覆盖旧value并返回旧值 - 插入新节点后,若链表长度达到8且
table.length >= 64,调用treeifyBin()转红黑树 - 最后判断是否需扩容:
size + 1 > threshold(阈值 = 容量 × 负载因子,默认0.75)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}get()为什么快?因为平均时间复杂度是O(1)
get()不遍历整个Map,只做三件事:算hash → 定位table下标 → 查该位置的链表或树。只要哈希分布均匀、负载因子合理,绝大多数情况一次就能拿到。
立即学习“Java免费学习笔记(深入)”;
- 如果
key为null,直接查table[0],不参与哈希计算 - 如果该位置是红黑树,走树的
find()逻辑,最坏O(log n),仍远好于O(n) - 如果自定义
key类没重写hashCode()或equals(),get()可能永远找不到——哪怕key内容一样
线程不安全的具体表现:put操作可能造成死循环或数据丢失
HashMap没加锁,多线程同时put()可能破坏内部结构。最经典的问题是JDK 7中扩容时的“头插法”导致链表成环,get()陷入无限循环;JDK 8改用尾插法,虽不再死循环,但仍有数据覆盖、丢失、size不准等问题。
- 不要在多线程环境中直接使用
HashMap,除非只读 - 需要线程安全时,优先选
ConcurrentHashMap,而不是Collections.synchronizedMap()(后者锁整张表,性能差) - 如果只是临时局部变量、单线程场景,
HashMap完全够用,别为“安全”过早引入复杂性
真正容易被忽略的,是hashCode()实现质量对性能的影响——哪怕你用的是标准类,如果key对象的hashCode()总是返回同一个数,所有元素都会挤在同一个桶里,HashMap就退化成了链表。










