HashMap的底层原理

img

  • 作者简介:大家好,我是五度鱼,一个普通的Java领域博主,不停输出Java技术博客和干货
  • 个人主页:五度鱼学Java的主页


前言

  在计算机编程中,哈希表(Hash Table)是一种常见的数据结构,它可以在常数时间内完成插入、删除和查找操作。而在 Java 中,哈希表的具体实现就是 HashMap 类。

  本文详细介绍hash冲突链表解决方法,在阅读完本文之后,您将了解到 HashMap 的基本原理和应用场景。
在这里插入图片描述

1、hashmap特点

(1)Map接口的常用实现类:HashMapHashtableProperties
(2)HashMap是Map接口使用频率最高的实现类
(3)HashMap是以key-val对的方式存储数据(HashMap$Node类型)
(4)key不能重复,但是值可以重复,允许使用null键和null
(5)如果添加相同的key,则会覆盖原来的key-val,等同于修改(key不会替换,val会替换)
(6)与HashSet一样,不保证映射的顺序,因为底层是以hash表的方式来存储的
(7)HashMap没有实现同步,因此线程是不安全的,方法没有做同步互斥的操作,没有synchronized
(8)(k,v)是一个Node实现类Map.Entry<K,V>接口
(9)jdk1.7hashmap底层实现【数组+链表】,jdk1.8底层【数组+链表+红黑树】

2、hashmap底层机制

(1)HashMap底层维护了Node类型的数组table,默认为null
(2)当创建对象时,将加载因子(loadfactor)初始化为0.75
(3)当添加key-val时,通过key的哈希值值得到在table的索引。然后判断该索引处是否有元素,如果没有元素直接添加。如果索引处有元素,继续判断该元素的key和准备加入的key比较是否相等,如果相等,则直接替换val,如果不相等需要判断是树结构还是链表结构,做出相应处理,如果添加时发现容量不够,则需要扩容
(4)第一次添加,则需要扩容table容量为16,临界值(threshold)为1216*0.75
(5)以后再扩容,则需要扩容table容量为原来的2倍(32),临界值为原来的2倍,即24,以此类推
(6)在java8中,若果一条链表的元素个数超过TREELY_THRESHOLD(默认为8),并且table的大小 > = MIN_TREEIFY_CAPACITY(默认64),就会进行数化(红黑树)

2、hashmap底层源码

1. 执行构造器 new HashMap()
初始化加载因子 loadfactor = 0.75
HashMap$Node[] table = null
2. 执行 put 调用 hash 方法,计算 key 的 hash 值 (h = key.hashCode()) ^ (h >>> 16)
public V put(K key, V value) {//K = "java"            value = 10 
return putVal(hash(key), key, value, false, true);
}
3. 执行 putVal
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;//辅助变量
//如果底层的 table 数组为 null, 或者 length =0 , 就扩容到 16
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length
//取出 hash 值对应的 table 的索引位置的 Node, 如果为 null, 就直接把加入的 k-v
//, 创建成一个 Node ,加入该位置即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;//辅助变量
// 如果 table 的索引位置的 key 的 hash 相同和新的 key 的 hash 值相同,
// 并 满足(table 现有的结点的 key 和准备添加的 key 是同一个对象 || equals 返回真)
// 就认为不能加入新的 k-v
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)//如果当前的 table 的已有的 Node 是红黑树,就按照红黑树的方式处
理
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//如果找到的结点,后面是链表,就循环比较
for (int binCount = 0; ; ++binCount) {//死循环
if ((e = p.next) == null) {//如果整个链表,没有和他相同,就加到该链表的最后
p.next = newNode(hash, key, value, null);
//加入后,判断当前链表的个数,是否已经到 8 个,到 8 个,后
//就调用 treeifyBin 方法进行红黑树的转换
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash && //如果在循环比较过程中,发现有相同,就 break,就只是替换 value
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value; //替换,key 对应 value
afterNodeAccess(e);
return oldValue;
}
}
++modCount;//每增加一个 Node ,就 size++
if (++size > threshold[12-24-48])//如 size > 临界值,就扩容
resize();
afterNodeInsertion(evict);
return null;
}
5. 关于树化(转成红黑树)
//如果 table 为 null ,或者大小还没有到 64,暂时不树化,而是进行扩容. //否则才会真正的树化 -> 剪枝
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
}
*/
}
}

1、为什么子类要重写hashCode方法和equals方法

  if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p;
  该段代码意思是要兼顾两种条件,第一个条件是判断插入的keytablekey是否相等,第二条就是要保证重写equals方法保证内容相同
  如果我现在有一个人类People(int num,String name,int age)判断插入相等的条件是name和age属性相同,那就一定要重写hashCode()方法了,当你基于nameage重写了equals()方法,使得两个People对象如果nameage都相同就被认为是相等的。为了保持hashCode()方法与equals()方法的一致性,这两个对象也应该具有相同的哈希码。如果不重写hashCode(),可能导致相等的对象拥有不同的哈希码,违反了哈希表的工作原理。

2、优化哈希表性能

  在使用哈希表结构如HashMapHashSet时,对象的哈希码决定了其在表中的索引位置。如果哈希码能均匀分布,就可以减少冲突,提高查找、插入等操作的效率。如果只是基于内存地址计算哈希值(默认实现),可能无法充分利用nameage的信息来达到好的散列效果。

3、基于name和age重写hashCode()的方法示例:

