一、HashMap 是什么?
一句话定义:HashMap 是 Java 中基于哈希表的“键值对存储小能手”,能让你用钥匙(Key)快速找到对应的宝藏(Value)。
基本特性:
- 随性:数据存储无序,像极了当代年轻人的房间(但找东西快啊!)。
- 包容:允许
null作为键和值(但别滥用,容易踩坑)。 - 高效:理想情况下,增删改查的时间复杂度都是 O(1)。
- 善变:底层结构会“变形”,链表和红黑树自由切换(像极了职场人的生存技能)。
二、用法与案例:HashMap 的日常秀
1. 基础操作
// 创建
Map<String, Integer> map = new HashMap<>();
// 添加
map.put("程序员", 1);
map.put("脱发指数", 99);
// 获取
int value = map.get("程序员"); // 输出1(但头发可能只剩1根)
// 删除
map.remove("脱发指数"); // 终于不用焦虑了!
2. 遍历方式
- 直男式遍历(效率低,但简单):
for (String key : map.keySet()) { System.out.println(key + " : " + map.get(key)); } - 高效社畜式遍历(推荐):
for (Map.Entry<String, Integer> entry : map.entrySet()) { System.out.println(entry.getKey() + " : " + entry.getValue()); }
3. 经典案例
- 统计单词频率:
String text = "java java hashmap hashmap hashmap"; Map<String, Integer> wordCount = new HashMap<>(); for (String word : text.split(" ")) { wordCount.put(word, wordCount.getOrDefault(word, 0) + 1); } // 结果:{java=2, hashmap=3} - 缓存用户信息:
Map<Long, User> userCache = new HashMap<>(); userCache.put(user.getId(), user); // 缓存用户,避免频繁查数据库
三、原理揭秘:HashMap 的“黑科技”
1. 数据结构
- 数组 + 链表 + 红黑树:
- 数组:存放桶(Bucket),快速定位。
- 链表:解决哈希冲突(多个 Key 落到同一个桶)。
- 红黑树:链表过长时(默认≥8)自动转换,查询效率从 O(n) 提升到 O(log n)。
2. 哈希计算
- 扰动函数:JDK8 中通过
(h = key.hashCode()) ^ (h >>> 16)打散哈希值,避免低位重复导致碰撞。 - 取模优化:用
(n - 1) & hash代替取模运算(n为数组长度,且必须是 2 的幂)。
3. 扩容机制
- 触发条件:元素数量 > 容量 × 负载因子(默认 0.75)。
- 扩容操作:数组大小翻倍(保持 2 的幂),并重新分配元素位置。
- 扩容后元素位置:要么在原位置,要么在原位置 + 旧容量(JDK8 优化,避免全量重哈希)。
4. 树化阈值
- 为什么是 8?:根据泊松分布,链表长度达到 8 的概率仅为千万分之六,此时转为红黑树性价比最高。
- 退化为 6?:避免频繁转换(比如长度在 7~8 之间反复横跳)。
5. 当满足以下条件时,链表会转换为红黑树:
- 链表长度阈值: 当单个桶(哈希槽位)中的链表长度达到8时,会触发树化检查。即,链表中已经有8个元素(节点数为9,因为链表头节点不计入长度),HashMap会考虑将链表转换为红黑树。
- 最小树化容量: 除了链表长度达到阈值外,HashMap的总容量(桶数组的大小)也必须至少为64。如果HashMap的容量小于64,即使链表长度达到了8,也不会进行树化,而是会选择扩容。扩容操作会将HashMap的容量增加到当前容量的两倍(并且总是保持为2的幂),然后重新分配所有元素。
- 总结来说,链表转换为红黑树的条件是: 单个桶中的链表长度达到8。 HashMap的总容量至少为64。
四、对比与避坑:HashMap 的“爱恨情仇”
1. HashMap vs. Hashtable
| 特性 | HashMap | Hashtable |
|---|---|---|
| 线程安全 | 不安全 | 安全(全表锁) |
| 性能 | 高 | 低 |
| Null 键值 | 允许 | 禁止 |
| 初始容量 | 16(2的幂) | 11(质数) |
总结:多线程环境别用 HashMap,除非你想体验“数据消失术”。
2. 避坑指南
- 线程安全:
- JDK7 多线程扩容可能引发死循环(头插法导致链表成环)。
- JDK8 虽修复死循环,但仍可能数据覆盖(用
ConcurrentHashMap替代)。
- 键对象设计:
- 使用不可变类(如
String、Integer)作为 Key,避免修改后哈希值变化。 - 重写
hashCode()和equals()(不重写?等着找 bug 到秃头吧)。
- 使用不可变类(如
- 性能优化:
- 初始化时指定容量(如
new HashMap<>(1024)),避免频繁扩容。 - 负载因子可调,但非必要别动 0.75(这是时间和空间的“最佳约会地点”)。
- 初始化时指定容量(如
五、面试考点:HashMap 的灵魂拷问
高频问题与解析
-
为什么负载因子是 0.75?
- 空间与时间的权衡:0.75 时,扩容前哈希冲突概率低,空间利用率高。
-
JDK7 和 JDK8 的区别?
- JDK8 引入红黑树、尾插法(避免死循环)、哈希计算优化。
-
为什么链表转红黑树的阈值是 8?
- 统计学玄学:泊松分布下,长度 8 的概率极低,树化性价比高。
-
HashMap 线程不安全的表现?
- 数据覆盖、死循环(JDK7)、
ConcurrentModificationException。
- 数据覆盖、死循环(JDK7)、
-
如何解决哈希冲突?
- 拉链法(链表 + 红黑树),开放寻址法的“表兄弟”。
六、总结与最佳实践
最佳实践
- 初始化容量:预估数据量,避免扩容(比如 1000 数据用
new HashMap<>(2048))。 - 遍历用 entrySet:比 keySet 更高效(少一次哈希计算)。
- 线程安全选 ConcurrentHashMap:分段锁或 CAS 机制,性能更优。
一句话总结
HashMap 是 Java 开发者的“瑞士军刀”,但用不好就是“自爆按钮”——理解原理、规避陷阱,方能纵横江湖!
彩蛋:如果面试官问你“HashMap 为什么不用跳表?”,请优雅回答:“因为红黑树更香!”(真实原因是跳表需要额外存储层级指针,内存开销大)。