1、HashMap的数据结构
HashMap本质上是一个一定长度的数组,数组中存储的是链表。JDK1.7中,HashMap采用的是数组+链表方式;JDK1.8中,HashMap采用的是数组+链表/红黑树。
2、HashMap如何添加元素?JDK1.7和1.8有什么不同呢?
调用HashMap的put(key, value)来添加元素。首先先通过hash算法计算出存放到数组中的位置索引,例如计算出来位置索引为i,那么就将新添加的元素B放入到数组[i]中;如果这个数组上面已经存储了元素A,那么就将元素B放入链表中。(JDK1.7中元素加入链表使用的是头插法,JDK1.8中元素加入链表使用的是尾插法)
加入链表的具体过程:B.next = A; 数组[i] = B; 由此可知,数组索引位置上存储的是新加入的元素。
3、HashMap的putVal()源码(JDK1.8)
/**
* Implements Map.put and related methods.
*
* @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;
//通过(n - 1) & hash进行位置索引的计算
//如果首节点没有元素,则新建一个节点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//首节点有元素,以下是发生了哈希冲突的情况
else {
Node<K,V> e; K k;
//如果首节点的hash和key值,与我们即将插入的元素的hash和key相同,那么先用e保存首节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//如果首节点的类型是红黑树,那么就是用putTreeVal方法添加元素
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//首节点类型为链表
else {
//通过for循环遍历链表,在尾部插入元素
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;
}
//如果遍历过程中有hash和key与待插入元素相同的元素就退出循环,e已经存储了该节点信息
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//上面第一个if语句中,已经执行了e = p.next,也就是循环到了下一个节点,此时p = e进行交接,本质上是p = p.next。
p = e;
}
}
//e != null说明冲突链表中有相同的元素,进行覆盖。所以HashMap中的元素都是不重复的。
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//判断当前的节点个数,如果> threshold需要进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
4、索引计算的(n - 1) & hash?
putVal()传进来的hash,是通过hash(key)算出来的。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
综上总结,存储索引i的计算过程:
(1)首先通过key的hashCode()计算出key的哈希码,通过内存地址返回的一个哈希码。
(2)进行哈希码处理。也就是扰动处理,哈希码的低16位与高16位进行一个异或操作。
(3)最后通过(length - 1) & hash计算存储索引i。
5、那么哈希码为什么要做一个扰动处理?
(1)如果(n - 1) & key.hashCode(),没有做扰动处理。HashMap的length一般情况是小于2的16次方的,所以一般是哈希码的低16位来参与计算,这样下标的哈希冲突的几率会更大。而且高16位也是用不到计算中的。但是如果高16位也参与运算,下标结果会更加散列。
其次,hashCode()方法的返回值为int,int的范围为-2^31~(2^31 - 1),而HashMap的容量范围为16~2^30,且HashMap的容量一般不会取到最大值,所以通过hashCode()算出的哈希值可能不在数组大小范围,所以就无法存储位置。
(2)做扰动处理。通过让高16位加入运算,使得下标结果更加的散列(也就是让哈希码的低位更加的随机)。那么高16位如何加入运算?让哈希码与哈希码的高16位进行异或运算,结合哈希码的低16位与高16位。即key.hashCode() ^ (key.hashCode() >>> 16)。
6、JDK1.8为什么要引入红黑树呢?
在JDK1.7中,数据结构使用的是数组+链表,在查找数据的时候,随着HashMap中存储的数据越来越多,冲突越来越多,链表越来越长。那么查找元素的平均时间复杂度就变为了O(n)。所以为了提高HashMap的查询效率,JDK1.8中引入了红黑树的结构。红黑树的平均时间复杂度为O(logn)。
7、红黑树的查找更快,那为什么JDK1.8中还要保留链表?
在JDK1.8中规定,当链表长度大于8的时候,将链表结构优化成为红黑树结构。当节点足够多的时候,链表查找性能下降,而红黑树的查找复杂度是明显优于链表的。但是红黑树节点所占的空间是链表的两倍,所以只有当节点足够多的时候,我们才会将链表结构转换为红黑树结构,从而提升查询速率。当节点比较少的时候,HashMap优先选择链表来进行存储。
8、为什么链表长度为8转化为红黑树?为什么是8?
在理想状态下,受随机分布的hashCode影响,链表中的节点是遵循泊松分布的。根据统计,链表长度为8的概率是非常小的,而链表长度为8的时候,性能已经非常差了,所以HashMap此时将链表结构转换为红黑树的结构。
详细解释:https://blog.csdn.net/weixin_43883685/article/details/109809049
9、链表长度为8,就一定会转换为红黑树吗?
在putVal()方法中,是调用了treeifyBin()方法来转换红黑树的。
/**
* Replaces all linked nodes in bin at index for given hash unless
* table is too small, in which case resizes instead.
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//先判断数组的长度是否小于MIN_TREEIFY_CAPACITY(64),如果小于则先扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//如果数组长度大于64了,就转换为红黑树
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
10、HashMap的数组长度为什么要保证为2的幂次方?
(1)只有当数组长度为2的幂次方时,hash & (length - 1)才等价于 hash % length。从而实现key的定位。
(2)如果length为2的幂次方,那么转化为二进制一定是111...的形式,与hash的二进制操作会非常快。如果length不是2的幂次方,假设length为15,那么length - 1位14,那么二进制就为1110,不论hash的最后一位是1或者是0,&操作中得到的最后一位都是0。所以,0001、0011、0101、1001等位置都无法存放数据。可存储的空间比数组原本的空间小了很多,那么元素的碰撞概率也会变大,从而减慢了查询的速率,这样也就造成了空间的浪费。
参考:
Java集合 深度总结70 道面试题资料分享