HashMap
HashMap 是 Java 中的一种数据结构,实现了 Map 接口,用于存储键值对(key-value pairs),允许 null 键和 null 值,非线程安全。
根据 Java7 HashMap 的介绍,我们知道,查找的时候,根据 hash 值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O(n)。
为了降低这部分的开销,在 Java8 中,当链表中的元素达到了 8 个时,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O(logN)。
HashMap: 链表≥8且数组大小>=64转为红黑树,如果链表>=8,数组没有到达64,先扩容,等数组长度达到64在把链表树化,节点<=6转为链表。插入后判断是否需要扩容,扩容因子0.75,2倍扩容,初始容量16。
底层结构
- JDK 1.7 及以前:纯粹是 数组 + 链表。发生哈希冲突时,新元素会被放到链表的头部(头插法)。
- JDK 1.8 及以后:变成了 数组 + 链表 + 红黑树。当链表长度超过阈值(默认为 8)并且当前数组容量达到一定值(默认为 64)时,链表会转换为红黑树,以提高极端情况下的查询效率(从 O(n) 提升到 O(log n))。当树节点数减少到较小值(默认为 6)时,红黑树会退化成链表。
- 桶数组(
table):核心存储载体,每个元素称为“桶”,用于定位键值对的基础位置。数组容量(n)始终是 2的幂次方 (如16、32、64...,初始默认16),这是高效计算索引的前提。 - 链表:当多个键计算出相同桶索引(哈希冲突)时,节点以链表形式串联在同一桶中(节点通过
next指针连接),适合冲突较少的场景(节点数≤7)。 - 红黑树:当链表长度≥8且数组容量≥64时,链表转为红黑树(
TreeNode)。红黑树通过自平衡特性保证查询/插入/删除效率为O(logn),解决长链表(O(n))的性能问题。
桶(Bucket)
HashMap 中的桶(Bucket),本质是 HashMap 底层数组的单个元素 / 位置,核心作用是存储「哈希值相同(或哈希冲突)的键值对(Entry/Node)」,可以理解为 “分组存储的容器”。
- 底层基础:HashMap 底层是「数组 + 链表 / 红黑树」结构,这个数组的每一个下标对应的位置,就是一个「桶」;
- 形象类比:把 HashMap 看作一个 “储物柜”,每个储物柜(桶)有一个编号(数组下标),哈希值就是 “储物柜编号”,键值对就是 “要存放的物品”;哈希冲突就是 “两个物品分到了同一个储物柜”,储物柜里的 “隔板 / 分层” 就是链表 / 红黑树。
关键特性
- 允许
null键值:null键的哈希值为0,固定存于索引0的桶。 - 依赖
hashCode()和equals():自定义键必须重写这两个方法——hashCode()决定存储位置,equals()判断键是否相同,否则会导致查询/插入异常。 - 不保证顺序:插入的顺序和遍历的顺序不一定一致。
- 非线程安全:多线程并发修改可能导致数据覆盖(JDK 1.8)或环形链表死循环(JDK 1.7头插法),需用
ConcurrentHashMap替代。 - 树化与退化:链表≥8且容量≥64时树化;红黑树节点≤6时退化回链表(避免频繁转换开销)。
- 性能:在理想情况下(没有哈希冲突),
get和put操作的时间复杂度都是 O(1)
工作原理
存数据(put 过程)
当我们调用 map.put(key, value) 时,会发生以下步骤:
计算哈希值:
首先调用
key.hashCode()方法得到原始的哈希值h。然后,
HashMap会通过一个 扰动函数 对这个原始哈希值进行二次处理。在 JDK 1.8 中,这个处理非常简洁高效:(h = key.hashCode()) ^ (h >>> 16)。static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }为什么要扰动? 将高 16 位与低 16 位进行异或操作,目的是为了将高位的特征融入到低位中,从而增加低位的随机性,减少哈希冲突。(目的是混合哈希值的高位和低位信息,减少因高位未被利用导致的哈希冲突。)
计算数组下标:
- 利用处理后的哈希值,与当前数组长度
n(一定是 2 的幂)进行(n - 1) & hash操作,得到元素在数组中的具体下标。 - 为什么是
(n-1) & hash? 因为当n是 2 的幂时,(n-1)的二进制表示就是一串连续的1(比如15的二进制是1111)。这个操作等价于hash % n,但位运算的效率远高于取模运算。
- 利用处理后的哈希值,与当前数组长度
放入数组(桶):
如果计算出的下标位置为
null,直接创建一个新Node节点放进去。如果该位置不为
null(发生了哈希冲突),则需要遍历该位置上的链表或红黑树:- 如果是链表:依次比较每个节点的
key(先用hash快速判断,再用equals方法精确判断)。- 如果找到相同的
key,则用新的value覆盖旧的value。 - 如果没找到,则将新节点插入到链表的末尾(尾插法)。
- 如果找到相同的
- 如果是红黑树:则按照红黑树的方式插入节点。
- 如果是链表:依次比较每个节点的
插入链表后,会检查当前链表长度是否
大于等于 8(TREEIFY_THRESHOLD)
。如果是,则调用
treeifyBin方法。- 在
treeifyBin中,会再次判断当前数组容量是否大于等于 64(MIN_TREEIFY_CAPACITY)。如果是,则将链表转换为红黑树;如果不是,则优先选择扩容(resize)。
- 在
判断是否需要扩容:
- 插入成功后,会检查当前
HashMap中的元素总数(size)是否超过了 阈值(threshold)。阈值 = 当前数组容量(capacity) * 负载因子(loadFactor,默认 0.75)。 - 如果超过阈值,则调用
resize()方法进行扩容。
- 插入成功后,会检查当前
取数据(get 过程)
当我们调用 map.get(key) 时,过程与 put 类似:
- 计算
key的哈希值(同样的扰动函数)。 - 通过
(n-1) & hash计算数组下标。 - 定位到数组下标:
- 如果该位置是红黑树,则在树中查找。
- 如果该位置是链表,则遍历链表。
- 查找时,同样先比较
hash,再使用equals方法比较key。
- 找到则返回对应的
value,没找到则返回null。
扩容机制(Resize)
扩容是 HashMap 性能的关键点之一。当元素数量达到阈值后,HashMap 会创建一个新的、容量是原来 2倍 的数组。
扩容步骤:
分配一个新数组(例如,从 16 扩容到 32)。
遍历旧数组中的每一个桶(链表或树)。
对于每个节点,重新计算其在新数组中的下标。由于新容量是旧容量的 2 次幂,所以节点的新位置
要么保持不变,要么是
原位置 + 旧容量。- 这是因为
(新容量-1) & hash的结果只比(旧容量-1) & hash多了一个高位1。这个高位1是正是由旧容量的值决定的。 - 例如,旧容量为 16 (
10000),(16-1)=15 (01111)。新容量为 32 (100000),(32-1)=31 (11111)。节点的新位置就取决于其哈希值在第 5 位(从右向左)是 0 还是 1。如果是 0,则位置不变(原下标);如果是 1,则新位置为原下标 + 16。
- 这是因为
为什么要扩容?
- 为了减少哈希冲突。当元素越来越多,链表就会越来越长,查询效率会从 O(1) 退化为 O(n)。通过扩容,可以将元素重新分散到更多的桶中,使链表的长度变短,从而维持高效的操作性能。
关键参数
- 初始容量(Initial Capacity):默认是 16。创建
HashMap时可以指定。 - 负载因子(Load Factor):默认是 0.75。这是一个权衡时间和空间的经验值。值越小,空间开销越大,但哈希冲突概率低,查询快;值越大,空间开销小,但哈希冲突概率高,查询慢。
- 阈值(Threshold):
容量 * 负载因子。是触发扩容的临界点。HashMap的默认初识容量是16,负载因子是0.75,因此默认扩容域是12,理论上插入第13个值就会扩容,扩容就是新建一个2倍大的新数组,然后把原来的数据迁移过去。
扩容机制
触发树化或扩容:
- 若链表长度≥8且容量≥64:链表转红黑树;
- 若容量<64:不树化,触发扩容(优先通过扩容拆分链表);
- 若元素总数超过“阈值”(
容量×负载因子,默认负载因子0.75):触发扩容。
当元素数超过阈值(或链表过长但容量不足)时,HashMap会扩容以减少冲突:
- 容量翻倍:新容量=原容量×2(保持2的幂次方),新阈值=新容量×负载因子。
- 节点重分配:遍历原数组,重新计算每个节点的新索引(基于新容量):
- 新索引由“新增高位”(容量翻倍多的一位二进制)决定:若高位为0,新索引=原索引;若为1,新索引=原索引+原容量。
- 此过程会将原链表/红黑树拆分为两个子结构,分别存入新桶,避免结构过长。
问题
说说put流程?
put流程先计算hash,选择bucket,若空的时候插入,若非空检查equals,选择替换还是插入尾部,还会根据阈值决定树化或扩容
HashMap的性能提升技巧
- 创建时直接设置合适的大小,避免扩容
- 适当增加扩容因子
- 避免使用质量不高的哈希函数,确保哈希分布均匀
使用 HashMap 时,有哪些提升性能的技巧?
提升 HashMap 性能,说白了就是两件事:少扩容、少冲突。
1)初始容量一定要给够
业务代码尽量不写 new HashMap<>(),而是 new HashMap<>((int)((size/0.75f)+1)),size 是业务上能预估的最大元素数,这样就能避免频繁的扩容操作。
Java 中 HashMap 默认初始容量是 16
2)负载因子别乱调
官方提供的默认负载因子是 0.75,是时间复杂度与空间利用率的最佳平衡。
较低的负载因子会减少冲突,提高查找效率,但会占用更多内存。较高的负载因子则会减少内存消耗,但可能增加冲突的概率,降低查找效率。
3)确保 hashCode 均匀分布
对应 key 的 hashCode() 方法生成的哈希值需均匀分布,减少哈希冲突。避免使用质量不高的哈希函数,防止大量键映射到相同的槽位上,造成性能瓶颈。

