hashmap面试相关、源码剖析

HashMap的底层原理实现

hashmap继承了abstractMap,实现了map、cloneable接口,也就说hashmap实现了map相关的接口,比如put、get等接口。

  • 数据结构

  • 红黑树

  • 数组

  • 链表

  • put

  • put方法实现是putval方法,通过hash(key),key,value进行插入,也就是咱们的key会给我们通过hashmap自定义实现的hash进行hash计算,

  • hash(key)

  • 如果说key为空,则返回0

  • 获取key的hash值异或该hash值右移16位,主要是避免理想状态下的hash碰撞,右量16位也是hashmap能将性能发挥到极致的原因

  • 1.首次创建hashmap,插入数据时候,回调用resize分配一个数组长度为16个的node数组,扩容也是在resize方法扩容

  • 2.threshold需要调整的下一个大小值(容量*负载因子)(容量DEFAULT_INITIAL_CAPACITY=16,负载因子load_factor=0.75)

  • 3.数组|红黑树|链表插入完成后,会去判断当前数组的长度是否大于当前已有的容量*0.75,如果大于,会调用resize方法进行扩容2次方

  • 4.putVal涉及两次扩容,没数据的时候扩容,当前数据>下一次需要调整容量的大小值后扩容

  • 5.resize方法,主要是对当前容量、下一次的容量进行扩容,节点重新排序,排序后还是原来的位置,最主要的是如果当前的容量超过1>>30位后,会给下一次需要扩展的容量分配int的最大值,如果下一次需要扩容的容量超过1>>30位后,会赋值一个最大的int值,不满足的话,当前下一次的容量就重新赋值为下一次的容量x2。如果下一次要分配的容量为0,也就是咱们创建hashmap的时候未设置初始容量和负载因子,那这一块会给我们的容量赋值为16,下一次扩容的容量会用这个容量x0.75,最后将原数据的值赋值到当前的新容器中,链表的话进行重排序,但是不会影响索引位置。

  • 6.回到putval方法,找到当前hash计算后数据的位置 没找到就创建一个新的,有的话就赋值给p,下一步,如果key和value都是相同的就替换,如果不是就判断下是不是红黑树的节点,如果是,就创建个新的节点,把数据扔进去

  • 7。如果不是树节点,那可能就是链表了,然后进行遍历,数据就放到当前遍历节点的后面,如果节点的长度超过8个,就将这个链表的数据转化为红黑树

  • 10。红黑树转链表,当链表的阙值低于6个时,红黑树转链表,也就是resize方法内,咱们对当前node进行重排序的时候,会调用split方法,这个方法在重排序的同时,会对红黑树和链表进行转换,阙值低于6,转链表,高于8,转红黑树

  • get

  • 我们都知道,key的hash是不会变的,就算最终通过hash(key)最终得到的结果也和插入的结果是一样的

  • 1.先去根据当前table表结构的长度和hash进行&(与)计算去获取元素,如果获取成功,获取失败直接return,获取成功则会进行key比对,比对成功则直接返回,比对失败则进入下一个点,其实这一块主要就是表的node结构

  • 2.去寻找当前点上的下一级,也就是next,如果next 结构是TreeNode红黑树结构,那么就会进行find查找,查到后直接返回

  • 3.如果是非红黑树,则去咱们的链表里查找,最终查到后返回

1 HashMap为什么异或原数右移16位计算哈希值?

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

简单的说如果key为null,返回0。否则返回key的hash值异或一个key的hash值右移16位。

我们看一下效果(异或^ 拆解为二进制数 相同为0 不同为1 与符号&同为1时为1 不同则为0,同为0也是0)

0000 1010 1000 1000 1010 0011 0111 0100 `原数`

0000 0000 0000 0000 0000 1010 1000 1000 `右移16`

0000 1010 1000 1000 1010 1001 1111 1100 `异或结果`

我们发现,高位16没有发生变化,因为右移16位之后高位都是补0,1异或0还是1,0异或0还是0。

到此我们不能明确的知道,这个异或右移16位有什么作用,我们看一下HashMap如何计算插入位置的。

源码
if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);

n为容量大小,假设我们现在的容量是起始容量16,则这里的算式就是15&hash值

