ARTICLE DETAIL

资讯详情

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

哈希表与HashMap核心原理:从数组到红黑树

哈希表与HashMap核心原理:从数组到红黑树 作为Java基础里绕不开的一个知识点哈希表HashMap绝对能排进“面试被问烂但真正懂的人不多”的前三名。我带新人和做面试官的过程中经常发现两类情况一类是把HashMap当字典用知道put和get被问到“哈希表数据结构”的原理时只能背出“数组加链表”再问深一点就含糊另一类是把源码记得很熟但遇到一个HashMap导致的内存异常问题完全不知道从哪下手。这篇文章我想用从业者的视角把哈希表的核心原理、JDK里的具体实现、日常使用最容易踩的坑以及Java面试题里真正会考到的细节点一条线串起来。无论你是零基础开始学Java基础还是在背Java面试八股文准备跳槽又或者是工作里遇到了和HashMap相关的性能问题这篇都值得收藏。1. 哈希表是什么先搞懂这个数据结构1.1 从数组讲到哈希表要真正理解哈希表得先回顾一下数组。数组最大的优势是通过下标访问是O(1)只要知道下标一次就能命中。但缺点是如果只是存值你想按某个属性比如姓名来找对应元素就不得不把整个数组遍历一遍最坏情况是O(n)。哈希表的思路特别朴素给每个对象算出一个“下标”然后用这个下标去数组里存或取。相当于在任意对象和数组下标之间建立一层映射这层映射就是哈希函数。你可以把哈希函数想象成电影院的取票机——自己的名字是Key取票机按规则打出一张座位号你拿着座位号直接去找对应位置不用挨个座位翻找。所以哈希表在理想情况下的查找时间复杂度是O(1)这是它区别于链表、树结构的最大优势。Java里的HashMap本质上就是一个“散列表”它用哈希函数把key映射到数组的一个槽位槽位下再挂链表或红黑树来解决冲突。这也是为什么它叫“哈希表数据结构”——它不只是Java的一个工具类更是一套“如何快速定位数据”的通用方法论。1.2 哈希函数把Key变成索引的关键一步在Java里每个对象都有一个hashCode()方法这个方法就是哈希函数在语言层面的入口。默认的Object.hashCode()通常和对象的内存地址有关具体和JVM实现相关但对我们日常开发来说大多数自定义对象直接用默认hashCode意义不大因为两个内容相同的对象默认hashCode大概率不一样。所以把自定义对象放进HashMap时通常要重写hashCode()让内容相同的对象能算出相同的哈希值。比如一个Person类有id和name你可以这样写Override public int hashCode() { return Objects.hash(id, name); }这个计算规则没有唯一答案核心要求就一条由equals()判断为相等的两个对象hashCode()必须相等。反过来并不强制也就是说hashCode()相同的对象可以不等于对方这种情况叫哈希冲突后面会重点讲。还有一个细节很多人没注意hashCode()返回的是一个int范围很大但HashMap底层数组的容量是有限的所以真正存储的时候需要把hashCode“压缩”到数组容量范围内。JDK里用的并不是简单的取模而是“高位异或扰动”加“按位与”具体逻辑在源码章节再展开。你现在只需要记住哈希函数设计得好不好直接决定后面冲突多不多这也是为什么面试官总爱追着hashCode和equals问。2. 哈希冲突与扩容机制HashMap的两个核心设计2.1 哈希冲突的常规解法理论上再好的哈希函数也没办法保证每个key都映射到不同的槽位。比如数组容量只有16两个不同对象的hashCode经过压缩后得出的下标完全可能相同。这个情况就叫哈希冲突。解决哈希冲突的常见方案有几种链地址法拉链法数组的每个槽位挂一个链表冲突的数据都挂在同一个槽位下。Java的HashMap就是这种方案。开放寻址法发生冲突后按规则继续往后找空位。ThreadLocalMap里用的就是线性探测适合数据量小、冲突率不高的场景。再哈希法换一个哈希函数重新计算直到找到空槽为止。链地址法最好理解也最好扩展。但隐患在于如果大量数据撞进同一个链表查询效率就会从理想的O(1)退化成O(n)。Java对这个问题的应对有两个方向一是让哈希值本身尽量分散二是把链表过长的情况兜底成红黑树详细写在第三章。2.2 加载因子与扩容时机接下来是HashMap设计上最容易忽略、面试又最爱考的点加载因子loadFactor和扩容resize。HashMap内部维护着两个核心数量当前节点数size以及底层数组的容量capacity也就是table.length。当size超过capacity乘以loadFactor时就会触发扩容数组长度直接翻倍。默认loadFactor是0.75这个值不是拍脑袋定的它是在时间成本和空间成本之间取的平衡——0.75意味着数组用到四分之三才扩容空间利用率不算低同时冲突率也被控制在可接受范围。如果你能预估数据量最好在创建时手动指定capacity这样可以有效避免多次扩容带来的性能损耗。扩容的过程不是简单把数组变大而是要重新计算每个节点在新数组中的位置。因为容量变了原先“hash (n - 1)”算出来的下标大概率也会变。这个过程叫rehash是扩容最耗时的环节。所以如果你知道大概的数据规模一开始就指定初始容量能省掉很多轮resize。实际业务里我见过不少因为初始化map太小数据一多就频繁扩容导致接口变慢的案例很多时候加一行容量参数就解决了。2.3 为什么容量必须设置为2的幂次方这个问题几乎每个面试官都会问同时也是理解HashMap源码的钥匙。HashMap的容量永远是2的幂次方就算你new HashMap(3)内部也会自动转成最近的2的幂也就是4。这样设计有两个实打实的好处。第一计算下标快。如果用取模运算hash % n虽然也能算但位运算比取模更快而n正好是2的幂时hash % n等价于hash (n - 1)。对一条指令就能完成的位运算你怎么优化都不过分。源码里到处出现的(n - 1) hash本质就是取模。第二扩容时计算新位置简单。数组从n扩大到2n之后节点的新下标要么不变要么变成“原下标旧容量”。原因很巧妙n - 1的二进制原来是111...扩充后变成1111...只多出了一位这一位由hash值的对应位决定是0还是1。所以源码里扩容才有那句经典判断if ((e.hash oldCap) 0) { // 留在原位置 } else { // 移动到原位置 oldCap }这个设计让rehash时不用重新整表扫描只要看新增的那一位是0还是1就行。理解了这个点再看HashMap源码就会顺很多。3. 源码级拆解JDK中HashMap的put和get流程3.1 Java 8之后的数组链表红黑树结构Java 8之前HashMap就是数组加链表。如果哈希函数写得稀烂或者有人故意构造key让其哈希值全都相同链表会变得非常长严重时HashMap会被拖慢成O(n)级别。这也是早期一些攻击方法能把HashMap变成拒绝服务弱点的原因之一。所以Java 8之后底层结构升级为数组链表红黑树。当某个槽位的链表长度超过TREEIFY_THRESHOLD默认8且数组容量超过MIN_TREEIFY_CAPACITY默认64时链表会转成红黑树。红黑树查询复杂度是O(log n)比链表的O(n)靠谱得多。为什么阈值选8源码注释里给了泊松分布的计算结果在负载因子0.75的情况下同一个槽位链表长度达到8的概率大约是千万分之一正常情况下几乎不可能自然产生。一旦出现要么是hashCode实现有问题要么是遇到了人为构造的恶意输入这时用红黑树兜底最合适。反方向也有一个阈值当红黑树的节点数因为删除等操作减少到UNTREEIFY_THRESHOLD默认6时会退化成链表。8和6之间故意留了两位的冗余就是为了避免在临界值附近反复横跳导致树化和退化来回切换白白消耗性能。3.2 手把手拆解put流程假设现在执行map.put(apple, 1)内部发生的事情可以拆成下面这几步计算apple的hashCode()。对这个hash值做扰动处理高16位和低16位做异或。这样做的目的是让高16位的信息也参与低位计算降低冲突概率。如果底层的table数组还没初始化先调用resize()完成初始化。用(n - 1) hash算出在数组中的下标。如果这个槽位是空的直接new一个Node放进去。如果槽位不为空说明发生冲突。这时候分两种情况当前节点是红黑树节点走红黑树的插入逻辑。当前节点是链表节点遍历链表逐个比较key是否相等。如果找到hash值相同且equals相等的key就覆盖value并返回旧value如果整条链表都没有匹配的key就在链表尾部插入新节点。Java 8之前是头插法Java 8之后改成了尾插法这也是修复并发扩容时可能形成循环链表的一个关键改动。插入完成后判断size threshold是否成立成立就扩容。用一句大白话总结put流程算位置看冲突有则覆盖无则插入最后检查要不要扩容。整个流程的时间消耗主要取决于冲突后的链表或红黑树有多长。3.3 get流程与hashCode/equals约定get的流程比put简单。先算hash再用(n - 1) hash定位数组下标然后在这个槽位的链表或红黑树里逐个比较。比较的时候先比hash值再比equals两个条件都必须满足才算命中。这里就引出了Java基础里一个著名的约定重写equals()必须同时重写hashCode()。原因一句话可以解释HashMap先用hashCode定位到槽位再用equals确认是不是目标key。如果两个对象equals相等但hashCode不同它们就会被放到不同的槽位get的时候根本找不到反过来如果hashCode相同但equals不等两个对象会落在同一个槽位虽然能通过equals区分开但链表会被拉长影响性能。我在实际review代码时见过最常见的错误是只重写了equals()或者hashCode()用了一个随业务状态变化的字段。结果就是对象作为key存进HashMap后字段被改了一下再get就永远返回null了。这类问题定位起来很费时因为不看数据流单从代码表面很难发现。4. 实战使用与并发安全哪些坑我替你踩过了4.1 HashMap的基本操作与遍历方式HashMap的常规操作其实非常简单MapString, Integer map new HashMap(); map.put(apple, 1); map.put(banana, 2); map.put(orange, 3); Integer value map.get(apple); // 1 boolean exists map.containsKey(banana); // true map.remove(apple); map.size();遍历方式的选择上我的建议有优先级for (Map.EntryString, Integer entry : map.entrySet())。最推荐一次就能拿到key和value不会产生额外查询。for (String key : map.keySet())。如果每个key还要再get一次相当于遍历过程中又查了一遍哈希表性能会差一些但胜在写法直观。Java 8之后的map.forEach((k, v) - ...)。代码简洁日常够用。用Iterator迭代器遍历。最大优势是可以在遍历过程中安全删除元素entrySet().iterator().remove()是允许的。for-each里直接调map.remove()会抛ConcurrentModificationException别问我怎么知道的。这里有个非常隐蔽的坑遍历过程中不能修改HashMap的结构性变化。有一次我在循环里写map.remove(key)以为删除自己的key没关系结果运行到一半直接抛异常。原因是HashMap内部维护了一个modCount字段每次结构变化都会加一迭代器在next()时会检查modCount有没有变变了就立刻报错。安全做法是用iterator.remove()或者先把要删的key收集到List里遍历结束后再统一删除。4.2 重写hashCode和equals的正确姿势还是用一个实际例子来讲。假设有一个User类业务上两个User只要id相同就算同一个用户那么equals()就该基于id判断hashCode()也要基于id计算。如果只重写equals不重写hashCode放进HashMap后你用另一个id相同的User实例去get返回的很可能就是null。正确写法可以直接借助JDK的Objects工具类Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof User)) return false; User user (User) o; return id user.id; } Override public int hashCode() { return Objects.hash(id); }这里我多说一句尽量不要让可变字段参与hashCode计算。比如一个订单对象有status字段你把它放进HashMap之后再改了status它的hashCode就变了用原key再去get会找不到。被这种问题坑过的人应该不少而且排查起来真的会让心态崩掉。4.3 并发场景别用HashMapHashtable、synchronizedMap与ConcurrentHashMapHashMap本身不是线程安全的。多线程同时put可能发生数据覆盖JDK 7时代扩容时头插法还可能造成循环链表导致get的时候死循环。JDK 8改成尾插法后这个死循环问题基本不再出现但并发下的数据一致性问题依旧存在。三个替代方案的对比可以看这张表方案线程安全实现缺点适用场景Hashtable所有方法加synchronized全局锁性能差基本不推荐Collections.synchronizedMap包装类方法级synchronized同样是全局锁简单并发场景ConcurrentHashMapCAS加synchronized锁单个槽位实现复杂但稳定绝大多数并发场景ConcurrentHashMap在Java 8之后变化很大放弃了原来的Segment分段锁概念改用CAS配合synchronized只锁住冲突的那个桶。查询时几乎无锁写操作只锁当前槽位并发度比全局锁高出一个量级。所以并发环境下我一般直接选ConcurrentHashMap而不是拿HashMap在外面套一层synchronized——后者在数据量一大时锁竞争会非常明显。5. 面试高频题与线上问题排查5.1 面试八股文的套路与答题思路哈希表相关的Java面试题几乎年年必考。这里把高频考察点整理成一份速查方便你有针对性地准备HashMap的底层数据结构是什么答数组链表红黑树。Java 8之前是数组链表。为什么加载因子是0.75答时间和空间折中。过高会增加冲突过低会浪费空间。为什么容量必须设置为2的幂次方答一方面可以用位运算代替取模另一方面扩容时能通过hash oldCap快速判断新位置。前面已经展开说过。HashMap如何解决哈希冲突答链地址法冲突节点串成链表链表过长且数组容量足够时转红黑树。为什么重写equals必须重写hashCode答HashMap先通过hashCode定位槽位再用equals确认key缺一个就会出问题。HashMap和Hashtable有什么区别答HashMap允许null的key和value非线程安全效率更高Hashtable不允许null方法加了synchronized保护。Java 8对HashMap做了哪些优化答链表转红黑树、尾插法取代头插法、扩容时利用高位判断减少rehash等。ConcurrentHashMap和HashMap的区别答线程安全、锁粒度、底层实现都有差异。回答这些问题时千万别只背结论可以主动说出“为什么”。比如面试官问阈值8你就可以顺带提一下泊松分布和千万分之一的概率这个细节通常很加分。作为面试官我听到候选人能把设计动机讲出来一般会直接在心里给高分。5.2 线上HashMap常见问题速查再分享一份实际排查问题时的经验记录。以下情况都是我或同事在真实项目里碰到过的不是凭空编的现象原因排查方向内存增长很快heap dump里出现大量HashMap$Nodekey无限增多或缓存未设置上限检查是否存在把HashMap当缓存的用法考虑引入LRU缓存或定时清理数据明明put过get却返回null可变对象作为keyput后又修改了参与hashCode计算的字段或没有正确重写hashCode/equals检查key对象的hashCode稳定性确认不可变链表异常长日志里大量key的hash相同自定义对象hashCode实现太差用Objects.hash或设计更好的散列算法并发环境下偶尔丢数据HashMap被多个线程同时修改换成ConcurrentHashMap接口耗时突然变长GC频繁初始化容量太小数据量大导致多次扩容预估数据规模并设置初始capacity这些问题里最容易被忽视的是“可变对象作key”。我的建议很直接HashMap的key尽量用String、Integer这类不可变类型除非你能保证自定义key在生命周期内永远不会被修改。类似这种问题一旦发生定位成本远高于一开始就规避的成本。6. 哈希表思想延伸面试之外还能用到哪6.1 Java集合框架里的其他哈希实现理解了HashMap之后整个Java集合框架里的“哈希家族”基本都能串起来。HashSet底层就是HashMap只是所有的value统一用了一个PRESENT占位对象。LinkedHashMap在HashMap基础上额外维护了一条双向链表用来记录插入顺序所以特别适合做LRU缓存继承它再重写removeEldestEntry方法一个简单的LRU就出来了。Properties类继承自Hashtable平时读配置文件时本质上也在用哈希表。IdentityHashMap则使用引用相等而不是equals来比较key适用于JVM内部或需要按对象身份去重的场景。这些类的底层都围绕一个核心决策展开哈希函数怎么设计、冲突怎么处理、顺序是否需要维护。你抓住这三个维度再去看任何哈希相关的类都会觉得顺理成章。6.2 哈希思想在工程中的扩展哈希表的思想也不局限于Java集合。数据库里有一种索引叫哈希索引专门为等值查询设计效率极高但不支持范围查询。Redis里的哈希对象在字段少、值小的时候用压缩列表字段多了就自动转成哈希表本质也是空间和性能的权衡。布隆过滤器用多个哈希函数把元素映射到一个位数组上能快速判断一个元素“一定不存在”所谓“可能存在”它在爬虫去重、缓存穿透场景里都能派上用场。一致性哈希则把哈希思想应用到了分布式负载均衡解决的是节点增删时大量缓存失效的问题。把这些串起来看你会发现哈希函数解决的本质问题是“如何快速定位数据”这个能力在不同存储和分布场景下都有不可替代的价值。所以即使你现在只是零基础学会HashMap也不亏它后面的思想可以迁移到很多方向。最后再说一点个人体会。我带新人这几年发现真正能拉开差距的往往不是谁背的源码多而是谁能把“为什么这样设计”讲清楚。哈希表看上去也就几十行核心代码但背后牵扯到的数据结构、位运算、并发、内存管理每一层都可以往深处挖。如果你现在还是刚接触Java基础我的建议是先把代码层面的增删改查跑熟再回去读源码最后带着问题去面对面试题。这条路走下来哈希表这块基本就稳了。如果在实际使用中遇到其他有意思的坑欢迎一起交流。
返回列表