概述
HashMap是我们非常常用的数据结构,由数组和链表组合构成的数据结构。
大概如下,数组里面每个地方都存了Key-Value这样的实例,在Java7叫Entry在Java8中叫Node。
因为他本身所有的位置都为null,在put插入的时候会根据key的hash去计算一个index值。
哈希冲突
当遇到哈希冲突,可以使用链地址法:
每一个节点都会保存自身的hash、key、value、以及下个节点,我看看Node的源码。
哈希冲突时节点如何插入链表
java8之前是头插法,就是说新来的值会取代原有的值,原有的值就顺推到链表中去,因为写这个代码的作者认为后来的值被查找的可能性更大一点,为了提升查找的效率。
但是,在java8之后,都是所用尾部插入了。
为什么?我们首先看一下HashMap的扩容机制:
- 扩容机制
数组容量是有限的,数据多次插入的,到达一定的数量就会进行扩容,也就是resize。
他取决于两个因素:
HashMap当前长度
负载因子
:默认是0.75
怎么理解呢,就比如当前的容量大小为100,当你存进第76个的时候,判断发现需要进行resize了,那就进行扩容,但是HashMap的扩容也不是简单的扩大点容量这么简单的。
分为两步
:
1.扩容:创建一个新的Entry空数组,长度是原数组的2倍。
2.ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。
需要再散列是因为Hash的公式—> index = HashCode(Key) & (Length - 1),是和数组长度有关系的,长度变为2倍,自然规则也就变了。 - 回到问题为什么不用头插法了
现在我们要在容量为2的容器里面用不同线程插入A,B,C,假如我们在resize之前打个短点,那意味着数据都插入了但是还没resize那扩容前可能是这样的。
因为再散列到新数组对于哈希冲突也是采用头插法解决的,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。
就有可能导致链表环状。
但是JDK1.8之后采用的尾插法虽然不会导致环状,但是通过源码看到put/get方法都没有加同步锁,多线程情况最容易出现的就是:无法保证上一秒put的值,下一秒get的时候还是原值,所以线程安全还是无法保证。
HashMap默认容量
我记得我在看源码的时候初始化大小是16
你那知道为啥是16么?
在JDK1.8的 236 行有1<<4就是16,为啥用位运算呢?直接写16不好么?
这样是为了位运算的方便,位与运算比算数计算的效率高了很多,之所以选择16,是为了将Key映射到index的算法。
我前面说了所有的key我们都会拿到他的hash,但是我们怎么尽可能的得到一个均匀分布的hash呢?
是的我们通过Key的HashCode值去做位运算。
之所以用位与运算效果与取模一样,性能也提高了不少!
那为啥用16不用别的呢?
因为在使用不是2的幂的数字的时候,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。
只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。
这是为了实现均匀分布。
为啥重写equals方法的时候需要重写hashCode方法呢?
因为在java中,所有的对象都是继承于Object类。Object类中有两个方法equals、hashCode,这两个方法都是用来比较两个对象是否相等的。
HashMap是通过key的hashCode去寻找index的,那index一样就形成链表了,那怎么找到链表中具体某一个对象呢?
equals!是的,所以如果我们对equals方法进行了重写,建议一定要对hashCode方法重写,以保证相同的对象返回相同的hash值,不同的对象返回不同的hash值。
不然一个链表的对象,你哪里知道你要找的是哪个,到时候发现hashCode都一样,这不是完犊子嘛。
与红黑树的关系
HashMap是有数组链表结构组成的,那么每一个链表就称为桶。
在JDK1.8中,桶满时就会从链表变成红黑树,并且,我们的TreeSet、TreeMap底层都是红黑树来实现的。
- 特征
1.红黑树是二叉搜索树。
2.根节点是黑色。
3.每个叶子节点都是黑色的空节点(NIL节点)。
4.每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
5.从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点(每一条树链上的黑色节点数量(称之为“黑高”)必须相等)。
红黑树的搜索、插入、删除时间复杂度都是O(logn)。