• HashMap知识总结(jdk1.8)


    HashMap数据结构

     HashMap是以数组+链表的形式进行存储数据。

     数组的优缺点:通过下标索引方便查找,但是插入或删除慢。

     链表的优缺点:查找一个元素需要以遍历链表的方式查找,但是插入或删除快。

    HashMap内部维护了一个Node数组:

     其中Node数据结构如下:

     

    HashMap构造方法

    HashMap数组默认大小是16,负载因子是0.75。

    当创建HashMap集合对象的时候,在jdk8以前,构造方法中会创建一个长度为16的Entry[] table。在jdk8以后不是在HashMap的构造方法创建数组了,是在第一次调用put法时创建数组--Node[] table。

    HashMap,tableSizeFor(Object key)

     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的 capacity 都是2的幂,因此这个方法用于找到大于等于initialCapacity的最小的2的幂(如果initialCapacity就是2的幂,则返回的还是这个数)。

    HashMap中,数组的最大长度是1 << 30,所以采用上面的算法,一定返回一个从cap中有效位数开始,后面全部变成1的数(比如:00010101会变成00011111)。

    注意:第一步用cap - 1,是为了防止cap已经是2的幂,如果不执行这个减1操作,则执行完后面的无符号右移操作之后,返回的capacity将是这个cap的2倍。

    HashMap,hash(Object key)   --- 计算key的hash值

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

    1、如果 K 是空,则 hash 值为0,这也就是为啥 HashMap 的 K 可以是空。

    2、不同的 K 的hashCode值可能相同。

    3、在计算数组下标时,计算公式是 (tab.length - 1) & hash) 。如果数组长度很小(比如是16),那么length - 1即为 00001111,这样的值和 hashCode 直接进行按位与运算,实际只用到了 hashCode 的后4位。如果当 hashCode 的高位变化很大,低位变化很小,这样就很容易发生 K 碰撞。这里将 hashCode 值右移16位,刚好将该二进制数半切开,并且使用位异或运算,相当于使用 hashCode 的高16位打乱 hashCode 的低16位。这样就降低了 K 碰撞的几率(称为扰动函数)。

    例如:

    两个 hashCode 值,后四位相同:1111 1111 1111 1111 1111 0000 1110 1010  、111 0101 1111 1001 1111 0000 1110 1010

    计算数组下标 (tab.length - 1) & hash) :

     a、如果高位不参与运算,直接得到:0000  0000 0000 0000 0000 0000 1010  、0000  0000 0000 0000 0000 0000 1010       ==>发生碰撞

     b、如果高位参与运算:

           先进行高低位 ^ 运算,得到:1111 1111 1111 1111 0000 1111 0001 0101、1111 0101 1111 1001 0000 1010 0001 0011

           再进行 & 运算,得到:0000 0000 0000 0000 0000 0000 0000 0101、0000 0000 0000 0000 0000 0000 0000 0011      ==>未发生碰撞

    HashMap,put(K key, V value)   --- 添加元素

      public V put(K key, V value) {
          return putVal(hash(key), key, value, false, true);
      }

    执行步骤:

    1、调用 hash(K) 方法计算K的 hash 值,然后结合数组长度 (tab.length - 1) & hash 得到数组下标。

    2、如果 K 的 hash 值在HashMap中不存在,则直接将数据插入到数组中。若存在,则发生碰撞。

    3、如果 K 的 hash 值在HashMap中存在,则比较两者 K 的 equals。

         a、equals 返回 true,则将该键替换成新值value。

        b、equals 返回 false,则新建一个Node节点,并插入到链表的尾部(尾插法)或者红黑树中。

        其中,上面的b,是采用遍历当前链表。如果链表长度大于8则判断数组长度是否大于64,大于64则转为红黑树,否则扩容

    4、判断数组中实际节点个数是否大于threshold,如果大于则扩容。

    HashMap,resize()   --- 扩容

    当HashMap中元素越来越多时,碰撞的几率也就越来越高(数组长度是固定的)。所以为了提高效率(增删改查),就要对HashMap中的数组进行扩容。

        final Node<K,V>[] resize() {
            Node<K,V>[] oldTab = table;
            int oldCap = (oldTab == null) ? 0 : oldTab.length;
            int oldThr = threshold;
            int newCap, newThr = 0;
            if (oldCap > 0) {
                if (oldCap >= MAXIMUM_CAPACITY) {
                    threshold = Integer.MAX_VALUE;
                    return oldTab;
                }
                else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                         oldCap >= DEFAULT_INITIAL_CAPACITY)
                    newThr = oldThr << 1; // double threshold
            }
      ...

    1、扩容时,会将 table 长度变为原来的两倍(注意是 table 长度,而不是 threshlod )。

    2、扩容时,会重新计算旧数组中节点的位置。节点在新数组中的位置只有两种情况:

         a、原下标

         b、原下标 + 旧数组长度

    因此,在HashMap扩容时,是不需要重新计算 hash 值的,直接将原hash & 新数组长度即可。

    HashMap,get(Object key)

    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }

    执行步骤:

    1、计算目标节点的hash值,并通过 (tab.length - 1) & hash 得到数组下标(这一步和 put 方法一样)。

    2、判断下标,得到首节点。判断首节点是否为空,为空则直接返回空。

    3、判断首节点 K 与目标节点的的 hash 是否相同,equals 方法是否相同,相同则直接返回首节点。

    4、上一步如果不相同,判断首节点的下个节点是否为空,为空则直接返回空。

    5、判断首节点是否为红黑树:

        a、如果是,则通过红黑树取值并返回

        b、如果不是,则遍历链表,直到 hash 值和 equals 相等则跳出返回

    HashMap数组长度为啥要是2的幂?

    在 put 元素时,需要计算 K 在数组中的下标,计算公式是 (tab.length - 1) & hash。只有数组长度是2的幂,减1后得到的二进制才全部是1。

    例如16,减1后就是15,二进制为00001111),这样进行 & 运算时,二进制的每一位都是有效的(如果后面的4位中有个0,进行 & 运算后就还是0,这一位对应的数组下标就永远不会有元素,这样就增加了碰撞的几率)。

    HashMap发生扩容的时机有哪些?

    a、put元素,数组为空时(第一次put时)

    b、put元素,数组中实际节点个数大于 threshold 时

    c、put元素,发现链表长度超过8,且数组长度小于64时

    HashMap初始化时,如何指定初始化大小?

    a、如果无法确定元素个数,那么使用默认值(16)即可。

    b、如果可以确定元素个数,initialCapacity = (需要存储的元素个数 / 负载因子) + 1。注意负载因子(即loader factor)默认为0.75。

    HashMap会发生死循环?

    多线程环境下:

    jdk1.7在进行扩容操作时,会重新定位每个节点的下标,并采用头插法将节点迁移到新数组中。头插法会将链表的顺序翻转,有可能会出现两个节点成为循环链表(双方节点的next都指向对方),最终造成死循环。

    jdk1.8是直接在 resize 函数中完成了数据迁移,并且jdk1.8在进行元素插入时使用的是尾插法,所以不会有死循环的问题。

    HashMap为啥线程不安全?

    多线程环境下:

    在进行 put 操作时,会发生数据覆盖的情况。主要体现在两点:

    a、假设两个线程A、B都在进行put操作,并且 hash 函数计算出的插入下标是相同的,当线程A执行完下面代码后由于时间片耗尽导致被挂起,而线程B得到时间片后在该下标处插入了元素,完成了正常的插入,然后线程A获得时间片,由于之前已经进行了 hash 碰撞的判断,所有此时不会再进行判断,而是直接进行插入,这就导致了线程B插入的数据被线程A覆盖了,从而线程不安全。

     b、插入数据完成后,++size 非原子操作,可能后面的线程会覆盖前一个线程的操作。

     

    ConcurrentHashMap如何保证线程安全?

    ConcurrentHashMap成员变量使用 volatile 修饰,避免指令重排序,同时保证内存可见性。另外使用 CAS (引入Unsafe对象)和 synchronized 进行赋值操作,多线程操作只会锁住当前操作索引的节点(即对应链表)。

    HashMap的链表转为红黑色的阈值是8,红黑色转链表阈值是6,为什么这么设计?

    通过计算,发生 hash 碰撞8次的几率为0.00000006,所以8基本够用了(转为红黑色的概率很低)。

    至于为什么转回来是6,因为如果 hash 碰撞次数在8附近徘徊,会一直发生链表和红黑色的转化,为了预防这种情况发生。

     

    知识改变世界
  • 相关阅读:
    mysql 存中文乱码
    解决办法:Message: 对实体 "useUnicode" 的引用必须以 ';' 分隔符结尾
    windows无法安装到这个磁盘选中的磁盘具有MBR分区表解决办法
    Windows快速添加开机启动项
    使用markdown做本地笔记软件/编辑器
    Apache负载均衡配置
    setuptools包的使用
    Git clone报错Someone Could Be Eavesdropping On You Right Now (Man-In-The-Middle Attack)!
    Code Review 的项目
    Pytorch 之Torch
  • 原文地址:https://www.cnblogs.com/xuwenjin/p/14326249.html
Copyright © 2020-2023  润新知