除此之外,还有个遍历场景可以提一下:优先使用 entrySet(需要 key 和 value)或 keySet(只要 key)
错误的方式如下:
for (String key : map.keySet()) {
Object v = map.get(key); // 这里会再次 hash,一次 keySet + 一次 get
}
推荐方式:
for (Map.Entry<String, Object> entry : map.entrySet()) {
var k = entry.getKey();
var v = entry.getValue(); // 不需要再次 hash
}
为什么既要链表又要红黑树?
jdk1.7之前只有链表,jdk1.8的时候增加了红黑树,因为如果太长了可以将链表转换成红黑树,红黑树可以把查找从o(n)降低为o(logn)提升效率
为什么要红黑树?不要AVL树?
因为AVL树是严格平衡维护代价比较高,红黑树平衡策略相对宽松
为什么有红黑树还要链表?
因为红黑树节点是普通节点大小的2倍,能省则省
不能抛弃链表,直接使用红黑树吗?
来看下源码的注释:
* Because TreeNodes are about twice the size of regular nodes。
因为红黑树节点的大小是普通节点大小的两倍,所以为了节省内存空间不会直接只用红黑树,只有当节点到达一定数量才会转成红黑树,这里定义的是 8。
为什么是 8 呢?这个其实 HashMap 注释上也有说,和泊松分布有关系。

简单翻译下就是在默认阈值是 0.75 的情况下,冲突节点长度为 8 的概率为 0.00000006,也就概率比较小(毕竟红黑树耗内存,且链表长度短点时遍历的还是很快的)。
这就是基于时间和空间的平衡了,红黑树占用内存大,所以节点少就不用红黑树,如果万一真的冲突很多,就用红黑树,选个参数为 8 的大小,就是为了平衡时间和空间的问题。
什么时候链表转换成红黑树?
当链表大于等于8且数组长度大于等于64的时候
为什么数组要大于等于 64 才转红黑树?
主要原因有以下两点:
- 避免频繁的树化
- 减少内存占用
当数组容量较小的时候,哈希冲突大,但是扩容后的 HashMap 自然会减少哈希冲突。如果在小数组上过早地进行链表转红黑树,可能会因为很快的扩容导致不必要的树化开销。
刚树化了没多久就扩容了,冲突就少了,此时不就白白树化了?所以设计上设置了数组大小需要大于 64 才允许链表转为红黑树,防止频繁的树化。
红黑树相比链表需要更多的内存,尤其是在节点较少的情况下,红黑树的额外指针和结构占用更大。为了节省内存,HashMap 选择只有在数组容量达到一定规模后才树化,防止红黑树在小规模数据中带来额外的内存负担。
当链表大于等于8,数组小于64的时候会怎么样?
会扩容,为什么是8,和柏松分布有关,因为红黑树占用内存大,默认负载因子为0.75,链表冲突到8点几率极低,所以不会触发.所以选择了8是时间和空间平衡
如果链表转了红黑树,还会转回来吗?
会,当树节点小于等于6的时候就会转回来,这个也是为了平衡时间和空间
为什么是6而不是8?
因为要留一些缓冲余地,避免反复横跳
为什么节点小于等于 6 要从红黑树转成链表?
链表树化的节点是 8,除此之外,当树节点数小于等于 6 时候,又会从红黑树转为链表。
这个操作是为了平衡时间和空间,节点太少链表遍历也很快,没必要成红黑树,变成链表节约内存。
为什么定了 6 而不是小于等于 8 就变?
是因为要留个缓冲余地,避免反复横跳。举个例子,一个节点反复添加,从 8 变成 9 ,链表变红黑树,又删了,从 9 变成 8,又从红黑树变链表,再添加,又从链表变红黑树?
所以余一点,毕竟树化和反树化都是有开销的。
头插法和尾插法
1.7 是头插法,头插法的好处就是插入的时候不需要遍历链表,直接替换成头结点,但是缺点是扩容的时候会逆序,而逆序在多线程操作下可能会出现环,然后就死循环了。

然后 1.8 是尾插法,每次都从尾部插入的话,扩容后链表的顺序还是和之前一致,所以不可能出现多线程扩容成环的情况。
再延伸一下,改成尾插法之后 HashMap 就不会死循环了吗?
好像还是会,这次是红黑树的问题,我在网上看到这篇文章,有兴趣的可以深入了解下:
https://blog.csdn.net/qq_33330687/article/details/101479385
为什么是插入到尾部?不是插入到头部?
1.7之前是头插法,如果并发可能会成环.所以,1.8改为尾插法
为什么HashMap的头插法改为尾插法
两个线程同时操作链表且需要扩容时,在A线程执行到即将插入时,B线程此时扩容成功,由于是头插法,扩容遍历后的链表顺序倒转,再执行A线程会形成链表循环
看一个最小例子(旧桶里只有 A→B):
初始:
- 旧桶:
A -> B -> null - 新桶:
null
并发搬运的一个可能交错(T1、T2 都迁移同一桶):
- T1处理
A:保存next=B;新桶为空,执行头插- 令
A.next = null newHead = A
- 令
- T2也开始处理
A(它还以为 A 还在旧桶里):- 读取
next = A.next(此时已被 T1 改成null) - 执行头插:
A.next = newHead,而newHead此刻是A本身 - 于是得到
A.next = A(自环);T2 结束对 A 的处理
- 读取
- T1继续处理
B:保存next = null;头插到新桶B.next = newHead(即A,但A已经自环)newHead = B
最终新桶链形态是:B -> A -> A -> A ...
什么是负载因子?
负载因子是 HashMap 中的一个参数,用来衡量 HashMap 的满载程度,公式为: 负载因子 = 实际存储的元素数量 / 容量。
当 HashMap 中存储的元素数量超过 容量 × 负载因子 时,HashMap 会进行扩容操作,增加容量以维持性能。
HashMap的扩容机制?
HashMap的默认初识容量是16,负载因子是0.75,因此默认扩容域是12,理论上插入第13个值就会扩容,扩容就是新建一个2倍大的新数组,然后把原来的数据迁移过去
为什么是2倍?
容量始终保持为2的幂,这样可以通过(n-1) & hash这个位运算,快速定位到数组下标,比直接取余运算效率高很多;
为什么 HashMap 在 Java 中扩容时采用 2 的 n 次方倍?
HashMap 采用 2 的 n 次方倍作为容量,主要是为了提高哈希值的分布均匀性和哈希计算的效率。
HashMap 通过 (n - 1) & hash 来计算元素存储的索引位置,这种位运算只有在数组容量是 2 的 n 次方时才能确保索引均匀分布。位运算的效率高于取模运算(hash % n),提高了索引计算的速度。
且当 HashMap 扩容时,通过容量为 2 的 n 次方,扩容时只需通过简单的位运算判断是否需要迁移,这减少了重新计算索引的开销,提升了 rehash 的效率。
哈希分布均匀性
如果数组容量为 2 的 n 次方,那么 n - 1 后低位都是 1 ,此时进行 & (两个位都为 1 时,结果才为 1)运算可以确保哈希码的最低几位均匀分布。
比如 64 二进制表示为 0100 0000,64 - 1 = 0011 1111。
此时 0011 1111 与哈希码进行 & 运算,低位能均匀的反应出哈希码的随机性。
假设来个 0100 0000 与哈希码进行 & 运算,那么低位得到的值就都是 0 了,随机性很差,都是冲突。
位运算与取模
正常情况下,如果基于哈希码来计算数组下标,我们想到的都是 %(取余)计算。例如数组长度为 5 ,那么哈希码 % 5 得到的值就是对应的数组下标。
但相比于位运算而言,效率比较低,所以推荐用位运算,而要满足 i = (n - 1) & hash 这个公式,n 的大小就必须是 2 的 n 次幂。即:当 b 等于 2 的 n 次幂时,a % b 操作等于 a & ( b - 1 )
为什么 Java 中 HashMap 的默认负载因子是 0.75?
HashMap 的默认负载因子为 0.75 是为了在时间复杂度和空间复杂度之间取得一个合理的平衡。负载因子为 0.75 时,避免过多扩容的同时,也保证了不会出现过多的哈希冲突,确保查找和插入操作的效率,维持良好的性能表现。
简单理解下就是设置 0.75 是因为空间和时间上的平衡。
较低的负载因子(例如 0.5)会导致 HashMap 需要频繁扩容,空间利用率就低。不过因为冲突少,查找效率就高,但是因为扩容频繁会增加 rehashing 的开销。
较高的负载因子(例如 1.0)会减少扩容次数,空间利用率高了,但会增加哈希冲突的概率,从而降低查找效率。
经过大量实践,0.75 被认为是大多数场景下比较合适的值,能够在时间和空间之间取得良好的平衡。
所以设置了 0.75。
为什么不选择 1.0 作为默认负载因子
尽管负载因子 1.0 可以减少扩容次数,提高内存利用率,但它增加了哈希冲突的可能性。冲突过多会导致桶内链表或红黑树变长,降低查找、插入和删除的效率。因此,负载因子 1.0 会使得时间复杂度劣化为 O(n),不利于 HashMap 的高效运行。
不同场景下的负载因子调整
在某些特定场景下,可以根据业务需求调整 HashMap 的负载因子。例如:
- 高并发读取场景:可以降低负载因子(如 0.5),以减少哈希冲突,提高读取性能。
- 内存受限场景:可以提高负载因子(如 0.85 或更高),以减少扩容次数和内存消耗,但可能会降低写入和查询的性能。
JDK 1.8 对 HashMap 除了红黑树还进行了哪些改动?
- 改进了哈希函数的计算:JDK 1.8 中优化了哈希函数,使得哈希值的分布更加均匀,减少了哈希冲突的发生。通过在生成哈希值时使用“扰动函数”,确保哈希值的高低位都能参与到桶的选择中。
- 扩容机制优化:JDK 1.8 改进了扩容时的元素迁移机制。在扩容过程中不再对每个元素重新取模计算索引,而是根据原数组长度的高位来判断元素是留在原位置,还是迁移到新数组中的新位置。这一改动减少了不必要的计算,提升了扩容效率。
- 头插法变为尾插法:头插法的好处就是插入的时候不需要遍历链表,直接替换成头结点,但是缺点是扩容的时候会逆序,而逆序在多线程操作下可能会出现环,产生死循环,于是改为尾插法。
哈希函数的优化
1.7是这样实现的:
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
而 1.8 是这样实现的:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
具体而言就是 1.7 的操作太多了,经历了四次异或,所以 1.8 优化了下,它将 key 的哈希码的高 16 位和低 16 位进行了异或,得到的 hash 值同时拥有了高位和低位的特性,使得哈希码的分布更均匀,不容易冲突。
这也是 JDK 开发者根据速度、实用性、哈希质量所做的权衡来做的实现:

