HashMap讲解(包括产生死循环问题的原因)

本文深入解析HashMap的工作原理,包括其内部结构、扩容机制以及多线程环境下可能产生的问题。特别关注HashMap如何处理冲突,并探讨了不同Java版本中插入方法的变化。

HashMap是由由数组和链表组合构成的数据结构。

数组里面每个地方都存了Key-Value这样的实例,如下所示:

HashMap本身所有的位置都为null,在put插入的时候会根据keyhash值去计算一个index值,index值即表示在HashMap中存放的位置。

如:将为了将键值对("Bull", 1)存入HashMap中,所计算得到的index值为1:

同时,如果再次将一个键值对("Red", 2)存入当前HashMap中,若计算得到的index值仍为1,则回以如下形式存入HashMap中:

 上述情况即为以链表的形式进行存储。

每一个节点都会保存自身的hash、key、value、以及下个节点。源码如下。

同时,需要注意的是,新的Entry节点在插入链表的时候,插入方法在不同的java版本有不同java8之前是头插法,就是说新来的值会取代原有的值,原有的值就顺推到链表中去,就像上面的例子一样。

但是,在java8之后,都是所用尾部插入了(使用头插法在多线程的情况下会产生循环链表的问题)。

产生死循环的原因如下:

首先,看一下HashMap的扩容机制:

HashMap扩容主要设计两个因素:

  • Capacity:HashMap当前长度。
  • LoadFactor:负载因子,默认值0.75f。

 HashMap在容量达到设定的阈值(0.75f)就会进行扩容。比如当前的容量大小为100,当你存进第76个的时候,判断发现需要进行resize了,那就需要进行扩容。

扩容具体步骤如下:

  • 扩容:创建一个新的Entry空数组,长度是原数组的2倍
  • ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。

这里进行ReHash的原因是,当长度扩大以后,Hash的规则也随之改变,即ReHash得到的index值因为数组长度的不同变得不同。

单线程扩容前:

  

 单线程扩容后(index不同):

单线程扩容后(极端情况下所得到的index值相同):

 在多线程情况下进行扩容:

现在假设线程T1与T2均指向第一个(“Bull“, 1)键值对,T1.next与T2.next均指向第二个("Red",2)键值对

现在两个线程两个线程均开始扩容,且此时线程T2的时间片恰好用完,则线程T1进行扩容,结果为:

 

 此时,线程T2所指向的键值对均没有改变(对线程T1的ReHash操作不知情),则此时对于线程T2就达成了一个死循环:

 如果线程T2此时去取值,则出现无限循环。

