手撕JDK的HashMap源码

目录

Hash表

哈希函数

处理哈希冲突的两种方案

1. 闭散列

2. 开散列

哈希算法

基于开散列方式实现的哈希表(重点)

添加操作

扩容

查找操作

删除操作

Object中的hashCode()和equals()方法

1. hashCode()

2. equals()

JDK的源码分析——HashMap的源码

1. JDK8之后HashMap的结构如下

2. 关于HashMap源码中属性的解读

3. put方法解读

3.1 hash函数计算索引

3.2 put方法

put方法核心流程小结

4. 关于哈希桶的数量必须为2^n的说明——HashMap的构造方法

5. 关于resize方法

关于Set集合和Map集合的关系


Hash表

哈希表中需要一种方法将任意的数据类型转为数组的索引,这样的一种方法我们称之为哈希函数。
哈希表的高效查找秘诀就在于数组的随机访问能力,在数组中若知道索引,可以在O(1)获取到该元素,用空间换取时间。


问题:在数组[9,5,2,7,3,6,8]中查找元素是否存在?
就建立一个长度为10的boolean数组,遍历原数组,若该元素在原数组中存在,boolean对应的位置置为true。
boolean[] hash = new boolean[10];
hash[9] = true;
hash[5] = true;
....直到扫描完整个集合。

查询元素3是否在原集合存在,判断hash[3] == true?时间复杂度:O(1)。

任意数字对应一个索引,数字值为多少,对应boolean数组的索引就为多少。


此时开辟的新哈希数组的大小是按照原数组最大数值+1
若此时原数组中的数字跨度非常之大,而且包含负数,上述方式就不再适用。
[9,100001,-2,800,55,30000000]没法创造一个一一对应的索引。
若数字本身的值较大,就需要让原数字和下标建立一个映射关系(hash函数)。让跨度较大的一组数据转为跨度很小的一组数据。从而高效利用有效空间。

哈希函数

哈希函数:将任意的数据 key 转为索引

如:字符c,f(c) = 'c' - 'a' 转为索引;
一个班的学号[1...30],直接将学号的数值作为索引值;

身份证号18位数,将大整数映射为小整数;

String 转为 int ,字符串内部就是字符数组,因此按照 char 转 int 的方式转换;

其他类型 转为 int ,任意类型都有toString()方法,先转 String 再转 int 。


一般来说,我们将任意的正整数映射为小区间数字,最常用做法为“取模”。

在理论上,数学中的任意函数f(x),两个不相同的 x 都一定有可能会映射到相同的 y ,因此产生哈希冲突。

哈希冲突:不同的 key 经过 hash 函数的计算得到了相同的 value 值(索引)。

解决冲突的一种办法:模一个素数


哈希函数的设计:哈希冲突在数学领域理论上一定存在。
哈希函数最核心的原则:尽可能在空间和时间上求平衡,利用最少的空间获取一个较为平均的数字分布。

哈希表:是哈希函数的设计和哈希冲突的解决。

将任意的key 转换为一个索引,映射得到的索引按照理论一定会冲突,冲突之后如何解决。

1. 将任意的 key 经过哈希函数的运算转为相应的索引值。
2. 若得到的索引在当前哈希表中没有元素保存,直接保存。
3. 若得到的索引在当前哈希表中已经保存了元素,处理哈希冲突后保存。

处理哈希冲突的两种方案

1. 闭散列

当发生冲突时,找到冲突位置的旁边是否存在空闲位置,直到找到第一个空闲位置放入元素。(好存、难查、更难删,工程中很少使用)

查找元素

若整个哈希表冲突非常严重,此时查找一个元素,时间复杂度从O(1)——>遍历数组O(n)。

2. 开散列

若出现hash冲突,就让这个位置变为链表。开散列方案下的哈希表:数组+链表。

查找元素

