一、hashmap底层实现结构
在jdk1.7以前,hashmap的底层数据结构的实现是数组 + 链表的实现方式。
但是在1.8之后hashmap的实现是数组 + 链表 + 红黑树
在了解hashmap实现原理之前,我们先要知道:
- hashmap数据底层具体存储的是什么?
- 这样的存储方式有什么优点?
1.1、hashmap数据底层具体存储的是什么
从源码可知,hashmap类中有一个非常重要的字段,就是node[] table,即哈系桶数组,明显它是一个node 数组,在jdk1.8中的实现如下:
static class node<k,v> implements map.entry<k,v> { final int hash; //⽤来定位数组索引位置 final k key; // // 当前节点的 key v value; // 当前节点的 value node<k,v> next; //链表的下⼀个node,在整个节点对象中, 仅有一个 next 节点, 说明该链表是一个单向链表 node(int hash, k key, v value, node<k,v> next) { … } public final k getkey(){ … } public final v getvalue() { … } public final string tostring() { … } public final int hashcode() { … } public final v setvalue(v newvalue) { … } public final boolean equals(object o) { … } }
node是hashmap的一个内部类,实现map.entry接口,本质上就是一个键值对的类型。
1.2、这样的存储方式有什么优点
hashmap就是使用哈希表来存储的。哈希表为解决冲突,可以采用开放地址法和链地址法等来解决 问题, java中hashmap采用了链地址法。链地址法,简单来说,就是数组加链表的结合。
在每个数组元 素上都一个链表结构,当数据被hash后,得到数组下标,把数据放在对应下标元素的链表上。
例如程序 执行下面代码知:
map .put("美团" ,"⼩美");
系统将调用"美团"这个key的hashcode()方法得到其hashcode 值,该方法适用于每个java对象
然后再通过hash算法的后两步运算样(高位运算和取模运算)来定位该键值对的存储位置,有时两个key会定位到相同的位置,表示发生了hash碰撞。当然hash算法计算结果越分散均匀, hash碰撞的概率就越小,map的存取效率就会越高。
如果哈希桶数组很大,即使较差的hash算法也会比较分散,如果哈希桶数组数组很小,即使好的hash算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少hash碰撞。那么通过什么方式来控制map使得hash碰撞的概率又小,哈希桶数组(node[] table)占用空间又少呢?答案就是好的hash算法和扩容机制。
在理解hash和扩容流程之前,我们得先了解下hashmap的几个字段:
int threshold; // 所能容纳的key-value对极限 final float loadfactor; // 负载因⼦ int modcount; int size;
loadfactor
:负载因子- 默认为0.75 ,是决定扩容阈值的重要因素之一
threshold
:扩容的阈值- 扩容阈值的计算公式:
threshold = length * loadfactor
,length为数组的长度 - threshold就是在此loadfactor和length(数组长度)对应下允许的最大元 素数目,超过这个数目就重新resize(扩容) ,扩容后的hashmap容量是之前容量的两倍
modcount
:记录内部结构发生变化的次数- 内部结构发生变化指的是结构发生变化,例如put新键值 对,但是某个key对应的value值被覆盖不属于结构变化
size
:hashmap中存在的键值对数量- 注意和table的长度length 、容纳 最大键值对数量threshold的区别
这里存在一个问题,即使负载因子和hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出 现拉链过长,则会严重影响hashmap的性能。于是,在jdk1.8版本中,对数据结构做了进一步的优 化,引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删 改查的特点提高hashmap的性能,其中会用到红黑树的插入、删除、查找等算法。
二、功能实现
2.1、确定哈希桶数组索引位置
不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。前面说过hashmap的 数据结构是数组和链表的结合,所以我们当然希望这个hashmap里面的元素位置尽量分布均匀些,尽 量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道 对应位置的元素就是我们要的,不用遍历链表,大大优化了查询的效率。 hashmap定位数组索引位 置,直接决定了hash方法的离散性能。
先看看源码的实现(方法一+方法二):
⽅法⼀: static final int hash(object key) { //jdk1.8 & jdk1.7 int h; // h = key.hashcode() 为第⼀步 取hashcode值 // h ^ (h >>> 16) 为第⼆步 ⾼位参与运算 // 1001 0111 0011 0101 1101 1110 1001 1111 => hash code // 0000 0000 0000 0000 1001 0111 0011 0101 => 右移 16 位 // 1001 0111 0011 0101 0100 1001 1010 1010 => 异或运算 // 让同一个 key 的高位与地位都参与 hash 运算 // 目的: 降低 hash 碰撞的概率 // 最终返回一个 异或运算 后的一个更混乱的 hash 值 return (key == null) ? 0 : (h = key.hashcode()) ^ (h >>> 16); } ⽅法⼆: static int indexfor(int h, int length) { //jdk1.7的源码,jdk1.8没有这个⽅法,但是实现原理⼀样的 return h & (length-1); //第三步 取模运算 }
对于任意给定的对象,只要它的hashcode()返回值相同,那么程序调用方法一所计算得到的hash码值总是相同的。我们首先想到的就是把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的,在hashmap中是这样做的:调用方法二来计算该对象 应该保存在table数组的哪个索引处。
这个方法非常巧妙,它通过h & (table.length -1)
来得到该对象的保存位,而hashmap底层数组的长度总 是2的n次方,这是hashmap在速度上的优化。当length总是2的n次方时, h& (length-1)
运算等价于对 length取模,也就是h%length
,但是&比%具有更高的效率。
在jdk1.8的实现中,优化了高位运算的算法,通过hashcode()的高16位异或低16位实现的知(h = k.hashcode()) ^ (h >>> 16)
,主要是从速度、功效、质量来考虑的,这么做可以在数组table的length 比较小的时候,也能保证考虑到高低bit都参与到hash的计算中,同时不会有太大的开销。
下面举例说明下, n为table的长度:
2.2、hashmap的put方法
hashmap的put方法执行过程可以通过下图来理解:
下面是jdk1.8 的hashmap put方法源码
public v put(k key, v value) { return putval(hash(key), key, value, false, true); }
实际上是putval
方法:
final v putval(int hash, k key, v value, boolean onlyifabsent, boolean evict) { node<k, v>[] tab; // node 数组 => hash 表, 额外的变量 node<k, v> p; // 当前 key 存放索引位置的元素 int n, // table 数组的长度 i; // 当前 key 经过运算后的索引位置 => (长度 - 1) & hash // 如果 table 数组为 null 或数组的长度为 0, 就进行扩容 => 初始化 if ((tab = table) == null || (n = tab.length) == 0) // 第一次扩容实际就是 table 的初始化 n = (tab = resize()).length; // 索引 = (长度 - 1) & hash // 例子: // hash = 1001 0111 0011 0101 0100 1001 1010 1010 // length - 1 = 0000 0000 0000 0000 0000 0000 0000 1111 // 索引 = 0000 0000 0000 0000 0000 0000 0000 1010 // 计算索引位置, 并访问该索引位置得到节点赋值给 p, 且判断 p 是否为 null if ((p = tab[i = (n - 1) & hash]) == null) // 说明索引位置没有值 // 如果该索引位置没有值, 就直接创建一个新的节点, 并将其存在这个位置上 tab[i] = newnode(hash, key, value, null); else { // 如果这个索引位置已经有值 node<k, v> e; // 上一个相同 key 的节点对象, 与本次存入的新的 key 完全相同的 node 对象 k k; // 当前索引位置上的 key // 判断当前索引位置的元素与新存入的 key 是否完全相同 // 第一种情况: 直接拿头节点进行比较, 不关心他是树节点, 还是链表节点 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) // 如果这个位置上的 key 与即将存入的 key 完全相同, 就直接将当前节点赋值给 e e = p; // 第二种情况: 判断是否是树节点, 也就是判断当前节点是否已经发生过 hash 冲突, 且已经变成红黑树 else if (p instanceof treenode) // 判断当前节点是否已经变成树节点 => 判断是否已经变成红黑树 // 往红黑树中存入当前新的 key/value, 并返回存入后的对象赋值给 e e = ((treenode<k, v>) p).puttreeval(this, tab, hash, key, value); else { // 第三种情况: 如果以上两者都不是, 就默认为链表, 判断是否有下一节点, 进行循环判断 for (int bincount = 0; ; ++bincount) { // 计数, 计算链表的长度 // 判断当前节点是否有下一个节点, 如果为 null if ((e = p.next) == null) { // 将即将存入的 key/value 存入一个新的节点, 并设置到当前节点的 next p.next = newnode(hash, key, value, null); // 判断当前链表长度是否大于树化的阈值(7) if (bincount >= treeify_threshold - 1) // -1 for 1st // 链表长度 >= 8, 那么就准备进行转成树 treeifybin(tab, hash); break; // 这次循环结束 } // 此时 e = 当前正在遍历的链表元素 // 判断该元素的 hash 与 key 是否与即将存入的 key 完全相同 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; // 如果相同, 直接结束循环 p = e; // 将当前遍历的节点, 赋值给之前索引位置的节点 } } // 如果找到了与本次要存入的 key 完全相同的节点, 直接将本次存入新的 value 替换旧节点的 value if (e != null) { // existing mapping for key v oldvalue = e.value; if (!onlyifabsent || oldvalue == null) // 如果配置了只有在不存在时才存入 或 原来的值为 空 e.value = value; // 将新的 value 覆盖原来旧的值 afternodeaccess(e); return oldvalue; } } ++modcount; // 并发修改数的记录 if (++size > threshold) // 存入键值对的数量+1, 且判断是否大于扩容阈值 resize(); // 扩容 afternodeinsertion(evict); return null; }
2.3、hashmap的扩容原理
扩容(resize)就是重新计算容量,向hashmap对象里不停的添加元素,而hashmap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更 多的水,就得换大水桶。
接下来就是jdk1.8的有关hashmap扩容的源码:
final node<k, v>[] resize() { // 将当前 map 中的 hash 表保存到 oldtab 变量 node<k, v>[] oldtab = table; // 再基于旧的数组长度得到容量, 如果旧的数组为 null(刚 new 的对象, 还没初始化数组), 返回容量为 0 int oldcap = (oldtab == null) ? 0 : oldtab.length; // 将当前的扩容阈值作为旧的扩容阈值 int oldthr = threshold; // 用于存储新的容量以及扩容阈值 int newcap, newthr = 0; // 已经初始化: 旧的容量肯定大于 0 if (oldcap > 0) { // 判断旧的容量是否已经大于最大容量 if (oldcap >= maximum_capacity) { // 如果大于, 就将阈值设置为 integer 的最大值 threshold = integer.max_value; // 并且直接返回旧的数组 return oldtab; /** * 下方计算扩容后的新容量以及新阈值 * 新的容量 = 旧的容量左移 1 位 === 旧的容量 * 2 * 判断: 如果新容量 < 最大容量 and 旧容量 >= 16 * 如果判断成立, 阈值也是 * 2 */ } else if ((newcap = oldcap << 1) < maximum_capacity && oldcap >= default_initial_capacity) // // 新的阈值为旧阈值的两倍 newthr = oldthr << 1; // double threshold } /** * 如果跳过上面的 if 没有进入, 走到下方任意一个逻辑, 都代表当前的 table 没有被初始化过 * 1. new hashmap(容量, 加载因子) * 2. new hashmap(容量) */ else if (oldthr > 0) // initial capacity was placed in threshold // 将旧的阈值作为新的容量 newcap = oldthr; else { // zero initial threshold signifies using defaults // new hashmap() => 进入这个逻辑 // 默认新的容量为 16 newcap = default_initial_capacity; // 新的阈值 = 默认的负载因子(0.75) * 默认容量(16) newthr = (int) (default_load_factor * default_initial_capacity); } // 如果在上方的 if 判断中, 进入的是中间的逻辑, 那么就会进入下方的逻辑 if (newthr == 0) { // 临时阈值 = 新的容量 * 负载因子 float ft = (float) newcap * loadfactor; // 新的阈值或新的容量 > 最大值, 就返回 integer 的最大值 // 否则就为上面计算的临时阈值 newthr = (newcap < maximum_capacity && ft < (float) maximum_capacity ? (int) ft : integer.max_value); } // 将计算后的新阈值, 覆盖当前 map 中的阈值变量 threshold = newthr; // 基于新的容量创建一个新的 node 数组 @suppresswarnings({"rawtypes", "unchecked"}) node<k, v>[] newtab = (node<k, v>[]) new node[newcap]; // 直接将新创建的数组覆盖当前 map 中的数组 table = newtab; // 已经初始化过了, 肯定就不会为 null, 同样也说明如果未初始化, 该逻辑就不会执行 if (oldtab != null) { // 对数组旧的容量进行遍历 for (int j = 0; j < oldcap; ++j) { // e == 当前遍历的元素 node<k, v> e; if ((e = oldtab[j]) != null) { // 将当前位置重置为 null oldtab[j] = null; // 判断当前是否为一个链表, 如果没有下一个说明该元素就是单个节点, 还未变成链表 if (e.next == null) // 重新计算该元素在新数组中的索引位置, 并将该元素存入新数组 newtab[e.hash & (newcap - 1)] = e; // 如果当前的 e 已经是红黑树的根节点 else if (e instanceof treenode) // 如果是红黑树, 就将这棵树做切割, 如果切割后单颗树的长度 < 6 会重新转换为链表 ((treenode<k, v>) e).split(this, newtab, j, oldcap); else { // preserve order // 既不是单个节点, 也不是红黑树, 就一定是链表 // 对链表进行拆分, 拆成高位链表与地位链表两个 // 下方定义的变量为低位链表的 头/尾 节点 node<k, v> lohead = null, lotail = null; // 下方定义的变量为高位链表的 头/尾 节点 node<k, v> hihead = null, hitail = null; // 临时变量, 用于作为遍历链表时的下一个节点 node<k, v> next; do { next = e.next; // 将当前元素的 hash 与旧数组长度做 & 运算 // 实际是在拿 hash 的第5位数与旧数组长度的第五位数做 & 运算 // 最终得到的结果, 要么就是 0, 要么就是 1 // 如果最终结果为 0: 那么该元素就会被存入低位链表 // 如果最终结果为 1: 那么该元素就会被存入高位链表 if ((e.hash & oldcap) == 0) { if (lotail == null) // 如果当前链表还没初始化, 意味着尾节点不存在 lohead = e; // 如果尾节点不存在, 那么基于尾插法, 就是将当前节点直接作为低位链表的头节点 else lotail.next = e; // 如果已经初始化过了, 就将当前元素直接插入到链表的最后 lotail = e; // 并且将当前节点设置为尾节点 } else { if (hitail == null) // 如果当前尾节点不存在, 说明链表还没初始化 hihead = e; // 因此将当前节点作为头节点 else hitail.next = e; // 如果已经初始化了, 就将当前节点作为链表的最后一个节点 hitail = e; // 同时由于是尾插法, 索引每次移动的这个元素一定是链表的最后一个元素, 因此直接作为尾节点 } } while ((e = next) != null); // 如果还有下一个节点, 就继续遍历 if (lotail != null) { // 判断低位的尾不为空 ==> 实际就是在判断低位链表是有值的 lotail.next = null; // 将尾的下一个设置为 null newtab[j] = lohead; // 将低位链表的头节点, 重新连接到新数组的当前位置 } if (hitail != null) { // 如果高位链表为空 ==> 实际就是在判断高位链表是有值的 hitail.next = null; // 将高位链表的尾节点设置为 空 newtab[j + oldcap] = hihead; // 将高位链表的头节点设置到新数组的当前位置 + 旧的长度的位置去 } } } } } return newtab; }
当初看源码的时候不清楚这里为什么要将链表拆分为高位链表和低位链表
后面查资料才发现hashmap在扩容的时候是扩容到原来的2倍,所以,元素的位置要么在原位置,要么就是根据原位置再移动2次幂的位置(也就是原位置两倍的地方),所以就是因为有这两个区别,所以在对元素重新进行索引的计算的时候分了高位链表和低位链表。
而元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色) ,因此新的index就会发生这样的变化:
因此,我们在扩充hashmap的时候,不需要像jdk1.7的实现那样重新计算hash ,只需要看看原来的 hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成原索引+oldcap
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持代码网。
发表评论