<think>嗯,我现在要了解一下HashMap的原理。首先,HashMap是Java中常用的数据结构,对吧?我记得它属于Map接口,用来存储键值对。那它的内部结构是怎样的呢?可能和数组、链表有关? 对了,之前学过数组和链表,数组查询快,插入删除慢;链表相反。那HashMap是不是结合了这两者的优点?比如用数组和链表结合的方式,这样既能快速查找,又能处理冲突? 然后,哈希函数的作用是什么呢?好像是把键(Key)转换成一个数组的索引。比如,当我要存一个键值对的时候,先计算键的哈希值,然后根据这个哈希值找到数组的位置,把值存进去。不过不同的键可能会有相同的哈希值,这时候就会发生哈希冲突。这时候应该怎么处理呢?可能用链表,也就是在同一个数组位置存放一个链表,把冲突的键值对都放在这里。 不过如果链表太长的话,查询效率会下降,变成O(n)。那Java是不是有办法优化这种情况?比如当链表长度超过某个阈值时,转换成红黑树?这样查询效率可以提高到O(log n)。可能是在Java 8之后引入的这个优化,称为“树化”? 另外,HashMap的容量和负载因子也很重要。容量是指数组的大小,负载因子是决定何时扩容的因子。默认负载因子是0.75,当元素数量超过容量乘以负载因子时,就会扩容。扩容的时候,数组大小会翻倍,然后重新计算所有键的位置,这个过程叫做rehash。这样做的目的是减少哈希冲突,保持效率。 那具体的put和get过程是怎样的呢?比如put时,先计算键的哈希值,找到对应的数组下标。如果该位置为空,就直接放入;如果有元素,就遍历链表或红黑树,比较键是否相等(equals方法),相等的话替换值,否则添加到链表末尾或树中。如果链表长度超过8,并且数组长度超过64,就转换为红黑树;否则只是扩容数组。 get的时候也是类似,计算哈希找到数组下标,然后遍历链表或树,用equals方法比较键,找到对应的值返回。 不过,哈希函数的好坏直接影响HashMap的性能。如果哈希函数分布不均匀,会导致很多冲突,降低效率。所以好的哈希算法很重要。Java中的String类的hashCode方法可能经过优化,减少碰撞。 还有,HashMap是非线程安全的,多线程环境下可能会引起死循环问题,可以使用ConcurrentHashMap来代替。另外,允许null键和null值,但只能有一个null键,因为键必须唯一。 总结的话,HashMap通过数组加链表(和红黑树)的结构,使用哈希函数快速定位,处理冲突,动态扩容来保证高效性能。需要注意哈希函数、负载因子和容量设置,根据实际情况调整。</think>HashMap是Java中基于哈希表实现的键值对存储结构,下面分步骤讲解其核心原理: --- ### 一、数据结构:数组 + 链表/红黑树 HashMap底层是一个`Node<K,V>[]`数组,每个数组位置称为一个**桶(Bucket)**。 - **普通情况**:每个桶存储一个链表,用于解决哈希冲突(不同键映射到同一位置)。 - **树化优化**:当链表长度超过8且数组长度≥64时,链表转为红黑树(时间复杂度从$O(n)$优化为$O(\log n)$)。 --- ### 二、哈希函数:定位桶的位置 1. **计算键的哈希值** 通过`hashCode()`方法获得原始哈希值,再经过扰动函数(异或高16位)减少碰撞概率: ```java static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } ``` 2. **确定桶下标** 哈希值对数组长度取模: $$ \text{index} = \text{hash} \ \& \ (\text{length} - 1) $$ (当`length`是2的幂时,取模等价于按位与操作) --- ### 三、解决哈希冲突 - **链表法**:冲突的键值对以链表形式存储在同一桶中。 - **红黑树法**:链表过长时转为红黑树,提升查询效率。 --- ### 四、核心操作流程 #### 1. 插入数据(put) - **步骤**: 1. 计算键的哈希值,确定桶下标。 2. 若桶为空:直接插入新节点。 3. 若桶非空: - **链表**:遍历链表,若找到相同键则更新值,否则添加到末尾。 - **红黑树**:按树结构插入。 4. 检查是否扩容。 - **树化条件**:链表长度 > 8 且 数组长度 ≥ 64。 #### 2. 获取数据(get) 1. 计算键的哈希值,确定桶下标。 2. 遍历链表或红黑树,通过`equals()`方法匹配键。 3. 返回对应的值,未找到则返回`null`。 --- ### 五、动态扩容机制 - **负载因子(Load Factor)**:默认0.75,表示当元素数量达到容量×0.75时触发扩容。 - **扩容过程**: 1. 新建一个2倍大小的数组。 2. 重新计算所有键的哈希,分配到新桶中(避免链表反转死循环)。 - **目的**:减少哈希冲突,保证高效性。 --- ### 六、关键特性 1. **线程不安全**:多线程操作可能导致数据不一致,需用`ConcurrentHashMap`替代。 2. **允许null键/值**:但最多只能有一个null键。 3. **无序性**:遍历顺序不固定。 --- ### 七、性能优化建议 1. 设置合理的初始容量和负载因子,减少扩容次数。 2. 键对象需正确重写`hashCode()`和`equals()`方法。 3. 避免高频修改操作(如反复插入删除),优先选择不可变对象作为键。 --- 通过上述设计,HashMap在大多数场景下实现了$O(1)$时间复杂度的插入和查询操作。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值