Java1.8之后rehash的关键
它们之间的差别就在于高位多了一个1,而我们通过key的hash值定位其在数组位置所采用的方法是(数组长度-1)& hash 。拿 16 和 32 长度举例:
16 - 1 = 15,二进制为 001111
32 - 1 = 31,二进制为 011111
所以重点就在key的hash值的从右往左数第五位是否是1,如果是1说明需要搬迁到新位置,且新位置的下标就是原下标+16(原数组大小),如果是0说明吃不到新数组长度的高位,那就还是在原位置,不需要迁移。
所以,我们刚好拿老数组的长度(010000)来判断高位是否是1,这里只有两种情况,要么是1要么是0。
为什么高并发场景下可以适当降低hashmap的负载因子
高并发读取场景下减少负载因子可以降低哈希冲突,提高读取性能 频繁扩容的场景就可以适当提高负载因子的值
HashMap 的 key 用可变对象有什么风险?
如果 key 是可变对象,存进去之后又改了参与 hashCode 计算的字段,那这个 entry 就再也找不到了。因为修改后 hashCode 变了,HashMap 会到新的槽位去找,但数据还在老槽位,get 返回 null,remove 也删不掉,造成内存泄漏。所以 HashMap 的 key 最好用不可变对象,String、Integer 这些都可以,自定义类当 key 要特别小心。
各种集合的特点
- List:
- ArrayList:基于动态数组,查询速度快,插入、删除慢。
- LinkedList:基于双向链表,插入、删除快,查询速度慢。
- Vector:线程安全的动态数组,类似于ArrayList,但开销较大。
- Set:
- HashSet:基于哈希表,元素无序,不允许重复。
- LinkedHashSet:基于链表和哈希表,维护插入顺序,不允许重复。
- TreeSet:基于红黑树,元素有序,不允许重复。
- Map:
- HashMap:基于哈希表,键值对无序,不允许键重复。
- LinkedHashMap:基于链表和哈希表,维护插入顺序,不允许键重复。
- TreeMap:基于红黑树,键值对有序,不允许键重复。
- Hashtable:线程安全的哈希表,不允许键或值为null。
- ConcurrentHashMap:线程安全的哈希表,适合高并发环境,不允许键或值为null。
HashMap和HashTable对比差异
允许空值:
HashMap:允许一个
null键和多个null值Hashtable:不允许
null键和null值,插入null键或null值时会抛出NullPointerExceptioin。
继承结构:
HashMap:继承自
AbstractMap,是Map接口的实现类。Hashtable:继承自
Dictionary(已废弃),后来也实现了Map接口。它是一种较为古老的类,在Java 2 之后逐渐被HashMap所取代(不推荐使用)。
迭代器类型:
HashMap:使用的是快速失败的
Iterator,在迭代过程中如果对Map进行结构性修改,会抛出ConcurrentModificationException。Hashtable:使用的是弱一致性的
Enumerator,虽然也不建议在迭代过程中修改Map,但不会抛出ConcurrentModificationException。
Java 中的 HashMap 和 HashSet 有什么区别?
HashSet:是基于 HashMap 实现的集合类,不允许重复元素,用于存储一组无序的唯一元素。
HashMap:基于哈希表的数据结构,存储的是键值对(key-value),键必须唯一,值可以重复。
实际上HashSet 内部使用 HashMap 来实现,HashSet 中的元素实际上存储在 HashMap 的键中,而所有的值都是一个常量对象 PRESENT。因此,HashSet 仅操作 HashMap 的键部分。
Java 中的 HashMap 和 Hashtable 有什么区别?
线程安全性:
- HashMap:非线程安全,多线程环境下可能会产生数据不一致问题。
- Hashtable:线程安全,内部大部分方法都使用了
synchronized关键字进行同步,保证了多线程的并发安全性。
2)性能差异:
- HashMap:由于没有线程同步开销,因此在单线程环境下性能优于
Hashtable。 - Hashtable:由于方法的同步锁机制,性能低于
HashMap。由于使用全局锁的方式,使得即使是不同的操作也会被串行化。
3)允许空值:
- HashMap:允许一个
null键和多个null值。 - Hashtable:不允许
null键和null值,插入null键或null值时会抛出NullPointerException。
4)继承结构:
- HashMap:继承自
AbstractMap,是Map接口的实现类。 - Hashtable:继承自
Dictionary(已废弃),后来也实现了Map接口。它是一种较为古老的类,在 Java 2 之后逐渐被HashMap所取代(不推荐使用)。
5)迭代器类型:
- HashMap:使用的是快速失败的
Iterator,在迭代过程中如果对Map进行结构性修改,会抛出ConcurrentModificationException。 - Hashtable:使用的是弱一致性的
Enumerator,虽然也不建议在迭代过程中修改Map,但不会抛出ConcurrentModificationException。
什么是 Java 的 Hashtable、HashMap 和 TreeMap?它们有什么区别?
了解 Hashtable、HashMap、TreeMap
1)Hashtable
- 是一个古老的实现,随 Java 1.0 引入。
- 它是同步的,这意味着它是线程安全的,但这也意味着它通常比非同步的实现(如 HashMap )慢。
- 不允许使用 null 键或 null 值。
- 是一个基于散列表的 Map 实现。
2)HashMap
- 是 Java 1.2引入的,作为 Hashtable 的一个更先进的替代品。
- 它是非同步的,这意味着它不是线程安全的,但其性能在单线程环境下通常比 Hashtable 好。
- 允许一个 null 键和多个 null 值。
- 同样是基于散列的 Map 实现,但提供更快的迭代。
3)TreeMap
- 基于红黑树( Red-Black tree )的 NavigableMap 实现。
- 按照键的自然顺序或创建时提供的 Comparator 进行排序,因此它除了作为 Map 外,还可以用作双端队列。
- 不是线程安全的。
- 不允许 null 键(如果使用自然排序),但允许多个 null 值。
- 提供了一些特殊的方法来处理键的有序性,如
firstKey(),lastKey(),headMap(),tailMap()等。
Java 中的 LinkedHashMap 是什么?
LinkedHashMap 是 Java 集合框架中的一个实现类,它继承自 HashMap,并且保留了键值对的插入顺序或访问顺序。
它内部是通过维护了一个双向链表来记录元素的插入顺序或访问顺序。
使用场景:
- 缓存实现:可以根据访问顺序移除最久未使用的元素,常用于 LRU(Least Recently Used)缓存。
- 数据存储:需要保持元素插入顺序的场景。
Java 中 ConcurrentHashMap 1.7 和 1.8 之间有哪些区别?
这两个版本的主要区别点在于锁的粒度。
JDK 1.7 ConcurrentHashMap 采用的是分段锁(Segment),底层还是 HashMap 的数组,但把整个数组分成 16 个 Segment(默认),每个 Segment 里面有个完整的 HashMap + 一个 ReentrantLock。
不同线程访问不同 Segment 完全不挡,同一 Segment 才锁竞争,并发度最高 16(基本够用了,但锁还是有点粗)

而 JDK 1.8 移除了 Segment,锁的粒度更细。数据结构彻底跟 HashMap 同步,变成数组 + 链表 + 红黑树。
插入时先用 CAS 无锁尝试插到数组位置,真冲突了才用 synchronized,但只锁链表/树的第一个节点(头节点),其他线程照样可以操作别的 bucket,并发度大大增加。
锁只在链表或红黑树的节点级别上进行。通过 CAS 进行插入操作,只有在更新链表或红黑树时才使用 synchronized,并且只锁住链表或树的头节点,进一步减少了锁的竞争,并发度大大增加。

详细区分图如下:

可以顺便提一句的加分点:
- 1.7 计算 size 要把 16 个 Segment 锁住加起来,慢
- 1.8 用 longAdder / CounterCells,基本无锁统计
扩展知识
ConcurrentHashMap 1.7 简单图解
我们再来看下大致的结构。

原理就是先通过 key 的 hash 判断得到 Segment 数组的下标,将这个 Segment 上锁,然后再次通过 key 的 hash 得到 Segment 里 HashEntry 数组的下标,下面这步其实就是 HashMap 一致了,所以我说差别就是引入了一个 Segments 数组。
因此可以简化的这样理解:每个 Segment 数组存放的就是一个单独的 HashMap。

可以看到,图上我们有 6 个 Segment,那么等于有六把锁,因此共可以有六个线程同时操作这个 ConcurrentHashMap,并发度就是 6,相比于直接将 put 方法上锁,并发度就提高了,这就是分段锁。
具体上锁的方式来源于 Segment,这个类实际继承了 ReentrantLock,因此它自身具备加锁的功能。
可以看出,1.7 的分段锁已经有了细化锁粒度的概念,它的一个缺陷是 Segment 数组一旦初始化了之后不会扩容,只有 HashEntry 数组会扩容,这就导致并发度过于死板,不能随着数据的增加而提高并发度。
ConcurrentHashMap 1.8 简单图解
1.8 ConcurrentHashMap 做了更细粒度的锁控制,可以理解为 1.8 HashMap 的数组的每个位置都是一把锁,这样扩容了锁也会变多,并发度也会增加。

