目录html
春节拜年取消,在家花了好多天时间啃一啃HashMap的源码,一样是找了不少不少的资料,有JDK1.7的,也有JDK1.8的,固然本文基于JDK1.8。将所学到的东西进行整理,但愿回过头再看的时候,有更深入的看法。java
先来看看史诗级长屏之官方介绍
算法
实际上,在JDK1.8中,HashMap底层是依据数组+单链表+红黑树的结构存储数据的。具体是怎么样的呢?
数组
HashMap实现了Map接口,维护的是一组组键值对,以便于咱们根据键就能马上获取其对应值。另外,HashMap用了特殊的手法,优化了它的性能,咱们本篇来具体学习并总结一下。数据结构
可是,哈希函数并非万能的,两个不一样的元素彻底有可能算出相同的哈希值,这个时候就产生了哈希碰撞。app
但,又有一个问题,要是真的出现了极端的状况:有大量的元素经过哈希函数求得的值汇集在同一个链表上,这时想要找到这个元素,须要花费大量的时间。JDK1.8中,运用了红黑树结构,链表中的节点数>TREEIFY_THRESHOLD时,链表结构将会转化为树形结构,将查找元素的时间复杂度从O(n)降为O(logn),大大提升了效率。函数
再看看HashMap中定义的一些常量:性能
//序列号 private static final long serialVersionUID = 362498820763181265L; //默认的初始容量为16(必须为2的幂) static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 //容许的最大容量2的30次幂 static final int MAXIMUM_CAPACITY = 1 << 30; //没有指定负载因子时,默认为0.75f static final float DEFAULT_LOAD_FACTOR = 0.75f; //链表转化为红黑树的阈值 static final int TREEIFY_THRESHOLD = 8; //红黑树退化为链表的阈值 static final int UNTREEIFY_THRESHOLD = 6; //数组的容量大于64时,桶才有可能转化为树形结构 static final int MIN_TREEIFY_CAPACITY = 64;
还有一些成员变量:学习
//存储的元素的数组,数组容量必定时2的幂次 transient Node<K,V>[] table; //存放具体元素的集 transient Set<Map.Entry<K,V>> entrySet; //存放元素的个数 transient int size; //每次更改结构的计数器 transient int modCount; //阈值,尚未分配数组时,阈值为默认容量或指定容量,以后该值等于容量*负载因子 int threshold; //负载因子 final float loadFactor;
咱们根据源码,来看看在JDK1.8中,这些究竟是如何实现的,以及为何要这样考虑。
仍是先看看其中三个构造器(暂时先忽略最后一个):优化
//无参构造器 public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } //指定容量的构造器 public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } //两参构造器 public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); } //传入映射集的构造器 public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }
这就是HashMap中提供的四个构造器,咱们从中能够察觉出一些端倪。
tableSizeFor
对咱们传入的初始容量进行计算,并为阈值赋值。说到这,咱们来看看这个巧妙的tableSizeFor
,咱们经过注解能够知道,这个方法返回的是大于等于传入值的最小2的幂次方(传入1时,为1)。它究竟是怎么实现的呢,咱们来看看具体的源码:
static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
说实话,我再看到这个方法具体实现以后,感叹了一句,数学好牛!我经过代入具体数字,翻阅了许多关于这部分的文章与视频,经过简单的例子,来作一下总结。
咱们再传入更大的数,为了写着方便,这里就以8位为例:
int n = cap -1
这一步实际上是为了防止cap自己为2的幂次的状况,若是没有这一步的话,在一顿操做以后,会出现翻倍的状况。好比传入为8,算出来会是16,因此事先减去1,保证结果。n>=MAXIMUM_CAPACITY的状况的断定,排除了移位和或运算以后所有为1的状况。
讲到这里,我知道了为何数组的容量老是2的幂次数了:是由于运算规定,可是这基本不算是缘由,选择2的幂次方数必定有出于便利的方面的缘由,这部分咱们待会再说。
咱们在分析成员变量的时候说过,
threshold
是用来表示一个阈值,表示数组容量和负载因子的乘积。可是咱们发现,还没分配数组的时候,实际上是咱们不小于指定容量的二次幂。
那么,数组何时才进行初始化呢?脑瓜子转一下,应该就知道,是往里面存元素的时候。咱们来看一看HashMap里面存储元素的方法。
//联系指定的键Key和值Value,若是在这以前map包含相同的key,返回旧key对应的value public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
其中调用了hash方法,对传入的键key进行哈希计算,具体计算细节以下:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
咱们着重了解一下,key不为null的状况下hash函数的实现,具体为啥要这样设计,咱们以后再总结:
有效地将高低位二进制特征混合,防止由高位的细微区别产生的频繁哈希碰撞,具体能够看一下文末的参考连接。
下面是一个及其关键的方法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; //若是数组未初始化或者长度为0,则调用resize()初始化数组 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //根据hash值计算数组中的桶位,若是为null,则在该桶位上新建节点 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; //hash值相同,落入同一个桶中,且key相同 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 { //在节点后面插入新节点,桶中链表最多有8个节点,再加就变成了树 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //超过阈值,转为树形 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //判断后面节点是否存在key相同的状况 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; //e=p.next;p=e;这两步完成遍历 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; //容量大于阈值,resize(); if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
在没了解resize方法以前,咱们暂且将他定义成扩容和重哈希的重要方法,咱们先就putVal方法进行一些总结:
p = tab[i = (n - 1) & hash]
,n为数组的长度,它是2的幂次方,咱们很容易可以明白,经过(n-1)&hash产生的索引值必然落在0~n-1的范围内,至关于i=hash%n
,可是位运算的效率更高。这就是容量设置为2的幂次方数的另外缘由。(k = p.key) == key || (key != null && key.equals(k)))
,这一步两边分别表示key是否为null的状况。TREEIFY_THRESHOLD
为8,是链表结构转换为树形结构的阙值,经过源码咱们能够知道,链表结构最多只能存储8个节点,若是要存第9个,就须要调用treeifyBin(tab, hash);
,转换为树。++size > threshold)
,从这部分咱们能够看出,除了初始化的时候是先resize再插入,其余的时候都是先插入,再判断是否须要扩容。那么接下来,终于轮到resize方法了,咱们先看一下代码的实现部分,哇这部分但是花了我好多的功夫,若是还有理解不正确的地方,还但愿评论区批评指正:
final Node<K,V>[] resize() { //oldTab存储的是扩容前的数组 Node<K,V>[] oldTab = table; //oldCap存储的是扩容前的数组容量 int oldCap = (oldTab == null) ? 0 : oldTab.length; //oldThr存储的是扩容前的阈值 int oldThr = threshold; //newCap新数组容量,newThr新数组阈值 int newCap, newThr = 0; if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { //若是老数组容量比数组最大容量还大,阈值变为Integer的最大值,返回老数组 threshold = Integer.MAX_VALUE; return oldTab; } //新数组容量变为老数组容量的两倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //新阈值变为两倍须要上面的条件都成立(一、扩容两倍以后的数组容量小于最大容量二、老容量大于等于16) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold //使用带有初始容量构造器,让新容量变为经过initial capacity求得的threshold newCap = oldThr; else { // zero initial threshold signifies using defaults //使用默认构造器,初始化容量为16 newCap = DEFAULT_INITIAL_CAPACITY; //新容量变为16,新阈值变为0.75*16 = 12 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); } //将newThr赋值给threshold表示阈值 threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; //数组若是进行初始化的步骤,不用进入下面的代码段 //判断老数组是否为空 if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { //建立临时节点存储老数组oldTab上的元素 Node<K,V> e; //若是老数组上索引j的位置不为null 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; //在原来索引位置新建链表 if ((e.hash & oldCap) == 0) { //尾节点为空时 if (loTail == null) //头节点指向原头节点,再也不变化 loHead = e; else //在尾部接上老数组中的当前节点 loTail.next = e; //尾节点指向当前节点 loTail = e; } //在原来索引位置+老数组容量的位置新建链表 else { //与上述相同 if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } //while循环保证从到到尾遍历链表 } while ((e = next) != null); //若是尾节点不为空,就让它的next指向空,链表完整 if (loTail != null) { loTail.next = null; //新数组的原索引位置指向链表头节点 newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; //新数组的原索引加老数组容量的索引位置指向链表头节点 newTab[j + oldCap] = hiHead; } } } } } return newTab; }
咱们先谈一谈数组的初始化部分:
initialCapacity
的时候,threshold一开始表示的是大于等于initialCapacity最小的2的幂次方数,直到第一次添加元素时进行扩容,数组容量为threshold的值,而threshold此时为指定负载因子与数组容量的乘积。咱们重点谈一谈数组的搬移的基础部分:
newTab[e.hash & (newCap - 1)] = e;
。((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
。最难的是,发生哈希碰撞时,数组的搬移是如何实现的呢?咱们能够发现,源码中对e.hash & oldCap
的值是0仍是1进行了分类判断,为啥要这样作呢?
咱们首先必须明确,一样的哈希值,扩容先后的区别只是在于被截取的那一位,就拿26而言(0001 1010),以16为容量时,它的有效索引位置为1010,而以32为容量时,它的有效索引则是11010,恰好差了10000,即oldCap,以下图:
e.hash&oldCap
为0,节点在新数组中的索引不变,newTab[j]。e.hash&oldCap
为1,节点在新数组中的索引值 = 老数组容量+原索引值,newTab[j + oldCap]。了解完这个,咱们对其中哈希碰撞时节点搬移的代码的分析开始!
关于其中针对e.hash & oldCap
不一样而定义的一对做用相同的节点,咱们暂且将他们单独拎出来,研究loHead和loTail,另一对其实同理便可。
//do……while循环 do{ next = e.next; }while((e = next)!=null);
loTail.next = null;
newTab[j] = loHead;
最后的最后,本文还有许多方面须要完善或者修改,以后会陆续将新体会上传,还望评论区批评指正。
参考:
HashMap中的hash算法中的几个疑问
HashMap中的hash函数
jdk1.8 HashMap工做原理和扩容机制(源码解析)
Java 1.8中HashMap的resize()方法扩容部分的理解