【面试准备】HashMap详细分析,妈妈再也不用担心我的学习

大家好,我是被白菜拱的猪。

一个热爱学习废寝忘食头悬梁锥刺股,痴迷于学习的潇洒从容淡然coding handsome boy。

前言

不知不觉已然大三,此时此刻我脑子回想起那篇《匆匆》“燕子去了,有再来的时候;杨柳枯了,有再青的时候;桃花谢了,有再开的时候。”想着还有一个学期就要秋招找工作,我不禁头涔涔而泪潸潸了,聪明的,你告诉我,怎样才能不被面试官吊打呢?

随手一搜面试题,oh,南湖的水,我的泪。

  • HashMap的底层数据结构?
  • HashMap的存取原理?
  • Java7和Java8的区别?
  • 为啥会线程不安全?
  • 有什么线程安全的类代替么?
  • 默认初始化⼤⼩是多少?为啥是这么多?为啥⼤⼩都是2的幂?
  • HashMap的扩容⽅式?负载因⼦是多少?为什是这么多?
  • HashMap的主要参数都有哪些?
  • HashMap是怎么处理hash碰撞的?

不知道小伙伴们是不是跟我一样都一脸懵逼,这学校也没这样教啊。少年,终究是年轻,这年头啥不都得靠自学这点道理还不懂吗?

不要怕,小弟偷偷告诉你一个小秘密,我最近关注了一个微信公众号,叫“放开这颗白菜让我来”,听说他跟咱一样也是要秋招,而且也是啥都不会,但是这不怕累爱吃苦,他最近要把自己学习准备面试历程记录下来,我们可以白嫖啊,看看他是怎么学的。你看,下面就是他写的关于HashMap的。

HashMap简单介绍

首先对HashMap做一个简单的介绍,HashMap是Map接口的一个具体实现类,而Map是一种跟Collection同一级别的结合,它是以键值对即key-value的形式存储数据的。而HashMap与众不同的地方就是在于前面的这个Hash,它是运用Hash算法来存储数据的。

什么是哈希算法?哈希算法就是把任意长度即这里的key通过一种算法变换到固定长度(地址),然后在通过这个地址进行访问数据。实在太官话了,那么就让利哥举个简单的小例子,比如我们想存储404,1,2,55,20有一种方式就是对他们求模,这样404%10=4,404这个数就放在了下标为4的位置中,1则放在了下标为1的位置,当然2,55分别放在了下标为2,5的位置。这里求模就是一种hash算法。4444,44,4这些不同的数可以放在同一个地址的位置。

那么问题来了,数组只能放一个元素啊,怎么能同时放多个元素呢?就比如你要上厕所,两个人通过哈希算法获得同一个索引,也就是同一间厕所,到底是你上呢还是我上呢,这说不好就要打起来的样子,这就是所谓的哈希冲突,也称之为哈希碰撞。那么如何解决这个问题呢?我们使用链表,即假如使用哈希算法得到的数组索引相同时我们使用链表的形式挂在元素后面。那这里为什么不使用数组呢?数组它是固定长度,而且存储元素的类型必须是一致的,没有链表那么方便。

关于是否树化问题

HashMap底层数据结构

所以通过上面的讲述,我们不难猜出HashMap采用的就是这个原理,它的底层数据结构为数组+链表的形式。这是JDK1.7形式,在1.8中,底层数据结构变成了数组+链表+红黑树。那么问题又来了,我的天,这小子天天问题怎么那么多?

问题一:为什么换成了红黑树?

我们知道链表的查询效率是不高的,在1.8时对它的查询进行了优化,我们可以想到使用二叉搜索树,但是二叉搜索树也有一种情况是所以的结点的在一条线上,那实际上也是一种链表的形式,所以我们能不能让这颗的左右子树看起来平衡一些,于是红黑树就重现江湖,但是严格意义上来说,它又不严格遵循平衡二叉树的定义。这里红黑树也是相当的重要,搞完HashMap之后在另起篇幅学习学习红黑树。

问题二:为什么不完全用红黑树?

