①DEFAULT_INITIAL_CAPACITY&MAXIMUM_CAPACITYhtml
Hash音译为“哈希”,直译为“散列”,是一种信息摘要算法,但他不是加密。散列函数(或散列算法,又称哈希函数,英语:Hash Function)是一种从任何一种数据中建立小的数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。该函数将数据打乱混合,从新建立一个叫作散列值(hash values,hash codes,hash sums,或hashes)的指纹(维基百科)。
咱们平时经常使用的MD5,SSL等都属于Hash算法,经过Key进行Hash的计算,就能够获取Key对应的HashCode。
以前咱们讲了ArrayList和LinkedList的优缺点——数组的特色是:寻址容易,插入和删除困难;而链表的特色是:寻址困难,插入和删除容易。
若是咱们综合二者的特色,就能够获得本篇咱们要讲的内容——HashMap(直译散列表,音译哈希表)。咱们知道,java.util中的clloection集合类中, 最为经常使用的两种是List和Map类,咱们以前将的ArrayList和LinkedList都是List集合类旗下的,而HashMap则是属于Map集合的阵营。为何说HashMap集合了前面两种数据结构的特色呢?HashMap最多见的实现方法是拉链法——即一系列链表为数组元素组成的数组。如图:java
从上图咱们能够看到,HashMap由链表+数组组成,他的底层结构是一个数组,而数组的元素是一个单向链表。图中是一个长度为16位的数组,每一个数组储存的元素表明的是每个链表的头结点。node
上面咱们说了有关Hash算法的事情,经过Key进行Hash的计算,就能够获取Key对应的HashCode。好的Hash算法能够计算出几乎出独一无二的HashCode,若是出现了重复的hashCode,就称做碰撞,就算是MD5这样优秀的算法也会发生碰撞,即两个不一样的key也有可能生成相同的MD5。
正常状况下,咱们经过hash算法,往HashMap的数组中插入元素。若是发生了碰撞事件,那么意味这数组的一个位置要插入两个或者多个元素,这个时候数组上面挂的链表起做用了,链表会将数组某个节点上多出的元素按照尾插法(jdk1.7及之前为头差法)的方式添加。算法
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { ...... //默认初始容量为16,这里这个数组的容量必须为2的n次幂。 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 //最大容量为2的30次方 static final int MAXIMUM_CAPACITY = 1 << 30; //默认加载因子 static final float DEFAULT_LOAD_FACTOR = 0.75f; //以Node<K,V>为元素的数组,也就是上图HashMap的纵向的长链数组,起长度必须为2的n次幂 transient Node<K,V>[] table; //已经储存的Node<key,value>的数量,包括数组中的和链表中的 transient int size; //扩容的临界值,或者所能容纳的key-value对的极限。当size>threshold的时候就会扩容 int threshold; //加载因子 final float loadFactor; ......
首先当咱们经过无参的构造函数new一个hashMap的时候,系统就会帮咱们指定你HashMap的默认大小为DEFAULT_INITIAL_CAPACITY也就是24,固然咱们能够刻本身制定初始容量的大小,只不过要注意必须是2n且小于MAXIMUM_CAPACITY(230)。bootstrap
LOAD_FACTOR(负载因子)和上面的CAPACITY(容量)的关系,简单来讲,Capacity就是数组的长度/大小,loadFactor是这个数组填满程度的最大比比例。一样的,咱们能够根据有参的HashMap构造函数来指定初始负载容量的大小,若是不指定,默认的负载因子为0.75。数组
size表示当前HashMap中已经储存的Node<key,value>的数量,包括数组和链表中的的Node<key,value>。threshold表示扩容的临界值,若是size大于这个值,则必需调用resize()方法进行扩容,具体的扩容过程咱们以后会讲。
这里先说一下threshold值是怎么获得的,在jdk1.7及之前,threshold = length * Load factor,其中length为数组的长度,也就是说数组的长度成负载因子的数量。这里须要说明一点,默认负载因子0.75是是对空间和时间(纵向横向)效率的一个平衡选择,建议你们不要修改。
而在jdk1.8中,这个值的计算算法获得了进一步改进,成了这个:数据结构
/** * Returns a power of two size for the given target capacity. */ 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; }
cap是咱们初始传递的Capacity值(或者默认的24),看注释咱们能够知道,这么一系列位移操做算法最后是为了获得一个power of two size的值。为何jdk中一再要强调这个2n这个值呢?这个等会咱们再分析。app
最后来重点说说这个Node[] table,他是整个HashMap的组成子元素,是构成HashMap的一砖一瓦:函数
static class Node<K,V> implements Map.Entry<K,V> { final int hash; //每一个储存元素key的哈希值 final K key; //key V value; //value Node<K,V> next; //链表下一个node Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; ...... } public final K getKey() { return key; } public final V getValue() { return value; } public final String toString() { return key + "=" + value; } public final int hashCode() { ...... } public final V setValue(V newValue) { ...... } public final boolean equals(Object o) { ....... } }
能够看到,这个Node<K,V>[]是HashMap的一个内部类,他既是HashMap底层数组的组成元素,又是每一个单向链表的组成元素。它其中包含了数组元素所须要的key与value,以及链表所须要的指向下一个节点的引用域next。固然这个hash值是系统在建立Node时经过必定的算法计算出来的一个int值,以后咱们会讲。优化
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[i]==null,说明这个位置上没有元素,这个时候就建立一个新的Node元素 tab[i] = 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就是咱们在第一大点“HashMap基本元素”中说的Node[] 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产生的步骤:
①Object类的hashCode
返回对象的通过处理后的内存地址,因为每一个对象的内存地址都不同,因此哈希码也不同。这个是native方法,取决于JVM的内部设计,通常是某种C地址的偏移。
②String类的hashCode
根据String类包含的字符串的内容,根据一种特殊算法返回哈希码,只要字符串的内容相同,返回的哈希码也相同。
③Integer等包装类
返回的哈希码就是Integer对象里所包含的那个整数的数值,例如Integer i1=new Integer(100),i1.hashCode的值就是100 。因而可知,2个同样大小的Integer对象,返回的哈希码也同样。
④int,char这样的基础类
它们不须要hashCode,若是须要存储时,将进行自动装箱操做,计算方法包装类。
在JDK1.8的实现中,优化了高位运算的算法,经过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么作能够在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到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循环当中实际上经历了如下几个步骤:
①e = p.next以及for语句以外后面的p = e;
其实是在向后循环遍历链表。
开始的时候P为每一个桶的头元素,而后将P的引用域(原本指向的是下一个元素)指向空节点e,这个时候实际上就至关于将p的下一个元素赋值给了e,即e已经变成了p的下一个元素。
②此时咱们把这个复制的e单独提出来,进行了两个判断:
第一个if:if ((e = p.next) == null)
若是e也就是p.next == null,那么说明当前的这个P已是链表最后一个元素了。这个时候采起尾插法添加一个新的元素:p.next = newNode(hash, key, value, null);
,即直接将p的引用域指向这个新添加的元素。若是添加新元素以后发现链表的长度超过了TREEIFY_THRESHOLD - 1
也就是超过了8,那么调用treeifyBin(tab, hash);
把这个链表转换成红黑树接着玩。
第二个if:if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))
若是发现key值重复了,也就是要插入的key已经存在,那么直接break,结束遍历(不用再费劲去插入了)。
③而后又将e赋给p,这个时候的p已经向后移动了一位。重复上面的过程,直到循环完整个链表,或者break出来。
整个不是红黑树的for循环(或者else)中就作了这三件事。
if (++size > threshold) resize(); afterNodeInsertion(evict);
具体的扩容操做,咱们接下来具体再讲
首先上源码,咱们在下面的源码中会加上详细的注释,但愿读者们一块儿跟着走一遍:
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),也符合上面两种状况。扩容以后同理。
原文连接:https://www.jianshu.com/p/17177c12f849