@Override
public int hashCode() {
    // 结合31这个质数可以帮助减少哈希冲突
    // 注意:这里的实现仅作为示例,实际应用中可能需要根据具体情况调整策略
    int result = 17;
    result = 31 * result + name.hashCode();
    result = 31 * result + age;
    return result;
}

  这里,我们使用了一个常量31作为乘子(因为31是一个质数,与其他常用数值相比,它与其他数字相乘后得到的散列值能更均匀地分布在哈希表中),并将name的哈希码和age的值组合起来计算出最终的哈希码。

4、不重写的默认hashCode()方法是怎么计算的

  在Java中,如果没有显式重写hashCode()方法,大多数对象会使用其所属类的默认hashCode()实现。这个默认实现通常由JVM提供,并且具体实现方式可能会根据不同的Java虚拟机实现略有差异,但核心思路是基于对象的内存地址来生成一个整数哈希码。

  对于对象实例,其默认的hashCode()方法一般会返回该对象在堆内存中的存储地址(经过一定的算法处理以适应整数范围),这个地址可以视为对象的一个唯一标识。由于地址对于同一对象来说在程序运行期间是固定的,因此默认的hashCode()实现满足了hashCode()方法的一致性要求(即同一个对象多次调用hashCode()应该返回相同的值)。

  例如,在HotSpot虚拟机中,一个常见的简单实现是对对象的内存地址进行某种形式的位运算,转换成一个整数,以适应int类型的大小限制。这种方式简单直接,但对于复杂的数据结构或者自定义的比较逻辑(比如基于nameage比较People对象),这种基于地址的哈希码生成方式可能就不能很好地反映对象内容的实际情况,从而导致在哈希表中性能不佳,比如频繁的哈希碰撞。

  因此,为了在将对象用作HashMapHashSet等集合的键时获得更好的性能,通常需要根据对象的内容逻辑(如特定属性)来重写hashCode()方法,以实现更加合理的哈希值分配,减少冲突,提升集合操作的效率。

5、得到了哈希码之后干什么

  哈希码被用来确定对象在哈希表中存储的具体位置。计算过程通常是将哈希码通过某种映射函数(通常是取模运算,例如hash % tableSize)转化为数组索引。这样,每个对象都能快速找到其应该存放的“桶”或数组位置。

6、hashmap的扩容为什么是2的幂次方

HashMap在Java中使用一个叫做“拉链法”的方式来解决哈希冲突,即将哈希值相同的数据放在同一个链表中。为了保证HashMap的良好性能,它需要较低的碰撞率和较好的分布性。HashMap的容量(capacity)与其扩容机制密切相关,设计为2的幂次方有以下几个主要原因:

  索引计算高效:HashMap中的索引计算是通过hash & (n - 1)来实现的,其中hash是元素的哈希值,nHashMap的容量。当容量是2的幂时,n-1就是一串二进制的1,与任何数进行按位与操作都能保留原数的低阶若干位,这样就可以直接通过哈希值的低几位来定位桶的位置,无需额外的除法或模运算,提高了计算效率。

  减少冲突:2的幂次方的容量可以使得哈希值的分布更加均匀,因为这样的容量能够最大化地利用哈希码的低位信息,而哈希码的低位通常比高位具有更好的散列性,从而减少了哈希碰撞的几率,确保了数据分布的均衡性。

  扩容时元素重新分配简单:当HashMap需要扩容时,旧的容量翻倍,即从2k次方变为2(k+1)次方。由于新的容量仍然是2的幂,因此原来位于索引i的元素在扩容后仍能保持在ii+n的位置,只需要将原数组的元素重新分配到新数组的相应位置即可,这大大简化了扩容操作的复杂度。

  空间利用率和性能平衡:虽然不是2的幂次方也可以工作,但是选择2的幂能够以最小的空间浪费换取高效的存取速度。这是因为其他非2的幂的容量可能会导致更多的空余槽位,降低空间利用率,同时也不利于上述的快速索引计算。

6、为什么加载因子是0.75

在Java的HashMap中,默认的加载因子(Load Factor)设置为0.75,这是一个经验性的折衷值,旨在平衡内存使用效率和查询性能。以下是几个关键原因解释为什么选择0.75作为默认加载因子:

  平衡时间与空间效率:加载因子决定了哈希表在何时进行扩容。较小的加载因子意味着哈希表更早地进行扩容,可以减少哈希碰撞的可能性,提升查询效率,但同时会增加内存的使用。较大的加载因子则相反,可以节省内存空间,但会导致更高的碰撞率和查找成本。0.75作为一个中间值,在这两者之间取得了一个较为平衡的状态。

  优化性能:实验和实践证明,0.75作为一个经验值,在很多应用场景下能够提供较好的性能表现。当哈希分布良好时,这个加载因子可以在保持较高空间利用率的同时,有效控制冲突概率,避免频繁扩容,从而维护了插入、删除和查找操作的高性能。

  扩容成本考虑:扩容是一个相对昂贵的操作,涉及到创建更大的内部数组,并将原有数据重新分配到新的数组中。选择0.75作为加载因子可以在数据量增长到一定程度之前避免扩容,从而减少了扩容操作的频率,降低了这部分开销。

  二分查找的效率影响:虽然HashMap中直接使用的是链地址法处理冲突,但其内部对链表达到一定长度后会转换成红黑树进一步优化查找性能。0.75的加载因子有助于在链表长度可控的情况下维持较好的平均查找时间。

  总之,0.75的加载因子是基于时间和空间效率权衡、实际性能测试和算法理论综合考虑的结果,它适用于大多数常规场景,但开发者可以根据具体需求调整这个值来优化特定应用的性能。
码文不易,最后求个关注点赞收藏,谢谢!

  • 27
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

五度鱼学Java

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值