高薪程序员&面试题精讲系列41之HashMap的容量为什么必须是2的N次方?说说HashMap添加数据的流程吧

Posted 一一哥Sun

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了高薪程序员&面试题精讲系列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()取值流程等,请各位继续关注 壹哥 吧,送给你的肯定都是满满的干货!

以上是关于高薪程序员&面试题精讲系列41之HashMap的容量为什么必须是2的N次方?说说HashMap添加数据的流程吧的主要内容,如果未能解决你的问题,请参考以下文章

高薪程序员&面试题精讲系列18之for和foreach的区别原理,哪个效率更高?

高薪程序员&面试题精讲系列22之说说Java的IO流,常用哪些IO流?

高薪程序员&面试题精讲系列43之HashMap扩容机制的底层实现原理,HashMap扩容后是如何进行rehash操作的?

高薪程序员&面试题精讲系列24之你熟悉反射吗?

java基础&amp;&amp;高薪面试

大厂算法面试之leetcode精讲6.深度优先&广度优先