简介
1、HashMap是由数组和链表组成的(jdk1.8之后加了红黑树)一个存储key/value的集合。key、value可以为null。
2、它的主干是数组,在查找某个值时,传入key值,通过hash函数一次性定位到数组部分中的某一个值,所以查找的时间复杂度为O(1),当查找的数在链表上时,则是O(n),所以为了提高HashMap的查找速度,我们应该尽量避免哈希冲突
。
hash
什么是hash:
static final int hash(Object key) {
int h;
//计算hash的算法,取对象的hashCode值然后无符号右移16位
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
hashMap作者采用了 key.hashCode() ^ (key.hashCode() >>> 16) 这个巧妙的扰动算法,key的hash值经过无符号右移16位,再与key原来的hash值进行 ^ 运算,这样就能让该元素的高16位与低16位取异或(相同为0,不同为1),就能很好的保留hash值的所有特征,让计算出的hash值更加随机,这种离散效果才是我们最想要的。目的都是为了让数组下标更分散
成员变量
//默认初始化容量大小,容量且必须是2的幂次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表树化的长度,即等链表长度达到8时,进行树化
static final int TREEIFY_THRESHOLD = 8;
//红黑树退化成数组+链表的长度,只有当链表的长度小于等于6时,进行链化
static final int UNTREEIFY_THRESHOLD = 6;
//数组树化的最小长度
static final int MIN_TREEIFY_CAPACITY = 64;
//Node是Map.Entry接口的实现类,可以将这个table理解为是一个entry数组;
//每一个Node即entry,本质都是一个单向链表
transient Node<K,V>[] table;
//HashMap已在结构上修改的次数 结构修改是指更改 HashMap 中的映射数量或以其他方式修改其内部结构(例如,重新散列)的那些
transient int modCount;
//下一次HashMap扩容的阀值大小,如果尚未扩容,则该字段保存初始entry数组的容量,或用零表示
int threshold;
//存储负载因子的常量,初始化的时候将默认的负载因子赋值给它;
final float loadFactor;
方法及解析
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) {
// tab指代是hashmap的散列表再,在下方初始化,hashmap不是在创建的时候初始化,而是在put的时候初始化,属于懒初始化
// p表示当前散列表元素
// n表示散列表数组长度
// i表示路由寻址的结果
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断数组是否为空,如果为空,则进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//计算要插入的元素的位置,判断是否为空
if ((p = tab[i = (n - 1) & hash]) == null)
//该元素位置为空,则直接插入该元素
tab[i] = newNode(hash, key, value, null);
//如果要插入的元素在这个位置有元素了,执行以下操作
else {
//e 临时的node元素
//k 表示临时的一个key
Node<K,V> e; K k;
//判断该元素和原有元素比较,判断是否equal也相同
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//如果相同则老元素地址指向新元素
e = p;
//不相等再判断节点类型,看看是不是红黑树
else if (p instanceof TreeNode)
//红黑树插入操作
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//链表插入操作
else {
//for循环:1.遍历到链表尾部,进行尾插
//2.判断链表长度,超过8将链表改为红黑树
for (int binCount = 0; ; ++binCount) {
//判断节点的下一个节点为空,遍历到链表尾部,进行尾插
if ((e = p.next) == null) {
//生成一个Node对象,将Node对象作为新节点插入到链表(p.next)
p.next = newNode(hash, key, value, null);
//如果链表长度 >= TREEIFY_THRESHOLD-1 = 7 因为是从0开始遍历,所以此时链表长度为8
if (binCount >= TREEIFY_THRESHOLD - 1)
//树化函数,进行树化
treeifyBin(tab, hash);
break;
}
//key相同时同样插入返回
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//这种属于覆盖操作,当e中有值进入操作
if (e != null) { // existing mapping for key
//oldValue保存老的值,方便return
V oldValue = e.value;
//onlyIfAbsent传入的是false,指定能进入判断
if (!onlyIfAbsent || oldValue == null)
//新元素的值将老元素的值覆盖掉
e.value = value;
//HashMap提供给子类的方法
afterNodeAccess(e);
//put操作有返回值,返回的是插入之前已经存在的元素的value值
return oldValue;
}
}
//增加修改次数
++modCount;
//统计当前map中有多少元素,和阈值对比,判断是否需要扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
第一步,我们的put方法会去判断这个hashmap是否为null 或者长度是否为0,如果是则对hashmap数组进行resize()扩容,
第二步,put方法会根据这个key计算hash码来得到数组的位置,(这里需要解释一下,我们的hashmap默认是由一个数组加链表组成的)
得到位置后当然是继续判断这个数组下标的值是否为null,为null 自然是直接插入我们的value值,如果不为空的话进行第三步
第三步,判断key是否为相同,当key相同我们就可以覆盖value值,key不相同继续第四步
第四步,如果key值也为空,则判断结点类型是链表还是红黑树
第五步,如果节点类型为红黑树,则执行红黑树插入操作
如果节点类型为链表,那么put方法就会遍历这个链表,for循环遍历链表直至链表尾部,然后进行尾插,当链表长度>=8时,会进入链表转红黑树的方法,treeifyBin方法中还会判断数组长度,数组长度>=64,链表长度>=8同时满足,才会将链表转为红黑树;在for循环遍历过程中,如果key相同,则直接插入元素
第六步,记录操作次数变量modCount+1,最后再判断当前map中有多少元素,和阈值做对比,如果超过阈值则进行扩容当数组容量超过最大容量时就会扩容一倍(即二进制的进位),没有则返回null。
put方法是由返回值的,在插入完成后,如果插入之前已经存在这个key,则返回的是插入之前已存在元素的value
resize方法扩容
final Node<K,V>[] resize() {
//将已存在的哈希数组赋值给oldTab记做老的哈希表
Node<K,V>[] oldTab = table;
//获取之前的数组长度赋值给oldCap
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//获取指定的扩容阀值
int oldThr = threshold;
//扩容后的新长度,新阀值
int newCap, newThr = 0;
//判断之前数组长度是否大于0
if (oldCap > 0) {
//判断之前长度是否大于等于 1<<30
if (oldCap >= MAXIMUM_CAPACITY) {
//大于则扩容阀值为Integer的最大值
threshold = Integer.MAX_VALUE;
//返回旧的哈希数组
return oldTab;
}
//新的长度为旧长度左移一位(2倍),判断是否小于最大值,且旧的长度大于等于默认值16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//新的阀值为旧的阀值长度左移一位
newThr = oldThr << 1;
}
// 旧的长度大于0
else if (oldThr > 0) // initial capacity was placed in threshold
// 新的长度等于旧的长度
newCap = oldThr;
else {
// 新的长度等初始值16
newCap = DEFAULT_INITIAL_CAPACITY;
// 新的阙值为初始值16*负载因子0.75
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果新的阀值长度等于0
if (newThr == 0) {
//阀值等于新的数组长度乘以负载因子
float ft = (float)newCap * loadFactor;
//新的长度小于最大值1<<30且阀值也小于最大值,如果不满足,取Integer的最大值
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//最终,将newThr(新的扩容阀值)赋给threshold;
//即,初始化好了扩容阀值;
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//所以新建一个entry数组,容量为newCap,即创建出扩容后新的Node数组;
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//如果原Node数组不为空
if (oldTab != null) {
//遍历原Node数组:
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
//如果该Node元素的next==null,则说明该Node元素后边既没有链表又没有红黑树;
if (e.next == null)
//则将该Node元素直接存于新Node数组的指定位置
newTab[e.hash & (newCap - 1)] = e;
//如果该Node元素后边跟着的是一个红黑树结构:
else if (e instanceof TreeNode)
//在新的Node数组中,将该红黑树进行拆分,
//(如果拆分后的子树过小(子树的节点小于等于6个),则取消树化,即将其转为链表结构);
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//如果是链表的情况下,则进行下面的链表数据转移的操作
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
//对链表进行遍历,把链表中的节点分成两个类别,一是需要更换数组下标的,一是不需要的
next = e.next;
//如果e.hash&oldCap进行与运算,算出的结果是为0,即说明该Node节点所对应的数组下标不需要改变
if ((e.hash & oldCap) == 0) {
//如果loTail为null,说明该链表没有头节点
if (loTail == null)
//所以把头节点指向该节点
loHead = e;
//如果该链表有头结点,则把遍历出来的节点放在该链表的尾部
else
loTail.next = e;
loTail = e;
}
//如果e.hash&oldCap进行与运算,算出的结果不为0,则更新该Node节点所对应的数组下标
else {
//逻辑跟上面一样
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//该Node节点所对应的数组下标不需要改变,直接把数组下标对应的节点指向新Node数组下标位置链表的头节点
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//该Node节点所对应的数组下标需要改变,重新计算出所对应的数组下标值,然后指向新Node数组下标位置链表的头节点
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
//总结:将链表分成两个不同的部分,可以使得数据更加的分散,使得链表的长度变短
}
}
}
//返回新的Node数组;
return newTab;
}
resize():
1、如果初始化的时候用户传入了容量参数和负载因子,或者只传入了容量参数
1.1、那么将 用户传入的容量参数 * 用户传入的负载因子loadFactor(也可能是默认的负载因子),的计算结果,赋值给扩容阀值threshold
1.2、将容量参数赋值给newCap(新的Node数组的长度),根据newCap(新的Node数组的长度)创建出一个新的Node数组newTab;
2、如果初始化的时候用户什么都没传
2.1、那么将 默认负载因子0.75f * 默认的容量大小DEFAULT_INITIAL_CAPACITY,的计算结果,赋值给扩容阀值threshold
2.2、将默认容量大小DEFAULT_INITIAL_CAPACITY(16)赋给newCap(新的Node数组的长度);根据newCap(新的Node数组的长度)创建出一个新的Node数组newTab;
到这里,resize()方法主要做了三件事:
- 初始化了(新的扩容阀值)newThr,(随后将newThr赋值并覆盖了threshold);
- 初始化了(新的Node数组的长度)newCap;
- 根据初始化好的(新的Node数组的长度)newCap,创建出(新的Node数组)newTab,(随后赋值并覆盖了table)
后面的操作是:将原Node数组中的Node元素,迁移到扩容后的新的Node数组中:
1、如果原Node数组不为空,遍历原Node数组。
2、如果遍历该Node元素的next == null,则说明该Nod元素后面既没有链表也没有红黑树,则将该Node元素直接存于新Node数组指定的位置。
3、如果遍历的该Node元素后面跟着的是一个红黑树结构,则在新的Node数组中,将该红黑树进行拆分(如果拆分后的子树过小(子树的节点小于6个),则取消树化,将其转化为链表结构)
4、如果遍历的该Node元素是链表的情况下,对链表进行遍历,将链表中的Node元素迁移到新的Node数组中。(对链表遍历的时候,把链表中的节点分成两个类别,一个需要更换数组下标的,一个是不需要的)
如果遍历的该Node元素是链表的情况下的元素迁移:
在Java8中,该部分代码不是简单的将旧链表中的数据拷贝到新数组中的链表就完了,而是会对旧的链表进行重新 hash, 如果 hash 得到的值和之前不同,则会从旧的链表中拆出,放到另一个下标中去,提高性能,刚刚的红黑树也是这么做的。
注意点:
在遍历的该Node元素是链表的情况下的元素迁移的这段代码,还有一个需要注意的地方:在JDK 7 中,这里的的代码是不同的,在并发情况下会链表会变成环状,形成死锁。
而JDK 8 已经修复了该问题,但是仍然不建议使用 HashMap 并发编程。HashMap 在 JDK 7 中并发扩容的时候是非常危险的,非常容易导致链表成环状。但 JDK 8 中已经修改了此bug。但还是不建议使用。在并发情况下,推荐并发容器 ConcurrentHashMap。
源码分析就到这里了,面试的一些问题看这
为啥hashMap1.7及之前使用头插法会造成死锁
在多个线程put操作的时候发生扩容,这时候就需要对链表上的数据进行转移,假设某个链表的数据为1->2->3,此时线程A刚执行到Entry<K,V> next = e.next;这是线程A的e是1,next为2;这时候线程A因为网络原因挂起。
这时候线程B来执行并完成了链表上的数据转移。此时为3->2->1。
这时候当线程A继续执行的话,就会出现1->2->1的情况,一直死循环下去,造成闭环。
//转移数据的具体步骤
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
其实在1.8之后,在线程不安全的情况下也会造成数据覆盖的情况,情况如下
如果没有hash碰撞则会直接插入元素。如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,所以这线程A、B都会进入第6行代码中。假设一种情况,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,问题出现:线程A会把线程B插入的数据给覆盖,发生线程不安全。
所以在多线程情况下使用concurrentHashMap 或者hashTable更好。