若当前哈希表中某个位置,图中索引19这个位置冲突非常严重。
恰好每个元素取模后都是19,某个数组对应的链表的长度过长,查找效率降低。

解决方案:
1. 针对整个数组进行扩容(例如:现在数组长度101,扩容到202)由原先%101=> % 202,很多原来同一个链表上的元素均分到其他新的位置,降低哈希冲突。

C++ STL的Map采用此方案
2. 将这个冲突严重的链表再次变为新的哈希表 / 二分搜索树

将O(n)——>O(logn),不用整张哈希表进行处理,只处理冲突严重的链表。

JDK采用此方案。

问题

线性探测方法,即闭散列方案,冲突之后在冲突位置之后寻找下一个为空的位置。

等概率成功查找的平均查找长度:当前表中所有元素的查找次数 / 表中有效的元素个数。

答:使用闭散列方法

得出答案 12 / 6 = 2,选择C。


若使用开散列方法

总共查找次数:8

等概率成功查找的平均查找长度:8 / 6 = 4 / 3 = 1.3

冲突概率变小,查找次数降低。

哈希算法

对于一般场景下的哈希函数的设计:
一般来说不用自己写,用现成的即可。
方案1:MD5,MD4,MD3
方案2:SHA1,SHA256
MD5一般用在字符串计算hash值

MD5的特点:
1. 定长。无论输入的数据有多长,得到的MD5值长度固定(16或32位)。
2. 分散。如果输入的数据稍有偏差,得到的MD5值相差很大(冲突概率非常低,工程领域忽略不计)。

3. 不可逆。根据字符串计算MD5容易,想通过得到的MD5还原字符串非常难(基本不可能)。

4. 稳定性。根据相同的数据计算的MD5值是稳定的,不会发生变化。稳定性是所有哈希函数都要满足的特点。

MD5的用途非常广泛:

1. 作为hash运算;
2. 用于加密;
3. 对比文件内容(内容稍有修改,得到的md5值天差地别)。

例如:我给小明发送大小为2G的文件源文件,计算MD5。小明在收到之后,如何知道这个文件内容是否有变化,传输是否成功?
小明在收到之后,再把收到的文件计算一个MD5

若原MD5 == 新MD5,则传输成功。

基于开散列方式实现的哈希表(重点)

添加操作

代码实现

    /**
     * 对key值求哈希
     */
    public int hash(int key) {
        return Math.abs(key) % M;
    }

    /**
     * 将一对键值对保存到当前hash表中
     *
     * @param key
     * @param value
     * @return 若key存在,此时修改原来的键值对,返回修改前的元素
     */
    public int put(int key, int value) {
        // 1.先对key值取模
        int index = hash(key);
        // 2.遍历这个index对应的链表,查看key值是否存在
        for (Node x = hashTable[index]; x != null; x = x.next) {
            if (x.key == key) {
                int oldValue = x.value;
                x.value = value;
                return oldValue;
            }
        }
        // 3.此时整个列表中不包括相应key的节点,头插到当前位置
        Node node = new Node(key, value);
        // 当前链表的头节点 hashTable[index]
        node.next = hashTable[index];
        hashTable[index] = node;
        size++;
        // 添加元素后判断是否扩容
        if (size >= hashTable.length * LOAD_FACTOR) {
            resize();
        }
        return value;
    }

扩容(引入负载因子)

采用整表扩容方式

什么时候需要对数组扩容?哈希表冲突严重。

如何判断是否冲突严重?引入负载因子。

负载因子 loadFactor = 哈希表有效元素个数 /  哈希表长度

这个值越大,就说明冲突越严重。
这个值越小,说明冲突越小,数组利用率越低。


扩容与否就根据负载因子来决定
数组长度 * 负载因子 <= 有效元素个数,就需要扩容。


假设此时数组长度16,负载因子 = 0.75(JDK HashMap的默认负载因子为0.75)

16 * 0.75 = 12,当保存的元素个数 >= 12,就需要扩容了。

