很多朋友一听到哈希表,第一反应就是面试题里的“哈希函数、冲突、扩容”三件套,但到了真正写代码的时候,反而连一个最典型的问题都容易卡壳:Java 哈希表输出顺序,为什么偏偏和插入顺序不一样。我最早学 HashMap 也是这样,直到后来用 Java 排查线上缓存热 key,才把哈希表的底层结构和 java 哈希表输出顺序彻底绑在一起捋明白。
这篇不打算讲教科书式定义,而是从“哈希表到底怎么把数据放进去、取出来”开始,一直聊到 HashMap 的桶位计算、冲突处理、扩容时机,再回到大家经常搜的 Java 哈希表输出问题。看完你不仅能应付面试,也能在日常代码里少踩几个坑。
1. 哈希表到底解决什么问题
1.1 数组、链表和哈希表的区别
在没有哈希表之前,我们处理数据主要有两种姿势:数组和链表。
数组的强项是按下标随机访问,时间复杂度 O(1),但前提是你得知道下标。比如你要找到一个学号为 10001 的学生,直接用students[10001]就能拿到,前提是学号不会太大、不会太稀疏。如果学号是手机号或者身份证号,总不能开一个 11 位长度的数组,那内存直接爆掉。
链表不一样,它不需要连续内存,插入和删除在已知节点的情况下可以做到 O(1),但查找某个元素得从头一个个比,最坏 O(n)。
哈希表的思路就是把这两者结合起来:设计一个函数,把任意 key 映射成一个数组下标,然后用数组来存。这样我不用知道 key 对应的具体下标,只要算一下哈希函数,就能直接定位。
1.2 核心思想:key 到数组位置的映射
哈希表的基本结构可以理解成一个数组,数组里的每个元素通常叫“桶”或者“槽位”。
往哈希表里放一个 key-value 时,先计算hash(key),得到一个整数值,然后对这个整数值做一次“压缩”,让它落到数组长度范围内。这个压缩过程最简单的写法是取模:
int index = hash(key) % table.length;取数据的时候也走同样的流程:算 key 的哈希值,拿到同一个下标,然后从桶里把数据拿出来。
这里有个关键点:哈希函数是确定性的。同样的 key,不管调用多少次,算出来的结果必须一样,否则这个表就没法用了。
1.3 复杂度为什么是 O(1)
哈希表之所以快,是因为它把“查找”这个动作变成了“计算”这个动作。正常情况下,你不需要遍历所有数据,只要做一次哈希计算,再跳转到对应位置就行了。
| 操作 | 平均复杂度 | 最坏复杂度(冲突严重时) |
|---|---|---|
| 插入 | O(1) | O(n) |
| 查找 | O(1) | O(n) |
| 删除 | O(1) | O(n) |
这里的 O(1) 是建立在哈希函数足够均匀、冲突足够少的前提下。如果所有 key 都算到同一个桶,那哈希表就退化成一条链表,查找变成 O(n),这也是为什么后面要花大力气处理冲突和设计哈希函数。
哈希表在真实项目里的影响范围非常广,Java 的 HashMap、HashSet、ConcurrentHashMap,Redis 里的字典,甚至很多数据库索引设计里都在用同一个思想。理解了哈希表,再看这些组件会轻松很多。
2. 哈希函数:从 key 到下标,这一步决定性能
2.1 好的哈希函数要满足什么条件
一个合格的哈希函数,至少要满足三点:
- 确定性:同一个 key 永远得到同一个哈希值。
- 均匀性:不同的 key 尽量均匀散落在各个桶,不要扎堆。
- 高效性:计算不能太复杂,否则哈希表整体的性能会被计算过程拖垮。
Java 里最常见的哈希函数是 String 的 hashCode,它的计算公式是:
h = 31 * h + char用代码写出来就是:
int h = 0; for (char c : value) { h = 31 * h + c; }为什么会选 31 这个数字?主要原因有两个。
31 是奇素数,在用乘法哈希的场景下,奇素数能让结果分布更好;另外31 * h在 JVM 里可以被优化成(h << 5) - h,移位和减法要比普通乘法更快。这个优化不是绝对的,但确实是一个经典设计。
2.2 为什么不直接用 hashCode 当数组下标
Java 的hashCode()返回的是int,范围从 -2147483648 到 2147483647。但哈希表的数组长度通常只有 16、32、64 这么大,不可能直接用 hashCode 当数组下标。
HashMap 里实际算下标的方式是:
int h = key.hashCode(); int hash = h ^ (h >>> 16); int index = (table.length - 1) & hash;这里有一个容易被忽略的细节:为什么不直接hash % length,而是用(length - 1) & hash?
因为 HashMap 的数组长度始终是 2 的幂次方,此时length - 1的二进制形式是低位全是 1。比如长度 16,length - 1 = 15,二进制是1111。用hash & 15等价于取 hash 的低 4 位,性能比取模更高。
但取低位有个风险:如果 hashCode 本身只在高位变化、低位非常集中,那么直接取低位会导致大量冲突。所以 HashMap 在算下标之前先做一次“扰动”:
hash = h ^ (h >>> 16);h >>> 16是把高 16 位搬到低 16 位,然后和原值做异或,让高位信息也参与低位的计算。这个步骤能显著改善小容量下的分布情况。
2.3 哈希冲突是必然的
不管你哈希函数设计得多好,冲突都是无法完全避免的。
原因是抽屉原理:你有 n 个桶,却有可能塞进远超 n 个的 key,必然会出现多个 key 算到同一个桶的情况。好的哈希函数只能让冲突尽量少,不能消灭冲突。
所以真正重要的不是“有没有冲突”,而是“冲突了之后怎么处理”。这一块也是哈希表原理里最值得花时间理解的部分。
3. 哈希冲突怎么解决:开放寻址与链地址法
3.1 开放寻址法:线性探测
开放寻址法的思路是:如果算出来的桶已经被占了,那就继续往后找下一个空的位置。
比如数组长度是 8,某个 key 算出来的下标是 2,但桶 2 已经有数据了,那就看桶 3;桶 3 也有数据,再看桶 4,直到找到一个空位。
查找的时候同理,先去看算出来的位置,如果位置上的 key 不是要找的 key,就继续往后探测。
开放寻址法实现简单,不需要额外的指针,对 CPU 缓存也友好,但有一个明显问题:容易产生“聚集”。一旦某个区域连续被占用,后续新的 key 要探测很长一段距离才能找到空位。
Java 里的ThreadLocalMap用的就是开放寻址法,只不过它处理删除的方式比较特殊,不能直接把数组位置置空,否则会中断探测链,需要用 tombstone 之类的标记。
3.2 链地址法:冲突挂在链表上
链地址法是另一种思路:每个桶不再只存一个元素,而是存一个链表的头节点。冲突的 key 全部挂到同一个桶的链表上。
Java 的 HashMap 使用的就是链地址法。往桶里放元素时,如果桶是空的,直接放一个新节点;如果桶里已经有节点,就把新节点追加到链表尾部。
查找时先算出桶下标,然后遍历这个桶对应的链表,用equals去匹配 key。
链地址法的好处是实现直观、删除容易、对冲突容忍度高;缺点是每个节点需要额外存储 next 指针,内存占用比数组高一些。
3.3 链表什么时候变成红黑树
Java 8 以后,HashMap 的桶里不只是链表,还引入了红黑树。
当某个桶的链表长度达到 8,并且数组长度达到 64 时,链表会转换成红黑树。树化之后,最坏情况下查找复杂度从 O(n) 降到 O(log n)。
这个阈值 8 不是拍脑袋定的,而是参考了泊松分布。在负载因子 0.75、哈希函数随机分布的前提下,某个桶里链表长度超过 8 的概率非常低。一旦真的出现这么长的链表,大概率说明哈希函数分布出了问题,树化可以作为一种兜底手段。
需要注意,树化有两个条件:链表长度大于等于 8,同时数组长度大于等于 64。如果数组长度还没到 64,HashMap 会优先扩容,而不是直接树化。
4. 扩容机制与负载因子:哈希表自适应的关键
4.1 为什么默认负载因子是 0.75
负载因子(load factor)是哈希表“多满之后需要扩容”的阈值。
HashMap 默认容量是 16,负载因子是 0.75,所以默认扩容阈值是:
int threshold = (int)(16 * 0.75f); // 12也就是说,当元素个数达到 12 个时,HashMap 就会扩容到原来的两倍,也就是 32。
负载因子取值是在时间和空间之间做权衡。取值越小,比如 0.5,桶越多空位越多,冲突少,查找快,但内存浪费严重;取值越大,比如 0.9,内存利用更充分,但冲突变多,链表变长,查询变慢。
0.75 是工程上比较中庸的选择,大多数场景下既不会频繁扩容,也不会让冲突失控。
4.2 扩容到底做了什么
扩容并不是简单地复制数组,而是创建一个新的、长度为原来两倍的数组,然后把旧数组里的所有节点重新放到新数组里。
因为数组长度变了,(length - 1) & hash的结果也会变,所以每个节点在新数组里的下标都要重新计算。
但 Java 8 对这一步做了优化:因为新长度是旧长度的两倍,一个节点在新数组里的位置只可能是两种:原来的下标,或者“原来的下标 + 旧容量”。
判断依据是看这个节点 hash 值的某一位是 0 还是 1。如果这一位是 0,节点留在原下标;如果是 1,节点移动到oldIndex + oldCapacity。
举个例子:旧容量 16,某节点原来在桶 5,扩容后它要么还在桶 5,要么跑到桶 21。HashMap 会把这个桶里的链表拆成两条链表,分别放到新数组的两个位置。
这个优化避免了每个节点重新计算 hash,只是在原来的链表上做了一次高低位拆分,效率更高。
4.3 扩容会影响输出顺序
理解扩容之后,你就能解释一个现象:同一个 HashMap,在 put 的元素数量达到阈值触发扩容后,再遍历输出,顺序可能和扩容前完全不一样。
原因很简单:容量变了,桶下标变了,而哈希表遍历顺序本来就是按桶顺序来的,所以输出顺序自然跟着变。
这也是为什么永远不要依赖 HashMap 的输出顺序。它不是排序容器,也不是按插入顺序保存的容器,它只保证你通过 key 能找到 value。
5. Java 哈希表输出顺序:为什么不能按插入顺序打印
5.1 复现“java哈希表输出”乱序
大家搜索“java哈希表输出”,大概率是遇到了这个问题:明明按顺序 put 了几个 key,打印出来顺序却是乱的。
我写一段非常简单的代码:
Map<String, Integer> map = new HashMap<>(16); map.put("apple", 1); map.put("banana", 2); map.put("cherry", 3); map.put("durian", 4); System.out.println(map);在 JDK 8 及以后的版本里,我这边跑出来的结果大致是这样的:
{banana=2, apple=1, cherry=3, durian=4}而你期望的顺序可能是:
{apple=1, banana=2, cherry=3, durian=4}这不是 bug,而是 HashMap 的遍历机制决定的。
这四个 key 经过扰动和计算后,落到的桶位置分别是:banana 在 0 号桶,apple 和 cherry 都在 1 号桶,durian 在 15 号桶。HashMap 遍历时从 0 号桶开始,按桶下标顺序一个个来,所以会先打印 0 号桶的 banana,再打印 1 号桶里的 apple 和 cherry,最后才轮到 15 号桶的 durian。
apple 和 cherry 在同一个桶里,所以它俩的输出顺序取决于冲突链表里的顺序,而这个顺序也和插入顺序不一定一致。
5.2 想要按插入顺序输出,用 LinkedHashMap
如果业务上确实需要“按插入顺序输出”,不要试图去改造 HashMap,直接用 LinkedHashMap。
Map<String, Integer> linkedMap = new LinkedHashMap<>(); linkedMap.put("apple", 1); linkedMap.put("banana", 2); linkedMap.put("cherry", 3); linkedMap.put("durian", 4); System.out.println(linkedMap);输出结果就是:
{apple=1, banana=2, cherry=3, durian=4}LinkedHashMap 本质上还是哈希表,但它额外维护了一条双向链表,用来记录节点的插入顺序。遍历的时候不走桶数组,而是走这条双向链表,所以顺序稳定。
如果你需要按键排序,可以用 TreeMap;如果需要线程安全,优先考虑 ConcurrentHashMap,而不是老的 Hashtable。
| 容器 | 底层结构 | 输出顺序 | 线程安全 |
|---|---|---|---|
| HashMap | 哈希表 | 不保证 | 否 |
| LinkedHashMap | 哈希表 + 双向链表 | 插入/访问顺序 | 否 |
| TreeMap | 红黑树 | 按键排序 | 否 |
| ConcurrentHashMap | 哈希表 + 分段/粒度锁 | 不保证 | 是 |
5.3 遍历时修改会报错
还有一个和 java 哈希表输出相关的常见报错:ConcurrentModificationException。
很多人喜欢在遍历 HashMap 的时候顺手往里面 put 新 key,比如:
for (String key : map.keySet()) { if ("banana".equals(key)) { map.put("fig", 5); } }这段代码大概率会在迭代过程中抛出ConcurrentModificationException。
原因是 HashMap 内部维护了一个modCount字段,每次发生结构变化(新增或删除节点)都会加 1。迭代器在创建时会记录这个值,之后每一步都会检查,发现modCount变了,就立刻抛异常。
需要注意,修改已存在的 key 对应的 value 不算结构变化,不会触发这个异常;但新增 key 一定算。
如果你确实想边遍历边删除,推荐用迭代器自己的remove方法;如果是多线程场景,直接用 ConcurrentHashMap,别在遍历的时候操作 HashMap。
6. 实战中的常见问题与排查技巧
6.1 打印 key 分布,定位大量冲突
我曾经排查过一个线上问题:某个 HashMap 里的数据量并不大,但 get 操作特别慢,最后发现是大量 key 都挤到了同一个桶里,链表变得很长。
要定位这种问题,最快的办法是写一个小工具,把每个 key 算出来的桶位置打印出来。
static int hashSpread(Object key) { int h = key.hashCode(); return h ^ (h >>> 16); } static int bucketIndex(int hash, int capacity) { return (capacity - 1) & hash; }然后遍历你要观察的 key,统计每个桶里的元素数量。如果发现某个桶的数据明显偏多,那基本可以断定哈希函数分布出了问题。
这种排查技巧不需要改生产代码,本地写个测试类就能跑,非常实用。
6.2 hashCode 的坑:equals 和 hashCode 必须一起重写
哈希表判断 key 是否相同,依赖两个东西:先比较hashCode,再比较equals。如果两个对象的equals相等但hashCode不同,它们会被放到不同的桶里,contains和get就会失效。
看一个经典的错误写法:
class Person { String id; Person(String id) { this.id = id; } @Override public boolean equals(Object obj) { if (!(obj instanceof Person)) { return false; } return ((Person) obj).id.equals(this.id); } // 故意不重写 hashCode }这时候如果你 new 两个 id 相同的 Person 放到 HashSet 里,就会出现一个奇怪现象:
Set<Person> set = new HashSet<>(); set.add(new Person("1001")); System.out.println(set.contains(new Person("1001"))); // false逻辑上相等的两个对象,因为默认 hashCode 不同,被哈希表当成了两个完全不同的 key。
规则很简单:只要重写了equals,就一定要重写hashCode,并且保证相等的对象必须有相同的 hashCode。反过来,hashCode 相同不代表 equals 相等,后者还得靠 equals 精确判断。
6.3 HashMap 不是线程安全的容器
有人会在并发场景里直接共享一个 HashMap,然后 get 的时候偶尔拿到 null,或者数据少了一条,甚至出现死循环。
JDK 7 里,多线程同时 put 触发扩容,可能在链表转移时形成环,导致 get 操作进入死循环。JDK 8 改用尾插法之后,这个问题少了很多,但数据丢失、覆盖仍然存在。
多线程场景下,正确做法是用 ConcurrentHashMap。它的设计不是为了死锁避免,而是把锁粒度拆得更细,性能和安全性都好于在外部用synchronized包一层 HashMap。
6.4 常见问题速查
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 打印顺序和插入顺序不一致 | HashMap 按桶遍历,不保存插入顺序 | 用 LinkedHashMap |
| 扩容后遍历顺序变了 | 容量变化导致桶下标重算 | 不要依赖 HashMap 顺序 |
| 遍历时 put 新 key 抛 ConcurrentModificationException | modCount 变化,迭代器快速失败 | 用迭代器 remove,或改用其他并发容器 |
| equals 相同但 contains 返回 false | 重写 equals 时没重写 hashCode | 两个方法一起重写 |
| 多线程 put 后数据丢失 | HashMap 线程不安全 | 改用 ConcurrentHashMap |
| 链表很长但数组容量小,迟迟不树化 | 数组长度低于 64,优先扩容 | 这是正常机制,不是 bug |
最后再分享一个调试小技巧:如果你想知道某个 key 到底落在哪个桶,不要靠猜,直接把bucketIndex打出来。我靠这个方法在几十个热 key 里发现两个 key 撞在同一桶,很快定位到了线上性能瓶颈。哈希表本身不复杂,复杂的是你在真实场景里怎么理解它的边界和坑。