目录
[HashMap 简要回顾](#HashMap 简要回顾)
HashMap 简要回顾
HashMap 是基于数组 + 链表(或树化)实现的键值存储结构:
-
底层维护一个数组(常称为 table),每个元素是一个桶(bucket)。
-
键对象的
hashCode()经哈希扰动后映射到数组索引。 -
相同索引处采用链表链接冲突节点;当链表长度超过阈值(通常为 8)并且数组足够大时,会转换为平衡树以加速查找。
这些设计确保在一般情况下能获得接近 O(1) 的查找/插入/删除性能。
为什么需要扩容
随着元素数量 size 增长而数组容量 capacity 不变,会导致:
-
每个桶平均元素数(负载)上升,冲突增加,访问链变长→查找/插入/删除成本上升。
-
空间与时间之间需要折中:容量太大浪费内存,太小则降低性能。
因此需要在合适时机将表扩容并重新分配元素,保持性能稳定。
扩容策略与加载因子
load factor(加载因子)定义为 size / capacity。
-
HashMap的默认加载因子为 0.75 。当size达到capacity * loadFactor时会触发扩容。 -
加载因子越小,表越稀疏,冲突越少,但内存利用率下降;加载因子越大,内存利用率上升,但冲突与访问时间可能增加。
在构造 HashMap 时可以指定初始容量和加载因子:
HashMap<K,V> map = new HashMap<>(initialCapacity, loadFactor);
合理指定这两个参数可以显著降低扩容次数和复制开销(例如已知预计元素数时)。
触发条件与阈值计算
触发扩容的关键量被称为 threshold(阈值) ,通常由 capacity * loadFactor 得到。注意:内部实现会把容量维持为 2 的幂,这让索引计算(hash & (capacity - 1))更高效。
举例:容量 capacity = 16,默认 loadFactor = 0.75,阈值 = 16 * 0.75 = 12。
当插入第 13 个元素时会触发扩容,通常新容量会变为原来的两倍(32)。
扩容的实现流程
扩容操作主要步骤:
-
计算新的容量(默认通常为原容量的两倍)。
-
创建新的数组
newTable。 -
遍历旧数组中每个 bucket,将其中的节点重新分配到
newTable的对应位置(重新计算索引或利用高位搬移优化)。 -
将内部引用替换为
newTable,更新threshold。
下面给出简化的伪代码(便于理解,而非完全等同于 JDK 源码):
void resize() {
int oldCap = table.length;
int newCap = oldCap << 1; // 通常扩大为两倍
Node[] newTable = new Node[newCap];
for (Node head : table) {
if (head == null) continue;
if (head.next == null) { // 单节点
int idx = indexFor(hash(head.key), newCap);
newTable[idx] = head;
} else {
// 将链表节点拆分到 newTable 的不同位置(重链或利用位操作分裂)
redistributeChain(head, newTable, newCap);
}
}
table = newTable;
threshold = (int)(newCap * loadFactor);
}
实际 JDK 的实现对链表分裂做了优化------不需要对每个元素重新计算完整哈希,而是利用容量翻倍时的高位变化将链表拆分为两部分,从而减少计算量与分配操作。
扩容的性能开销与复杂度分析
-
时间复杂度:扩容需要访问并重新放置所有现有元素,单次扩容为 O(n)。
-
摊还(amortized)复杂度:虽然某次插入可能触发 O(n) 的扩容,但若合理增长(如倍增策略),多数插入的平均开销仍是 O(1) 的摊还代价。
-
内存开销:扩容期间需要同时存在旧表和新表,会短时增加内存占用。
在极端场景下(频繁构造并逐步增长),频繁扩容会导致较高的 CPU 与 GC 压力。
实战优化建议
-
预估大小并设置 initialCapacity :若能预知大致元素数量
n,可在构造时把初始容量设为ceil(n / loadFactor)的最小 2 的幂,这能避免或减少扩容次数。int expected = 10000; Map<K,V> map = new HashMap<>((int)(expected / 0.75f) + 1); -
适配 loadFactor :对空间比时间更敏感的场景可适当增大
loadFactor(例如 0.9);对高性能低延迟场景可减小(例如 0.5)。 -
一次性批量 putAll 或初始化时填充 :如果要从大集合初始化
HashMap,优先使用putAll到已经预分配好容量的HashMap,避免边插入边扩容。 -
避免键的非良好哈希实现 :
hashCode()分布不均会造成聚集,增加冲突。保证键的哈希实现均匀分布很关键。 -
对多线程写入场景使用并发结构 :在多线程频繁写入场景,使用
ConcurrentHashMap更安全且性能更好。
并发环境下的差异
-
HashMap不是线程安全的:并发写入可能导致数据丢失或链表结构损坏(甚至死循环的历史风险,虽在新实现中已修复相关问题)。 -
ConcurrentHashMap在并发环境下采用分段/桶级别的并发控制、特殊的扩容策略与迁移机制,以减小锁竞争与停顿。
如果你的场景有多个线程同时进行大量写操作,应优先考虑 ConcurrentHashMap 或其他并发集合。
总结
-
HashMap的扩容是为维持较低冲突率与稳定性能而必须的机制,默认通过loadFactor=0.75与容量倍增来触发扩容与重新哈希。 -
扩容开销为 O(n),但采用倍增策略可以将单次开销摊薄,得到平均 O(1) 的插入复杂度。
-
在实际项目中,通过合理设置初始容量与加载因子、优化键的哈希、尽量批量初始化等做法,可以大幅降低扩容带来的性能与内存开销。