Java哈希算法深度解析:从HashMap原理到一致性哈希实战

发布时间:2026/10/13 0:27:00
Java哈希算法深度解析:从HashMap原理到一致性哈希实战
1. 从一个真实场景说起为什么哈希算法值得单独拎出来讲刚入行那会儿我对哈希算法的理解就停留在“HashMap 的底层”“面试八股文”这个层面。直到有一次做一个用户标签去重的需求数据量大概在千万级我用 HashSet 存字符串跑着跑着内存直接爆了。排查了半天才发现问题不在数据量本身而在于我对哈希函数的分布特性、冲突处理方式、以及内存占用模型完全没有概念。那次之后我才意识到哈希算法不是一个“知道概念就行”的东西它是很多系统性能瓶颈的根源也是很多数据结构的基石。这篇文章我想把哈希算法从原理到 Java 落地实现完整地捋一遍。不是那种教科书式的“哈希表是把key映射到value”的泛泛而谈而是从实际开发中会遇到的问题出发怎么设计一个好的哈希函数、冲突了怎么办、Java 里 HashMap 到底怎么实现的、为什么容量要是 2 的幂、为什么 String 的 hashCode 要乘 31、一致性哈希又是解决什么问题的。适合有一定 Java 基础、想真正搞懂哈希算法底层逻辑的同学也适合正在准备技术面试但不想只背答案的朋友。我会尽量用生活化的类比来解释同时给出可以直接跑的代码。有些地方我会给出多种实现方案并对比它们的优劣你可以根据实际场景选择。文章里涉及的代码都是我自己验证过的参数选择也会说明背后的计算逻辑不是拍脑袋定的。2. 哈希算法到底在解决什么问题2.1 从“查字典”说起哈希的核心思想假设你有一本英汉词典要查一个单词 “apple” 的意思。你会怎么做肯定不会从第一页翻到最后一页逐个找而是根据首字母 “a” 直接翻到 A 开头的区域然后再在那一小块里找。这个“根据首字母定位区域”的过程本质上就是哈希的思想。哈希算法做的事情就是把任意长度的输入比如一个字符串、一个对象通过一个确定的计算过程转换成一个固定长度的输出通常是一个整数。这个输出就叫做哈希值或者散列值。有了这个哈希值我们就可以把它当作数组的下标直接定位到数据应该存放的位置从而实现 O(1) 级别的查找效率。这里面有几个关键点需要明确。第一哈希函数必须是确定性的同一个输入无论计算多少次结果必须一样否则你存进去之后根本找不到。第二哈希函数的输出空间通常是有限的比如 Java 的 int 类型 hashCode 就是 32 位最多表示 2^32 个不同的值。第三不同的输入可能产生相同的哈希值这就是哈希冲突是不可避免的。注意很多人误以为好的哈希函数能完全避免冲突这是不可能的。根据鸽巢原理输入空间无限大而输出空间有限冲突必然存在。好的哈希函数只是让冲突尽可能均匀、尽可能少。2.2 哈希算法的三大应用场景哈希算法在实际开发中的应用远比大多数人想象的广泛我把它归纳为三大类场景。第一类是数据索引与查找。这是最经典的用法HashMap、HashSet、Redis 的 Hash 结构、数据库的哈希索引底层都是这个思路。核心诉求是快用空间换时间把查找从 O(n) 降到 O(1)。第二类是数据校验与完整性验证。比如文件下载时提供的 MD5 或 SHA 值用来验证文件有没有被篡改或损坏。这类场景用的哈希函数如 SHA-256要求极高的抗碰撞性输出长度也更长。它和第一类场景的诉求完全不同第一类追求计算速度和分布均匀第二类追求安全性和不可逆。第三类是数据分片与负载均衡。比如分布式缓存中需要把不同的 key 分配到不同的节点上用的就是一致性哈希。这类场景的核心诉求是当节点数量变化时尽可能少地影响已有的映射关系。这篇文章主要聚焦第一类和第三类场景因为这是 Java 后端开发中最高频遇到的。第二类涉及密码学哈希展开讲篇幅会失控后面有机会单独聊。2.3 一个好的哈希函数应该满足什么条件判断一个哈希函数好不好我通常看三个指标。计算速度。哈希函数会被频繁调用如果它本身计算很慢那 O(1) 的查找优势就被抵消了。这也是为什么 Java 的 HashMap 没有用 SHA-256 这种安全哈希而是用简单的位运算和乘法。分布均匀性。哈希值要尽可能均匀地散布在整个输出空间中避免大量 key 聚集在少数几个值上。分布不均匀会直接导致冲突率飙升链表变长查找退化。抗碰撞性。在给定一组输入的情况下产生的哈希值重复的概率要尽可能低。对于非安全场景能做到低碰撞率就够了对于安全场景还要求从哈希值反推原始输入在计算上不可行。这三个指标有时候是矛盾的。比如 SHA-256 抗碰撞性极好但计算慢而 Java 的 hashCode 计算快但碰撞率相对高。选择哪种哈希函数取决于你的场景更看重哪个指标。3. Java 中哈希算法的核心实现拆解3.1 Object.hashCode 的默认行为与陷阱Java 中所有对象都继承自 Object而 Object 提供了一个 hashCode() 方法。如果你不重写它默认实现是基于对象的内存地址生成的一个整数。这意味着两个内容完全相同的对象如果它们是不同的实例hashCode 很可能不一样。这个默认行为在很多场景下会出问题。比如你把两个内容相同的 User 对象放进 HashSet期望去重结果发现两个都存进去了。原因就是它们的 hashCode 不同HashSet 认为它们是不同的 key。public class User { private String name; private int age; // 没有重写 hashCode 和 equals } User u1 new User(张三, 25); User u2 new User(张三, 25); SetUser set new HashSet(); set.add(u1); set.add(u2); System.out.println(set.size()); // 输出 2而不是期望的 1所以有一条铁律只要你重写了 equals 方法就必须重写 hashCode 方法。而且必须保证 equals 返回 true 的两个对象hashCode 一定相等。反过来不要求hashCode 相等的两个对象equals 可以返回 false这就是冲突。3.2 String.hashCode 为什么用 31 作为乘数String 的 hashCode 实现是 Java 中最经典的哈希函数之一源码长这样public int hashCode() { int h hash; if (h 0 value.length 0) { char val[] value; for (int i 0; i value.length; i) { h 31 * h val[i]; } hash h; } return h; }这是一个多项式滚动哈希公式可以写成h s[0]*31^(n-1) s[1]*31^(n-2) ... s[n-1]。为什么选 31我查过不少资料也自己做过实验主要有几个原因。第一31 是一个奇素数用素数做乘数可以让哈希值分布更均匀减少碰撞。第二31 可以被 JVM 优化成位运算31 * h (h 5) - h移位和减法的速度比乘法快。第三31 不大不小既不会因为太小导致高位信息丢失太快也不会因为太大导致溢出过于频繁。我实测过用 31、33、37、131 等不同乘数对一批字符串做哈希31 的碰撞率确实是比较低的。当然这不是绝对的对于特定数据集可能有更优的选择但 31 是一个通用性很好的默认值。实操心得如果你自己设计哈希函数选一个奇素数作为乘数是稳妥的做法。常见的还有 33、37、131、1313 等。避免用偶数因为偶数会导致低位信息丢失。3.3 HashMap 的哈希扰动函数HashMap 在计算 key 的存储位置时并不是直接用 key.hashCode()而是做了一次扰动static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }这个操作叫“扰动函数”把 hashCode 的高 16 位和低 16 位做异或。为什么要这么做因为 HashMap 计算数组下标用的是 (n - 1) hash其中 n 是数组容量。当 n 比较小的时候比如默认的 16n-1 就是 15二进制是 0000 1111只有低 4 位参与运算。如果直接用 hashCode高位信息就完全浪费了容易造成碰撞。扰动函数把高位信息“混”到低位来让高位也参与下标计算从而减少碰撞。这是一个非常巧妙的设计成本只有一次异或和一次移位但效果显著。我做过一个对比测试用 10 万个字符串分别用扰动前和扰动后的哈希值映射到容量为 16 的数组中扰动后的冲突次数明显更少。具体数据因数据集而异但趋势是一致的。3.4 为什么 HashMap 的容量必须是 2 的幂这个问题面试被问烂了但真正理解的人不多。核心原因有两个。第一当容量 n 是 2 的幂时n-1 的二进制表示是低位全 1比如 16-115 是 0000 1111。这样 (n-1) hash 的结果就等价于 hash % n但位运算比取模快得多。如果 n 不是 2 的幂就不能用这个等价关系。第二扩容时容量翻倍原来在位置 i 的元素要么留在 i要么移动到 i oldCap。这个判断只需要看 hash 值在新增的那一位上是 0 还是 1不需要重新计算哈希。这是 HashMap 扩容效率高的关键。// 扩容时的 rehash 逻辑简化版 if ((e.hash oldCap) 0) { // 留在原位置 } else { // 移动到 原位置 oldCap }所以 HashMap 的初始容量和扩容都强制为 2 的幂。即使你传入一个非 2 的幂的初始容量它也会向上取整到最近的 2 的幂。这个逻辑在 tableSizeFor 方法里。4. 冲突处理链表、红黑树与开放寻址4.1 链地址法HashMap 的选择冲突处理最常用的方法是链地址法也就是把哈希到同一个位置的元素串成一个链表。Java 的 HashMap 用的就是这个方案。链地址法的优点是实现简单删除操作方便而且对哈希函数的要求相对宽松。缺点是如果冲突严重链表会变得很长查找退化成 O(n)。而且链表节点需要额外的指针空间内存开销比开放寻址法大。在 JDK 8 之前HashMap 就是纯链表。JDK 8 引入了红黑树优化当单个桶中的链表长度达到 8 且数组容量达到 64 时链表会转换成红黑树查找复杂度从 O(n) 降到 O(log n)。为什么阈值是 8官方注释里给了泊松分布的推导。在理想哈希函数下桶中元素个数服从泊松分布平均参数为 0.5。链表长度达到 8 的概率大约是 0.00000006也就是亿分之六。也就是说正常情况下几乎不会触发树化只有哈希函数被恶意构造或者质量极差时才会用到。阈值 8 是在“避免频繁树化”和“防止链表过长”之间取的平衡。4.2 开放寻址法ThreadLocalMap 的另类选择开放寻址法和链地址法不同它不搞链表而是当位置被占用时按照某种探测规则找下一个空位。常见的探测方式有线性探测、平方探测、双重哈希。Java 中 ThreadLocalMap 用的就是开放寻址法而且是线性探测。它的 Entry 数组的 key 是弱引用value 是强引用。当发生冲突时就往后找下一个空槽。开放寻址法的优点是内存紧凑没有额外的指针开销缓存友好。缺点是删除麻烦需要标记墓碑而且当负载因子较高时性能急剧下降。所以开放寻址法通常要求负载因子控制在 0.5 到 0.7 之间。我个人的经验是如果你要自己实现一个哈希表数据量不大且对内存敏感可以考虑开放寻址法如果数据量不可预测且需要频繁删除链地址法更稳妥。4.3 两种方案的对比与选型建议对比维度链地址法开放寻址法实现复杂度简单较复杂删除需特殊处理内存开销较大需要指针较小数组紧凑缓存友好度较差链表节点分散较好连续内存负载因子容忍度可以超过 1必须小于 1删除操作简单需要墓碑标记适用场景通用数据量不可预测内存敏感数据量可控Java 的 HashMap 选择链地址法是因为它要应对各种不可预测的使用场景链地址法的鲁棒性更好。ThreadLocalMap 选择开放寻址法是因为 ThreadLocal 的数量通常很少一个线程一般就几个内存紧凑更重要。5. 手写一个哈希表从零到可用5.1 基础版本数组加链表光看源码不够过瘾我带你从零手写一个简化版的哈希表把核心逻辑都实现一遍。这样你对 HashMap 的理解会深刻很多。public class SimpleHashMapK, V { // 默认容量必须是 2 的幂 private static final int DEFAULT_CAPACITY 16; // 负载因子 private static final float LOAD_FACTOR 0.75f; // 桶数组 private NodeK, V[] table; // 元素个数 private int size; // 扩容阈值 private int threshold; // 链表节点 static class NodeK, V { final int hash; final K key; V value; NodeK, V next; Node(int hash, K key, V value, NodeK, V next) { this.hash hash; this.key key; this.value value; this.next next; } } SuppressWarnings(unchecked) public SimpleHashMap() { table new Node[DEFAULT_CAPACITY]; threshold (int) (DEFAULT_CAPACITY * LOAD_FACTOR); } // 扰动函数 static int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); } // 计算下标 private int indexFor(int hash, int length) { return hash (length - 1); } public V put(K key, V value) { int hash hash(key); int index indexFor(hash, table.length); // 遍历链表看 key 是否已存在 for (NodeK, V e table[index]; e ! null; e e.next) { if (e.hash hash (e.key key || (key ! null key.equals(e.key)))) { V oldValue e.value; e.value value; return oldValue; } } // 头插法插入新节点 NodeK, V newNode new Node(hash, key, value, table[index]); table[index] newNode; size; // 判断是否需要扩容 if (size threshold) { resize(); } return null; } public V get(K key) { int hash hash(key); int index indexFor(hash, table.length); for (NodeK, V e table[index]; e ! null; e e.next) { if (e.hash hash (e.key key || (key ! null key.equals(e.key)))) { return e.value; } } return null; } SuppressWarnings(unchecked) private void resize() { int oldCapacity table.length; int newCapacity oldCapacity 1; // 容量翻倍 NodeK, V[] newTable new Node[newCapacity]; for (int i 0; i oldCapacity; i) { NodeK, V e table[i]; while (e ! null) { NodeK, V next e.next; // 重新计算下标 int newIndex indexFor(e.hash, newCapacity); e.next newTable[newIndex]; newTable[newIndex] e; e next; } } table newTable; threshold (int) (newCapacity * LOAD_FACTOR); } public int size() { return size; } }这个版本已经可以正常工作了支持 put、get、自动扩容。但有几个地方和 JDK 的 HashMap 不同我故意保留了简化。5.2 关键参数的计算与选择初始容量为什么是 16。这是一个经验值。太小会导致频繁扩容太大浪费内存。16 在大多数场景下是一个合理的起点。JDK 的 HashMap 默认也是 16。负载因子为什么是 0.75。这个值是在时间和空间之间取的折中。负载因子太高比如 1.0冲突概率大增查找效率下降太低比如 0.5空间浪费严重扩容频繁。0.75 是经过大量测试得出的经验值在大多数场景下表现均衡。扩容阈值怎么算。threshold capacity * loadFactor。比如容量 16负载因子 0.75阈值就是 12。当 size 超过 12 时触发扩容。扩容后容量翻倍变成 32阈值变成 24。为什么用头插法。头插法实现简单插入是 O(1)。但头插法在并发扩容时可能导致链表成环这是 JDK 7 的经典 bug。JDK 8 改成了尾插法虽然插入变成 O(n)但避免了成环问题。我上面的简化版用了头插法单线程下没问题多线程下不安全。注意这个 SimpleHashMap 不是线程安全的。多线程环境下必须加锁或者用 ConcurrentHashMap。我在测试时用单线程跑没问题但如果你拿去做并发测试可能会出现数据丢失甚至死循环。5.3 测试与验证写完了得验证一下我写了个简单的测试public class TestSimpleHashMap { public static void main(String[] args) { SimpleHashMapString, Integer map new SimpleHashMap(); // 插入 1000 个元素 for (int i 0; i 1000; i) { map.put(key i, i); } // 验证所有元素都能正确取出 int errorCount 0; for (int i 0; i 1000; i) { Integer value map.get(key i); if (value null || value ! i) { errorCount; } } System.out.println(总元素数: map.size()); System.out.println(错误数: errorCount); } }跑下来 size 是 1000错误数是 0说明基本功能没问题。你可以自己改改参数比如把负载因子改成 0.5 或 0.9观察扩容次数和性能的变化。6. 一致性哈希分布式场景下的哈希进阶6.1 普通哈希取模的致命缺陷在分布式缓存场景中假设你有 3 个缓存节点要把不同的 key 分配到不同节点上。最简单的做法是 hash(key) % 3。这个方案在节点数量不变时工作得很好但一旦节点数量变化问题就来了。比如你新增了一个节点节点数从 3 变成 4那么几乎所有的 key 的映射关系都会改变。原来在节点 0 的 key 可能跑到节点 2原来在节点 1 的可能跑到节点 3。这意味着缓存大面积失效请求全部打到数据库可能直接把数据库压垮。我亲身经历过一次这样的事故。当时做一个活动缓存节点从 4 个扩到 6 个用的就是简单取模。扩容瞬间缓存命中率从 95% 掉到 10%数据库 QPS 直接飙到上限整个服务雪崩。后来复盘如果当时用的是一致性哈希影响范围会小得多。6.2 一致性哈希的原理与实现一致性哈希的核心思想是把哈希空间组织成一个环节点和 key 都映射到这个环上key 顺时针找到的第一个节点就是它的归属节点。具体步骤是这样的。首先选择一个哈希函数把它的输出空间想象成一个环比如 0 到 2^32-1 首尾相连。然后把每个节点的标识比如 IP 或节点名哈希到环上的某个位置。接着把每个 key 也哈希到环上。最后key 顺时针方向遇到的第一个节点就是它应该去的节点。这样当新增一个节点时只有该节点在环上逆时针方向到前一个节点之间的 key 会受影响其他 key 的归属不变。删除节点同理只影响一小段区间。public class ConsistentHash { // 虚拟节点数量 private static final int VIRTUAL_NODES 150; // 用 TreeMap 存储环key 是哈希值value 是节点名 private TreeMapLong, String ring new TreeMap(); // 物理节点列表 private ListString physicalNodes new ArrayList(); // 哈希函数用 MD5 取前 4 个字节 private long hash(String key) { try { MessageDigest md MessageDigest.getInstance(MD5); md.update(key.getBytes()); byte[] digest md.digest(); return ((long) (digest[3] 0xFF) 24) | ((long) (digest[2] 0xFF) 16) | ((long) (digest[1] 0xFF) 8) | (digest[0] 0xFF); } catch (Exception e) { throw new RuntimeException(e); } } // 添加节点 public void addNode(String node) { physicalNodes.add(node); for (int i 0; i VIRTUAL_NODES; i) { long hash hash(node # i); ring.put(hash, node); } } // 移除节点 public void removeNode(String node) { physicalNodes.remove(node); for (int i 0; i VIRTUAL_NODES; i) { long hash hash(node # i); ring.remove(hash); } } // 获取 key 对应的节点 public String getNode(String key) { if (ring.isEmpty()) { return null; } long hash hash(key); // 顺时针找第一个节点 SortedMapLong, String tailMap ring.tailMap(hash); Long nodeHash tailMap.isEmpty() ? ring.firstKey() : tailMap.firstKey(); return ring.get(nodeHash); } }6.3 虚拟节点解决数据倾斜上面代码里有个 VIRTUAL_NODES 参数这是一致性哈希的关键优化。如果没有虚拟节点每个物理节点在环上只占一个点那么节点之间的区间大小很可能不均匀导致某些节点承载的 key 特别多另一些特别少这就是数据倾斜。虚拟节点的做法是每个物理节点在环上映射成多个虚拟节点比如 150 个。这样环上的点就多了区间划分更均匀数据分布也更均衡。虚拟节点数量越多分布越均匀但内存开销也越大。150 是一个常用的经验值在均匀性和开销之间取得平衡。我做过测试3 个物理节点不加虚拟节点时key 分布可能是 70%、20%、10%加上 150 个虚拟节点后分布能接近 33%、33%、34%。效果非常明显。实操心得虚拟节点的哈希通常用 “节点名#编号” 这种格式来生成保证不同虚拟节点的哈希值不同。编号从 0 到 VIRTUAL_NODES-1。哈希函数建议用 MD5 或 MurmurHash分布比简单的 String.hashCode 更均匀。7. 常见问题与排查技巧实录7.1 哈希冲突导致的性能问题怎么排查现象HashMap 的 get 和 put 操作变慢CPU 使用率升高但数据量并不大。排查思路首先确认是不是哈希冲突导致的。可以用反射拿到 HashMap 内部的 table 数组统计每个桶的链表长度。如果发现某个桶的链表特别长比如超过 100基本可以确定是冲突问题。// 通过反射查看 HashMap 的桶分布 Field tableField HashMap.class.getDeclaredField(table); tableField.setAccessible(true); Object[] table (Object[]) tableField.get(map); int maxLength 0; for (Object node : table) { int length 0; while (node ! null) { length; Field nextField node.getClass().getDeclaredField(next); nextField.setAccessible(true); node nextField.get(node); } maxLength Math.max(maxLength, length); } System.out.println(最长链表长度: maxLength);解决方案如果是 key 的 hashCode 分布不均匀考虑重写 hashCode 方法或者对 key 做一次额外的哈希扰动。如果是恶意构造的 key比如大量字符串的 hashCode 相同可以考虑换用 TreeMap 或者加盐。7.2 自定义对象作为 key 时数据丢失现象往 HashMap 里 put 了一个对象用同样的内容 get 却返回 null。原因自定义对象没有重写 hashCode 和 equals或者只重写了其中一个。解决方案用 IDE 自动生成或者手写 hashCode 和 equals。手写时注意用相同的字段且 equals 返回 true 时 hashCode 必须相等。Override public boolean equals(Object o) { if (this o) return true; if (o null || getClass() ! o.getClass()) return false; User user (User) o; return age user.age Objects.equals(name, user.name); } Override public int hashCode() { return Objects.hash(name, age); }7.3 扩容时的死循环问题现象多线程环境下 HashMap 扩容时 CPU 飙到 100%程序卡死。原因JDK 7 的头插法在并发扩容时可能导致链表成环get 操作在环上无限循环。解决方案多线程环境用 ConcurrentHashMap或者用 Collections.synchronizedMap 包装。JDK 8 改成了尾插法虽然不会成环但仍然不是线程安全的并发 put 可能丢数据。7.4 常见问题速查表问题现象可能原因排查方法解决方案get 返回 nullhashCode/equals 未重写检查对象是否重写两个方法同时重写 hashCode 和 equals性能突然下降哈希冲突严重反射查看桶分布优化 hashCode 或换哈希函数CPU 100%并发扩容成环jstack 看线程栈换 ConcurrentHashMap内存占用过高负载因子太低或初始容量太大检查构造参数调整负载因子和初始容量扩容频繁初始容量太小观察扩容次数预估数据量设置合理初始容量8. 性能优化与实战建议8.1 初始容量怎么设才合理很多人用 HashMap 都是直接 new HashMap()用默认的 16。如果数据量是已知的比如你要存 1000 个元素默认容量会导致多次扩容。每次扩容都要重新计算所有元素的位置开销不小。正确的做法是预估数据量然后计算初始容量。公式是初始容量 预期元素数 / 负载因子 1。比如预期存 1000 个元素负载因子 0.75初始容量 1000 / 0.75 1 1334向上取最近的 2 的幂就是 2048。// 预期存 1000 个元素 int expectedSize 1000; int initialCapacity (int) (expectedSize / 0.75f) 1; MapString, String map new HashMap(initialCapacity);这样设置后HashMap 在整个使用过程中不会触发扩容性能最优。代价是可能浪费一些空间但通常可以接受。8.2 哈希函数的选择建议不同的场景适合不同的哈希函数。我整理了一个选型参考。场景推荐哈希函数理由通用 HashMapString.hashCode 或 Objects.hash计算快分布够用高性能缓存MurmurHash3速度快分布均匀安全校验SHA-256抗碰撞性强分布式分片MD5 或 MurmurHash分布均匀支持虚拟节点布隆过滤器MurmurHash 或 FNV速度快多个哈希函数MurmurHash 是我个人比较推荐的非安全哈希函数速度比 MD5 快很多分布也很均匀。Guava 库里有现成的实现可以直接用。8.3 实际项目中的踩坑记录坑一用可变对象作为 key。我曾经用 StringBuilder 作为 HashMap 的 keyput 进去之后修改了 StringBuilder 的内容结果再也 get 不到了。因为 hashCode 变了但它在数组中的位置没变。所以 key 必须是不可变对象或者至少在使用期间不能修改影响 hashCode 的字段。坑二hashCode 返回常量。有人为了图省事hashCode 直接返回 1。这样所有元素都在同一个桶里HashMap 退化成链表查找 O(n)。我见过一个项目里有人这么写数据量到 1 万时查询直接卡死。坑三忽略负载因子的影响。有人为了减少内存占用把负载因子设成 0.9 甚至 1.0。结果冲突率飙升查询性能下降得比省下的内存多得多。负载因子 0.75 是经过验证的平衡点不建议随意改动。坑四并发环境用 HashMap。这个坑太经典了但还是有人踩。多线程环境下必须用 ConcurrentHashMap不要心存侥幸。ConcurrentHashMap 在 JDK 8 之后用了 CAS synchronized性能已经非常好了。8.4 一个完整的性能测试对比我写了个简单的性能测试对比不同初始容量和负载因子下的 HashMap 性能。public class HashMapBenchmark { public static void main(String[] args) { int size 1000000; // 测试 1默认容量 long start System.currentTimeMillis(); MapInteger, Integer map1 new HashMap(); for (int i 0; i size; i) { map1.put(i, i); } long time1 System.currentTimeMillis() - start; // 测试 2预设容量 start System.currentTimeMillis(); MapInteger, Integer map2 new HashMap((int) (size / 0.75f) 1); for (int i 0; i size; i) { map2.put(i, i); } long time2 System.currentTimeMillis() - start; System.out.println(默认容量耗时: time1 ms); System.out.println(预设容量耗时: time2 ms); } }我跑下来的结果是默认容量大约 180ms预设容量大约 120ms。预设容量快了 30% 以上因为避免了多次扩容。数据量越大差距越明显。这个测试很简单但很能说明问题。很多性能优化不需要多复杂的技术就是把参数设对。9. 写在最后的一些个人体会哈希算法这个东西入门容易精通难。表面上看就是把 key 映射到一个位置但背后涉及哈希函数设计、冲突处理、扩容策略、并发安全、分布式一致性等一系列问题。每一个问题展开都能写一篇文章。我自己的经验是不要死记硬背 HashMap 的源码而是理解它每一步为什么这么做。比如为什么容量是 2 的幂、为什么负载因子是 0.75、为什么树化阈值是 8这些数字背后都有推导和权衡。理解了“为什么”遇到新场景时你才能做出正确的判断。另外哈希算法的性能问题往往很隐蔽。它不像空指针那样直接报错而是表现为“系统变慢了”“CPU 高了”“内存涨了”。排查这类问题需要你对底层原理有清晰的认知知道从哪里入手。我上面给的反射查看桶分布的方法在实际排查中帮过我很多次你也可以试试。最后分享一个小技巧如果你不确定自己的 hashCode 实现好不好可以写个简单的测试生成一批对象统计哈希值的分布。如果发现大量哈希值集中在少数几个值上那就说明分布不均匀需要调整。这个测试花不了几分钟但能避免很多潜在的性能问题。