一文浅析---从面试题连环炮的角度梳理HashMap

一文浅析—从面试题连环炮的角度梳理HashMap

你了解HashMap的底层数据结构吗?

对于HashMap的底层数据结构在Java7Java8中的实现是不同的,在Java7中是采用数组+链表的数据结构进行实现,而在Java8中是采用数组+链表+红黑树的数据结构实现的。

说时迟那时快,刚话说完,从兜里拿出笔和纸,啪地一声放在桌子上画了起来,许久之后,出现了两幅jdk7和jdk8的HashMap的内部结构图:
在这里插入图片描述

在这里插入图片描述

那你清楚HashMap的数据插入原理吗?

我们来看jdk1.8的put方法

在这里插入图片描述

①.判断键值对数组table[i]是否为空或为null,为空就执行resize()进行扩容;

②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;

③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;

④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;

⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;

⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容

  public V put(K key, V value) {
      // 对key的hashCode()做hash
      return putVal(hash(key), key, value, false, true);
  }
  
  final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                 boolean evict) {
      Node<K,V>[] tab; Node<K,V> p; int n, i;
      // 步骤①:tab为空则创建
     if ((tab = table) == null || (n = tab.length) == 0)
         n = (tab = resize()).length;
     // 步骤②:计算index,并对null做处理 
     if ((p = tab[i = (n - 1) & hash]) == null) 
         tab[i] = newNode(hash, key, value, null);
     else {
         Node<K,V> e; K k;
         // 步骤③:节点key存在,直接覆盖value
         if (p.hash == hash &&
             ((k = p.key) == key || (key != null && key.equals(k))))
             e = p;
         // 步骤④:判断该链为红黑树
         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) {
                     p.next = newNode(hash, key,value,null);
                        //链表长度大于8转换为红黑树进行处理
                     if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st  
                         treeifyBin(tab, hash);
                     break;
                 }
                    // key已经存在直接覆盖value
                 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;
         }
     }

     ++modCount;
     // 步骤⑥:超过最大容量 就扩容
     if (++size > threshold)
         resize();
     afterNodeInsertion(evict);
     return null;
 }

这里我们对value的覆盖做一个详细的解释 看他是怎么替换value的 我们截取一部分代码

作者学这里的时候 网上的博客质量参差不齐 希望我这里能给大家说清楚

 // 步骤②:计算index,并对null做处理 
     if ((p = tab[i = (n - 1) & hash]) == null) 
         tab[i] = newNode(hash, key, value, null);

在步骤2这里 我们计算了index索引 并且把位置i上的Node节点赋值给了p 这里再次强调 此时的p就是位置i上的Node节点 我们只有看懂了这个p代表什么 才能继续往下

 // 步骤③:节点key存在,直接覆盖value
         if (p.hash == hash &&
             ((k = p.key) == key || (key != null && key.equals(k))))
             e = p;

在步骤3这里 我们发现要放得元素的hash值和已有的这个节点p的hash值是一样的 我们进入了这个if里

将p的值赋给了e 这里要放的和已有的hash值是一样的情况 我们暂时用e进行了保存 接下来的else和else if也就不会进入了 代码直接到了这里

  if (e != null){ // existing mapping for key
             V oldValue = e.value;
             if (!onlyIfAbsent || oldValue == null)
                 e.value = value;
             afterNodeAccess(e);
             return oldValue;
         }

这里就是做了一个value的覆盖 将oldValue返回 我们自己在调用HashMap的put方法是也会发现 put方法的返回值就是oldValue

这里 在明确一个事情!!!

 // 步骤③:节点key存在,直接覆盖value
         if (p.hash == hash &&
             ((k = p.key) == key || (key != null && key.equals(k))))
             e = p;

没有进入这个if的情况有2种 其一是两者的hash值根本不等 其二是hash值相等 但是equals方法不等

这里还是要再次强调!!!

你知道jdk1.8下什么情况下会扩容吗?

这里有两种情况会发生扩容

第一 超过阈值 就是我们熟悉的超过阈值12时才会扩容

第二 链表转为红黑树且数组元素小于64时 在jdk1.8中,默认长度为16情况下,要么元素一直放在同一下标,链表转为红黑树且数组元素小于64时就会扩容

这第二种情况我们相对来说 不是很熟悉 我们看一下源码

final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
     //链表转为红黑树时,若此时数组长度小于64,扩容数组
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
           ...//省略
        }
    }

也就是说我们在去完善一下 当数组的某一个索引位置上的元素以链表的形式存在>8 并且数组长度大于64时 此时索引位置上的所有数据改成红黑树存储 如果数组长度小于64 他是在扩容数组啊

这里可得千万注意 不要跟作者一样 作者当年以为当元素>8就转红黑树了 这里看还是太年轻了 留下了不学无术的泪水

你来聊一下jdk1.7为什么会发生死循环吧