基本不冲突,每个链表长度1左右。最高效查询
假设此时数组长度16,负载因子 = 10(阿里巴巴实验数据)
16*10= 160,保存的元素个数 >= 160(每个子链表平均长度为10)需要扩容。
冲突较上面比较严重,每个链表平均都有10个节点。空间利用率较高

负载因子就是空间和时间取平衡,负载因子的取舍需要根据现实的需求去做实验。


代码实现

    /**
     * hash表的扩容,新数组的长度变为原来的2倍
     */
    private void resize() {
        // 1.产生一个新数组且新数组长度变为原来的2倍
        Node[] newTable = new Node[hashTable.length << 1];
        // 2.进行元素的搬移操作,将原数组中的所有元素搬移到新数组中,
        // 此时取模数变为新数组的长度
        this.M = newTable.length;
        // 3.进行元素搬移
        for (int i = 0; i < hashTable.length; i++) {
            for (Node x = hashTable[i]; x != null;) {
                Node next = x.next;
                // 将x搬移到新数组的位置
                int index = hash(x.key);
                // 新数组的头插
                x.next = newTable[index];
                newTable[index] = x;
                // 继续遍历原数组的后继节点
                x = next;
            }
        }
        hashTable = newTable;
    }
}

查找操作

查找key

    /**
     * 判断当前key是否在表中存在
     */
    public boolean containsKey(int key) {
        int index = hash(key);
        for (Node x = hashTable[index]; x != null; x = x.next) {
            if (key == x.key) {
                return true;
            }
        }
        return false;
    }

查找value

    /**
     * 判断当前value是否在表中存在
     */
    public boolean containsValue(int value) {
        // 全表扫描
        for (int i = 0; i < hashTable.length; i++) {
            for (Node x = hashTable[i]; x != null; x = x.next) {
                if (value == x.value) {
                    return true;
                }
            }
        }
        return false;
    }

查找(key,value)键值对

    /**
     * 判断(key,value)存在
     */
    public boolean containsKeyAndValue(int key, int value) {
        int index = hash(key);
        for (Node x = hashTable[index]; x != null; x = x.next) {
            if (value == x.value) {
                return true;
            }
        }
        return false;
    }

删除操作

    /**
     * 在哈希表中删除指定的键值对(key,value)
     */
    public boolean remove(int key, int value) {
        int index = hash(key);
        // 判断头节点是否是待删除的节点
        Node head = hashTable[index];
        if (head.key == key && head.value == value) {
            // 此时头节点是待删除的节点
            hashTable[index] = head.next;
            head = head.next = null;
            size--;
            return true;
        }
        Node prev = head;
        while (prev.next!=null) {
            if (prev.next.key == key && prev.next.value == value) {
                // prev恰好是待删除节点的前驱
                Node x = prev.next;
                prev.next = x.next;
                x = x.next = null;
                size--;
                return true;
            } else {
                prev = prev.next;
            }
        }
        // 当前hash表中没有这个节点
        throw new NoSuchElementException("no such element!remove error!");
    }

Object中的hashCode()和equals()方法

1. hashCode()

将任意的对象转为数组索引,只要是不同的对象原则上都会返回不同的整数。

Object提供的hashCode()可以将任意对象转为int,不同的对象(地址不同)原则上一定转为不同的int。
原则上自定义的类若需要保存到HashMap哈希表中,不能直接使用Object提供的hashCode(),需要覆写这个方法。原因是,hashCode返回的整型太大,数组开辟的空间就会过大。

2. equals()

判断两个对象是否是"相同"内容。

class Student {
    private String name;
    private int age;
}

此时要将Student对象存储到HashMap的key上。
1. 计算Student对象的哈希值,得到一个数组的索引下标。hashCode()计算哈希值。
2. 判断当前这个Student对象是否已经在哈希表中"存在"。equals()是否是相同的key值。

问题:equals相同的两个对象,其hashCode是否相同?

