在写上一篇线性表的文章的时候,笔者看的是Android源码中support24中的Java代码,当时发现这个ArrayList和LinkedList的源码和Java官方的没有什么区别,然而在阅读HashMap源码的时候,却发现Android中的Java与官方版的出入略大,遂不得不转而用Eclipse导入jdk源码阅读,这里不得不吐槽一句,用惯了IDEA的快捷键,Eclispe还真是用不习惯~~好了,接下来我们言归正传:
一.什么是Hash?什么是HashMap?Hash音译为“哈希”,直译为“散列”,是一种信息摘要算法,但他不是加密。散列函数(或散列算法,又称哈希函数,英语:Hash Function)是一种从任何一种数据中创建小的数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。该函数将数据打乱混合,重新创建一个叫做散列值(hash values,hash codes,hash sums,或hashes)的指纹(维基百科)。
我们平时常用的MD5,SSL等都属于Hash算法,通过Key进行Hash的计算,就可以获取Key对应的HashCode。
上一篇有关线性表的文章线性表及ArrayList/LinkedList源码分析总结中,我们讲了ArrayList和LinkedList的优缺点——数组的特点是:寻址容易,插入和删除困难;而链表的特点是:寻址困难,插入和删除容易。
如果我们综合两者的特点,就可以得到本篇我们要讲的内容——HashMap(直译散列表,音译哈希表)。我们知道,java.util中的clloection集合类中, 最为常用的两种是List和Map类,我们之前将的ArrayList和LinkedList都是List集合类旗下的,而HashMap则是属于Map集合的阵营。为什么说HashMap集合了前面两种数据结构的特点呢?HashMap最常见的实现方法是拉链法——即一系列链表为数组元素组成的数组。如图:
从上图我们可以看到,HashMap由链表+数组组成,他的底层结构是一个数组,而数组的元素是一个单向链表。图中是一个长度为16位的数组,每个数组储存的元素代表的是每一个链表的头结点。
上面我们说了有关Hash算法的事情,通过Key进行Hash的计算,就可以获取Key对应的HashCode。好的Hash算法可以计算出几乎出独一无二的HashCode,如果出现了重复的hashCode,就称作碰撞,就算是MD5这样优秀的算法也会发生碰撞,即两个不同的key也有可能生成相同的MD5。
正常情况下,我们通过hash算法,往HashMap的数组中插入元素。如果发生了碰撞事件,那么意味这数组的一个位置要插入两个或者多个元素,这个时候数组上面挂的链表起作用了,链表会将数组某个节点上多出的元素按照尾插法(jdk1.7及以前为头差法)的方式添加。
①DEFAULT_INITIAL_CAPACITY&MAXIMUM_CAPACITY
首先当我们通过无参的构造函数new一个hashMap的时候,系统就会帮我们指定你HashMap的默认大小为DEFAULT_INITIAL_CAPACITY也就是24,当然我们可以刻自己制定初始容量的大小,只不过要注意必须是2n且小于MAXIMUM_CAPACITY(230)。
②DEFAULT_LOAD_FACTOR&loadFactor
LOAD_FACTOR(负载因子)和上面的CAPACITY(容量)的关系,简单来说,Capacity就是数组的长度/大小,loadFactor是这个数组填满程度的最大比比例。同样的,我们可以根据有参的HashMap构造函数来指定初始负载容量的大小,如果不指定,默认的负载因子为0.75。
③size&threshold
size表示当前HashMap中已经储存的Node<key,value>的数量,包括数组和链表中的的Node<key,value>。threshold表示扩容的临界值,如果size大于这个值,则必需调用resize()方法进行扩容,具体的扩容过程我们之后会讲。
这里先说一下threshold值是怎么得到的,在jdk1.7及以前,threshold = length * Load factor,其中length为数组的长度,也就是说数组的长度成负载因子的数量。这里需要说明一点,默认负载因子0.75是是对空间和时间(纵向横向)效率的一个平衡选择,建议大家不要修改。
而在jdk1.8中,这个值的计算算法得到了进一步改进,成了这个:
cap是我们初始传递的Capacity值(或者默认的24),看注释我们可以知道,这么一系列位移操作算法最后是为了得到一个power of two size的值。为什么jdk中一再要强调这个2n这个值呢?这个等会我们再分析。
可以看到,这个Node<K,V>[]是HashMap的一个内部类,他既是HashMap底层数组的组成元素,又是每个单向链表的组成元素。它其中包含了数组元素所需要的key与value,以及链表所需要的指向下一个节点的引用域next。当然这个hash值是系统在创建Node时通过一定的算法计算出来的一个int值,之后我们会讲。
2.HashMap的构造函数jdk1.8中,HashMap共有四种构造函数:
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(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } public HashMap(Map<? extends K, ? extends V> m) { this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); }首先看源码:
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; //tab[]为数组,p是每个桶 ① if ((tab = table) == null || (n = tab.length) == 0) //第一步,table为空,则调用resize()函数创建一个 n = (tab = resize()).length; ② if ((p = tab[i = (n - 1) & hash]) == null) //第二步,计算元素所要储存的位置index,并对null做出处理 //注意这里,如果tab==null,说明这个位置上没有元素,这个时候就创建一个新的Node元素 tab = newNode(hash, key, value, null); else { //else,否则,也就是,这个要添加的位置上面已经有元素了,也就是发生了碰撞。这个时候就要具体情况分 //类讨论:1.key值相同,直接覆盖 2.链表已经超过了8位,变成了红黑树 3.链表是正常的链表 Node<K,V> e; K k; if (p.hash == hash && //如果节点key存在,则覆盖原来位置的key ((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); //链表长度大于8转换为红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } //如果节点key存在,则覆盖原来位置的key,同时将原来位置的元素,沿着链表向后移一位 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } 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; }上面代码中加了一些注释,希望读者仔细分析这段代码中的逻辑。可以看到,我们将put一个元素的过程分为四步,下面我们分步骤讲解:
第一步:table为空,则调用resize()函数创建一个这里的table就是我们在第一大点“HashMap基本元素”中说的Node<K,V>[] table;也就是HashMap的基本子节点。关于这个元素,这里还需要多说一点,在他声明的地方有一段注释:
/** * The table, initialized on first use, and resized as * necessary. When allocated, length is always a power of two. * (We also tolerate length zero in some operations to allow * bootstrapping mechanics that are currently not needed.) */ transient Node<K,V>[] table;其中明确提到:这个table数组在第一次使用时需要初始化。在JDK1.7中源码的构造函数中,我们发现:
public HashMap(int initialCapacity, float loadFactor) { ...... this.loadFactor = loadFactor; threshold = (int)(capacity * loadFactor); table = new Entry[capacity]; //注意这里初始化了 init();}也就是说,在jdk1.7中,当HashMap创建的时候,table这个数组确实会初始化;但是到了jdk1.8中,我们观察上面四个构造函数,除了第四个构造函数调用了resize()外,其他三个常用的构造函数都没有与table初始化相关的迹象,而真正table初始化的地方是在我们上面讲的putVal()方法中,即首次向HashMap添加元素时,调用resize()创建并初始化了一个table数组。
这里笔者的理解是,类似于“懒加载”,用的时候再初始化,这样有利于节省资源~~同时,估计1.7和1.8的代码不是一个工程师写的吧,代码优化之后注释忘了改…关于resize()方法,我们之后再讲。
储存位置即研究这个这个新添加的元素是通过何种规则添加到什么位置的,我们注意到这句源码:p = tab[i = (n - 1) & hash]可以看到,index = (n - 1) & hash。n是新创建的table数组的长度:(tab = resize()).length;,那么hash是什么呢?注意到这两段代码:
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) { ......可以看待,hash是由hash(key)这个方法计算所得:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }这里可以看到,首先将得到key对应的哈希值:h = key.hashCode(),然后通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16)我们分布来讲解这个index产生的步骤:
(1).取key的hashcode值:在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
(3). (n-1) & hash; 取模运算这个n我们说过是table的长度,那么n-1就是table数组元素应有的下表。这个方法非常巧妙,它通过hash & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,hash&(length-1) 运算等价于对length取模,也就是hash % length,但是&比%具有更高的效率。
关于为什么要先高16位异或低16位再取模运算,我们这里先看第三步:
我们知道,n代表的是table的长度length,之前一再强调,表table的长度需要取2的整数次幂,就是为了这里等价这里进行取模运算时的方便——取模运算转化成位运算公式:a%(2^n) 等价于 a&(2^n-1),而&操作比%操作具有更高的效率。
当length=2n时,(length - 1)正好相当于一个"低位掩码","与"操作的结果就是散列值的高位全部归零,只保留低位,用来做数组下标访问:
可以看到,当我们的length为16的时候,哈希码(字符串“abcabcabcabcabc”的key对应的哈希码)对(16-1)与操作,对于多个key生成的hashCode,只要哈希码的后4位为0,不论不论高位怎么变化,最终的结果均为0。也就是说,如果支取后四位(低位)的话,这个时候产生"碰撞"的几率就非常大(当然&运算中产生碰撞的原因很多,这里只是举个例子)。为了解决低位与操作碰撞的问题,于是便有了第二步中高16位异或低16位的“扰动函数”。
右移16位,自己的高半区和低半区异或,就是为了混合原始哈希码的高位和低位,以此来加大低位随机性。
可以看到:
扰动函数优化前:1954974080 % 16 = 1954974080 & (16 - 1) = 0扰动函数优化后:1955003654 % 16 = 1955003654 & (16 - 1) = 6很显然,减少了碰撞的几率。
第三步,判断该链是否为红黑树并添加元素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); // 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; } }关于是红黑树的部分,不在我们本节的讨论范围之内,我们主要看else里边的内容:
for (int binCount = 0; ; ++binCount) {表示循环遍历链表,这个for循环当中实际上经历了以下几个步骤:
具体的扩容操作,我们接下来具体再讲
4.HashMap扩容机制的实现首先上源码,我们在下面的源码中会加上详细的注释,希望读者们一起跟着走一遍:
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; //创建一个oldTab数组用于保存之前的数组 int oldCap = (oldTab == null) ? 0 : oldTab.length; //获取原来数组的长度 int oldThr = threshold; //原来数组扩容的临界值 int newCap, newThr = 0; if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { //如果原来的数组长度大于最大值(2^30) threshold = Integer.MAX_VALUE; //扩容临界值提高到正无穷 return oldTab; //返回原来的数组,也就是系统已经管不了了,随便你怎么玩吧 } //else if((新数组newCap)长度乘2) < 最大值(2^30) && (原来的数组长度)>= 初始长度(2^4)) //这个else if 中实际上就是咋判断新数组(此时刚创建还为空)和老数组的长度合法性,同时交代了, //我们扩容是以2^1为单位扩容的。下面的newThr(新数组的扩容临界值)一样,在原有临界值的基础上扩2^1 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) newCap = oldThr; //新数组的初始容量设置为老数组扩容的临界值 else { // 否则 oldThr == 0,零初始阈值表示使用默认值 newCap = DEFAULT_INITIAL_CAPACITY; //新数组初始容量设置为默认值 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); //计算默认容量下的阈值 } if (newThr == 0) { //如果newThr == 0,说明为上面 else if (oldThr > 0) //的情况(其他两种情况都对newThr的值做了改变),此时newCap = oldThr; float ft = (float)newCap * loadFactor; //ft为临时变量,用于判断阈值的合法性 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); //计算新的阈值 } threshold = newThr; //改变threshold值为新的阈值 @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; //改变table全局变量为,扩容后的newTable if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { //遍历数组,将老数组(或者原来的桶)迁移到新的数组(新的桶)中 Node<K,V> e; if ((e = oldTab[j]) != null) { //新建一个Node<K,V>类对象,用它来遍历整个数组 oldTab[j] = null; if (e.next == null) //将e也就是oldTab[j]放入newTab中e.hash & (newCap - 1)的位置, newTab[e.hash & (newCap - 1)] = e; //这个我们之前讲过,是一个取模操作 else if (e instanceof TreeNode) //如果e已经是一个红黑树的元素,这个我们不展开讲 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // 链表重排,这一段是最难理解的,也是ldk1.8做的一系列优化,我们在下面详细讲解 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 ((e = next) != null); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }上面这段代码中,前面一部分我们做了详细的注释,从下面的for()循环开始,我们需要单独拉出来讲一下,因为这一段是重点的数组迁移,也是比较难以理解的:
Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null;首先命名了两组Node<K,V>对象,loHead = null, loTail = null;与hiHead = null, hiTail = null;,这两组对象是为了针对(e.hash & oldCap) == 0是否成立这两种情况,而作出不同的处理;结合后面的:
if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; }可以知道,如果(e.hash & oldCap) == 0,则 newTab[j] = loHead = e = oldTab[j],即索引位置没变。反之 (e.hash & oldCap) != 0, newTab[j + oldCap] = hiHead = e = oldTab[j],也就是说,此时把原数组[j]位置上的桶移到了新数组[j+原数组长度]的位置上了。
这是个啥意思呢?我们借用美团点评技术团队Java 8系列之重新认识HashMap一文中的部分解释(这篇文章讲的确实很好,网上很多讲解1.8中HashMap的博客都是抄的这篇):
我们之前一直说的一个移位运算就是—— a % (2^n) 等价于 a & (2^n - 1),也即是位运算与取模运算的转化,且位运算比取模运算具有更高的效率,这也是为什么HashMap中数组长度要求为2^n的原因。我们复习一下,HanshMap中元素存入数组的下表运算为**index = hash & (n - 1) **,其中n为数组长度为2的整数次幂。
在上面的图中,n表示一个长度为16的数组,n-1就是15,换算成二进制位1111。这个时候有两种不同的哈希码来跟他进行与操作(对应位置都为1结果为1,否则为0),这两种哈希码的低四位都是相同的,最大的不同是第五位,key1为0,key2为1;
这个时候我们进行扩容操作,n由16变为32,n-1=32,换算成二进制位11111,这个时候再和key1,key2进行与操作,我们发现,由于第5位的差异,得到了两种不同的结果:
可以看到,得出的结果恰好符合上面我们将的两种情况。这也就是1.8中扩容算法做出的改进,至于为什么这么搞?借用刚那篇文章中的一句话——由于(hashCode中)新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。
这个时候有些同学就有问题了,刚刚说了半天hash & (n - 1),源码中明明是if ((e.hash & oldCap) == 0) {,并没有减1啊~~我们可以看看如果不减1的话,16就是10000,和key1(第5位为0)相与结果为0,而和key2(第5位上面为1)就成了16了(!=0),也符合上面两种情况。扩容之后同理。
作者:游骑兵810
链接:https://www.jianshu.com/p/17177c12f849
來源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。
欢迎光临 黑马程序员技术交流社区 (http://bbs.itheima.com/) | 黑马程序员IT技术论坛 X3.2 |