Java-HashMap类

159 阅读10分钟

HashMap类使用详解

HashMap采用哈希:hash算法实现,是Map接口最常用的实现类。 由于底层采用了哈希表存储数据,我们要求键不能重复,如果发生重复,新键值对会替换旧的键值对。 HashMap在查找、删除、修改方面都有非常高的效率。

HashMap 集合中的 key 不能重复(key可以为null),因此我们需要通过重写 hashCode() 与 equals()方法来保证Key的唯一性。

image.png

 情况一:key为常用类对象

HashMap 中key为 JavaAPI 中提供的类型元素时,不需要重写元素的 hashCode 和 equals 方法,因为这两个方法,在 JavaAPI 的每个类中已经重写完毕,如 String 类、Integer 类等。

【示例】HashMap中key为 String,Integer类型

 public static void main1(String[] args) {
        Map<String, Integer> map = new HashMap<>();
        map.put("zhangsan", 18);
        map.put("lisi", 25);
        map.put("wangwu", 20);
        map.put(null, 28);
        map.put("wangwu", 30);
     System.out.println("map = " + map);//map = {null=28, lisi=25, zhangsan=18, wangwu=30}
 }
 public static void main2(String[] args) {
        Map<Integer, String> map = new HashMap<>();
        map.put(1,"java");
        map.put(2,"web");
        map.put(3,"python");
        map.put(2,"c++");
        System.out.println("map = " + map);//map = {1=java, 2=c++, 3=python}
  }

情况二:key为自定义对象

给 HashMap 中存放自定义对象时,如果自定义对象作为 key 存在,这时要保证对象唯一,必须重写对象的 hashCode 和 equals 方法,建立自己的比较方式,才能保证 HashMap 集合中的对象唯一。

