写在前面:
由于今年疫情的原因,再加上学业的繁重和找工作的艰难准备,csdn个人博客的文章从去年放寒假到现在一直没有更新过了,在这半年的时间里,自己对于所学的知识有了一个更加清晰的认知,发现以前的文章有很多错误,难免有误人子弟的嫌疑,所以在接下来的时间我会将前面文章的错误一一改正,并继续写出高质量的技术文章,欢迎广大网友的批评与指正。
这一篇HashMap标志着我的csdn个人博客继续更新了,哈哈。HashMap基本是每一个Java程序员都会用的一个key-value键值对集合,很多小伙伴说HashMap里面的内容太难了,今天我袁非非就不信这个邪,誓要将HashMap弄清楚。以下是正文,有点硬核,小伙伴们,are you ready。
1.HashMap几个重要的属性(JDK1.8):
由于HashMap的内容太多,想要每一行代码,每一个方法都完全弄明白,其实也没有必要,现在就以其中作者认为重要的方法,属性拿出来闹闹磕,如有不正确的地方,欢迎批评指正。
/**
* 初始化容量 - 必须是2的次幂. 默认初始值为16
*/
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;
/**
*链表转红黑树时冲突链表节点的个数
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 红黑树转链表时 冲突节点的个数
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
容器可能被树化的最小表容量。也就是说链表转红黑树的条件是:冲突链表的节点个数达到8,
并且HashMap中table的长度达到64
*/
static final int MIN_TREEIFY_CAPACITY = 64;
HashMap 的基本数据结构图示:
这个图实在是懒的画,是在网上copy的。有了这个大概图示,然后我们在来看代码层面的数据结构与实现吧。
以上就是HashMap的基本属性和数据结构示意图:接下来我要阐述几个常见的问题,一大波干货来袭。are you ready!!!
1.HashMap的容量为什么是2的次幂:
因为table的长度n永远是2的次幂,那么n-1的二进制表示就是低位一连串的1,例如:0000 1111,0011 1111
,当hash&(n-1)时,实际上就是取hash的低m位,2^m=n,那么就会保留后x位的1,例如: 00001111(n-1) & 10000011(hash) = 00000011
这样做的好处有3个:
1.&运算速度快,至少比%取模运算快
2.能保证计算出的索引在capacity中,不会数组越界
3.当n为2的次幂时满足:hash&(n-1)=hash%n
2.加载因子为什么是0.75:
HashMap源码中,我找到了以下注释:
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
使用随机哈希码,节点出现的频率在hash桶中遵循泊松分布,同时给出了桶中元素个数和概率的对照表。
从上面的表中可以看到当桶中元素到达8个的时候,概率已经变得非常小,也就是说用0.75作为加载因子,每个碰撞位置的链表长度超过8个是几乎不可能的。
加载因子是表示Hash表中元素的填满的程度。加载因子越大,填满的元素越多,空间利用率越高,但冲突的机会加大了。反之,加载因子越小,填满的元素越少,冲突的机会减小,但空间浪费多了。冲突的机会越大,则查找的成本越高。反之,查找的成本越小。因此,必须在 "冲突的机会"与"空间利用率"之间寻找一种平衡与折衷。
3.java8中为什么要将链表转成红黑树:
链表的插入效率很高,但是查询效率较低,完全平衡二叉树的查询效率很高,但是插入效率很低,于是在链表和完全平衡二叉树之间做了一个折中,采用的是红黑树。
查询效率:完全平衡二叉树>红黑树>链表
插入效率:链表>红黑树>完全平衡二叉树
4.HashMap为什么会线程不安全:
HashMap的线程安全问题主要体现在put操作里面,会有数据覆盖的问题。
主要发生线程安全的代码是这一行:
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
我们假设2个线程同时来进行put操作,并且key的hash值相同(产生了hash冲突),那么都会找到在table上的相同的索引位置,在此索引位置上没有元素。假设线程1执行完了if ((p = tab[i = (n - 1) & hash]) == null)
里面的内容,并且条件成立,此时cpu时间片用完,线程1挂起。线程2也执行了if ((p = tab[i = (n - 1) & hash]) == null)
,并且条件成立,那么线程2就会完成tab[i] = newNode(hash, key, value, null);
这一步的赋值操作。线程2完成了操作后,线程1此时获得了时间片,继续执行tab[i] = newNode(hash, key, value, null);
这一步操作,也会执行成功,此时线程1就将线程2的数据覆盖了。
5.当HashMap的key是Object对象时,为什么需要重写equals
和hashcode
方法:
在HashMap查找的过程中,使用的是key的hashcode
和key的equals
方法进行查找,先根据key的hashcode
计算出hash
值,然后再根据hash&(n-1)
定位到table中的索引位置,如果发生了hash冲突,需要遍历链表或者红黑树来查找该元素,那么则使用key的equals
方法进行查找,如果是简单字符串类型,那么则不必重写equals
方法,如果是对象的话,如果不重写equals
方法,那么比较的是对象地址,而不是值。在Object
中类的equals
方法:
public boolean equals(Object obj) {
//this - s1
//obj - s2
return (this == obj);
}
Node<K,V>节点类(链表)
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; //Hash值 并不直接就是HashCode
final K key; //key
V value; //value
Node<K,V> next; //链表节点
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
TreeNode<K,V>节点类(红黑树)
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // 父节点
TreeNode<K,V> left; //左子节点
TreeNode<K,V> right; //右子节点
TreeNode<K,V> prev; // 删除时需要断开next链接
boolean red; //是红节点还是黑节点
重要的方法:
1.计算Hash的相关代码方法
//计算key的Hash值
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
//根据Hash值计算hash桶的索引位置 n是table的长度 hash是根据key计算出来的hash值
(n - 1) & hash
以上方法是计算key的Hash值的方法:(h = key.hashCode()) ^ (h >>> 16)
获取key的hashcode(32位),将hashcode右移16位然后与hashcode做亦或运算。这里引申出一个问题:为什么要无符号右移16位呢?
举个简单的例子:就以初始容量16为例吧。
h=key.hashcode() 1111 1101 1101 1111 0101 1101 0010 1111
^
h>>>16 0000 0000 0000 0000 1111 1101 1101 1111
-------------------------------------------------------------
hash=h^h>>>16 1111 1101 1101 1111 1010 0000 1111 0000
hash 1111 1101 1101 1111 1010 0000 1111 0000
&
16-1 0000 0000 0000 0000 0000 0000 0000 1111
-------------------------------------------------------------
hash&(16-1) 0000 0000 0000 0000 0000 0000 0000 0000
通过以上分析,我们可以看出,当HashMap的数组长度比较小时,那么hash值的高16位很可能被数组长度的二进制码屏蔽掉,那么hash值的高16位就完全失去了作用,如果使用(h = key.hashCode()) ^ (h >>> 16)
这种方式,就能够充分利用高16位的特征,降低hash冲突。
那么问题又来了(为什么总有这么多的问题,哈哈):为什么使用 ^ 进行运算,而不是使用 | 或者是 & 进行运算呢?因为 ^ 运算能最大程度的保留数据的特征,使用 | 或则 & 运算会使得数据向1或者0靠拢,失去原有的特征。
2.get()方法的流程
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
* 正儿八经的获取Node节点的流程
* @param hash 根据这个key计算的hash值
* @param key 要查找的key
* @return the node, or null if none
*/
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) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
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;
}
get()方法的流程分2种情况:
-
获取不到数据:
-
1.table没有被初始化(从这里我们也可以看出HashMap是懒加载的)
-
2.table初始化了,但是初始化的容量为0(手动指定容量为0)
-
3.根据这个hash&(table.length-1)计算出的位置上的Node为空
-
获取到数据:
-
前提:没有出现以上三种情况
-
1.根据hash&(table.length-1)计算出的位置上的Node的hash值等于方法传进来的hash值,并且该节点的key等于传进来的key。
-
2.如果根据hash&(table.length-1)计算出的位置上的Node没有满足1,并且node.next!=null,那么则说明产生了hash冲突,冲突的结果要么是链表,要么是红黑树。
-
3.如果是链表则遍历链表,判断每一个节点的hash值是否等于传进来的hash值并且key等于传进来的key,找到则返回该node节点。
-
4.如果链表已经树化了,那么就按照红黑树的方式去查找(红黑树的操作比较复杂,这里就不展开了)。
3.put()方法的流程
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
*正儿八经的put的流程
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
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未初始化(可以看出HashMap是懒加载)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//table初始化了 但是没有产生hash冲突
//就是这一行代码会造成线程安全问题,会有数据覆盖的问题
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//产生了hash冲突
Node<K,V> e; K k;
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 (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果这个key映射已经存在,那么使用新的vallue替换旧的value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
//这个方法是LinkedHashMap里面的,按照访问顺序实现LRU,在HashMap里面只有声明,但是并没有实现该方法
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
//这个方法是LinkedHashMap里面的,按照插入顺序实现LRU,在HashMap里面只有声明,没有实现。
afterNodeInsertion(evict);
return null;
}
put()操作流程主要分为4步:
- 1.如果table没有初始化,那么就要先初始化table,然后在执行以下逻辑
- 2.如果table初始化了,并且根据hash值计算出的索引位置上为null,那么则说明该位置还没有被占,则将该hash和key封装成一个Node放在该位置上
- 3.如果根据hash值计算出的索引的位置上的Node不为null,那么则说明产生了hash冲突,冲突的结果要么是链表,要么已经树化。
- 4.如果已经树化,那么则执行红黑树的插入逻辑(比较复杂,这里不再展开)。
- 5.如果是链表,那么则遍历该链表,将hash,key封装成一个Node放到链表的末尾。如果这个key的映射已经存在了,那么使用新的value替换掉旧的value。
4.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;
//原table中已经有值
if (oldCap > 0) {
//元素超过容器最大容量限制,不再进行扩容,直接返回
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//扩容成原来的2倍
//有个条件:原来的容量>=默认的容量
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//在构造函数中我们知道,如果创建HashMap的时候没有指定initial capacity,那么这个值就会被初始化为0。
//如果指定了这个值,那么会被初始化成大于他最近的2的指数,比如14 15 ,都会被初始化为16 会满足2的次幂。
//这里的意思是,如果在构造函数中指定了这个initial capacity,那么就使用它来作为新的table的实际长度
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//如果构造函数中没有指定initial capacity 那么就使用默认值16
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//计算指定了initial capacity的新的threahold
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
//初始化table或者扩容 都是通过以下代码实现的 实际上是新建一个table
@SuppressWarnings({"rawtypes","unchecked"})
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;
//table中存放的是Node节点的引用 此时这个e指向这个节点的引用
if ((e = oldTab[j]) != null) {
//将原table中的Node置空
oldTab[j] = null;
//如果原来table的位置就只有一个Node元素,也即是没有发生冲突
if (e.next == null)
//那么就重新hash 根据新的table的长度计算索引 并将原表的Node给新表的位置
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;
}
以上这个扩容的方法可能有点复杂,我们拆开来看。
首先看第一部分:
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;
}
//没超过最大值 扩充为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//在构造函数中我们知道,如果创建HashMap的时候没有指定initial capacity,那么这个值就会被初始化为0。
//如果指定了这个值,那么会被初始化成大于他最近的2的指数,比如14 15 ,都会被初始化为16 会满足2的次幂。
//这里的意思是,如果在构造函数中指定了这个initial capacity,那么就使用它来作为新的table的实际长度
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//如果构造函数中没有指定initial capacity 那么就使用默认值16
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//计算指定了initial capacity的新的threahold
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
首先我们必须要知道一个值的作用,就是:threshold。
这个值的作用就是用来判断HashMap是否需要扩容的一个标志,threshold=capacity * load factor(0.75),如果HashMap.size>threshold的话,那么则需要执行扩容逻辑。
这个部分的代码主要做了2件事:第一则是计算新表的大小。第二则是计算新表的threahold。
再来看第二部分:
Node<K,V> e;
//table中存放的是Node节点的引用 此时这个e指向这个节点的引用
if ((e = oldTab[j]) != null) {
//将原table中的Node置空
oldTab[j] = null;
//如果原来table的位置就只有一个Node元素,也即是没有发生冲突
if (e.next == null)
//那么就重新hash 根据新的table的长度计算索引 并将原表的Node给新表的位置
newTab[e.hash & (newCap - 1)] = e;
//如果原节点是红黑树节点 那么则按照红黑树的方法进操作(这里不做展开)
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//如果是链表 这段代码单独来说
这部分代码主要解决了2个问题。第一个是没有发生Hash冲突时直接rehash,然后将节点给到新的表。第二个问题就是处理红黑树(这里没有展开)
最后来看最精妙的代码:
resize时候的链表拆分
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;
}
}
我们再拆分,再做进一步的详细剖析:
第一段:
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
上面的代码定义了2个链表,我们称之为:lo链表和hi链表,loHead和loTail分别时lo链表的头和尾;hiHead和hiTail分别时hi链表的头和尾。
第二段:
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);
我们再将以上代码框架抽象出来:
do {
next = e.next;
} while ((e = next) != null);
我们可以从这个结构看出,这段代码就是顺序遍历旧的table上的链表的节点
我们再来看里面的if…else:
if ((e.hash & oldCap) == 0) {
// 将节点插入lo链表
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//将节点插入hi链表
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
上面的代码就是说,我们原来table的一条链,现在被拆分成了2条链,原来一条链上的元素现在分布到这2个链上。这个元素分布到hi链还是lo链的标准就是 e.hash & oldCap) == 0
与e.hash & oldCap) != 0
,如果e.hash & oldCap) == 0
,那么Node就被划分到了lo链,如果e.hash & oldCap) != 0
,那么就被划分到了hi链。通过以上方式,原先旧的table索引位的一条链表的元素,现在就被拆分到了新的table的2条链中。
最后我们再来看第三段:
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
第二段代码我们已经将链表的元素划分到了2个链表中,那么这2个链表的元素如何处理的呢,就是通过以上代码实现的:这段代码看上去就很简单了,就是将新的2条链表放在新的table的j
和j+oldCap
的位置,j
就是指旧的链表在table的索引位置,oldCap
指的是旧的tabl的长度。
以下内容将对e.hash & oldCap) == 0
和j j+oldCap
做一些解释说明:
首先我们要明确几个点:
1.oldCap一定是2的整数次幂,2^m。
2.newCap是oldCap的2倍,2^(m+1)。
3.获取hash值的方式为:(n-1)&hash,实际上就是取hash的低m位。
对于扩容后的hash,那么就是取hash的低m+1位,对于同一个hash(每个元素的hash值相同)来说,低m
位和低m+1
位的区别在于要么相同,要么m+1=m+1000....(oldCap)
。实在不好说明白,举个例子吧。
假设table大小为16,那么就是2^4
,取元素hash值低4位就是:假设是abcd,那么扩容后的table的大小是32,那么就是 2^5
,取hash值的低5位,这里有2种可能:1abcd或者0abcd
,此时低5位和低4位有如下关系:0abcd=abcd , 1abcd=abcd+10000 即有:1abcd=abcd+oldCap
,此处的abcd
就相当于j
,旧的table中的位置。
以上就差不多解释了:(e.hash & oldCap) == 0
这行代码的精妙,由于作者语言水平有限,如有解释不当,请读者自行斟酌,或给我留言评论。
作者感言:
这是今年的第一篇文章,也是我认为写的比较全面的一篇文章,自己的理解再加上参考了几位大佬的博客才写出来的。这一篇文章写了一周呀,利用中午上班的休息时间,作者精力旺盛,中午都不需要休息的,话说实习也没啥太多事情,所以跑这来写文章来了,哈哈,都是为了秋招呀,希望能够有个好的offer,奥里给。以后每篇文章我都会写一个作者感言,也聊聊生活,生活不只有代码,还有诗和远方。