我们看一下效果

1101 0011 0010 1110 0110 0100 0010 1011 `原数`
    
0000 0000 0000 0000 0000 0000 0000 1111 `15的二进制`
    
0000 0000 0000 0000 0000 0000 0000 1011 `结果`

仔细观察上文不难发现,高区的16位很有可能会被数组槽位数的二进制码锁屏蔽,如果我们不做刚才移位异或运算,那么在计算槽位时将丢失高区特征(虽然高区特性不同hashcode也可以计算出不同的槽位)

也许你可能会说,即使丢失了高区特征不同hashcode也可以计算出不同的槽位来,但是你设想如果两个哈希值的低位差异极小而高位差异很大,导致这两个哈希值计算出来的桶位比较接近,会插入到HashMap的两个位置比较相邻的位置,这样哈希碰撞的概率就变高了!

我们认为一个健壮的哈希算法应该在hash比较接近的时候,计算出来的结果应该也要天差地别,足够的散列,所以这个高位右移16位的异或运算也是HashMap将性能做到极致的一种体现。

2 HashMap的hash算法为什么使用异或?

异或运算能更好的保留各部分的特征,如果采用&(与)运算计算出来的值会向0靠拢,采用 |(或) 运算计算出来的值会向1靠拢。这样的话得到的hashCode基本相近,容易产生重复的hash值,碰撞的概率就高了,而使用异或,无论值是否相近,得到的hash值都是差别很大的,也就是说使用异或可以使hash碰撞的概率降低,理想状态下是不会出现hash碰撞

举例:(此处截图只是展示得到的公式为 (n-1)&hash,通过整合最终得到公式为(n-1)&(n=Objects.hash(value))^(n>>>16))

异或

当我们的默认长度为16的时候,值为4.5得到的结果为13,值为4.6得到的结果为11

当我们的默认长度为16的时候,值为4.5得到的结果为2,值为4.6得到的结果为4

当我们的默认长度为16的时候,值为4.5得到的结果为15,值为4.6得到的结果为15

3 可以用%取余运算吗?

&(与)运算是二进制逻辑运算符,是计算机能直接执行的操作符,而%是Java处理整形浮点型所定义的操作符,底层也是这些逻辑运算符的实现,效率的差别可想而知,效率相差大概10倍。

HashMap的加载因子

加载因子为什么是0.75?

很多人说HashMap的DEFAULT_LOAD_FACTOR = 0.75f是因为这样做满足泊松分布,这就是典型的半知半解、误人子弟、以其昏昏使人昭昭。实际上设置默认DEFAULT_LOAD_FACTOR为0.75和泊松分布没有关系,而是我们一个随机的key计算hash之后要存放到HashMap的时候,这个存放进Map的位置是随机的,满足泊松分布。泊松分布是一种概率,也就是让我们放入map的位置随机,减少hash碰撞。

我们来看一下官方对这个加载因子的解释:

简单翻译一下:理想情况下,在随机hashCodes下,bin中节点的频率遵循Poisson分布(http://en.wikipedia.org/wiki/Poisson_distribution),默认调整大小阈值0.75的平均参数约为0.5,尽管由于调整粒度而差异很大。忽略方差,列表大小k的预期出现次数是(exp(-0.5)* pow(0.5,k)/ * factorial(k))。第一个值是:

  • 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

其他:少于一百万分之十

也就是说,我们单个Entry的链表长度为0,1的概率非常高,而链表长度很大,比8还要大的概率忽略不计了。

加载因子可以调整吗??

可以调整,hashmap运行用户输入一个加载因子

public HashMap(int initialCapacity, float loadFactor) {
}

加载因子为0.5或者1,会怎么样?能大于1吗

我们凭借逻辑思考,如果加载因子非常的小,比如0.5,那么我们是不是扩容的频率就会变高,但是hash碰撞的概率会低很多,相应的链表长度就普遍很低,那么我们的查询速度是不是快多了?但是内存消耗确实大了。

那么加载因子很大呢?我们想象一下,如果加载因子很大,我们是不是扩容的条件就变的更加苛刻了,hash碰撞的概率变高,每个链表长度都很长,查询速度变慢,但是由于我们不怎么扩容,内存是节省了不少,毕竟扩容一次就翻一倍。

那么加载因子大于1会怎么样,我们加载因子是10,初始容量是16,当桶数达到160时扩容,平均每个链表长度为10,链表并没有长度限制,所以,加载因子可以大于1,但是我们的HashMap如果查询速度取决于链表的长度,那么HashMap就失去了自身的优势,尽管JDK1.8引入了红黑树,但是这只是补救操作。

如果在实际开发中,内存非常充裕,可以将加载因子调小。如果内存非常吃紧,可以稍微调大一点。

HashMap的初始容量

为什么HashMap的初始容量是16?

我们知道扩容是个耗时的过程,有大量链表操作,16作为一个折中的值,即不会存入极少的内容就扩容,也不会在加入大量数据而扩容太多次。16扩容3次就达到128的长度。

其实还有一个很重要的地方,16是2的4次方,我们在看HashMap的源码时,可以看到初始容量的定义方式如下:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

为什么初始容量是2的多次方比较好?

这是我们计算插入位置的算法,n代表的就是容量。假设我们没有设置容量,也没扩容过,那么这个n就是16,n-1=15

if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);

