ARTICLE DETAIL

资讯详情

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

面试必问HashMap:底层原理、设计取舍与高频面试题全解析

面试必问HashMap:底层原理、设计取舍与高频面试题全解析 面试阿里的场景HashMap基本是躲不掉的。几乎每一轮技术面都会把它拎出来问一遍而且问法千奇百怪从“HashMap底层结构是什么”到“为什么容量是2的幂次方”再到“JDK7和JDK8的HashMap有什么区别”“HashMap线程不安全具体体现在哪里”。很多人背了一堆八股结果面试官一问“为什么”当场就露馅了。这篇文章我打算换个思路不按源码顺序逐行讲而是按面试提问的逻辑来梳理。把HashMap底层实现原理拆成几个必须吃透的模块讲清楚每个设计背后的为什么再附上我多次面试和被面试中积累的答题经验。如果你正在准备大厂面试或者工作几年了但还没真正啃过HashMap源码这篇应该能帮你省下不少时间。1. 面试前必须建立的心智模型HashMap底层到底长什么样很多同学第一次看HashMap源码直接点开put方法然后被一堆位运算和红黑树搞得一头雾水。其实正确姿势是先建立整体心智模型HashMap本质上就是一个“数组 链表 红黑树”的复合结构JDK8及以上。JDK7及以前只有“数组 链表”。用做生活化的类比来解释想象一个大图书馆里面有许多书架数组的槽位每个书架上本来只放一本书。如果两本书的编号正好对应到同一个书架哈希冲突那就把它们串成一串挂在同一条链子上链表。后来书越来越多某个书架上挂的链子特别长找书就得从头往后翻效率变低了。于是图书馆定了条规矩链子超过一定长度就把这条链子改造成一个小型索引树红黑树查书效率从O(n)变成O(log n)。这个心智模型能帮你回答90%的基础问题。面试官问底层结构你把这个类比讲出来再补一句“JDK8在链表长度超过阈值8且table容量不小于64时会把链表树化”第一印象基本就稳了。1.1 数组链表红黑树数据结构演进背后的设计逻辑先说数组那部分。HashMap用数组存数据下标是通过key的哈希值计算出来的。数组有个天然优势按下标随机访问的复杂度是O(1)。这就是HashMap在理想情况下查找能到O(1)的根本原因——理想情况是每个桶里最多只有一个元素。但哈希函数做不到完全均衡两个不同的key可能算出同样的下标这就是哈希冲突。解决冲突最直接的办法就是在冲突的位置拉一条链表出来。新来的元素挂到链表上这叫作“拉链法”也叫链地址法。链表的问题是极端情况下会退化。如果大量key算出来落在同一个桶里链表长度涨到成百上千查询就变成O(n)HashMap的性能会急剧恶化。为了解决这个极端情况JDK8引入了红黑树当链表长度超过8且table容量大于等于64时链表转红黑树当树节点减到6以下时再退化成链表。这里注意两个数字树化阈值是8退化阈值是6中间留了2的缓冲。为什么留缓冲如果阈值都设成8那么链表长度在7、8之间来回晃的时候就会反复树化和退化性能抖动很严重。设成6和8树化不会那么容易被触发换来的是稳定性。红黑树本身是一个自平衡二叉搜索树。它的好处是保证最坏情况下增删查的复杂度是O(log n)。代价是节点结构更复杂每个树节点占用空间大约是链表节点的两倍多了颜色标记、左子节点、右子节点、父节点几个字段。所以HashMap也不是一见链表就树化——短链表上遍历不一定比树慢树节点占内存还大只在链表足够长时才值得转树。1.2 不得不背的几个关键参数容量、负载因子、树化阈值有几个参数建议你直接背下来因为它们几乎是面试必问初始容量DEFAULT_INITIAL_CAPACITY16也就是创建HashMap不指定容量时table数组长度为16。最大容量MAXIMUM_CAPACITY2^30也就是1 30。负载因子DEFAULT_LOAD_FACTOR0.75f。树化阈值TREEIFY_THRESHOLD8链表节点数大于等于8时尝试转红黑树。退化阈值UNTREEIFY_THRESHOLD6扩容或删除时树节点数降到6以下红黑树转回链表。最小树化容量MIN_TREEIFY_CAPACITY64就算链表长度到8如果table数组长度小于64也不会树化而是先扩容。面试官特别爱问“负载因子为什么是0.75”。这个要从时间和空间的权衡来看。负载因子越大比如1.0意味着数组快装满了才扩容空间利用率高但哈希冲突的概率明显增大桶里的链表变长查询效率下降。负载因子越小比如0.5冲突少、查找快但数组经常处于半空状态浪费内存。0.75是JDK团队在大量性能测试后取的一个折中值在时间和空间之间做到了一个相对均衡。至于为什么树化阈值是8源码注释里有一段泊松分布的推演在负载因子0.75下单个桶内链表长度达到8的概率大概是千万分之一这个概率已经低到可以认为是一种极端情况。所以设计者选了8作为阈值意思是正常情况下你根本不会看到链表转树一旦转树说明hash函数的分布出现了严重问题必须靠树化来兜底。扩容阈值threshold等于当前容量乘以负载因子。比如默认容量16负载因子0.75阈值就是12。当你put进去的元素个数超过12时HashMap就会触发扩容数组翻倍到32阈值变成24。这里注意判断条件是“元素个数”而不是“某个桶的元素个数”所以就算数据分布得很均匀只要总数量到了阈值就会扩。1.3 一次put操作经历了什么完整流程拆解面试手撕HashMap流程很多人会漏细节。我来把put的完整流程捋一遍面试时按这个顺序讲基本不会遗漏。第一步检查table数组是否为空首次put时table还没初始化会调用resize()方法做初始化默认给16的容量。这是懒加载设计——不是new HashMap就立刻分配数组而是第一次put才分配。第二步计算key的hash值。注意不是直接用key.hashCode()而是把hashCode的高16位和低16位做异或运算h key.hashCode() ^ (h 16)。这个操作叫“扰动函数”目的是让高位信息也能参与低位的下标运算减少冲突。因为数组下标计算用的是hash (n-1)而n通常比较小比如16n-1的二进制低位基本都是1、高位全是0如果直接用原始hashCode高位信息就全丢了碰撞概率会增加。第三步定位桶下标i hash (table.length - 1)。这里用位运算代替取模运算前提是table长度必须是2的幂次方。这也是HashMap强制容量为2的幂次方的第一个原因。第四步看table[i]是否为null。为null就直接newNode放进去不为null说明这个桶已经有元素了需要处理哈希冲突。这里又分几种情况如果table[i]的hash和key都与当前key相等说明是同一个key的覆盖操作直接替换value即可。如果table[i]是红黑树节点走红黑树的插入逻辑。否则就是链表遍历链表找有没有相同key有就覆盖没有就尾插法追加到链表末尾。追加后检查链表长度是否超过8超过且数组长度大于等于64就调用treeifyBin转红黑树。第五步put完之后HashMap会检查一个全局变量size——当前键值对的数量——如果size超过threshold就调用resize()扩容。整个过程有一个值得强调的细节JDK8之后采用尾插法把新节点插到链表尾部JDK7是头插法插到头部。这个区别是面试中高频考到的点后面讲线程安全会具体说。2. 高频面试题逐一拆解从原理到延伸这一节我按实际面试中“问到停不下来”的提问链来整理。面试官一般从底层结构问起然后追问性能设计最后落到并发安全。你顺着这条线复习比零散背知识点有效得多。2.1 为什么HashMap的容量必须是2的幂次方这个问题我几乎每次面试都被问到而且经常连环追问。你需要从两个角度来回答。第一个原因是性能。HashMap计算元素应该放在哪个桶里标准做法是用hash值对数组长度取模hash % length。但取模运算涉及除法在CPU层面比较慢。如果length是2的幂次方length - 1的二进制就全是低位1这时hash % length等价于hash (length - 1)一个按位与的操作CPU只需要一个时钟周期就能算完。对于put和get这种高频操作性能差异在大量调用时非常明显。第二个原因和扩容有关。2的幂次方容量在扩容时有个无比优雅的性质元素在新数组中的位置要么在原下标位置要么在原下标加上旧容量的位置。因为容量翻倍相当于n的二进制多了一个1位之前参与下标运算的位多了一位。元素要不要挪位置就看原hash值得这个“多出来”的位是0还是1。是0位置不变是1位置变成oldIndex oldCap。代码里用(e.hash oldCap) 0来判断如果为0就不动不为0就挪到oldIndex oldCap。这个判断也是位运算非常快。面试官可能会补一刀“如果我new HashMap(15)容量是多少”答案是16。因为HashMap会把用户传入的容量向上取到最近的2的幂次方。源码里有一个专门的方法tableSizeFor通过一连串无符号右移和或运算把最高位以下的位全部填成1再加1就得到了最接近且大于等于传入值的2的幂次方数。这里要提醒一下如果你知道数据量大概是100个最好直接new HashMap(128)之类让table数组一次性给够避免中途多次扩容。如果预估不了也可以借用一个现成思路初始容量 (预估元素个数 / 负载因子) 1这样能减少扩容次数。2.2 hash方法高16位异或低16位到底在做什么直接看源码里那段static final int hash(Object key) { int h; return (key null) ? 0 : (h key.hashCode()) ^ (h 16); }key为null时直接返回0所以HashMap允许存null键null键会固定在桶下标0。非null时把hashCode无符号右移16位再和原hashCode做异或。这步操作的价值要从数组下标计算说起。下标计算用的是hash (n-1)如果n比较小比如默认16n-1就等于15二进制是0000...00001111。这个掩码只有低4位是1意味着无论hash值的高位是什么都会被“掩掉”最后决定下标只靠低4位。那么问题来了如果你的key都是同一个类产生的而hashCode低4位又碰巧都一样那就全被分到同一个桶里去了。JDK团队想了个成本极低的办法把高16位和低16位异或一下让高位的特征也能“混入”低位这样就算原始hashCode低位分布不好经过扰动之后也能相对分散。这个操作只用了两次位运算代价极低却在哈希分布上获得了可观的提升。面试时你把“高位信息参与低位计算、减少碰撞”这个核心讲清楚就够了。如果面试官继续追问“那为什么是异或而不是与或”你就说异或的结果0和1的比例更均衡信息损失最小与操作会让结果偏向0或操作会偏向1都会让分布不均匀。这一步答出来就加分了。2.3 扩容机制什么时候扩、怎么扩、扩容后元素怎么放扩容是HashMap性能设计里的核心一环也是面试必问。先说时机当HashMap中的元素个数size大于threshold时触发扩容。threshold capacity * loadFactor默认就是16 * 0.75 12。不是某个桶冲突严重就扩而是全局元素数量到了阈值就扩。这是一种“宏观感知”的策略整体数据变多了hash冲突的概率整体上升所以全局扩容来提高桶的分布密度。扩容过程JDK8做了重大优化。JDK7的扩容会把所有元素重新计算一遍hash然后通过头插法转移到新数组。JDK8则基于前面说的2的幂次方性质做了一个非常巧妙的优化元素在新数组的位置只取决于新增的那个“位”是0还是1。具体流程用一段伪代码辅助理解// 遍历旧数组的每个桶 for (NodeK,V e : oldTab) { if (e ! null) { // 该桶为null或只有一个节点时直接转移 // 树节点走split方法 // 链表节点拆成lo和hi两条链 if ((e.hash oldCap) 0) { // 保持原下标挂到loHead链上 } else { // 移动到 oldIndex oldCap挂到hiHead链上 } // 把lo链放到newTab[j]hi链放到newTab[j oldCap] } }其中e.hash oldCap这个位运算非常关键oldCap是旧数组长度是个2的幂次方二进制只有一个1。与hash值做与运算其实就是在检查hash值对应oldCap那一位是0还是1。是0说明扩容后多出来的那一位也是0元素下标不变是1说明下标会多出oldCap这么多。整个转移过程不需要重新计算每个key的hash值不需要取模只需要两次位运算和链表拼接操作。JDK8在扩容这块的性能提升主要就来自这里。扩完之后threshold会相应更新为newCap * loadFactor容量和阈值等比放大。2.4 线程安全问题JDK7环形链表问题、JDK8的改进与遗留问题HashMap线程不安全是面试重中之重。你需要能说清楚两个版本的具体问题。JDK7的问题出在扩容时的头插法。扩容是在线程执行put时触发的多个线程同时扩容会把旧数组的元素迁移到新数组。如果两个线程同时处理同一个桶线程A刚把节点串到一半线程B又把顺序反过来了就可能出现环形链表。下一次在这个桶上做查询for循环遍历链表就会陷入死循环导致CPU飙升到100%。这是JDK7一个著名的并发Bug。JDK8改成尾插法新节点追加到链表尾部避免了扩容时链表倒置的问题所以JDK8之后不会再因为扩容而出现环形链表。但这不是说JDK8的HashMap就线程安全了。它依然不是线程安全的主要问题变成多个线程同时putsize的统计会丢失更新桶数组的节点可能被覆盖。举个例子两个线程同时判断table[i]为null同时往table[i]放节点后放的那个会覆盖先放的那个数据就丢了一条。还有一种典型场景两个线程同时触发扩容一个线程刚把数组换掉另一个线程在旧数组上继续操作最后新旧数组相互覆盖数据直接错乱。所以结论是多线程环境必须用ConcurrentHashMap这是面试官希望听到的答案。如果你能补一句“用Collections.synchronizedMap也行但锁粒度太大并发度低性能不如ConcurrentHashMap”那就更显示你思考过方案选型。再往深了说ConcurrentHashMap在JDK8之后放弃了分段锁改用CAS synchronized锁住桶头节点锁粒度从Segment细到了每个桶并发度进一步提高这个知识点也建议提前准备好。3. 横向对比面试官爱问的HashMap“亲戚关系”面试官问HashMap往往不会只问一个类他喜欢把HashMap和它的“亲戚们”放在一起让你对比考察你知识体系的完整度。3.1 一份可以直接背的对比表维度HashMapHashtableConcurrentHashMapLinkedHashMapTreeMap线程安全否是是否否允许null键值键和值都允许null都不允许null大版本差异JDK8及之后键不允许null值不允许null键和值都允许null键不允许null值允许null底层结构数组链表红黑树数组链表数组链表红黑树JDK8数组链表红黑树双向链表红黑树是否有序无序无序无序保持插入顺序按键的自然顺序或Comparator排序默认容量16111616不适用定位性能O(1)O(1)O(1)O(1)O(log n)这张表建议你亲手整理一遍不要直接背而是通过看源码确认每一项记忆会牢很多。有一个特别容易被忽略的细节ConcurrentHashMap为什么在JDK8之后不允许存null这个至少有两种解释流传一是说CLH锁的语义问题二是说并发场景下null值会产生二义性——如果get返回null你分不清是“这个key不存在”还是“这个key对应的值本来就是null”。我倾向后者因为并发场景下如果你允许null值那么get返回null时到底要不要加锁去确认这会让并发代码变得很别扭。这个点如果面试时能主动提出来会显得你真的思考过设计取舍。3.2 为什么有了HashMap还需要这些“亲戚”一句话总结不同业务场景有不同需求HashMap只解决“通用的无序遍历”问题解决不了线程安全、有序性、自动排序这些问题。Hashtable是老古董所有方法都用synchronized加锁粒度是整个map并发一高就卡到爆炸。早年没有ConcurrentHashMap时大家凑合着用现在基本只出现在面试题里了。ConcurrentHashMap是并发场景下的正解。JDK7用分段锁把整张表拆成若干个Segment每个Segment是一把锁不同线程操作不同Segment时可以并行。JDK8把分段锁改成CAS synchronized锁桶头节点锁粒度更细而且读操作几乎无锁put操作只有在桶头节点不为null时才需要synchronized锁住当前桶。这把锁的粒度从“一段”细化到“一桶”并发能力又上了一个台阶。LinkedHashMap是在HashMap基础上加了一条双向链表用来记录插入顺序或访问顺序。访问顺序模式accessOrdertrue配合重写removeEldestEntry方法可以非常轻松地实现LRU缓存这是面试官很爱问的一个点。TreeMap底层是一棵红黑树按键排序存储。需要范围查询、按序遍历、取最大最小值时TreeMap比HashMap合适。但因为要维护树的平衡性插入和删除的复杂度是O(log n)比HashMap的平均O(1)要慢。4. 实操环节从“看得懂”到“用得好”面试不只是考概念特别是一些注重工程能力的团队会直接让你写代码。HashMap相关的手写题大概有这么几类我把自己练过的一些方法和技巧分享出来。4.1 如何高效阅读HashMap源码很多人拿到源码不知道从哪看起我的建议是带着问题看而不是从头到尾读。第一步先看静态常量和构造函数搞清楚有哪些配置项、默认值是什么。第二步看hash方法和tableSizeFor方法这两个方法最短却最能体现设计思想。第三步看put方法它是HashMap操作的主干你会发现putVal方法里的逻辑几乎覆盖了所有数据结构的分支处理。第四步看resize方法这是最复杂的一个方法耐心对照JDK7的版本看差异。最后再看get、remove、treeifyBin这些方法就轻松多了。读源码时强烈建议配一个调试工具。在put方法里打几个断点构造几个hash冲突的key单步执行你会直观看到链表怎么挂、树怎么转、扩容怎么迁移。有一类专门用来制造哈希冲突的测试key网上搜“HashMap冲突字符串”能找到现成的它们hashCode值相同但equals不同能让链表瞬间变长对理解树化过程特别有用。4.2 经典场景实战分组统计、去重计数与LRU缓存分组统计是HashMap最经典的实际应用。比如统计一张订单表里每个商品类目的销量套路就是遍历订单用类目id做key用计数器做value有则加一无则初始化为1。MapString, Integer categoryCount new HashMap(); for (Order order : orderList) { categoryCount.merge(order.getCategoryId(), 1, Integer::sum); }注意这里用了merge方法比先判断containsKey再put要简洁得多还避免了put和检查之间的并发窗口。JDK8之后Map接口加的computeIfAbsent、merge这些方法在实际开发中非常常用面试时顺手提一下能加分。去重计数的场景也类似有一批用户ID要统计去重后有几个直接塞进HashSet就行。HashSet底层其实就是HashMap只是把所有value都指向一个固定的Object占位符。LRU缓存是另一个高频面试题。用LinkedHashMap实现非常方便class LRUCacheK, V extends LinkedHashMapK, V { private final int capacity; public LRUCache(int capacity) { super(capacity, 0.75f, true); this.capacity capacity; } Override protected boolean removeEldestEntry(Map.EntryK, V eldest) { return size() capacity; } }关键在于第三个参数accessOrder设为true这样LinkedHashMap就会按照访问顺序排列节点每次get都会把命中的节点移到链表尾部。当size超过capacity时队首那个最久没被访问的节点就会被淘汰。这个实现写起来不过十行但背后涉及HashMap的哈希定位、双向链表的节点维护、访问顺序调整三个机制能讲明白非常显功力。4.3 手写一个简化版HashMap从零理解原理如果面试官让你手写一个迷你HashMap别慌。核心就是数组加链表我提供一个参考实现思路public class SimpleHashMapK, V { private static final int DEFAULT_CAPACITY 16; private NodeK, V[] table; private int size; 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 (NodeK, V[]) new Node[DEFAULT_CAPACITY]; } private int hash(K key) { int h key.hashCode(); return h ^ (h 16); } private int indexFor(int hash) { return hash (table.length - 1); } public V put(K key, V value) { int index indexFor(hash(key)); NodeK, V head table[index]; if (head null) { table[index] new Node(hash(key), key, value, null); size; return null; } // 遍历链表找到相同key则覆盖 for (NodeK, V node head; node ! null; node node.next) { if (node.key.equals(key)) { V oldValue node.value; node.value value; return oldValue; } } // 找不到则尾插 NodeK, V last head; while (last.next ! null) { last last.next; } last.next new Node(hash(key), key, value, null); size; return null; } public V get(Object key) { SuppressWarnings(unchecked) K k (K) key; int index indexFor(hash(k)); for (NodeK, V node table[index]; node ! null; node node.next) { if (node.key.equals(k)) { return node.value; } } return null; } }这个简化版省略了扩容、红黑树、null值处理但保留了HashMap最核心的设计数组定位、哈希扰动、链表冲突处理。面试时如果时间有限写出这个版本已经能证明你理解了基本机制。手写完之后面试官如果让你加上扩容逻辑你就把数组长度翻倍、重新分配节点写进去注意别绕晕逻辑和源码resize要一致。5. 面试答题思路与避坑指南这一节是我最想说的。很多技术不错的人挂在HashMap这道题上不是不知道知识点而是不知道怎么组织答案。面试官问一个开放性问题比如“讲讲HashMap的底层实现”如果你只背结论说“数组加链表加红黑树”三秒钟结束面试官连追问都找不到角度。他其实是想借这个题看你的思维方式和表达结构。5.1 面试官问HashMap时到底想看什么第一层看你基础扎不扎实。数组为什么O(1)链表为什么O(n)红黑树为什么O(log n)这些数据结构基础得脱口而出。第二层看你有没有“设计感”。同样是解决查找问题为什么选数组做主干而不是链表为什么负载因子要取0.75为什么树化阈值是8而不是4或16这些问题没有标准答案但考察的是你权衡取舍的能力。第三层看你有没有工程意识。线程安全、性能退化、内存占用这些问题在真实业务中一定会碰到面试官想确认你不是只在教科书里写过Java。所以回答HashMap相关问题时建议按“结构 - 操作 - 设计取舍 - 并发问题 - 工程实践”这条线展开。结构讲几段式操作讲put和get的流程设计取舍讲为什么是2的幂、为什么0.75、为什么8和64并发问题讲JDK7和JDK8的差异最后落到工程实践什么场景该用什么Map、怎么预估容量、怎么避免频繁扩容。5.2 常见错误回答盘点我听过不少候选人犯一个同样的错误把Hashtable和HashMap搞混说“HashMap线程安全”。这属于致命伤一旦说出来面试官基本可以判定你对并发基础没概念。还有一种错误是只背结论不问原因。比如问“为什么负载因子是0.75”直接说“源码里就是这么写的”。这等于把送分题变成送命题。哪怕你是真不确定原因也完全可以倒推负载因子是空间和时间的折中0.75偏保守让HashMap保持较多空桶来减少冲突同时又不至于浪费太多内存。这个回答逻辑是正确的。再有就是树化条件记不全。很多人只记得“链表长度超过8转红黑树”漏了另一个条件“table容量不小于64”。如果容量还不到64就算链表超了8HashMap也只是先扩容而不是直接树化。漏掉这个条件说明你对源码的理解停留在背诵层面没有形成完整逻辑链。还有一个不太容易察觉的坑把时间复杂度记错。链表是O(n)红黑树是O(log n)HashMap平均是O(1)但最坏情况所有key都落在同一桶且是链表是O(n)。如果你能主动提起“最坏情况”反而体现出你考虑问题全面。5.3 如果面试官追问该怎么接HashMap的追问方向我总结过大概有六个方向追问hash函数设计为什么右移16位异或运算和与运算、或运算比好在哪追问扩容过程JDK8的扩容为什么不需要rehash元素位置怎么确定的追问红黑树红黑树的特性是什么为什么不直接用二叉搜索树为什么不直接转成AVL树追问线程安全ConcurrentHashMap的锁粒度演进CAS在什么场景下用追问源码细节map.put返回的是什么答案是旧value这个细节很容易被忽略但面试官爱问。追问最佳实践预估数据量时初始容量怎么设如何避免多次扩容遇到自己真不会的有个实在的经验不要硬编。直接说“这个点我平时没有深入研究过我目前的理解是xxx如果不对请指正”。面试官看重的是诚实和学习能力比支支吾吾好得多。而且HashMap是可深可浅的题答不出最深的点不致命致命的是给错误信息。比如“JDK7并发put一定会造成死循环”这种绝对化说法就是错的只能说“在某些并发条件下可能”。给结论时留有余地也是技术人员做事的习惯。最后再分享一个小技巧如果面试官让你“讲讲HashMap”你先反问一句“您想听我讲底层的实现细节还是更关注并发和设计取舍”这个问题不是滑头而是能帮你精准定位面试官考察范围避免长篇大论讲偏重点。通常面试官会告诉你他想听的方向你只需要顺着他的方向把细节讲透、把为什么讲清楚这一轮基本就稳了。
返回列表