1.引子
这是我们高级并发编程系列的第十五篇,这一篇原来是准备写ConcurrentHashMap,标题我都想好了,叫做:一文搞懂ConcurrentHashMap。
但是想了想,要说清楚ConcurrentHashMap,还需要优先说清楚HashMap,于是我临时把标题换一换,换成一文搞懂HashMap。不过你放心,关于ConcurrentHashMap,我会放到下一篇来分享。
相信这两篇文章,你都会有所收获。我打算从分析底层原理,结合源码的方式,争取让我们在实际项目开发中,用到HashMap,或者ConcurrentHashMap的时候,都能够知其然,还知其所以然。那么让我们开始吧
#考考你:
1.很多时候,你都会直接用到HashMap,那么你真的认识HashMap吗
2.关于HashMap,你知道什么是hash冲突,负载因子,以及如何扩容吗
2.案例
2.1.HashMap原理
2.1.1.原理分析
说起HashMap的底层实现原理,用到的数据结构,你一定很熟系:数组。我们一起来尝试分析一下,为什么HashMap的底层数据结构会选择数组呢?
你可以先回顾一下,数组这种数据结构都有什么特点。我们一起来回忆一下:数组是一种基于线性表的数据结构,支持按照下标随机访问,时间复杂度是O(1),非常高效。
你再回顾一下,平常在项目中使用HashMap,相应的业务场景有哪些?通常情况下,我们把HashMap作为一个容器,比如说本地缓存解决方案,把缓存目标对象,按照key/value的方式存放到HashMap中,需要用到缓存对象的时候,根据缓存key从容器中查找目标对象。
这里你需要注意两个字:查找。等价于说我们使用HashMap,大多数业务场景都是在读多写少的场景(一次写入,多次读取)。对于读我们的期望是要高效,最好是O(1)的时间复杂度,嗯这不数组天然就满足吗?
到这里,我相信你应该能够理解了:为什么HashMap的底层数据结构,会选择数组。我给你看一个对应关系图,就不会那么抽象了:
2.1.2.一图胜千言,图解
上图即是HashMap的底层实现,你需要关注:左边的散列函数、中间的数组、右边的链表。我们来逐个分析一下。
我们已经明确了HashMap的底层数据结构是数组,即HashMap中的元素,其实就是存在数组中,因此中间的数组,对于你来说,理解起来不是什么难事。
这里有一个问题,我们知道数组是按照下标来查找数组中元素的,即下标0,1,2......比如array[0]=小明。实际使用HashMap中,我们是通过key/value键值对的方式,与之对应array[0]是HashMap中key部分,小明是value部分。
关键问题是,HashMap中key可以是任意类型,我们需要一种方式,将任意类型的key,与数组联系起来,准确说是与数组的下标联系起来,即:任意类型key--->转换--->数组下标。这里的转换,即转换函数,就是上图中左边的散列函数hash(key) 。这么解释以后,相信左边散列函数的作用,你可以理解了。
最后,右边的链表到底是什么用意呢?它是解决散列冲突的方法之一拉链法,还有另外一个解决散列冲突的方法开放寻址法。这里我先不解释关于拉链法,与开放寻址法的区别。我们重点关注散列(hash)冲突的问题,为什么就冲突了呢?
我们知道,要把数据放入HashMap容器中,必然有一个过程,即把任意类型的key,转换成数组下标的过程。我们知道容器是已知,且容量是有限的,比如说只能放10个元素的容器;但是要放入容器的元素是未知,无限的。将未知无限的空间,转换到已知有限的空间,必然会有冲突存在。这段话很抽象,你估计在怀疑这是人话吗?
我举个例子,你就明白了:1+4=5,2+3=5,0+5=5,你看等号左边不同,但是右边结果都是5。也就是说hash(key) :hash(1+4),hash(2+3),hash(0+5),不同的key,经过hash函数后,会有相同值的情况出现,这就是hash冲突的由来。你看可以理解了吧。
那既然hash冲突概率上一定会发生,发生概率的大小,取决于hash函数的设计,好的hash函数设计是一件挺有难度的事情,这是另外一个话题,我们暂时不关心。我们将重点放在发生冲突以后怎么处理?这就是我需要给你解释的上图中右边部分的链表解决的问题,如果发生了hash冲突,把冲突的元素通过链表串起来。
图解这部分内容,多少会有些抽象,你需要结合图一起多看一下,一旦看明白了其实也不难。我简单总结一下关于HashMap:
- 左边散列函数:用于将任意类型的key,转换成数组的下标,与数组联系起来
- 中间数组:HashMap的底层数据结构,HashMap中的元素实际上是存储在数组中
- 右边链表:因为HashMap中有发生hash冲突,链表用于将冲突的多个元素串连起来
2.2.源码与差异化分析
2.2.1.关键源码分析
这里我把HashMap的关键源码列举出来,只能起到抛砖引玉的效果,我建议你实际打开完整的源码看一看会更好
/*
*HashMap底层数据结构:数组
*/
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
/*
*HashMap默认初始容量:16
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/*
*HashMap默认负载因子:0.75f
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/*
*HashMap每次扩容:都是原容量的2倍
*/
// 扩容调用
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
// 在原有容量上,扩容2倍
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
// 实际扩容方法
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
2.2.2.差异分析:jdk8与jdk7
关于HashMap差异化分析,实现细节上的差异分析,有两个纬度:
- 针对不同的jdk版本(主要是jdk8,与jdk8以前)
- 差异内容:hash冲突解决方式
前面我们分析了,HashMap中如果发生hash冲突后,通过链表(拉链法)将冲突的多个元素串联起来,解决hash冲突。你需要注意,这是jdk7及以前版本的解决方案。
在jdk8中,除了原有的拉链法,即链表方式解决hash冲突外,还引入了红黑树的解决方案。即当某个点冲突元素个数小于8的时候,还是通过链表解决hash冲突;当冲突元素个数大于等于8以后,将链表转换成红黑树解决hash冲突。
文字描述始终比较抽象,我们通过分享两个图直观看一下,方便你理解:
jdk7拉链法解决hash冲突:
jdk8拉链法、红黑树解决hash冲突: