HashMap 的数据结构(重点)
数组 + (链表或红黑树)
- HashMap由 数组 + 链表 +红黑树 构成的,数组是HashMap的主体,以键值对的方式存储
什么时候创建数组/链表
HashMap<String,String> hashMap = new HashMap<String,String>();
形成数组和链表的时间
**数组 **—— 查找时间复杂度 O(1)
-
在jdk7,构造方法中创建一个长度是16的 Entry[]table 用来存储键值对数据的。
在jdk8以后,是在第一次调用
put()
方法时创建的数组
**链表 **—— 解决hash冲突,O(n)
-
发生hash冲突时形成链表,新元素存放在最后
所以当数组容量使用超过 16*0.75=12 的时候就会进行数组扩容,减少hash冲突提高查询效率
红黑树 —— 提高查询效率 O(logn)
-
jdk8之后,当链表长度大于8,数组容量大于64时会根据当前链表形成红黑树
当红黑树中节点小于6时会转换回链表
为什么要在 put 的时候创建数组
-
数组在内存中是一段连续的内存空间(存放在堆中)
-
在 put() 时创建可以避免内存的浪费
HashMap存储过程
- 根据key计算一个hash值
- 在 put 的时候创建默认容量为 16 的数组
怎么确定键值对在数组中的下标位置:
hash&(length-1)
目标:尽量减少碰撞,把数据分配均匀
需要用到 hash 值和数组最大索引值,两者的与运算&
- 可以通过hashcode值和数组长度取模
hash%length
定位到要存储的下标,- 但是直接求余效率不如位移运算。所以源码中做了优化,使用
hash&(length-1)
,而实际上hash%length == hash&(length-1)
的前提——length是2的n次幂
- 但是直接求余效率不如位移运算。所以源码中做了优化,使用
确定索引位置后有三种情况
- 该位置为空,直接创建 Node节点, 将元素放进去
- 该位置有值(有可能形成链表)
- 判断链表的长度,是否需要转换为红黑树
- 链表的长度大于8
- 数组的容量大于64(如果小于64,只会进行数组扩容)
- 通过 equal() 比较,如果两者key相等则直接覆盖,如果不等则在最后存储该元素(由数组结构变成了数组+链表结构)
- 判断链表的长度,是否需要转换为红黑树
- 该位置为红黑树
- 以 Node 的方式存入
jdk8 加入红黑树的目的
目的:可以提高查找效率,避免在jdk7 的时候可能发生的循环链表
- 空间:树节点所占空间是普通节点的两倍,所以只有当节点足够多的时候,才会使用树节点。
- 节点的分布频率会遵循泊松分布,链表长度达到8个元素的概率为0.00000006,几乎是不可能事件.
- 时间:遍历的时间复杂度:链表O(n) < 红黑树O(logn)
- 链表平均查找长度为8/2=4,树查找长度为log(8)=3,这才有转换成树的必要
面试题 1、扩容什么都是2的次幂
面试题:数组长度为什么必须是2的n次幂?如果输入值不是2的幂比如10会怎么样?
hash&(length-1)
hash值和数组的最大索引值进行与运算
问题1:数组长度为什么是2的n次幂
- 为了数据的的均匀分布,减少hash冲突
- 如果数组长度不是2的n次幂,计算出的索引特别容易相同,及其容易发生hash碰撞,导致其余数组空间很大程度上并没有存储数据,链表或者红黑树过长,效率降低
2的n次方实际就是1后面n个0,2的n次方-1实际就是n个1;
假设长度为8(2^3)
2&(8-1)
0000 0010
0000 0111
----------
0000 0010 索引:2
3&(8-1)
0000 0011
0000 0111
----------
0000 0011 索引:3
假设修改长度为9
2&(9-1)
0000 0010
0000 1000
----------
0000 0000 索引:0
3&(9-1)
0000 0011
0000 1000
----------
0000 0000 索引:0
小结
- 当我们根据key的hash确定其在数组的位置时,如果n为2的幂次方,可以保证数据的均匀插入,如果n不是2的幂次方,可能数组的一些位置永远不会插入数据,浪费数组的空间,加大hash冲突。
- 一般会通过
%
求余来确定位置,只不过性能不如按位与&
运算。而且当n是2的幂次方时:hash&(length-1)==hash%length
- 因此,HashMap 容量为2次幂的原因,就是为了数据的的均匀分布,减少hash冲突,毕竟hash冲突越大,代表数组中一个链的长度越大,这样的话会降低hashmap的性能
问题2:输入值为10时,会发生什么?
通过HashMap构造方法指定数组大小为n,会通过移位和或运算,找比n大的最小2 的幂次
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
-
cap - 1
防止给定的数恰好是2的幂次 -
说明:int 四个字节,32位 int n = cap - 1; n=10-1=9 00000000 00000000 00000000 00001001 -->9 n |= n >>> 1; 00000000 00000000 00000000 00001001 00000000 00000000 00000000 00000100 右移1位 00000000 00000000 00000000 00001101 或操作 -->13 n |= n >>> 2; 00000000 00000000 00000000 00001101 -->13 00000000 00000000 00000000 00000011 右移2位 00000000 00000000 00000000 00001111 或操作-->15 n |= n >>> 4; //后续都为15 n |= n >>> 8; n |= n >>> 16;
-
容量最大也就是32bit的正数,因此最后n=n>>>16;最多也就32个1(最高位符号位,为负数),恰好给定最大值230,已经提前做了判断,结果不会大于230次
面试题 2、HashMap的线程安全问题发生在哪个阶段
线程不安全的原因:多个线程同时访问一个资源
很多位置都会出现线程安全问题,主要问题都是出现在,HashMap底层在操作每个数组位置时都是将节点头拿下来进行操作,操作后再将节点头放回去这样就会导致两个线程同时获取相同的节点头先放上去节点头的线程被后放上去的覆盖导致线程安全问题,在添加时也会出现同时获取到最后一个元素先添加的next节点被后添加的覆盖导致线程安全问题。
面试题3、HashMap线程不安全
- 在jdk1.7中,在多线程环境下,扩容时会造成环形链(死循环)或数据丢失。
- 在put时,触发扩容rehash(),会重新计算每个元素在新数组中的位置,
- 在多线程的环境下,存在同时其他的元素也在进行put操作,如果hash值相同,可能出现同时在同一数组下用链表表示,造成闭环,导致在get时会出现死循环
- 在jdk1.8中,在多线程环境下,会发生数据覆盖的情况
- 在put的时候,由于put操作不是原子性的,线程A在计算好链表位置后,挂起,线程B正常执行put操作,之后线程A恢复,会直接替换掉线程b put的值 所以依然不是线程安全的