在1.8的源码中我们看到他是有一个阈值的,这是说在元素大于等于8时才转换为红黑树,在此之前是使用链表的形式。

    static final int TREEIFY_THRESHOLD = 8;



	 for (int binCount = 0; ; ++binCount) {
    
    
                    if ((e = p.next) == null) {
    
    
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // 这里binCount从0开始,也就是数量大于等于8进行树化
                            treeifyBin(tab, hash);
                        break;
                    }

那为什么不直接使用红黑树呢?其实这是一种空间与时间的平衡。我们看看源码怎么说

     * Because TreeNodes are about twice the size of regular nodes, we
     * use them only when bins contain enough nodes to warrant use
     * (see TREEIFY_THRESHOLD). And when they become too small (due to
     * removal or resizing) they are converted back to plain bins.  In

用我这蹩脚的英语翻译一下,就是说树的结点比常规的结点要大两倍,也就是空间上比较大,我们使用红黑树的时候要将常规结点转化为拥有左右子结点的树节点,我们当结点达到一定数量时才使用红黑树,这里就是树化的阈值8,然后数量变少我们又要重新转化为普通的结点,这时为6。

    static final int UNTREEIFY_THRESHOLD = 6;//不树化的阈值

那么这时候爱问问题的小帅bi又要说为什么为6而不是等于8呢?假如是8的话,那当binCount为8时,是该树化呢还是不树化呢,就陷入一个死循环,所以肯定比8小,那么为什么为7呢?若是 7,则当极端情况下(频繁插入和删除的都是同一个哈希桶)对一个链表长度为 8 的的哈希桶进行频繁的删除和插入,同样也会导致频繁的树化<=>非树化。可以想象一下,当binCount为8时要树化,然后删除一个元素变成7,就要不树化,反反复复,复复反反,crazy。因此,选定 6 的原因一部分是需要低于 8,但过于接近也会导致频繁的结构变化。假如再低于6,就没有达到接近我们刚开始讲的空间与时间平衡的问题。

这时候小帅比又要问了,讲了大半天,你还是没有说明白为什么树化的阈值是8啊?

小帅比你真是一个刨根问底的三好青年,不要急不要躁,如此重要的问题,我怎么会忘记呢?我们看看源码中的作者怎么说,小天才翻译家又来了。

     * removal or resizing) they are converted back to plain bins.  In
     * usages with well-distributed user hashCodes, tree bins are
     * rarely used.  Ideally, under random hashCodes, the frequency of
     * nodes in bins follows a Poisson distribution
     * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
     * parameter of about 0.5 on average for the default resizing
     * threshold of 0.75, although with a large variance because of
     * resizing granularity. Ignoring variance, the expected
     * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
     * factorial(k)). The first values are:
     *
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million

意思就是说在hashcode分布很好的话,树容器(也就是红黑树)是很少使用的,在理想情况下,随机的hashCode值是满足泊松分布的,然后给出了下面的值,当容器的长度为8也就是链表的长度为8时几乎是一件不可能事件,但是用户会自定义hashcode,就会导致hash不均匀。总的来说,设计者在设计的时候不是无缘无故瞎想一个值的,是基于理论研究得来了,这种精神必须得点一个赞。

关于树化还有一个变量我们需要了解

    /**
     * The smallest table capacity for which bins may be treeified.
     * (Otherwise the table is resized if too many nodes in a bin.)
     * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
     * between resizing and treeification thresholds.
     */
    static final int MIN_TREEIFY_CAPACITY = 64;

最小的容器容量,否则结点太多就会被扩容,这是为了避免树化和扩容的冲突。也就是说链表转化为红黑树有两个条件,一是链表的长度大于等于8,二是数组的长度大于等于64。

        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();

在树化的这个方法中,假如tab也就是数组的长度小于64,则调用扩容的方法。

如何确定桶的索引