演示计算过程

1101 0011 0010 1110 0110 0100 0010 1011 `原数`
    
0000 0000 0000 0000 0000 0000 0000 1111 `15的二进制`
    
0000 0000 0000 0000 0000 0000 0000 0011 `结果`

我们发现,插入位置实际上又由原数的最低的4位决定的,每个位置都有插入的可能。

初始容量如果不是2的次方呢?

HashMap确实提供我们手动设置初始容量

public HashMap(int initialCapacity) {

this(initialCapacity, DEFAULT_LOAD_FACTOR);

假如我们设置为17,我们看一下计算插入位置的过程,hash & 16

1101 0011 0010 1110 0110 0100 0010 1011 `原数`
    
0000 0000 0000 0000 0000 0000 0001 0000 `16的二进制`
    
0000 0000 0000 0000 0000 0000 0000 0000 `结果`

我们发现16的二进制只有一个为1其他都是0,其他数字与上它,不是16就是0。也就是说,这简直是Hash冲突的噩梦。

你将会得到一个Java双单向链表

再举例,初始长度是15 , hash & 14

1101 0011 0010 1110 0110 0100 0010 1011 `原数`
    
0000 0000 0000 0000 0000 0000 0000 1110 `14的二进制`
    
0000 0000 0000 0000 0000 0000 0000 0000 `结果`

结果发现,最后一位永远是0,那么0,2,4,6,8,10,12,14这几位就无法插入上了。

这也是2N的性质,2N-1,结果为全是1,插入的位置由原数决定,每个点都有机会插入。

HashMap对于你输入非2的次方的数,会怎么样?

当然HashMap不会让你们这么做的,实际上你给定的初始容量,HashMap还会判断是不是2的次幂,如果不是,则给出一个大于给定容量的最小2的次幂的值作为新的容量。

public HashMap(int initialCapacity, float loadFactor) {
 ...
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

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;
    }

这也验证了一个重要的编程思想:永远要把客户当成傻子。

HashMap树化

为什么要进行树化?

我们看一下官方的描述

简单翻译一下:

由于TreeNodes的大小大约是常规节点的两倍,因此我们仅在容器包含足够的节点以保证使用时才使用它们(参见 TREEIFY_THRESHOLD 值)。当它们变得太小(由于移除或调整大小)时,它们会被转换回普通的bin。理想情况下,在随机哈希代码下,bin中的节点频率遵循泊松分布,下面就是list size k 的频率表。

  • 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

其他:少于一百万分之十

为什么链表长度为8的概率如此之低,还要去树化?

这里科普一个东西:Hash碰撞攻击,就是说,有人恶意的向服务器发送一些hash值计算出来一样,但是又不相同的数据,用我们的Java语言来理解就是:

a.hash()==b.hash() , a.equals(b)==false

这样,我们的HashMap会把这些数据全部加入到同一个位置,即一条链表上,倘若我们的链表长度达到了100,那么可想而知,性能急剧下降。这时我们的红黑树可以缓解这种性能急剧下降的问题,但是最好的解决方案是去拦截这些恶意的攻击。

为什么不选择6进行树化?

我们看一下TreeNode的源码

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
       ........
}

