ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

HashMap底层原理与工程实践:从数组链表到并发安全

HashMap底层原理与工程实践:从数组链表到并发安全 很多开发者学了HashMap能背出“数组链表红黑树”但真到线上出现问题或者面试官深挖时往往就卡壳了。哈希表HashMap作为使用频率最高的键值对容器它的get和put原理、底层数据结构、为什么多线程不安全每个点单独拉出来都能写一篇长文。这篇文章我基于一次真实线上排查和源码走读的经验把HashMap从数据结构到工程实践完整拆开讲透适合三类读者准备面试想系统梳理底层原理的人、日常开发想搞懂容器内部逻辑的人、以及准备深入读Java集合源码但不知从哪下手的初学者。1. 从数组随机访问说起为什么哈希表能做到O(1)查找1.1 哈希表是一个“带算法的数组”有不少人把哈希表理解成一种神奇的数据结构其实拆开看哈希表的本质就是数组 散列函数 冲突处理。数组天生支持O(1)随机访问因为它在内存里是连续空间给定下标就能通过“起始地址 下标 × 元素大小”直接算出内存地址。哈希表要做的事就是把任意类型的Key通过一个散列函数映射成数组下标然后复用数组的随机访问能力。这里有个关键问题散列函数把无限可能的Key映射到有限的数组下标必然会出现不同Key算到同一个下标的情况这就是哈希冲突。解决冲突的主流方案有两种链地址法和开放寻址法。Java的HashMap用的是链地址法——每个数组槽位挂一个链表冲突的元素挂在链表上C的unordered_map也是链地址法Python的dict则混合使用了开放寻址法。你可以把哈希表想象成一个带编号抽屉的柜子。散列函数负责决定“这个物品该放进几号抽屉”抽屉编号相同的物品就按顺序堆在一起。查找时先算编号直接在对应抽屉里翻找。只要抽屉够多、物品分布够散每个抽屉里的东西就少查找自然快。1.2 Java HashMap的物理结构数组、链表和红黑树的组合JDK 8之后的HashMap内部核心是一个NodeK,V[] table数组每个数组元素是链表头节点。Node除了保存key、value、hash值还有一个next指针指向下一个节点。当链表长度过长时会把链表转换为红黑树利用红黑树O(log n)的查找复杂度来替代链表的O(n)线性扫描。HashMap的默认初始容量是16负载因子是0.75。负载因子的含义是“元素个数 / 数组长度”的比值达到这个比值就会触发扩容。0.75这个值不是拍脑袋定的而是时间与空间的折中——太小则浪费内存、频繁扩容太大则哈希冲突增多、查询变慢。在HashMap源码的注释里官方给出的结论是0.75能在时间和空间成本上取得比较好的平衡。这里要顺带提一句Java自带的扰动函数。hash(Object key)方法会把key的hashCode()高16位和低16位做异或运算再用来计算数组下标。为什么要多此一举因为hashCode()是int类型如果不同对象的高位差异很大、低位差异很小直接拿低位参与数组下标计算很容易撞车。高16位异或低16位相当于把高位信息混合进低位让最终参与下标计算的hash值分布更均匀。这属于典型的“以少量计算换更少冲突”的思路。2. put和get的完整链路hash定位不是终点equals确认才是关键2.1 put一个键值对JVM内部到底做了什么HashMap的put流程可以拆成四步。第一步计算key的hash值并做扰动第二步通过(table.length - 1) hash计算槽位下标第三步判断槽位是否为空为空就直接new Node放进去第四步如果不为空说明发生了哈希冲突需要遍历链表或红黑树判断是否存在相同key——如果存在则覆盖旧值不存在则追加到链表末尾JDK 8是尾插法。截取JDK 8的putVal核心逻辑大致长这样final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { NodeK,V[] tab; NodeK,V p; int n, i; if ((tab table) null || (n tab.length) 0) n (tab resize()).length; if ((p tab[i (n - 1) hash]) null) tab[i] newNode(hash, key, value, null); else { NodeK,V e; K k; if (p.hash hash ((k p.key) key || (key ! null key.equals(k)))) e p; else if (p instanceof TreeNode) e ((TreeNodeK,V)p).putTreeVal(this, tab, hash, key, value); else { for (int binCount 0; ; binCount) { if ((e p.next) null) { p.next newNode(hash, key, value, null); if (binCount TREEIFY_THRESHOLD - 1) treeifyBin(tab, hash); break; } if (e.hash hash ((k e.key) key || (key ! null key.equals(k)))) break; p e; } } if (e ! null) { V oldValue e.value; e.value value; return oldValue; } } modCount; if (size threshold) resize(); return null; }注意判断key是否相同的代码p.hash hash ((k p.key) key || (key ! null key.equals(k)))。这个条件很有讲究它分了三层第一层先比hash值hash不同直接排除这是快速过滤不用调用耗时的equals第二层用判断是否为同一个对象引用第三层才是调用equals用来判断两个内容不同的对象是否按业务规则等价。2.2 什么情况下会用equals比较这是理解HashMap的关键很多人背原理时会忽略这个问题既然hash已经算到了具体桶为什么还需要equals因为hashCode相同不代表两个对象相等哈希冲突的本质就是不同对象算出了相同下标。设想的场景是两个不同的字符串key算出了相同的hash值被放进同一个桶里。此时想区分它们到底是不是同一个key只能靠equals逐字比较内容。所以equals在整个查找链路中承担的是“最终确认”职责hash定位帮你找到候选的桶equals帮你确认桶里到底哪个才是真正要找的目标。反过来如果两个对象的hashCode不同那HashMap绝对不会调用它们的equals——因为压根没定位到同一个桶equals作为“更精确的比较手段”根本没有出场机会。这就是hashCode和equals的分工hashCode负责粗筛定位equals负责精准确认。实际开发中如果一个类被用作HashMap的key必须同时重写hashCode()和equals()且严格满足约定两个对象equals返回truehashCode必须相同hashCode相同equals可以返回false。我见过不少同事只重写equals不重写hashCode结果出现诡异bug——两个在业务上等价的key对象因为hashCode不同被放进了不同桶get永远查不到put进去的值。这类问题隐蔽性极强不打断点几乎发现不了。get流程和put的查找部分完全对称计算扰动hash → (length-1)定位下标 → 桶为空直接返回null → 遍历桶内链表/红黑树。树节点比较逻辑在getTreeNode中但比较key的套路一样也是先比hash再比equals。2.3 null key怎么处理为什么会放在table[0]HashMap允许key为null但hash(null)没有调用null.hashCode()而是直接返回0。所以null key最终被分配到table[0]对应的桶。这算是一个历史设计——Java官方出于对null的支持考虑保留了这一行为。从数据结构角度看null key的散列值固定为0必然和一部分hash低位为0的正常key共享table[0]这个桶会带来一些额外碰撞但影响有限不必刻意避免。3. 扩容机制0.75阈值、2次幂长度和树化门槛的关系3.1 为什么数组长度必须是2的幂次方HashMap要求初始容量和扩容后的容量都是2的幂次方这不是偏好问题而是两行代码的需求。第一行是下标定位(n - 1) hash只有当n是2的幂次方时n - 1的二进制才是全1这样操作才能完整保留hash的低位信息等价于hash % n取模但位运算比取模快得多。第二行是扩容迁移扩容时元素的新位置要么留在原下标要么移动到“原下标 旧容量”这个结论的推导同样依赖n是2的幂次方。具体推导很简单。扩容后容量从oldCap变成oldCap * 2参与运算的掩码多了一个bit参与。如果这个新增bit对应的hash位为0元素留原地如果为1元素的新下标就是原下标加上oldCap。JDK 8的resize正是利用这个特性把链表拆成lo和hi两条再整体挂到新数组对应位置避免了JDK 7中每个元素重新计算下标再头插的折腾。3.2 树化条件链表长度到8数组容量到64两个条件缺一不可JDK 8引入红黑树是为了防退化但树化不是链表一长就立刻做。触发treeifyBin需要同时满足两个条件链表长度大于等于8数组容量大于等于64。这两个条件的先后逻辑很有趣——链表超过8但数组容量还不到64时HashMap会优先扩容而不是树化。因为此时冲突多很可能是数组太小导致的扩容之后链表自然会变短而数组容量达到64后还出现长链表才说明某个桶内确实存在大量同hash元素有树化的必要。TREEIFY_THRESHOLD取8的官方依据是泊松分布模型。在理想随机分布的hash函数下链表长度达到8的概率极低大约千万分之一级别。换句话说出现长链表本身就在提示“hash分布异常”需要树化兜底。红黑树的插入、删除、查找都是O(log n)相比链表O(n)有所改善但树节点是普通链表节点大小的两倍左右所以树化属于“用空间换时间”的防御性策略并非每个桶都要追求树化。3.3 扩容过程中的空间换时间与预分配技巧HashMap扩容时新数组容量是旧数组的两倍然后逐桶迁移数据。迁移过程中所有元素要重新计算桶位这本身是O(n)操作。如果业务上一直向Map里塞数据扩容会反复发生积累起来很影响性能。所以一个实用的经验是能预估元素数量就提前设定容量。假设确定要存入1000个元素负载因子0.75时若初始容量是16第一次扩容发生在元素达到16×0.7512时一路扩容到2048才够装下1000个元素过程中至少触发五六次扩容拷贝。直接new HashMap(2048)因为2048×0.751536大于1000全程不扩容一次成型。计算方式很简单初始容量至少是expectedSize / 0.75 1再向上取整到2的幂次方。比如1000 / 0.75 1 1335向上取整到2的幂次就是2048。Guava的Maps.newHashMapWithExpectedSize(1000)底层也是这个算法懒得手算就直接用工具类。4. HashMap为什么不安全并发场景下的死循环、数据覆盖与fast-fail4.1 JDK 7扩容死循环的根源头插法与环形链表HashMap在多线程环境下不安全这是老生常谈但很多人说不出具体原因。最经典的例子是JDK 7的扩容死循环。JDK 7在扩容迁移链表时采用头插法把每个节点重新插入到新桶的头部遍历方向是移动原链表的next指针。当两个线程同时触发扩容、处理同一个链表时可能互相覆盖对方的next引用最终把链表改造成环形结构。之后任何一次get遍历到这个环上都会在链表里无限循环CPU直接打满。JDK 8把头插法改成了尾插法在扩容时把链表按“低位桶/高位桶”拆成两条局部变量持有新旧链表的头尾从代码层面规避了环形链表问题。但这只解决了“死循环”没有解决其他并发问题——数据覆盖依然存在。4.2 数据覆盖、modCount与ConcurrentModificationException数据覆盖最容易发生的场景是两个线程同时执行put都发现目标桶为空都走到tab[i] newNode(...)这一步后写入的节点直接覆盖了先写入的节点导致一条put丢失。另外putVal在成功新增元素后会执行modCount; if (size threshold)size的累加也不是原子操作多线程下size会偏小间接导致扩容判断失真。modCount是另一个常见问题的引线。HashMap内部维护一个修改计数器迭代器创建时会记录当前的modCount每次迭代检查modCount是否变化如果变化就抛ConcurrentModificationException。这就是fast-fail机制——与其让迭代在脏数据上继续执行产生不可预期结果不如立刻失败。单线程下在for-each中直接调用map.remove(key)同样会触发这个异常正确做法是使用迭代器的iterator.remove()方法或者JDK 8的removeIf。4.3 并发场景的正确替代方案追求线程安全时不要直接给HashMap加synchronized完事。JDK提供的替代方案很明确方案特点适用场景Hashtable全表加锁读写串行基本不推荐Collections.synchronizedMap返回包装类内部全表锁读也锁读多写少但要求实现简单ConcurrentHashMap分段锁/CAS局部锁读不加锁绝大多数并发场景不可变Map防御性复制本身不可变修改时创建新副本读极多、写极少ConcurrentHashMap在JDK 8之后放弃了Segment分段锁改用CASsynchronized锁头节点读操作几乎不加锁。put时如果桶为空用CAS直接写入桶不为空对头节点加锁防止同一个桶内的并发修改。性能和HashMap在单线程下差距很小在并发下则显著优于Hashtable。具体选型上我先看写多读多还是读多写少读多写少用不可变Map或ConcurrentHashMap都行写多的一定选ConcurrentHashMap。5. 哈希表和字典的区别换个语言视角重新理解HashMap5.1 “字典”是一种抽象概念“哈希表”是一种实现方式很多初学者把“哈希表”和“字典”混为一谈其实两者的层次完全不同。字典Dictionary/Map是一种抽象数据结构它描述的是“键值映射”这一接口能力——建立key到value的映射、支持按key查询哈希表是一种具体的底层实现它通过散列函数和冲突解决策略来实现这种映射能力。同一个字典接口既可以用哈希表实现也可以用红黑树实现甚至可以用跳表。Java里最典型的三兄弟就体现了这个区别HashMap是哈希表实现无序LinkedHashMap在哈希表基础上加了一条双向链表来维护插入顺序TreeMap直接用红黑树实现key按自然顺序或自定义比较器排序。三种Map对外暴露的接口都是“键值映射”但底层结构和性能特征完全不同。5.2 Java、Python、C的哈希表实现各有各的脾气同样是哈希表不同语言的实现细节差异很大聊这个对跨语言开发很有帮助。我整理了一个对比表格语言/容器底层结构key要求有序性典型坑点Java HashMap数组链表红黑树重写hashCode与equals无序多线程不安全树化阈值Python dict稀疏数组开放寻址对象需hashable实现了__hash__和__eq__3.7起保持插入顺序冲突用探测解决删除标记dummyC unordered_map哈希桶数组链表需提供hash函数与operator无序rehash会使迭代器失效自定义类型麻烦Python dict的开放寻址和Java的链地址法相比冲突时不挂链表而是一路探测下一个空闲槽位所以它要求数组有相当比例的空槽保证探测效率因此负载因子通常控制在2/3以内。C的unordered_map在使用自定义结构体作为key时需要自己写hash仿函数和相等仿函数或者特化std::hashT比Java默认用Object的hashCode要麻烦不少。还有一个容易踩的坑unordered_map在元素数量达到max_load_factor时会触发rehash导致所有迭代器失效Java HashMap的扩容则不会失效迭代器但会失效已经获取到的Node引用具体场景要区分。HashMap与字典对比时还有一个理解点Java早期版本还有个Dictionary抽象类Hashtable的父类后来被Map接口取代。如今主流语境下说“字典”基本等同于Map接口聊“哈希表”就特指哈希表实现。读者如果在技术方案里纠结选型真正要判断的不是“关键字叫什么”而是“我是否需要有序、是否要求线程安全、性能瓶颈在读写还是扩容”。6. 实战中的高频坑位与调优经验6.1 自定义对象做keyhashCode和equals的正确写法我见过的最典型线上事故是有人用自定义的Order对象作为key只重写了equals没重写hashCode结果所有orderMap.get(order)都返回null排查了一下午才发现是两个Order内容相同但继承自Object的hashCode不同。大家记住一个开发规范重写equals必须同时重写hashCode否则HashMap的桶定位直接失败。正确写法可以参考public class Order { private final String orderId; private final long userId; public Order(String orderId, long userId) { this.orderId orderId; this.userId userId; } Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof Order)) return false; Order order (Order) o; return userId order.userId Objects.equals(orderId, order.orderId); } Override public int hashCode() { return Objects.hash(orderId, userId); } }注意这里的字段最好是不可变的。如果hashCode依赖的字段可以变put之后再修改字段对象的hash会变但它在桶中的位置还是按旧hash计算的结果就是get时按新hash定位不到原桶这个key就永久“丢失”了。规避办法是让key字段全部final或者干脆用String、Integer这类不可变类型做key。6.2 遍历中删除元素的三种正确姿势在for-each循环里直接map.remove()会抛ConcurrentModificationException这一点很多新手踩过。正确做法有三条一是用Iterator显式遍历并调用iterator.remove()二是用map.entrySet().removeIf(e - 条件)三是先收集要删除的key循环结束后再统一remove。第三种在数据量大时内存开销略高但胜在逻辑清晰。如果并发遍历且别的线程可能修改Map则必须用ConcurrentHashMap它的迭代器是弱一致性的不会抛fast-fail异常但不保证一定看到所有最新改动。6.3 hashCode均匀分布比想象中更重要HashMap的性能前提是hash分布均匀。如果自定义hashCode写得很差比如只返回固定值或只用某个字段的低位大量元素会挤在同一个桶里put/get的复杂度直接从O(1)恶化成O(n)。我曾经在日志里看到一条线上查询耗时从毫秒级涨到几百毫秒定位结果就是某个枚举对象的hashCode实现太粗糙所有实例hash都一样整个Map退化成一条大链表。排查方法很简单在元素数量较大时打印每个桶的链表长度分布如果超过几个桶出现上百长度的链表基本可以判定hashCode出现了严重碰撞。解决这类问题的思路有两个层面一是从Key设计上避免尽量使用分布性好的字段参与hash计算并善用Objects.hash组合多个字段二是从HashMap角度兜底容量的2次幂特性和扰动函数只能改善低位分布无法拯救一个本质劣质的hashCode。更极端的方法是为Key提供独立的hashStrategy但这只有在自己实现Map时才可行常规Java场景不建议过度设计。6.4 我在实际项目里的操作系统级建议从工程角度看HashMap本身是单线程性能优秀、并发场景取舍明确的数据结构。选型时先问三个问题容器会不会被多线程同时写会不会有频繁扩容Key的equals/hashCode是否可靠其中任一问题的答案是否定的就停下来重新设计。很多线上问题不是HashMap的锅而是把HashMap用在了不该用的场景。我在一次促销活动服务中见过团队用“双重检查锁HashMap”做本地缓存看起来加了锁很安全实际上并发穿透时两个线程各put各的value是不同批次的数据导致大量请求读到过期值——后来换成ConcurrentHashMap并去掉锁问题彻底消失。HashMap是工具不是银弹理解它的安全边界和性能特性才能真正把它用得顺手。
返回列表