Java 面试:你了解 HashMap 吗?
前言:面试过的人都知道,HashMap 是 Java 程序员在面试中最最最经常被问到的一个点,可以说,不了解 HashMap 都不好意思说自己是做 Java 开发的。基本上你去面试十家公司,有七八家都会问到你 HashMap。那么今天,就带着大家从源码的角度去分析一下,HashMap 具体是怎么实现的。
二、HashMap 的构造方法 1.HashMap 构造方法我们先来看 HashMap 的四个构造方法
//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 赋值给新的 HashMapfinal void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {//定义变量接收旧 hashmap 的 size
int s = m.size();//判断 s 的容量是否大于 0if (s > 0) {//判断当前数组有没有初始化 if (table == null) { // pre-size////求出以 旧 hashmap 数组容量为阈值 的数组容量赋值给 ftfloat ft = ((float)s / loadFactor) + 1.0F;//判断是不是大于最大容量,如果是,赋值为最大容量,否则将 ft 赋值给 tint 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 的值放入当前 hashmapfor (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 位的数组。
*/tableSizeFor 方法刚才 putMapEntries 方法中,调用了 tableSizeFor 方法,接下来我们看一下这个 tableSizeFor 方法。
作用:创建 hashMap 对象时调用有参构造,传入初始容量,需要保证 hashMap 的初始长度为 2 的 n 次幂。
tableSizeFor 方法用来计算大于等于并离传入值最近的 2 的 n 次幂的数值,比如传入 15,算出结果为 16,传入 17,算出结果为 32。
通过二进制的位移,第一次右移一位,第二次右移两位,第三次右移四位。。。通过五次位移,将范围内所有的位数都变为 1,高位补 0,最后得出来的结果再加上 1,最后算出来的结果一定是 2 的 n 次幂。
//这个方法的作用是找到大于等于给定容量的最小 2 的次幂值//>>>表示无符号右移,也叫逻辑右移,即若该数为正,则高位补 0,而若该数为负数,则右移后高位同样补 07--》816--》16static final int tableSizeFor(int cap) {//先将数组长度减 1,之所以在开始移位前先将容量-1,是为了避免给定容量已经是 8,16 这样 2 的幂时,不减一 直接移位会导致得到的结果比预期大。比如预期 16 得到应该是 16,直接移位的话会得到 32。int n = cap - 1;//右移一位,在进行或运算,这个时候最高位和次高位就已经都是 1,此时是 2 个 1n |= n >>> 1;//右移两位,在进行或运算,这个时候由上次运算得出的两个 1,变成了四个 1n |= n >>> 2;//右移四位 n |= n >>> 4;//右移八位 n |= n >>> 8;//右移十六位,这个时候所有的位数都变为了 1n |= 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.
以传入参数 24 为例:
24 转换为二进制是 11000,如下图所示:
经过一次位移如下图所示:
经过第二次位移如下图:
经过第三次位移如下图:
因为经过第三次位移运算,所有的位数都变成了 1,后续还有第四次和第五次位移,但是已经右移到了最低位所以是无意义操作(针对当前数值 24 而言,如果数值很大,第四次和第五次操作可以保证所有位数都是 1),这个时候所有的位数都是 1,所以最后再给它+1,得出来的数值一定是 2 的 n 次幂,并且是离传入值最近的 2 的 n 次幂的数值。
得出来的数值是 11111,在二进制+1,变为 100000;将 100000 转换为十进制,结果 为 32.
可以看出,不管传入的数值是多少,我们都能在二进制下将其所有位都转换为 1。而且分别经过 1,2,4,8,16 次转换,不管这个 int 类型值多大,我们都会将其转换,只是值较小时,可能多做几次无意义操作。
三、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 进行了底层优化,改为了由 数组+链表或者数值+红黑树实现,主要的目的是提高查找效率
Jdk8 数组+链表或者数组+红黑树实现,当链表中的元素大于等于 8 个并且数组长度大于等于 64 以后, 会将链表转换为红黑树,当红黑树节点 小于 等于 6 时又会退化为链表。当 new HashMap()时,底层没有创建数组,首次调用 put()方法示时,会调用 resize 方法,底层创建长度为 16 的数组,jdk8 底层的数组是:Node[],而非 Entry[],用数组容量大小乘以加载因子得到一个值,一旦数组中存储的元素个数超过该值就会调用 resize 方法将数组扩容到原来的两倍,在做扩容的时候会生成一个新的数组,原来的所有数据需要重新计算哈希码值重新分配到新的数组,所以扩容的操作非常消耗性能。默认的负载因子大小为 0.75,数组大小为 16。也就是说,默认情况下,那么当 HashMap 中元素个数超过 160.75=12 的时候,就把数组的大小扩展为 216=32,即扩大一倍。
在我们 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()方法
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);//因为新插入了一条数据,所以判断链表长度是不是大于等于 8if (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 传进来的是 falseif (!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 老数组容量设置为 0int 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 等价于 oldCap2//运算过后判断是不是最大值并且 oldCap 需要大于 16else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&oldCap >= DEFAULT_INITIAL_CAPACITY)newThr = oldThr << 1; // double threshold 等价于 oldThr2}//如果 oldCap<0,但是已经初始化了,像把元素删除完之后的情况,那么它的临界值肯定还存在, 如果是首次初始化,它的临界值则为 0else if (oldThr > 0) // initial capacity was placed in thresholdnewCap = oldThr;//数组未初始化的情况,将阈值和扩容因子都设置为默认值 else { // zero initial threshold signifies using defaultsnewCap = 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;//通过 与操作 计算得出结果为 0if ((e.hash & oldCap) == 0) {//如果低位尾为 null,证明当前数组位置为空,没有任何数据 if (loTail == null)//将 e 值放入低位头 loHead = e;//低位尾不为 null,证明已经有数据了 else//将数据放入 next 节点 loTail.next = e;//记录低位尾数据 loTail = e;}//通过 与操作 计算得出结果不为 0else {//如果高位尾为 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);}}//没有查询到返回 nullreturn 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
扩容后:00000000 00000000 00000000 00000101&00000000 00000000 00000000 00001111 16-1=15
第二种情况:扩容前:00000000 00000000 00000000 00001101&00000000 00000000 00000000 00000111 8-1=7
扩容后:00000000 00000000 00000000 00001101&00000000 00000000 00000000 00001111 16-1=15
扩容后到底是在原来位置还是在原脚标位+扩容长度的位置,主要是看新扩容最左边一个 1 对应的上方数字是 0 还是 1 如果是 0 则扩容后在原来位置,如果是 1 则扩容后在原脚标位+扩容长度的位置 HashMap 源码里扩容也是这么做的。
总结来说,就是 hash&(n-1)这个计算有关。如果不为 n 不为 2 的 n 次方的话,那转换为二进制情况下,n-1 就会有某一位为 0,那与 hash 做了 &运算后,该位置永远为 0,那么计算出来的数组位置就永远会有某个下标的数组位置是空的,也就是这个位置永远不会有值。
JDK1.7 中形成的环形链表线程一:读取到当前的 hashmap 情况,在准备扩容时,线程二介入
线程二:读取 hashmap,进行扩容
线程一:继续执行
此线程先将 A 移入新的链表,再将 B 插入到链头,由于另外一个线程的原因,B 的 next 指向了 A,所以 B->A->B,形成循环。
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。
评论