这是node节点,继承了Map.Entry

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;

对比发现:TreeNode每一个数都是一个TreeNode,正如官方所说的,TreeNode大概是普通的2倍,所以我们转换成树结构时会加大内存开销的。

我们发现在加载因子没有修改的前提下,单一条链表的长度大于等于8的概率是非常的低的,所以我们选择8才树化,树化的频率还是很低的,HashMap整体性能受到影响还是比较小的。

如果选择6进行树化,虽然概率也很低,但是也比8大了一千倍,遇到组合Hash攻击时(让你每个链表都进行树化),也会遇到性能下降的问题。

为什么树化之后,当长度减至6的时候,还要进行反树化?

  • 长度为6时我们查询次数是6,而红黑树是3次,但是消耗了一倍的内存空间,所以我们认为,转换回链表是有必要的。

  • 维护一颗红黑树比维护一个链表要复杂,红黑树有一些左旋右旋等操作来维护顺序,而链表只有一个插入操作,不考虑顺序,所以链表的内存开销和耗时在数据少的情况下是更优的选择。

为什么在JDK1.8中进行对HashMap优化的时候,把链表转化为红黑树的阈值是8,而不是7或者不是20呢?

如果选择6和8(如果链表小于等于6树还原转为链表,大于等于8转为树),中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

还有一点重要的就是由于treenodes的大小大约是常规节点的两倍,因此我们仅在容器包含足够的节点以保证使用时才使用它们,当它们变得太小(由于移除或调整大小)时,它们会被转换回普通的node节点,容器中节点分布在hash桶中的频率遵循泊松分布,桶的长度超过8的概率非常非常小。所以作者应该是根据概率统计而选择了8作为阀值

总结

本篇文章主要讲了一下HashMap那几个为什么?希望能对你有帮助!

如果实际面试的时候,你能提出一些对HashMap的优化的一些思路,也是加分项!比如你说我觉得hash算法可以优化,hash散列种子可以优化,等等。

=============源码在这儿=======================

