前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >HashMap设计思想学习

HashMap设计思想学习

作者头像
大忽悠爱学习
发布2023-02-26 17:33:43
9020
发布2023-02-26 17:33:43
举报
文章被收录于专栏:c++与qt学习c++与qt学习

HashMap设计思想学习


引言

hashmap在jdk 1.7之前是数组+链表结构,而jdk1.8之后变为是数组+(链表|红黑树)


树化与退化

树化意义:

  • 红黑树用来避免 DoS 攻击,防止链表超长时性能下降,树化应当是偶然情况,是保底策略
  • hash 表的查找,更新的时间复杂度是 O(1),而红黑树的查找,更新的时间复杂度是 O(log_2⁡n ),TreeNode 占用空间也比普通 Node 的大,如非必要,尽量还是使用链表
  • hash 值如果足够随机,则在 hash 表内按泊松分布,在负载因子 0.75 的情况下,长度超过 8 的链表出现概率是 0.00000006,树化阈值选择 8 就是为了让树化几率足够小

树化规则

  • 当链表长度超过树化阈值 8 时,先尝试扩容来减少链表长度,如果数组容量已经 >=64,才会进行树化

退化规则

  • 情况1:在扩容时如果拆分树时,树元素个数 <= 6 则会退化链表
  • 情况2:remove 树节点时,若 root、root.left、root.right、root.left.left (根节点的左右孩子和左孙子)有一个为 null ,也会退化为链表

总结:

通常情况下,除非是恶意伪造大量hash相同的元素,否则一般情况下链表长度最长也就是6左右,但是为了应对恶意伪造数据进行攻击的情况,引入了红黑树在链表长度达到指定阈值8时,进行替换;当然,如果此时数组长度没有大于等于64,那么会先尝试通过扩容数组大小来减少链表长度。扩容时,如果某个树的元素个数小于了6,那么红黑树会退化为链表,或者红黑树根节点的左右孩子或者左孙子中有一个为null,也会退化为链表。


红黑树的优势

传统的BST二叉搜索树需要满足根节点大于左子树小于右子树的条件,并且查询和插入复杂度为0(logn),但是极端情况下二叉搜索树会退化为线性结构,此时查询和插入复杂度变为o(n)。

AVL自平衡二叉树在二叉搜索树的基础上进行了优化,需要满足左右子树的高度差小于等于1,AVL树的最差查询和插入复杂度也为O(logn)。

AVL自平衡二叉树对"平衡"的定义非常严格,在插入和删除非常频繁的场景下,会产生大量的旋转操作,性能会受到很大影响。

红黑树:

1.每个节点非红即黑

2.root节点必须是黑节点

3.null节点会被视为叶子节点,叶子节点必须是黑节点

4.红节点的子节点必须是黑节点

5.新插入的节点是红节点

6.从任意一个节点出发到叶子节点的任意路径上,黑色节点的个数都是相等的

红黑树和AVL树所要求的严格的"平衡"条件不同,红黑树最大允许左右节点数相差一倍(左子树全部都是黑色节点,右子树一红一黑), 红黑树对"平衡"的条件要求不是那么苛刻,因此红黑树在插入和删除节点时,产生的旋转变换操作会更少,性能更高,并且红黑树整体复杂度依然为0(logn);


索引计算

索引计算方法

  • 首先,计算对象的 hashCode()
  • 再进行调用 HashMap 的 hash() 方法进行二次哈希
    • 二次 hash() 是为了综合高位数据,让哈希分布更为均匀
  • 最后 & (capacity – 1) 得到索引

hashmap的二次哈希方法中会对key的hashcode进行扰动计算,防止不同的hashcode的高位不同但低位相同导致hash冲突。简单来说,就是为了把高位的特征和低位的特征组合起来,降低哈希冲突的概率,也就是说,尽量做到任何一位的变化都能对最终得到的结果产生影响。

数组容量为何是 2 的 n 次幂

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

注意

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

偶数对偶数进行取模得到的结果还是偶数,因此容量为2的n次幂最大的缺点就是hash分布不均匀,因此,如果追求的是更好的hash分散性,应该采用质数作为数组容量; 但是2的n次幂可以提供很多优化特性,追求性能,还是可以考虑的。