假设:有线程A和线程B,并发访问HashMap中的数据。假设HashMap的长度为2(这里只是为了讲解方便假设长度为2),链表的结构图如下所示:

在这里插入图片描述

4和8都位于同一条链表上,其中的threshold为1,现在线程A和线程B都要进行put操作,首先线程A进行插入值。

此时,线程A执行到transfer函数中(transfer函数是resize扩容方法中调用的另一个方法),当执行(1)位置的时候,如下所示:

/**
* Transfers all entries from current table to newTable.
*/
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
    while(null != e) {
        Entry<K,V> next = e.next; ---------------------(1)
        if (rehash) {
            e.hash = null == e.key ? 0 : hash(e.key);
        }
        int i = indexFor(e.hash, newCapacity);
        e.next = newTable[i];
        newTable[i] = e;
        e = next;
    } // while
    }
}

此时线程A挂起,在此时在线程A的栈中就会存在如下值:

e = 4
next = 8

此时线程B执行put的操作,并发现在进行put操作的时候需要扩容,当线程B执行 transfer函数中的while循环,即会把原来的table变成新一table(线程B自己的栈中),再写入到内存中。

执行的过程如下图所示
在这里插入图片描述

特别注意一个关键点

我们看这行代码

 e.next = newTable[i];

我们想说明一个这样的一个问题 线程B对8这个节点进行操作的时候 e的next指向了新table

也就是说此时线程B

8.next=4;
也就是上图中8指向了4

我们在往下继续

此时线程A有获取到cpu的执行时间,接着执行(但是线程A中的数据仍是旧表数据),即从transfer代码(1)处接着执行,当前线程A的 e = 4, next = 8, 上面已经描述,执行的的过程若下图所示:
在这里插入图片描述

当操作完成,执行查找时,会陷入死循环!

这里我们插一句题外话

为啥线程A的数据仍然是旧表的数据啊?这块应该跟我们的JVM有关系

在Java虚拟机栈当中 每个线程在创建的时候都会创建一个虚拟机栈 栈中保存了我们的基本数据类型和引用类型还有局部变量 上述e和next就作为局部变量保在了每一个线程所私有的栈当中 所以线程A的数据仍然是旧值(只有堆和方法区的数据会被所有线程所共享)

那jdk1.8换了尾插法就是线程安全的了吗?

此时 一口老血吐了出来 并发出了(老子不干了)这样的小声哔哔

回答是 :不是

即是是解决了死循环的问题 那HashMap在1.8依然是线程不安全的 我们看下他的原因

如果线程A和线程B同时进行put操作,刚好这两条不同的数据hash值一样,并且该位置数据为null,所以这线程A、B都会开始操作。假设一种情况,线程A进入后还未进行数据插入时挂起,而线程B正常执行,从而正常插入数据,然后线程A获取CPU时间片,此时线程A不用再进行hash判断了,问题出现:线程A会把线程B插入的数据给覆盖,发生线程不安全。

总结一下

HashMap是线程不安全的,其主要体现:

#1.在jdk1.7中,在多线程环境下,扩容时会造成环形链表。

#2.在jdk1.8中,在多线程环境下,会发生数据覆盖的情况。

你来说一下怎么计算元素的索引位置吧(Hash函数怎么计算啊)

Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算

在这里插入图片描述

高位运算:

给大家科普 这里用到了异或 就是^东西 这个东西的规则就是 两个数不一样就是1 一样就是0

在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。

取模运算

(这里并不是用了取模运算 而是用了&来优化他的速度)

这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。


关于HashMap以上只可能是冰山一角 由HashMap可以牵扯的东西是在太多了 既可以涉及到底层的数据结构 为啥加入了红黑树(你要了解红黑树 就得先懂二叉树 排序树 平衡树 各种树…)红黑树相比较以前会有那些优化 又可以聊到线程安全问题 这时候syn和lock这些就要来了 一个HashMap引出了太多的问题需要我们去解决 恰恰给了面试官可乘之机 (面试官为就可以有这么令人发指的深度和广度)…

其实 最近越来越感觉学习并不是最难的东西 学习是一个面对困难和挑战的过程

我们要学习的应该不是学习知识本身 而是要在学习中培养解决困难的能力 培养坚持的品格

用这些品质和心性去乐观的面对生活 解决随着年龄增加 残酷真实的社会所带给我们的挑战 去学着尽量热爱生活!!

以上就是我一个人的瞎话 大家看了也就散了

关于他的好兄弟ConcurrentHashMap 应该会有一篇的 看看我的好兄弟pdd-jason能不能先写出来

他写完我给大家在正式的 由浅入深的copy一遍

溜了!


发布了39 篇原创文章 · 获赞 19 · 访问量 1444

猜你喜欢

转载自blog.csdn.net/weixin_44222272/article/details/105689425
今日推荐