关于树化的几个概念我们理清了,我们知道元素放在哪个桶是根据hashcode来决定的,那么他具体是怎么实现的呢?也就是说怎么来确定放在哪个桶里面的呢?这里的桶指的是数组的位置,直接上源码:

    static final int hash(Object key) {
    
    
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

看到这里有些懵逼,>>>这是什么玩意,而且为什么要异或而不是直接返回hashcode呢?首先>>>是获得无符号右移,获得hashcode的高16位,因为hashcode是一个int类型,4个字节32位,右移了16位就剩下了之前的16位,这个方法只是返回hash值,还没有确定桶的索引,在jdk1.7中有单独的一个方法,1.8中没有另创方法但是原理是一样的。

        if ((p = tab[i = (n - 1) & hash]) == null)//这里的i = (n - 1) & hash
            tab[i] = newNode(hash, key, value, null);
        else {
    
    

这里的i = (n - 1) & hash就是获取索引的方法,n是桶的数量,hash就是前面根据hashCode与本身右移16位异或得到的hash值,为什么这么写?简单的讲是为了更好的让他们元素分布在桶中,为什么这样写就能均匀分布呢?还有为什么在创建HashMap时推荐我们初始容量为2的幂次方。这一系列的问题都在围绕让元素均匀分布在桶中这一目的。

因为在绝大数情况下length一般都小于2^16,我们右移在异或是为了保留hashcode的高16位和低16的特征,让他更加随机化,从而达到分布均匀的效果,假如直接返回hashcode,那么在绝大多数情况下我们只获得了低16位的特征,那为什么不用&和|呢,也是避免了浪费,他们会让结果偏向1或者0,没有达到均匀的效果。

All right,既然得到的hash已经作了均匀化处理,那么如何保持这种效果呢?这里的(n - 1)& hash,真的很厉害,尤其是&这个符号,这也是为什么推荐数组的长度是2的幂次方,假如这里为2^4,16-1=15,二进制为1111,全为1,然后&一下,那么hash的低四位的效果全保留下来了,假如均匀的话,那么在桶中的分布也是均匀的,假如为10的话,减个1二进制为1001,那么hash的低四位中间的两位完全不起作用都为0,不知道小帅比们是否领悟到了这个神之一笔,而且这里&运算的效果跟取模效果相同,效率更高。

除此之外,扩容之后,一个元素的新索引要么是在原位置,要么是在原位置加上扩容之前的容量,这是因为每次扩容 = 原容量*2,也就是111扩容之后变成1111,扩容之后位置是否改变取决于hash对应位置是1还是0,又增加了随机性,之前发生碰撞的Node又可能被分开,变得离散化,真的是妙哉妙哉,这源码不读不知道,一读吓一跳,短短几行代码,竟然有这么大的学问,看看垃圾的自己,我不禁头涔涔而泪潸潸了。

HashMap的扩容机制

当元素达到一定数量,那么之前数组的长度已经满足不了当前现状就需要采取必要的扩容措施,那么是如何扩容的呢?它是根据数组的容量(CAPACITY)和 负载因子(DEFAULT_LOAD_FACTOR)决定的,默认的负载因子为0.75,假如数组的默认长度为100,当数组长度达到76时就要扩容(resize)。我们通过debug一步步的来分析源码。

public class MyHashMap {
    
    
    public static void main(String[] args) {
    
    
        HashMap<String,String> map = new HashMap<>();
        map.put("nihao","first");
        map.put("lijiali","second");
    }
}

首先调用HashMap的无参构造方法,这里采用的是懒汉式加载,只是把负载因子赋值,注意这里还没有给数组赋初始值,即DEFAULT_INITIAL_CAPACITY。

    public HashMap() {
    
    
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

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;
        if ((tab = table) == null || (n = tab.length) == 0)//1.第一次添加元素时,tab为空
            n = (tab = resize()).length;//2.调用resize()方法,这行代码只有在第一次添加元素时起作用
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
    
    
            // 走到这一步,说明原先位置上已经有值了,即发生了hash碰撞,也就是不是第一次添加元素
            // p是前面已经数组对应索引已经有的值
            Node<K,V> e; K k;
            //假如此时p的hash跟要插入的hash一样且key的值也一样,说明两个值一样,那就不用往下找了,用临时结点e保存当前结点,然后最后用新值取代旧值。
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p; 
            //假如当前结点是树结点,说明已经树化了,那么就不能使用链表的形式putVal
            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) {
    
    //假如第一个结点的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;
            }
        }
        //避免fail-fast问题
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

通过源码分析,第一次添加元素时的扩容跟非第一次扩容是不一样的,我们看看第一次扩容是什么样子的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xL4cj8De-1606198688438)(C:\Users\Lily\AppData\Roaming\Typora\typora-user-images\image-20201123102910779.png)]

            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//然后给数组16大小,返回出去,

然后将元素添加进数组中

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NsPZqcJQ-1606198688442)(C:\Users\Lily\AppData\Roaming\Typora\typora-user-images\image-20201123103539249.png)]

这就是第一次添加元素的过程。注意这里有一个modCount,这是关于线程安全问题的,我们下文在讲,暂且不在这里叙述。

非第一次添加元素可以通过阅读源码加以分析,上面的对源码添加了注释,下面进行总结。从代码的结构if-else if-else可以看出当数组对应位置已存在元素时共有三种情况。

  1. 发现数组中的元素跟我们要添加的元素发生碰撞时,使用临时结点e保存当前结点,最后做统一的判断,因为我们在put元素时包含了添加和修改两个功能。比如我想map.put(“handsomeboy”,“handsome”),之后在map.put(“handsomeboy”,“excellent”),此时就是修改的功能,所以在putVal()方法中不仅仅是要单纯的添加元素,还要判断要添加的元素是否已存在,即key已存在,存在则是修改之前的值。
  2. 第一个元素跟要添加的元素不相等,也就是不采用新值覆盖旧值,那么我们就往后添加,在此要判断是否是树节点,因为树化的过的跟采用红黑树put的策略不一样,假如该元素是树节点又不与要添加的key相同,那就改变策略使用putTreeVal()继续添加此值。
  3. 这种情况就是链表形式,这里有两种情况,一是走到了头就是没有发现key相同的元素,就根据传过来的值创建一个新的结点,挂在最后一个结点的后面,也就是尾插法,然后判断是否需要树化,另一种在移动的时候发现了key相同的,则跟第一种一样,记录当前结点,跳出循环,在最后新值覆盖旧值。onlyIfAbsent if true, don’t change existing value