put流程

1.hashMap是懒惰创建数组的,首次使用时才会创建 2.计算索引(桶下标)–>hashcode–>二次哈希–>与运算 3.如果桶下标没人占用,创建Node占位返回 4.如果桶下标已经被占用了 4.1 已经是TreeNode走红黑树添加或者更新逻辑 4.2 是普通的Node,走连接的添加或更新逻辑 4.2.1 如果链表长度超过树化阈值8,并且当前数组容量是小于64,那么会首先通过扩容,减少链表长度 4.2.2 如果链表长度超过树化阈值8,并且当前数组容器是大于等于64,那么会将链表转换为红黑树,走树化逻辑 5.返回前检查容量是否超过扩容阈值,一旦超过进行扩容

jdk 1.7和jdk1.8的不同:

1.插入节点时,jdk1.7采用头插法,1.8采用尾插法 2…1.7是大于等于阈值并且计算出的索引不是空位的情况下才进行扩容,而1.8是大于阈值就扩容 3. 1.7和1.8在在扩容计算Node索引时进行优化,会通过 hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap


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

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

hashMap并发丢失数据问题

hashMap本身并不是线程安全的,所以无论是jdk 1.7 还是 jdk 1.8,都存在并发丢失数据的风险:

在这里插入图片描述
在这里插入图片描述

如果存在两个并发线程1和2都同时向hashmap中put一对键值对,并且key计算出来的hash值都是相同的,那么线程1和线程2同时来到判断索引位是否为空的逻辑,发现为空,填充数据,此时就存在线程2覆盖线程1的数据导致丢失数据的风险。


jdk 1.7并发死链问题

1.7 源码如下:

代码语言:javascript
复制
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;
        }
    }
}
  • e 和 next 都是局部变量,用来指向当前节点和下一个节点
  • 线程1(绿色)的临时变量 e 和 next 刚引用了这俩节点,还未来得及移动节点,发生了线程切换,由线程2(蓝色)完成扩容和迁移
在这里插入图片描述
在这里插入图片描述
  • 线程2 扩容完成,由于头插法,链表顺序颠倒。但线程1 的临时变量 e 和 next 还引用了这俩节点,还要再来一遍迁移
在这里插入图片描述
在这里插入图片描述

第一次循环

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

第二次循环

  • next 指向了节点 a
  • e 头插节点 b
  • 当循环结束时,e 指向 next 也就是节点 a
在这里插入图片描述
在这里插入图片描述

第三次循环

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

总结: 由于jdk 1.7的hashmap采用的是头插法,所以当存在两个并发线程同时尝试对hash数组进行扩容时,会出现线程1先扩容完毕,将原本的元素A指向B顺序,颠倒为B指向A。而由于线程2无法感知该变化,因此依然按照A指向B的顺序迁移元素到新数组,最终会产生a指向b,b指向a的死链。


key 的设计

key 的设计要求

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

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

代码语言:javascript
复制
public class HashMapMutableKey {
    public static void main(String[] args) {
        HashMap<Student, Object> map = new HashMap<>();
        Student stu = new Student("张三", 18);
        map.put(stu, new Object());

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

        stu.age = 19;
        System.out.println(map.get(stu));
    }

    static class Student {
        String name;
        int age;

        public Student(String name, int age) {
            this.name = name;
            this.age = age;
        }

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }

        public int getAge() {
            return age;
        }

        public void setAge(int age) {
            this.age = age;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            Student student = (Student) o;
            return age == student.age && Objects.equals(name, student.name);
        }

        @Override
        public int hashCode() {
            return Objects.hash(name, age);
        }
    }
}

String 对象的 hashCode() 设计

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

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2023-02-11,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • HashMap设计思想学习
  • 引言
  • 树化与退化
  • 红黑树的优势
  • 索引计算
  • put流程
  • 扩容(加载)因子为何默认是 0.75f
  • hashMap并发丢失数据问题
  • jdk 1.7并发死链问题
  • key 的设计
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档