一篇:HashMap

https://cdn.pixabay.com/photo/2022/03/01/20/58/peace-genius-7042013_960_720.jpg

HashMap

HashMap的底层数据结构是啥?

版本不同,底层数据结构不同。

  1. jdk1.7之前,HashMap底层数据结构是【数组+链表】
  2. jdk1.8之后,HashMap底层数据结构是【数组+链表/红黑树】

好好的数组+链表为啥要引入红黑树?

假设一种极端情况,插入的n个数据hash值都相同,那么其索引也就相同。那么哈希表就会退化成链表,搜索的时间复杂度为O(n)。性能会降低。引入红黑树后性能最坏时O(log2n)

你再理解下哈希表的好处吧

比如有一串数据[a,e,r,h,s,y,p,d]如果要查找a,只能一个一个的比较,最多会比较8次;

如果使用hash表的话,在put时,先会a计算出a的二次hash=97,然后mod16=1,然后将a存进数组下标为1处。

查找的时候以同样的方法计算出a的索引,就可以快速定位到你想要的数据了,最好的时间复杂度为O(n)。

HashMap链表过长怎么办?

不就是链表长吗,我要么给你把链表长度减短一点或者链表转成红黑树

扩容
转成红黑树

在数组扩容时,元素的下标会重新计算。But,扩容也不一定缩小链表长度,如图:

image-20220215233230216

别慌,这个时候就可以树化了。树化有两个条件:【链表长度大于8 && 数组长度大于等于64】

树化一定时万不得已时才触发,否则HashMap会优先考虑使用扩容。

树化后一般都会减少查找次数,因为红黑树嘛,检查次数最多不超过树的高度。

HashMap中链表的长度有可能超过8。

树化的两个条件是啥?

见上个问题

为啥不一上来就树化?

  • 没必要,元素很少的情况下,链表查找代价<(树化+树查找)代价

  • 而且树节点的内存占用>链表

树化的原因是防止链表超长时性能下降(树化是偶然情况)

树化阈值为什么是8呢?

选择8是为了让树化的几率足够小

hash值如果足够随机,则在hash表内按泊松分布,在负载因子0.75的情况下,长度超过8的链表出现概率时0.000000006

树的退化来啦 哈哈哈哈

  1. 在扩容时,如果树的元素个数<=6,那么树会被拆分成链表;
    或者
  2. 移出节点时,如果移出之前,root、root.left、root.left.left、root.right有一个为null,那么就会退化成链表;

HashMap什么时候扩容?

当插入的元素数量>阈值时便会扩容

  • HashMap容量(capacity):默认 16
  • HashMap负载因子(loadFactor):默认 0.75。
    • 负载因子=size / capacity 这里的size是填入HashMap的元素的个数,而不是数组(桶)的填充数量。比如数组的长度是16 ,占用桶的数量可能只有10,但是因为链表的存在可以存入12个元素。当存入第13个元素时才会进行扩容,注意是 添加元素后
  • HashMap阈值(threshold):阈值=容量*加载因子。默认 12当元素数量>阈值时便会扩容。

HashMap扩容时都干了些啥?

为每个元素重新计算桶下标

讲讲HashMap的扩容机制

当xxxx(见上),就会触发扩容机制。**扩容到原来的2倍。**但是HashMap的容是上限的,为Integer.MAX_VALUE。如果超出了这个数,就不再增长,且阈值为Integer.MAX_VALUE