resize()

第二次扩容时,当容量达到了阈值,即插入了第十三个元素就要第二次扩容,扩容的机制是capacity和threshold扩大到之前的2倍。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NC8BPct4-1606198688444)(C:\Users\Lily\AppData\Roaming\Typora\typora-user-images\image-20201123190733506.png)]

扩容之后要将原先的hashMap重新hash,在前面讲过,元素放在哪个桶里面index = hash & (length - 1),length是桶的数量,也就是数组的长度也就是这里的capacity,length改变之后index也要相应的改变,所以每次扩容要重新计算元素要放在哪个桶中。

源码resize()方法中下面一大坨就是将元素重新分布的过程,看着长长的代码,实在不想看,这什么玩意啊,看也看不懂,正想打算放弃,小手不自觉在搜索栏上输入了hashMap resize(),看完他人的解释,我不禁对源码作者产生了深深的钦佩之情,佩服至极,这或许就是代码乐趣所在,在看懂之后的那种喜悦以及代码设计之精妙!下面就随着我的步伐去剖析这段让人赞不绝口代码块。

    //以下这段代码的目的是讲之前的元素转移到扩容过后的新的数组中  
	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;
                        }
                    }
                }
            }
        }

让我们一段一段一行一行小心翼翼的看这个代码块,我们把这几行代码分为三个部分

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uGdXK79w-1606198688447)(C:\Users\Lily\AppData\Roaming\Typora\typora-user-images\image-20201123195622718.png)]

第一段:

                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;

我们根据名字大胆的猜测一下,这有四个结点,一个lo(low)链表,一个hi(high)链表,然后每个链表有头结点和尾结点。

第二段:

是一个do-while结构,目的是遍历桶中的每一个结点。

                        do {
    
    
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
    
    
                                //这段代码实际上是将e结点插入到lo链表中
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
    
    
                                //这段代码是将e结点插入到hi结点中
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);

为什么要将桶中的每一个结点根据判断条件是否成立放进不同的两个链表中呢?为了解释这个原因我们必须要搞懂(e.hash & oldCap) == 0这个判条件的意思是什么,以及为什么要这么做,我们之前讲过扩容时,容量是扩大之前的两倍,而且也说明了初始容量为什么要是2的幂次方以及index = hash &(length - 1)的精妙所在。扩容后元素的index要么是当前位置,要么是当前位置 + 之前的容量,这取决于新加的那个位置是1还是0,比如之前是 111,扩容之后length -1变成了1111,那么hash第四位原先为0111,扩容过后依旧还是111,位置不变,假如是1111,那么扩容后的位置为1111 = 1000 + 111(扩容前的容量+之前的位置)。

那么如何判断是位置是1还是0呢?该位置的值就是e.hash & oldCap,所以这个条件准确的应该是(e.hash & oldCap) == 0和(e.hash & oldCap) == 1。等于0说明位置不变,先暂时存放在lo链表中,等于的暂时存放在hi链表中,然后最后将其头结点挂上去,搞定。

那就来看看第三段代码是怎么写的:

                   if (loTail != null) {
    
    
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
    
    
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }

j是外面for循环数组的下标,也就是桶的下标,j + oldCap 就是前面所说的扩容前的容量+之前的位置。小伙伴们是不是都惊呆了,当然文字的描述由于我语言功底的有限会让大家阅读起来会产生一种只可意会不可言传的情况,但是还是希望能够静下心来去感受一下代码的魅力。

getNode()

在了解putVal和resize()后,在看getNode方法就很容易理解了

    final Node<K,V> getNode(int hash, Object key) {
    
    
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
    
      //根据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))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

在这里我们经常看到下面这段代码做判断条件,为什么要这么写呢?而且面试官会经常问到重写equals为什么一定还要重写hashcode呢?

p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k)))

1.使用hashcode方法提前校验,可以避免每一次比对都调用equals方法,提高效率

