【Java集合】你回答得出HashMap(JDK1.8)的7个问题吗?
前言
可能有小伙伴问,现在Java 14都发布了,我们还在回顾Java 8的内容,不会跟不上时代了吗?其实学习Java 8中HashMap的底层原理,除了应付面试,我们还可以多问问:为什么要做出这些改变?有什么好处吗?
本文主要对HashMap的底层结构和功能原理进行介绍。
(若文章有不正之处,或难以理解的地方,请多多谅解,欢迎指正)
1. 简单介绍HashMap
HashMap的底层结构是应用更为广泛的哈希表,了解HashMap,可以先抓住以下几点。
1.1 HashMap的底层结构
HashMap就是使用哈希表来存储的。在JDK1.8中,HashMap是由3种数据结构组成的:数组+链表+红黑树,而在JDK1.7中是由前两者实现。
1.2. HashMap的null
- HashMap可以存储null键和null值,其中null键只能有一个,但null值可以有一个或多个。
- 当get(key)方法返回null值时,可以表示HashMap中没有该key,也可以表示这个key对应的value是null。所以,在HashMap中不能使用get()方法来判断HashMap中是否存在某个key,应该用containsKey()方法来进行判断。
- 而在HashTable中,无论是key还是value,都不能为null。
1.3. HashMap的size
- HashMap的初始size为16,扩容机制为newsize = oldsize * 2,HashMap的size一定为2的n次幂。
- 当HashMap中的元素总数超过了Entry数组的75%,则触发扩容操作。为了减少链表长度,元素会分配得更加均匀。
- 每次扩容的时候,已经存储了的元素会依次重新计算存放位置,并重新插入。
1.4. HashMap的JDK1.7与JDK1.8
- JDK1.7底层是由数组+链表实现;而JDK1.8中底层是由数组+链表/红黑树实现。
- 在JDK1.7中是先扩容再插入新值,而JDK1.8中是先插值再扩容。
- 如果是插入元素之后再扩容,有可能会因为扩容后没有元素再次插入,导致无效扩容。
1.5. HashMap的线程不安全
因为在接近扩容临界点时,此处如果有两个或多个线程进行put()操作,这些线程都会进行resize(扩容)和rehash(为key重新进行存储位置),而rehash在并发的情况可能会发生Entry链表形成环形数据结构,这时,Entry的next节点永远不为空,就会产生死循环。
上述情况在JDK1.8有所好转。因为在JDK1.7中采用的是头插法,而JDK1.8中采用了尾插法。且JDK1.7采用的是数组+链表结构,在链表长度过长的时候,会严重影响查询效率。所以在JDK1.8中,当链表长度大于阈值(默认长度为8)时,链表为转为红黑树结构。在此推荐一篇关于JDK1.7HashMap形成环形数据结构的文章:jdk1.7 HashMap中的致命错误:循环链表。
但也并未解决HashMap线程不安全的问题,因为在多线程的情况下,当Node结点转换成TreeNode结点时,可能会报出操作对象内部不一致的问题;也可能在红黑树左右旋的时候的时候出现问题。所以在并发情况下建议使用ConcurrentHashMap。
可以参考:HashMap在jdk1.8中也会死循环(这篇文章可以当做参考,笔者开了万条线程都没刷出来,但就是有人刷出来了,心情复杂.jpg)、JDK8:HashMap源码解析:TreeNode类的balanceInsertion方法(这篇介绍了红黑树的重新结构化,值得一看)
在我们对HashMap有着初步了解之后,下文主要以问答的形式,介绍一些我们在使用HashMap时较少注意到的问题。
2. HashMap是什么?
在了解HashMap的底层结构之前,我们先来看看HashMap的属性:
/**
* The default initial capacity - MUST be a power of two.
* 默认初始容量 - 必须是2的幂次方(后文会对其进行解释)
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
* 如果有更大容量值,也不能超过1<<30(后文会对其进行解释)
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* The load factor used when none specified in constructor.
* 负载因子为0.75(后文会对其进行解释)
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
* 在hash冲突发生的时候,默认采用单链表存储,当单链表节点个数大于8的时候,就会转换为红黑树存储(后文会对其进行解释)
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* The bin count threshold for untreeifying a (split) bin during a
* resize operation. Should be less than TREEIFY_THRESHOLD, and at
* most 6 to mesh with shrinkage detection under removal.
* 当红黑树的节点少于6时,则转换为单链表存储
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* The smallest table capacity for which bins may be treeified.
* (Otherwise the table is resized if too many nodes in a bin.)
* Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
* between resizing and treeification thresholds.
* 虽然在hash冲突发生的时候,默认使用单链表存储,当单链表节点个数大于8时,会转换为红黑树存储
* 但是有一个前提(很多文章都没说):要求数组长度大于64,否则不会进行转换,而是进行扩容。
*/
static final int MIN_TREEIFY_CAPACITY = 64;
2.1. 最大容量为什么是不超过1<<30?
关于这个问题,我们可以从最大容量的类型为Integer下手。为了使得HashMap的容量值是2n,又不能超过Integer的范围:
public static void main(String[] args) {
System.out.println(Integer.MAX_VALUE);
System.out.println(1<<30);
System.out.println(1<<31);
}
运行结果为:
2147483647
1073741824
-2147483648
笔者在Java的基本数据类型、拆装箱(深入版)介绍过,int类型的数据所占空间大小为32位,所以如果超过这个范围之后,会出现溢出。所以,1<<30是在int类型取值范围中2次幂的最大值,即为HashMap的容量最大值。
2.2. HashMap的加载因子为什么是0.75?
笔者在HashMap的加载因子为什么是0.75?有详细解答过,加载因子0.75是提高空间利用率和减少查询成本的折衷,因为在加载因子为0.75时,泊松分布的碰撞最小。
HashMap中除了哈希算法之外,有两个参数影响了性能:初始容量和加载因子。初始容量是哈希表在创建时的容量,加载因子是哈希表在其容量自动扩容之前可以达到多满的一种度量。
通常,加载因子需要在时间和空间成本上寻求一种折衷。
加载因子越大,填满的元素越多,空间利用率越高,但发生冲突的机会变大了;
加载因子越小,填满的元素越少,冲突发生的机会减小,但空间浪费了更多了,而且还会提高扩容rehash操作的次数。
所以,选择了0.75作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择。
2.3. HashMap具体存储的是什么数据?
在JDK1.7中,HashMap中的数组中的每一个元素其实就是Entry<K, V>[] table,Map中的key和value都是以Entry的形式存储的。但是因为在JDK1.8中HashMap需要支持红黑树,所以换成了Node<K, V>的形式,但其实本质上二者很相似。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
....
}
public final K getKey() { ... }
public final V getValue() { ... }
public final String toString() { ... }
public final int hashCode() { ... }
public final V setValue(V newValue) { ... }
public final boolean equals(Object o) { ... }
}
可以看到Node实现了Map.Entry接口,本质是一个映射(键值对),上图中每一个黑点就是一个Node对象。
2.4 为什么要将链表中转红黑树的阈值设为8?
链表的时间复杂度为O(n),而红黑树的时间复杂度为O(log2(n)),红黑树相对于链表来说,是一个相对复杂的数据结构,感兴趣的读者可以参考这篇文章:教你初步了解红黑树。
笔者在HashMap的加载因子为什么是0.75?提到过,在理想情况下,使用随机哈希码,在扩容阈值(加载因子)为0.75的情况下,节点出现在频率在Hash桶(表)中遵循参数平均为0.5的泊松分布,而且在哈希桶中的链表长度达到8个元素的概率为0.00000006,几乎是一个不可能事件。
/* Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* 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
*/
既然几乎是一个不可能事件,那么为什么还要在链表长度为8的时候转换成红黑树呢?
俗话说:圣人千虑,必有一失。愚人千虑,必有一得。
极小概率发生的事件,只要在基数大的环境下,它的发生就是一种必然事件。
当链表长度为8的时候,链表的性能已经非常差了。所以在这种比较罕见和极端的情况下,才会将链表转换为红黑树。因为链表转换为红黑树也是需要消耗性能的,特殊情况特殊处理,为了挽救性能,才会使用红黑树来提高性能。所以在大部分情况下,HashMap还是使用链表,如果是理想的均匀分布,哈希桶的节点数不到8,HashMap就自动扩容。
HashMap不直接使用红黑树,是因为树节点所占空间是普通节点的两倍,所以只有当节点足够的时候,才会使用树节点。也就是说,尽管时间复杂度上,红黑树比链表好一点,但是红黑树所占的空间比较大,所以综合考虑之下,只有在链表节点数太多的时候,红黑树占空间大这一劣势不太明显的时候,才会舍弃链表,使用红黑树。
需要注意的是,转换成红黑树的条件有两个:
- 链表长度超过8;
- HashMap数组长度超过64。
2.5 HashMap中的hash函数是怎么散列的?(HashMap初始容量为什么是2的n次幂?)
我们先来看段hash()函数的源码:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
大家都知道key.hashCode()函数调用的是key键值类型自带的哈希函数,返回int型散列值。
理论上散列值是一个int型的数值,但是如果直接那散列值作为下标访问HashMap主数组的话,考虑到int型数据的取值范围在-2147483648到2147483647,前后加起来大概40亿的映射空间,一般来说只要哈希函数散列的比较均匀松散,一般应用是很难出现碰撞的。
但是40亿长度的数组,内存是放不下的。HashMap的数组初始大小也才16而已,所以这个散列值是不能用的。因此,需要对数组的长度做取模运算,得到的余数才能进行访问数组下标。
(h = key.hashCode()) ^ (h >>> 16) 执行了三步操作,接下来简单介绍一下:
- 第一步:h = key.hashCode()
这一步会根据key值计算出一个int类型的hashCode值。而根据key计算hashCode值的hashCode()需要分情况进行介绍:
- 如果是我们创建的对象,在没有重写hashCode()方法的情况下,会调用Object()类hashCode()方法,返回的是对象的内存地址值。如果对象不同,那么计算出来的hashCode值也不同。
- 如果是Java中定义的引用类型如String、Integer等作为key,这些类一般都会重写hashCode()方法,所以一般会根据不同类型的对象返回不同的值,如Integer类的hashCode即Integer值,而String类型的hashCode()方法稍微复杂一点,这里就不赘述了。
所以,hashCode()方法就是要根据不同的key得到不同的hashCode值。
- 第二步:h>>>16
这一步是将第一步计算出来的hashCode值无符号右移16位,这一步将第三步操作需要的高低位区域分出来了。
- 第三步:h ^ (h>>>16)
这一步将hashCode值的高低16位进行了异或处理,就是为了混合原始哈希码的高低位,以此来加大低位的随机性。而混合后的低位掺杂了高位的部份特征,这样高位的信息也被变相保留下来。
至此,hash函数的散列过程就已经介绍完了,而hash函数的这个过程也称为“扰动函数”。
然而,这三步还不能确定元素存放的位置。元素在数组中存放的位置是由下面这行代码决定的:
i = (n - 1) & hash // i为数组对应位置的索引 n为当前数组的大小
因为这个过程是做’&‘运算的位计算,计算机能直接进行运算,特别高效。’&'运算的计算方法是,只有当对应位置的数据都为1,运算结果才为1,否则为0。所以当HashMap的容量是2的n次幂时,(n-1)的2进制也就是以"1111111"的形式显示,这样与添加元素的hash值进行位运算时,能够充分进行散列,使得添加的元素在HashMap上均匀分布,减少hash冲突。
举个栗子,这整个过程就是这样的:
2.6 HashMap的put()方法是什么样的?
我们先来整段源码看看:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
HashMap中的put方法会调用putVal()方法,而在调用putVal()方法之前还会先调用hash()函数来计算key的哈希值,接下来我们来看看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;
//判断表是否为空,如果为空,则调用resize()函数初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果没有hash冲突,直接插入即可
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);
//如果链表长度到达了转换陈红黑树的长度,执行treeifyBin()方法
//如果hashMap数组长度小于64,则进行扩容操作
//否则进行树化操作
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,则考虑值覆盖
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;
}
可以看到,首先会先判断table是否为空,如果是空的话,就调用resize()函数进行初始化。之后进行hash映射计算(n-1)&hash,得到元素在HashMap中的位置,如果没有发生hash冲突,就直接插入。
之后需要分两种情况来讨论:
- 红黑树结构:按照红黑树的方式进行插入或覆盖。
- 链表结构:使用尾插法进行插入或替换,并且如果插入后,超过规定的阈值,链表结构会转换成后红黑树结构。
最后,如果插入元素后发现数组中包含的元素超过了加载阈值,则调用resize()函数进行扩容操作。
2.7 HashMap的扩容机制是什么样的?(HashMap的扩容为什么是2倍的形式?)
前文介绍了,HashMap的数组元素数量超过了加载阈值,则会触发扩容机制,而且扩容了之后,原数组会进行rehash的过程。接下来具体看看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;
//以下是rehash的过程
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;
//第一种情况:n&hash == 0
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//第二种情况:n&hash != 0
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;
}
我们看到,如果原数组容量扩大两倍之后小于最大容量阈值,就可以进行扩容操作,而且不仅原数组扩容成两倍大小,加载阈值也随之扩大两倍。而数组大小必须是2的幂次方,因为在进行(n-1)&hash计算的时候,只有在n为2的幂次方时,(n-1)才能是前部均为0,尾部均为1的形式,这样在进行(n-1)&hash运算时,其范围才会是[0, n-1]。
在扩容之后,原数组必然会有一个rehash的过程。此处有两种情况:
-
红黑树结构:通过分裂实现rehash过程。
-
链表结构:分情况,主要是通过原数组大小n与结点的hash值之间的’&'操作结果。(以下结论参考:你真的懂大厂面试题:HashMap吗?)
- 如果n & hash == 0,则 (2n-1) & hash == (n-1) & hash ;
- 如果n & hash != 0,即 (2n-1) & hash == (n-1) & hash + n ;
所以可以根据n & hash的结果,将链表中的元素分成两个链表,一个依旧放在原位置,另一个放在原位置+n处。
结语
比起前面的文章,HashMap的前期准备要多一点,拖延症突然发作,所以这篇晚了一点。笔者前面也有一些文章,各位看官有需要可以看看:
【Java面试题】除了Vector,还有另一个提供线程安全的List是什么?
怎么打开Chrome网上应用店+分享Chrome六个好用插件
如果本文对你有帮助,请给一个赞吧,这会是我最大的动力~
参考资料:
HashMap初始容量为什么是2的n次幂及扩容为什么是2倍的形式