数组是无法扩容的,所以只能新建一个大数组,然后把数据copy过去。扩容方法resize()

    /**
     * Rehashes the contents of this map into a new array with a
     * larger capacity.  This method is called automatically when the
     * number of keys in this map reaches its threshold.
     *
     * If current capacity is MAXIMUM_CAPACITY, this method does not
     * resize the map, but sets threshold to Integer.MAX_VALUE.
     * This has the effect of preventing future calls.【这有阻止未来调用的效果】
     * @param newCapacity the new capacity, MUST be a power of two;【一定是2的幂】	
     *        must be greater than current capacity unless current
     *        capacity is MAXIMUM_CAPACITY (in which case value
     *        is irrelevant).
     */
	 void resize(int newCapacity) {	// newCapacity为新的容量
	     // 小数组,临时过度下
	     Entry[] oldTable = table;
	     // 扩容前的容量
	     int oldCapacity = oldTable.length;
	     // MAXIMUM_CAPACITY 为最大容量,2 的 30 次方 = 1<<30
	     if (oldCapacity == MAXIMUM_CAPACITY) {
	         // 容量调整为 Integer 的最大值 0x7fffffff(十六进制)=2 的 31 次方-1
	         threshold = Integer.MAX_VALUE;
	         return;
	     }
	 
	     // 初始化一个新的数组(大容量)
	     Entry[] newTable = new Entry[newCapacity];
	     // 把小数组的元素转移到大数组中
         // void transfer(Entry[] newTable, boolean rehash)
	     transfer(newTable, initHashSeedAsNeeded(newCapacity));
	     // 引用新的大数组
	     table = newTable;
	     // 重新计算阈值
	     threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
	 }

<<是什么,见《计算机科普常识》

