zoukankan      html  css  js  c++  java
  • 并发-Hashmap 1.7和1.8有哪些区别

    参考:

    https://blog.csdn.net/qq_36520235/article/details/82417949

    https://www.cnblogs.com/jing99/p/11319175.html

    https://blog.csdn.net/thqtzq/article/details/90485663 

    Hashmap的结构,1.7和1.8有哪些区别

    (一) 真实面试题之:Hashmap的结构,1.7和1.8有哪些区别

    不同点:

    (1)JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法,那么他们为什么要这样做呢?因为JDK1.7是用单链表进行的纵向延伸,当采用头插法时会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。

    (2)扩容后数据存储位置的计算方式也不一样:1. 在JDK1.7的时候是直接用hash值和需要扩容的二进制数进行&(这里就是为什么扩容的时候为啥一定必须是2的多少次幂的原因所在,因为如果只有2的n次幂的情况时最后一位二进制数才一定是1,这样能最大程度减少hash碰撞)(hash值 & length-1)

    2、而在JDK1.8的时候直接用了JDK1.7的时候计算的规律,也就是扩容前的原始位置+扩容的大小值=JDK1.8的计算方式,而不再是JDK1.7的那种异或的方法。但是这种方式就相当于只需要判断Hash值的新增参与运算的位是0还是1就直接迅速计算出了扩容后的储存方式。
    这里写图片描述
    在计算hash值的时候,JDK1.7用了9次扰动处理=4次位运算+5次异或,而JDK1.8只用了2次扰动处理=1次位运算+1次异或。

    扩容流程对比图:
    这里写图片描述
    (3)JDK1.7的时候使用的是数组+ 单链表的数据结构。但是在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率)

    这里写图片描述

    这里在重新进行补充两个问题:(2019-09-03)

    (1)为什么在JDK1.7的时候是先进行扩容后进行插入,而在JDK1.8的时候则是先插入后进行扩容的呢?

    //其实就是当这个Map中实际插入的键值对的值的大小如果大于这个默认的阈值的时候(初始是16*0.75=12)的时候才会触发扩容,
    //这个是在JDK1.8中的先插入后扩容
    if (++size > threshold)
                resize();


    • 其实这个问题也是JDK8对HashMap中,主要是因为对链表转为红黑树进行的优化,因为你插入这个节点的时候有可能是普通链表节点,也有可能是红黑树节点,但是为什么1.8之后HashMap变为先插入后扩容的原因,我也有点不是很理解?欢迎来讨论这个问题?
    • 但是在JDK1.7中的话,是先进行扩容后进行插入的,就是当你发现你插入的桶是不是为空,如果不为空说明存在值就发生了hash冲突,那么就必须得扩容,但是如果不发生Hash冲突的话,说明当前桶是空的(后面并没有挂有链表),那就等到下一次发生Hash冲突的时候在进行扩容,但是当如果以后都没有发生hash冲突产生,那么就不会进行扩容了,减少了一次无用扩容,也减少了内存的使用
    void addEntry(int hash, K key, V value, int bucketIndex) {
    		//这里当钱数组如果大于等于12(假如)阈值的话,并且当前的数组的Entry数组还不能为空的时候就扩容
          if ((size >= threshold) && (null != table[bucketIndex])) {
           //扩容数组,比较耗时
              resize(2 * table.length);
              hash = (null != key) ? hash(key) : 0;
              bucketIndex = indexFor(hash, table.length);
          }
    
          createEntry(hash, key, value, bucketIndex);
      }
    
     void createEntry(int hash, K key, V value, int bucketIndex) {
          Entry<K,V> e = table[bucketIndex];
        //把新加的放在原先在的前面,原先的是e,现在的是new,next指向e
          table[bucketIndex] = new Entry<>(hash, key, value, e);//假设现在是new
          size++;
      }


    (2)为什么在JDK1.8中进行对HashMap优化的时候,把链表转化为红黑树的阈值是8,而不是7或者不是20呢(面试蘑菇街问过)?

    • 如果选择6和8(如果链表小于等于6树还原转为链表,大于等于8转为树),中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
    • 还有一点重要的就是由于treenodes的大小大约是常规节点的两倍,因此我们仅在容器包含足够的节点以保证使用时才使用它们,当它们变得太小(由于移除或调整大小)时,它们会被转换回普通的node节点,容器中节点分布在hash桶中的频率遵循泊松分布,桶的长度超过8的概率非常非常小。所以作者应该是根据概率统计而选择了8作为阀值
    	//Java中解释的原因
       * 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
         * 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


    (二)哈希表如何解决Hash冲突?

    这里写图片描述

    (三)为什么HashMap具备下述特点:键-值(key-value)都允许为空、线程不安全、不保证有序、存储位置随时间变化

    这里写图片描述

    (四)为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键

    这里写图片描述

    (五)HashMap 中的 key若 Object类型, 则需实现哪些方法?

    这里写图片描述

    参考文章;https://www.jianshu.com/p/8324a34577a0?utm_source=oschina-app

    HashMap闭环(死循环)的详细原因(转)

    为何出现死循环简要说明

      HashMap是非线程安全的,在并发场景中如果不保持足够的同步,就有可能在执行HashMap.get时进入死循环,将CPU的消耗到100%。

      HashMap采用链表解决Hash冲突。因为是链表结构,那么就很容易形成闭合的链路,这样在循环的时候只要有线程对这个HashMap进行get操作就会产生死循环,

      单线程情况下,只有一个线程对HashMap的数据结构进行操作,是不可能产生闭合的回路的。

      只有在多线程并发的情况下才会出现这种情况,那就是在put操作的时候,如果size>initialCapacity*loadFactor,hash表进行扩容,那么这时候HashMap就会进行rehash操作,随之HashMap的结构就会很大的变化。很有可能就是在两个线程在这个时候同时触发了rehash操作,产生了闭合的回路。

      所以多线程并发的情况下推荐使用currentHashMap。

    多线程下[HashMap]的问题:

      1、多线程put操作后,get操作导致死循环。
      2、多线程put非NULL元素后,get操作得到NULL值。
      3、多线程put操作,导致元素丢失。

    HashMap闭环的详细原因

      Java的HashMap是非线程安全的,所以在并发下必然出现问题,以下做详细的解释:

    1、问题的症状

      从前我们的Java代码因为一些原因使用了HashMap这个东西,但是当时的程序是单线程的,一切都没有问题。因为考虑到程序性能,所以需要变成多线程的,于是,变成多线程后到了线上,发现程序经常占了100%的CPU,查看堆栈,你会发现程序都Hang在了HashMap.get()这个方法上了,重启程序后问题消失。但是过段时间又会来。而且,这个问题在测试环境里可能很难重现。

      我们简单的看一下我们自己的代码,我们就知道HashMap被多个线程操作。而Java的文档说HashMap是非线程安全的,应该用ConcurrentHashMap。

    2、Hash表数据结构

      简单地说一下HashMap这个经典的数据结构。

      HashMap通常会用一个指针数组(假设为table[])来做分散所有的key,当一个key被加入时,会通过Hash算法通过key算出这个数组的下标i,然后就把这个<key, value>插到table[i]中,如果有两个不同的key被算在了同一个i,那么就叫冲突,又叫碰撞,这样会在table[i]上形成一个链表。

      我们知道,如果table[]的尺寸很小,比如只有2个,如果要放进10个keys的话,那么碰撞非常频繁,于是一个O(1)的查找算法,就变成了链表遍历,性能变成了O(n),这是Hash表的缺陷(可参看《Hash Collision DoS 问题》)。

      所以,Hash表的尺寸和容量非常的重要。一般来说,Hash表这个容器当有数据要插入时,都会检查容量有没有超过设定的thredhold,如果超过,需要增大Hash表的尺寸,这样一来,整个Hash表里的无素都需要被重算一遍。这叫rehash,这个成本相当的大。

    3、HashMap的rehash源代码

      下面,我们来看一下Java的HashMap的源代码。

      Put一个Key,Value对到Hash表中:

    复制代码
    public V put(K key, V value)
    {
        ......
        //算Hash值
        int hash = hash(key.hashCode());
        int i = indexFor(hash, table.length);
        //如果该key已被插入,则替换掉旧的value (链接操作)
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        //该key不存在,需要增加一个结点
        addEntry(hash, key, value, i);
        return null;
    }
    复制代码

      检查容量是否超标

    复制代码
    void addEntry(int hash, K key, V value, int bucketIndex)
    {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
        //查看当前的size是否超过了我们设定的阈值threshold,如果超过,需要resize
        if (size++ >= threshold)
            resize(2 * table.length);
    }
    复制代码

      新建一个更大尺寸的hash表,然后把数据从老的Hash表中迁移到新的Hash表中。

    复制代码
    void resize(int newCapacity)
    {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        ......
        //创建一个新的Hash Table
        Entry[] newTable = new Entry[newCapacity];
        //将Old Hash Table上的数据迁移到New Hash Table上
        transfer(newTable);
        table = newTable;
        threshold = (int)(newCapacity * loadFactor);
    }
    复制代码

      迁移的源代码,注意高亮处:

    复制代码
    void transfer(Entry[] newTable)
    {
        Entry[] src = table;
        int newCapacity = newTable.length;
        //下面这段代码的意思是:
        //  从OldTable里摘一个元素出来,然后放到NewTable中
        for (int j = 0; j < src.length; j++) {
            Entry<K,V> e = src[j];
            if (e != null) {
                src[j] = null;
                do {
                    Entry<K,V> next = e.next;
                    int i = indexFor(e.hash, newCapacity);
                    e.next = newTable[i];
                    newTable[i] = e;
                    e = next;
                } while (e != null);
            }
        }
    }
    复制代码

      好了,这个代码算是比较正常的。而且没有什么问题。

    4、正常的扩容的过程

      画了个图做了个演示。

    • 我假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。
    • 最上面的是old hash 表,其中的Hash表的size=2, 所以key = 3, 7, 5,在mod 2以后都冲突在table[1]这里了。
    • 接下来的三个步骤是Hash表 resize成4,然后所有的<key,value> 重新rehash的过程

          

    5、并发下的扩容

      1)假设我们有两个线程。我用红色和浅蓝色标注了一下。

      我们再回头看一下我们的 transfer代码中的这个细节:

    复制代码
    do { 
        Entry<K,V> next = e.next; // <--假设线程一执行到这里就被调度挂起了 
        int i = indexFor(e.hash, newCapacity); 
        e.next = newTable[i]; 
        newTable[i] = e; 
        e = next; 
    } while (e != null); 
    复制代码

      而我们的线程二执行完成了。于是我们有下面的这个样子。

          

      注意,因为Thread1的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。我们可以看到链表的顺序被反转后。

      2)线程一被调度回来执行。

    • 先是执行 newTalbe[i] = e;
    • 然后是e = next,导致了e指向了key(7),
    • 而下一次循环的next = e.next导致了next指向了key(3)

          

      3)一切安好。

      线程一接着工作。把key(7)摘下来,放到newTable[i]的第一个,然后把e和next往下移。

          

      4)环形链接出现。

      e.next = newTable[i] 导致  key(3).next 指向了 key(7)

      注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。

          

      于是,当我们的线程一调用到,HashTable.get(11)时,悲剧就出现了——Infinite Loop。

    jdk1.7HashMap链表头插法导致的死循环

    jdk1.7的HashMap的源码分析参考我之前整理的HashMap,之前也有整理头插法导致的死循环,这里再整理一下。参考连接
    扩容的核心源码如下:

    void transfer(Entry[] newTable, boolean rehash) {
            int newCapacity = newTable.length;
            for (Entry<K,V> e : table) {
                while(null != e) {
                	//1, 获取旧表的下一个元素
                    Entry<K,V> next = e.next;
                    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;
                }
            }
        }

    1, 假设旧表的初始长度为2,此时已经在下标为1的位置存放了两个元素,再put第三个元素的时候考虑需要扩容;

    在这里插入图片描述
    2, 此刻有两个线程A,B都进行put操作,线程A先扩容,执行到代码Entry<K,V> next = e.next;执行完这段代码,线程A挂起;
    然后线程B开始执行transfer函数中的while循环,会把原来的table变成一个table(线程B自己的栈中),再写入到内存中。
    在这里插入图片描述
    注意,因为线程A的e指向了key(3), next指向了key(7), 其在线程B rehash后,指向了线程B重组后的链表。我们可以看到链表的顺序被反转了。
    3, 线程A被唤醒,继续执行:

    • 先是执行newTable[i] = e ;
    • 然后是e = next , 导致了e指向了key(7);
    • 而下一次循环的next = e.next 导致next指向了key(3)
      如下图:
      在这里插入图片描述
      4, 当前循环:
      e.next = newTable[i];
      newTable[i] = e ;
      e = next;
      将key(7)摘下来采用头插法,放到newTable[i]的第一个元素中,下一个结点指向key(3)
      下一次循环:
      next = e.next; 此时e为key(3)
      此时next = null; 不会在往下循环了。
      在这里插入图片描述
      5,此时key(3)采用头插法又放到newTable[i]的位置,导致key(3)指向key(7),注意此时key(7).next已经指向了key(3),所以环形链表就出现了。如下图:
      在这里插入图片描述
      于是当我们的线程A调用get()方法时,如果下标映射到3处,则会出现死循环。

    总结:
    线程A先执行,执行完Entry<K,V> next = e.next;这行代码后挂起,然后线程B完整的执行完整个扩容流程,接着线程A唤醒,继续之前的往下执行,当while循环执行3次后会形成环形链表

    注:楼主在跳回A线程说得可能不太清楚,帮忙补充一下。就是A被挂起后,重新执行,这时候e还是原来的key(3),key(3)会hash到新的节点,此时最关键的是,key(7)的next指向Key(3),然后又去遍历了一次key(7),然后再遍历key(3),才形成了环链

  • 相关阅读:
    openstack学习-创建一台云主机(七)
    openstack学习-Horizon安装(八)
    openstack学习-nove控制节点部署(四)
    openstack学习-nove计算节点部署(五)
    openstack网络服务Neutron(六)
    openstack基础环境准备(一)
    openstack学习-KeyStone安装(二)
    openstack学习-glance安装(三)
    rabbitmq安装及基本操作(含集群配置)
    docker私有仓库
  • 原文地址:https://www.cnblogs.com/xuwc/p/14044016.html
Copyright © 2011-2022 走看看