声明:本文以jdk1.8为主!面试
做为一个Java从业者,面试的时候确定会被问到过HashMap,由于对于HashMap来讲,能够说是Java==集合中的精髓==了,若是你以为本身对它掌握的还不够好,我想今天这篇文章会很是适合你,至少,看了今天这篇文章,之后不怕面试被问HashMap了算法
其实在我学习HashMap的过程当中,我我的以为HashMap仍是挺复杂的,若是真的想把它搞得明明白白的,没有足够的内力怕是一时半会儿作不到,不过咱们总归是在不断的学习,所以真的没必要强迫本身把如今遇到的一些知识点所有搞懂。数组
可是,对于HashMap来讲,你所掌握的应该足够可让你应对面试,因此今天我们的侧重点就是学会那些常常被问到的知识点。bash
我猜,你确定看过很多分析HashMap的文章了,那么你掌握多少了呢?从一个问题开始吧微信
怎么样,想要回答这个问题,仍是须要你对HashMap有个比较深刻的了解的,若是仅仅知道什么key和value的话,那么回答这个问题就比较难了。数据结构
这个问题你们能够先想一想,后面我会给出解答,下面咱们一步步的来看HashMap中几个你必须知道的知识点。多线程
HashMap隶属于Java中集合这一块,咱们知道集合这块有list,set和map,这里的HashMap就是Map的实现类,那么在Map这个你们族中还有哪些重要角色呢?app
上图展现了Map的家族,都是狠角色啊,咱们对这些其实都要了解并掌握,这里简单的介绍下这几个狠角色:函数
TreeMap从名字上就能看出来是与树有关,它是基于树的实现,而HashMap,HashTable和ConcurrentHashMap都是基于hash表的实现,另外这里的HashTable和HashMap在代码实现上,基本上是同样的,还记得以前在讲解ArrayList的时候提到过和Vector的区别嘛?这里他们是很类似的,通常都不怎么用HashTable,会用ConcurrentHashMap来代替,这个也须要好好研究,它比HashTable性能更好,它的锁粒度更小。性能
因为这不是本文的重点,只作简单说明,后续会发文单独介绍。
简单来讲,Map就是一个映射关系的数据集合,就是咱们常见的k-v的形式,一个key对应一个value,大体有这样的图示
这只是简单的概念,放到具体的实例当中,好比在HashMap中就会衍生出不少其余的问题,那么HashMap又是个啥?
上面简单提到过,HashMap是基于Hash表的实现,所以,了解了什么是Hash表,那对学习HashMap是至关重要。
建议了解了哈希表以后再学习HashMap,这样不少难懂的也就不那么难理解了。
接着,HashMap是基于hash表的实现,而说到底,它也是用来存储数据供咱们使用的,那么底层是用什么来存储数据的呢?可能有人猜到了,仍是数组,为啥仍是数组?想一想以前的ArrayList。
因此,对于HashMap来讲,底层也是基于数组实现,只不过这个数组可能和你印象中的数组有些许不一样,咱们日常整个数组出来,里面会放一些数据,好比基础数据类型或者引用数据类型,数组中的每一个元素咱们没啥特殊的叫法。
可是在HashMap中人家就有了新名字,我发现这个知识点其实不少人都不太清楚:
在HashMap中的底层数组中,每一个元素在jdk1.7及以前叫作Entry,而在jdk1.8以后人家又更名叫作Node。
这里可能仍是会有人好奇这Entry和Node长啥样,这个看看源码就比较清楚了,后面咱们会说。
到了这里你因该就能简单的理解啥是HashMap了,若是你看过什么是哈希表了,你就会清楚,在HashMap中一样会出现哈希表所描述的那些问题,好比:
没事,这些问题咱们后续都会谈到。
先来看HashMap的基础用法:
HashMap map = new HashMap();
就这样,咱们建立好了一个HashMap,接下来咱们看看new以后发生了什么,看看这个无参构造函数吧
public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }
解释下新面孔:
很简单,当你新建一个HashMap的时候,人家就是简单的去初始化一个负载因子,不过咱们这里想知道的是底层数组默认是多少嘞,显然咱们没有获得咱们的答案,咱们继续看源码。
在此以前,想一下以前ArrayList的初始化大小,是否是在add的时候才建立默认数组,这里会不会也同样,那咱们看看HashMap的添加元素的方法,这里是put
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
这里大眼一看,有两个方法;
这里须要再明确下,这是咱们往HashMap中添加第一个元素的时候,也就是第一次调用这个put方法,能够猜测,如今数据已通过来了,底层是否是要作存储操做,那确定要弄个数组出来啊,好,离咱们想要的结果愈来愈近了。
先看这个hash方法:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
记得以前聊哈希表的时候说过,哈希表的数据存储有个很明显的特色,就是根据你的key使用哈希算法计算得出一个下标值,对吧。
而这里的hash就是根据key获得一个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; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; 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 { 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; } } 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; }
咋样,是否是感受代码一下变多了,咱们这里逐步的有重点的来看,先看这个:
if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
这个table是啥?
transient Node<K,V>[] table;
看到了,这就是HashMap底层的那个数组,以前说了jdk1.8中数组中的每一个元素叫作Node,因此这就是个Node数组。
那么上面那段代码啥意思嘞?其实就是咱们第一次往HashMap中添加数据的时候,这个Node数组确定是null,还没建立嘞,因此这里会去执行resize这个方法。
resize方法的主要做用就是初始化和增长表的大小,说白了就是第一次给你初始化一个Node数组,其余须要扩容的时候给你扩容
看看源码:
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; 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); } 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) { Node<K,V> e; 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 ((e = next) != null); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
感受代码也是比较多的啊,一样,咱们关注重点代码:
newCap = DEFAULT_INITIAL_CAPACITY;
有这么一个赋值操做,DEFAULT_INITIAL_CAPACITY字面意思理解就是初始化容量啊,是多少呢?
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
这里是个移位运算,就是16,如今已经肯定具体的默认容量是16了,那具体在哪建立默认的Node数组呢?继续往下看源码,有这么一句
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
ok,到这里咱们发现,第一次使用HashMap添加数据的时候底层会建立一个长度为16的默认Node数组。
那么新的问题来了?
这个问题想必你在HashMap相关分析文章中也看到过,那么该怎么回答呢?
想搞明白为啥是16不是其余的,那首先要知道为啥HashMap的容量要是2的整数次幂?
先看这个16是怎么来的:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
这里使用了位运算,为啥不直接16嘞?这里主要是位运算的性能好,为啥位运算性能就好,那是由于位运算人家直接操做内存,不须要进行进制转换,要知道计算机但是以二进制的形式作数据存储啊,知道了吧,那16嘞?为啥是16不是其余的?想要知道为啥是16,咱们得从HashMap的数据存放特性来讲。
对于HashMap而言,存放的是键值对,因此作数据添加操做的时候会根据你传入的key值作hash运算,从而获得一个下标值,也就是以这个下标值来肯定你的这个value值应该存放在底层Node数组的哪一个位置。
那么这里必定会出现的问题就是,不一样的key会被计算得出同一个位置,那么这样就冲突啦,位置已经被占了,那么怎么办嘞?
首先就是冲突了,咱们要想办法看看后来的数据应该放在哪里,就是给它找个新位置,这是常规方法,除此以外,咱们是否是也能够聚焦到hash算法这块,就是尽可能减小冲突,让获得的下标值可以均匀分布。
好了,以上巴拉巴拉说一些理念,下面咱们看看源码中是怎么计算下标值得:
i = (n - 1) & hash
这是在源码中第629行有这么一段,它就是计算咱们上面说的下标值的,这里的n就是数组长度,默认的就是16,这个hash就是这里获得的值:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
继续看它:
i = (n - 1) & hash
这里是作位与运算,接着咱们还须要先搞明白一个问题
为何要进行取模运算以及位运算
要知道,咱们最终是根据key经过哈希算法获得下标值,这个是怎么获得的呢?一般作法就是拿到key的hashcode而后与数组的容量作取模运算,为啥要作取模运算呢?
好比这里默认是一个长度为16的Node数组,咱们如今要根据传进来的key计算一个下标值出来而后把value放入到正确的位置,想一下,咱们用key的hashcode与数组长度作取模运算,获得的下标值是否是必定在数组的长度范围以内,也就是获得的下标值不会出现越界的状况。
要知道取模是怎么回事啊!明白了这点,咱们再来看:
i = (n - 1) & hash
这里就是计算下标的,为啥不是取模运算而是位与运算呢?使用位与运算的一方面缘由就是它的性能比较好,另一点就是这里有这么一个等式:
(n - 1) & hash = n % hash
所以,总结起来就是使用位与运算能够实现和取模运算相同的效果,并且位与运算性能更高!
接着,咱们再看一个问题
为何要减一作位运算
理解了这个问题,咱们就快接近为何容量是2的整数次幂的答案了,根据上面说的,这里的n-1是为了实现与取模运算相同的效果,除此以外还有很重要的缘由在里面。
在此以前,咱们须要看看什么是位与运算,由于我怕这块知识你们以前不注意忘掉了,而它对理解咱们如今所讲的问题很重要,看例子:
好比拿5和3作位与运算,也就是5 & 3 = 1(操做的是二进制),怎么来的呢?
5转换为二进制:0000 0000 0000 0000 0000 0000 0000 0101
3转换为二进制:0000 0000 0000 0000 0000 0000 0000 0011
因此啊,位与运算的操做就是:第一个操做数的的第n位于第二个操做数的第n位若是都是1,那么结果的第n位也为1,不然为0
看懂了吧,不懂得话能够去补补这块的知识,后续我也会单独发文详细说说这块。
咱们继续回到以前的问题,为何作减一操做以及容量为啥是2的整数次幂,为啥嘞?
告诉你个秘密,2的整数次幂减一获得的数很是特殊,有啥特殊嘞,就是2的整数次幂获得的结果的二进制,若是某位上是1的话,那么2的整数次幂减一的结果的二进制,以前为1的后面全是1
啥意思嘞,可能有点绕,咱们先看2的整数次幂啊,有2,4,8,16,32等等,咱们来看,首先是16的二进制是: 10000 ,接着16减一得15,15的二进制是: 1111 ,再形象一点就是:
16转换为二进制:0000 0000 0000 0000 0000 0000 0001 0000
15转换为二进制:0000 0000 0000 0000 0000 0000 0000 1111
再对照我给你说的秘密,看看懂了不,能够再来个例子:
32转换为二进制:0000 0000 0000 0000 0000 0000 0010 0000
31转换为二进制:0000 0000 0000 0000 0000 0000 0001 1111
这会总该懂了吧,而后咱们再看计算下标的公式:
(n - 1) & hash = n % hash
n是容量,它是2的整数次幂,而后与获得的hash值作位于运算,由于n是2的整数次幂,减一以后的二进制最后几位都是1,再根据位与运算的特性,与hash位与以后,获得的结果是否是多是0也多是1,,也就是说最终的结果取决于hash的值,如此一来,只要输入的hashcode值自己是均匀分布的,那么hash算法获得的结果就是均匀的。
啥意思?这样获得的下标值就是均匀分布的啊,那冲突的概率就减小啦。
而若是容量不是2的整数次幂的话,就没有上述说的那个特性,这样冲突的几率就会增大。
因此,明白了为啥容量是2的整数次幂了吧。
那为啥是16嘞?难道不是2的整数次幂都行嘛?理论上是都行,可是若是是2,4或者8会不会有点小,添加不了多少数据就会扩容,也就是会频繁扩容,这样岂不是影响性能,那为啥不是32或者更大,那不就浪费空间了嘛,因此啊,16就做为一个很是合适的经验值保留了下来!
咱们上面也提到了,在添加数据的时候尽管为实现下标值的均匀分布作了不少努力,可是势必仍是会存在冲突的状况,那么该怎么解决冲突呢?
这就牵涉到哈希冲突的解决办法了,了解了哈希冲突的解决办法以后咱们还要关注一个问题,那就是新的节点在插入到链表的时候,是怎么插入的?
如今你应该知道,当出现hash冲突,可使用链表来解决,那么这里就有问题,新来的Node是应该放在以前Node的前面仍是后面呢?
Java8以前是头插法,啥意思嘞,就是放在以前Node的前面,为啥要这样,这是以前开发者以为后面插入的数据会先用到,由于要使用这些Node是要遍历这个链表,在前面的遍历的会更快。
可是在Java8及以后都使用尾插法了,就是放到后面,为啥这样?
这里主要是一个链表成环的问题,啥意思嘞,想一下,使用头插法是否是会改变链表的顺序,你后来的就应该在后面嘛,若是扩容的话,因为本来链表顺序有所改变,扩容以后从新hash,可能致使的状况就是扩容转移后先后链表顺序倒置,在转移过程当中修改了原来链表中节点的引用关系。
这样的话在多线程操做下就会出现死循环,而使用尾插法,在相同的前提下就不会出现这样的问题,由于扩容先后链表顺序是不变的,他们之间的引用关系也是不变的。
下面咱们继续说HashMap的扩容,通过上面的分析,咱们知道第一次使用HashMap是建立一个默认长度为16的底层Node数组,若是满了怎么办,那就须要进行扩容了,也就是以前谈及的resize方法,这个方法主要就是初始化和增长表的大小,关于扩容要知道这两个概念:
这里怎么扩容的呢?首先是达到一个条件以后会发生扩容,什么条件呢?就是这个负载因子,好比HashMap的容量是100,负载因子是0.75,乘以100就是75,因此当你增长第76个的时候就须要扩容了,那扩容又是怎么样步骤呢?
首先是建立一个新的数组,容量是原来的二倍,为啥是2倍,想想为啥容量是2的整数次幂,这里扩容为原来的2倍不正好符号这个规则嘛。
而后会通过从新hash,把原来的数据放到新的数组上,至于为啥要从新hash,那必须啊,你容量变了,相应的hash算法规则也就变了,获得的结果天然不同了。
在Java8以前是没有红黑树的实现的,在jdk1.8中加入了红黑树,就是当链表长度为8时会将链表转换为红黑树,为6时又会转换成链表,这样时提升了性能,也能够防止哈希碰撞攻击。
下面咱们分析一下HashMap增长新元素的时候都会作哪些步骤:
因此啊,这里面的重点就是判断放入HashMap中的元素要不要替换当前节点的元素,那怎么判断呢?总结起来只要知足如下两点便可替换:
一、hash值相等。
二、==或equals的结果为true。
好了,到了这里就差很少了,开篇就说过HashMap能够说是Java集合的精髓了,想要完全搞懂真心不容易,可是咱们所掌握的应该足够应对日常的面试,关于HashMap更多的高级内容,后续会继续分享。
感谢你们的阅读,若有错误之处欢迎指正!
想要阅读更多精彩内容,能够关注个人微信公众号:Java技术zhai,这是个人私人公众号,专一于Java原创,主要涉及数据结构与算法,计算机基础以及Java核心知识的讲解,期待你的参与。