HashMap从入门到源码:Java7/8/21区别+面试陷阱+高频追问合集

张开发
2026/4/19 19:31:47 15 分钟阅读

分享文章

HashMap从入门到源码:Java7/8/21区别+面试陷阱+高频追问合集
文章目录一、开篇图书馆管理员的烦恼二、Java 7那个时代的头铁设计三、Java 8老树开新花红黑树登场四、Java 21新时代的微整形五、面试陷阱那些让你挂掉的细节陷阱1为什么容量必须是2的幂次方陷阱2链表转红黑树是8转回链表是6为啥差2陷阱3为什么重写equals必须重写hashCode陷阱4resize时元素的rehash六、高频追问面试官的连环炮Q1HashMap能存null键吗原理是啥Q2红黑树退化成链表的条件Q3为啥不用AVL树用红黑树Q4HashMap初始化时指定容量实际容量就是指定的吗Q5Java 8的stream操作对HashMap性能有影响吗七、源码里的魔鬼细节八、实战建议别在简历上给自己挖坑九、总结无意间发现了一个巨牛巨牛巨牛的人工智能教程非常通俗易懂对AI感兴趣的朋友强烈推荐去看看传送门https://blog.csdn.net/HHX_01一、开篇图书馆管理员的烦恼想象一下你是个图书馆管理员手里有1000本书读者来借书时你得一排排扫过去找这得多崩溃HashMap就是来解决这问题的——它像个智能书架你告诉它书名key它秒秒钟给你定位到第几排第几格value。但这么个看似简单的东西Java版本迭代里却藏了无数坑面试时面试官眼神一闪“说说HashMap在Java 7和8的区别” 你要是只答个加了红黑树那基本就凉了半截。今天咱们不整那些虚的从源码层面把这玩意儿扒个底朝天顺带把Java 21的新变化也给你整明白。二、Java 7那个时代的头铁设计Java 7的HashMap结构简单得让人心疼数组链表没了。那时候处理哈希冲突用的是头插法。啥意思就是新来的元素直接插到链表头部老元素被挤到后面。代码大概长这样// Java 7 头插法简化逻辑voidaddEntry(inthash,Kkey,Vvalue,intbucketIndex etable[bucketIndex];table[bucket(hash,key,value,e);// 新节点指向旧链表}听着挺高效插入是O(1)不用遍历到链表尾部。但这玩意儿有个致命bug——并发环境下的死循环。两个线程同时扩容的时候因为头插法会改变链表顺序最后可能形成一个环你get()的时候直接死循环CPU飙到100%。这也是为什么面试时你说HashMap是线程不安全的面试官会让你展开讲讲看你知道不知道这个死循环的梗。而且Java 7的哈希计算也糙直接拿key的hashCode高位异或低位扰动次数少哈希碰撞概率高。数据量一大链表长得跟贪吃蛇似的查询直接退化成O(n)。三、Java 8老树开新花红黑树登场Java 8算是HashMap的翻身仗。最大的改动就仨字红黑树。但别急着背概念咱先想个问题链表太长的时候查询慢得跟蜗牛爬似的能不能用二叉搜索树加速能但普通二叉树会退化成链表。于是Java 8祭出了红黑树——一种自平衡的二叉搜索树保证最坏情况下查询也是O(log n)。具体触发条件是链表长度≥8且数组长度≥64。为啥是8源码里写了根据泊松分布hash冲突达到8的概率已经低得可怜0.00000606用树结构的额外开销才值得。要是数组长度不到64优先扩容而不是树化毕竟扩容比树化便宜多了。还有个大改动Java 8把头插法改成了尾插法。新元素老老实实排到链表尾巴上这样并发扩容的时候不会再形成环虽然还是线程不安全数据丢失问题还在但至少不会死循环了。哈希计算也优化了把高16位和低16位异或扰动更充分减少碰撞。resize的逻辑也重写了Java 7是重新算hash定位Java 8发现扩容后元素位置要么在原位要么在原位置旧容量因为容量是2的幂次方直接省去了重新hash的开销。// Java 8 尾插法核心逻辑finalVputVal(inthash,Kkey,Vvalue,booleanonlyIfAbsent,booleanevict)p;intn,i;if((tabtable)null||(ntab.length)0)n(tabresize()).length;if((ptab[i(n-1)hash])null)tab[i]newNode(hash,key,value,null);// 直接放else{// 冲突了遍历链表for(intbinCount0;;binCount){if((ep.next)null){p.nextnewNode(hash,key,value,null);// 插到尾部if(binCountTREEIFY_THRESHOLD-1)// 达到8个转红黑树treeifyBin(tab,hash);break;}if(e.hashhash((eke.key)key||(ek!nullkey.equals(ek))))break;pe;}}}四、Java 21新时代的微整形到了Java 21LTS版本HashMap的结构没变还是数组链表红黑树但底层做了不少微整形。首先是内存布局优化。Java 21对对象头Header和字段对齐做了调整在64位JVM上HashMap的Node对象内存占用更紧凑了。对于大容量的HashMap能省出不少内存。比如你塞100万个Entry进去Java 21可能比Java 8少用几MB内存听起来不多但在高并发场景下缓存友好性提升明显。其次是虚拟线程Virtual Threads兼容性。虽然HashMap本身还是非线程安全的但Java 21的虚拟线程环境下ThreadLocal的改动间接影响了HashMap的使用模式。以前在大量线程场景下用ConcurrentHashMap是标配现在虚拟线程轻量化了有些场景下用HashMap配合其他同步手段也变得更可行当然并发修改还是要用ConcurrentHashMap这点没变。还有性能上的暗搓搓优化。Java 21的JIT编译器对HashMap的resize和treeify操作做了更好的内联优化特别是红黑树的旋转操作在部分基准测试里比Java 17快了5-10%。虽然日常CRUD感知不强但对于高频交易系统或者大数据处理这提升就很香了。五、面试陷阱那些让你挂掉的细节陷阱1为什么容量必须是2的幂次方别只说为了取模快。真实原因是HashMap用(n-1) hash来代替hash % n位运算比取模快得多。但只有当n是2的幂时n-1的二进制才是全1比如16是1000015是01111这样与运算才能均匀散列。如果n10n-191001那hash的某些位永远参与不了运算冲突率飙升。陷阱2链表转红黑树是8转回链表是6为啥差2这叫hysteresis滞后性。如果都是8那在8这个临界点来回插入删除会频繁在链表和树之间转换性能抖动严重。设成6相当于有个缓冲带树变回链表需要降到6以下避免反复横跳。陷阱3为什么重写equals必须重写hashCodeHashMap先比hashhash一样再比equals。如果你两个对象equals为true但hashCode不同那HashMap会认为它们在不同的桶里get的时候永远找不到造成内存泄漏。这个坑新手必踩。陷阱4resize时元素的rehashJava 8里元素在扩容后的位置要么在原索引要么在原索引旧容量。因为容量翻倍二进制高位多了一位hash oldCap的结果要么是0原位要么是1原位旧容量。所以Java 8的resize比Java 7快不需要重新算hash。六、高频追问面试官的连环炮Q1HashMap能存null键吗原理是啥能但只能一个。null的hash被特殊处理为0直接放在数组第0个位置。ConcurrentHashMap不让存null是因为null有二义性不存在vs值为null多线程下无法区分。Q2红黑树退化成链表的条件不是长度降到8以下就退而是resize时或者remove后检查长度≤6才会untreeify。而且如果在remove时树太小了可能直接untreeify如果是resize导致节点分散也可能退化。Q3为啥不用AVL树用红黑树AVL树严格平衡查询快但旋转多红黑树宽松平衡插入删除旋转少。HashMap里插入操作更频繁红黑树综合性能更好实现也简单点虽然红黑树代码已经够复杂了300多行。Q4HashMap初始化时指定容量实际容量就是指定的吗不是。比如你(100)实际容量会是128最近的2的幂。而且因为load factor默认0.75实际上到75个元素就扩容了。如果你明确要存100个元素且不扩容应该new( (int) (100 / 0.75f) 1)也就是134这样实际容量是256足够撑到100个元素。Q5Java 8的stream操作对HashMap性能有影响吗map.entrySet().stream()其实挺快的因为HashMap的spliterator做了优化可以并行分割。但如果你用map.keySet().toArray()这类操作注意Java 8之后返回的是迭代器视图某些操作可能有轻微性能损耗。七、源码里的魔鬼细节看一段resize的核心代码体会下Java 8的巧妙[][]oldTabtable;intoldCap(oldTabnull)?0:oldTab.length;intoldThrthreshold;intnewCap,newThr0;if(oldCap0){if(oldCapMAXIMUM_CAPACITY){thresholdInteger.MAX_VALUE;returnoldTab;}elseif((newCapoldCapMAXIMUM_CAPACITYoldCapDEFAULT_INITIAL_CAPACITY)newThrold1;// 容量和阈值都翻倍}// ... 省略部分代码Node[]newTab[])newNode[newCap];tablenewTab;if(oldTab!null){for(intjoldCap;j){e;if((eoldTab[j])!null){oldTab[j]null;if(e.nextnull)newTab[e.hash(newCap-1)]e;// 单个元素直接放elseif(einstanceofTreeNode))e).split(this,newTab,j,oldCap);// 红黑树分裂else{// 链表拆成两份loHeadnull,loTailnull;NodehiHeadnull,hiTailnullnext;do{nexte.next;if((e.hasholdCap)0){// hash 10000 0 留在低位if(loTailnull)loHeade;elseloTail.nexte;loTaile;}else{// 去高位if(hiTailnull)hiHeade;elsehiTail.nexte;hiTaile;}}while((enext)!null);if(loTail!null){loTail.nextnull;newTab[j]loHead;// 原位}if(hiTail!null){hiTail.nextnull;newTab[joldCap]hiHead;// 原位旧容量}}}}}returnnewTab;}看到那个(e.hash oldCap) 0了吗这就是判断元素该留在原地还是去高位的关键。oldCap是旧容量比如1610000hash 16的结果只看hash的第五位从右数是0就在原位是1就去j16的位置。这种位运算的技巧比重新hash快得多。八、实战建议别在简历上给自己挖坑别在并发场景用HashMap哪怕你是Java 21。虚拟线程环境下虽然线程开销小了但HashMap的fast-fail机制modCount检查在并发修改时会抛ConcurrentModificationException或者更糟的是丢数据。预估容量别让HashMap频繁resize。resize是个重操作要新建数组要rehash要搬数据。如果你知道大概存多少数据一定要在构造时指定足够大的初始容量。自定义对象做key务必重写hashCode和equals并且最好是不可变对象。要是key对象后来改了内容hashCode变了你就再也找不到这个Entry了这就是内存泄漏。Java 21用var时要注意类型推断虽然跟HashMap本身没关系但()这种写法如果后面你要用Map接口没有的方法比如LinkedHashMap的removeEldestEntryvar推断的是HashMap具体类型可能会限制你的灵活性。九、总结HashMap这玩意儿从Java 7的简单粗暴头插法死循环到Java 8的华丽转身红黑树尾插法再到Java 21的润物细无声内存优化虚拟线程适配每一次改动都是为了应对更极端的场景和更大的数据量。面试时别只背概念把resize的过程、树化的触发条件、hash的计算方式这些细节整明白再能扯两句Java 21的优化基本上就能让面试官眼前一亮。当然最重要的还是理解位运算在其中的妙用以及空间换时间的核心思想。下次面试官问HashMap希望你不仅能答出来还能笑着补充一句“Java 8那个尾插法改得真妙不然现在还有人在Debug死循环呢。” 这就到位了。无意间发现了一个巨牛巨牛巨牛的人工智能教程非常通俗易懂对AI感兴趣的朋友强烈推荐去看看传送门https://blog.csdn.net/HHX_01

更多文章