hashmap底层实现原理_HashMap的底层实现

  1. 存储结构
  • Node[]为哈希桶数组,本质是键值对映射,当链表长度大于8时,采用红黑树存储,TreeNode表示一个节点;
  • HashMap是基于哈希表存储,HashMap采用链地址法解决hash冲突;
944f2cfd2621e38e09ba738b27d7ecdf.png
  1. 元素查找(index)
  • 步骤:取key的hashCode:任意给定的对象,进行多次hashCode()返回值相同高位运算:取高16位取模运算:对数组长度取模;

//方法一:static final int hash(Object key) { //jdk1.8 & jdk1.7
int h;// h = key.hashCode() 为第一步 取hashCode值// h ^ (h >>> 16) 为第二步 高位参与运算return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}//方法二:static int indexFor(int h, int length) { //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的return h & (length-1); //第三步 取模运算
}

  • 示例,n为table的长度:
1993dad759f7bfd0aae329b8ffa260d4.png
  1. 元素存放(put)
  • 步骤:

①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;

②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;

③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;

④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;

⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;

⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容 ;

63eb26844df1f763c46c440932156428.png

public V put(K key, V value) {// 对key的hashCode()做hashreturn putVal(hash(key), key, value, false, true);
}final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;// 步骤①:tab为空则创建if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;// 步骤②:计算index,并对null做处理 if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);else {
Node<K,V> e; K k;// 步骤③:节点key存在,直接覆盖valueif (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;// 步骤④:判断该链为红黑树else if (p instanceof TreeNode)
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转换为红黑树进行处理if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);break;
}// key已经存在直接覆盖valueif (e.hash == hash &&
((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;
afterNodeAccess(e);return oldValue;
}++modCount;// 步骤⑥:超过最大容量 就扩容if (++size > threshold)
resize();
afterNodeInsertion(evict);return null;
}

  1. 扩容机制(resize)
  • 步骤:

1)扩容数组:如果超过了数组的最大容量,那么就直接将阈值设置为整数最大值,然后如果没有超过,那就扩容为原来的2倍;

//第一部分:扩容if (oldCap > 0) {if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;return oldTab;
}else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}

2)设置阈值:第一个else if表示如果阈值已经初始化过了,那就直接使用旧的阈值,然后第二个else表示如果没有初始化,那就初始化一个新的数组容量和新的阈值;

//第二部分:设置阈值else if (oldThr > 0) //阈值已经初始化了,就直接使用
newCap = oldThr;else { // 没有初始化阈值那就初始化一个默认的容量和阈值
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;

3)将旧数据复制到新数组里面,设计rehash过程(详情见3)):

A:扩容后,若hash值新增参与运算的位=0,那么元素在扩容后的位置=原始位置

B:扩容后,若hash值新增参与运算的位=1,那么元素在扩容后的位置=原始位置+扩容后的旧位置;

1cdbf286ea4e770de1a8d48c83209238.png

final Node<K,V>[] resize() {//先将老的Table取别名,这样利于后面的操作。
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;//表示之前的数组容量不为空。if (oldCap > 0) {// 如果 此时的数组容量大于最大值if (oldCap >= MAXIMUM_CAPACITY) {// 扩容 阙值为 Int类型的最大值,这种情况很少出现
threshold = Integer.MAX_VALUE;return oldTab;
}//表示 old数组的长度没有那么大,进行扩容,两倍(这里也是有讲究的)对阙值也进行扩容else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}//表示之前的容量是0 但是之前的阙值却大于零, 此时新的hash表长度等于此时的阙值else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;else { // zero initial threshold signifies using defaults//表示是初始化时候,采用默认的 数组长度* 负载因子
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}//此时表示若新的阙值为0 就得用 新容量* 加载因子重新进行计算。if (newThr == 0) {float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}// 开始对新的hash表进行相对应的操作。
threshold = newThr;@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;if (oldTab != null) {//遍历旧的hash表,将之内的元素移到新的hash表中。for (int j = 0; j < oldCap/***此时旧的hash表的阙值*/; ++j) {
Node<K,V> e;if ((e = oldTab[j]) != null) {//表示这个格子不为空
oldTab[j] = null;if (e.next == null)// 表示当前只有一个元素,重新做hash散列并赋值计算。
newTab[e.hash & (newCap - 1)] = e;else if (e instanceof TreeNode)// 如果在旧哈希表中,这个位置是树形的结果,就要把新hash表中也变成树形结构,
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);else { // preserve order//保留 旧hash表中是链表的顺序
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;do {// 遍历当前Table内的Node 赋值给新的Table。
next = e.next;// 原索引if ((e.hash & oldCap) == 0) {if (loTail == null)
loHead = e;else
loTail.next = e;
loTail = e;
}// 原索引+oldCapelse {if (hiTail == null)
hiHead = e;else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);// 原索引放到bucket里面if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}// 原索引+oldCap 放到bucket里面if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}return newTab;
}

  • 示例:假设哈希桶数组长度为2,依次插入key为5、7、3,负载因子为1:
58e6b79dd7215d0130b737c9fbe06e84.png
  • JDK1.8对rehash的优化

1)n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果,key2元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化;

ee59c3cfecb070fbc2d7815a1d3b0de7.png
90f19f9988ad455967d1f6929ec7d92d.png

2)JDK1.8扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:

e989be4d8ee89c21dd9eec4e2f0599b3.png
  • 解决hash冲突的方法开放

1)地址法

#H(key)为哈希函数,m为哈希表表长,di为增量序列,i为已发生冲突的次数
Hi = (H(key) + di) MOD m,其中i=1,2,…,k(k<=m-1)

A:线性探查法(Linear Probing):di = 1,2,3,…,m-1

B:平方探测法(Quadratic Probing):di = ±12, ±22,±32,…,±k2(k≤m/2)

C:伪随机探测法:di = 伪随机数序列

2)再哈希法

#RHi()函数是不同于H()的哈希函数,用于同义词发生地址冲突时,计算出另一个哈希函数地址,直到不发生冲突位置;#这种方法不容易产生堆集,但是会增加计算时间
Hi = RHi(key), 其中i=1,2,…,k

3)建立公共溢出区

假设哈希函数的值域为[0, m-1],设向量HashTable[0,…,m-1]为基本表,每个分量存放一个记录,另外还设置了向量OverTable[0,…,v]为溢出表。基本表中存储的是关键字的记录,一旦发生冲突,不管他们哈希函数得到的哈希地址是什么,都填入溢出表;缺点:查找冲突数据的时候,需要遍历溢出表才能得到数据;

4)链地址法(拉链法)

将冲突位置的元素构造成链表;在添加数据的时候,如果哈希地址与哈希表上的元素冲突,就放在这个位置的链表上;优点:平均查找长度较短、适合造表前无法确定表长的情况、删除结点操作易于实现;缺点:需要额外的存储空间;

  • HashMap的加载因子为什么是0.75

1)冲突:填充程度高,空间利用率高,但是哈希冲突频繁;填充程度低,冲突发生率减少,但是空间利用率不高;加载因子表示Hash表的元素填充程度;

2)泊松分布:描述单位时间内随机事件发生的次数的概率分布;

3)在理想情况下,使用随机哈希码,在扩容阈值(加载因子)为0.75的情况下,节点出现在频率在Hash桶(表)中遵循参数平均为0.5的泊松分布;当一个Node中的链表长度达到8个元素的时候,概率为0.00000006,几乎是一个不可能事件;

4)加载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cache missing)按照指数曲线上升;

  • 为什么HashMap的长度是2的整数次幂

①加快哈希计算

<1>为了找打key的位置,需要进行hash(key)%length操作,但是%计算比&慢很多;

<2>使用&代替%有两个条件:求&的length比%的length要减一,即hash(KEY) & (length - 1)、length需要为2的整数次幂;

<3>根据&计算方法,元素哈希值不变;而通过 % 计算的方式会因为 length 的变化导致计算出来的 hash 桶的位置不断变化,数据一致在漂移,影响性能

②减少冲突

<1>length 为偶数时,length-1 为奇数,奇数的二进制最后一位是 1,这样便保证了 hash &(length-1) 的最后一位可能为 0,也可能为 1,即 & 运算后的结果可能为偶数,也可能为奇数,这样便可以保证散列的均匀性;

<2>length 为奇数时,length-1 为偶数,它的最后一位是 0,这样 hash & (length-1) 的最后一位肯定为 0,即只能为偶数,这样任何 hash 值都只会被散列到数组的偶数下标位置上,这浪费了近一半的空间;

  • JDK8对HshMap的优化

1)链表的长度超过了8,那么链表将转换为红黑树(桶的数量必须大于64,小于64的时候只会扩容);

2)发生hash碰撞时,java 1.7会在链表的头部插入,而java 1.8会在链表的尾部插入;

3)java 1.7的Entry被java 1.8的Node替代;

4)在插入时,java 1.7先判断是否需要扩容,再插入,java 1.8先进行插入,插入完成再判断是否需要扩容;

  • JDK1.7头插法扩容的问题

1)头插法会使链表发生反转,多线程环境下会产生环;

2)A线程在插入节点B,B线程也在插入,遇到容量不够开始扩容,重新hash,放置元素,采用头插法,后遍历到的B节点放入了头部,这样形成了环;

4147ec1ab78bd777f5f537cbde75e0c7.png
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值