package com.huayu.map;
/*
    @Author xiangge
    @Date 2023/7/22 
    @Description 
*/
public class Student {
    private String name;
    public Student() {
    }
    public Student(String name) {
        this.name = name;
    }
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Student student = (Student) o;
        return name != null ? name.equals(student.name) : student.name == null;
    }
    @Override
    public int hashCode() {
        return name != null ? name.hashCode() : 0;
    }
    @Override
    public String toString() {
        return "Student{" +
                "name='" + name + ''' +
                '}';
    }
}
​
public static void main(String[] args) {
        Map<Student, String> map = new HashMap<>();
        map.put(new Student("jack"),"001");
        map.put(new Student("jack"),"002");
        map.put(new Student("lisa"),"003");
        System.out.println("map = " + map);//map = {Student{name='lisa'}=003, Student{name='jack'}=002}
}

注意:当自定义对象作为HashMap的key时,一定得重写自定义类的 hashCode 和 equals 方法,建立自己的比较方式,才能保证 HashMap 集合中的对象唯一.

HashMap类底层实现

HashMap源码分析

哈希表(数组+链表): 是一种非常重要的数据结构,应用场景极其丰富,许多缓存技术(比如:Redis)的核心其实就是在内存中维护一张大的哈希表,而HashMap的实现原理也常常出现在各类的面试题中,重要性可见一斑。

  在讨论哈希表之前,我们先回顾一下数组和链表来实现对数据的存储的优缺点:

数组: 占用空间连续。 寻址容易,查询速度快。但是,增加和删除效率非常低。

链表: 占用空间不连续。 寻址困难,查询速度慢。但是,增加和删除效率非常高。

从上分析我们知道,数组优势是查询效率高,链表的优势是增删效率高。那么有没有一种数据结构能结合“数组+链表”的双方优点呢?答案就是“哈希表”。

哈希表的本质就是“数组+链表” ,这是一种非常重要的数据结构。在哈希表中进行添加、删除和查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成。

我们知道,数据结构的物理存储结构只有两种:顺序存储结构链式存储结构。而在上面我们提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组。

我们打开HashMap源码,发现有如下两个核心内容:

image.png

其中的,Node[] table 就是HashMap的核心数组结构,我们也称之为“位桶数组”。我们再继续看Node是什么,源码如下:

image.png

一个Node对象存储了:

l key:键对象

l hash:键对象的hash值

l value:值对象

l next:下一个节点

显然就是一个单向链表结构,我们使用图形表示一个Entry的典型示意:

image.png

然后,我们画出Node[]数组的结构(这也是HashMap的结构):

image.png

由图可知,哈希表就是数组+链表,底层还是数组但是这个数组存储的每个元素就是一个单链表。

哈希表的存储原理分析

根据代码理解HashMap存储数据原理

image.png

哈希表存储原理图

image.png

散列函数

散列函数(也就是hashCode()方法),通过散列函数获得key对象的哈希码,实际上就是建立起key值与int值映射关系的函数。这就好比每个人都有一个身份证号一样,无论是男是女,出生在何处,都可以通过身份证号来分辨,这就是把人的信息映射成一串数字的典型做法。散列函数和此类似,不过是把任意的Java对象,映射成一个int数值(散列均匀,尽量不重复),供哈希表使用。

另外,通过hashCode()方法获得的结果是一个很大的整数,我们的哈希表不可能提供那么大的存储空间,所以我们还需要对获得的哈希码值做处理。现实开发中,最常见的做法就是“取余法”,把获得的“哈希码值%哈希表长度”,这样得到的结果就肯定在哈希表索引的合法取值范围了。

散列碰撞

通过hashCode()方法,获得了一个散列均匀的哈希码值,但是不同的key对象获得哈希码值可能相同;另外,通过对不同的“哈希码值%哈希表长度”得到的索引值也可能相同,那就意味着在哈希表的某个索引位置需要存储多条数据,这就是所谓的散列碰撞问题。

目前比较通用的解决散列碰撞的方法,就是使用“数组+链表”组合的方式。当出现散列碰撞时,在该位置的数据就通过单链表的方式链接起来,这样一来数组中的每个元素维护的就是一个单链表啦。

总结:

散列碰撞情况一:不同的key通过hashCode()得到的hash值相同。

散列碰撞情况二:不同的hash值,通过hash值%数组长度得到的索引相同

也就是说:只要是在数组的同一个索引处,存储多条数据的情况都称为:散列碰撞

模拟HashMap的实现

接下来我们来基于JDK1.7来模拟HashMap的实现,本章节重点模拟HashMap的put()方法和get()方法,在进行模拟put()方法和get()方法的实现之前,我们先做好相关的准备工作。

首先创建一个Node节点类,Node节点类是HashMap的内部类,它有几个重要的属性:键对象(key) 、值对象(value)、键对象的hash值(hash)和下一个节点(next)。

代码实现如下:

class MyHashMap<K, V> {
// Node节点,是一个单链表
    private static class Node<K, V> {
        int hash; // 键对象的hash值
        K key; // 键对象
        V value; // 值对象
        Node<K, V> next; // 下一个节点
        // 构造方法
        public Node(int hash, K key, V value, Node<K, V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }
}

Node节点类实现完毕,我们继续添加HashMap中的两个重要属性:哈希表的Node[]数组(table)和存放元素的个数(size)属性,代码实现如下:

class MyHashMap<K, V> {
    // HashMap的核心数组结构,数组中的每一个元素都是一个链表
    private Node<K, V>[] table = new Node[16]; // 默认长度为16
    // 实际存储元素的个数
    private int size;
    // 此处省略Node节点的实现
}

本次模拟HashMap属于简化实现,此处并没有去考虑table数组的“扩容问题”,所以我们在声明table数组的同时并完成了数组的初始化操作,默认初始化长度16个空间大小。

  • 存储数据过程put(key,  value)

    准备工作完成之后,我们继续深入学习HashMap如何存储数据。此处的核心是如何产生hash值,该值用来对应数组的存储位置。

image.png

我们的目的是将“key-value”两个对象成对存放到HashMap的Node[]数组中,具体实现过程如下:

**【第一步】:判断key是否为null**

先判断一下要存储内容的key值是否为null,如果key为null,则将该内容存储到table数组的第一个位置。

**【第二步】** :获得key对象的hashcode

如果key不为null,则再去调用key对象的hashcode()方法,获得key对象的哈希值。

【**第三步】** :获得存储位置的下标

hashcode是一个整数,我们需要将它转化成[0,数组长度-1]范围的整数。我们要求转化后的hash值尽量均匀地分布在[0,数组长度-1]这个区间,减少“hash冲突”。

取模法:索引 = key的哈希值 % 数组长度

**【第四步】** :将**Node**对象添加到table数组中

当table[index]返回的结果为null时,则直接创建一个新的Entry对象添加到table[index]处。

当table[index]返回的结果不为null时,则判断链表中是否在相同key。如果存在相同的key,就用新的value代替老的value,也就是执行覆盖操作。如果不存在相同的key,那么新创建的Node对象将会储存在链表的表头,通过next指向原有的Node对象,形成链表结构(hash碰撞解决方案)。

代码实现如下:

```
public class MyHashMap<K, V> {
    // 此处省略HashMap的属性
    /**
     * 添加键值对
     * 如果存在相同的key,则返回被覆盖的value值
     * 如果不存在相同的key,则返回null
     */
    public V put(K key, V value) {
        // 第一步:如果 key 为 null,调用 putForNullKey 方法写入null键的值
        if (null == key) {
            return putForNullKey(value); // null总是放在数组的第一个链表中
        }
        // 第二步:获得key对象的hashcode,确保散列均匀
        int hashCode = key.hashCode();
        // 第三步:获取在table中的实际位置,也就是在数组中的下标
        int index = hashCode % table.length;
        // 第四步:将Node对象添加到table数组中
        // 如果table[index]不为 null,通过循环不断遍历链表查找是否在链表中有相同key
        for (Node<K, V> node = table[index]; null != node; node = node.next) {
            // 找到与插入的值的key相同的Node
            if (node.hash == hashCode && (node.key == key || key.equals(node.key))) {
                // 保存覆盖之前的value值
                V oldValue = node.value;
                // key值相同时直接替换value值
                node.value = value;
                // 结束方法,完成hashMap添加的操作
                return oldValue;
            }
        }
        // 如果table[index]为null或者key的hash值相同而key不同,则需要新增Node
        addNode(hashCode, key, value, index);
        return null;
    }
​
    /**
     * 添加元素节点
     */
    private void addNode(int hashCode, K key, V value, int index) {
        // 获取索引值为hash的Node对象
        Node<K, V> node = table[index];
        // 在table数组中新增Node对象
        table[index] = new Node<K, V>(hashCode, key, value, node);
        // 实际存放元素个数累加
        size++;
    }
​
    /**
     * 当key为null时,存放key所对应的value值。
     */
    public V putForNullKey(V value) {
        // 当数组的第一个元素,存在key为null时,直接覆盖以前的旧值即可
        for (Node<K, V> node = table[0]; null != node; node = node.next) {
            // 找到与插入的值的key相同的Node
            if (null == node.key) {
                // 保存覆盖之前的value值
                V oldValue = node.value;
                // key值相同时直接替换value值
                node.value = value;
                // 结束方法,完成hashMap添加的操作
                return oldValue;
            }
        }
        // 当数组的第一个元素,不存在key为null时,直追加元素即可
        addNode(0, null, value, 0);
        return null;
    }
    // 此处省略Node节点的实现
}
```

简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的。
  • 取数据过程get(key)

    实现步骤:

    我们需要通过key对象获得“键值对”对象,进而返回value对象。明白了存储数据过程,取数据就比较简单了。

    第一步】:判断key是否为null

    先判断一下要获取内容的key值是否为null,如果为null,则执行getForNullKey()方法,这个方法的作用就是将table数组的第一个位置的节点的value值取出来返回。

    第二步】:获得key对象的hashcode

    如果key不为null,则再去调用key对象的hashcode()方法,获得key对象的哈希值。

    【第三步】:获得存储位置的下标

    获得key的hashcode,通过hash()散列算法得到hash值,进而定位到数组的位置找到对应的链表。

    第四步】:在链表上挨个比较key对象

    调用equals()方法,将key对象和链表上所有节点的key对象进行比较,直到碰到返回true的节点对象为止。如果key对象和链表上的某个节点的key对象相同,则直接返回该节点对象的value对象值。如果链表遍历比较完毕,都没有遇到key对象和链表节点的key对象相同的情况,那么证明key对象对应的value对象不存在,直接返回null即可!

    代码实现如下:

    public class MyHashMap<K, V> {
        // 此处省略HashMap的属性
        /**
         * 根据key,获取key所对应的value值
         */
        public V get(Object key) {
            // 如果key是null,调用getForNullKey取出null的value 
            if (null == key) {
                return getForNullKey();
            }
            // 1.根据该 key的hashCode值计算它的 hash码 
            int index = key.hashCode() % table.length;
            // 2.直接取出table数组中指定索引处的值,
            for (Node<K, V> node = table[index]; null != node; node = node.next) {
                // 如果该 Entry 的 key和hash 与被搜索 key 相同 
                if ((node.hash == key.hashCode() && node.key == key) || key.equals(node.key)) {
                    return node.value;
                }
            }
            return null;
        }
    ​
        /**
         * 当key为null时,获取key所对应的value值
         */
        public V getForNullKey() {
            for (Node<K, V> node = table[0]; node != null; node = node.next) {
                if (null == node.key)
                    return node.value;
            }
            return null;
        }
        // 此处省略Node节点的实现
    }
    

    到此处,基于JDK1.7关于HashMap底层put()方法和get()方法的实现就讲解完毕,那么接下来我们来讲解关于HashMap常见面试题.

HashMap的性能优化

哈希表是一种优化存储的思想,具体存储元素的依然是其它的数据结构。前面我们通过“数组+链表”的方式来实现了哈希表,使其哈希表能同时兼备数组和链表的优点,它能在插入和查找时都具备良好的性能。虽然,哈希表的整体性能已经相当高,但是我们使用哈希表的时候,还是有很多地方值得优化,接下来我们就讲解几个哈希表的优化操作。

  • 散列表的容量设置为2的整数次幂

    把散列表的容量设置为2的整数次幂,就是为了加快散列计算以及减少散列冲突。

    为什么可以加快散列计算?因为& 运算属于位运算,是直接执行的二进制操作,因此&运算比%运算效率更高。当散列表的容量(length)为2的整数次幂时,执行“hash & (length - 1)”和“hash % length”的运算的结果相同,但是&运算的效率高于%运算,所以建议散列表的容量设置为2的整数次幂,代码验证如下:

    public class Test01 {
        public static void main(String[] args) {
            // 获得散列码值,值为:2998638
            int hashCode = "ande".hashCode();                  000000011
            // 计算得到2^14的结果,结果为:16384                 & 000111001
            int length = (int)Math.pow(2, 14);
            // &运算结果为:366                                  0000001110
            System.out.println(hashCode & (length - 1));                1
            // %运算结果为:366
            System.out.println(hashCode % length);
        }
    }
    

    为什么可以减少冲突?假设现在数组的长度length可能是偶数也可能是奇数。

    当length为偶数时,length-1为奇数,奇数的二进制最后一位是1,这样便保证了hash &(length-1) 的最后一位可能为0,也可能为1(这取决于hash的值),即&运算后的结果可能为偶数,也可能为奇数,这样便可以保证散列的均匀性。

    当 length为奇数的话,很明显length-1为偶数,它的最后一位是0,这样hash & (length-1)的最后一位肯定为0,即只能为偶数,这样任何 hash 值都只会被散列到数组的偶数下标位置上,这便浪费了近一半的空间。

    因此,length值为2的整数次幂,是为了使不同hash值发生碰撞的概率较小,这样就能使元素在散列表中均匀地散列。

  • 设置散列表的初始化容量

    当散列表中的元素越来越多的时候,散列碰撞的几率也就越来越高(因为数组的长度是固定的),从而导致链表过长,降低了散列表的性能,此时我们就需要对散列表进行扩容操作。

    那么Hashtable什么时候进行扩容呢?当执行put()操作的时候,如果Hashtable中存储元素的个数超过“数组长度* loadFactor”的结果(loadFactor指的是负载因子,loadFactor的默认值一般为0.75),那么就就需要执行数组扩容操作。

    所谓的扩容操作,就是把数组的空间大小扩大一倍,然后遍历散列表中元素,把这些元素重新均匀分散到扩容后的散列表中。例如,默认情况下,数组大小为16,那么当Hashtable中元素个数超过160.75=12的时候,就需要执行扩容操作,把数组的大小扩展为216=32,然后重新计算每个元素在数组中的位置,这是一个非常消耗性能的操作。

    为了避免扩容带来的性能损坏,建议使用散列表之前,先预测散列表需要存储元素的个数,提前为散列表中的数组设置合适的存储空间大小,避免去执行扩容的操作,进一步提升散列表的性能。

    例如:我们需要存储1000个元素,按照散列表的容量设置为2的整数次幂的思想,我们设置散列表的容量为1024更合适。但是0.75*1024 < 1000,需要执行消耗性能的扩容操作,因此我们设置散列表的容量为2048更加合适,这样既考虑了&的问题,也避免了扩容的问题。

    思考:当我们创建一个HashMap对象,设置哈希表的容量为15,请问HashMap对象创建成功后,哈希表的实际容量为多少呢???

  • 使用红黑树来优化存储

    如果散列表处理“碰撞”的时候,都是采用链表来存储,当碰撞的结点很多时,就会造成检索效率低下。所以我们可以对散列表的结构进一步优化,当碰撞结点较少时(例如:元素小于等于8个),采用链表存储;当较大时(例如:元素大于8个),则采用红黑树(下一章节就要学习)来存储,这样大大的提高了检索的效率。

image.png

总结:

当JDK1.7及以前: hash = 数组 + 单链表

当JDK1.8及以后: hash = 数组 + 单链表 + 红黑树

什么时候单链表编程红黑树?

当hash表中数组容量 >= 64 并且同一个索引处的单列表的节点个数>= 8时,单链表会变成红黑树。