
很多朋友一听到哈希表第一反应就是面试题里的“哈希函数、冲突、扩容”三件套但到了真正写代码的时候反而连一个最典型的问题都容易卡壳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、ConcurrentHashMapRedis 里的字典甚至很多数据库索引设计里都在用同一个思想。理解了哈希表再看这些组件会轻松很多。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。比如长度 16length - 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。如果数组长度还没到 64HashMap 会优先扩容而不是直接树化。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打印出来顺序却是乱的。我写一段非常简单的代码MapString, 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 及以后的版本里我这边跑出来的结果大致是这样的{banana2, apple1, cherry3, durian4}而你期望的顺序可能是{apple1, banana2, cherry3, durian4}这不是 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。MapString, Integer linkedMap new LinkedHashMap(); linkedMap.put(apple, 1); linkedMap.put(banana, 2); linkedMap.put(cherry, 3); linkedMap.put(durian, 4); System.out.println(linkedMap);输出结果就是{apple1, banana2, cherry3, durian4}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 里就会出现一个奇怪现象SetPerson 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 抛 ConcurrentModificationExceptionmodCount 变化迭代器快速失败用迭代器 remove或改用其他并发容器equals 相同但 contains 返回 false重写 equals 时没重写 hashCode两个方法一起重写多线程 put 后数据丢失HashMap 线程不安全改用 ConcurrentHashMap链表很长但数组容量小迟迟不树化数组长度低于 64优先扩容这是正常机制不是 bug最后再分享一个调试小技巧如果你想知道某个 key 到底落在哪个桶不要靠猜直接把bucketIndex打出来。我靠这个方法在几十个热 key 里发现两个 key 撞在同一桶很快定位到了线上性能瓶颈。哈希表本身不复杂复杂的是你在真实场景里怎么理解它的边界和坑。