HashMap
HashMap是我们常用的数据结构,由数组和链表组合构成的数据结构。
数组里面每个地方都存了key-value这样的实例,在java7中叫Entry 在java8中叫Node。
每一个节点都会保存自身的hash、key、value、以及下一个节点。
新的Entry节点在插入链表的时候是怎么插入的?
Java8之前是头插法,就是说新来的值会取代原有的值,原有的值就顺推到链表中去,因为写这个代码的作者认为后来的值被查找的可能性更大一点,提升查找的效率。 但是在java8之后,都是所有的尾部插入了。
为啥会尾部插入呢?
头插法在多线程环境下容易引起死循环,和数据丢失。https://mp.weixin.qq.com/s/VtIpj-uuxFj5Bf6TmTJMTw
HashMap的数据容量是有限的,数据多次插入,到达一定数量就会进行扩容,也就是resize。
决定扩容的两个因素:
1.Capacity:HashMap当前长度
2.LoadFactor:负载因子,默认值0.75f
(比如当前容量大小为100,当你存进第76个的时候,判断发现需要进行resize了,那就进行扩容,但是HashMap的扩容也不是简单的扩大点容量那么简单)。
扩容?怎么扩容?
分为两步:
1.扩容:创建一个新的Entry空数组,长度是原数组的2倍。
2.ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。
为什么要重新Hash呢,直接复制过去不香吗?
因为长度扩大以后Hash的规则也随之改变。
(Hash的公式:HashCode(Key) & (Length - 1))
为啥会尾部插入呢?
举个栗子:
我们现在往一个容量大小为2的数组里,put两个值,负载因子是0.75,当我们put第二个的时候就会进行扩容(resize)
现在我们要在容量为2的容器里面用不同线程插入A、B、C三个值,假如我们在resize之前打个断点,那意味着数据都插入了,但是还没有resize那扩容之前可能就是这样的:
因为resize的赋值方式,也就是使用了单链表的插入方式,同一位置上的新元素总会被放在链表的头部位置,在旧数组中,同一条entry链上的元素,通过重新计算索引位置后,可能会放到了新数组的不同位置上。
就可能出现下面的情况:
B的下一个指针指向了A
一旦几个线程都调整完成,就可能出现环形链表。
Java8之后链表有红黑树的部分,红黑树的引入巧妙的将原本O(n)的时间复杂度降低到了O(logn)
使用头插会改变链表上的顺序,但如果使用尾插,在扩容时会保持链表原本的顺序,就不会出现链表成环的问题。
Java7在多线程操作HashMap时可能引起死循环,原因是扩容转移后前后链表顺序倒置,在转移过程中修改了原来链表中节点的应用关系。
Java8在同样的前提下并不会出现死循环,原因是扩容转移前后链表顺序不变,保持之前节点的引用关系。
那是不是意味着Java8就可以把HashMap用在多线程中呢?
即使不会出现死循环,但通过源码看到put/get方法都没有加同步锁,多线程情况最容易出现的就是:无法保证上一秒put的值,下一秒get的时候还是原值,所以线程安全还是无法保证。
HashMap的默认初始化长度是16 为什么是16?
1<<4 = 16
16是2的幂
为了位运算的方便,位与运算比算数计算的效率高了很多选者6是为了服务Key映射到index的算法
Index的计算公式 : index = HashCode(Key) & (Length- 1)
15 = 16-1
15的二进制1111
做&运算 只要看最后四位的值
为啥是16不用别的?
因为在使用不是2的幂的数字的时候,Length-1的值是所有二进制位全位1,这种情况下,index的结果等同于hashcode后几位的值。
只要输入的hashCode本身分布均匀,Hash算法的结果就是均匀的。
为了实现均匀算法。
为什么我们重写equals方法的时候需要重写hashcode方法?
在java中,所有的对象都是继承于Object类。Object类中有两个方法equals、hashCode,这两个方法都是用来比较两个对象是否相等的。
在未重写equals方法,我们是继承了object和equals方法,那里的equals是比较两个对象的内存地址。
HashMap是通过key在hashCode去寻找index的,那index一样的就形成了链表了,如果容量是16,也就是hashcode后四位相等的值会在同一个链表上。
我们去get的时候,他就是根据key去hash然后计算出index。
然后在同一个链表上,通过equals去寻找具体是哪一个。
如果我们对equals方法进行了重写,建议一定要对hashCode方法重写,以保证相同的对象返回相同的hash值,不同的对象返回不同的hash值。
HashMap是线程不安全的,如何处理HashMap在线程安全的场景?
使用HashTable 或者 ConcurrentHashMap
因为前者的并发度的原因,基本上没有啥使用场景,所以线程不安全的场景基本上都是使用ConcurrentHashMap
HashTable我看过他的源码,很简单粗暴,直接在方法上锁,并发度很低,最多同时允许一个线程访问
为什么HashMap的默认负载因子是0.75
HashMap只是一个数据结构,既然是数据结构最主要的就是节省时间和空间。负载因子的作用肯定也是节省是时间和空间。
考虑几种极端情况
负载因子是1.0
HashMap的底层数据结构:
我们的数据一开始是保存在数组里面的,当发生了Hash碰撞的时候,就在这个数据节点上,生出一个链表,当链表长度达到一定的长度的时候 就会把链表装欢为红黑树。
当负载因子是1.0的时候,也就意味着,只有当数组的8个值(这个图表示了8个)全部填充了,才会发生扩容。
这就带来了很大的问题,因为Hash冲突是避免不了的。当负载因子是1.0的时候,意味着会出现大量大Hash的冲突,底层的红黑树变得异常得复杂。对于查询效率及其不利。这种情况就是牺牲了时间来保证空间的利用率。
==》负载因子过大,虽然空间利用率上去了,但是时间效率降低了。
负载因子是0.5
负载因子是0.5的时候,意味着,当数组中的元素达到了一半就开始扩容,既让填充的元素少了,Hash冲突也会减少,那么底层的链表长度或者是红黑树的高度就会降低。查询效率就会增加。
但是这个时候空间利用率就会大大降低,原本存储1M的数据,现在就意味着需要2M的空间。
负载因子是0.75
是时间和空间上的权衡。源码上有介绍,大致说:负载因子是0.75的时候,空间利用率 比较高,而且避免了相当多的Hash冲突,使得底层的链表或者红黑树的高度比较低,提升了空间效率。
JDK 1.8 进行了优化,当链表长度较大时(超过8),会采用红黑树来存储,这样大大提高了查询效率。