HashMap简介
HashMap基于哈希表的Map接口实现,是以key-value存储形式存在。(除了不同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同。)
HashMap 的实现不是同步的,这意味着它不是线程安全的。它的key、value都可以为null。此外,HashMap中的映射不是有序的。在 JDK1.8 中,HashMap 是由 数组+链表+红黑树构成,新增了红黑树作为底层数据结构,结构变得复杂了,但是效率也变的更高效。
HashMap数据结构
在 JDK1.8 中,HashMap 是由 数组+链表+红黑树构成,新增了红黑树作为底层数据结构,结构变得复杂了,但是效率也变的更高效。当一个值中要存储到Map的时候会根据Key的值来计算出他的
hash,通过哈希来确认到数组的位置,如果发生哈希碰撞就以链表的形式存储。但是这样如果链表过长来的话,HashMap会把这个链表转换成红黑树来存储。
来看依一下HashMap的存储结构
类结构
我们来看一下类结构
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {}
核心参数
-
默认初始容量,必须是2的幂次函数:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
-
最大容量:
static final int MAXIMUM_CAPACITY = 1 << 30;
-
默认的加载因子:
static final float DEFAULT_LOAD_FACTOR = 0.75f;
-
链表转为红黑树的阈值:
static final int TREEIFY_THRESHOLD = 8;
-
红黑树转为链表的阈值:
static final int UNTREEIFY_THRESHOLD = 6;
-
当Map里面的数量超过这个值时,表中的桶才能进行树形化 ,否则桶内元素太多时会扩容:
static final int MIN_TREEIFY_CAPACITY = 64;
-
jdk1.8使用的是node类,而非jdk1.7的entry,不过它也继承了Map.Entry。
里面除了有key,value还包含了指向下一个节点的next 属性。
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } //省略 //返回的hashCode是key的hashCode值与value的hashCode值的异或的结果。(异或:相同为1,不相同为0) public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final boolean equals(Object o) { if (o == this) return true; if (o instanceof Map.Entry) { Map.Entry<?,?> e = (Map.Entry<?,?>)o; if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; } }
其他参数
-
初始化扩容使用的数组:
transient Node<K,V>[] table;
-
存放缓存的:
transient Set<Map.Entry<K,V>> entrySet;
-
HashMap中存储的数量:
transient int size;
-
HashMap修改的次数:
transient int modCount;
-
扩容的阈值(容量*负载因子):
int threshold;
-
负载因子:
final float loadFactor;
构造方法
-
构造一个空的 HashMap,默认初始容量(16)和默认负载因子(0.75)。
public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }
里面只是给加载因子赋了默认值为0.75.
-
构造一个空的 HashMap具有指定的初始容量和默认负载因子(0.75)
public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }
-
构造一个空的 HashMap具有指定的初始容量和负载因子。我们来分析一下,具体在代码的注释里。
public HashMap(int initialCapacity, float loadFactor) { //如果初始化容量initialCapacity 小于零 则直接抛出参数非法异常。 if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); //如果初始化容量initialCapacity 大于 定义的最大值(1 << 30,也就是1073741824),那初始容量就是最大值 if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; //下面对负载因子进行判断,如果负载因子<=0或者为空,抛出参数非法异常。 if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); //给负载因子和阈值赋值。 this.loadFactor = loadFactor; //根据给定的初始容量计算得出阈值,是一个2的幂次函数 this.threshold = tableSizeFor(initialCapacity); }
下面来看tableSizeFor方法,它会返回大于输入参数且最近的2的整数次幂的数。比如10,则返回16。
static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
根据传入的参数cap, 减一后的n值用于右移运算以及或运算(或:有一就是一)
详解如下:
先来分析有关n位操作部分:先来假设n的二进制为01xxx...xxx。接着
对n右移1位:001xx...xxx,再位或:011xx...xxx
对n右移2为:00011...xxx,再位或:01111...xxx
此时前面已经有四个1了,再右移4位且位或可得8个1
同理,有8个1,右移8位肯定会让后八位也为1。
综上可得,该算法让最高位的1后面的位全变为1。
最后再让结果n+1,即得到了2的整数次幂的值了。
那为什么要cap - 1 ?
让cap-1再赋值给n的目的是:令找到的目标值大于或等于原值。例如二进制1000,十进制数值为8。如果不对它减1而直接操作,将得到答案10000,即16。显然不是结果。减1后二进制为111,再进行操作则会得到原来的数值1000,即8。
put操作
先看源码:
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
调用了putVal方法,返回key的oldValue或者是null。先看一下这里的hash(key)方法:
static final int hash(Object key) { int h; //可以看到当key为null的时候,也是有hash值为0的返回的。 //首先计算key的hashCode为h,然后与h无符号右移16位后的二进制进行异或得到最终的hash值 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
上面就已经解释了为什么HashMap的长度为什么要是2的幂:因为HashMap 使用的方法很巧妙,它通过 hash & (table.length -1)来得到该对象的保存位,前面说过 HashMap 底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当 length 总是2的n次方时,hash & (length-1)运算等价于对 length 取模,也就是 hash%length,但是&比%具有更高的效率。比如 n % 32 = n & (32 -1)。
现在看putVal方法,看看它到底做了什么。
主要参数:
-
hash - key的hash值
-
key - 原始Key
-
value - 要存放的值
-
onlyIfAbsent - 如果true代表不更改现有的值
-
evict - 如果为false表示table为创建状态
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { //定义了空的数组tab,节点p,变量n,i Node<K,V>[] tab; Node<K,V> p; int n, i; //给tab赋值table,给n赋初始值tab.length,如果table时空或者length=0,就初始化 if ((tab = table) == null || (n = tab.length) == 0) //调用resize方法对map进行初始化,分配空间,初始化后的数组给tab,长度给n。先往下看 n = (tab = resize()).length; //对hash码进行取模运算 //i = (n - 1) & hash 这行代码是对值的位置进行确定 等价于hash%n //如果tab[i]为null,则新增一个元素 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { //如果tab[i]不为null,表示这个位置已经有值了。 //定义节点e表示新节点,变量k 赋初始值p.key Node<K,V> e; K k; //p是tab[i] //如果key的值已经存在,直接进行替换 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //否则,判断p是否树节点,如果是红黑树 else if (p instanceof TreeNode) //这里往树上添加节点,后面再看 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { //不是树,就是链表,需要遍历到最后节点然后插入新元素(尾插法) for (int binCount = 0; ; ++binCount) { //e赋值为p的下一个节点值:p.next,为空的话(表示是最后一个节点)给它创建一个节点 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //同时判断节点的长度大于等于树化的阈值-1,就转化链表为红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //插入的元素在链表里存在,break; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; //更新p的值,继续进行for循环遍历 p = e; } } //经过上面的赋值,如果e已经存在,对e的value进行赋值,并返回 if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; //这个方法在hashMap中没有用到 afterNodeAccess(e); return oldValue; } } //记录修改次数 ++modCount; //如果当前元素数量>扩容的阈值threshold,调用resize方法进行扩容。 if (++size > threshold) resize(); //这个方法在hashMap中没有用到,在concurrentHashMap中有用。 afterNodeInsertion(evict); return null; }
put操作的流程图:
下面再来看一下resize方法,先思考以下两个问题:
-
那为什么要扩容呢?
为了解决冲突, 我们就需要对table进行扩容, 就是加长table的长度, 来减少hash冲突的概率,减少链表的长度;
-
都有哪里调用了resize方法进行扩容?
-
HashMap实行了懒加载, 新建HashMap时不会对table进行赋值, 而是到第一次插入时, 进行resize时构建table;
-
当HashMap.size 大于 threshold时, 会进行resize.
threshold的值当第一次构建时, 如果没有指定HashMap.table的初始长度, 就用默认值16, 否则就是指定的值; 然后不管是第一次构建还是后续扩容, threshold = table.length * loadFactor;
大概流程如下:
-
如果table == null, 则为HashMap的初始化, 生成空table返回即可;
-
如果table不为空, 需要重新计算table的长度, newLength = oldLength << 1(注, 如果原oldLength已经到了上限, 则newLength = oldLength);
-
遍历oldTable: 3.1 首节点为空, 本次循环结束; 3.2 无后续节点, 重新计算hash位, 本次循环结束; 3.3 当前是红黑树, 走红黑树的重定位; 3.4 当前是链表, 通过(e.hash & oldCap) == 0来判断是否需要移位; 如果为真则在原位不动, 否则则需要移动到当前hash槽位 + oldCap的位置;
-
//初始化或者数组容量扩容至两倍,扩容后的元素的位置要么是之前的位置要么是新数组的2的幂次函数的偏移量 final Node<K,V>[] resize() { //首先给oldTab赋值为table Node<K,V>[] oldTab = table; //给oldCap赋值为0(空的时候)或者是未扩容之前的数组的长度 int oldCap = (oldTab == null) ? 0 : oldTab.length; //oldThr赋值为之前的阈值threshold int oldThr = threshold; //定义新的容量,新的阈值初始值为0 int newCap, newThr = 0; //如果之前的数组容量大于0 if (oldCap > 0) { //如果之前的数组容量比最大容量大,则阈值直接定义为2的31次方-1. if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; //直接返回未扩容之前的数组 return oldTab; } //这里给newCap赋值为oldCap << 1 也就是oldCap的两倍,如果新容量<最大容量,并且老的容量大于等于默认初始容量(16) else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //新的阈值为老阈值的2倍 newThr = oldThr << 1; // double threshold } //这种情况应该很少出现 else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; else { // zero initial threshold signifies using defaults //oldCap=0也就是new map后第一次put元素时, //oldThr <= 0 也就是阈值<=0,新容量为默认容量16,新的阈值为加载因子*默认初始容量 newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; //创建新容量的数组 @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; if (oldTab != null) { //对旧数组遍历,每一个节点进行判断 for (int j = 0; j < oldCap; ++j) { //e是旧数组的遍历的当前节点 Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; //e.next为null,说明下面没有链表,直接结算e的位置,将e挪到新数组去 if (e.next == null) newTab[e.hash & (newCap - 1)] = e; //e的next有值,判断是否红黑树,红黑树的话调用split方法将节点转到新数组 else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); //不是红黑树,那就是链表,思想就是将链表的节点分为2份,分好后再一起找再新数组的位置 else { // preserve order //loHead用于存储低位(位置不变)key的链头,loTail用于指向链尾位置。 Node<K,V> loHead = null, loTail = null; //hiHead用户存储即将存储在高位的key的链头,hiTail用于指向链尾位置。 Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; //与原数组长度进行与运算后,得到的结果为0的,意味着在新数组中的位置是不变的,因此,将其组成一个链条 if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { //对于非0的key,其在新数组中的位置是需要更新的,需要存储在新的数组中的一个新的位置,将其形成一个链条。 if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; //将位置加上原数组长度,即为新的位置信息。 newTab[j + oldCap] = hiHead; } } } } } return newTab; }
树化相关算法可以看JDK1.8HashMap与红黑树 这个文章里面的分析 https://www.cnblogs.com/yunyunde/p/14377743.html。
hash算法
在put方法中调用了hash算法用于计算key的hash值。
static final int hash(Object key) { int h; //可以看到当key为null的时候,也是有hash值为0的返回的。 //首先计算key的hashCode为h,然后与h无符号右移16位后的二进制进行异或得到最终的hash值 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
为什么要无符号右移16位后做异或运算
根据上面的说明我们做一个简单演练
将h无符号右移16为相当于将高区16位移动到了低区的16位,再与原hashcode做异或运算,可以将高低位二进制特征混合起来
从上文可知高区的16位与原hashcode相比没有发生变化,低区的16位发生了变化
我们可知通过上面(h = key.hashCode()) ^ (h >>> 16)进行运算可以把高区与低区的二进制特征混合到低区,那么为什么要这么做呢?
我们都知道重新计算出的新哈希值在后面将会参与hashmap中数组槽位的计算,计算公式:(n - 1) & hash,假如这时数组槽位有16个,则槽位计算如下:
仔细观察上文不难发现,高区的16位很有可能会被数组槽位数的二进制码锁屏蔽,如果我们不做刚才移位异或运算,那么在计算槽位时将丢失高区特征
也许你可能会说,即使丢失了高区特征不同hashcode也可以计算出不同的槽位来,但是细想当两个哈希码很接近时,那么这高区的一点点差异就可能导致一次哈希碰撞,所以这也是将性能做到极致的一种体现
使用异或运算的原因
异或运算能更好的保留各部分的特征,如果采用&运算计算出来的值会向0靠拢,采用|运算计算出来的值会向1靠拢
为什么槽位数必须使用2^n (重点)
1、为了让哈希后的结果更加均匀
这个原因我们继续用上面的例子来说明
假如槽位数不是16,而是17,则槽位计算公式变成:(17 - 1) & hash
从上文可以看出,计算结果将会大大趋同,hashcode参加&运算后被更多位的0屏蔽,计算结果只剩下两种0和16,这对于hashmap来说是一种灾难
上面提到的所有问题,最终目的还是为了让哈希后的结果更均匀的分部,减少哈希碰撞,提升hashmap的运行效率
寻址算法
putVal() 中寻址部分tab[i = (n - 1) & hash]
tab 就是 HashMap 里的 table 数组 Node<K,V>[] table
;
n 是这个数组的长度 length;
hash 就是上面 hash() 方法返回的值;
寻址为什么不用取模?
为什么不直接用 hashCode() % length ?
对于上面寻址算法,由于计算机对比取模,与运算会更快。所以为了效率,HashMap 中规定了哈希表长度为 2 的 k 次方,而 2^k-1 转为二进制就是 k 个连续的 1,那么 hash & (k 个连续的 1)
返回的就是 hash 的低 k 个位,该计算结果范围刚好就是 0 到 2^k-1,即 0 到 length - 1,跟取模结果一样。
也就是说,哈希表长度 length 为 2 的整次幂时, hash & (length - 1)
的计算结果跟 hash % length
一样,而且效率还更好。
hash冲突后如何存储
在解决这个问题之前,我们首先需要知道什么是哈希冲突,而在了解哈希冲突之前我们还要知道什么是哈希;
-
什么是hash?
Hash,一般翻译为“散列”,也有直接音译为“哈希”的,这就是把任意长度的输入通过散列算法,变换成固定长度的输出,该输出就是散列值(哈希值);这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
所有散列函数都有如下一个基本特性:根据同一散列函数计算出的散列值如果不同,那么输入值肯定也不同。但是,根据同一散列函数计算出的散列值如果相同,输入值不一定相同。
-
什么是hash冲突?
我们知道HashMap底层是由数组+链表/红黑树构成的, 当我们通过put(key, value)向hashmap中添加元素时,需要通过散列函数确定元素究竟应该放置在数组中的哪个位置,当不同的元素被放置在了数据的同一个位置时,后放入的元素会以链表的形式,插在前一个元素的尾部,这个时候我们称发生了hash冲突。
-
如何解决hash冲突?
扰动处理 = 2次扰动 = 1次位运算 + 1次异或运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
1. 使用链地址法(使用散列表)来链接拥有相同hash值的数据;
2. 使用2次扰动函数(hash函数)来降低哈希冲突的概率,使得数据分布更平均;
3. 引入红黑树进一步降低遍历的时间复杂度,使得遍历更快;
-
那为什么是两次扰动呢
什么时候转红黑树
出现条件:满足:链表长度到8,并且数组长度到64
-
情况一:链表长度到满足8,先尝试转红黑树
TREEIFY_THRESHOLD:8
for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st //这里链表转红黑树 treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; }
-
情况二:校验数组长度满足64
MIN_TREEIFY_CAPACITY:64
看下面代码,调用treeifyBin()的方法,先判断数组长度是否小于64,小于则进行扩容;否则,转红黑树。
final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; //先判断数组长度是否小于MIN_TREEIFY_CAPACITY 64,小于则进行扩容 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); else if ((e = tab[index = (n - 1) & hash]) != null) { TreeNode<K,V> hd = null, tl = null; do { TreeNode<K,V> p = replacementTreeNode(e, null); if (tl == null) hd = p; else { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); if ((tab[index] = hd) != null) hd.treeify(tab); } }