HashMap的数组长度为何必须是2的n次方
Posted 神一样的存在
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了HashMap的数组长度为何必须是2的n次方相关的知识,希望对你有一定的参考价值。
- 扩容方便,数字位移计算方便效率高;
- 计算元素下标使用的方式是
key的hash & (数组length - 1)
,由于length
是2^n
,转换成二进制后2^-1
最低位就全部都是1
,比如111
,就相当于是数组长度的掩码,那么hash & 111
就可以将数组的每一位都覆盖,加入数组长度不是2^n
,那么length-1
低位不全是1
,比如101
,那么hash & 101
就会出现数组有的位置永远都不会有值。 - 扩容的时候一条链表最多映射到两条链表上,计算效率高
- 2^n长度设计hash算法比较容易实现散列比较均匀
高薪程序员&面试题精讲系列41之HashMap的容量为什么必须是2的N次方?说说HashMap添加数据的流程吧
一. 面试题及剖析
1. 今日面试题
请说一下HashMap及其底层实现原理
HashMap默认的初始容量是多大?
HashMap的容量为什么必须是2的N次方?如何保证这一点?
HashMap是怎么添加数据的?
HashMap中put()方法的实现过程是什么样的?
你有没有看过HashMap的put()方法源码?
......
2. 题目剖析
在前2篇文章中,壹哥 给大家介绍了HashMap的基本特点,介绍了HashMap的底层数据结构。并且 壹哥 给大家重点讲解了HashMap中的重要属性,分析了HashMap的默认初始容量、负载因子等重要属性。但HashMap中还有很多其他的核心方法,那么在这篇文章中,壹哥 会重点带大家来阅读HashMap中的一些核心方法的源码。
前2篇文章地址如下:
高薪程序员&面试题精讲系列39之说说HashMap的特点及其底层数据结构
高薪程序员&面试题精讲系列40之HashMap默认初始容量、最大容量、负载因子是多少?链表转红黑树阈值是多少?HashMap什么时候进行扩容?
二. HashMap的初始化方法
HashMap中的方法有很多,我们从哪里开始看呢?在使用HashMap时,我们一般都要先创建一个对象出来,所以构造方法无疑就是我们阅读核心方法的入口点。
1. HashMap的构造方法
HashMap提供了4个构造方法来进行对象的初始化。
- HashMap(): 创建一个默认初始容量为16 和 默认负载因子为0.75 的空HashMap;
- HashMap(int initialCapacity): 创建一个 带有指定初始容量和默认负载因子为0.75 的空HashMap;
- HashMap(int initialCapacity, float loadFactor): 创建一个 带有指定初始容量和负载因子的 空HashMap;
- HashMap(Map<? extends K, ? extends V> m):根据某个已有的Map创建一个HashMap对象。
这4个构造方法如下图所示:
构造方法的作用,一般就是进行对象创建,还有就是进行必要的初始化操作。在HashMap中,除了可以在构造方法中进行初始化之外,还会在put()方法中对HashMap进行一定的初始化操作。当我们调用put(k,v)方法添加添加时,其内部会先检查 table数组 是否为空,如果为空就先进行初始化,对table数组进行扩容。
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
首次初始化分为无参初始化和有参初始化两种情况,无参时默认容量是16,也就是table数组的初始长度为16。有参初始化的时候,会使用tableSizeFor()方法来确保实际的容量是2的N次方,最后在resize()方法中new一个Node数组出来。
final Node<K,V>[] resize()
Node<K,V>[] oldTab = table;
......
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
......
return newTab;
其中 newCap就是容量,默认是16或者是自定义的值。至于HashMap的扩容机制,我在后面再给大家细说。
2. HashMap的初始容量设置
本节相关面试题:
为什么HashMap的容量必须是2的整数倍?
怎么保证这一点?
前面我们已经知道,在构建HashMap对象时应该设置一个初始容量,并且该初始容量的大小必须是 2的n次方,否则默认会采用 16 作为初始容量。但真正开发的时候,我们又很难保证每个开发人员都会按要求来设置初始容量,总有些不守规则的呆逼用7、15、39等数字来做初始容量,那假如就有人采用了这样的数值作为初始容量会发生什么呢?
其实对这个问题,我们大可不必担心。默认情况下,当我们设置HashMap的初始容量时,如果该值不是2的n次方,HashMap会把 第一个大于等于该数值的2的n次方 来作为初始容量。
比如某个呆逼就把初始容量设置成3,你以为这个呆逼不成熟的想法就会实现吗?不会的!!!HashMap的作者早就预料到会有这种菜鸟存在了!这个3根本就不会成为初始容量的!
真正的初始容量会被HashMap自动设置为4(以此类推,1->1、3->4、7->8、9->16...)。也就是说HashMap并不一定会直接采用我们传入的数值来作为初始容量,而是会经过计算,得到一个新值,这个新值必须是2的整数倍!这样做可以提高hash的效率,后面到hash()方法时我再细说。
另外在JDK 1.7和JDK 1.8中,HashMap设置这个初始容量的时机是不同的。JDK 1.8中,当我们调用HashMap构造函数时,就会进行初始容量的设置;而在JDK 1.7中,要等到第一次put操作时才会进行这一设置。
这时有些小伙伴可能很好奇,到底是在哪里进行的自动转换,可以把初始容量自动变成2的整数倍?这个功能是由HashMap中的tableSizeFor()方法来实现的,我们继续往下看。
3. tableSizeFor初始化容量转换
本节相关面试题:
为什么HashMap中的容量必须为2的整数幂?
上面 壹哥 给大家说过,即使我们在进行HashMap初始化时,设置的初始容量不是2的整数倍,我们也不用担心,因为HashMap内部会自动把这个初始容量设置为2的整数倍。这个功能是由tableSizeFor(initialCapacity)方法来实现的,其源码如下:
/**
* 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;
这是一个非常巧妙,也非常有意思的算法。比如你给定的初始大小是63,经过该方法计算后,最后得到的结果是64;如果给定的初始大小是65,那得到的最后结果是128;如果给定的初始大小是64,最后得到的结果还是64。总是能得到一个不小于给定初始大小,并且是2的n次方的结果!
那么tableSizeFor()这个方法具体是怎么进行计算的呢?结合着源码,壹哥 给大家细讲一下。
该方法首先会 把初始参数减 1,然后 连续地执行 或等于 和 无符号右移 操作,最后计算出一个 2的N次方的结果。下图演示了初始参数为 18 时的一系列计算过程,最后得出的初始大小为 32。
tableSizeFor()方法具体的执行过程是:
先把18减去1得到17;
将17转为对应的8位二进制,把该数据进行无符号右移1位,再把17和位移后的数据执行 或运算,然后把或运算的结果再赋值给n变量;
然后依次重复上面的步骤执行下去......
经过该方法的计算,不管我们给定的初始值是不是2的整数倍,最后一定会得到一个大于或等于给定值的2的整数倍!
三. HashMap的put()方法【重点】
接下来我们进入到HashMap的put()方法中来,了解一下HashMap是如何添加数据的,以及HashMap的存储机制到底是怎么回事。
本节相关面试题:
HashMap中put()方法的实现机制是什么样的?
1. HashMap插入数据步骤
首先我们来了解一下HashMap#put()方法插入数据的具体步骤,这里我借用网上的一张图,给大家展示put()方法是如何插入一个数据节点的。
根据上图,我们可以总结出向HashMap中插入数据的具体步骤,其实可以简单总结为如下几步:
- 根据待插入数据的key,计算出其对应的hash值,并根据该hash值确定元素的插入位置(即在动态数组中的位置);
- 将元素放入到数组的指定位置,如果该位置上之前没有元素,则直接放入;
- 放入该位置后,如果数组元素超过了扩容阈值,则对数组进行扩容;
- 放入该位置后,如果数组元素没超过扩容阈值,则写入结束;
- 如果该位置上之前已有元素,则直接覆盖掉旧元素;
- 如果元素之前组成了红黑树,则挂入到树的指定位置;
- 如果之前元素组成了链表,则先进行判断,看看链表长度是否超过了树化的阈值;
- 如果加入该元素后,链表长度超过8,则将链表转化为红黑树后插入;
- 如果加入该元素后,链表长度不超过8,则直接插入。
2. put(key,value)方法源码
上面简单梳理了插入元素的步骤,但为了弄清楚HashMap的存储机制及原理,我们还是要来看看put(k,v)方法的源码,如下:
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with <tt>key</tt>, or
* <tt>null</tt> if there was no mapping for <tt>key</tt>.
* (A <tt>null</tt> return can also indicate that the map
* previously associated <tt>null</tt> with <tt>key</tt>.)
*/
public V put(K key, V value)
return putVal(hash(key), key, value, false, true);
猛看一眼,这个put()方法很简单哎,里面一句话就完事了!但事实真的如此吗?作为HashMap中非常重要的方法,怎么可能就这么简单呢?所以我们需要继续往下追踪,看看putVal()方法,这才是HashMap存值的关键所在!
3. putVal()方法源码
接下来我们看看实现存值的关键所在吧,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;
//如果table数组没有初始化,或者初始化的大小为0,则对table进行resize扩容操作
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果数组的当前位置上没有存储数据节点,则直接生成一个数据节点并放入到数组的当前位置上
//注意:数组的索引位置i,是由数组长度n-1,和key的hash值进行&与运算得到
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else
//如果当前位置上已经有了数据节点,则先解决冲突,再放入到该位置上。
//冲突的解决有3种情况:①.直接覆盖;②.使用链表;③.使用红黑树。
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//判断要添加的元素和已经存在的元素是否相同(hash一致,并且equals返回true)
//如果相同,则进行元素替换,新元素直接覆盖旧元素
e = p;
else if (p instanceof TreeNode)
//如果要添加的元素和已经存在的元素不相同(hash一致,并且equals返回true)
//且桶内元素的类型是TreeNode,也就是解决hash冲突用的是树型结构,则把元素放入到树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else
//如果桶内元素的类型不是TreeNode,而是链表类型,则把元素放入到链表的最后一个元素上
for (int binCount = 0; ; ++binCount)
if ((e = p.next) == null)
p.next = newNode(hash, key, value, null);
//如果链表的长度大于等于转换为树的阈值8(TREEIFY_THRESHOLD),则将存储元素的数据结构变更为树,进行树化操作
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
//如果是已存在的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;
//记录每次add/remove等操作次数
++modCount;
// 如果K-V数量大于阈值,进行resize操作
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
这个putVal()方法的源码明显就复杂了很多,好在 壹哥 对该方法中的核心代码,都给大家做了详细注释,对于该方法的代码功能,请仔细阅读代码中的注释。
4. HashMap的put()方法执行流程
虽然上面的putVal()方法源码中,壹哥 对核心代码都做了中文注释,但可能还是有些难以理解,所以我们再结合下图,来重点分析put()方法的内部实现。
我结合上面的源码和图片,最终给大家总结出put()方法的代码执行流程如下:
- ①. 判断键值对数组table[i]是否为空或leng=0,如果是,则执行resize()扩容;
- ②. 根据键key计算出hash值,然后得到插入的数组索引i,如果table[i]==null,则直接新建一个节点进行插入,并转向⑥;如果table[i]不为空,则转向③;
- ③. 判断table[i]中首个元素的key是否和当前key一样,如果相同直接覆盖value,否则转向④,这里的相同依据的是hashCode以及equals();
- ④. 判断table[i]是否为TreeNode类型,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
- ⑤. 遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;如果在遍历过程中发现key已经存在,则直接覆盖value即可;
- ⑥. 插入成功后,判断实际存在的键值对数量size是否超过了最大容量threshold,如果超过,则进行扩容。
以上这些内容就是HashMap的存储机制及其实现原理,掌握这些内容就可以回答出如下面试题:
HashMap中put()方法的实现过程是什么样的?
五. 结语
至此,壹哥 就把今天的内容给大家讲解完毕,我们要重点掌握HashMap对容量的设置、put()方法的执行流程,这也是面试时的重点。接下来,壹哥 会继续分析HashMap的底层原理,比如HashMap中的扩容机制、索引定位、冲突解决、链表与红黑树的转换、get()取值流程等,请各位继续关注 壹哥 吧,送给你的肯定都是满满的干货!
以上是关于HashMap的数组长度为何必须是2的n次方的主要内容,如果未能解决你的问题,请参考以下文章
高薪程序员&面试题精讲系列41之HashMap的容量为什么必须是2的N次方?说说HashMap添加数据的流程吧
Java集合 -- HashMap底层实现HashMap 的长度为什么是2的幂次方ConcurrentHashMap 和 HashtableConcurrentHashMap线程安全的实现