㊗️本期内容已收录至专栏《Java核心实操(进阶版)》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~
㊙️本期难度指数:⭐⭐⭐
🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。
前言:从一次线上事故说起
那是某年某月,一个凌晨三点的生产环境。
我盯着监控大屏,CPU 飙到 100%,某个服务线程全卡死,日志里一堆 java.lang.OutOfMemoryError,看得我头皮发麻。排查到最后,定位到的罪魁祸首是——几个线程同时往一个 HashMap 里写数据,触发了扩容,然后形成了死循环。
对,你没看错。HashMap 在多线程扩容的时候,会形成链表死循环,然后 CPU 就永远转圈圈了,永不停止,堪称死亡螺旋。
那是 JDK 1.7 的年代。
后来升到 JDK 1.8,这个问题消失了,但并不是说 HashMap 就线程安全了——它只是换了一种方式"坑"你。
所以这篇文章就是我在踩完这些坑、翻完 OpenJDK 源码之后,用我自己的话,把 JDK 1.7 和 JDK 1.8 的 HashMap 到底有哪些区别、为什么要改、改了之后有什么影响,全都讲清楚。
适合人群:
- 刚接触 Java 集合框架、听说过 HashMap 但没深入研究的同学
- 面试前临时抱佛脚想搞清楚这道经典题的同学
- 像我一样在生产环境踩过坑、想彻底搞明白底层原因的同学
全文会从数据结构、哈希算法、插入方式、扩容机制、线程安全问题五个维度展开,每个维度都会有代码和图解辅助。准备好了,咱们开始。
一、先搞清楚 HashMap 的基本骨架
在聊 1.7 和 1.8 的区别之前,先把 HashMap 的基本设计逻辑梳理一遍,不然后面讲变化的时候容易摸不着头脑。
1.1 HashMap 是个什么东西?
说人话就是:一个允许你用任意类型的键来存取值的容器,底层通过哈希表实现快速的存取操作,平均时间复杂度是 O(1)。
用生活化的比喻来说,你可以把 HashMap 想象成一个超大型的快递柜:
- 每个格子有一个编号(数组下标)
- 你寄快递的时候,系统根据你的手机号(键)算出一个格子编号(哈希值),然后把快递放进去
- 取的时候一样,输入手机号,算出格子编号,直接开柜
但问题来了:不同的手机号可能算出同一个格子编号——这就是哈希冲突(Hash Collision)。
解决哈希冲突的方式,HashMap 用的是链地址法(Separate Chaining)——同一个格子里放不下了,就在格子后面挂一条链表,新来的往链表上加。
这是 HashMap 的基础逻辑,在 JDK 1.7 和 1.8 中都一样,区别在于:当链表越来越长的时候,该怎么处理?
二、JDK 1.7 的实现:数组 + 链表
2.1 数据结构
JDK 1.7 的 HashMap,底层是一个 Entry<K, V>[] 数组,每个 Entry 是链表的节点,包含 key、value、hash、next 四个字段。
这张图就是 JDK 1.7 HashMap 的真实状态:大部分位置是 null,有些位置只挂了一个节点,有些位置因为哈希冲突,挂了一条长长的链表。
2.2 哈希算法:对扰动函数的执念
JDK 1.7 对 key 的 hashCode 做了四次扰动,目的是让哈希分布更均匀,减少冲突:
// JDK 1.7 的 hash 方法
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
// 然后通过这个方法计算数组下标
static int indexFor(int h, int length) {
return h & (length - 1); // 等价于 h % length,但位运算更快
}
说实话,第一次看到这段代码的时候我是懵的。这几个右移和异或操作,粗看像是乱写的,其实背后是有数学依据的——通过多次移位和异或,把高位的信息"混入"低位,这样在 & (length - 1) 的时候,高位信息也能参与到下标计算中,减少碰撞概率。
2.3 插入方式:头插法
JDK 1.7 往链表里插入新节点,用的是头插法——每次都把新节点放到链表的头部,原有的节点往后挪。
为什么用头插法?当年的设计者觉得:新插入的数据被访问的概率更高(局部性原理),放在头部可以更快被找到。
这个逻辑听起来没问题,但问题藏在扩容的时候……等我们讲到多线程问题那节再展开。
// JDK 1.7 的 addEntry 和 createEntry(简化版)
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length); // 扩容
// ...重新计算 hash 和 bucketIndex
}
createEntry(hash, key, value, bucketIndex);
}
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
// 头插法:新节点的 next 指向原来的头节点
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
2.4 扩容机制
JDK 1.7 扩容的触发条件:当 size >= threshold(threshold = capacity × loadFactor,默认 16 × 0.75 = 12)且当前 bucket 非空时,触发扩容,容量翻倍。
扩容时,每一个元素都要重新计算哈希值和新数组下标,然后再把元素重新插入新数组。这个过程叫 rehash。
// JDK 1.7 的 transfer 方法(扩容核心逻辑,简化版)
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
// 头插法!新数组里也是头插
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
注意那个头插法——单线程下没问题,多线程下这就是灾难的起源。后面重点讲。
三、JDK 1.8 的进化:数组 + 链表 + 红黑树
3.1 最大的变化:引入红黑树
JDK 1.8 对 HashMap 做了重大重构,最显眼的变化是:当链表长度超过 8,且数组长度达到 64 时,链表会转换成红黑树。
为什么要引入红黑树?
链表查找的时间复杂度是 O(n),如果哈希冲突严重,链表越来越长,性能就越来越差。红黑树的查找复杂度是 O(log n),在链表比较长的场景下,性能提升非常明显。
但是红黑树的维护成本也比链表高,节点数少的时候,用链表反而更轻量。所以 JDK 1.8 选了一个阈值:8。这个值的选取基于泊松分布的计算——在默认负载因子(0.75)的情况下,一个 bucket 里链表长度超过 8 的概率约为 0.00000006,也就是说正常情况下很少会触发树化,只有在哈希函数特别差或者恶意攻击的情况下才会出现。
3.2 数据结构变化
节点类型从 Entry 变成了 Node(正常情况)和 TreeNode(树化之后):
// JDK 1.8 的 Node 类
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
// ...
}
// JDK 1.8 的 TreeNode 类(继承自 LinkedHashMap.Entry,再继承自 Node)
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // 父节点
TreeNode<K,V> left; // 左子节点
TreeNode<K,V> right; // 右子节点
TreeNode<K,V> prev; // 用于删除时维护链表
boolean red; // 颜色标志
// ...
}
3.3 哈希算法简化
JDK 1.8 把原来的四次扰动简化成了一次扰动:
// JDK 1.8 的 hash 方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
只做了一次右移 16 位再异或。为什么够用了?因为 JDK 1.8 引入了红黑树,即使冲突稍多一点,也能保证查询性能,不需要像 1.7 那样用复杂的扰动来极力减少冲突。
另外,null 键会被映射到下标 0,这一点两个版本都一样。
3.4 插入方式:尾插法
这是 JDK 1.8 一个非常关键的改变:链表的插入方式从头插法改成了尾插法。
新节点不再插到链表头部,而是插到链表尾部。这个改变看似微小,却直接解决了多线程扩容时死循环的问题(虽然并没有让 HashMap 变成线程安全的,但至少不会死循环了)。
// JDK 1.8 的 putVal 方法(链表插入部分,简化版)
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 尾插法!
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // TREEIFY_THRESHOLD = 8
treeifyBin(tab, hash); // 转红黑树
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
3.5 扩容优化:高低位拆分
JDK 1.8 扩容时有一个非常精妙的优化,不再需要像 1.7 那样每个元素都重新计算哈希值。
逻辑是这样的:数组容量始终是 2 的幂次(设原容量为 n),扩容后变成 2n。元素在新数组中的位置,要么还在原来的下标 i,要么在 i + n。判断的依据是元素的哈希值的第 log2(n) + 1 位(也就是新增的那一位掩码位)是 0 还是 1。
// JDK 1.8 扩容的核心判断逻辑(简化版)
// e.hash & oldCap 如果为 0,新下标 = 原下标
// e.hash & oldCap 如果为 1,新下标 = 原下标 + oldCap
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
// 放到低位链表(lo 链表),位置不变
if (loTail == null) loHead = e;
else loTail.next = e;
loTail = e;
} else {
// 放到高位链表(hi 链表),位置 = 原位置 + 原容量
if (hiTail == null) hiHead = e;
else hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
这个优化有两个好处:
- 不需要重新计算哈希值,性能更好
- 维持了链表的原有顺序(因为是尾插),不会出现链表反转
四、核心区别汇总对比
到这里,核心的变化已经全部讲完了,来个表格对比一下,方便记忆:
| 对比维度 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 底层数据结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| 节点类型 | Entry | Node / TreeNode |
| 哈希扰动次数 | 4次(复杂扰动) | 1次(简单扰动) |
| 链表插入方式 | 头插法 | 尾插法 |
| 扩容 rehash | 每个元素重新 hash | 高低位拆分,无需重新 hash |
| 树化阈值 | 无 | 链表长度 ≥ 8 且数组长度 ≥ 64 |
| 退化为链表阈值 | 无 | 树节点数 ≤ 6 时退化 |
| 并发死循环风险 | 有(扩容时头插导致) | 无(但仍然不是线程安全) |
五、最容易被忽视的坑:线程安全问题
这部分是整篇文章最重要的实战内容。我在前言里提到的那次线上事故,就出在这里。
5.1 JDK 1.7 的死循环问题
先用图来理解 JDK 1.7 扩容时为什么会死循环。
假设当前链表是 A -> B -> null,两个线程同时触发扩容:
核心原因:头插法在扩容时会反转链表顺序,两个线程并发执行时,会形成一个指针环,导致后续的 get 操作陷入无限循环,CPU 100%。
5.2 JDK 1.8 的改进与残留问题
JDK 1.8 改成尾插法之后,扩容时不再反转链表,所以不会再出现死循环。但是——
HashMap 在 JDK 1.8 中仍然不是线程安全的!多线程并发 put 时,可能会出现数据丢失:
// 这是一个反面教材!千万不要在多线程环境下这样用!
HashMap<String, Integer> map = new HashMap<>();
// 线程1和线程2同时执行 put,且两个 key 映射到同一个 bucket
// 当两个线程同时判断 (p.next == null) 为 true
// 都执行 p.next = newNode(...)
// 其中一个线程的写入会被覆盖,数据直接丢失!
另外,JDK 1.8 的扩容过程中,多线程可能导致 table 引用和 size 不一致,最终也会出现各种稀奇古怪的问题,比如 get 返回 null(明明已经 put 过了)。
5.3 正确的多线程方案
老实说,工作这么多年,我见过太多把 HashMap 用在多线程场景的代码了。这里给出几个正确姿势:
方案一:使用 ConcurrentHashMap(推荐)
import java.util.concurrent.ConcurrentHashMap;
// 直接替换,API 基本一致
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// JDK 1.7: 分段锁(Segment)
// JDK 1.8: CAS + synchronized,锁的粒度更细(只锁链表/树的头节点)
map.put("user_count", 100);
Integer count = map.get("user_count");
// 原子操作:不存在才插入
map.putIfAbsent("session_id", 1);
// 原子操作:计算并更新
map.compute("visit_count", (key, oldVal) -> oldVal == null ? 1 : oldVal + 1);
方案二:Collections.synchronizedMap(不推荐,性能差)
Map<String, String> syncMap = Collections.synchronizedMap(new HashMap<>());
// 所有操作都加了 synchronized,并发性能很差
// 而且遍历的时候还需要手动加锁,否则依然不安全
synchronized (syncMap) {
for (Map.Entry<String, String> entry : syncMap.entrySet()) {
// 遍历逻辑
}
}
方案三:Hashtable(别用了,太老了)
// Hashtable 的所有方法都加了 synchronized
// 性能比 Collections.synchronizedMap 还差
// 而且继承了很多历史遗留问题,现代项目别用这个了
Hashtable<String, String> table = new Hashtable<>();
结论:多线程场景,老老实实用 ConcurrentHashMap。
六、红黑树的那些细节
红黑树这块很多文章一笔带过,但如果面试被问到"为什么用红黑树而不用 AVL 树",不提前想清楚可能会答错。
6.1 红黑树 vs AVL 树
AVL 树是严格平衡的二叉搜索树,左右子树高度差不超过 1,查找效率极高,但维护平衡的代价也大——每次插入删除都可能触发大量旋转操作。
红黑树是"近似平衡"的,不要求严格平衡,插入和删除时旋转次数更少(最多 3 次旋转)。对于 HashMap 这种读写都频繁的场景,红黑树的综合性能更优。
6.2 树化与反树化阈值为什么是 8 和 6?
- 树化阈值 8:泊松分布计算的结果,正常情况下链表长度超过 8 的概率极低(约 6×10⁻⁸),只有哈希函数很差或者数据刻意构造的时候才会发生。
- 反树化阈值 6:并不是 7,而是 6,为了防止频繁在链表和红黑树之间来回转换——如果树化和反树化的阈值一样,比如都是 8,那么删除一个元素变成 7 就退化成链表,再插入一个变成 8 又树化,这就是性能抖动,很蠢。设置一个差值(8 - 6 = 2)来做缓冲,是很合理的设计。
// JDK 1.8 中相关的常量定义
static final int TREEIFY_THRESHOLD = 8; // 树化阈值
static final int UNTREEIFY_THRESHOLD = 6; // 反树化阈值
static final int MIN_TREEIFY_CAPACITY = 64; // 最小树化数组容量
注意 MIN_TREEIFY_CAPACITY = 64 这个常量——如果数组长度还不到 64,即使链表长度超过 8,也不会树化,而是选择扩容。这是因为当数组长度比较小的时候,扩容可以更有效地减少哈希冲突,没必要急着树化。
七、从实战角度:如何用好 HashMap?
光知道原理还不够,工程上怎么用才是关键。
7.1 初始化容量的设置
这个细节很多人忽略,但可能对性能有影响。
如果你已经知道 HashMap 里大概会放多少元素,应该在初始化的时候就指定容量,避免频繁扩容:
// 反面教材:不指定初始容量
Map<String, Object> userInfo = new HashMap<>();
// 随着数据增加,会触发多次扩容(每次扩容都要 rehash,性能开销大)
// 正确做法:预估好数量,除以负载因子,向上取整到 2 的幂
// 比如预计存 100 个元素:100 / 0.75 = 134,所以初始化 256(2^8)
Map<String, Object> userInfoOptimized = new HashMap<>(256);
// 或者用 Guava 提供的工具方法,它帮你算好了
// Maps.newHashMapWithExpectedSize(100)
// 等价于 new HashMap<>(147),内部按 expectedSize / 0.75 + 1 计算
7.2 Key 的设计原则
HashMap 的 key 需要正确实现 hashCode() 和 equals() 方法,这几乎是个老生常谈的点,但踩坑的人依然不少:
// 反面教材:用可变对象作为 key
public class MutableKey {
private String name;
// 省略 hashCode 和 equals,都基于 name
}
Map<MutableKey, String> map = new HashMap<>();
MutableKey key = new MutableKey("Alice");
map.put(key, "工程师");
key.setName("Bob"); // 修改了 key 的属性!
// 此时 key 的 hashCode 变了,但它在 map 里存的位置没变
// get 操作用新的 hashCode 找,找到错误的 bucket,返回 null
String value = map.get(key); // null!!!
System.out.println(value); // 数据"丢失"了,其实还在,只是找不到了
最佳实践:用不可变对象作为 key,比如 String、Integer 等包装类,或者你自己定义的 final 类。
// 正确做法:用不可变的 String 作为 key
Map<String, UserInfo> userMap = new HashMap<>();
userMap.put("user_001", new UserInfo("Alice", 28));
// String 是不可变的,hashCode 不会变,安全
7.3 一个完整的电商场景示例
结合一个真实场景来展示 HashMap 的合理用法——一个简单的商品库存管理模块:
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 商品库存管理服务
* 单机版用 HashMap,分布式场景需要换成 Redis
*/
public class InventoryService {
// 单线程操作用 HashMap(比如批量导入场景)
private final HashMap<String, Integer> localCache;
// 多线程操作用 ConcurrentHashMap(比如并发下单场景)
private final ConcurrentHashMap<String, Integer> stockMap;
public InventoryService(int expectedProductCount) {
// 预估商品数量,计算合理的初始容量,避免扩容开销
// expectedProductCount / 0.75 + 1,取最近的 2 的幂
int initialCapacity = tableSizeFor((int)(expectedProductCount / 0.75f + 1.0f));
this.localCache = new HashMap<>(initialCapacity);
this.stockMap = new ConcurrentHashMap<>(initialCapacity);
}
/**
* 批量初始化库存(单线程批量导入,用普通 HashMap 处理后转入)
*/
public void batchInitStock(Map<String, Integer> stockData) {
// 在单线程的批量处理逻辑里用 HashMap 临时存储,性能更好
HashMap<String, Integer> tempMap = new HashMap<>(stockData.size() * 2);
for (Map.Entry<String, Integer> entry : stockData.entrySet()) {
String skuId = entry.getKey();
Integer stock = entry.getValue();
// 简单的数据校验
if (skuId == null || skuId.isBlank() || stock == null || stock < 0) {
System.err.printf("非法库存数据,跳过:skuId=%s, stock=%s%n", skuId, stock);
continue;
}
tempMap.put(skuId, stock);
}
// 批量转入线程安全的 ConcurrentHashMap
stockMap.putAll(tempMap);
System.out.printf("成功初始化 %d 个 SKU 的库存数据%n", tempMap.size());
}
/**
* 扣减库存(多线程安全版)
* 使用 ConcurrentHashMap 的原子操作
*/
public boolean deductStock(String skuId, int quantity) {
if (skuId == null || quantity <= 0) {
throw new IllegalArgumentException("参数非法:skuId=" + skuId + ", quantity=" + quantity);
}
// compute 方法是原子操作,避免并发扣减出现超卖
final int[] result = {-1}; // 用数组绕过 lambda 的 effectively final 限制
stockMap.compute(skuId, (key, currentStock) -> {
if (currentStock == null) {
result[0] = -1; // 商品不存在
return null;
}
if (currentStock < quantity) {
result[0] = 0; // 库存不足
return currentStock; // 不变
}
result[0] = 1; // 扣减成功
return currentStock - quantity;
});
if (result[0] == 1) {
System.out.printf("库存扣减成功:skuId=%s, 扣减数量=%d%n", skuId, quantity);
return true;
} else if (result[0] == 0) {
System.out.printf("库存不足:skuId=%s%n", skuId);
return false;
} else {
System.out.printf("商品不存在:skuId=%s%n", skuId);
return false;
}
}
/**
* 查询库存
*/
public int getStock(String skuId) {
return stockMap.getOrDefault(skuId, 0);
}
/**
* 批量查询库存(返回一个快照,用 HashMap 存结果)
*/
public Map<String, Integer> batchGetStock(Iterable<String> skuIds) {
Map<String, Integer> result = new HashMap<>();
for (String skuId : skuIds) {
result.put(skuId, getStock(skuId));
}
return result;
}
// 计算最近的 2 的幂(参考 HashMap 内部实现)
private static int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= (1 << 30)) ? (1 << 30) : n + 1;
}
}
// 使用示例
public class Main {
public static void main(String[] args) {
InventoryService service = new InventoryService(1000);
// 批量初始化
Map<String, Integer> initData = new HashMap<>();
initData.put("SKU_APPLE_001", 500);
initData.put("SKU_MILK_002", 1000);
initData.put("SKU_BREAD_003", 300);
service.batchInitStock(initData);
// 查询库存
System.out.println("苹果库存: " + service.getStock("SKU_APPLE_001")); // 500
// 扣减库存
boolean success = service.deductStock("SKU_APPLE_001", 50);
System.out.println("扣减结果: " + success); // true
System.out.println("扣减后库存: " + service.getStock("SKU_APPLE_001")); // 450
// 尝试超卖
boolean oversell = service.deductStock("SKU_BREAD_003", 400);
System.out.println("超卖扣减结果: " + oversell); // false
}
}
代码里有几个设计值得说一下:
- 批量导入用普通 HashMap 做临时容器:ConcurrentHashMap 的并发控制是有开销的,单线程批量处理的时候用普通 HashMap 更快,处理完再整体 putAll 进去。
- compute 做原子扣减:ConcurrentHashMap 的
compute方法是原子操作,在单机场景下能保证不超卖,比get+ 判断 +put这种非原子组合安全得多。 - 初始化容量的预估:复用了 HashMap 内部的
tableSizeFor方法逻辑,确保初始容量是 2 的幂,且足够容纳预期数量的元素,避免扩容。
八、面试高频问题与我的理解
既然讲到这里了,顺带把 HashMap 相关的高频面试题也梳理一下,算是帮大家把学到的知识做个应用:
Q1:HashMap 的初始容量是多少?负载因子是多少?为什么默认这些值?
初始容量 16,负载因子 0.75。容量必须是 2 的幂,方便用位运算代替取模(h & (n-1) vs h % n)。负载因子 0.75 是空间和时间的平衡值:太大了冲突多,太小了浪费空间,官方文档提到这是基于泊松分布选出的一个经验值。
Q2:HashMap 的 key 可以是 null 吗?
可以,而且只能有一个 null key,会被映射到数组下标 0。Hashtable 不允许 null key。
Q3:为什么 HashMap 不是线程安全的?
JDK 1.7:并发扩容时头插法导致链表成环,get 操作无限循环,CPU 100%。
JDK 1.8:尾插法解决了死循环,但并发 put 时仍然可能丢失数据(两个线程同时判断链表尾节点为 null,后写的覆盖前写的)。
Q4:HashMap 和 LinkedHashMap、TreeMap 的区别?
- HashMap:无序,查找 O(1),不保证迭代顺序
- LinkedHashMap:维护插入顺序或访问顺序(双向链表),可用来实现 LRU 缓存
- TreeMap:按 key 的自然顺序或 Comparator 排序,基于红黑树,查找 O(log n)
Q5:JDK 1.8 的红黑树化,什么时候触发?
两个条件同时满足:① 链表长度 ≥ 8;② 数组长度 ≥ 64。如果数组长度 < 64 但链表长度 ≥ 8,会优先选择扩容而不是树化。
九、总结与思考
回过头来看,JDK 1.8 对 HashMap 的改进,本质上是在解决一个问题:如何在保证查找性能的前提下,应对极端的哈希冲突场景?
JDK 1.7 的方案是:更好的哈希算法(四次扰动),让冲突尽量少发生。
JDK 1.8 的方案是:一方面简化哈希算法(减少计算开销),另一方面引入红黑树做兜底(就算冲突发生了,查找也能维持 O(log n) 而不是退化到 O(n))。
同时,把头插法改成尾插法,消除了并发扩容时的死循环风险,让 HashMap 的行为在多线程场景下更可预期(虽然还是不安全,但至少不会把服务搞死)。
工程上,记住几个原则:
- 单线程用 HashMap,多线程用 ConcurrentHashMap,坚决不用 Hashtable
- 知道数据量的时候,指定初始容量,减少扩容开销
- key 要用不可变类型,或者确保 hashCode 和 equals 实现正确
- 如果需要有序,考虑 LinkedHashMap(维护插入顺序)或 TreeMap(按 key 排序)
最后说一句:这些原理不是为了背面试题而存在的。真正搞懂了,你才能在生产环境出问题的凌晨三点,不是茫然地看着崩掉的服务,而是有条不紊地定位问题,三分钟给出解决方案。
那种感觉,真的不一样。
🌟 文末
好啦~以上就是本期 《Java核心实操(进阶版)》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Java核心实操(进阶版)》,我会按照“入门 → 进阶 → 工程化 → 项目落地”的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/weixin_66592566/article/details/166847700




