HashMap
Hash理解(因为和hash相关,所以叫hashmap)
什么是hash
把任意长度的输入通过一个hash算法以后映射成为一个固定长度的输出。
问题:两个value值经过hash函数以后可能得到两个相同的hash值,也就是会发生hash冲突。
hash冲突可以避免吗?
答:理论上是不可以避免的,举个例子,抽屉原理,鸽巢原理,比如有10个苹果,9个抽屉,最终一定有一个抽屉里边的苹果数量是大于1的,是不可避免的,只能尽量避免。
好的hash算法的特点,要考虑些什么问题?
1效率高:对于长文本也能高效的计算出hash值
2不能逆推出原文?
3对于不同的输入,保证输出也是不同的
4hash出的值要尽可能分散,在table大部分的slot处于空闲状态的时候,尽可能降低hash冲突
hashMap底层的数据结构
jdk的底层是使用了数组+链表+红黑树实现。
每一个数据单元都是一个Node结构,Node结构中有key字段,有value字段,还有next字段,还有hash字段,hash字段用于解决hash冲突的时候的比较,next字段用于解决hash冲突的时候,当前桶位的node与冲突node连成一个链表要用的字段。
hashMap的初始数组长度
16
hashMap的初始化
散列表采用的是懒加载的模式,不会在new HashMap的时候进行加载,会在调用第一次put的时候进行加载。
HashMap map = new HashMap(); // 伪初始化
map.put(“键”,“值”); // 真初始化
在调用put函数的时候,如果发现table是null就调用resize()进行初始化
负载因子作用
0.75f
负载因子的作用:计算扩容的阈值
扩容时机
使用无参构造方法创建的HashMap,默认情况下的扩容阈值就是16*0.75 = 12
链表转化为红黑树时机
链表转化为红黑树的2个时机:
1链表长度等于8
2当前散列表数组的长度已经达到64
否则:就算slot的链表长度大于8,也不会从链表转为红黑树,而仅仅会触发一次散列表扩容,resize()
Node内部的hash字段是由key.hashCode()生成的吗?
Node内部的hash字段是由key.hashCode()生成的吗?不是的。
Hash值是由key对象的hashCode二次加工得到的。
加工原则:key的hashcode的高16位异或key的hashcode的低16位得到的一个新值
hash值采用hashcode的高低位异或的原因
主要是为了优化hash算法
HashMap的散列表数组通常情况下不会特别的大,这样的话,table.length()-1得到的二进制数的实际有效位很有限,一般都在低16位以内!!!这样的话key的hashCode的值的高16位就没有参与到与运算当中,就浪费掉了,没有起到作用,所以Node的hash字段采用了高16位和低16为相异或的方式去处理。
原因:hash寻址算法 table.length()-1得到的值一般是一个低16位有效的一个值
hash寻址算法
indexFor(hash,table.length())
return hash & ( table.length()-1)
满足0-length-1的数组下标
散列表的长度必须是2次方,比如说16 , 32 ,64
寻址算法: hash & ( table.length()-1)
table的长度是2的次方,转化为2进制以后,一定是高位是1,其他位是0, table.length()-1就是高位是0,其他低位是1;
例如:16 0001 0000 (1个1,4个0) 高位是1
32 0010 0000 (1个1 ,5个0)高位是1
这样的数字-1转化为的二进制也很特殊。16-1=15 转为的二进制就是4个1, 0000 1111
0000 1111与任何数与运算,高位全部都是0,低位都是一串1,按位与以后得到的数字是从0到这个二进制数的,>=0 <=这个二进制数,带上数组下标为0的slot,加起来正好是2的次方数。按照位与出来是 0 到 length-1 刚好 length个数。
hashMap的put方法
hashmap写数据的流程
详细版:
主要是分为了4种情况:
首先的寻址算法是一样的,通过key的hashcode经过高低位异或之后的值,然后按位与(table.length()-1),得到一个slot槽位的下标,根据槽内的状况不同,分为不同的状况,大概是4种状况
1 slot == null
2 slot != null ,引用的Node结点还没有链化(还没有冲突)
3 slot != null ,slot内部的结点已经链化了(已经冲突)
4 slot != null ,冲突很严重的情况下,链表已经转化为了红黑树(已经冲突)
1 当slot等于null的时候,将key value封装为 一个 Node 结点占领这个slot
2 对比一下这个node的key和当前put的key是不是完全相等的,如果相等的话,就是一个替换操作,把当前新的value值替换当前slot的value值就可以了,并且返回oldValue。
3 否则的话,这个put操作就是一个hash冲突的,如果当前是一个树结点的实例,直接加到树上
4 如果是一个链表,一个一个比较key值是否相等(具体的是先比较hash,后比较key值,再equal),如果相等的话就是一个替换操作,如果到最后没有相等的key,则直接在slot的最后一个node后边采用尾插法追加一个node,插入链表尾部就可以了,还需要检查一下当前链表长度有没有达到树化的阈值(8),如果达到的话就调用一个树化的方法,具体的树化的操作都在这个函数里边完成,函数里边还有一个条件,就是散列表数组的长度如果没有达到64的化,就只触发resize的扩容操作。
红黑树的写入操作
红黑树的性质
五大性质:
性质1:结点是黑色和红色的;
性质2:根结点要是黑色的;
性质3:所有叶子结点都是黑色的;(叶子结点NIL结点,这类结点不可忽视,否则代码看不懂)
性质4:每个红色结点必须有两个黑色子节点(从每个叶子到根的所有路径上不能有两个连续的红色节点。)红色节点下边不可能是红色的
性质5:从任一节点到其每个叶子的所有简单路径都包含相同数目的黑鱼节点(黑色平衡)。黑高
还有一个就是一定是红插,就是新插入的结点一定是红色的。因为红插在父节点是黑色的情况可以避免树的失衡,如果是黑插的话树就一定会失衡。
红黑树的左旋和右旋
左旋:旋转结点的右节点作为旋转结点的父节点,旋转结点的右节点的左孩子作为旋转结点的右孩子。
右旋:旋转结点的左孩子作为旋转结点的父节点,旋转结点的左孩子的右孩子作为旋转节点的左孩子。
HashMap红黑树结点结构
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
比Node多了parent指针,指向父节点,left和right指针,分别指向左右孩子,还有一个颜色属性,标识红黑树的颜色。
红黑树结点插入操作
1首先找到一个合适的插入点 ,就是找到插入结点的父节点,因为红黑树满足二叉排序树的特性(红黑树是一个二叉排序树),寻找父节点的过程和二叉排序树是完全一致的,二叉排序是就是小于根节点的在左边,大于根节点的在右边,而且每一颗二叉排序树的子树也是一棵二叉排序树,所以,每次向下查找一层就可以排除掉一半的结点,查找效率logn,效率高;
查找过程分情况
1:一直向下查找,直到查询到左子树或者右子树为null,说明整个树中没有发现和当前put进去的key一致的key值,此时,探测到的结点就是待插入结点的父节点所在了,然后根据插入节点的hash值和父节点hash值大小决定左右,插入到父节点的左边或者右边就可以了,但是结点的插入会破坏平衡,然后需要红黑树的平衡算法去调整树的平衡。
2:从根节点向下的探测过程中发现TreeNode的key和put的key完全一致,说明也是一次replace操作,直接替换这个节点的value值就可以了。
jdk8引入红黑树的原因
主要就是为了解决hash冲突导致链化严重:
jdk7。当slot链化严重的情况下,严重影响get的查询效率,本身散列表理想的查询效率是o(1),链化特别严重的时候,会导致查询效率变为o(n),严重影响了查询的性能,才引入了红黑树,红黑树就是一棵二叉排序树,提高了查询的效率。
为什么链化以后效率变低
因为链表不是数组,从内存的角度看,没有连续,如果要查询的数据在链表的末尾,只能一个结点一个结点的next查找过去,十分耗费性能。
hashMap的扩容机制
扩容触发:写数据的时候会触发扩容,有一个扩容的阈值,threshold,达到阈值就会扩容。
hashMap的扩容操作
因为table的长度必须为2的次方,每次都是按照上一次的tableSize左移一位得到的,假设当前的tablesize是16,左移一位就是32,为什么移位,不直接乘以2,性能原因,因为cpu毕竟不支持乘法运算,所有的乘法运算在底层的指令都转化为了加法实现的,效率很低如果采用位运算的话就很高效。
老数组是怎么迁移的?
得到老数组的table的大小,如果是0的话新的table设置为默认的大小16和容量并返回。
得到老数组的table的大小,如果大于0的话,则一个桶位一个桶位的处理,主要看当前桶位的数据状态, 分为四种状态:
状态一:solt为null
状态二:slot不为null,存储的是一个node,但是还没有链化
状态三:slot不为null,存储的node链化了,
状态四:slot不为null,存储的是一个树的根节点TreeNode,
情况一不用处理
情况二:如果发现slot中的存储的是一个node结点,它的next是一个null的时候,说明没有hash冲突,直接迁移就好了,根据hash值和新表的tableSize计算出它在新表中的下标位置,然后存放过去就可以了。
情况三:如果发现node的next字段不是null,说明slot发生过hash冲突,需要把slot中保存的链表拆分为两个链表,分别是高位链和低位链,我们知道老的slot桶中的链表已经链化了,就能推理出这个链表中所有的node的hash字段转化为二进制以后,低位都是相同的,低位指的就是老表的tableSize-1转化出来的二进制的有效位数,16 16-1=15 0000 1111说明低位是低4位,高位是第5位,说明这个链表的低位是相同的,但是高位不一定,有些node的高位是0 , 有些node的高位是 1,这块对应的node迁移到新表中,所存放的slot位置是不一样的,低位链因为高位是0,,所以说迁移到node新表的时候,这个slot的下标和老的是一样的,不同的是拆分出来的高位链,需要看一下它的长度,如果长度小于等于6的话直接将TreeNode转化为普通的node链表,放到扩容后的新表,如果拆分出来的链表长度仍然是大于6的话,还是需要把链表升级为红黑树(重建红黑树),