transfer用来将小数组的数据copy到新数组中去。


    /**
     * 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;
                //是否要重新计算hash
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                //根据大数组的容量和键的hash计算元素在数组的下标
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

HashMap索引的计算方法是啥?

先调用对象的hashcode()方法,然后再调用HashMap中的int hash(int hash)方法进行二次hash,二次hash是为了综合高位数据,使得hash分布更加均匀。最后再进行&(capactiy-1)得到索引

注意:二次hash是为了配合容量是2的n次幂这一设计前提,如果hash表的容量不是2的n次幂,则不需要进行二次hash

HashMap容量为何是2的n次幂?

  1. 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
  2. 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap

注意

  • 二次 hash 是为了配合 容量是 2 的 n 次幂 这一设计前提,如果 hash 表的容量不是 2 的 n 次幂,则不必二次 hash
  • 容量是 2 的 n 次幂 这一设计计算索引效率更好,但 hash 的分散性就不好,需要二次 hash 来作为补偿,没有采用这一设计的典型例子是 Hashtable

讲讲put方法的流程

  1. HashMap是懒惰创建数据的,首次使用才会创建数组
  2. 计算索引(桶下标)
  3. 如果还没有人占用,就创建node占位后返回
  4. 如果下标已经被人占用,
    1. 已经是TreeNode走红黑树的添加或者更新
    2. 是普通Node,走链表的添加或者更新,如果链表长度超过阈值,走树化逻辑
  5. 返回前检查容量是否超过阈值,一旦超过就进行扩容

1.7与1.8的区别

  1. 链表插入节点时,1.7 是头插法,1.8 是尾插法

  2. 1.7 是大于等于阈值且该索引下没有空位时才扩容,而 1.8 是大于阈值就扩容

  3. 1.8 在扩容计算 Node 索引时,会优化(旧的hash值跟当前的数组容量按位与,如果是0,说明不用动位置; 不是0,旧索引+旧容量)

扩容(加载)因子为何默认是 0.75f?

  1. 在空间占用与查询时间之间取得较好的权衡
  2. 大于这个值,空间节省了,但链表就会比较长影响性能
  3. 小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多

HashMap下的并发问题

  • 扩容死链(1.7会存在)
  • 数据错乱(1.7,1.8 都会存在)

HashMap并发下的数据错乱是怎么回事?

数据错乱(1.7,1.8 都会存在)

  • 代码参考 day01.map.HashMapMissData,具体调试步骤参考视频
public class HashMapMissData {
    public static void main(String[] args) throws InterruptedException {

        HashMap<String, Object> map = new HashMap<>();
        Thread t1 = new Thread(() -> {
            map.put("a", new Object()); // 97  => 1
        }, "b");

        Thread t2 = new Thread(() -> {
            map.put("1", new Object()); // 49 => 1
        }, "a");

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(map);
    }
}

image-20220216123743326

1.7中HashMap的扩容死链是怎么回事?

扩容死链(1.7 会存在)

1.7 源码如下:

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

前提说明
image-20220323085345063

在链表迁移时并不会创建新的对象,上图其实只有两个对象:a和b。链表迁移只是改变了引用而已。

死链产生的根源:迁移前顺序是a、b,迁移后顺序是b、a。

  • e 和 next 都是局部变量,用来指向当前节点和下一个节点
  • 线程1(绿色)的临时变量 e 和 next 刚引用了这俩节点,还未来得及移动节点,发生了线程切换,由线程2(蓝色)完成扩容和迁移

image-20210831084325075

  • 线程2 扩容完成,由于头插法,链表顺序颠倒。但线程1 的临时变量 e 和 next 还引用了这俩节点,还要再来一遍迁移

image-20210831084723383

  • 第一次循环
    • 循环接着线程切换前运行,注意此时 e 指向的是节点 a,next 指向的是节点 b
    • e 头插 a 节点,注意图中画了两份 a 节点,但事实上只有一个(为了不让箭头特别乱画了两份)
    • 当循环结束是 e 会指向 next 也就是 b 节点

image-20210831084855348

  • 第二次循环
    • next 指向了节点 a
    • e 头插节点 b
    • 当循环结束时,e 指向 next 也就是节点 a

image-20210831085329449

  • 第三次循环
    • next 指向了 null
    • e 头插节点 a,a 的 next 指向了 b(之前 a.next 一直是 null),b 的 next 指向 a,死链已成
    • 当循环结束时,e 指向 next 也就是 null,因此第四次循环时会正常退出

image-20210831085543224

HashMap中key的设计

  1. HashMap 的 key 可以为 null,但 Map 的其他实现则不然
  2. 作为 key 的对象,必须实现 hashCode 和 equals,并且 key 的内容不能修改(不可变),否则有可能找不到对象
  3. key 的 hashCode 应该有良好的散列性

如果 key 可变,例如修改了 age 会导致再次查询时查询不到

 @Test
    public void test02161254(){
        Person person = new Person("稚浩",21);
        HashMap<Object, Object> map = new HashMap<>();

        map.put(person,new Object());

        System.out.println(map.get(person));

        person.setAge(18);

        System.out.println(map.get(person));

    }
java.lang.Object@4dcbadb4
null

hashcode()的设计

hashcode()设计的最终目标是达到较为均匀的散列效果,每个对象的 hashCode 越独特越好

String 对象的 hashCode() 设计

  • 目标是达到较为均匀的散列效果,每个字符串的 hashCode 足够独特
  • 字符串中的每个字符都可以表现为一个数字,称为 S i S_i Si,其中 i 的范围是 0 ~ n - 1
  • 散列公式为: S 0 ∗ 3 1 ( n − 1 ) + S 1 ∗ 3 1 ( n − 2 ) + … S i ∗ 3 1 ( n − 1 − i ) + … S ( n − 1 ) ∗ 3 1 0 S_0∗31^{(n-1)}+ S_1∗31^{(n-2)}+ … S_i ∗ 31^{(n-1-i)}+ …S_{(n-1)}∗31^0 S031(n1)+S131(n2)+Si31(n1i)+S(n1)310
  • 31 代入公式有较好的散列特性,并且 31 * h 可以被优化为
    • 即 $32 ∗h -h $
    • 2 5 ∗ h − h 2^5 ∗h -h 25hh
    • h ≪ 5 − h h≪5 -h h5h

de() 设计**

  • 目标是达到较为均匀的散列效果,每个字符串的 hashCode 足够独特
  • 字符串中的每个字符都可以表现为一个数字,称为 S i S_i Si,其中 i 的范围是 0 ~ n - 1
  • 散列公式为: S 0 ∗ 3 1 ( n − 1 ) + S 1 ∗ 3 1 ( n − 2 ) + … S i ∗ 3 1 ( n − 1 − i ) + … S ( n − 1 ) ∗ 3 1 0 S_0∗31^{(n-1)}+ S_1∗31^{(n-2)}+ … S_i ∗ 31^{(n-1-i)}+ …S_{(n-1)}∗31^0 S031(n1)+S131(n2)+Si31(n1i)+S(n1)310
  • 31 代入公式有较好的散列特性,并且 31 * h 可以被优化为
    • 即 $32 ∗h -h $
    • 2 5 ∗ h − h 2^5 ∗h -h 25hh
    • h ≪ 5 − h h≪5 -h h5h
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值