更新时间:2022-08-24 来源:黑马程序员 浏览量:
一、前言:
面试过的人都知道,HashMap是Java程序员在面试中最最最经常被问到的一个点,可以说,不了解HashMap都不好意思说自己是做Java开发的。基本上你去面试十家公司,有七八家都会问到你HashMap。那么今天,就带着大家从源码的角度去分析一下,HashMap具体是怎么实现的。
二、HashMap的构造方法
1.HashMap构造方法
我们先来看HashMap的四个构造方法
```java //initialCapacity给定的初始化容量,loadFactor扩容因子 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) {//构造传入一个map,将map中的值放到hashmap中 this.loadFactor = DEFAULT_LOAD_FACTOR; putMapEntries(m, false); } ```
2.构造方法里的putMapEntries方法
刚才构造方法中提到了putMapEntries这个方法,接下来就让我们一起看一下
//该函数用于将一个map赋值给新的HashMap final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) { //定义变量接收旧hashmap的size int s = m.size(); //判断s的容量是否大于0 if (s > 0) { //判断当前数组有没有初始化 if (table == null) { // pre-size ////求出以 旧hashmap数组容量为阈值 的数组容量赋值给ft float ft = ((float)s / loadFactor) + 1.0F; //判断是不是大于最大容量,如果是,赋值为最大容量,否则将ft赋值给t int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY); //判断t是否大于threshold(数组扩容阈值) if (t > threshold) //通过tablesizefor方法求出大于等于t的最小2的次幂值赋值给threshold(数组扩容阈值) threshold = tableSizeFor(t); } //如果数组长度大于扩容阈值,进行resize扩容操作 else if (s > threshold) resize(); //循环遍历取出旧hashmap的值放入当前hashmap for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) { K key = e.getKey(); V value = e.getValue(); putVal(hash(key), key, value, false, evict); } } } /* if (table == null)分支,是判断当前数组是否初始化,因为在jdk1.8之后,只有当你第一次放值时,才会帮你创建16位的数组。 float ft = ((float)s / loadFactor) + 1.0F经过除运算再加上1.0F是为了向上取整 if (t > threshold)注意一个细节,做判断的的时候,数组还没有初始化,这里的threshold的值还是给定的数组长度的值,也就是capacity的值 else if (s > threshold)说明传入的map集合大于当前的扩容阈值,需要进行resize扩容操作 */ ```
tableSizeFor方法
刚才putMapEntries方法中,调用了tableSizeFor方法,接下来我们看一下这个tableSizeFor方法。
作用:创建hashMap对象时调用有参构造,传入初始容量,需要保证hashMap的初始长度为2的n次幂。
tableSizeFor方法用来计算大于等于并离传入值最近的2的n次幂的数值,比如传入15,算出结果为16,传入17,算出结果为32。
通过二进制的位移,第一次右移一位,第二次右移两位,第三次右移四位。。。通过五次位移,将范围内所有的位数都变为1,高位补0,最后得出来的结果再加上1,最后算出来的结果一定是2的n次幂。
//这个方法的作用是找到大于等于给定容量的最小2的次幂值 //>>>表示无符号右移,也叫逻辑右移,即若该数为正,则高位补0,而若该数为负数,则右移后高位同样补0 7--》8 16--》16 static final int tableSizeFor(int cap) { //先将数组长度减1,之所以在开始移位前先将容量-1,是为了避免给定容量已经是8,16这样2的幂时,不减一 直接移位会导致得到的结果比预期大。比如预期16得到应该是16,直接移位的话会得到32。 int n = cap - 1; //右移一位,在进行或运算,这个时候最高位和次高位就已经都是1,此时是2个1 n |= n >>> 1; //右移两位,在进行或运算,这个时候由上次运算得出的两个1,变成了四个1 n |= n >>> 2; //右移四位 n |= n >>> 4; //右移八位 n |= n >>> 8; //右移十六位,这个时候所有的位数都变为了1 n |= n >>> 16; //n+1操作,是为了进1,这个时候算出来的数值就一点是 2的n次幂 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; } ```
移位的思想
2的整数幂用二进制表示都是最高有效位为1,其余全是0。
对任意十进制数转换为2的整数幂,结果是这个数本身的最高有效位的前一位变成1,最高有效位以及其后的位都变为0。
核心思想是,先将最高有效位以及其后的位都变为1,最后再+1,就进位到前一位变成1,其后所有的满2变0。所以关键是如何将最高有效位后面都变为1。
先移位,再或运算。
> 右移一位,再或运算,就有两位变为1;
>
> 右移两位,再或运算,就有四位变为1,,,
>
> 最后右移16位再或运算,保证32位的int类型整数最高有效位之后的位都能变为1。
三、HashMap的底层原理
先来看几个重要的参数:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认数组初始容量 static final int MAXIMUM_CAPACITY = 1 << 30;//数组最大容量 static final float DEFAULT_LOAD_FACTOR = 0.75f;//默认加载因子 static final int TREEIFY_THRESHOLD = 8;//树化的阈值 static final int UNTREEIFY_THRESHOLD = 6;//由树退化到链表的阈值 static final int MIN_TREEIFY_CAPACITY = 64;//树化最小数组容量 //node节点,继承了Map.entry,在Entry原有的K,V的基础上追加了hash和next字段 //分别表示key的hash值和下一个节点 static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; } //重写了计算hash的方法 //将 Hash 值的高 16 位右移并与原 Hash 值取异或运算(^),混合高 16 位和低 16 位的值 //得到一个更加散列的低 16 位的 Hash 值。 static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } ```
HashMap在JDK1.8之前的实现方式数组+链表,
但是在JDK1.8后对HashMap进行了底层优化,改为了由 **数组+链表或者数值+红黑树**实现,主要的目的是提高查找效率
1. Jdk8数组+链表或者数组+红黑树实现,当链表中的元素大于等于8 个并且数组长度大于等于64以后, 会将链表转换为红黑树,当红黑树节点 小于 等于6 时又会退化为链表。
2. 当new HashMap()时,底层没有创建数组,首次调用put()方法示时,会调用resize方法,底层创建长度为16的数组,jdk8底层的数组是:Node[],而非Entry[],用数组容量大小乘以加载因子得到一个值,一旦数组中存储的元素个数超过该值就会调用resize方法将数组扩容到原来的两倍,在做扩容的时候会生成一个新的数组,原来的所有数据需要重新计算哈希码值重新分配到新的数组,所以扩容的操作非常消耗性能。
默认的负载因子大小为0.75,数组大小为16。也就是说,默认情况下,那么当HashMap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍。
3. 在我们Java中任何对象都有hashcode,hash算法就是通过hashcode与自己进行向右位移16的异或运算。这样做是为了使高位的16位hash也能参与运算,使运算出来的hash值足够随机,足够分散,还有产生的数组下标足够随机。
map.put(k,v)实现原理:
(1)首先将k,v封装到Node对象当中(节点)。
(2)先调用k的hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标。
(3)下标位置上如果没有任何元素,就把Node添加到这个位置上。如果说下标对应的位置上有链表。此时,就会拿着k和链表上每个节点的k进行equal。如果所有的equals方法返回都是false,那么这个新的节点将被添加到链表的末尾。如其中有一个equals返回了true,那么这个节点的value将会被覆盖。
HashMap中的put()方法
```java public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } ```
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) //如果未初始化,调用resize方法 进行初始化 n = (tab = resize()).length; //通过 & 运算求出该数据(key)的数组下标并判断该下标位置是否有数据 if ((p = tab[i = (n - 1) & hash]) == null) //如果没有,直接将数据放在该下标位置 tab[i] = newNode(hash, key, value, null); //该数组下标有数据的情况 else { Node<K,V> e; K k; //判断该位置数据的key和新来的数据是否一样 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) //如果一样,证明为修改操作,该节点的数据赋值给e,后边会用到 e = p; //判断是不是红黑树 else if (p instanceof TreeNode) //如果是红黑树的话,进行红黑树的操作 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); //新数据和当前数组既不相同,也不是红黑树节点,证明是链表 else { //遍历链表 for (int binCount = 0; ; ++binCount) { //判断next节点,如果为空的话,证明遍历到链表尾部了 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; } //判断链表当中有数据相同的值,如果一样,证明为修改操作 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; //把下一个节点赋值为当前节点 p = e; } } //判断e是否为空(e值为修改操作存放原数据的变量) if (e != null) { // existing mapping for key //不为空的话证明是修改操作,取出老值 V oldValue = e.value; //一定会执行 onlyIfAbsent传进来的是false if (!onlyIfAbsent || oldValue == null) //将新值赋值当前节点 e.value = value; afterNodeAccess(e); //返回老值 return oldValue; } } //计数器,计算当前节点的修改次数 ++modCount; //当前数组中的数据数量如果大于扩容阈值 if (++size > threshold) //进行扩容操作 resize(); //空方法 afterNodeInsertion(evict); //添加操作时 返回空值 return null; } ```
map中resize方法
//扩容、初始化数组 final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; //如果当前数组为null的时候,把oldCap老数组容量设置为0 int oldCap = (oldTab == null) ? 0 : oldTab.length; //老的扩容阈值 int oldThr = threshold; int newCap, newThr = 0; //判断数组容量是否大于0,大于0说明数组已经初始化 if (oldCap > 0) { //判断当前数组长度是否大于最大数组长度 if (oldCap >= MAXIMUM_CAPACITY) { //如果是,将扩容阈值直接设置为int类型的最大数值并直接返回 threshold = Integer.MAX_VALUE; return oldTab; } //如果在最大长度范围内,则需要扩容 OldCap << 1等价于oldCap*2 //运算过后判断是不是最大值并且oldCap需要大于16 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold 等价于oldThr*2 } //如果oldCap<0,但是已经初始化了,像把元素删除完之后的情况,那么它的临界值肯定还存在, 如果是首次初始化,它的临界值则为0 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); } //初始化容量小于16的时候,扩容阈值是没有赋值的 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; //判断当前下标为j的数组如果不为空的话赋值个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 { //比如老数组容量是16,那下标就为0-15 //扩容操作*2,容量就变为32,下标为0-31 //低位:0-15,高位16-31 //定义了四个变量 // 低位头 低位尾 Node<K,V> loHead = null, loTail = null; // 高位头 高位尾 Node<K,V> hiHead = null, hiTail = null; //下个节点 Node<K,V> next; //循环遍历 do { //取出next节点 next = e.next; //通过 与操作 计算得出结果为0 if ((e.hash & oldCap) == 0) { //如果低位尾为null,证明当前数组位置为空,没有任何数据 if (loTail == null) //将e值放入低位头 loHead = e; //低位尾不为null,证明已经有数据了 else //将数据放入next节点 loTail.next = e; //记录低位尾数据 loTail = e; } //通过 与操作 计算得出结果不为0 else { //如果高位尾为null,证明当前数组位置为空,没有任何数据 if (hiTail == null) //将e值放入高位头 hiHead = e; //高位尾不为null,证明已经有数据了 else //将数据放入next节点 hiTail.next = e; //记录高位尾数据 hiTail = e; } //如果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; } ```
map.get(k)实现原理
(1)、先调用k的hashCode()方法得出哈希值,并通过哈希算法转换成数组的下标。
(2)、在通过数组下标快速定位到某个位置上。重点理解如果这个位置上什么都没有,则返回null。如果这个位置上有单向链表,那么它就会拿着参数K和单向链表上的每一个节点的K进行equals,如果所有equals方法都返回false,则get方法返回null。如果其中一个节点的K和参数K进行equals返回true,那么此时该节点的value就是我们要找的value了,get方法最终返回这个要找的value。
HashMap中的get()方法
public V get(Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; } ```
getNode()方法
final Node<K,V> getNode(int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; //判断数组不为null并且长度大于0,并且通过hash算出来的数组下标的位置不为空,证明有数据 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { //判断数组的位置的key的hash和内容是否等同与要查询的数据 if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) //相等的话直接返回 return first; //判断是否有下个节点 if ((e = first.next) != null) { //判断是否为为红黑树 if (first instanceof TreeNode) //进行红黑树查询操作 return ((TreeNode<K,V>)first).getTreeNode(hash, key); //链表查询操作 do { //循环链表,逐一判断 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) //发现key的话就返回 return e; } while ((e = e.next) != null); } } //没有查询到返回null return null; }
四、HashMap常见面试题分析
为何HashMap的数组长度一定是2的次幂?
首先,HashMap的初始化的数组长度一定是2的n次的,每次扩容仍是原来的2倍的话,就不会破坏这个规律,每次扩容后,原数据都会进行数据迁移,根据二进制的计算,扩容后数据要么在原来位置,要么在【原来位置+扩容长度】,这样就不需要重新hash,效率上更高。
HashMap中,如果想存入数据,首先它需要根据key的哈希值去定位落入哪个桶中
HashMap的做法,我总结的是,三步:>>>无符号右移、^异或、&与
具体是:拿着key的哈希值,先“>>>”无符号右移16位,然后“^”异或上key的哈希值,得到一个值,再拿着这个值去“&”上数组长度减一
最后得出一个数(如果数组长度是15的话,那这个数就是一个0-15之间的一个数),这个数就是得出的数组脚标位置,也就是存入的桶的位置。
由上边可以知道,定位桶的位置最后需要做一个 “&” 与运算,&完了得出一个数,就是桶的位置
知道了这些以后,再来说为什么HashMap的长度之所以一定是2的次幂?
至少有以下**两个原因**:
1、HashMap的长度是2的次幂的话,可以让**数据更散列更均匀的分布**,更充分的利用数组的空间
怎么理解呢?下面举例子说一下如果不是2的次幂的数的话假设数组长度是一个奇数,那参与最后的&运算的肯定就是偶数,那偶数的话,它二进制的最后一个低位肯定是0,0做完&运算得到的肯定也是0,那意味着&完后得到的数的最低位一定是0最低位一定是0的话,那说明一定是一个偶数,换句话说就是:&完得到的数一定是一个偶数,所以&完获取到的脚标永远是偶数位,那意味着奇数位的脚标永远都没值,**有一半的空间是浪费的**奇数说完了,来说一下偶数,假设数组长度是一个偶数,比如6,那参与&运算的就是55的二进制 00000000 00000000 00000000 00000101发现任何一个数&上5,倒数第二低位永远是0,那就意味着&完以后,最起码肯定得不出2或者3(这点刚开始不好理解,但是好好想一下就能明白)意味着第二和第三脚标位肯定不会有值。
虽然偶数的话,不会像奇数那么夸张会有一半的脚标位得不到,但是也总会有一些脚标位得不到的。**所以不是2的次幂的话,不管是奇数还是偶数,就肯定注定了某些脚标位永远是没有值的,而某些脚标位永远是没有值的,就意味着浪费空间,会让数据散列的不充分,这对HashMap来说绝对是个灾难!**
2、HashMap的长度一定是2的次幂,还有另外一个原因,那就是在扩容迁移的时候不需要再重新通过哈希定位新的位置了。扩容后,元素新的位置,要么在原脚标位,要么在原脚标位+扩容长度这么一个位置。
比如扩容前长度是8,扩容后长度是16 第一种情况: 扩容前: 00000000 00000000 00000000 00000101 &00000000 00000000 00000000 00000111 8-1=7 ------------------------------------- 101 ===== 5 原来脚标位是5 扩容后: 00000000 00000000 00000000 00000101 &00000000 00000000 00000000 00001111 16-1=15 ------------------------------------- 101 ===== 5 扩容后脚标位是5(原脚标位) 第二种情况: 扩容前: 00000000 00000000 00000000 00001101 &00000000 00000000 00000000 00000111 8-1=7 ------------------------------------- 101 ===== 5 原来脚标位是5 扩容后: 00000000 00000000 00000000 00001101 &00000000 00000000 00000000 00001111 16-1=15 ------------------------------------- 1101 ===== 13 扩容后脚标位是13(原脚标位+扩容长度) ```
扩容后到底是在原来位置还是在原脚标位+扩容长度的位置,主要是看新扩容最左边一个1对应的上方数字是0还是1如果是0则扩容后在原来位置,如果是1则扩容后在原脚标位+扩容长度的位置HashMap源码里扩容也是这么做的。
总结来说,就是hash&(n-1)这个计算有关。如果不为n不为2的n次方的话,那转换为二进制情况下,n-1就会有某一位为0,那与hash做了&运算后,该位置永远为0,那么计算出来的数组位置就永远会有某个下标的数组位置是空的,也就是这个位置永远不会有值。
JDK1.8中HashMap的性能优化
JDK1.8在1.7的基础上对一些操作进行了优化。
| 不同 | JDK1.7 | JDK1.8 |
| ------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
| 存储结构 | 数组+链表 | 数组+链表+红黑树 |
| 初始化方式 | 单独函数inflateTable() | 集成至扩容方法resize() |
| hash值计算方式 | 扰动处理=9次扰动=4次位运算+5次异或运算 | 扰动处理=2次扰动=1次位运算+1次异或运算 |
| 存放数据规则 | 无冲突时,存放数组;冲突时存放链表 | 无冲突时,存放数组;冲突&链表长度<8:c存放单链表;冲突&链表长度 >8:树化并存放红黑树 |
| 插入数据方式 | 头插法(将原位置的数据移动到后一位,再插入数据到该位置) | 尾插法 (直接插入链表尾部/红黑树) |
| 扩容后存储位置的计算方式 | 全部按照原来的方法进行运算(hashCode()->>扰动函数->>(h&length-1)) | 按照扩容后的规则计算(即扩容后位置=原位置或原位置+旧容量) |
注意:JDK1.8里转换为红黑树的时候,数组长度必须大于64,如果数组长度小于64,链表长度达到8的话,会进行resize扩容操作。
五、总结
看到这里,我们已经HashMap的源码和实现有了清晰的理解,并且对它的构造方法再到它的一个put数据和get数据的一个源码解析,还有一些它里边比较精妙和重要的一些方法也都探索清楚了,希望这些知识点可以在大家以后的面试中也能够帮助到大家,斩获一个心仪的offer。