主要成员变量
- 节点数组 table 默认容器大小capacity为16
- 节点 ,节点成员有hash,key,value,next
- 扩容因子loadFactor ,默认为0.75
- 扩容节点大小threshold 计算公式为capacity*loadFactor
构造函数
HashMap提供四个构造函数
public HashMap(int initialCapacity, float loadFactor);
//此构造函数传入初始容器大小和扩容因子
public HashMap(int initialCapacity) ;
//此构造函数传入初始容器大小,扩容因子就会使用默认的0.75
public HashMap();
//此构造函数是无参的,只会设置扩容因子为默认的0.75,而容器大小是不会初始化的,等PUT的时候再扩容,下文介绍。
public HashMap(Map<? extends K, ? extends V> m);
//此构造函数是传入一个初始map,容器大小会初始化为大于此map大小的最小的2的幂次方的数,如传入map的大小为10,则容器大小为16.
PUT方法
1、判断容器是否为空,如果为空,则进行扩容(扩容方法详解下面介绍)。
2、根据key获取的hash值(下面介绍)跟容器大小-1(n-1)取模获取到容器下标,根据下标在节点容器中查找。
a.如果没有找到,则直接新建一个节点放入容器
b.如果有找到,且这个节点是红黑树节点,则以红黑树的方式进行插入新节点。且这个节点的hash,key都一致,则将旧值替换为新值,且返回旧值。如果在这个节点链表中没找到这个key,且已有的节点链表长度小于7,则直接在这个节点末尾直接插入这个新节点(尾插法),如果已有的节点长度等于7(加上新加的这个节点等于8),则会将这个节点链表变为红黑树。以红黑树的方式插入。
3、如果加入后的节点数量大于扩容节点数量(默认为12)则需要扩容。
流程图如下:
key的hash算法
//扰动函数
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
tab[i = (n - 1) & hash]
这是用了二次hash运算。
第一次是通过key的hashcode无符号右移16位与key的hashcode做异或运算,这样能保证高16位能参与第二次hash(&)运算(如果直接用hashcode与n-1进行&运算,因为n-1的前16位基本都是0,则hashcode的高位基本不影响最终的结果),混合原始哈希码的高位和低位,以此来加大低位的随机性,使最终二次hash计算出来的hash值碰撞减少。
第二次是为了能使计算的hash值能落在容器table中,所以需要对table的长度取模 hash%n;
计算机中,&运算比%运算高很多,所以基于以下的公式:
当 lenth = 2^n 时,X % length = X & (length - 1)
为了满足这个公式,让&取代%,我们容器的大小必须是2的幂次方,而且每次扩容都是扩大一倍。这也说明了为啥上面构造函数,如果你传的容器大小是18的话,他也会把你设为32。
扩容(resize)
在上面,我们知道,在put的时候有多次地方用到扩容。扩容源码如下(已添加了注释)
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//不是第一次扩容
if (oldCap > 0) {
//如果已有的tabledable大小已经是最大的,无法扩容,直接返回
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//newCap = oldCap << 1这里把容器扩大两倍,在上面有说明为啥扩大两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 相应的扩容节点大小也扩大两倍
}
else if (oldThr > 0) //是第一次扩容,且是通过自己传入容器大小来初始化的,则直接把初始容器大小设为自己传入的容器大小
newCap = oldThr;
else { // 是第一次扩容,且是无参构造函数初始化的,用默认的大小
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {//是第一次扩容,且是通过自己传入容器大小来初始化的,因为此时oldThr是等于容器大小,不能直接赋值,则需要再根据公式容器大小*加载因子 计算一遍
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {//不是第一次扩容,则需要将原来的节点重新进行hash计算
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)//节点链表就一个节点,则直接计算hash存储
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)//红黑树节点,则需要将红黑树分组,重新计算
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 分组链表
Node<K,V> loHead = null, loTail = null;//重新计算后的hash下标值不变
Node<K,V> hiHead = null, hiTail = null;//重新计算后的hash下标值等于原来的hash下标+oldCap
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {//下标不变,使用尾插法,避免了多线程死循环
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {//下标变了
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
每次扩容都是以两倍的大小扩容,且如果不是第一次扩容,则需要重新计算hash值排列,这是很耗费资源的,所以如果初始大概知道需要用到的容器大小,我们可以先初始设置容器大小,这样就能减少扩容的次数了。
多线程下put引发的线程不安全问题
在put方法中有一个判断是否hash碰撞
if ((p = tab[i = (n - 1) & hash]) == null)// A
tab[i] = newNode(hash, key, value, null);
假设两个线程A、B都在进行put操作,并且hash函数计算出的插入下标是相同的,当线程A执行完上面第A行代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了hash碰撞的判断,所以此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。
if (++size > threshold)//B
resize();
除此之前,还有就是代码中最后有一个判断是否需要扩容的第B行处有个++size,我们这样想,还是线程A、B,这两个线程同时进行put操作时,假设当前HashMap的zise大小为10,当线程A执行到第B行代码时,从主内存中获得size的值为10后准备进行+1操作,但是由于时间片耗尽只好让出CPU,线程B快乐的拿到CPU还是从主内存中拿到size的值10进行+1操作,完成了put操作并将size=11写回主内存,然后线程A再次拿到CPU并继续执行(此时size的值仍为10),当执行完put操作后,还是将size=11写回内存,此时,线程A、B都执行了一次put操作,但是size的值只增加了1,所以说还是由于数据覆盖又导致了线程不安全。
GET方法
源码如下,已添加注释,比较简单,只是涉及到链表查找以及红黑树查找。
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//hash碰撞有值
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))//hash跟key都一样,则返回
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)//红黑树节点,则需要再红黑树中查找节点
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);//循环在单链表中查找
}
}
return null;
}
红黑树与链表转换
1、hashMap并不是在链表元素个数大于8就一定会转换为红黑树,而是先考虑扩容,扩容达到默认限制后才转换。
2、hashMap的红黑树不一定小于6的时候才会转换为链表,而是只有在resize的时候才会根据 UNTREEIFY_THRESHOLD(6) 进行转换。