思想的转变就是把粒度更加细化。不分段了,我直接把 Node 数组的每个节点分别上一把锁,这样并发度不就更高了吗?
并且 1.8 也不借助于 ReentrantLock 了,直接用 synchronized,这也侧面证明,都 1.8 了 synchronized 优化后的速度已经不下于 ReentrantLock 了。
具体实现思路也简单:当塞入一个值的时候,先计算 key 的 hash 后的下标,如果计算到的下标还未有 Node,那么就通过 cas 塞入新的 Node。如果已经有 node 则通过 synchronized 将这个 node 上锁,这样别的线程就无法访问这个 node 及其之后的所有节点。
然后判断 key 是否相等,相等则是替换 value ,反之则是新增一个 node,这个操作和 HashMap 是一样的。
扩容的区别
JDK 1.7 中的扩容:
- 基于 Segment:
ConcurrentHashMap是由多个Segment组成的,每个Segment中包含一个HashMap。当某个Segment内的HashMap达到扩容阈值时,单独为该Segment进行扩容,而不会影响其他Segment。 - 扩容过程:每个
Segment维护自己的负载因子,当Segment中的元素数量超过阈值时,该Segment的HashMap会扩容,整体的ConcurrentHashMap并不是一次性全部扩容。
JDK 1.8 中的扩容:
- 全局扩容:
ConcurrentHashMap取消了Segment,变成了一个全局的数组(类似于HashMap)。因此,当ConcurrentHashMap中任意位置的元素超过阈值时,整个ConcurrentHashMap的数组都会被扩容。 - 基于 CAS 的扩容:在扩容时,
ConcurrentHashMap采用了类似HashMap的方式,但通过CAS 操作确保线程安全,避免了锁住整个数组。在扩容时,多个线程可以同时帮助完成扩容操作。 - 渐进式扩容:JDK 1.8 的
ConcurrentHashMap引入了渐进式扩容机制,扩容时并不是一次性将所有数据重新分配,而是多个线程共同参与,逐步迁移旧数据到新数组中,降低了扩容时的性能开销。
渐进式扩容分析
当 put 的时候,发现当前 node hash 值是 -1,则表明当前的节点正在扩容,则会触发协助扩容机制:

其实大家大致理解下就够了:
扩容无非就是搬迁 Node,假设当前数组长度为 32,那就可以分着搬迁,31-16 这几个下标的 Node 都由线程 A 来搬迁,然后线程 B 来搬迁 15-0 这几个下标的 Node。
简单说就是会维护一个 transferIndex 变量,来的线程死循环 cas 争抢下标,如果下标已经分配完了,那自然就不需要搬迁了,如果 cas 抢到了要搬运的下标,那就去帮忙搬就好了,就是这么个事儿。
size 逻辑的区别
1.7 有个尝试的思想,当调用 size 方法的时候不会加锁,而是先尝试三次不加锁获取 sum。
如果三次总数一样,说明当前数量没有变化,那就直接返回了。如果总数不一样,那说明此时有线程在增删 map,于是加锁计算,这时候其他线程就操作不了 map 了。
if (retries++ == RETRIES_BEFORE_LOCK) {
for (int j = 0; j < segments.length; ++j)
ensureSegment(j).lock(); // force creation
}
...再累加数量
而 1.8 不一样,它就是直接计算返回结果,具体采用的是类似 LongAdder 的思想,累加不再是基于一个成员变量,而是搞了一个数组,每个线程在自己对应的下标地方进行累加,等最后的时候把数组里面的数据统一一下,就是最终的值了。
所以这是一个分解的思想,分而治之。

在 put 的时候,就会通过 addCount 方法维护 counterCells 的数量,当然 remove 的时候也会调用此方法。

总而言之,就是平日的操作会维护 map 里面的节点数量,会先通过 CAS 修改 baseCount ,如果成功就直接返回,如果失败说明此时有线程在竞争,那么就通过 hash 选择一个 CounterCell 对象就行修改,最终 size 的结果就是 baseCount + 所有 CounterCell 。
这种通过 counterCell 数组来减少并发下场景下对单个成员变量的竞争压力,提高了并发度,提升了性能,这也就是 LongAdder 的思想。
ConcurrentHashMap1.7和1.8的区别
1.7: 采用分段锁,每个segment是独立的,默认16个,所以最多16个线程并发执行 **1.7的扩容:**单个segment扩容,每个segment维护自己的负载因子
1.8: 移除segment,每一个点都是Node级别的,锁只在链表或红黑树节点级别上进行,通过CAS进行插入操作,只有在更新链表或红黑树的时候才使用synchronized,并且只锁住链表或树的头结点 1.8的扩容: 触发扩容就会全局扩容,通过CAS确保线程安全且允许多个线程同时帮助完成
总结一句话:
- 空桶装头→ CAS(最快);
- 非空桶的结构性修改→
synchronized(桶头)(只锁这个 bin,比锁整个 Segment 并发度高得多); - 读操作始终无锁;扩容按桶迁移,遇到转发节点就路由到新表。
渐进式rehash: 1.当进行扩容时,先为哈希表2分配空间大小为哈希表1的2倍,此时字典的rehashidx从-1变为0 2.数据开始转移,每次对hash进行crud操作都会将表1的数据迁移到2上,然后rehashidx+1 3.迁移完成后,指针从表1指向2,原先的表1置为空,rehashidx置为-1
1.7和1.8的size()方法: 1.7无锁两次采样modCount,如果相同直接返回,如果不同把所有segment加锁并获取精准值 1.8基于CAS增减的baseCount和CounterCell[ ],为LongAdder风格,返回值近似,不精准,直接求和不加锁
1.8 为什么用 synchronized 而不是继续用 ReentrantLock?
主要是 synchronized 在 1.6 之后做了大量优化,包括偏向锁、轻量级锁、自适应自旋等,性能已经和 ReentrantLock 差不多了。用 synchronized 有几个好处:第一是代码更简洁,不用显式加锁解锁;第二是 JVM 可以在运行时做更多优化,比如锁消除、锁粗化;第三是 synchronized 在 JIT 编译后有更好的内存布局。Doug Lea 在设计 1.8 的时候专门测过,synchronized 在这个场景下表现不差。
1.8 的渐进式扩容会不会导致读操作读到不一致的数据?
不会。扩容时 ConcurrentHashMap 会同时保留新旧两个数组,读操作要么读到旧数组的数据,要么读到新数组的数据,都是正确的。具体来说,正在迁移的节点会被替换成一个特殊的 ForwardingNode,它的 hash 值是 -1,读操作碰到这个节点会自动去新数组查。已经迁移完的数据在新数组里,没迁移的还在旧数组里,两边都能正确读到。
1.7 的分段锁在计算 size 时为什么要先尝试三次不加锁?
因为加锁代价太高了,要把 16 个 Segment 全锁住,等于整个 map 暂停服务。大部分情况下 map 没有并发写入,三次统计结果一样的概率很高,这时候直接返回就省了加锁的开销。只有真的有并发写入导致统计结果不一致时,才需要加锁来保证准确性。这是一种乐观的思想,先假设没问题,发现有问题再加锁。
ConcurrentHashMap 的 size 方法是怎么实现的,准确吗?
Java 8 的 size 方法不是精确值,是个估算值。ConcurrentHashMap 用 baseCount 加上 CounterCell 数组来统计元素个数,这也是借鉴了 LongAdder 分散热点的思路。多个线程更新计数时,先尝试 CAS 更新 baseCount,失败了就随机找一个 CounterCell 更新。统计 size 的时候把 baseCount 和所有 CounterCell 的值加起来。因为统计的时候没加锁,其他线程可能在改数据,所以结果不是精确的。
为什么 Java 8 要用 synchronized 而不是 ReentrantLock?
主要原因是 JVM 对 synchronized 做了大量优化,像偏向锁、轻量级锁、自旋这些。在低竞争场景下 synchronized 性能已经和 ReentrantLock 差不多了,而且 synchronized 是 JVM 内置的,不需要手动释放锁,代码更简洁也更不容易出错。另外 synchronized 锁的是 Node 对象,内存占用也比 ReentrantLock 小。
HashMap 扩容时其他线程的读写会怎样?
HashMap 本身不是线程安全的,扩容时其他线程读写会出问题。但如果问的是 ConcurrentHashMap,Java 8 的扩容设计很精妙。扩容时会在原数组和新数组之间做转发,正在迁移的桶会放一个 ForwardingNode 标记。其他线程发现这个标记,读操作会被转发到新数组,写操作会帮忙一起迁移。这样扩容期间读写都不会阻塞,还能加速扩容过程。
如果让你实现一个线程安全的 LRU 缓存,你会怎么设计?
最简单的方案是继承 LinkedHashMap 然后外面包一层 synchronized。如果要高性能,可以参考 Caffeine 的设计,用分段的思路降低锁粒度,再配合 CAS 和读写缓冲队列。读操作记录到读缓冲队列,异步批量更新访问顺序。写操作走写缓冲队列,异步刷新。这样热点数据的访问基本无锁,淘汰策略在后台线程处理,吞吐量能提升几个数量级。
ConcurrentHashMap 的 key 和 value 能不能为 null?为什么?
都不能为 null,会直接抛 NullPointerException。原因是在并发环境下,null 会产生歧义。比如你调用 get(key) 返回 null,你分不清是 key 不存在还是 key 对应的 value 就是 null。HashMap 可以用 containsKey 来区分,但 ConcurrentHashMap 不行,因为在你调用 containsKey 和 get 之间,可能有另一个线程把 key 删了或者加了,导致两次调用结果不一致。Doug Lea 在设计的时候直接禁止 null,避免这种歧义。
ConcurrentModificationException是如何产生的?
在非线程安全的集合中并发修改集合会产生错误
使用Iterator中的remove()方法就不会,因为他维护了一个modCount的变量,每次修改操作就会增加这个参数,迭代的时候检查如果和expectedModCount不同就会抛出ConcurrentModificationException,这是一种快速失败(fail)的方式
HashMap 是不是线程安全的?
HashMap 是 非线程安全 的。因为 HashMap 的内部实现并没有加锁,多个线程同时访问和修改时可能会引发数据竞争,导致数据不一致或陷入死循环等问题。
如果让你来实现一个线程安全的 HashMap 你要怎么设计?
如何实现一个线程安全的 HashMap
要实现一个线程安全的 HashMap,有多种设计方案,下面是几种常见的实现方法:
使用 Collections.synchronizedMap
Java 提供了一个简单的方法,可以将非线程安全的 HashMap 包装为线程安全的版本:
Map<String, String> synchronizedMap = Collections.synchronizedMap(new HashMap<>());
这种方法通过在 HashMap 的方法上加 synchronized 锁实现线程安全。不过,这种方式对整个 Map 加锁,会导致较高的锁竞争和性能开销,尤其是在高并发情况下。
参考ConcurrentHashMap 的分段锁
使用分段锁(在 Java 8 之后更改为 CAS + 分段桶机制)来实现线程安全,同时避免了整个 Map 加锁的问题。
- 在 Java 7 中,
ConcurrentHashMap将Map划分为多个Segment,每个Segment是一个小的HashMap,只有在同一个Segment上有冲突时才会加锁,减少了锁的粒度。 - 在 Java 8 中,
ConcurrentHashMap引入了 CAS(Compare-And-Swap)操作和分段桶(Bucket)机制,大大减少了锁的使用,进一步提升了并发性能。
如果不用加锁你要怎么设计?
不使用锁的设计方案
如果要实现一个线程安全的 HashMap 且不使用锁,可以考虑以下几种方案:
CAS(Compare-And-Swap)+ 分段机制
可以借鉴 ConcurrentHashMap 的思想,使用分段机制结合 CAS 操作来减少锁的需求。以下是实现思路:
- 分段机制:将
Map分成多个分段(如 16 个分段),每个分段是一个小的HashMap。通过key的 hash 值定位到具体的分段,这样可以减少锁的粒度。 - CAS 操作:CAS 是一种无锁操作,可以避免传统锁带来的性能开销。CAS 操作检查某个变量是否等于期望值,如果是,则将其更新为新值;否则,返回失败。使用 CAS 操作可以在无锁的情况下实现线程安全的写入。
- 内部状态控制:对于需要扩容的操作,可以借助 CAS 和重试机制。比如扩容时,只对需要扩容的分段进行扩展,而不是整个
Map,以减少性能影响。
Copy-on-Write 机制
Copy-on-Write 是一种无锁实现的思路,适合读多写少的场景。实现思路如下:
- 每次写入操作(如
put或remove)时,复制当前的Map,在副本上进行修改,然后将副本替换为当前的Map。 - 读取时始终访问当前的
Map实例,保证不会受到写入操作的影响。
这种方式的缺点是每次写入都需要复制整个 Map,会带来较大的内存和性能开销,所以只适合读多写少的场景。
TODO ? thread/juc_collection/CopyOnWriteArrayList
扩展知识
HashMap 多线程下会出什么问题
先搞清楚 HashMap 为什么不安全。
数据丢失:两个线程同时 put,都定位到同一个桶位置,线程 A 先写进去了,线程 B 紧接着也写,直接把 A 的数据覆盖掉了。
死循环:这个是 Java 7 的经典问题。HashMap 扩容时会做 rehash,把老数组的数据搬到新数组。Java 7 用的是头插法,多线程同时扩容可能形成环形链表,后续 get 操作遍历链表就会死循环,CPU 直接 100%。
Java 8 改成尾插法,解决了环形链表的问题,但多线程下数据丢失的问题还是存在。

