引言:是否还在面试过程中被问到HashMap数据结构而被难住,来看看这篇吧!如有不足敬请斧正!
1.数据结构模型
数组 + 链表 + 红黑树(JDK 1.8引入)
初始化时为数组,插入时存在冲突,引入链表,链表查询慢引入红黑树。
2.JDK1.8引入红黑树只是为了效率吗?
不仅仅。HashMap会导致DOS攻击,可以搜索:线程安全的场景下;CVE-2011-4858;Tomcat邮件组的讨论时间。查看一种:没想到 Hash 冲突还能这么玩,你的服务中招了吗? - 开发者头条 。 如果黑客在get后大量拼接hash冲突的键值对,tomcat键值存储时,形成链表,查询聊表效率低下,分分钟让CPU爆掉宕机!(扩展:服务器出现DOS,cpu 100%。kill -9不合适,重启不合适,别人依旧会再次攻击,top、jstack、jmap、atrhas命令,JVM性能调优,JVM性能排查,确定哪个线程,哪段代码有问题)
JDK8前为了避免上述问题,tomcat给参数长度设置了最大长度,JDK8后引入红黑树避免链表长度过长。
对于链表的查询效率,可以简单的使用:List<String> list = new ArrayList<>();List<String> list = new LinkedList<>();测试效率。
/***
* HashMap先比较hashcode,然后比较equals方法看是否是同一个对象。是就覆盖key的旧值,否则链表新插入。
* hashcode相同,且equals为true
*/
@Test
public void hashMap(){
Map<String,String> hashMap = new HashMap<>();
List<String> list = Arrays.asList("Aa","BB","C#");
for(String s : list){
//2112 2112 2112 哈希值相同,构成链表
System.out.println(s.hashCode());
hashMap.put(s,s);
}
for(String key : hashMap.keySet()){
//Aa,Aa BB,BB C#,C#
System.out.println(key + "," + hashMap.get(key));
}
}
3.为什么是红黑树?
红黑树,属于二叉树,是二叉排序树,为什么不是平衡二叉树呢?树里面查询效率最高的是完全平衡的二叉树,但是比如10.11.12,11在10右边,12在11右边,这不是树,组成树树结构形式下那么10在11左下边,但是树的旋转也是十分消耗性能,红黑树尽量减少旋转属于折中方案,遵守最小长度min和最长长度:max <= 2min。
4.HashMap的哈希方法
Object的hashCode方法,调用了native本地(jvm实现,下载hotspot源码可查看)的hashCode(),
而HashMap定义了hash方法:
/***
* HashMap的哈希方法
* static final int hash(Object key) {
* int h;
* return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
* }
* 拆解:
*/
public int hash(Object key){
int h;
if(key == null){
return 0;
}else{
h = key.hashCode();//哈希值,int 4个字节32位
int temp = h>>>16; //右移16位
int newHash = h ^ temp; //异或运算(不同为1)
return newHash;
}
}
代码的拆解:
hash = hash(Object key)=hashCode^(hashCode>>>16)是为了什么?
查看源码,是为了计算下标,得到的值作为数组的定位,hash & (n-1)得到数组下标。
为啥hash & (n-1)这样计算可以作为数组下标?
一般计算下标的方法:hash%n取模,n为数组长度。但是这样需要多次运算,让得到的结果小于n。HashMap中对这种处理做了优化。
hash & (n-1)与hash%n为啥可以等效?
等效的前提是n为2的n次幂,HashMap的初始数组容量为16=2^4=10000,(n-1)=15=01111.
hash&(n-1)=hash&00000000000000000000000000001111,在与运算中,前28位无效,与之后的结果肯定是0,那么看低4位,所以hash&(n-1)的结果就是(0000,1111)=(0,15)正好是下标的取值范围。
为什么要右移16位?
让高位也参与运算,避免比较大的数据,高位不同,低位相同,那么下标都是低位计算,都是一样的。
为什么要用异或?
假如用&与,或者用非|,和异或^进行概率统计:
可以得出结论:HashMap的hash()算法是为了计算数组定位,避免hash冲突,使计算的数组下标更均匀,从而链表比较少,更高效。
5.数组的容量为什么是2的整数次幂?
h(k) = k mod m,除法散列发,在《算法导论》中,推荐我们m不应是2的整数幂,因为m=2^p,则h(k)就是k的p个最低位数字,除非我们已经知道各种最低P位的排列是等可能的,否则我们最好慎重的选择m,而一个不太接近2的整数幂的素数,往往是较好的选择。
因为在hash算法中已经使用了hash^(hash>>>16)优化,为了效率的同时,违背了算法导论。
在hashtable中,初始容量是11,遵循了算法导论。
6.扩容因子(元素的填充因子、加载因子 )为什么是0.75?
扩容因子0.75就是元素占用率达到0.75,数组长度就扩容。
HashMap数组初始大小16,但是hash算法就是为了避免哈希冲突,不让产生大量的链表和红黑树。
扩容因子为1,空间利用率很高,但是意味填满了,很有可能有几个形成了链表,查询成本高,扩容因子为0.5利用率太低,综合考虑选取0.75。
7.HashMap树化参数为什么是8?
即链表大于等于8,转化成红黑树。
泊松分布:链表元素长度为7(7个元素出现哈希冲突)的概率是0.00000094,为8的概率是0.00000006,树化参数很小,转化红黑树资源消耗大。但是链表长度达到8,一定会树化吗?不是,源码里还有一个最小数化容量参数,值为64,就是要扩容到64,且长度大于等于8才会树化。
8.put方法详解
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//tab为空时候,resize初始化或者扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//(n - 1) & hash 就是上面说的计算小标,判断下标有没有值
if ((p = tab[i = (n - 1) & hash]) == null)
//没有哈希冲突,直接存入
tab[i] = newNode(hash, key, value, null);
else {
//哈希冲突
Node<K,V> e; K k;
//p是原有数组,特例:两个相同的key就会走这儿。
if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
//原来的值赋值给e
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
//p后面没有值
if ((e = p.next) == null) {
//p指向新的后面的节点
p.next = newNode(hash, key, value, null);
//判断是否需要要树化,判断长度是否大于等于8
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//要判断数组长度是否大于64
treeifyBin(tab, hash);
break;
}
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
//onlyIfAbsent 是false,旧值也不是null
if (!onlyIfAbsent || oldValue == null)
//新的value覆盖旧的value。
e.value = value;
afterNodeAccess(e);
//hash冲突了,新的值覆盖旧的,且返回旧值。
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
9.扩容机制,扩容后数组怎么定位?
如上,哈希为4或者20,长度为16,取余( 或者上述的hash&(n-1) )数组下标都是4,扩容成32长度数组,则分别挂在4和20下标下-->扩容后的数组下标要么为原来位置,要么为原来位置+16。
为什么呢?
原hash&(16-1)更换为现在hash&(32-1)=hash&000000000000000000000000000011111,原来计算低四位,现在换成低5位,hash倒数低5位为1,则相当于原位置+10000即+16,为0则对结果没有影响就还是原位置。(扩容是为了让元素更分散)
10.为什么HashMap线程不安全?
多线程出现循环链表,尾指针指向上一个节点的头;
此时推荐使用ConcurrentHashMap;
为什么不用Map加锁?加锁让多线程变成了单线程,效率。concurrentHashMap没有用锁,CAS机制,原子操作。
完结!