可能有小伙伴问,现在Java 14都发布了,我们还在回顾Java 8的内容,不会跟不上时代了吗?其实学习Java 8中HashMap的底层原理,除了应付面试,我们还可以多问问:为什么要做出这些改变?有什么好处吗?
本文主要对HashMap的底层结构和功能原理进行介绍。
(若文章有不正之处,或难以理解的地方,请多多谅解,欢迎指正)
HashMap的底层结构是应用更为广泛的哈希表,了解HashMap,可以先抓住以下几点。
HashMap就是使用哈希表来存储的。在JDK1.8中,HashMap是由3种数据结构组成的:数组+链表+红黑树,而在JDK1.7中是由前两者实现。
因为在接近扩容临界点时,此处如果有两个或多个线程进行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时较少注意到的问题。
在了解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;
关于这个问题,我们可以从最大容量的类型为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的容量最大值。
笔者在HashMap的加载因子为什么是0.75?有详细解答过,加载因子0.75是提高空间利用率和减少查询成本的折衷,因为在加载因子为0.75时,泊松分布的碰撞最小。
HashMap中除了哈希算法之外,有两个参数影响了性能:初始容量和加载因子。初始容量是哈希表在创建时的容量,加载因子是哈希表在其容量自动扩容之前可以达到多满的一种度量。
通常,加载因子需要在时间和空间成本上寻求一种折衷。
加载因子越大,填满的元素越多,空间利用率越高,但发生冲突的机会变大了;
加载因子越小,填满的元素越少,冲突发生的机会减小,但空间浪费了更多了,而且还会提高扩容rehash操作的次数。
所以,选择了0.75作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择。
在JDK1.7中,HashMap中的数组中的每一个元素其实就是Entry
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对象。
链表的时间复杂度为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的时候,链表的性能已经非常差了。所以在这种比较罕见和极端的情况下,才会将链表转换为红黑树。因为链表转换为红黑树也是需要消耗性能的,特殊情况特殊处理,为了挽救性能,才会使用红黑树来提高性能。所以在大部分情况下,HashMap还是使用链表,如果是理想的均匀分布,哈希桶的节点数不到8,HashMap就自动扩容。
HashMap不直接使用红黑树,是因为树节点所占空间是普通节点的两倍,所以只有当节点足够的时候,才会使用树节点。也就是说,尽管时间复杂度上,红黑树比链表好一点,但是红黑树所占的空间比较大,所以综合考虑之下,只有在链表节点数太多的时候,红黑树占空间大这一劣势不太明显的时候,才会舍弃链表,使用红黑树。
需要注意的是,转换成红黑树的条件有两个:
我们先来看段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) 执行了三步操作,接下来简单介绍一下:
这一步会根据key值计算出一个int类型的hashCode值。而根据key计算hashCode值的hashCode()需要分情况进行介绍:
所以,hashCode()方法就是要根据不同的key得到不同的hashCode值。
这一步是将第一步计算出来的hashCode值无符号右移16位,这一步将第三步操作需要的高低位区域分出来了。
这一步将hashCode值的高低16位进行了异或处理,就是为了混合原始哈希码的高低位,以此来加大低位的随机性。而混合后的低位掺杂了高位的部份特征,这样高位的信息也被变相保留下来。
至此,hash函数的散列过程就已经介绍完了,而hash函数的这个过程也称为“扰动函数”。
然而,这三步还不能确定元素存放的位置。元素在数组中存放的位置是由下面这行代码决定的:
i = (n - 1) & hash // i为数组对应位置的索引 n为当前数组的大小
因为这个过程是做’&‘运算的位计算,计算机能直接进行运算,特别高效。’&'运算的计算方法是,只有当对应位置的数据都为1,运算结果才为1,否则为0。所以当HashMap的容量是2的n次幂时,(n-1)的2进制也就是以"1111111"的形式显示,这样与添加元素的hash值进行位运算时,能够充分进行散列,使得添加的元素在HashMap上均匀分布,减少hash冲突。
我们先来整段源码看看:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
HashMap中的put方法会调用putVal()方法,而在调用putVal()方法之前还会先调用hash()函数来计算key的哈希值,接下来我们来看看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()函数进行扩容操作。
前文介绍了,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的结果,将链表中的元素分成两个链表,一个依旧放在原位置,另一个放在原位置+n处。
比起前面的文章,HashMap的前期准备要多一点,拖延症突然发作,所以这篇晚了一点。笔者前面也有一些文章,各位看官有需要可以看看:
关于String的这9个问题,值得一看
你真的有好好了解过序列化吗:Java序列化实现的原理
【Java集合】ArrayList的使用及原理
【Java集合】LinkedList的使用及原理
【Java面试题】除了Vector,还有另一个提供线程安全的List是什么?
计算机网络知识框架总结(复习)
怎么打开Chrome网上应用店+分享Chrome六个好用插件
如果本文对你有帮助,请给一个赞吧,这会是我最大的动力~
参考资料:
HashMap在Jdk1.7和1.8中的实现
Java 8系列之重新认识HashMap
HashMap初始容量为什么是2的n次幂及扩容为什么是2倍的形式
HashMap默认加载因子为什么选择0.75?(阿里)
JDK1.8以后的hashmap为什么在链表长度为8的时候变为红黑树
HashMap中的hash函数
JDK1.8中HashMap如何应对hash冲突?
你真的懂大厂面试题:HashMap吗?