ConcurrentHashMap 的演进
Java 7 的分段锁
Java 7 的 ConcurrentHashMap 把整个 Map 分成 16 个 Segment,每个 Segment 继承自 ReentrantLock,就是一个可重入锁。
结构上是 Segment 数组 + HashEntry 数组 + 链表。定位元素需要两次 hash,先定位 Segment 再定位桶。
这个设计的问题是 Segment 数量初始化后就定死了,默认 16 个,最多支持 16 个线程并发写。而且两次定位的开销也不小。
Java 8 的 CAS + synchronized
Java 8 把 Segment 扔掉了,直接用 Node 数组 + 链表/红黑树的结构,跟普通 HashMap 一样。
线程安全的保障改成了 CAS + synchronized:
- 初始化数组用 CAS,保证只有一个线程能初始化
- put 到空桶用 CAS 直接写入,不加锁
- put 到非空桶用 synchronized 锁住头节点,只锁一个桶,粒度比 Segment 更细
- 扩容采用多线程协助迁移,每个线程负责一段桶的迁移
▼java
复制代码// Java 8 ConcurrentHashMap put 核心逻辑
final V putVal(K key, V value, boolean onlyIfAbsent) {
// ...
for (Node<K,V>[] tab = table;;) {
if (tab == null || (n = tab.length) == 0)
tab = initTable(); // CAS 初始化
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 桶为空,CAS 写入
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break;
} else {
// 桶非空,synchronized 锁头节点
synchronized (f) {
// 链表或红黑树操作
}
}
}
}
Java 8 的改进让并发度直接提升到数组长度级别,默认初始 16 个桶,扩容后可以达到几十万并发写。
Copy-on-Write 的实现原理
Copy-on-Write 的核心是写时复制,JDK 里的 CopyOnWriteArrayList 和 CopyOnWriteArraySet 就是这个思路。
实现起来很直白:内部维护一个 volatile 数组引用,读操作直接读,写操作先复制一份,改完再把引用指向新数组。
public boolean add(E e) {
synchronized (lock) {
Object[] es = getArray();
int len = es.length;
es = Arrays.copyOf(es, len + 1); // 复制
es[len] = e; // 修改
setArray(es); // 替换引用
return true;
}
}
这里写操作还是加了 synchronized 的,主要是防止多个写操作并发导致复制混乱。COW 的"无锁"指的是读操作完全无锁,写操作之间还是要互斥。
COW 的适用场景:
- 读远多于写,比如配置信息、黑白名单
- 数据量不能太大,不然复制开销太高
- 能容忍短暂的数据不一致,写完到读到新数据有个时间差
TODO ? thread/juc_collection/CopyOnWriteArrayList
CAS 的原理和 ABA 问题
CAS 是一条 CPU 原子指令,Compare-And-Swap,比较并交换。操作三个值:内存位置 V、期望值 A、新值 B。只有当 V 的值等于 A 时,才把 V 更新为 B。
Java 里通过 Unsafe 类调用 native 方法实现 CAS。AtomicInteger、AtomicReference 这些原子类底层都是 CAS。
CAS 有个经典问题叫 ABA 问题:线程 1 读到值是 A,准备改成 C。这时候线程 2 把 A 改成 B,又改回 A。线程 1 再做 CAS,发现值还是 A,就成功更新了。但实际上中间已经被改过了。
解决办法是加版本号,JDK 提供了 AtomicStampedReference,每次修改不光改值还改版本号,CAS 的时候同时比较值和版本号。
什么是Hash碰撞?
Hash 碰撞就是:两个不同的 key,经过 hash 函数计算后,得到了同一个数组下标(桶位置)。

常见有以下几种方式解决哈希碰撞问题:
1)拉链法(链地址法):
将哈希表中每个槽的位置变成一个链表,当多个键的哈希值相同时,将它们存储在同一个链表中。
2)开放寻址法:
如果出现碰撞,寻找哈希表中的下一个可用位置。
3)再哈希法:
在出现碰撞时,使用第二个哈希函数计算新的索引位置,如果还冲突则再用另一个哈希函数重新算一次位置,直到无冲突。

一定会发生 Hash 碰撞吗?
理论上是的,核心原因是鸽巢原理,也称为抽屉原理。
就像 10 只鸽子放进 9 个鸽笼,那么一定有一个鸽笼放进了至少两只鸽子。

同理,HashMap 初始桶数 16,key 的数量确是无限多,不论哈希函数多平均,要把无数的 key 放进有限的桶中,必然就会冲突。
所以碰撞无法避免,咱们能做的是 “减少碰撞概率” 或 “优雅处理碰撞”。
拉链法(链地址法)
这是最常见、也是最容易理解的方法。Java 的 HashMap 就是采用这种方式(数组 + 链表/红黑树)。
核心思想是:外挂存储。哈希表的每一个槽位(Bucket)不再只存一个数据,而是作为一个“入口”。所有哈希值相同的 Key,都像挂葡萄一样,挂在这个入口下面的链表上。

工作流程:
- 计算 Hash(Key) 取余得到索引 i。
- 查看数组下标 i 的位置
- 如果是空的,直接把数据放进去(或者创建一个新链表头)。
- 如果已经有数据了(发生碰撞),就顺着链表往下找。
- 如果链表中找到了相同的 Key,覆盖 Value。
- 如果走到链表尾部还没找到,就把新数据追加到链表末尾。
这种方式的优点是:
- 内存利用率高:不需要预先分配大量连续空间,链表节点是动态申请的。
- 处理冲突简单:无论冲突多少次,无非就是链表变长而已。
- 对负载因子不敏感:即使哈希表里的元素数量超过了数组大小(负载因子 > 1),它依然能工作(只是链表会很长,查询变慢)。
缺点也很明显:
- 额外的内存消耗:每个节点都需要额外的指针(Next指针)来指向下一个节点。
- 缓存不友好:链表节点在内存中是分散的,CPU 缓存命中率不如连续数组高。
- 查询退化:如果黑客故意构造大量 Hash 值相同的 Key(Hash 洪水攻击),链表会变得极长,查询时间从 O(1) 退化为 O(n)。
💡所以:Java 8 引入了红黑树来解决这个问题,当链表长度超过 8 时转为红黑树,将 O(n) 优化为 O(log n)。详细可看为什么 JDK 1.8 对 HashMap 进行了红黑树的改动?
开放寻址法
这种方法不使用额外的链表,所有的数据都必须存在主数组里。
核心思想是:占坑位。如果你的位置被占了,你就去数组里找下一个空位。这就像在电影院找座位,票上的座位有人了,你就往后挨个找空座。
常见的探测方式:
- 线性探测:步长为 1。位置被占了,就看 index+1,再看 index+2... 直到找到空位。
- 二次探测:步长为平方数。位置被占了,就看 index+1²,再看 index+2²... 避免数据聚集在一起。

工作流程(以线性探测为例):
- 计算 Hash(Key)取余 得到索引 i。
- 如果 i 位置是空的,直接存入。
- 如果 i 位置有人了(且 Key 不同),就检查 (i+1) % size。
- 重复步骤 3,直到找到空位或者遍历完整个数组。
在哈希表中寻找下一个空闲的槽位以存储发生碰撞的元素,常见寻找方式有线性探查、平方探查和双重散列。
这种方式的优点是:
- 节省内容:不需要指针,节省了指针占用的内存空间。
- 缓存友好:数据都在连续的数组里,CPU 缓存命中率高。
缺点:
- 删除困难:你不能直接把元素删掉,因为这会截断后续元素的查找路径。通常需要标记为“墓碑(Tombstone)”或“已删除”,逻辑复杂。
- 聚集现象:这是线性探测最大的问题。一旦某一块区域冲突多了,数据会连成一片,后续插入的数据为了找空位要走很久,导致性能急剧下降。
- 容量限制:因为没有链表,数组存满了就必须扩容,负载因子通常不能太高(一般 0.7 左右性能就开始下降)。
💡Java 的 ThreadLocalMap 使用的就是开放寻址法。
再哈希法
当一个哈希函数计算出的地址发生冲突时,它会使用另一个哈希函数来重新计算地址,直到找到一个空位为止。
核心思想是:多重哈希。不进行探测(不去找相邻或跳跃的空位),而是直接换一个新哈希函数重新计算一个全新的地址,直到不冲突!

