HashMap 是平常开发中,用的最多的集合类之一,也是面试中常常被问到的 Java 类之一。同时,HashMap 在实现方式上面又有十分典型的范例。不论是从哪一方面来看,学习 HashMap 均可以说是有利无害的。java
分析 HashMap 的源码的文章在网上面已经数不胜数了,本文就另辟蹊径来分析 HashMap 的设计思想。面试
说到 HashMap 的数据库,咱们须要从两个 JDK 版原本分析:JDK7
和 JDK8
。算法
JDK7 版本的 HashMap 的数据结构为:数组 + 链表
。而 JDK8 版本的 HashMap 的数据结构为: 数组 + 链表 + 红黑树
。能够看到 7 和 8 中 HashMap 的底层数据结构最主要的区别就是 Java8 多了红黑树。数据库
上文中说到了 不论是 7 或者8 ,底层数据结构都是 数组 + 链表,但这又是为何呢?数组
数组是一个链式数据结构。put
的时候,经过哈希函数将数据进行 哈希运算 以后,就获得数组的下标,这样子就能够将数据保存在对应的槽中,这个槽在 HashMap 中被称为 Entry。在 get
时候,经过相同的哈希函数,将 key 进行哈希运算,能够获得对应的下标,就能够快速找到该 key 对应的 value。这时候, get 的时间复杂度仍是 O(1)。数据结构
但,哈希运算就避免不了有哈希冲突,也就说,不一样的值经过哈希运算以后可能获得同一个值。在散列表的相关概念中,咱们说了几种解决哈希冲突的方案,在 HashMap中,则是采用了链表法。函数
也就是说,发生了冲突以后,咱们在Entry
中造成一个单链表。可是这里有存在了一个问题,若是链表过长,检索起来的效率一样也会很低。因而,在 Java8 中,经过链表转红黑树来解决这个问题。性能
为何要链表转红黑树,咱们须要从数据结构来解析。学习
若是从一个无序单链表中检索数据,咱们只能从头至尾一个一个检索,一旦数据量很大的状况下,检索的效率就很低。这时,咱们想到了红黑树,从目前的状况来看,红黑树能很好地解决这个问题。spa
咱们先来看看红黑树的定义:
红黑树是每一个节点都带有颜色属性的二叉查找树,颜色为红色或黑色。在二叉查找树强制通常要求之外,对于任何有效的红黑树咱们增长了以下的额外要求:
要是红黑树,首先得是二叉查找树:
二叉查找树(英语:Binary Search Tree),也称为二叉搜索树、有序二叉树(ordered binary tree)或排序二叉树(sorted binary tree),是指一棵空树或者具备下列性质的二叉树:
简单作一个总结,红黑树的左节点要比父节点小,右节点要比父节点大。若是要检索一个数字,能够将时间复杂度从 O(n) 下降到 O(logn)。
固然了,添加了红黑树的数据结构以后,代码实现要比 只用数组 + 链表要复杂了好几倍。看代码的时候兼职是不能再痛苦了。
在源码中有这么一个字段,static final int TREEIFY_THRESHOLD = 8;
,见字知义,这个字段的意思链表转红黑树的阈值,也就是 8。一样的,还有这么一个字段,static final int UNTREEIFY_THRESHOLD = 6;
,它意思是红黑树转链表的阈值。
为何是 8 呢?在源码的注释中也有解释,英文翻译过来就是下面的意思。
链表查询的时间复杂度是 O (n)
,红黑树的查询复杂度是 O (log n)
。在链表数据很少的时候,使用链表进行遍历也比较快,只有当链表数据比较多的时候,才会转化成红黑树,但红黑树须要的占用空间是链表的 2 倍,考虑到转化时间和空间损耗,因此咱们须要定义出转化的边界值。
在考虑设计 8 这个值的时候,咱们参考了泊松分布几率函数,由泊松分布中得出结论,链表各个长度的命中几率为:
* 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
复制代码
意思是,当链表的长度是 8 的时候,出现的几率是 0.00000006,不到千万分之一,因此说正常状况下,链表的长度不可能到达 8 ,而一旦到达 8 时,确定是 hash 算法出了问题,因此在这种状况下,为了让 HashMap 仍然有较高的查询性能,因此让链表转化成红黑树,咱们正常写代码,使用 HashMap 时,几乎不会碰到链表转化成红黑树的状况,毕竟概念只有千万分之一。
为何两个阈值不同的,你们想一想,若是同样的,在链表达到8 的时候,会转成红黑树,但红黑树转链表的阈值也是8,这时候就会出现循环转换。
链表转红黑树还有一个条件,就是当数组容量大于 64 时,链表才会转化成红黑树
在说扩容以前,先来讲说 HashMap 在 7 和 8 中初始化时的不一样表现。
在 Java 7 中,HashMap 初始化的时候,会有个默认容量 (16)。但在 Java8 中,HashMap 初始化的时候,默认容量为0,只有在第一次 put 的时候,才会扩容到 16。(其实 ArrayList 在 Java8 也是这么表现的)。
在 HashMap 源码中,有一个字段定义 static final float DEFAULT_LOAD_FACTOR = 0.75f;
。这个字段的意思是,当HashMap 的长度 = HashMap 当前容量 * 0.75
的时候,就会发生扩容。
关于为何负载因子是 0.75,咱们能够在源码注释找到必定的答案。
大体意思就是说负载因子是0.75的时候,空间利用率比较高,并且避免了至关多的Hash冲突,使得底层的链表或者是红黑树的高度比较低,提高了空间效率。
HashMap
的扩容是变成原先容量的 2 倍。
咱们先来看看 Java 8 的 hash 函数。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
复制代码
这里的大概意思就是,先计算出 key 的 hashCode h
。而后计算计算 h ^ (h >>> 16)
。无符号右移16位。这么作的好处是使大多数场景下,算出来的 hash 值比较分散。
通常来讲,hash 值算出来以后,要计算当前 key 在数组中的索引下标位置时,能够采用取模的方式,就是索引下标位置 = hash 值 % 数组大小
,这样作的好处,就是能够保证计算出来的索引下标值能够均匀的分布在数组的各个索引位置上,但取模操做对于处理器的计算是比较慢的,数学上有个公式,当 b 是 2 的幂次方时,a % b = a &(b-1),因此此处索引位置的计算公式咱们能够更换为: (n-1) & hash。
此问题能够延伸出三个小问题:
1:为何不用 key % 数组大小,而是须要用 key 的 hash 值 % 数组大小。
答:若是 key 是数字,直接用 key % 数组大小是彻底没有问题的,但咱们的 key 还有多是字符串,是复杂对象,这时候用 字符串或复杂对象 % 数组大小是不行的,因此须要先计算出 key 的 hash 值。
2:计算 hash 值时,为何须要右移 16 位?
答:hash 算法是 h ^ (h >>> 16),为了使计算出的 hash 值更分散,因此选择先将 h 无符号右移 16 位,而后再于 h 异或时,就能达到 h 的高 16 位和低 16 位都能参与计算,减小了碰撞的可能性。
3:为何把取模操做换成了 & 操做?
答:key.hashCode() 算出来的 hash 值还不是数组的索引下标,为了随机的计算出索引的下表位置,咱们还会用 hash 值和数组大小进行取模,这样子计算出来的索引下标比较均匀分布。
取模操做处理器计算比较慢,处理器对 & 操做就比较擅长,换成了 & 操做,是有数学上证实的支撑,为了提升了处理器处理的速度。
hash 冲突指的是 key 值的 hashcode 计算相同,但 key 值不一样的状况。
若是桶中元素本来只有一个或已是链表了,新增元素直接追加到链表尾部;
若是桶中元素已是链表,而且链表个数大于等于 8 时,此时有两种状况:
这里不只仅判断链表个数大于等于 8,还判断了数组大小,数组容量小于 64 没有当即转化的缘由,猜想主要是由于红黑树占用的空间比链表大不少,转化也比较耗时,因此数组容量小的状况下冲突严重,咱们能够先尝试扩容,看看可否经过扩容来解决冲突的问题。
文章首发于:baozi.fun/archives/th…