你了解HashMap吗?

你了解HashMap吗?

HashMap的数据结构

JDK1.7:HashMap由数组+链表的数据结构 哈希表组成。

JDK1.8的Hashmap相对于1.7而言,发生最大的变化便是引入了红黑树的设计。当hash表的单一链表长度大于8的时候,其链表结构就会转为红黑树结构。

数组结构

数组结构的特点:存储区间连续、内存占用严重、空间复杂性大

  • 优点:随机读取和修改的效率高 (原因:它是一段连续的存储空间,可以通过角标/下标直接找到对应位置的元素)
  • 插入和删除效率低(原因:要插入元素的话,需要将其位置后面的元素在内存中全部往后移动,数组的大小是固定的不易动态扩展)

链表结构

链表结构的特点: 存储区间离散、占用内存宽松、空间复杂度小(物理单元上非连续、非顺序的存储结构)

  • 插入速度快,内存利用率高,没有固定大小,扩展灵活。(每一个节点都有指针,插入只需要改变指针的方向即可)
  • 不能随机查找,如果进行查找每次都会从第一个元素开始查找(查询效率低)

哈希表结构

哈希表结构特点:结合数组结构和链表结构的特点,从而实现查找和修改、删除和插入效率都高的数据结构。

如图所示

在这里插入图片描述

HashMap.put(k,v)

  • map.put(k,v)的实现原理
1.首先将k,v封装到Node对象中
2.然后它的底层会调用K的hashcode()方法计算出hash值
3.然后通过hash表函数/哈希算法,将hash值转换成数组下标,如果数组下标位置上没有任何其他的元素,就将Node添加到这个位置。如果下标对应的位置上存在链表,那么此时就会用k和链表上每一个节点进行equal方法比较。如果所有的equals方法都返回了false,就在链表的末尾添加这个新的节点,如果其中有一个equals返回了true,那么此时该节点的value会被覆盖。
  • map.get(k)实现原理
1.先调用k的hashCode()方法计算出hash值,并通过哈希算法转换成数组的下标
2.根据计算出的数组下标快速找到定位到该位置。如果这个位置上什么都没有,则返回null,如果这个位置上存在单向链表,那么他就好拿着k和单向链表上每一个K进行equals方法的比较。如果所有的equals返回的是false,那么get(k)返回一个null,如果在单向链表上其中一个节点的k和参数k进行的equals返回了true,那么该节点上的value便是我们要找到value,get(k)最终则会返回这个value.

  • 为何随机增删、查询效率都很高的原因是?
    原因: 增删是在链表上完成的,而查询只需扫描部分,则效率高。

  • HashMap集合的key,会先后调用两个方法,hashCode and equals方法,这这两个方法都需要重写。

  • 为什么放在hashMap集合key部分的元素需要重写equals方法?
    因为equals方法默认比较的是两个对象的内存地址

HashMap红黑树原理分析

相比 jdk1.7 的 HashMap 而言,jdk1.8最重要的就是引入了红黑树的设计,当hash表的单一链表长度超过 8 个的时候,链表结构就会转为红黑树结构。
为什么要这样设计呢?好处就是避免在最极端的情况下链表变得很长很长,在查询的时候,效率会非常慢。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Q4kyS4ND-1617609987303)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20210402152708398.png)]

红黑树查询:其访问性能近似于折半查找,时间复杂度 O(logn);
链表查询:这种情况下,需要遍历全部元素才行,时间复杂度 O(n);
简单的说,红黑树是一种近似平衡的二叉查找树,其主要的优点就是“平衡“,即左右子树高度几乎一致,以此来防止树退化为链表,通过这种方式来保障查找的时间复杂度为 log(n)。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7HCdUuuu-1617609987305)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20210402152734484.png)]

关于红黑树的内容,网上给出的内容非常多,主要有以下几个特性:

1、每个节点要么是红色,要么是黑色,但根节点永远是黑色的;

2、每个红色节点的两个子节点一定都是黑色;

3、红色节点不能连续(也即是,红色节点的孩子和父亲都不能是红色);

4、从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点;

5、所有的叶节点都是是黑色的(注意这里说叶子节点其实是上图中的 NIL 节点);

在树的结构发生改变时(插入或者删除操作),往往会破坏上述条件 3 或条件 4,需要通过调整使得查找树重新满足红黑树的条件。
原文链接:https://blog.csdn.net/qq_43370771/article/details/111353046

观看HashMap源码

