HashMap是在JDK1.2中引入的集合类,通过键值对(K,V)对数据进行处理,随着JDK版本的迭代,HashMap也在逐渐的完善和优化,那么今天我们一起来深入HashMap的源码。
HashMap类常用字段
//默认容量
//当不通过构造函数传入容量时,则默认哈希桶数量为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子
//当已使用的桶的数量与全部桶数量的比值超过负载因子时,触发扩容机制
//选择0.75作为负载因子是在时间与空间上的折衷
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//转化为红黑树的阈值
//当桶内节点数量超过8时,将桶内元素转化为红黑树节点
static final int TREEIFY_THRESHOLD = 8;
//退化为链表的阈值
//当桶内红黑树节点小于6时则退化为链表
static final int UNTREEIFY_THRESHOLD = 6;
//Hash桶数组
transient Node<K,V>[] table;
//HashMap大小
transient int size;
//Hash桶扩容阈值
//该threshold = 当前哈希桶个数(table数组大小) * 负载因子
//例如,当我们通过空参构造器初始化HashMap,此是table数组为16,默认负载因子是0.75
//此时的threshold为12,即当前哈希桶数量超过12时会触发扩容。
int threshold;
HashMap的构造函数
HashMap中一共提供了四个构造函数
public HashMap(int initialCapacity, float loadFactor){}
public HashMap(int initialCapacity){}
public HashMap(){}
public HashMap(Map<? extends K, ? extends V> m)
当使用HashMap的时候,我们看见在构造函数中传入HashMap的初始化大小(initialCapacity),但是该initialCapacity不一定是真实的哈希桶数量,因为HashMap的哈希桶数量一定是2的N次幂,如果传入的initialCapacity不是2的N次幂,此时会选择大于initialCapacity的最小2的N次幂作为哈希桶数量,例如initialCapacity=20,此时哈希桶的数量为32。
确定K对应哈希桶的位置
我们都知道通过哈希算法对数据进行对位的时间复杂度为O(1),因为不需要对数组进行遍历,所以查询速度很快。HashMap也是通过hash算法对数据进行定位。然后进行后续的操作。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
在计算hash值时,我们首先判断key值是否为空,如果为空,则返回0。
如果不为空,我们通过hashCode()方法计算出h的值。h为32位。然后将h值的高16位与低16位进行异或运算,保证高位数字参与计算过程。可能有些朋友不理解为什么要高位参与运算。请看下图
假设我们当前有h1与h2两个值,他们hashCode()方法计算出的返回值如图所示,他们的低16位是相同的,如果直接使用低16位进行计算k的位置,则会增加哈希冲突的概率。看到这里可能有朋友要问了,我直接使用原始32位哈希码不可以嘛?答案是可以的,但是32位数据比16位数据多了一倍的空间需求,当我们有千万个哈希桶时会浪费很多空间。所以设计人员通过将高位与低位进行异或运算来降低哈希冲突的可能性。
HashMap.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;
//如果table为空或者长度为0,则对其进行进行扩容(resize)
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 {
Node<K,V> e; K k;
//判断key是否和当前桶内第一个元素的key是否相同,如果相同则对该元素进行覆盖
//这里的相同指的时hashCode()和equals()方法
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//判断当前桶内元素是否为TreeNode类型,即判断当前桶内是否是红黑树结构
//如果时红黑树结构,则直接将代添加节点放入红黑树
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//此时判断当前桶内并非红黑树结构,进入该层结构
else {
//遍历链表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//如果链表长度大于8,则将其转化为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//待添加key与当前key相等(equals相等),直接对value进行覆盖
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;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//将元素放入后判断当前已使用桶数量是否超过扩容阈值
//如果是则进行扩容,扩容会变为当前容量的二倍
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
HashMap.resize()
在刚刚put方法中我们发现,如果当前已使用的桶数量超过扩容阈值,HashMap会自动扩容
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) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//新容量变为原容量的二倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//扩容后新的table[] 大小为原来的二倍
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//将原桶内红黑树类型数据进行分装
((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;
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;
}
在扩容这里有一点需要注意的是,我们对数据进行分桶时的时候,是根据HashMap.hash()方法的返回值与table的长度做取模运算得到的
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
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);
....
}
在代码块中注释下面的一行,熟悉位运算的朋友们可能会知道,(n - 1) & hash这个表达式的计算结果就等于hash%(n-1),这算是一种骚操作,但是位运算的速度远快于取模运算。在扩容的时候,我们需要对原桶中的值进行分装,在这里我们举一个例子,假设原table长度为16,我们有十个哈希值为(n*16+14)的数据,经过计算这十个数据全部会落入table[14],那么当前table[14]桶内指向一个10个节点的红黑树结构。当我们resize后,newTable长度为32,我们需要重新结算这十个节点在newTable中的位置,我们可以惊奇的发现(n * 16 +14)对32取模,结果一定是14或者(14+16),也就是说,当我们扩容之后,桶内元素要么保留在原桶内,要么会分入(i+oldCapicity)桶内
HashMap是线程安全的吗?
先说结论,不是
在JDK1.7中,多线程环境下HashMap会造成死循环
在JDK1.8中,多线程环境下HashMap会造成数据覆盖
JDK1.7
在JDK1.7中,HashMap向桶中添加数据时会采用头插法,在resize时会调用transfer方法
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;
}
}
}
假设我们当前有两条线程A和B以及如下图所示的HashMap
如果在单线程环境下进行resize,结果如下图所示
但是在多线程环境下,不同线程抢占CPU时间片,可能某条线程在resize过程中调用transfer函数中被挂起,其他线程抢占了CPU时间片,从而导致一些"奇怪"的问题发生
此时线程A工作内存中指针情况如下
此时线程B正常执行并完成resize过程,
此时线程A获取了CPU时间片,当前线程A工作内存中 newTable[i] = 7,e = 7, next = 3,此时引用情况如下
之后执行 e = next; 此时e = key(3) ,e.next = key(7) 进入下一次循环,next = e.next导致next指向key(7)
e.next = newTable[i] 导致Key(7)的引用指向了Key(3),从而出现了死循环
JDK1.8
JDK1.8对JDK1.7中的头插法进行了优化改为尾插法,从而避免了死循环的问题。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
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); //注意这一行
......
}
但是在多线程环境下HashMap仍会存在线程安全问题,假设现在我们有两条线程A与B并发地向HashMap中添加数据,且线程A与线程B都准备响一个空桶中添加元素(即二者hash值相同但value值不同)假设线程A先进入代码块中存在注释地那一行的同时被挂起,然后线程B进入并顺利执行,将其键值对添加到桶中。然后线程A恢复,但此时线程A仍会认为当前桶为空桶,并新建节点放入桶内,这就会造成线程B添加的值被线程A覆盖的问题。
线程A准备添加(3,x) 线程B准备添加(3,y)
此时线程A被挂起,线程B顺利执行
但是此时线程A需要进行newNode方法,则会覆盖掉线程B添加的键值对
微信扫码关注我