HashMap可谓是面试中的重中之重,基本上每家面试公司都会问一下。
常见面试题总结在文章后部分~!
介绍
HashMap是怎么实现的?
- JDK1.7的HashMap是采用数组+链表实现的
- JDK1.8之后HashMap是采用的数组+链表+红黑树实现的
HashMap的主干就是一个数组,假设我们有三个键值对 dnf:1,cf:2,lol:3。每次放入HashMap中时候都会根据key.hash % table.length(对象的hashcode进行操作后对数组的长度取余)确定这个键值应该放到数组的那个位置
1 = indexFor(dnf),我们将此键值对放到数组下标为1的位置
3 = indexFor(cf)
1 = indexFor(lol) 这时候我们发现下标为1的位置已经存在数据,我们把lol:3放到数组下标为1的位置,将原来dnf:1采用链表形式放到lol键值对下面
jdk1.7是头插法(从链表头部插入相同hascode的数据) jdk1.8是尾插法(从链表尾部插入相同hascode的数据)
在获取key为dnf的键值对时候,1=hash(dnf),得到这个键值对在数组下标为1的位置,dnf和lol不相等,和下一个元素进行对比,相等返回。set和get的过程大致就如此。先定位到槽的位置(数组中的位置),在遍历链表找到相同元素。
由上面图可以看出,HashMap在发生hash冲突时候采用的链地址法,解决hash冲突并不只有这种方法常见的如下
- 开放定址法
- 链地址法
- 再哈希法
- 公共溢出区域法
jdk1.7源码
几个重要属性
- DEFAULT_INITIAL_CAPACITY (map初始大小 默认 16)
- MAXIMUM_CAPACITY (最大容量,如果指定最大容量使用)
- DEFAULT_LOAD_FACTOR (负载因子 默认0.75)
- threshold (map是否扩容的决定性因素)
threshold = caopacity * loadFactor,即扩容的阈值=数组长度 * 负载因子,如果HashMap中放入16个元素,负载因子为0.75,则扩容阈值为16*0.75=12
负载因子越小,容易扩容,浪费空间,但是查询效率高
负载因子越大,不容易扩容,堆空间利用更加充分,查找效率低(链表拉长)
存储数据的静态内部类,数组+链表,这里数组指的就是Entry数组
构造函数
其他都是在此基础上的扩展,主要就是设置初始容量和负载因子
put方法的执行过程
scss
key为null直接放到table[0]处,对key的hashCode()做hash运算,计算index;
如果节点已存在数据,就替换old value(保证key的唯一性),并返回old value;
如果达到扩容的阈值(超过threshold),并且发生碰撞们就要resize扩容;
将元素放到bucket的首位,即头插法;
-
为null时,HashMap还没有创建这个数组,有可能用的是默认16的初始值,也可能自定义长度,这时候需要把数组长度变为2的最小倍数,并且这个2的倍数大于等于初始容量。如下图:
-
如key为null时候,则将至放到table[0]这个链上
-
找到应该放在数组的位置,h&(length-1)这个方式可以理解为hash值对数组长度取余
-
添加元素
-
将新的元素放到table的第一位,并且将其他元素跟在第一个元素后
-
容量超过阈值并且发生碰撞,开始扩容
-
transfer 重新计算元素在新的数组中的位置,并进行复制处理
transfer有两个需要注意的地方
css
原来在oldTable[i]位置的元素,会被放到newTable[i]或者newTable[i+oldTable.length]的位置
链表在复制的时候会反转
get方法的执行过程
- key为null直接从table[0]处取值,对key的hashCode()做hash运算,计算index
- 通过key.equals(k)去查找对应的Entry,返回value
-
从table[0]取值,key为null
-
key不为null时候
jdk1.8源码
jdk1.8存取key对null的数据没有进行特殊判断,而是通过将hash值返回为0的将其放到table[0]处
put执行过程
sql
对key的hashcode()高16位和低16位进行异或运算求出具体的hash值
如果table数组没有初始化,则初始化table数组长度为16
根据hash值计算index,如果没有碰撞则直接放到bucket里面(bucket可为链表或者红黑树)
如果碰撞导致链表过长,则把链表转为红黑树
如果key一簇按在,用new value替换old value
如果超过扩容阈值则进行扩容,threshold = caopacity * loadFactor
移动的过程和jdk1.7相比变化较大
jdk1.8和jdk1.7重新获取元素值在新的数组中所处位置的算法发生变化(实际效果还一样)
scss
jdk1.7, hash&(length-1)
jdk1.8, 判断原来的hash值要新增bit位是0还是1.假如是0,放到newTable[i],否则放到newTbale[i=oldTable.length]
get的执行过程
scss
对key的hashcode()高16位和低16位进行异或运算求出具体的hash值
如在bucket里的第一个节点直接命中则直接返回
如果有冲突,通过key.equals(k)去找相对应的Node,并返回value。在树中查找的效率为O(logn),在链表中查找的效率为O(N)
常见面试题
1.HashMap,HashTable,ConcurrentHashMap之间的区别
主要问题点为 是否key允许为null,线程是否安全
2.HashMap在什么条件下扩容
jdk1.7
超过扩容阈值
发生碰撞
jdk1.8
超过扩容阈值
3.HashMap的大小为何是2的N次方
简单说为了降低碰撞的概率,增加查询检索效率
-
为了通过hash值确定元素在数组中位置,我们需要进行hash%length操作,当%操作时比较消耗时间,所以优化为hash &(length - 1)
-
当length为2的n次方时候, hash&(length-1) = hash%length
-
我们假设数组长度为15和16,hash码为8和9
如图可以看出数组长度为15时候,hash码为8和9的元素被放到数组中统一位置形成链表,降低了查询效率,当hash码和15-1(1110)进行&时候,最后一位永远是0,这样 0001,0011,0101,1001,1011,0111,1101这些位置永远不会被放入元素,导致的结果就是
空间浪费大 增加了碰撞几率,减慢查询效率
当数组长度为2的n次方时候,2的n次方-1的所有位都是1,如8-1=7 即111,那么进行低位&运算时候,值总与原来的hash值相同,降低的碰撞概率
4.jdk1.8发生了那些变化?
-
由数组+链表改为了数组+链表+红黑树,当链表长度超过8时候,链表变为红黑树。
scss为什么要这样改?我们知道链表的查询效率为O(n),而红黑树的查询效率为O(length),查询效率变高了。 为什么不直接用红黑树?因为红黑树查找效率高,但是插入效率低,如果从一开始直接使用红黑树并不合适。从概率学角度官方选择了一个临界点8
-
优化了hash算法
-
计算元素在数组中的位置算法发生了变化,新算法通过新增位判断oldTable[i]应该放到newTable[i]还是放到newTable[i+oldTable.length]
-
头插法改为尾插法,扩容时候链表不发生倒置(避免形成死循环)
5.Hash在高并发下会发生什么问题
- 多线程下扩容,会让链表形成环,从而导致死循环
- 多线程put可能会导致元素丢失
jdk1.8中死循环问题已经解决,但是元素丢失问题还存在
6.怎样避免HashMap在高并发下的问题?
- 使用ConcurrentHashMap
- 使用Collection.synchronizedMap(hashMap) 包装成同步集合