默认初始容量
/**
 * The default initial capacity - MUST be a power of two.   默认初始容量必须是2的幂
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
负载因子
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;

当创建HashMap时,会有一个默认的负载因子(load factor),其默认值是0.75,

这是时间和空间成本上的一种折中:

增大负载因子: 可以减少hash表(就是那个Entry数组)所占用的内存空间,但是会增加查询数据的时间开销,而查询是最麻烦的操作(HashMap的put()和get()方法都要用到查询);

**减小负载因子:**会提高数据查询的性能,但是会增加Hash表所占用的内存空间。

Hah冲突

HashMap里面没有出现hash冲突时,没有形成单链表时,HashMap查找元素很快,get()方法能够直接定位到元素,但是出现单链表以后。单个的bucket存储的不是一个Entry,而是一个Enter链。系统只能按顺序遍历每隔Entey,直到找到想要的Entry为止。----那最尴尬的时候就是恰好需要搜索的Entry位于该链的最末端(该Entry是最早放入该Bucket中),那系统必须循环到最后才能找到该元素。

若:加载因子越大,则bucket填满的元素越多。好处是:发生冲突的机会减小了,但是这样浪费的内存空间也就多了。表中的数据过于稀疏(很多空间还没有进行使用,就开始扩容了)。

因此,必须在“冲突的机会”与“空间利用率”之间寻找一种平衡与折中。折中平衡与折中的本质上是数据结构中有名的"时-空"矛盾的平衡和折中。

如果机器内存足够,并且想要提高查询的速度,可以将加载因子设置小一点,相反如果机器内存紧张,并且对查询速度没有什么要求的话,可以将加载因子设置大一点。不过一般我们都不用区设置它,使用其默认值即可。

map集合允许key为null

感谢:https://blog.csdn.net/qq_35956041/article/details/77532763

HashMap最多只允许一条记录的键为Null;允许多条记录的值为Null;

public V put(K key, V value) {
     // 若“key为null”,则将该键值对添加到table[0]中。
         if (key == null) 
            return putForNullKey(value);
     // 若“key不为null”,则计算该key的哈希值,然后将其添加到该哈希值对应的链表中。
         int hash = hash(key.hashCode());
     //搜索指定hash值在对应table中的索引
         int i = indexFor(hash, table.length);
     // 循环遍历Entry数组,若“该key”对应的键值对已经存在,则用新的value取代旧的value。然后退出!
         for (Entry<K,V> e = table[i]; e != null; e = e.next) { 
             Object k;
              if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { //如果key相同则覆盖并返回旧值
                  V oldValue = e.value;
                 e.value = value;
                 e.recordAccess(this);
                 return oldValue;
              }
         }
     //修改次数+1
         modCount++;
     //将key-value添加到table[i]处
     addEntry(hash, key, value, i);
     return null;
}

上面程序中用到了一个重要的内部接口,Map.Entry,每个Map.Entry其实就是一个key-value键值对,从上面程序中可以看出:当系统决定要存储HashMap中的key-value键值对时,完全没有考虑Entry中的value,仅仅是根据key来计算并决定Enrty的存储位置。所以有这样一个结论:我们可以吧Map集合中的Value当成key的附属,当系统决定了key的存储位置,value也随之保存。

我们慢慢的来分析这个函数,第2和3行的作用就是处理key值为null的情况,我们看看putForNullKey(value)方法:

1 private V putForNullKey(V value) {
2         for (Entry<K,V> e = table[0]; e != null; e = e.next) {
3             if (e.key == null) {   //如果有key为null的对象存在,则覆盖掉
4                 V oldValue = e.value;
5                 e.value = value;
6                 e.recordAccess(this);
7                 return oldValue;
8            }
9        }
10         modCount++;
11         addEntry(0, null, value, 0); //如果键为null的话,则hash值为0
12         return null;
13     }

注意:如果key为null的话,hash值为0,对象存储在数组中索引为0的位置。即table[0]

我们再回去看看put方法中第4行,它是通过key的hashCode值计算hash码,下面是计算hash码的函数:

1  //计算hash值的方法 通过键的hashCode来计算
2     static int hash(int h) {
3         // This function ensures that hashCodes that differ only by
4         // constant multiples at each bit position have a bounded
5         // number of collisions (approximately 8 at default load factor).
6         h ^= (h >>> 20) ^ (h >>> 12);
7         return h ^ (h >>> 7) ^ (h >>> 4);
8     }

得到hash码之后就会通过hash码去计算出应该存储在数组中的索引,计算索引的函数如下:

1 static int indexFor(int h, int length) { //根据hash值和数组长度算出索引值
2     return h & (length-1);  
		//这里不能随便算取,用hash&(length-1)是有原因的,这样可以确保算出来的索引是在数组大小范围内,不会超出
3     }

这个我们要重点说下,我们一般对哈希表的散列很自然地会想到用hash值对length取模(即除法散列法),Hashtable中也是这样实现的,这种方法基本能保证元素在哈希表中散列的比较均匀,但取模会用到除法运算,效率很低,HashMap中则通过h&(length-1)的方法来代替取模,同样实现了均匀的散列,但效率要高很多,这也是HashMap对Hashtable的一个改进。

接下来,我们分析下为什么哈希表的容量一定要是2的整数次幂。首先,length为2的整数次幂的话,h&(length-1)就相当于对length取模,这样便保证了散列的均匀,同时也提升了效率;其次,length为2的整数次幂的话,为偶数,这样length-1为奇数,奇数的最后一位是1,这样便保证了h&(length-1)的最后一位可能为0,也可能为1(这取决于h的值),即与后的结果可能为偶数,也可能为奇数,这样便可以保证散列的均匀性,而如果length为奇数的话,很明显length-1为偶数,它的最后一位是0,这样h&(length-1)的最后一位肯定为0,即只能为偶数,这样任何hash值都只会被散列到数组的偶数下标位置上,这便浪费了近一半的空间,因此,length取2的整数次幂,是为了使不同hash值发生碰撞的概率较小,这样就能使元素在哈希表中均匀地散列。

在这里插入图片描述

根据 put 方法的源代码可以看出,当程序试图将一个key-value对放入HashMap中时,程序首先根据该 key 的 hashCode() 返回值决定该 Entry 的存储位置:如果两个 Entry 的 key 的 hashCode() 返回值相同,那它们的存储位置相同。如果这两个 Entry 的 key 通过 equals 比较返回 true,新添加 Entry 的 value 将覆盖集合中原有 Entry 的 value,但key不会覆盖。如果这两个 Entry 的 key 通过 equals 比较返回 false,新添加的 Entry 将与集合中原有 Entry 形成 Entry 链,而且新添加的 Entry 位于 Entry 链的头部——具体说明继续看 addEntry() 方法的说明。

1 void addEntry(int hash, K key, V value, int bucketIndex) {
2         Entry<K,V> e = table[bucketIndex]; //如果要加入的位置有值,将该位置原先的值设置为新entry的next,也就是新entry链表的下一个节点
3         table[bucketIndex] = new Entry<>(hash, key, value, e);
4         if (size++ >= threshold) //如果大于临界值就扩容
5             resize(2 * table.length); //以2的倍数扩容
6     }

参数bucketIndex就是indexFor函数计算出来的索引值,第2行代码是取得数组中索引为bucketIndex的Entry对象,第3行就是用hash、key、value构建一个新的Entry对象放到索引为bucketIndex的位置,并且将该位置原先的对象设置为新对象的next构成链表。

第4行和第5行就是判断put后size是否达到了临界值threshold,如果达到了临界值就要进行扩容,HashMap扩容是扩为原来的两倍。

4、调整大小

resize()方法如下:

重新调整HashMap的大小,newCapacity是调整后的单位

1     void resize(int newCapacity) {
2         Entry[] oldTable = table;
3         int oldCapacity = oldTable.length;
4         if (oldCapacity == MAXIMUM_CAPACITY) {
5             threshold = Integer.MAX_VALUE;
6             return;
7        }
8 
9         Entry[] newTable = new Entry[newCapacity];
10         transfer(newTable);//用来将原先table的元素全部移到newTable里面
11         table = newTable;  //再将newTable赋值给table
12         threshold = (int)(newCapacity * loadFactor);//重新计算临界值
13     }

新建了一个HashMap的底层数组,上面代码中第10行为调用transfer方法,将HashMap的全部元素添加到新的HashMap中,并重新计算元素在新的数组中的索引位置

当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,这是一个常用的操作,而在HashMap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。

那么HashMap什么时候进行扩容呢?当HashMap中的元素个数超过数组大小loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过160.75=12的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,扩容是需要进行数组复制的,复制数组是非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。

5、数据读取

public V get(Object key) {   
2.    if (key == null)   
3.        return getForNullKey();   
4.    int hash = hash(key.hashCode());   
5.    for (Entry<K,V> e = table[indexFor(hash, table.length)];   
6.        e != null;   
7.        e = e.next) {   
8.        Object k;   
9.        if (e.hash == hash && ((k = e.key) == key || key.equals(k)))   
10.            return e.value;   
11.    }   
12.    return null;   
13.}  

在了解到存储时利用hash算法作为基础之后,可以很轻松的明白get方法。首先利用其key得到它的hashCode并利用hash算法找到他对应的数组位置上的元素。再根据key.equals方法在对应位置的链表中找到value。当然不一定存在链表。

6、HashMap的性能参数:

HashMap 包含如下几个构造器:

HashMap():构建一个初始容量为 16,负载因子为 0.75 的 HashMap。

HashMap(int initialCapacity):构建一个初始容量为 initialCapacity,负载因子为 0.75 的 HashMap。

HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的负载因子创建一个 HashMap。

HashMap的基础构造器HashMap(int initialCapacity, float loadFactor)带有两个参数,它们是初始容量initialCapacity和加载因子loadFactor。

initialCapacity:HashMap的最大容量,即为底层数组的长度。

loadFactor:负载因子loadFactor定义为:散列表的实际元素数目(n)/ 散列表的容量(m)。

负载因子衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。

HashMap的实现中,通过threshold字段来判断HashMap的最大容量:

threshold = (int)(capacity * loadFactor); 

结合负载因子的定义公式可知,threshold就是在此loadFactor和capacity对应下允许的最大元素数目,超过这个数目就重新resize,以降低实际的负载因子。默认的的负载因子0.75是对空间和时间效率的一个平衡选择。当容量超出此最大容量时, resize后的HashMap容量是容量的两倍

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值