最近在一个业务接口的性能优化里,我发现一个很典型的问题:一个List.contains()调用,数据量从几千涨到几十万之后,接口耗时直接从几十毫秒飙到好几秒。翻了一下代码,罪魁祸首是线性查找——每次判断元素是否存在都要全表扫一遍。换用HashMap后,耗时瞬间掉回毫秒级。
这就是哈希表(Hash Table)的价值:用近乎O(1)的平均时间复杂度完成查找、插入和删除。Java里最常见的实现就是HashMap,但很多人只停留在“会用map.put()和map.get()”,至于底层为什么这么快、什么时候会退化、为什么有的key明明放得进去却取不出来,这些坑我几乎都在项目里踩过。
这篇文章就从哈希表的本质开始,结合HashMap的源码实现,再带大家手写一个入门级的哈希表,最后聊聊实际开发中必须避开的几个关键问题。不管是准备面试,还是想在业务里真正用好哈希表,这篇文章都能提供一些参考。
1. 从一次查找性能优化说起:哈希表到底解决了什么问题
在没有哈希表之前,我们最常用的查找结构是数组和链表。数组用下标访问是O(1),但前提是你知道下标。更常见的场景是“判断某个值是否在集合里”,这时候只能遍历,时间复杂度是O(n)。链表也一样,查找一个节点得从头一路走过去。
哈希表的核心思路,是把“要查找的值”本身直接映射成“存储位置的下标”。这个映射函数就叫哈希函数。如果两个不同的值映射到了同一个下标,就叫哈希冲突。所以哈希表的设计目标,就是设计一个好的哈希函数尽量分散数据,同时准备好冲突处理方案,保证在最差情况下也不会差得离谱。
我当年在学校学数据结构时,老师用了一个很形象的类比:你去一栋楼找人,如果不知道房间号,只能挨个敲门问,这就是线性查找;如果知道“姓张的住3层”“姓李的住5层”这种规律,就能直接找到对应楼层,这就是哈希。当然,如果一层楼住了太多张姓的人,你还是得在楼层内挨个找,这就是冲突。
哈希表的性能特征可以这样总结:
- 平均情况下,插入、查找、删除都是O(1);
- 最坏情况下,如果所有元素都冲突成一条链,查找会退化到O(n);
- 在Java 8之后的
HashMap中,当链表长度超过阈值时还会转成红黑树,最坏情况也能控制在O(log n)。
这就是哈希表能成为编程语言内置数据结构的原因。Java里的HashMap、HashSet、Hashtable,都是基于哈希表的思想实现的。理解了哈希表,就能理解它们的底层行为差异。
2. HashMap源码拆解:Java哈希表的核心机制
2.1 哈希函数与扰动算法
HashMap中保存一个键值对时,先根据key计算哈希值,再通过哈希值确定它应该放在数组的哪个桶(bucket)里。key的hashCode()方法返回的是一个int,理论上范围很大,但底层数组的初始容量只有16,所以必须把哈希值压缩到数组长度范围内。
HashMap中默认使用(n - 1) & hash来定位桶下标,其中n是数组长度,hash是对hashCode()做了一定处理后的值。为什么用位运算而不用取模?因为当n是2的幂次方时,hash % n等价于(n - 1) & hash,按位与比取模速度更快。这也是HashMap要求数组容量必须是2的幂次方的原因之一。
但只看hashCode()的原始值是不够的。如果一个对象重写了hashCode(),返回的都是同一个数字,比如固定值1,那么所有元素都会落在同一个桶里,形成链表甚至红黑树,哈希表就退化了。所以Java在得到hashCode()后,还会做一次扰动:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }这段代码把哈希值的高16位和低16位做了异或,目的是让高位的特征也能参与到低位运算中。因为数组长度通常不大,定位桶时主要看低位;如果低位相同而高位不同,不扰动的话就会大量冲突。扰动之后,即使hashCode()实现得不够好,也能在一定程度上打散数据。
我见过不少刚接触源码的人问:直接返回key的hashCode()然后与容量做位与不行吗?理论上可以,但面对糟糕的hashCode()实现,比如有人写业务类时直接返回一个固定值,冲突会特别严重。扰动算法是一个低成本高收益的防御手段,它不解决根本问题,但能让哈希分布更均匀。
2.2 冲突解决:链表与红黑树
当两个不同的key定位到同一个桶时,HashMap的做法是拉链法——在这个桶上挂一个链表,新节点插到链表尾部(Java 8之前是头插,Java 8改成尾插避免扩容时链表成环的问题)。
链表的查找复杂度是O(n)。如果某个桶中的元素非常多,比如所有key的哈希都相同,那么查询时就要遍历一整条链表。Java 8对这个场景做了优化:当链表长度达到8,并且数组容量不小于64时,链表会转换成红黑树。红黑树的查找复杂度是O(log n),能有效防止极端哈希冲突下的性能崩塌。
为什么阈值取8而不是10或者16?这是因为在理想哈希函数下,链表中节点数量服从泊松分布,链长达到8的概率已经极低,大约是千万分之六。这个数字是经过统计学计算后选定的,可以理解为“在正常情况下几乎不可能触发,在极端情况下又能兜底”。
还有一点要注意:从链表转成红黑树,并不只是长度到8就行。如果数组容量还不到64,会优先选择扩容,而不是树化。因为扩容会让所有元素重新分布,很多冲突也会随之化解。树化本身有额外的维护成本,能用扩容解决的事,就不急着上树。
2.3 扩容与负载因子
HashMap维护着一个很重要的参数:负载因子(loadFactor),默认是0.75。它的含义是:当数组中已占用的桶数量达到容量乘以负载因子时,就触发扩容。
比如初始容量16,负载因子0.75,那么当元素个数达到12时,就会扩容到32。扩容之后,所有元素都要重新计算桶位置,这个过程叫resize()。重新哈希的代价是O(n),所以频繁扩容会严重影响写入性能。
为什么负载因子选0.75,而不是1或者0.5?这是一个时间和空间上的权衡。
- 负载因子越大,例如1.0,空间利用率高,但冲突概率也越高,查找性能会下降。
- 负载因子越小,例如0.5,冲突少,但数组中大量槽位空着,浪费内存,而且更容易触发扩容。
0.75是JDK在大量测试后选择的一个平衡点。在日常开发中,如果你能提前估算数据规模,创建HashMap时指定初始容量,可以减少扩容次数,这一点在写入量大的场景下提升非常明显。
扩容的倍数默认是2。为什么必须是2?因为数组长度是2的幂时,(n - 1) & hash才能等价于取模,并且扩容后元素在新数组中的位置,要么保持原索引,要么索引加上一个旧容量。这一点让resize()过程变得非常高效,不需要重新调用hashCode(),只需要看新增的那一位是0还是1。这是HashMap能保持高性能的重要细节。
2.4 为什么HashMap线程不安全
很多人在并发场景直接使用HashMap,结果在扩容时出现CPU占用飙满或者数据丢失。原因并不神秘:HashMap的方法没有加锁,多个线程同时修改结构时,会产生竞态条件。
在Java 7的头插法实现中,并发扩容时可能出现环形链表,一旦get某个不存在的key,就会进入死循环。Java 8改成尾插后,环形链表的场景大大减少,但数据覆盖、size计数不准确、resize过程中丢元素等问题依然存在。并发场景下的正确选择是ConcurrentHashMap:它通过锁分离(比如用CAS加synchronized控制桶节点)来保证线程安全,而不是把整张表锁住。
我早年在某个项目中就踩过这个坑:一个缓存组件直接用HashMap,在并发刷新时偶尔出现key查不到,查了半天才发现是HashMap的put方法被两个线程同时执行,后写的覆盖了先写的正确数据。换成ConcurrentHashMap后问题立刻消失。
3. 从零手写一个哈希表:核心逻辑逐步实现
理解了HashMap的原理,最好的验证方式是自己实现一个简化版。这里我带着大家写一个最精简但五脏俱全的哈希表,支持put、get、remove和自动扩容,冲突处理用拉链法。
3.1 设计一个最简单可用的哈希表
先定义内部节点类,保存key、value和指向下一个节点的引用:
static class Entry<K, V> { final K key; V value; Entry<K, V> next; Entry(K key, V value, Entry<K, V> next) { this.key = key; this.value = value; this.next = next; } }主类里维护一个Entry数组,以及元素个数、容量、负载因子三个字段:
public class SimpleHashMap<K, V> { private Entry<K, V>[] table; private int size; private int capacity; private float loadFactor; private int threshold; public SimpleHashMap(int capacity, float loadFactor) { this.capacity = capacity; this.loadFactor = loadFactor; this.threshold = (int) (capacity * loadFactor); this.table = new Entry[capacity]; } public SimpleHashMap() { this(16, 0.75f); } }初始化时我们就根据容量和负载因子算好扩容阈值,这样put时只需要做一次数值比较,不需要每次乘法。
3.2 put方法的完整流程
put的核心逻辑分成四步:
- 对key的哈希值做扰动,定位桶下标;
- 遍历桶内链表,如果找到相同key,更新value并返回旧值;
- 如果没有找到,在链表头部插入新节点,或者尾部插入;
- 插入后如果
size超过阈值,触发扩容。
代码如下:
public V put(K key, V value) { if (key == null) { return putForNullKey(value); } int hash = hash(key); int index = indexFor(hash, capacity); Entry<K, V> e = table[index]; while (e != null) { if (e.key.equals(key)) { V oldValue = e.value; e.value = value; return oldValue; } e = e.next; } addEntry(key, value, index); return null; } private void addEntry(K key, V value, int bucketIndex) { table[bucketIndex] = new Entry<>(key, value, table[bucketIndex]); size++; if (size > threshold) { resize(capacity * 2); } }这里用的是头插法,简化实现。之所以可以头插,是因为我们只处理equals判断,不涉及Java 7的并发扩容问题。在单线程教学场景里,头插代码更简洁。Java 8采用尾插,目的上文说过,是为了避免并发扩容成环,但我们的简化版不讨论并发,所以头插也能跑。
判断key是否相同的条件是e.key.equals(key),而不是e.key == key。否则两个内容相同但引用不同的对象,会被当成不同key,导致同一个逻辑key重复插入。这一点是哈希表实现最容易错的地方。
3.3 get方法与哈希定位
get的过程和put几乎一样,先定位桶,再遍历链表:
public V get(K key) { if (key == null) { return getForNullKey(); } int hash = hash(key); int index = indexFor(hash, capacity); Entry<K, V> e = table[index]; while (e != null) { if (e.key.equals(key)) { return e.value; } e = e.next; } return null; }哈希定位辅助方法:
private int hash(K key) { int h = key.hashCode(); return h ^ (h >>> 16); } private int indexFor(int hash, int capacity) { return (capacity - 1) & hash; }(capacity - 1) & hash能成立的前提是capacity是2的幂次。如果你的容量是20,那么19 & hash不等于hash % 20,可能导致下标越界或大量碰撞。所以在自己实现时,要么强制容量必须是2的幂,要么改用hash % capacity。HashMap选择前者,我们也可以照做,在构造时把传入的非2幂容量修正成最近的上一个2的幂。
3.4 手动扩容的实现
扩容的逻辑是新建一个长度为原来两倍的数组,然后把每个链表上的节点重新哈希到新数组里。重新哈希的公式还是(newCapacity - 1) & hash。因为容量变了,同一节点在新数组中的位置可能发生变化。
private void resize(int newCapacity) { Entry<K, V>[] newTable = new Entry[newCapacity]; for (int i = 0; i < table.length; i++) { Entry<K, V> e = table[i]; while (e != null) { Entry<K, V> next = e.next; int newIndex = indexFor(e.key == null ? 0 : hash(e.key), newCapacity); e.next = newTable[newIndex]; newTable[newIndex] = e; e = next; } } table = newTable; capacity = newCapacity; threshold = (int) (capacity * loadFactor); }这里要注意一个隐藏问题:在把节点移到新数组时,我们用Entry<K, V> next = e.next先把后继节点保存下来,否则一旦改变e.next,原链表就断了。这个细节在面试里也经常被问到。
3.5 手写版本与HashMap的差异
我手写的这个版本和真正的HashMap相比,还有不少简化:
- 没有红黑树。链表过长时,我们的查找会退化成O(n),而
HashMap可以转树; - 没有处理key为null的特殊优化。我上面的代码里
putForNullKey和getForNullKey没有展开,你可以直接让null映射到下标0,或者单独用一个字段存储; - 没有并发控制,只能在单线程环境使用;
- 没有实现
remove,实际上做起来也很简单,遍历链表找到目标后调整前驱节点的next即可。
从学习的角度看,手写一遍的最大价值,是让你真正理解“哈希表为什么快”“扩容为什么贵”“equals和hashCode为什么必须一起重写”。这些认知比背源码更有用。
4. 哈希表实战避坑指南
4.1 equals和hashCode的约定,一个反例
Java中Object类里,equals和hashCode有一个约定:如果两个对象通过equals判断为相等,那么它们的hashCode()必须返回相同的值。反过来不要求,不同的对象也可以有相同哈希值。
我先说一个反面例子。早期我写过一个订单对象作为key,只重写了equals,把订单号相同视为同一个订单,没有重写hashCode。结果两个订单号相同的对象,被放进HashMap时计算出了不同的哈希值,分别落到不同桶里。取数据时用订单号重新构造一个对象,发现containsKey返回false。排查了半天才发现,没有重写hashCode的对象,默认哈希值基于对象的内存地址,两个内容一样的对象根本不会命中同一个桶。
这个问题在“只重写equals没重写hashCode”场景下非常常见。用HashMap做去重、做缓存、做分组时,一致性的前提是这两个方法同时遵守约定。
4.2 为什么重写equals必须重写hashCode
从哈希表的结构上理解,这个约定不是语法要求,而是查找正确性的要求。HashMap先通过哈希值定位桶,再在桶内用equals判断是否相同。如果相同key的哈希值不一致,它会被分到不同的桶,查找时根本不会走进同一个桶去判断equals。
我们来看这个反例的代码:
class Order { String orderId; @Override public boolean equals(Object obj) { if (this == obj) return true; if (!(obj instanceof Order)) return false; return this.orderId.equals(((Order) obj).orderId); } }两个内容相同的Order,因为继承的Object.hashCode()返回不同值,在HashMap中就是两个key。每次查询都得到null。修法很简单,重写:
@Override public int hashCode() { return orderId.hashCode(); }把参与equals的字段也放进hashCode,就能保证相等对象的哈希值必然一致。这也是Objects.hash(orderId)背后的逻辑。
4.3 自定义对象作为key的注意点
另一个更隐蔽的坑是:key对象的哈希值不能随状态改变而改变。如果把一个对象放进HashMap之后,再去修改它参与hashCode的字段,那么下一次查找时,哈希值变了,定位到的桶也会变,原来的key就“找不到了”。
最典型的场景是把对象存入HashSet后修改对象的某个字段。HashSet底层就是HashMap,元素存在key中。修改后元素还在,但是用同一个对象去contains却返回false。正确做法是:
- 尽量使用不可变对象作为key,比如
Integer、String; - 如果必须是自定义对象,要保证参与
hashCode的字段在放入容器后不再变化; - 不要一边修改key的字段一边依赖它在哈希容器中的存在性。
我在实际项目中遇到过类似的缓存丢数据问题。某个缓存key是一个可变DTO,某个定时任务会刷新DTO上的部分字段,刷新后缓存命中率骤降,因为key的哈希变了。后来统一改成把不可变ID作为key,问题才解决。
4.4 并发场景下该用什么
单线程环境用HashMap没问题,多线程读多写少可以用Collections.synchronizedMap,但写入量大时竞争会很激烈。真正的高并发场景推荐ConcurrentHashMap,它在内部把整个哈希表分成了多个桶段,不同桶段的操作可以并发执行,降低锁竞争。
ConcurrentHashMap还有一个重要特性:读操作通常不加锁,靠volatile和CAS保证可见性。这意味着它在高并发读场景下,性能比Hashtable强很多。Hashtable是给所有方法加synchronized,读写都不允许并发,已经属于老古董了,除非是遗留代码否则不应该再用。
另外,ConcurrentHashMap不允许key为null,HashMap允许一个null key。这个差异是因为在并发环境下无法通过get结果判断key是否存在,如果value也是null,会产生歧义。所以设计上干脆禁止null key。
4.5 哈希表常见性能陷阱
除了并发,哈希表还有一个高频性能问题是初始容量设置不当。如果不设置,HashMap默认初始容量16,向里面插入几百万条数据时会经历很多次扩容,每次扩容都重新哈希,开销非常大。更推荐的做法是创建时预估容量。
预估容量的公式,参考com.google.common.collect.Maps.newHashMapWithExpectedSize的逻辑:
int expectedSize = 1000; int capacity = (int) (expectedSize / 0.75f + 1); Map<String, String> map = new HashMap<>(capacity);为什么要加1?因为避免刚好等于阈值时立刻触发扩容。如果你知道实际数据量是1000,直接new HashMap<>(1000),那map在达到1000之前,其实在size大约875时就会扩容,因为阈值是capacity * loadFactor。预先除以负载因子,才能让扩容节奏符合预期。
还有一个陷阱是自定义hashCode()返回固定常数。我见过某些代码为了快速实现,直接让所有对象返回相同哈希值,虽然没问题,但性能会退化到吃不满。如果你确定不会用作key或Set元素,可以这样实现,但一旦放进哈希容器,这会成为灾难。
5. 哈希表的应用场景与调优建议
5.1 去重、缓存、哈希索引
日常开发中哈希表最常见的应用是去重:把数据放入HashSet,利用哈希查找判断是否已存在,时间复杂度从O(n²)降到O(n)。在数据量大的批处理任务里,这种优化效果特别明显。
缓存场景就更典型了。本地缓存、分布式缓存的key-value模型本质就是一个哈希映射。比如在内存中缓存用户信息,用userId作为key,用户对象作为value。哈希表根据key直接定位,不需要遍历全量数据。这也是为什么Redis的哈希结构用哈希表实现的原因。
数据库的哈希索引也是同一思想。对某一列建立哈希索引后,精确匹配查询可以通过哈希值定位到存储位置,不需要走B+树的树形查找。但哈希索引不支持范围查询,因为哈希值本身就是无序的。所以哈希表适合精确匹配场景,范围查询还是交给有序结构。
5.2 初始容量和负载因子的选择
负载因子不是只能有0.75一个选择。如果你明确知道业务中查找频率远高于写入频率,且内存足够,可以把负载因子调小一点,比如0.5,减少冲突概率,换取更快的查询。如果内存紧张,而且写入后很少读取,可以把负载因子调大到0.9甚至1.0,牺牲一点查询性能,节省空间。
但要记住,负载因子调整的同时,扩容阈值也会改变。调小负载因子会让扩容提前触发,如果初始容量设置不足,可能频繁扩容,反而拖慢性能。所以负载因子和初始容量必须一起考虑。
初始容量的设置还有一个细节:HashMap的capacity必须是2的幂,如果你传入比如100,构造方法会把它修正为128。这个修正的算法在源码里是通过一系列无符号右移和按位或完成的,不用我们自己写。但理解这一点有助于避免写出“为什么我设了100,数组长度是128”这类疑问。
5.3 哈希表与其他数据结构的对比
把哈希表和几个常见结构放在一起,可以更好地理解它的定位:
| 数据结构 | 查找复杂度 | 有序性 | 适用场景 |
|---|---|---|---|
| 数组 | O(1)按下标 | 是 | 固定集合、按下标访问 |
| 链表 | O(n) | 是 | 频繁插入删除,但查找少 |
| 二叉搜索树 | O(log n) | 是 | 需要范围查询、排序遍历 |
| 哈希表 | O(1)平均 | 否 | 精确查找、快速增删 |
哈希表的短板是无序。如果你需要遍历元素时按顺序输出,哈希表帮不上忙,得用TreeMap或者排序后输出。哈希表也不能高效支持范围查询。所以在“快速精确查找”和“有序遍历”之间,需要根据业务取舍。
我个人的习惯是:如果元素有明确的唯一标识,且操作以查询为主,优先哈希表;如果数据需要按序展示,且插入删除频繁,用TreeMap;如果数据规模极小,直接用数组或ArrayList也不会有性能问题,不要为了用哈希而用哈希。
6. 最后再分享两个我实际踩过的细节
写这篇文章之前,我特意把自己手写哈希表的代码翻出来跑了一遍。这里分享两个容易忽略的小细节。
第一个是hashCode()用Objects.hash()时,会不会产生额外开销?会。Objects.hash内部会构造一个数组并调用Arrays.hashCode,生成一个中间对象。虽然现代JIT会优化掉一部分,但如果你有大量对象频繁作为key,性能会有可感知的差异。这时候可以手写哈希计算,比如基于几个字段直接算:
@Override public int hashCode() { int result = 1; result = 31 * result + (userId != null ? userId.hashCode() : 0); return result; }用31作为乘数,是因为31是奇素数,并且虚拟机可以用移位和减法优化乘法。这是String也是这么做的原因。
第二个是在排查哈希表扩容问题时,可以通过debug观察table数组的长度变化。比如在put方法里打一个断点,当size超过阈值时,数组从16变到32,原本在桶i的元素,有一部分会移到i+16的位置。亲眼看到这个过程,比背一百遍源码都更有帮助。
哈希表不是银弹,但在绝大多数精确查找场景下,它确实是效率最高的通用方案。理解它的冲突处理、扩容时机和方法约定,不仅是为了过面试,更是为了在线上遇到奇怪问题时,能快速想到排查方向。希望这篇文章能帮你在自己的项目里,用得比之前更稳一些。