题目:HashMap 在 get 和 put 时经过哪些步骤?
难度:medium
标签:java/collections, general/knowledge
评分:⭐⭐⭐☆☆(3/5)
Put,Put 先把 Key 转化成 Hash Code,然后去进行确认存储位置,确认存储位置之后进行比对,看当前位置是否已经存储了元素。如果没有存储元素就放进去,如果有存储元素就对比进行链表叠加,或者是转化成红黑树。然后另外 Put 要判定队列的数量,然后一定比例考虑是否扩容等一系列事件。插入的时候选择,当下 JDK 后期版本使用的是尾插法,不是最开始的头插法。头插法虽然更快,但是会有风险安全问题。尾插法的话不会有出现断链以及循环等极端严重的风险。是有对 key value 的判定,key 不能为空,只能有一个空,在 HashMap 中。
在比对 key 的时候,使用,在 get 的时候将 key 也是转化成 hash code,然后先去找到对应位置,位置之后再依次作比,找到对应的是否是同一个,然后从链表中,如果是在对比链表中,当前桶下的链表中的对象,对比到是哪一个,通过 equals,如果有就去除,如果没有就返回 null。
优点:
不足:
hash() 方法 (h = key.hashCode()) ^ (h >>> 16)——高 16 位异或低 16 位参与计算,减少碰撞;然后 index = (n - 1) & hash 定位桶。整段缺失resize() 时容量翻倍(16→32),元素按新 hash 重新分桶;JDK8 有个关键优化——只判断新 hash 的第 n 位决定元素留在原位还是搬到 index+ngetTreeNode(红黑树查找),也是 O(log n)补充:
hash(key) = (h ^ (h >>> 16)) 扰乱 hashCode;②(n - 1) & hash 定位桶下标;③桶为空则新建 Node 放入,桶非空则遍历链表,若 key equals 则覆盖 value 否则尾插新节点;④若链表长度 > 8 且表容量 ≥ 64 → 转红黑树;++size > threshold → 扩容oldIndex 或 oldIndex + oldCap,用 e.hash & oldCap 判断(0 则原位,非 0 则远端),避免了重新 hashget 命中该桶会 CPU 100%——这就是为什么 JDK8 改尾插法核心考点定位:验证对 HashMap 内部实现的完整掌握——hash 计算、桶定位、冲突处理、扩容机制、以及 JDK7/8 关键差异(头插法 → 尾插法、引入红黑树)。
知识点拆解:
hash(key) = key.hashCode() ^ (key.hashCode() >>> 16) 高位参与运算;②(n - 1) & hash 定位桶(容量是 2 的幂,等价于模运算);③桶为空直接放,桶非空遍历:key.equals() 相同则覆盖 value,不同则尾插;④若链长 > 8 且容量 ≥ 64 → 转红黑树;⑤++size > threshold 触发 resize()。getTreeNode 树形查找 O(log n);⑤否则遍历链表逐一 equals 比对;⑥未命中返回 null。new HashMap<>(expectedSize / 0.75f + 1) 避免扩容;自定义 key 类必须同时重写 hashCode 和 equals(违背会让 get 失效);不要在多线程中用 HashMap 做共享状态。按「底层原理 → 设计权衡 → 工程实践」递进排列。
考察方向:
(n-1) & hash 为什么能替代 %n(容量为 2 的幂时才等价)ConcurrentHashMap 的 put 流程(多了 CAS + synchronized)延伸递进面试题:
n-1 是低位全是 1,(n-1) & hash 等价于 hash % n;扩容时 e.hash & oldCap 一个 bit 就能判断新桶是原位还是 +oldCap;且低位散列均匀。hashCode 和 equals 契约是什么?打破会怎样?面试官您好,我以 JDK8 为例说 put 和 get 的完整流程。
put 分四步:第一步算 hash,代码是 hash(key) = key.hashCode() ^ (key.hashCode() >>> 16),把 hashCode 的高 16 位异或到低 16 位,让高位也参与计算,减少高位碰撞;第二步定位桶,index = (n - 1) & hash,因为容量 n 是 2 的幂,n-1 低位全 1,这一步等价于取模但更快;第三步桶处理,桶为空直接 new 一个 Node 放进去,桶非空就遍历:如果头节点 key 和当前 key 用 == 或 equals() 相等就覆盖 value,否则尾插一个新 Node 到链表末尾——这里请注意是尾插法,JDK7 用头插法在并发扩容时会形成环形链表导致 CPU 100%;第四步检查扩容条件,如果链长 > 8 且表容量 ≥ 64 就转红黑树,如果 size > threshold(默认容量 × 0.75)就调用 resize() 扩容到 2 倍。
扩容有个巧妙设计:对每个旧桶元素判断 e.hash & oldCap,如果结果为 0 就留在原 index,否则搬到 index + oldCap。因为容量翻倍后低位 bit 不变,新位置只会在这两个候选值之间。
get 流程类似:先算 hash 定位桶;桶头命中直接返回;如果桶是红黑树走 getTreeNode 走树形查找 O(log n);如果是链表就逐一 equals 比对;都没命中返回 null。
几个易踩的坑:容量一定要选 2 的幂;自定义 key 类必须同时重写 hashCode 和 equals,违背契约会导致 get 找不到刚 put 的值;预估容量时用 new HashMap<>(expectedSize / 0.75f + 1) 避免扩容抖动;并发场景直接换 ConcurrentHashMap。