final HashMap.Node<K,V>[] resize() {
    HashMap.Node<K,V>[] oldTab = table; //table为当前已存的数据 包含分配的空的数据
    int oldCap = (oldTab == null) ? 0 : oldTab.length; //当前已存的数据的容量 包含空数据
    int oldThr = threshold; //下一次的容量
    int newCap , newThr = 0; //新的容量 新的下一次容量
    if (oldCap > 0) { //当前数据的容量超过0
        if (oldCap >= MAXIMUM_CAPACITY) { //是否大于最大容量 1 << 30 左移30位 计算出来 1073741824
            threshold = Integer.MAX_VALUE; //当前容量扩容为int最大值
            return oldTab; //然后返回当前数据
        }
            //如果不大于hashmap设定的最大容量  左偏移1位 x2 也就是新容量就等于分配容量x2
            // 新容量就等于当前分配容量x2 < hashmap的最大容量 && 当前分配的容量>=默认的初始容量16
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
            //老的容量x2 就得到一个新的容量
            newThr = oldThr << 1; // double threshold
    }//不满足当前容量>0 看看下一次分配的容量是否大于0 如果大于0 下一次分配的容量就赋值给当前的新容量
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else { //如果下一次要分配的容量不大于0 就将默认的容量赋值给新容量              // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); //新的容量 = 负载因子0.75*默认容量16
    }
    //新的下一次的容量如果等于0
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor; //当前新的容量*负载因子0.75
        //新的下一次容量
        //如果新的容量>hashmap最大容量&&新的容量*负载因子小于最大容量限制。
        //新的下一次容量就不变 如果不满足以上条件 则赋值为int最大
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr; //将新的下一次容量赋值给下一次容量变量值
    @SuppressWarnings({"rawtypes","unchecked"})
        HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap]; //重新分配大小为当前新的容量node数组
    table = newTab; //将这个新的容器赋值给原table容器
    if (oldTab != null) { //如果oldTab不为空 也就是原本咱们容器就没得数据
        for (int j = 0; j < oldCap; ++j) { //循环一个node 因为是一个数组
            HashMap.Node<K,V> e; //创建个空的node
            if ((e = oldTab[j]) != null) { //如果当前当前下标存在数据 并且赋值给e
                oldTab[j] = null; //将当前下标的数据清空
                if (e.next == null)  //获取e下的链表next是否有数据 但是e目前来说是有数据的 就是没有next指向
                    //没有数据 就将当前e的 hash与容量-1 作为下标进行e数据赋值
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof HashMap.TreeNode) //如果e是树节点类型 就通过split重排节点
                    ((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    HashMap.Node<K,V> loHead = null, loTail = null;
                    HashMap.Node<K,V> hiHead = null, hiTail = null;
                    HashMap.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;
                    }
                }
            }
        }
    }
    return newTab;
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        //声明了一个局部变量 tab,局部变量 Node 类型的数据 p,int 类型 n,i
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //首先将当前 hashmap 中的 table(哈希表)赋值给当前的局部变量 tab,然后判断tab 是不是空或者长度是不是 0,实际上就是判断当前 hashmap 中的哈希表是不是空或者长度等于 0
        if ((tab = table) == null || (n = tab.length) == 0)
            //如果是空的或者长度等于0,代表现在还没哈希表,所以需要创建新的哈希表,默认就是创建了一个长度为 16 的哈希表
            n = (tab = resize()).length;
        //将当前哈希表中与要插入的数据位置对应的数据取出来,(n - 1) & hash])就是找当前要插入的数据应该在哈希表中的位置,如果没找到,代表哈希表中当前的位置是空的,否则就代表找到数据了, 并赋值给变量 p
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);//创建一个新的数据,这个数据没有下一条,并将数据放到当前这个位置
        else {//代表要插入的数据所在的位置是有内容的
            //声明了一个节点 e, 一个 key k
            Node<K,V> e; K k;
            if (p.hash == hash && //如果当前位置上的那个数据的 hash 和我们要插入的 hash 是一样,代表没有放错位置
                    //如果当前这个数据的 key 和我们要放的 key 是一样的,实际操作应该是就替换值
                    ((k = p.key) == key || (key != null && key.equals(k))))
                //将当前的节点赋值给局部变量 e
                e = p;
            else if (p instanceof TreeNode)//如果当前节点的 key 和要插入的 key 不一样,然后要判断当前节点是不是一个红黑色类型的节点
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);//如果是就创建一个新的树节点,并把数据放进去
            else {
                //如果不是树节点,代表当前是一个链表,那么就遍历链表
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {//如果当前节点的下一个是空的,就代表没有后面的数据了
                        p.next = newNode(hash, key, value, null);//创建一个新的节点数据并放到当前遍历的节点的后面
                        if (binCount >= TREEIFY_THRESHOLD - 1) // 重新计算当前链表的长度是不是超出了限制 TREEIFY_THRESHOLD = 8
                            treeifyBin(tab, hash);//超出了之后就将当前链表转换为树,注意转换树的时候,如果当前数组的长度小于MIN_TREEIFY_CAPACITY(默认 64),会触发扩容,我个人感觉可能是因为觉得一个节点下面的数据都超过8 了,说明 hash寻址重复的厉害(比如数组长度为 16 ,hash 值刚好是 0或者 16 的倍数,导致都去同一个位置),需要重新扩容重新 hash
                        break;
                    }
                    //如果当前遍历到的数据和要插入的数据的 key 是一样,和上面之前的一样,赋值给变量 e,下面替换内容
                    if (e.hash == hash &&
                            ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { //如果当前的节点不等于空,
                V oldValue = e.value;//将当前节点的值赋值给 oldvalue
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value; //将当前要插入的 value 替换当前的节点里面值
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;//增加长度
        if (++size > threshold)
            resize();//如果当前的 hash表的长度已经超过了当前 hash 需要扩容的长度, 重新扩容,条件是 haspmap 中存放的数据超过了临界值(经过测试),而不是数组中被使用的下标
        afterNodeInsertion(evict);
        return null;
    }

猜你喜欢

转载自blog.csdn.net/qq_31671187/article/details/128632842
今日推荐