再谈HashMap

版权声明: https://blog.csdn.net/qq_16525279/article/details/82697213

回顾:散列表(HashMap)概述&数据结构&实现

存储结构

JDK7 中的 HashMap 采用大家所熟悉的数组+链表的结构来存储数据。

JDK8 中的 HashMap 采用了数组+链表或树的结构来存储数据。

重要参数

HashMap中有两个重要的参数,容量(Capacity) 和 负载因子(Load factor)

Initial capacity 决定 bucket 的大小,Load factor 决定 bucket 内数据填充比例,基于这两个参数的乘积,HashMap 内部由 threshold 这个变量来表示 HashMap 能放入的元素个数。

  • Capacity 就是 HashMap 中数组的 length
  • loadFactor 一般都是使用默认的0.75
  • threshold 决定能放入的数据量,一般情况下等于 Capacity * LoadFactor

以上参数在 JDK7 和 JDK8中是一致的,接下来会根据实际代码分析。

JDK8 中的 HashMap 实现

new

HashMap 的bucket数组并不会在new 的时候分配,而是在第一次 put 的时候通过 resize() 函数进行分配。

JDK8中 HashMap 的bucket数组大小肯定是2的幂,对于2的幂大小的 bucket,计算下标只需要 hash 后按位与 n-1,比%模运算取余要快。如果你通过 HashMap(int initialCapacity) 构造器传入initialCapacity,会先计算出比initialCapacity大的 2的幂存入 threshold,在第一次 put 的 resize() 初始化中会按照这个2的幂初始化数组大小,此后 resize 扩容也都是每次乘2。

hash

JKD8 中put 和 get 时,对 key 的 hashCode 先用 hash 函数散列下,再计算下标。

hash 函数大概的作用就是:高16bit不变,低16bit和高16bit做了一个异或,目的是减少碰撞。

put

put函数的思路大致分以下几步:

  1. 对key的hashCode()进行hash后计算数组下标index;
  2. 如果当前数组table为null,进行resize()初始化;
  3. 如果没碰撞直接放到对应下标的bucket里;
  4. 如果碰撞了,且节点已经存在,就替换掉 value;
  5. 如果碰撞后发现为树结构,挂载到树上。
  6. 如果碰撞后为链表,添加到链表尾,并判断链表如果过长(大于等于TREEIFY_THRESHOLD,默认8),就把链表转换成树结构(红黑树);
  7. 数据 put 后,如果数据量超过threshold,就要resize。

get

在理解了put之后,get就很简单了。大致思路如下:

  1. bucket里的第一个节点,直接命中;
  2. 如果有冲突,则通过key.equals(k)去查找对应的entry
    若为树,则在树中通过key.equals(k)查找,O(logn);
    若为链表,则在链表中通过key.equals(k)查找,O(n)。

总结

  1. HashMap 在 new 后并不会立即分配bucket数组,而是第一次 put 时初始化,类似 ArrayList 在第一次 add 时分配空间。
  2. HashMap 的 bucket 数组大小一定是2的幂,如果 new 的时候指定了容量且不是2的幂,实际容量会是最接近(大于)指定容量的2的幂,比如 new HashMap<>(19),比19大且最接近的2的幂是32,实际容量就是32。
  3. HashMap 在 put 的元素数量大于 Capacity * LoadFactor(默认16 * 0.75) 之后会进行扩容。
  4. JDK8处于提升性能的考虑,在哈希碰撞的链表长度达到TREEIFY_THRESHOLD(默认8)后,会把该链表转变成树结构。
  5. JDK8在 resize 的时候,通过巧妙的设计,减少了 rehash 的性能消耗。

加深理解

1. 什么时候会使用HashMap?他有什么特点?
是基于Map接口的实现,存储键值对时,它可以接收null的键值,是非同步的,HashMap存储着Entry(hash, key, value, next)对象。

2. 你知道HashMap的工作原理吗?
通过hash的方法,通过put和get存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,Hashmap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。

3. 你知道get和put的原理吗?equals()和hashCode()的都有什么作用?
通过对key的hashCode()进行hashing,并计算下标( n-1 & hash),从而获得buckets的位置。如果产生碰撞,则利用key.equals()方法去链表或树中去查找对应的节点

4. 你知道hash的实现吗?为什么要这样实现?
在Java 1.8的实现中,是通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在bucket的n比较小的时候,也能保证考虑到高低bit都参与到hash的计算中,同时不会有太大的开销。

5. 如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?
如果超过了负载因子(默认0.75),则会重新resize一个原来长度两倍的HashMap,并且重新调用hash方法。

关于Java集合的小抄

以Entry[]数组实现的哈希桶数组,用Key的哈希值取模桶数组的大小可得到数组下标。

插入元素时,如果两条Key落在同一个桶(比如哈希值1和17取模16后都属于第一个哈希桶),我们称之为哈希冲突。

JDK的做法是链表法,Entry用一个next属性实现多个Entry以单向链表存放。查找哈希值为17的key时,先定位到哈希桶,然后链表遍历桶里所有元素,逐个比较其Hash值然后key值。

在JDK8里,新增默认为8的阈值,当一个桶里的Entry超过阈值,就不以单向链表而以红黑树来存放以加快Key的查找速度。

当然,最好还是桶里只有一个元素,不用去比较。所以默认当Entry数量达到桶数量的75%时,哈希冲突已比较严重,就会成倍扩容桶数组,并重新分配所有原来的Entry。扩容成本不低,所以也最好有个预估值。

取模用与操作(hash & (arrayLength-1))会比较快,所以数组的大小永远是2的N次方, 你随便给一个初始值比如17会转为32。默认第一次放入元素时的初始值是16。

iterator()时顺着哈希桶数组来遍历,看起来是个乱序。

参考

猜你喜欢

转载自blog.csdn.net/qq_16525279/article/details/82697213