面试关于HashMap的热度我就不说了,都被问烂了。可是你能把HashMap说到什么程度?你能和面试官扯多久?
话不多说,看我表演就完事!
为什么要用HashMap?
我们知道集合是用来存储元素的,那存储元素的话我们就可以选择使用数组或者链表。那数组和链表各自有什么优点呢?
-
数组
我们知道数组的查询速度是o(1),因为是按数组下标来存储的,所以查询的时候直接定位到下标然后读取,所以很快,但是在插入的时候却很慢,涉及到数组元素的大量移动,比如一个长度为100的数组,已经插入了99个元素,现在我要在第二个位置插入一个元素,那就要把第二个位置以后的元素都向后移动一位然后把这个位置空出来,然后把要插入的元素放到这个位置。删除是同样的道理,只不过是向前移动。这样就导致数组的插入和删除的时候性能不高。 -
链表
链表相对于数组来说正好是互补的,插入删除很快,但是查找的时候只能从头开始遍历,时间复杂度是o(n)。所以我们把数组和链表的优点结合起来不就🆗了。
HashMap的数据结构
JDK1.7的时候是数组+链表
JDK1.8的时候是数组+链表或者数组+红黑树
我们知道在hash严重的情况下使用链表的查找性能是 O(n),而使用红黑树是 O(logn)。
HashMap主要成员变量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认初始化Node数组容量16
static final int MAXIMUM_CAPACITY = 1 << 30; //最大的数组容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认负载因子0.75
static final int TREEIFY_THRESHOLD = 8; //由链表转红黑树的临界值
static final int UNTREEIFY_THRESHOLD = 6; //由红黑树转链表的临界值
static final int MIN_TREEIFY_CAPACITY = 64; //桶转化为树形结构的最小容量
什么时候用链表什么时候用红黑树?
数组长度大于64,并且链表长度大于8的时候才会由链表转换为红黑树。否则不会由链表转红黑树,而是会进行扩容,因为这个时候的数据量还比较小,体现不了红黑树的优势。但是当红黑树上的元素数量少于6就会转换会链表。
为什么阈值是8?
其实关于这个问题我之前是有了解的。在hash理想情况下,使用随机的哈希码,那节点分布在 hash 桶中的频率是会遵循泊松分布的,按照泊松分布的公式计算,链表中节点个数为8时的概率为 0.00000006(小伙伴们想一下这个概率可以用来形容什么),这个概率可太低了,并且到8个节点时,红黑树的性能优势才会开始展现出来,所以8是一个较合理的数字。我们可以看一下源码中也有说明
人家把数据给你放在这,自己悟吧~~
为什么负载因子是0.75?
我们知道HashMap的初始数组容量为16,当数组中元素数量超过12就会进行扩容。**loadFactor:负载因子->扩容阈值 = 容量 * 负载因子。
**那loadFactor是1不好吗?想一下如果是1,也就是数组元素到达16才会进行扩容,那是不是会导致hash冲突的概率增大?那0.5好吗?如果是0.5那在数组元素是8就会进行扩容,虽然相比于0.75减小了hash冲突,但是我们是不是太奢侈了,数组用到一半就扩容,我们始终浪费一般的空间。那0.75就是对时间和空间上权衡的结果。
那为什么转回链表节点是6而不是8?
其实链表和红黑树的相互转换是很耗性能的,那如果元素在8左右徘徊,那你一会是红黑树一会是链表。你觉得这么做合适吗?
HashMap的数组长度
我们知道初始容量是16。其实HashMap 会根据我们传入的容量计算一个大于等于该容量的最小的2的N次方,比如我们传入 11,容量其实还是16。为什么会这样?看源码~
我们可以传入一个参数,也就是定义一个我们想要大小的数组。这个时候我们看到会执行这个tableSizeFor()这个方法,那我们现在看一下这个方法:
我们平时+=,-=遇到的比较多,但是这个|=运算好像平时几乎不会使用,其实一开始的n-1操作,是为了防止我们传入的参数一开始就是2的n次方,可以看出来,这5个公式会通过最高位的1,拿到2个1、4个1、8个1、16个1、32个1。当然有多少个1,取决于我们的入参有多大,但我们肯定的是经过这5个计算,得到的值是一个低位全是1的值,最后返回的时候 +1,则会得到1个比n 大的 2 的N次方。计算机底层是二进制的,移位和或运算是非常快的,所以这个方法的效率很高。
HashMap的容量为什么必须是2的n次方
计算索引位置的公式为:(n - 1) & hash,当 n 为 2 的 N 次方时,n - 1 为低位全是 1 的值,任何值跟 n - 1 进行 & 运算的结果为该值的低 N 位,这样与添加元素的hash值进行位运算时,能够充分的散列,我们添加的元素均匀分布在HashMap的每个位置上,减少hash碰撞,实现了均匀分布。
当HashMap的容量是16时,它的二进制是10000,(n-1)的二进制是01111,我们可以拿几个hash值进行与运算得到几个结果
然后我们自己设置一个n不是2的n次方的情况,再和上面我们举的那几个hash值进行与运算,小伙伴们自己举个例子一眼就能看出来了
是不是没有对比就没有伤害?所以说这里面的每一个细节都是值得思考的~~
HashMap插入流程
我们知道在进行插入数据的时候会有个计算 key 的 hash 值的操作,那这个是什么操作呢?
1拿到 key 的 hashCode值
2拿 hashCode 的高16位和 hashCode 进行异或运算,然后得到最终的 hash 值。
其实仔细想想也很简单,如果不加入高位运算,那结果只取决于 hash 值的低位,无论高位怎么变化,结果都是一样的。那我们就让高位也参与进来,就先进行移位运算就可以了。
HashMap的put方法
可以看到put方法调用的是putval方法,那我们直接看这个putval方法就好了
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);
else {
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;
}
}
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;
}
1、首先我们要判断当前数组是否为空,如果还没有初始化或者数组长度为0我们就进行扩容操作
2、根据hash值计算元素所在数组下标,如果对应位置桶为空的话就,就newNode把新生成结点放入桶中
3、如果桶中已经存在元素了,就与桶中的第一个元素做比较,如果hash值相等,key相等,那就直接覆盖value
4、判断当前Node是否为一棵红黑树,是的话就直接放入树中
5、如果为链表,那就采用尾插法,插到链表的尾部,在遍历链表的时候判断链表中结点的key值与插入的元素的key值是否相等,相等就跳出循环
6、插入完成之后判断是否超过了最大容量,超过就进行扩容(实际大小大于阈值则扩容)
大概的插入流程是这样,那我们然后看看扩容操作
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"})
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;
}
1、先判断原来表的容量是否超过了上限,超过就把阈值修改为Integer的最大值,如果没超过就把容量和阈值扩大为原来的2倍
2、遍历原来的数组,判断当前索引的位置是否有节点,是不是只有一个节点,如果只有一个节点就计算该节点在新表中的位置,然后直接放到新表中
3、如果当前位置不是只有一个节点,判断当前是否是一颗红黑树,是红黑树就遍历当前树,进行(e.hash & oldCap) == 0,如果等于0,就将节点加到loHead(红黑树链表节点转换处理之后放到原索引位置)链表的尾部否则加到hiHead链表尾部
4、如果是是链表,同样进行(e.hash & oldCap) == 0判断,如果等于0节点就加到loHeah(原索引位置)链表尾部,否则节点加到hiHead(原索引+原数组长度)链表尾部
解释一下(e.hash & oldCap) == 0,当我们进行数组扩容之后,n-1和之前的n-1相比只是在高位多了一个一,多出来的那个1正好是原来数组的长度,比如原来n=16,n-1是1111,现在n=32,n-1是11111,第五位多出来的1,不就是16嘛,也就是原数组容量,hash值不变那我们就和原数组容量做与运算,我们只看和原数组做与运算最高的那一位,剩下的都是0了,所以结果只能是0或者1。
HashMap线程安全
HashMap1.7和1.8都不是线程安全的,1.7的多线程操作的时候由于采用头插法可能会造成链表成环,那当我们使用get方法的时候,由于成环了,next一直不为空,就会造成死循环。1.8的时候多线程操作可能会导致数据丢失的问题。HashMap是线程安全的,但是比较古老了,底层大量使用synchronized关键字,效率不高。当然我们也可以使用Collections集合工具类下面的SynchronizedMap方法,但是效率也不高,一般我们使用java.util.currenct包下面的ConcurrenctHashMap,底层大量使用了CAS操作和volatile关键字。所以效率还是很高的。
以上就是我对HashMap的大概了解,还有很多方法比如get(),这里我没有拓展,感兴趣的小伙伴可以去学习一下~~
记得点赞~