必须相同。equals相同的两个对象,就认为是同一个对象。哈希表中这个对象有且只能有一个。经过哈希函数运算后,两个对象保存的索引也应该相同。
问题:hashCode相同的两个对象,其equals是否相同?

不一定相同。hashCode相同,说明此时发生了哈希冲突,不一定就是相同的对象,到底是否相同还要取决于equals方法。
在哈希表中,只有equals和hashCode都相同的对象,才称为唯一对象。


class Student {
    private String name;
    private int age;

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

    @Override
    public int hashCode() {
        return 0;
    }

    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        }
        if (obj instanceof Student) {
            Student stu = (Student) obj;
            return this.age == stu.age && this.name.equals(stu.name);
        }
        return false;
    }

    @Override
    public String toString() {
        return "Student{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

输出结果

此时设置equal()的返回值为0,将三个对象都放在索引为0处。因为stu1和stu3这两个对象的equals相同,此时在HashMap只会存储一份。认为是相同的一个对象。equals相同的对象,就认为是同一个对象。

自定义对象作为key的唯一性,就是通过equals方法保证的。

JDK的源码分析——HashMap的源码

主要问题:
1. HashMap的哈希函数是如何设计的

2. put方法的逻辑,到底是如何存储元素的当发生冲突时如何解决的
3. 哈希表冲突较严重,如何扩容的,resize操作。

1. JDK8之后HashMap的结构如下

数组+链表+红黑树(冲突严重的链表会被"树化",将链表转为红黑树,提高冲突严重的链表的查询效率)

JDK8之前,JDK7以及更老版本,HashMap就是数组+链表

2. 关于HashMap源码中属性的解读

若某个链表长度>=8,此时哈希桶的数量不足64,则只是简单的哈希表扩容而已。

3. put方法解读

3.1 hash函数计算索引

ctrl + alt+鼠标左键,选择实现子类的方法而不是接口方法


首先计算一下当前key的哈希值,哈希Map的哈希方法。

高低16位都参与哈希函数的运算,尽可能保证不同key映射到比较均衡的状态。

 

问题汇总

1. 为何不采用Object类提供的hashCode方法计算出来的key值作为桶下标?
基本不会发生碰撞,哈希表就和普通数组基本没有区别。
2. 为何h >>> 16?
为何取出key值得高16位右移参与hash运算?
这样高低16位都参与运算,尽量保证数据均匀分布。
3. 为何HashMap中容量均为2^n ?
(n - 1) & hash:当n为2^n,此时的位运算就相当于 hash % (n - 1)。

3.2 put方法


put方法核心流程小结

I. 若HashMap还未初始化,先进行哈希表的初始化操作(默认初始化为16个桶)。
II. 对传入的key值做hash,得出要存放该元素的桶编号。
        a. 若没有发生碰撞,即头结点为空,将该节点直接存放到桶中作为头结点。
        b. 若发生碰撞
                1. 此桶中的链表已经树化,将节点构造为树节点后加入红黑树。
                2. 链表还未树化,将节点作为链表的最后一个节点入链表。
III. 若哈希表中存在key值相同的元素,替换最新的value值。
IV. 若桶满了(size++ 是否大于threshold),调用resize()扩容哈希表。


thresholed = 容量(默认16) * 负载因子 (默认0.75)。

问题:重写了hashcode还需要重写equals方法吗?

4. 关于哈希桶的数量必须为2^n的说明——HashMap的构造方法

(n - 1)& hash == hash % n (n为2^n)

4.1 使用无参构造,内部数组还没有初始化,只有第一次调用put方法时才初始化内部哈希桶数组。(懒加载模式)第一次使用(添加)时才初始化相应的内存。


4.2 使用有参构造

检查传入的哈希桶大小是否是2^n,若不是,调整为最接近的2^n的数(大于传递的参数)

5. 关于resize方法

即是扩容方法又是初始化方法,在resize方法中进行哈希桶数组的初始化操作。

关于Set集合和Map集合的关系

Set集合的子类实际上在存储元素时就是放在了Map集合的Key中,这也是为什么Set是不可重复的。
HashSet其实就使用HashMap保存的
TreeSet其实就使用TreeMap保存的

Set就是用的Map的子类来存储元素,Set的不可重复就是因为元素保存在了Map的Key中,因此Set保存的元素不可重复。
HashSet能否保存null?可以,因为HashMap的key可以为null。
TreeSet能否保存null?不可以,因为TreeMap的key不能为null。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
HashMap 是一种哈希表数据结构,它实现了 Map 接口,可以存储键值对。下面是 JDK 8 中 HashMap码详解。 1. 基本概念 哈希表是一种基于散列原理的数据结构,它通过将关键字映射到表中一个位置来访问记录,以加快查找的速度。在哈希表中,关键字被映射到一个特定的位置,这个位置就称为哈希地址或散列地址。哈希表的基本操作包括插入、删除和查找。 2. 类结构 HashMap 类结构如下: ``` public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { ... } ``` HashMap 继承了 AbstractMap 类,并实现了 Map 接口,同时还实现了 Cloneable 和 Serializable 接口,表示该类可以被克隆和序列化。 3. 数据结构 JDK 8 中的 HashMap 采用数组 + 链表(或红黑树)的结构来实现哈希表。具体来说,它使用了一个 Entry 数组来存储键值对,每个 Entry 对象包含一个 key 和一个 value,以及一个指向下一个 Entry 对象的指针。当多个 Entry 对象的哈希地址相同时,它们会被放入同一个链表中,这样就可以通过链表来解决哈希冲突的问题。在 JDK 8 中,当链表长度超过阈值(默认为 8)时,链表会被转化为红黑树,以提高查找的效率。 4. 哈希函数 HashMap 的哈希函数是通过对 key 的 hashCode() 方法返回值进行计算得到的。具体来说,它使用了一个称为扰动函数的算法来增加哈希值的随机性,以充分利用数组的空间。在 JDK 8 中,HashMap 使用了以下扰动函数: ``` static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } ``` 其中,^ 表示按位异或,>>> 表示无符号右移。这个函数的作用是将 key 的哈希值进行扰动,以减少哈希冲突的概率。 5. 插入操作 HashMap 的插入操作是通过 put() 方法实现的。具体来说,它会先计算出 key 的哈希值,然后根据哈希值计算出在数组中的位置。如果该位置是空的,就直接将 Entry 对象插入到该位置;否则,就在该位置对应的链表(或红黑树)中查找是否已经存在具有相同 key 的 Entry 对象,如果存在,则更新其 value 值,否则将新的 Entry 对象插入到链表(或红黑树)的末尾。 6. 查找操作 HashMap 的查找操作是通过 get() 方法实现的。具体来说,它会先计算出 key 的哈希值,然后根据哈希值计算出在数组中的位置。如果该位置为空,就直接返回 null;否则,就在该位置对应的链表(或红黑树)中查找是否存在具有相同 key 的 Entry 对象,如果存在,则返回其 value 值,否则返回 null。 7. 删除操作 HashMap 的删除操作是通过 remove() 方法实现的。具体来说,它会先计算出 key 的哈希值,然后根据哈希值计算出在数组中的位置。然后,在该位置对应的链表(或红黑树)中查找是否存在具有相同 key 的 Entry 对象,如果存在,则将其删除,否则什么也不做。 8. 总结 以上就是 JDK 8 中 HashMap码详解。需要注意的是,哈希表虽然可以加快查找的速度,但是在处理哈希冲突、扩容等问题上也存在一定的复杂性,因此在使用 HashMap 时需要注意其内部实现细节,以便更好地理解其性能和使用方法。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

瘦皮猴117

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

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

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

打赏作者

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

抵扣说明:

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

余额充值