尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Java HashMap 原理详解

Java HashMap 原理详解 Java HashMap 原理详解面向前端开发者的深度解析。如果你用过 JS 的Map/Object/WeakMap这篇文章会让你彻底理解 Java HashMap 的底层。目录先看 JSHashMap 是什么HashMap 的骨架数组 链表 红黑树hash 函数如何把 key 变成数组下标put 过程插入数据get 过程读取数据扩容机制resize红黑树为什么 什么条件 什么时候退回链表关键常量速查线程安全问题JDK 7 vs JDK 8致命区别面试高频追问对比总结JS Object vs JS Map vs Java HashMap1. 先看 JSHashMap 是什么你每天都在用这个概念只是换了个名字// JS 中的 HashMapconstuser{};user[name]张三;// key → valueuser[age]25;console.log(user[name]);// 张三// 或者用真正的 MapconstmapnewMap();map.set(name,张三);map.get(name);// 张三Java 的 HashMap 做的就是同一件事存键值对通过 key 快速找到 value。但 Java 没有 JS 那么自由——问题来了问题JS ObjectJava HashMapkey 是 “name”底层怎么定位的V8 用 Hidden Class 内联缓存hash 算法 数组下标两个 key 算出了同一个位置怎么办V8 内部处理链表 / 红黑树拉链法key 太多了怎么办动态扩容resize 扩容 rehash接下来逐个拆解。2. HashMap 的骨架数组 链表 红黑树HashMap 的底层结构JDK 8 transient NodeK,V[] table; // 主干一个 Node 数组 class NodeK,V { final int hash; // key 的哈希值存起来扩容时复用 final K key; // 键 V value; // 值 NodeK,V next; // 指向下一个节点链表结构 }画成图table 数组: ┌───┐ │ 0 │ → Node(张三,25) → Node(李四,30) ← 链表哈希冲突 ├───┤ │ 1 │ → null ├───┤ │ 2 │ → TreeNode(王五,28) ← 红黑树链表太长时升级 │ │ / \ │ │ left right ... ├───┤ │ 3 │ → null ├───┤ │...│ └───┘默认容量 16 个槽位。key 经过 hash 运算后落在某个槽位上冲突时用链表串起来链表太长≥8则升级为红黑树。用 JS 类比// HashMap 就像consttablenewArray(16).fill(null);// 插入 keyname, value张三constidxhash(name)%16;// → 假设 2table[2]{key:name,value:张三,next:null};// 另一个 keyage 也算出 idx2冲突table[2]{key:age,value:25,next:table[2]};// 头插3. hash 函数如何把 key 变成数组下标3.1 两步走① 调用 key.hashCode() 拿到一个 int32 位整数 ② HashMap 自己再做一次扰动h ^ (h 16) ③ 取模落到数组index (n-1) hash3.2 为什么是h ^ (h 16)// JDK 8 HashMap.hash()staticfinalinthash(Objectkey){inth;return(keynull)?0:(hkey.hashCode())^(h16);}问题用hashCode % n取模时如果n较小比如默认 16只有 hashCode 的低位参与运算高位被浪费了——碰撞率很高。解法把 hashCode 的高 16 位和低 16 位做异或让高位也混进来分布更均匀。hashCode: 1010 1100 0011 0001 | 0101 1110 1001 0010 h 16: 0000 0000 0000 0000 | 1010 1100 0011 0001 XOR: 1010 1100 0011 0001 | 1111 0010 1010 0011 ↑ 高位参与了搅动3.3 为什么用(n-1) hash而不是hash % n因为位运算比取模快一个数量级。前提是n必须是 2 的幂16、32、64…此时n-1的二进制全是1(n-1) hash等价于hash % nn16, n-115: 0000 1111 hash: 0101 1010 : 0000 1010 → 10等价于 hash % 16所以 HashMap 的容量始终是 2 的幂就是为了能用代替%。4. put 过程插入数据map.put(name,张三);完整流程① 计算 hashhash(name) → h ^ (h 16) ② 如果 table 为空 → 先 resize() 初始化默认 16 ③ (n-1) hash → 数组下标 i ④ 如果 table[i] null → 直接放入 ⑤ 如果 table[i] 有值 ├─ key 相同equals → 覆盖旧值 ├─ 是 TreeNode红黑树节点 → 走红黑树插入 └─ 是普通 Node链表 → 遍历链表 ├─ 找到相同 key → 覆盖旧值 ├─ 找不到尾插到链表末尾JDK 8 尾插 → 检查链表长度 │ └─ 长度 ≥ 8 且 table.length 64 → resize() │ └─ 长度 ≥ 8 且 table.length ≥ 64 → 链表 → 红黑树 └─ 插入后 size threshold容量×0.75 → resize() 扩容流程图put(key, value) │ ▼ 计算 hash(key) │ ▼ table 为空──是──→ resize() 初始化 │ 否 ▼ (n-1) hash → 数组下标 i │ ▼ table[i] null──是──→ 直接放入结束 │ 否 ▼ table[i] 上的节点类型 │ ├── TreeNode ──→ 红黑树插入 │ └── Node ──→ 遍历链表 │ ├── 找到相同 key → 覆盖 value │ └── 没找到 → 尾插入 │ └── 检查长度 ≥ 8 ├── 否 → 结束 └── 是 → table.length ≥ 64 ├── 否 → resize() └── 是 → 链表 → 红黑树 │ ▼ size threshold──是──→ resize()JDK 7 用头插法JDK 8 改成了尾插法。这个改动解决了并发扩容时的死循环问题第 9 节细讲。5. get 过程读取数据Stringvaluemap.get(name);流程比 put 简单得多① 计算 hash(name) ② (n-1) hash → 下标 i ③ 如果 table[i] null → 返回 null ④ 如果 table[i] 有值 ├─ 第一个节点 key 相同 → 返回它的 value ├─ 是 TreeNode → 走红黑树查找 O(log n) └─ 是普通 Node → 遍历链表 O(k)逐个 equals 比较 ⑤ 都没找到 → 返回 null时间复杂度情况复杂度说明没有冲突O(1)直接命中数组槽位链表长度 8O(k)k 通常很小红黑树O(log n)最坏情况n 是这个槽位的节点数6. 扩容机制resize6.1 什么时候扩容if(sizethreshold)resize();size当前 key-value 总数thresholdcapacity × loadFactor16 × 0.7512即超过容量的 75% 时触发扩容6.2 扩容做了什么① 新容量 旧容量 × 2始终是 2 的幂 ② 新 threshold 新容量 × 0.75 ③ 创建新数组 ④ 把旧数组的每个节点迁移到新数组rehash迁移时的巧妙之处因为容量翻倍是×2新索引只有两种可能旧容量 n16 hash (16-1) 即 hash 1111 → 只取低 4 位 新容量 n32 hash (32-1) 即 hash 11111 → 取低 5 位 新索引只有两种可能 - hash 的第 5 位是 0 → 索引不变 (oldIndex) - hash 的第 5 位是 1 → 索引 oldIndex 16 (oldCap)// JDK 8 的迁移逻辑简化版NodeK,VloHeadnull,loTailnull;// 不动的链表NodeK,VhiHeadnull,hiTailnull;// 移到 oldIndexoldCap 的链表NodeK,Vnext;for(NodeK,VeoldTab[j];e!null;enext){nexte.next;if((e.hasholdCap)0){// 高位是 0 → 索引不变// 放入 lo 链表}else{// 高位是 1 → 索引 oldCap// 放入 hi 链表}}newTab[j]loHead;// 原地不动newTab[joldCap]hiHead;// 移到新位置这样避免了每个节点都重新算hash % n效率很高。6.3 为什么负载因子是 0.75值效果1.0空间利用率最高但冲突概率大增查询退化0.5冲突少但浪费一半空间0.75时空折中的经验值泊松分布下冲突概率可控7. 红黑树为什么 什么条件 什么时候退回链表7.1 为什么需要红黑树如果所有 key 都落到同一个槽位比如恶意构造的哈希碰撞链表会退化成 O(n)table[5] → A → B → C → D → E → F → G → H → I → J → ... ↑ 查 J 要遍历 10 次红黑树把 O(n) 降到 O(log n)。7.2 树化的两个条件缺一不可// 条件 1链表长度 ≥ TREEIFY_THRESHOLD8// 条件 2table.length ≥ MIN_TREEIFY_CAPACITY64条件值为什么链表长度 ≥ 8泊松分布下概率 千万分之一树化代价高正常情况下不会触发数组长度 ≥ 64如果数组很小扩容就能分散避免在小表上碎片化树化7.3 退化为链表untreeify当红黑树节点数 ≤ 6 时退化为链表UNTREEIFY_THRESHOLD 6。注意是6 而不是 8留了 7 这个缓冲区避免频繁转换。8. 关键常量速查常量默认值含义DEFAULT_INITIAL_CAPACITY16默认初始容量MAXIMUM_CAPACITY2³⁰最大容量DEFAULT_LOAD_FACTOR0.75默认负载因子TREEIFY_THRESHOLD8链表 → 树的阈值UNTREEIFY_THRESHOLD6树 → 链表的阈值MIN_TREEIFY_CAPACITY64触发树化的最小数组长度9. 线程安全问题HashMap 不是线程安全的。三个经典问题9.1 JDK 7 并发扩容 → 死循环CPU 100%JDK 7 使用头插法多线程同时 resize 时可能形成循环链表。线程 A、B 同时扩容 A 正在迁移节点 X→Y时间片用完挂起 B 完成扩容Y→X 变成 Y.next X头插导致反序 A 恢复后继续发现 X.next Y, Y.next X → 死循环JDK 8 改为尾插法不会反转顺序死循环问题已修复。但数据覆盖问题仍在。9.2 JDK 8 并发 put → 数据覆盖// 线程 A: put(a, 1)// 线程 B: put(a, 2)// 两个线程同时判断 table[i]null同时写入// 最终只有一个线程的值被保留另一个丢失9.3 解决方案方案特点Hashtable全表 synchronized性能差不推荐Collections.synchronizedMap()包装一个同步 MapConcurrentHashMap推荐— 分段锁/CAS性能最好10. JDK 7 vs JDK 8致命区别JDK 7JDK 8数据结构数组 链表数组 链表 红黑树插入方式头插法尾插法hash 扰动4 次移位 4 次异或1 次移位 1 次异或更高效扩容条件size ≥ threshold且table[i] ≠ nullsize threshold并发扩容可能死循环头插 反转不会死循环尾插但仍有数据覆盖初始化构造时直接初始化数组构造时只设参数首次 put 时才初始化懒加载11. 面试高频追问Q1为什么容量必须是 2 的幂(n-1) hash代替hash % n位运算更快扩容迁移时用hash oldCap就能判断是留在原索引还是移到oldIndexoldCap不需要重新算取模Q2如果我传入初始容量 10实际容量是多少newHashMap(10);// 实际容量 tableSizeFor(10) 16// HashMap 会取 ≥10 的最小 2 的幂tableSizeFor源码staticfinalinttableSizeFor(intcap){intncap-1;n|n1;n|n2;n|n4;n|n8;n|n16;return(n0)?1:(nMAXIMUM_CAPACITY)?MAXIMUM_CAPACITY:n1;}// 输入 10 → 输出 16// 输入 17 → 输出 32Q3HashMap 的 key 可以是 null 吗可以。HashMap 允许一个 null key存在table[0]也允许多个 null value。map.put(null,nullKey);map.put(name,null);对比Hashtable和ConcurrentHashMap不允许null key/value。Q4为什么重写 equals 必须重写 hashCode因为 HashMap 先比 hashCode再比 equals同一个对象 a.equals(b) true → a.hashCode() b.hashCode() 必须成立 反之不一定 a.hashCode() b.hashCode() ⇏ a.equals(b) 哈希碰撞如果只重写 equals 不重写 hashCodeclassUser{Stringname;User(Stringname){this.namename;}Overridepublicbooleanequals(Objecto){returnname.equals(((User)o).name);}// 没重写 hashCode → 用 Object 的 native hashCode// 两个 name 相同的 UserhashCode 不同 → HashMap 当不同的 key}Q5HashMap 和 Hashtable 区别HashMapHashtable线程安全❌✅synchronizednull key/value✅❌继承AbstractMapDictionary迭代器fail-fastEnumerator非 fail-fast性能高低12. 对比总结JS Object vs JS Map vs Java HashMapJS ObjectJS MapJava HashMapkey 类型String / Symbol任意类型任意类型需实现 hashCode equals顺序字符串 key 按插入序严格插入序无序底层V8 Hidden Class 内联缓存数组 链表有序数组 链表 红黑树性能字符串 key 极快所有 key 稳定 O(1)O(1) ~ O(log n)线程安全单线程单线程❌用 ConcurrentHashMapnull key—✅✅一个 null key遍历for...in/Object.keys()for...of/.forEach()entrySet()/keySet()/ Iterator核心记住三点就够了数组 链表 红黑树hash 决定下标冲突用拉链法长链变红黑树容量始终是 2 的幂为了运算代替取模扩容时用hash oldCap分流JDK 7 头插 → JDK 8 尾插解决了并发扩容死循环但线程安全问题要上ConcurrentHashMap
返回列表