**2.保证是同一个对象,如果重写了equals方法,而没有重写hashcode方法,会出现equals相等的对象,hashcode不相等的情况,重写hashcode方法就是为了避免这种情况的出现。**比如我 Person person1 = new Person(“小帅哥”);然后Person person2 = new Person(“小帅哥”);我们想要的效果是person1和person2是同一个人,但是 对于对象引用比较的是内存地址,计算机在判断的时候他们不是同一个人,所以为了达到我们想要的效果就要重写hashcode。

面试常见问题

下面对几个面试常见问题进行汇总解答

1、HashMap1.8为什么采用尾插法

1.7中采用的是头插法,那为什么换成了尾插法了呢?这是因为在多线程的情况下,头插法会造成链表翻转,本来A连接B,然后假如现在出发了扩容机制,B重新分配位置也是该位置,那么B连着A,A这时连着B,就构成了一个环形链表,取值的时候就造成了死循环,出不去。为什么尾插法不会出现这种情况呢?因为尾插法在扩容转移时,结点之间的顺序不会发生改变,保持着之前的引用关系。

2、HashMap1.7与1.8对比

  1. 底层数据结构:1.7中是数组+链表。1.8中是数组+链表或数组+红黑树;
  2. 元素插入方式:1.7是头插法插入链表。1.7是尾插法插入链表;
  3. 节点类型:1.7中数组中节点类型是Entry节点,1.8中数组中节点类型是Node节点;
  4. 元素插入流程:1.7中是先判断是否需要扩容,再插入。1.8中是先插入,插入成功之后再判断是否需要扩容;
  5. 扩容方式:1.7中需要对原数组中元素重新进行hash定位在新数组中的位置。1.8中采用更简单的逻辑判断,原下标位置或原下标+旧数组的大小。

3、HashMap是线程安全的吗?

不是的,在1.7中会就像前面讲的头插法会出现死循环的情况,除此之外还会出现数据覆盖、数据丢失。并且即便在解决死循环的情况下,1.8的hashMap仍然出现数据覆盖的情况,比如两个线程put元素时,A线程在判断位置为空时挂起,B线程则将元素put到index中,此时A线程恢复,这不就把B写入的元素覆盖掉啦。

4、如何解决HashMap线程不安全问题?

可以使用Hashtable,和Collections.sychronizedMap()或者ConcurrentHashMap,HashTable十分粗暴,直接在方法上加了一个sychronized关键,ConcurrentHashMap则采用了分段锁。

5、为什么会有负载因子,并且为0.75而不是0.8或者其他

一般的数据结构,不是查询快就是插入快,HashMap就是一个插入慢、查询快的数据结构。

但这种数据结构容易产生两种问题:① 如果空间利用率高,那么经过的哈希算法计算存储位置的时候,会发现很多存储位置已经有数据了(哈希冲突);② 如果为了避免发生哈希冲突,增大数组容量,就会导致空间利用率不高。

而加载因子就是表示Hash表中元素的填满程度。

加载因子 = 填入表中的元素个数 / 散列表的长度

加载因子越大,填满的元素越多,空间利用率越高,但发生冲突的机会变大了;

加载因子越小,填满的元素越少,冲突发生的机会减小,但空间浪费了更多了,而且还会提高扩容rehash操作的次数。

冲突的机会越大,说明需要查找的数据还需要通过另一个途径查找,这样查找的成本就越高。因此,必须在“冲突的机会”与“空间利用率”之间,寻找一种平衡与折衷。

而这里的0.75是经过满足一种概率论中的泊松分布。

6、解决hash冲突的办法

  1. 开放地址法:为发生冲突的地址求得一个地址序列,分为线性探查法、线性补偿探测法、随机探测法。
  2. 再哈希法:产生冲突时计算另一个哈希函数地址,直到不产生冲突为止。
  3. 链地址法:将所有hash地址相同的元素都链接在同一个链表中
  4. 建立公共溢出区:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表

HashMap解决hash冲突的方法就是链地址法。

最后总结

看完此篇,是不是对HashMap又多了几分认识,对代码又多了几分思考,简单的put,get背后竟然有那么多东西,想想HashMap后面还有ConcurrentHashMap,Hashtable,除此之外,还有JUC、MySQL、Spring各种底层原理等等。我的眼角又湿润了…又想吟诗一首“路漫漫其修远兮,吾将上下而不求索”。

最后,不要光看,记得点赞哟。

假如想要获取更多知识,请关注微信公众号“放开这颗白菜让我来”,期待与你相遇。

猜你喜欢

转载自blog.csdn.net/weixin_44226263/article/details/110070862