HashMap作为Map的一种实现方式,会频繁的出现的我们的代码中,那么你知道HashMap具体的工作原理,以及为什么是这样工作的吗,本篇文章将带你了解HashMap的底层原理。
首先当我们得知道HashMap的基本结构,在JDK1.8之前HashMap的结构仅仅是数组+链表,结构如下图所示。
横方向上表示的是数组,方便实现快速的定位查询。
竖方向上表示的是链表,方便产生冲突时快速的实现插入或删除。
但这样的结构会有一个缺点,当数组上某一点频繁的产生冲突时,就会形成很长的链表。当我们想要查询时,必须从表头遍历到表尾,这样会极大的消耗时间。因此在JDK1.8以后对HashMap的结构进行了优化,形成了数组+链表+红黑树的结构。
当数组上某处的链表结点数超过8个时就会把自动的把链表结构转化为红黑树结构。红黑树避免了普通AVL树会出现的左、右子子树会特别高的情况,同时也权衡了查询以及插入删除的效率。
了解了HashMap的结构,先来看看HashMap的初始化,当我们new一个HashMap对象时,系统默认会如何构造呢?
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //数组大小
static final float DEFAULT_LOAD_FACTOR = 0.75f; //加载因子
首先,HashMap会默认给定几个参数,其中最重要是这两个,DEFAULT_INITIAL_CAPACITY代表默认的数组大小,1左移4位也就是16,DEFAULT_LOAD_FACTOR表示默认的加载因子,值为0.75。
这里就抛出并解决可以两个问题。
问题一:为什么数组大小设置为16?(为什么数组大小为2的倍数?)
1、JVM在处理左移操作是可以优化性能。
2、在内存申请中都是以2的倍数申请,可以减少内存碎片的产生。
3、可以提高散列度,减少冲突。在后面会讲到,数据存放是通过一个计算公式(n-1)&hash,n代表数组长度,hash表示哈希值,如果n为2的倍数例如16,则它的二进制数为0001 0000,n-1则为0000 1111,与hash值按位与后可以保留后四位,减少了冲突的发生。
问题二:为什么加载因子设置为0.75?
首先要明白什么是加载因子。加载因子代表这Hash数组的填充程度,当数组中的元素个数超过数组大小乘加载因子时(16*0.75=12),则认为该数组饱和了,需要扩充。也就意味着需要在时间与空间上进行取舍。过大的填充因子会导致冲突加大,链表(或红黑树)的查询时间增加,过小的填充因子减少了冲突但会导致数组空间上的浪费。所以0.75是官方给出的相对折中的一个数值。
接着就是关键部分,初始化完成后,我们想要使用put()函数把<K,V>存储到HashMap中,那么这一过程是如何完成的呢?
首先必须获取每一个Key 的Hash值,其源码如下。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以看到Hash值是如何产生的,key继承Object类本身会有一个hash值,先调用本身的hash值再与这个hash值右移16位相异或。那么问题来了,为什么不用key本身的hash值呢?这个问题一会再讨论。
有了新的hash值,就会把hash值,key,value等参数丢掉一个putVal()的函数中。由于源码分析起来太过于枯燥复杂,这里大致将一下具体的流程。首先数组中的元素应该是什么类型的,应该有key和value吧,也应该有hash值,还有作为链表它应该有指向下一个结点的next指针,因此我们先得把这些数据封装在一个对象中,即为Node对象,其结构大致如下:
class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
……
}
封装完了数据,我们就得把它插入到HashMap中去,如何找到自己应该存放的位置,HashMap中给出了这样的公式(n-1)&hash,通过公式计算出数组下标,如果数组空着的,那正好直接放进去就好了。如果数组被占了且为链表,则找到链尾添加到链尾。如果数组被占了且为红黑树,则调用红黑树的插入方法插入。
在这个过程中还伴随这两个if判断。第一个判断,如果链表太长了(长度超过默认的8)则自动把链表转换为红黑树。第二个判断,如果插入后数组元素太多了(超过16*0.75=12),则自动扩充数组,直接再左移一位(扩大一倍)。
整个流程还是十分清晰的,如果感兴趣,可以带着这个流程去看看源码。
最后,我们回到之前的问题,为什么不用key本身的hash值呢?可以看出hash值对于整个存储过程来说是十分重要的,影响着冲突的发生,空间与时间的利用率等诸多问题。所以新的hash值肯定会带来好处:提高散列度,降低冲突的发生。
举个例子,现在有两个key本身的hash值,old_key_1和old_key_2,和优化后的新的hash值new_key_1和new_key_2。
old_key_1 = 0101 0000 0000 1111 经过hash^(hash>>>16)得到 new_key_1 = 0101 0000 0101 1111
old_key_2 = 1101 0000 0000 1111 经过hash^(hash>>>16)得到 new_key_2 = 1101 0000 1101 1111
我们分别用原hash值和新hash值来通过公式(n-1)& hash计算一下。
old_key_1 = 0101 0000 0000 1111 new_key_1 = 0101 0000 0101 1111
n-1 = 0000 0000 1111 1111 n-1 = 0000 0000 1111 1111
___________________________ ____________________________
0000 0000 0000 1111 0000 0000 0101 1111
old_key_2 = 1101 0000 0000 1111 new_key_2 = 1101 0000 1101 1111
n-1 = 0000 0000 1111 1111 n-1 = 0000 0000 1111 1111
___________________________ ____________________________
0000 0000 0000 1111 0000 0000 1101 1111
通过对比可以发现,如果直接使用key的hash值计算得到结果一样,这样就会产生冲突。而通过优化后的hash值通过计算得到两个不同的数组下标,提高来数组的散列度,降低了冲突的可能。