Java 基础篇之Java HashMap

Java 基础篇之 Java HashMap

  • jdk1.7 和 jdk1.8 的HashMap的底层数据结构

    jdk1.7:数组、链表

    jdk1.8:数组、链表、红黑树

    数组的特点:查询的效率高,插入、删除的效率低

    链表的特点:查询的效率低,插入、删除的效率高

    HashMap 使用两者的结构,使得查询和插入、删除效率都很高,jdk1.8引入红黑树解决链表过长效率低的问题。

    🤔思考:为什么初始不使用红黑树?(空间和时间的考虑)

  • HashMap 的初始容量,加载因子

    初始容量:16

    加载因子:0.75

    加载因子:是衡量哈希表密集程度的一个参数,如果加载因子越大,说明装载的数据越多,出现hash冲突的可能性越大。反之加载因子越小,说明装载的数据越少,出现hash冲突的可能性越小(其他空余空间不装数据)。该值考虑内存使用率和hash冲突的平衡。

  • 链表转红黑树的阈值,红黑树转链表的阈值

    数组长度大于64且链表(桶)的长度大于等于8时,链表转成红黑树

    红黑树节点数小于6时,红黑树转成链表

    当数组长度未达到64,但是链表长度大于8之后,会考虑进行数组扩容,而不是直接转红黑树

    原因:如果数组比较小,应避免红黑树结构。红黑树数据结构比较复杂TreeNode的空间比数组节点大(约2倍),并且需要进行左旋、右旋、变色这些操作才能保持平衡,在数组容量较小时,操作数组扩容比操作红黑树更节省时间

    static final int TREEIFY_THRESHOLD = 8;
    static final int MIN_TREEIFY_CAPACITY = 64;
    static final int UNTREEIFY_THRESHOLD = 6;
    
    final V putVal(K key, V value) {
        ...
        for (int binCount = 0; ; ++binCount) {
            if ((e = p.next) == null) {
                p.next = newNode(hash, key, value, null);
                if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                    treeifyBin(tab, hash);  //第一个条件,链表长度大于等于8
                break;
            }
            ...
        }
    }
    
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize(); //扩容
        else if ((e = tab[index = (n - 1) & hash]) != null) { //第二个条件,数组大小大于64
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }
    
    //resize方法会调用split
     final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
        ...
         if (loHead != null) {
             if (lc <= UNTREEIFY_THRESHOLD) //桶的长度小于等于6,红黑树转换未链表
                 tab[index] = loHead.untreeify(map);
             else {
                 tab[index] = loHead;
                 if (hiHead != null) // (else is already treeified)
                     loHead.treeify(tab);
             }
         }
         if (hiHead != null) {
             if (hc <= UNTREEIFY_THRESHOLD)
                 tab[index + bit] = hiHead.untreeify(map);
             else {
                 tab[index + bit] = hiHead;
                 if (loHead != null)
                     hiHead.treeify(tab);
             }
         }
     }
    
  • 为什么桶中的个数超过8才转为红黑树

    ① 红黑树的节点TreeNode所需要的空间约是普通链表节点node的2倍,且数据结构更复杂,要尽量避免红黑树结构

    ② 桶中的节点频率遵守泊松分布,从桶长度k的频率表可看出,桶长度超过8的概率不到千万分之一,概率很低。

  • HashMap的哈希函数怎么设计的

    hash函数的作用:通过hash算法来决定每个元素的存储位置

    hash函数的设计:hash函数先拿到key的hashcode(这个已存在,因为key都是String类型的),它是一个32位的int值,然后让hashcode的高16位和低16位进行异或操作

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    
  • 为什么采用hashcode的高16位与低16位的异或操作

    异或:二进制码同位进行比较,相同的取0,不相同的取1

    ① 高半区和低半区做异或,混合了原始哈希码的高位和低位,加大了低位的随机性,减少了哈希碰撞

    ② 混合后的低位掺杂了高位的部分特征,高位的信息变相的保留下来

    //计算hash
    h = hashCode(); //1111 1111 1111 1111 1111 0000 1110 1010
    h >>> 16; //0000 0000 0000 0000 1111 1111 1111 1111
    hash = h ^ h>>>16; //1111 1111 1111 1111 0000 1111 0001 0101
    //计算桶下标
    (n-1)& hash; // 15 & h
    //15:0000 0000 0000 0000 0000 0000 0000 1111
    //h:1111 1111 1111 1111 0000 1111 0001 0101
    //结果:0000 0000 0000 0000 0000 0000 0101 = 5
    &运算规则:0&0=00&1=01&0=01&1=1
    
  • 两个键的hashcode相同,如何存储键值对

    相同hashcode值算出来桶的位置是一样的,所以存放的都是同一个桶

    ① 如果键是一样的,则直接替换之前的值

    ② 如果键是不一样的,则插入进桶中,形成单链表(jdk1.7采用头插法,jdk1.8采用尾插法)

  • 有哪些办法解决hash冲突,HashMap是怎么解决hash冲突的

    ① 有以下方法解决hash冲突

    1. 开放地址法(再散列法)

      当发生hash冲突时,以当前地址为基准,通过一个探测算法,继续查找下一个可以使用的空的地址。

      线性探测:冲突发生时,顺序查看表中下一单元,直到找出一个空单元或查遍全表
      f i ( k e y ) = ( f ( k e y ) + d i ) M O D m ( d i = 1 , 2 , 3 , . . . , m − 1 ) fi(key) = (f(key) + di) MOD m (di = 1,2,3,...,m-1) fi(key)=(f(key)+di)MODm(di=1,2,3,...,m1)
      二次探测:冲突发生时,在表的左右进行跳跃式探测,比较灵活
      f i ( k e y ) = ( f ( k e y ) + d i ) M O D m ( d i = 1 2 , − 1 2 , 2 2 , − 2 2 , . . . , n 2 , − n 2 , n < = m / 2 ) fi(key) = (f(key) + di) MOD m (di = 1^2,-1^2,2^2,-2^2,...,n^2,-n^2,n<=m/2) fi(key)=(f(key)+di)MODm(di=12,12,22,22,...,n2,n2,n<=m/2)
      伪随机探测:建立一个伪随机数发生器,生成一个位随机序列,并给定一个随机数做起点,每次加上这个伪随机数
      f i ( k e y ) = ( f ( k e y ) + d i ) M O D m ( d i 是 一 个 随 机 数 ) fi(key) = (f(key) + di) MOD m (di 是一个随机数) fi(key)=(f(key)+di)MODm(di)

    2. 再哈希法

      当发生hash冲突时,再次hash算法一次,直至不发生冲突位置

    3. 链地址法(拉链法)

      将hash值一样的数据组装成一个单链表

    4. 建立一个公共溢出区

      所有冲突的关键字建立一个公共的溢出区来存放。在查找时,对给定的值通过hash计算出地址后,先与基表位置进行比对,

      如果相同,则直接返回值,

      如果不相同,则到溢出表中进行顺序查找

    ② HashMap 采用链地址法决绝hash冲突的

  • HashMap在什么条件下进行扩容,为什么扩容时2的次幂
    ① 在什么条件下进行扩容

    ​ 第一种情况:当向HashMap中的新增元素,已有的元素个数已经达到了 容量 * 加载因子并且数组下标的值不为空时,就会触发扩容。

    ​ 第二种情况:当向HashMap中的新增元素,HashMap的数组长度未达到64,但hash出来的结果目标桶的数据节点已有7个时,就会触发扩容。

    ② 为什么扩容是2的次幂

    ​ HashMap 计算元素需要存放在哪个桶时,是利用key的hashcode值进行高低位异或运算得到hash值,进而与(数组的长度-1 )&运算得到存放的桶下标, 通俗的讲就是取hash值的二进值后四位(数组默认为16,16-1=15即1111)。

       1000 1111 0101 1010 1101 1101 0011 1010  //hash 值
    &                                     1111  //数组长度-1,15
    =                                     1010  //最后下标,10
    

    ​ 当需要扩容时,为了保证扩容之后,一样可以高效的使用**&运算**,那么就是用 xxx1 1111来计算桶的下标,刚好对应之前值得2倍(位运算)。

  • 为什么HashMap,jdk1.8版本使用尾插法,而弃用之前的头插法(为什么线程不安全)

    ① 头插法的实现逻辑,以及多线程下为何不安全

    ​ jdk1.7版本,先扩容后重新插入数据(因为要重新计算元素存放到扩容之后的数组里面的桶位置,所以先扩容)

    void transfer(Entry[] newTable, boolean rehash) {
    	int newCapacity = newTable.length;
    	//遍历原数组table,用e指向每次遍历到的桶的头结点
    	for (Entry<K,V> e : table) {
    		//当遍历到的e不为null的时候,此位置的所有元素(桶)全部移动到新数组
    		while(null != e) {
    			//next指向当前元素e的下一个节点(链表)
    			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指向新数组下标处的元素
    			e.next = newTable[i];
                 //e元素放在头部
    			newTable[i] = e;
                 //赋值之前的下一个元素,循环
    			e = next;
    		}
    	}
    }
    

    😫在多线程情况下

    第一种情况put元素时:当有一个线程A先计算得到下标值,然后被挂起,另一个线程B也是计算得到下标值(同样的),然后执行完插入元素操作,当线程A再次调度时对过期的表头没有感知,于是覆盖了原有的线程B插入的数据,数据凭空消失。

    第二种情况resize操作:当一个线程A执行到Entry<K,V> next = e.next;这一句时,然后被挂起,另一个线程B被调用然后完成了resize操作。这时A继续被调度时,就可能发生获取next元素时是环形链的情况。

    ② 尾插法的实现逻辑

    jdk1.8版本,先插入数据,后扩容

    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)
            n = (tab = resize()).length;
        // 若数组的这个位置还没有元素则直接将key-value放进去
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            // 若该下标位置已有元素
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                // 已有元素的key值与新增元素的key判断是同一个,覆盖
                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) {
                       // 当节点的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;
            }
        }
        ++modCount;
        // 判断是否需要扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
    
    
    final Node<K,V>[] resize() {
        ...
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        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; //元素的位置在原位置 + 数组原长度
                        }
                    }
                }
            }
        }
        return newTab;
    }
    

    😫在多线程情况下
    第一种情况put操作:当有一个线程A先计算得到下标值,进行了hash碰撞,发现此桶没有数据,准备直接插入数据时被挂起,另一个线程B也是计算得到下标值(同样的),然后因为此桶没有数据直接插入了数据,当线程A再次调度时对过期的表头没有感知,于是覆盖了原有的线程B插入的数据,数据凭空消失。

    第二种情况put操作:当一个线程A执行++size,然后被挂起,另一个线程B被调用然后完成了put操作。这时A继续被调度时,++size就比真实情况少了1。

    总结:jdk1.8中HashMap没有重新计算元素的下标,而是利用其下标计算规则(&运算)以及扩容规则(2倍),直接判断hashcode在扩容之后新增位是0还是1,0则位置无变化,1则为原位置 + 数组原长度

    例如:原数组长度为16,扩容之后是32
    150000 0000 0000 0000 0000 0000 0000 1111
    310000 0000 0000 0000 0000 0000 0001 1111
    ① 有元素key算得hash值为52&运算结果
    原数组位置:
       0000 0000 0000 0000 0000 0000 0011 0100
     & 0000 0000 0000 0000 0000 0000 0000 1111
     =                                    0100  //4
     新数组位置
       0000 0000 0000 0000 0000 0000 0011 0100
     & 0000 0000 0000 0000 0000 0000 0001 1111
     =                                  1 0100  //16 + 4 = 20
     ② 有元素key算得hash值为36&运算结果
        原数组位置:
       0000 0000 0000 0000 0000 0000 0010 0100
     & 0000 0000 0000 0000 0000 0000 0000 1111
     =                                    0100  //4
     新数组位置
       0000 0000 0000 0000 0000 0000 0010 0100
     & 0000 0000 0000 0000 0000 0000 0001 1111
     =                                    0100  //4
    
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

图图学Java

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值