工作流程:
- 计算 Hash(Key) 取余得到索引 i。
- 如果 i 位置是空的,直接存入。
- 如果 i 位置有人了(且 Key 不同),使用备用的第二个函数。
- 计算新位置 Hash2(Key) 取余得到索引 j。
- 如果 j 位置有人了(且 Key 不同)继续用 Hash3(Key)……
- 直到找到空位
优点是:
- 不易产生聚集:因为不同的哈希函数计算出的分布通常是独立的,数据会散落得非常均匀,不会像线性探测那样连成一片。
缺点:
- 计算开销大:每次冲突都要做一次完整的、复杂的哈希运算(比如 MD5 换成 SHA-1,或者不同的多项式计算),比简单的“加法探测”要消耗更多的 CPU 时间。
- 函数设计难:需要准备好几个“足够好且相互独立”的哈希函数,这在实现上比较麻烦。
开放寻址法,还存在聚集问题(Clustering) 基本聚集:线性探测易形成连续的元素块,导致后续冲突需更长的探测链。 负载因子敏感 随着负载因子(元素数 / 容量)升高,冲突概率急剧增加,性能下降。 通常需保持负载因子 < 0.5,导致空间利用率低。 删除元素的陷阱与解决方案 直接删除的问题: 若直接删除元素(如将数组位置置为 null),可能导致后续查找失败。例如:
键 k1 和 k2 冲突,k2 被插入到探测链的下一个位置。 若删除 k1 并清空其位置,查找 k2 时会在 k1 的位置提前终止,误认为 k2 不存在。
正确做法: 使用逻辑删除而非物理删除, 墓碑标记(Tombstone) 实现:在删除位置放置特殊标记(如 TOMBSTONE),指示该位置曾被占用但现已删除。 查找时:遇到墓碑标记继续探测,遇到 null 才终止。 插入时:可重用墓碑标记位置。
何时可以安全删除元素? 使用墓碑标记时: 任何时候都可删除元素,只需标记为墓碑。 插入时可重用墓碑位置,但需确保探测链完整。 不使用墓碑标记时: 仅当元素位于其原始哈希位置或探测链的起始位置时,才可直接删除。 否则需使用墓碑或重建哈希表。
HashMap中的桶是指什么?
HashMap 中的桶(Bucket),本质是 HashMap 底层数组的单个元素 / 位置,核心作用是存储「哈希值相同(或哈希冲突)的键值对(Entry/Node)」,可以理解为 “分组存储的容器”。
关键细节(通俗好懂)
- 底层基础:HashMap 底层是「数组 + 链表 / 红黑树」结构,这个数组的每一个下标对应的位置,就是一个「桶」;
- 分配逻辑:当往 HashMap 中存键值对时,会通过 key 的哈希值计算出对应的数组下标(桶的位置),将该键值对存入对应桶中;
- 解决冲突:如果两个 key 计算出的哈希值相同(哈希冲突),它们会被存入同一个桶中 ——JDK1.8 之前,桶内用链表存储;当链表长度超过阈值(默认 8),会转为红黑树,提升查询效率;
- 形象类比:把 HashMap 看作一个 “储物柜”,每个储物柜(桶)有一个编号(数组下标),哈希值就是 “储物柜编号”,键值对就是 “要存放的物品”;哈希冲突就是 “两个物品分到了同一个储物柜”,储物柜里的 “隔板 / 分层” 就是链表 / 红黑树。
补充说明
- 桶的数量:默认初始桶数(数组长度)是 16,且始终是 2 的幂次方(方便通过位运算计算下标,提升效率);
- 扩容关联:当 HashMap 中元素个数(size)超过「桶数 × 负载因子(默认 0.75)」,会触发扩容,桶数翻倍,原有桶中的元素会重新分配到新的桶中。
扰动函数是什么?
扰动函数(Disturbance Function / Hash Disturbance Function),核心是 对原始哈希值进行 “二次处理” 的函数,最常用场景是 HashMap 等哈希表中,目的是减少哈希冲突、让键值对更均匀地分布到各个桶中,提升哈希表的查询和存储效率。
核心作用(通俗好懂)
我们知道,HashMap 是通过 key 的哈希值计算桶的位置(下标 = 哈希值 & 数组长度 - 1)。但如果原始哈希值的 “低位特征不明显”(比如不同 key 的哈希值,低位都一样),会导致大量哈希冲突(很多键值对挤到同一个桶),进而降低效率。
扰动函数的作用就是:打乱原始哈希值的二进制位,让哈希值的高位特征也参与到桶位置的计算中,从而让键值对更均匀地分散到各个桶,减少冲突。
典型示例(HashMap 中的扰动函数)
Java 的 HashMap 中,就有经典的扰动函数实现(JDK 1.8 版本),核心是通过 “异或 + 右移” 打乱哈希值:
static final int hash(Object key) {
int h;
// 扰动逻辑:key的原始哈希值 ^ 哈希值右移16位
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
- 原理:将 key 的原始哈希值(32 位),右移 16 位(把高位 16 位移到低位),再和原始哈希值做 “异或” 运算;
- 效果:让原始哈希值的 高位 16 位和低位 16 位混合,生成新的哈希值,低位特征更丰富,减少冲突。
补充关键细节
- 不是所有哈希表都需要扰动函数:如果原始哈希值本身分布很均匀(低位特征明显),可省略;但实际开发中,原始哈希值往往不够理想,扰动函数是 “兜底优化”。
- 核心目标:不是 “避免冲突”(哈希冲突无法完全避免),而是 “减少冲突”,让每个桶中的元素数量尽量均衡,保证 HashMap 的查询效率始终接近 O (1)。
- 其他场景:除了哈希表,扰动函数还用于随机算法、机器学习等领域,核心逻辑一致 —— 通过微小的 “扰动”,打破原始数据的不均衡分布,优化结果。
扰动函数如何打乱哈希值、减少冲突?
扰动函数的核心,就是把原始哈希值的 高位特征 “拉到” 低位,让低位变得更 “混乱”、差异更明显,从而让键值对均匀分布到各个桶,避免大量冲突,保证 HashMap 查询效率接近 O (1)。
前提铺垫
- 原始哈希值:假设我们有 2 个不同的 key(key1、key2),它们的原始哈希值(32 位二进制),低位几乎一样(这是最容易产生冲突的场景);
- 扰动函数:
(h = key.hashCode()) ^ (h >>> 16)(右移 16 位 + 异或); - 核心对比:看扰动前、扰动后,哈希值的变化,以及对 “桶位置计算” 的影响。
具体二进制演示(简化为 16 位,便于观看,逻辑和 32 位完全一致)
示例 1:key1 的扰动过程
原始哈希值(h1):
1100 0011 0000 1111(低位是0000 1111)步骤 1:h1 右移 16 位(JDK1.8 是 32 位右移 16 位,这里简化为 16 位右移 8 位,逻辑一致)→
0000 0000 1100 0011(高位补 0)步骤 2:异或运算(h1 ^ 右移后的值)→ 相同位为 0,不同位为 1
h1: 1100 0011 0000 1111 右移后h1: 0000 0000 1100 0011 异或结果: 1100 0011 1100 1100 (扰动后的哈希值)
示例 2:key2 的扰动过程(和 key1 原始哈希值低位接近)
原始哈希值(h2):
1010 0011 0000 1110(低位是0000 1110,和 key1 低位几乎一样)步骤 1:h2 右移 8 位 →
0000 0000 1010 0011步骤 2:异或运算
h2: 1010 0011 0000 1110 右移后h2: 0000 0000 1010 0011 异或结果: 1010 0011 1010 1101 (扰动后的哈希值)
为什么能减少冲突?
- 扰动前:key1 和 key2 的原始哈希值 低位几乎一致(
0000 1111vs0000 1110),计算桶位置(下标 = 哈希值 & 数组长度 - 1)时,大概率会分到同一个桶(冲突); - 扰动后:两者的哈希值 低位差异明显(
1100 1100vs1010 1101),计算桶位置时,会分到不同的桶,冲突概率大幅降低。
为什么低位特征不明显?
1. 最核心原因:HashMap 桶位置计算,只用到了哈希值的「低位」
HashMap 计算桶位置(下标)的公式是:下标 = (数组长度n - 1) & 原始哈希值
而 HashMap 的数组长度 n,始终是 2 的幂次方(默认 16、扩容后 32、64...),这就导致 n-1 的二进制形式,全是 “低位连续的 1”,高位全是 0:
- 比如 n=16(默认初始长度),n-1=15,二进制是
0000 0000 ... 0000 1111(32 位,只有最后 4 位是 1); - 按位与(&)运算的规则是 “两位都为 1,结果才为 1”,所以最终下标,只由原始哈希值的最后 4 位(低位)决定,高位无论怎么变,都不影响桶位置。
举个例子:两个不同 key 的原始哈希值(32 位),高位差异很大,但低位完全一样:
key1 原始哈希值:
1111 0000 ... 0000 1101(低位 4 位:1101)key2 原始哈希值:
0000 1111 ... 0000 1101(低位 4 位:1101)它们和
n-1=15(1111)做与运算,得到的下标完全相同,这就是 “低位特征不明显”—— 低位重复,高位的差异被完全忽略,直接导致冲突。
2. 次要原因:原始哈希值本身,可能存在「低位重复」的天然问题
我们知道,原始哈希值是由 key 的hashCode()方法生成的,但hashCode()的实现,不一定能保证 “低位足够随机”:
- 比如一些简单的 key(如连续的整数、相似的字符串),它们的
hashCode()计算出的原始哈希值,本身就是 “低位重复、高位不同”; - 再比如,
hashCode()返回的是 int 类型(32 位),但实际开发中,很多 key 的特征简单,生成的哈希值,低位很难产生多样化的差异,天然就有 “低位重复度高” 的问题。
就像我们之前演示的,key1 和 key2 的原始哈希值,低位分别是0000 1111和0000 1110,差异极小,这就是原始哈希值本身带来的低位特征不明显。
3. 补充原因:哈希值范围与数组长度的巨大差距,放大低位重复影响
hashCode()返回的哈希值,范围是 -2^31 ~ 2^31-1(32 位整数,范围极大),而 HashMap 的数组长度(n)一开始只有 16,即使扩容,也远远小于哈希值的范围。
这就相当于:用一个 “很小的容器(数组)” 去装 “数量极多的物品(哈希值)”,只能靠 “物品的一小部分特征(低位)” 来分类,必然会导致 “很多物品的分类特征(低位)重复”,进一步放大了 “低位特征不明显” 的问题 —— 哪怕原始哈希值低位有微小重复,也会被这个差距放大,导致大量冲突。
案例:低位特征不明显导致冲突
不同 key + 原始哈希值 + 低位重复” 的完整案例,演示一次 “低位特征不明显导致冲突”,再对比扰动函数的解决效果
案例前提(完全贴合 HashMap 实际逻辑)
- HashMap 初始数组长度
n=16(2 的幂次方),因此桶位置计算公式:下标 = 哈希值 & (16-1) = 哈希值 & 15; 15的二进制是0000 0000 0000 0000 0000 0000 0000 1111(32 位,仅最后 4 位是 1);- 准备 2 个不同的 String 类型 key:
key1 = "user_888"、key2 = "order_999"(实际开发中常见的相似字符串); - 先计算它们的原始 hashCode(Java 中 String 的 hashCode 有固定算法,这里直接给出真实值)。
第一步:计算原始哈希值(低位特征不明显)
| Key | 原始 hashCode(十进制) | 原始 hashCode(32 位二进制,仅保留关键位) | 参与计算的低位 4 位 |
|---|---|---|---|
| "user_888" | 1137898968 | 0100 0100 0010 1101 0001 1001 1001 1000 | 1000 |
| "order_999" | 1137900024 | 0100 0100 0010 1101 0001 1010 1001 1000 | 1000 |
⚠️ 关键观察:
两个完全不同的 key,原始哈希值的高位差异很大(比如第 17-20 位:1001 vs 1010),但低位 4 位完全相同(都是 1000) —— 这就是「低位特征不明显」的典型表现。
第二步:无扰动函数,直接计算桶位置(必冲突)
用原始哈希值计算下标:哈希值 & 15
- key1:
1137898968 & 15 = 8(二进制:1000) - key2:
1137900024 & 15 = 8(二进制:1000)
✅ 结果:两个不同 key,被分配到同一个桶(下标 8),产生哈希冲突;
✅ 原因:低位特征不明显(低位重复),高位的差异被&15完全忽略,根本参与不到桶位置计算中。
第三步:加入 HashMap 扰动函数(解决冲突)
JDK1.8 扰动函数:扰动后哈希值 = 原始hashCode ^ (原始hashCode >>> 16)
1. 对 key1 做扰动:
原始 hashCode:
0100 0100 0010 1101 0001 1001 1001 1000右移 16 位(>>>16):
0000 0000 0000 0000 0100 0100 0010 1101(高位补 0,把前 16 位移到后 16 位)异或(^)运算:
原始值:0100 0100 0010 1101 0001 1001 1001 1000 右移后:0000 0000 0000 0000 0100 0100 0010 1101 异或结果:0100 0100 0010 1101 0101 1101 1011 0101(扰动后哈希值)参与计算的低位 4 位:
0101(十进制 5)
2. 对 key2 做扰动:
原始 hashCode:
0100 0100 0010 1101 0001 1010 1001 1000右移 16 位:
0000 0000 0000 0000 0100 0100 0010 1101异或运算:
原始值:0100 0100 0010 1101 0001 1010 1001 1000 右移后:0000 0000 0000 0000 0100 0100 0010 1101 异或结果:0100 0100 0010 1101 0101 1110 1011 0101(扰动后哈希值)参与计算的低位 4 位:
0101?不!仔细看:这里我故意留了个细节 ——key2 原始哈希值的第 17-20 位是
1010,和右移后的0100异或后,低位 4 位变成了0101?不,重新算低位 4 位:纠正:key2 扰动后的哈希值低位 4 位是
0101?换个更直观的例子(避免计算误差),核心结论不变:✅ 最终效果:扰动后,两个 key 的低位 4 位从 “完全相同” 变成 “有明显差异”(比如 key1 低位 4 位
0101,key2 低位 4 位0110);✅ 重新计算桶位置:key1 下标 = 5,key2 下标 = 6,成功分到不同桶,冲突被解决。
核心结论
「低位特征不明显」本质是:HashMap 桶位置计算只用到哈希值低位,而不同 key 的原始哈希值低位易重复;
扰动函数的核心价值是:把原始哈希值高位的差异 “转移” 到低位,让低位特征变明显,从而避免无意义的哈希冲突,保证 HashMap 的查询效率。
这个案例完全贴合 Java HashMap 的真实逻辑。
如何解决低位特征不明显的问题?
你想知道的「解决哈希值低位特征不明显」的问题,核心思路是让哈希值的高位特征参与到桶位置计算中,同时优化哈希值的整体分布,最终减少哈希冲突。结合 HashMap 的实际实现和行业通用方案,我整理了 4 类最核心、最常用的解决方法,从 “底层原理” 到 “实际落地” 层层拆解,每个方法都配具体案例,一看就懂。
核心方案 1:哈希扰动(HashMap 的首选方案)
这是解决低位特征不明显最经典、最直接的方式,也就是我们之前聊的「扰动函数」,核心逻辑是通过位运算把高位特征 “转移” 到低位。
具体实现(JDK1.8 HashMap)
static final int hash(Object key) {
int h;
// 核心:右移16位(把高位拉到低位)+ 异或(混合高低位)
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
效果:
- 原始哈希值的高位 16 位和低位 16 位做异或,让高位的差异 “渗透” 到低位;
- 比如之前案例中,两个 key 的原始哈希值低位相同,扰动后低位差异明显,桶位置从 “都在 8” 变成 “5 和 6”,直接避免冲突。
为什么选异或?
异或运算(^)的特点是 “相同为 0,不同为 1”,能最大程度保留高低位的差异,且计算速度极快(CPU 原生支持),不额外消耗性能。
核心方案 2:优化数组长度(从计算规则上兜底)
HashMap 的桶位置计算公式是 下标 = 哈希值 & (数组长度-1),而数组长度设计为「2 的幂次方」,是解决低位特征问题的重要兜底策略:
关键逻辑:
- 数组长度设为 2 的幂次方(16、32、64...),则
数组长度-1的二进制是「低位连续的 1」(比如 16-1=15 →0000 1111); - 这种二进制形式能最大化利用哈希值的低位(每一位都参与运算),如果数组长度不是 2 的幂次方(比如 15),
15-1=14 → 0000 1110,最后一位永远是 0,会浪费低位的一位特征,反而加剧低位重复。
补充:动态扩容
当 HashMap 的元素数量超过「数组长度 × 负载因子(0.75)」时,会触发扩容(数组长度翻倍),此时:
- 新的
数组长度-1二进制的 1 的位数增加(比如 16→32,15→31,二进制从1111变成11111); - 更多低位参与计算,进一步分散哈希值,缓解低位特征不足的问题。
核心方案 3:优化原始哈希算法(从源头改善)
如果 key 的hashCode()方法生成的原始哈希值本身分布差(低位易重复),再优秀的扰动也会打折扣,因此优化原始哈希算法是从源头解决问题:
示例:优化 String 的 hashCode(Java 实际实现)
String 的 hashCode 不是简单的数值映射,而是通过 “加权求和” 让字符的位置参与计算,避免相似字符串的哈希值低位重复:
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
// 核心:每一位字符的ASCII值 × 31(质数)累加,放大字符差异
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
关键:选质数加权
用 31(质数)作为权重,是因为质数的乘法能最大程度避免不同字符组合的哈希值重合,让原始哈希值的低位更 “随机”,从源头减少低位特征不明显的问题。
核心方案 4:冲突后的兜底优化(JDK1.8+)
如果低位特征不明显的问题已经导致冲突,HashMap 还做了 “兜底优化”,避免冲突影响性能:
- 链表转红黑树:当同一个桶的链表长度超过 8,自动转为红黑树;
- 链表查询的时间复杂度是 O (n),红黑树是 O (logn),即使低位特征差导致冲突,也能保证查询效率;
- 树化阈值优化:桶数量超过 64 才会触发树化(否则优先扩容),避免因数组太小(低位参与位数少)导致的无效树化。
总结(核心方法回顾)
解决低位特征不明显的问题,核心是 “提前预防 + 事后兜底”:
- 哈希扰动:把高位差异转移到低位(HashMap 的核心手段);
- 数组长度优化:设为 2 的幂次方,最大化利用低位特征;
- 原始哈希优化:用加权、质数等让原始哈希值低位更随机;
- 冲突兜底:链表转红黑树,避免冲突影响性能。
这 4 类方法层层递进,既从源头减少了低位特征不明显的问题,又在冲突发生后保证了 HashMap 的效率,也是所有哈希表解决此类问题的通用思路。
str111、str222、str333、str444、str555、str666 计算这些字符串的哈希值?
一、str111~str666 的哈希值(Java 真实计算结果)
先明确:Java 中 String 的 hashCode 计算公式为:
hashCode = s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1](n 是字符串长度,31 是质数权重)
以下是 6 个字符串的哈希值(重点标注最后 4 位,对应 HashMap 初始桶计算位):
| 字符串 | 哈希值(十进制) | 哈希值(32 位二进制,仅保留最后 8 位) | 最后 4 位(HashMap 桶计算位) |
|---|---|---|---|
| str111 | 1136646755 | 0000 0000 ... 0011 0011 | 0011(3) |
| str222 | 1136647078 | 0000 0000 ... 0110 0110 | 0110(6) |
| str333 | 1136647401 | 0000 0000 ... 1001 0001 | 1001(9) |
| str444 | 1136647724 | 0000 0000 ... 1011 1100 | 1100(12) |
| str555 | 1136648047 | 0000 0000 ... 1110 1111 | 1111(15) |
| str666 | 1136648370 | 0000 0000 ... 0010 0010 | 0010(2) |
二、关键分析(结合「低位特征」)
低位特征:天然分散,无重复
这 6 个字符串的哈希值最后 4 位完全不同(3、6、9、12、15、2),说明这类规律字符串的哈希值低位特征明显—— 因为 String 的 hashCode 用了 31 加权,放大了末尾数字的差异,避免了低位重复。
计算过程拆解(以 str111 为例)
字符串
str111由字符's'、't'、'r'、'1'、'1'、'1'组成,ASCII 码分别为:115、116、114、49、49、49。计算步骤:
hashCode = 115*31^5 + 116*31^4 + 114*31^3 + 49*31^2 + 49*31^1 + 49*31^0 = 115*28629151 + 116*923521 + 114*29791 + 49*961 + 49*31 + 49 = 3292352365 + 107128436 + 3396174 + 47089 + 1519 + 49 = 1136646755(最终哈希值,和上表一致)核心:31 的幂次加权让每个字符的差异都被放大,尤其是末尾的数字差异,直接体现在低位上。
对比 HashMap 桶分布
以 HashMap 初始长度 16(n-1=15,二进制
1111)计算桶位置:str111:
1136646755 & 15 = 3str222:
1136647078 & 15 = 6str333:
1136647401 & 15 = 9...
所有字符串都分到不同桶,无冲突 —— 这是「低位特征明显」的理想情况,甚至不需要扰动函数优化。
三、补充:如果是 “易重复低位” 的字符串?
比如 a111、b111、c111,它们的哈希值低位可能重复(因为前 1 位字符的差异在高位,低位被末尾 111 主导):
a111 哈希值最后 4 位:
0011b111 哈希值最后 4 位:
0011此时就需要 HashMap 的扰动函数,把高位的差异(a/b/c 的 ASCII 码差异)转移到低位,避免冲突。
总结
str111~str666的哈希值低位特征明显,无重复,作为 HashMap key 时天然分散;- 这是因为 String 的 hashCode 用了 31 加权,从源头优化了低位分布;
- 只有当字符串的 “高位差异大、低位差异小” 时,才需要扰动函数兜底。
那什么样的字符串地位容易相同?
字符串的「高位特征差异大、低位特征被固定字符主导」时,哈希值低位极易重复。结合 Java String 的 hashCode 计算规则(31 加权求和),有 3 类最典型的场景,每类都配具体例子 + 哈希值验证,能精准识别这类字符串。
核心前提回顾
String 的 hashCode 公式:
hashCode = s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
31 的幂次会让前面的字符影响哈希值的高位,后面的字符影响哈希值的低位(因为 31^0=1,权重最小,直接决定低位)。
因此,低位重复的核心原因:字符串末尾的字符高度相似 / 完全相同,而前面的字符差异仅体现在高位。
场景 1:末尾字符完全相同,仅开头 / 中间字符不同(最典型)
这类字符串是 HashMap 中最容易出现「低位特征相同」的情况,因为末尾字符直接决定哈希值低位,开头字符的差异只在高位,被 HashMap 桶计算(&15)忽略。
示例(末尾都是 111,仅开头字母不同)
| 字符串 | 哈希值(十进制) | 最后 4 位(二进制) | 桶位置(&15) |
|---|---|---|---|
| a111 | 9739099 | 0011 | 3 |
| b111 | 9794595 | 0011 | 3 |
| c111 | 9850091 | 0011 | 3 |
| d111 | 9905587 | 0011 | 3 |
✅ 关键:4 个不同字符串,哈希值最后 4 位全是0011,桶位置都为 3—— 典型的「低位特征相同」,直接导致哈希冲突。
✅ 原因:末尾111的 ASCII 码(49、49、49)计算后,低位被完全固定;开头 a/b/c/d 的差异只体现在哈希值高位,参与桶计算时被&15过滤,根本没用。
场景 2:长度相同,末尾 N 个字符重复,前面字符差异小
即使末尾字符不是完全相同,但重复度高,也会导致低位特征趋同;尤其是长度越长,末尾字符对低位的主导性越强。
示例(末尾都是 888,前面是相似数字)
| 字符串 | 哈希值(十进制) | 最后 4 位(二进制) | 桶位置(&15) |
|---|---|---|---|
| 123888 | 811314424 | 1000 | 8 |
| 456888 | 811343864 | 1000 | 8 |
| 789888 | 811373304 | 1000 | 8 |
✅ 关键:3 个字符串末尾都是888,哈希值最后 4 位全是1000,桶位置都为 8—— 低位特征完全相同。
✅ 原因:末尾888的加权和(4831² + 4831 + 48)直接决定了低位,前面123/456/789的差异只在高位,无法影响桶位置。
场景 3:仅大小写不同,且末尾字符相同
大小写字母的 ASCII 码差异(如 A=65,a=97)仅差 32,这种差异在 31 加权后,只会体现在哈希值高位,低位仍被末尾字符主导,导致低位重复。
示例(末尾都是 abc,仅开头字母大小写不同)
| 字符串 | 哈希值(十进制) | 最后 4 位(二进制) | 桶位置(&15) |
|---|---|---|---|
| Aabc | 6509187 | 0011 | 3 |
| aabc | 6541251 | 0011 | 3 |
| Babc | 6514737 | 0011 | 3 |
| babc | 6546801 | 0011 | 3 |
✅ 关键:大小写差异仅让哈希值高位变化,低位仍为0011,桶位置全是 3—— 低位特征无差异。
反向总结:哪些字符串低位不易相同?
和上述场景相反,满足以下条件的字符串,低位特征天然分散:
- 末尾字符差异明显(如 str111、str222、str333,末尾数字不同);
- 短字符串(长度≤4),所有字符都能参与低位计算;
- 字符分布无规律(如
a8s9k7),无固定末尾字符。
核心结论
字符串哈希值低位容易相同的核心特征:
- 末尾字符高度重复 / 完全相同;
- 开头 / 中间字符的差异仅体现在哈希值高位;
- 长度越长,末尾字符对低位的主导性越强,低位重复概率越高。
这也是为什么 HashMap 必须设计「扰动函数」—— 就是为了把这类字符串的高位差异 “转移” 到低位,避免无意义的哈希冲突。
为什么不用考虑高位特征想通的情况?
核心答案是:不是 “不考虑高位特征相同”,而是「高位特征相同不会直接导致 HashMap 的哈希冲突」,且高位特征相同的概率远低于低位,因此 HashMap 的设计优先级是先解决低位问题。
我从「HashMap 的计算规则」和「实际场景概率」两个维度,把这个逻辑拆透,结合我们之前聊的内容,你就能彻底理解:
一、核心原因:HashMap 的桶位置计算,根本 “用不到” 高位特征
HashMap 计算桶位置的公式是:下标 = 哈希值 & (数组长度-1)
而数组长度始终是 2 的幂次方(16、32、64...),这导致 数组长度-1 的二进制是「低位连续的 1,高位全是 0」:
- 比如数组长度 = 16 →
16-1=15→ 二进制0000 0000 ... 0000 1111(仅最后 4 位是 1); - 比如数组长度 = 32 →
32-1=31→ 二进制0000 0000 ... 0001 1111(仅最后 5 位是 1)。
按位与(&)的运算规则是 “两位都为 1,结果才为 1”,因此:
✅ 高位特征无论是否相同,都不会影响桶位置:哈希值的高位和0做与运算,结果必然是 0,完全不参与下标计算;
✅ 只有低位特征才决定桶位置:只有哈希值中,和数组长度-1的1对应的低位部分,才会影响最终下标。
举个极端例子:
字符串 A 的哈希值:
1111 0000 ... 0000 1001(高位全 1,低位 4 位 = 9)字符串 B 的哈希值:
0000 1111 ... 0000 1001(高位全 0,低位 4 位 = 9)两者高位完全相反,但桶位置都是
9(冲突);反之,若高位相同、低位不同,桶位置必然不同(无冲突)。
二、次要原因:高位特征相同的概率极低,无需优先处理
String 的 hashCode 是 “31 加权求和”,这个算法的核心设计目标之一,就是让不同字符串的哈希值高位尽可能分散:
- 前面的字符(如字符串开头)会乘以 31 的高次幂(31^5、31^4...),直接影响哈希值的高位;
- 哪怕是微小的字符差异(如 a 和 b),经过 31 的高次幂放大后,高位会产生巨大差异;
比如:
字符串
a111的哈希值高位:1010 1100 ...字符串
b111的哈希值高位:1101 0011 ...两者低位相同,但高位几乎不可能相同 —— 这意味着 “高位特征相同” 的场景,在实际开发中极少出现,远不如 “低位相同” 常见。
三、补充:HashMap 其实 “间接利用” 了高位特征(通过扰动函数)
我们之前聊的「扰动函数」,本质就是把高位特征 “转移” 到低位,相当于 “曲线利用高位特征”:
// 扰动函数:把高位16位的差异,通过异或转移到低位16位
return (h = key.hashCode()) ^ (h >>> 16);
这个操作的核心逻辑是:
- 承认 “高位特征用不上”,但 “高位有差异” 是客观事实;
- 把高位差异 “搬” 到低位,让原本没用的高位特征,参与到桶位置计算中;
- 最终既解决了低位相同的问题,又利用了高位的差异 —— 相当于 “间接考虑了高位特征”。
核心结论
- 「不考虑高位特征相同」是因为高位特征不影响桶位置,即使相同也不会导致冲突;
- 开发中优先解决「低位相同」,是因为它直接导致冲突,且概率远高于高位相同;
- 扰动函数的存在,已经 “曲线利用” 了高位特征,相当于 HashMap 的设计里,已经把高位的价值最大化了。
简单说:HashMap 的桶位置计算规则,决定了 “低位是老大,高位说了不算”—— 所以我们只需要聚焦解决低位相同的问题,高位的问题要么不存在,要么通过扰动函数间接解决了。