谈谈HashMap

谈谈HashMap

HashMap是基于哈希表的Map接口的非同步实现。此实现提供所有可选的映射 操作,并允许使用null值和null键。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。

HashMap底层存储原理

谈到底层存储,这里要聊的就是两个地方,一个是存储的数据结构,另一个就是存储的算法

HashMap的数据结构:

Java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap也不例外。 HashMap实际上是一个“链表散列”的数据结构,即数组和链表的结合体,关于数组和链表,这里不作过多解释,如有需要可见我的另一篇文章,数组和链表的区别

HashMap的Hash算法

Hash算法也被称为散列算法,就是把任意长度值(Key)通过散列算法变换成固定长度的key(地址)通过这个地址进行访问的数据结构,它通过把关键码映射到表中一个位置来访问记录,以加快查找的速度,下面这张图。

image-20210128144639922

  • 过程是先将lies中的四个字母的ASCII码进行求和,得到429,然后将429进行取模20,得到9,然后就把lies这个值放到这一段连续的存储单元的9号位置上面

那么这里引申出两个问题:既然进行了hash算法算出来这个这个hash值,直接放到对应的下标位置就好了,为什么要取个模?如果当一个新的key经过计算取模后得到的下标位置已经有值了怎么办?

  • 第一个问题,首先我们要知道的是,数组是用一段连续的存储单元来存储数据的,通常我们通过散列算法得到的值都会比较大,如果我们给每一个值都分配一块存储空间,那么我们需要的连续的存储空间就会更加的大,数组对内存要求又会比较高,所以这样不可取,需要取个模才行。
  • 第二个问题,取模后,原本比较松散的数据就会变得紧凑,如果出现了目标地址已经有了数据怎么办,其实这就是大家常说的哈希碰撞或哈希冲突,下图解释了如果出现了哈希碰撞,是怎么解决的。

image-20210128151232487

存储时,如果出现hash值相同的key,此时有两种情况。

  • 如果key相同,则覆盖原始值;
  • 如果key不同(出现冲突),则将当前的key-value放入链表中
    • 获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值。
    • 理解了以上过程就不难明白HashMap是如何解决hash冲突的问题,核心就是使用了数组的 存储方式,然后将冲突的key的对象放入链表中,一旦发现冲突就在链表中做进一步的对比。

手写一个自己的HashMap

在知道了HashMap的底层实现原理后,我们接下来手写一个自己的HashMap

首先定义一个Map接口

/**
 * @author PengHuAnZhi
 * @createTime 2021/1/28 15:28
 * @projectName JavaSEReview
 * @className Map.java
 * @description TODO
 */
public interface Map<K, V> {
    //这里实现基础的put,get和size方法
    V put(K k, V v);

    V get(K k);

    int size();

    //定义一个内部接口,用于哈希冲突后定义链表节点
    interface Entry<K, V> {
        K getKey();

        V getValue();
    }
}

定义HashMap类实现Map

/**
 * @author PengHuAnZhi
 * @createTime 2021/1/28 15:30
 * @projectName JavaSEReview
 * @className HashMap.java
 * @description TODO
 */
public class HashMap<K, V> implements Map<K, V> {
    private Entry<K, V> table[] = null;
    private int size = 0;

    public HashMap() {
        this.table = new Entry[16];
    }

    /**
     * @param k key
     * @param v value
     * @return 返回当前节点
     * @description 通过hash算法算出key的哈希值,再对取取模算出index数组下标,找到下标对象,判断当前对
     * 象是否为空,如果为空,说明可以直接存储,如果不为空,表示出现hash冲突,就需要用到链表
     */
    @Override
    public V put(K k, V v) {
        int index = hash(k);
        Entry<K, V> entry = table[index];
        if (null == entry) {
            //可以直接存储
            table[index] = new Entry<>(k, v, index, null);
            //新增数据,扩容
            size++;
        } else {
            //出现冲突,直接将当前节点作为新节点的next,将新节点插入到原始位置
            table[index] = new Entry<>(k, v, index, entry);
            //新增数据,扩容
            size++;
        }
        return table[index].getValue();
    }

    /**
     * @param k key
     * @return 返回key应该存储位置的下标
     * @description 计算出key应该存储位置的下标
     */
    private int hash(K k) {
        /*
        这里直接取模即可,但是真正的HashMap底层是通过移位来实现取余操作的,移位的性能会比取模操作高很多,
        但是这里就用取模即可,由于hashcode算出来的值可能为负数,数组下标我们应该将其变为正数。
         */
        int index = k.hashCode() % 16;
        //判断是否小于0
        return index >= 0 ? index : -index;
    }

    /**
     * @param k key
     * @return value
     * @description 通过key算出hash值对应的数组下标,判断当前对象是否为空,如果不为空,判断是否相等,如果不相等,判断next是否相等,直到找到相等的值或者next为null
     */
    @Override
    public V get(K k) {
        if (size == 0) {
            return null;
        }
        int index = hash(k);
        Entry<K, V> entry = findValue(table[index], k);
        return entry == null ? null : entry.getValue();
    }

    /**
     * @param entry 对应KV的节点
     * @param k     key
     * @return 返回
     * @description
     */
    private Entry<K, V> findValue(Entry<K, V> entry, K k) {
        if (k.equals(entry.getKey()) || k == entry.getKey()) {
            return entry;
        } else {
            //递归遍历是否有指定节点
            if (entry.next != null) {
                return findValue(entry.next, k);
            }
        }
        return null;
    }

    /**
     * @return 返回map大小
     */
    @Override
    public int size() {
        return size;
    }

    class Entry<K, V> implements Map.Entry<K, V> {
        K k;
        V v;
        int hash;
        Entry<K, V> next;

        public Entry(K k, V v, int hash, Entry<K, V> next) {
            this.k = k;
            this.v = v;
            this.hash = hash;
            this.next = next;
        }

        @Override
        public K getKey() {
            return k;
        }

        @Override
        public V getValue() {
            return v;
        }
    }
}

测试

public static void main(String[] args) {
    HashMap<String, String> hashMap = new HashMap<>();
    hashMap.put("Phz", "我是彭焕智");
    hashMap.put("Rfz", "我是人贩子");
    System.out.println(hashMap.get("Phz"));
    System.out.println(hashMap.get("Rfz"));
}

image-20210128162435157

探索性能

循环插入大量重复数据

public static void main(String[] args) {
    HashMap<String, String> hashMap = new HashMap<>();
    for (int i = 0; i < 1000; i++) {
        hashMap.put("Phz", "我是彭焕智");
    }
    System.out.println(hashMap);
}

断点分析

image-20210128163158492

说明了一个什么问题?我们的数组仅仅只有16个位置,但是在2号位置上面存储的链表长度是巨大的,也就是说这个哈希冲突也是非常严重的,就会造成一种什么问题呢?

image-20210128163945516

回顾链表和数组的差异,数组有查询快插入慢,链表有插入快查询慢的特点。如果HashMap链表十分巨大,那我们的HashMap的存在的意义又在哪呢?

这也是jdk 1.8以后HashMap用到了红黑树的根本原因所在!也就是为了解决链表过长查询效率过低的问题。

查询HashMap源码寻找蛛丝马迹

image-20210128170130844

找到treeifyBin方法,发现有一个TREEIFY_THRESHOLD常量,也就是当节点个数超过这个值以后,执行treeifyBin方法

image-20210128172037747

treeifyBin方法中发现一个TreeNode对象

image-20210128170228397

找到这个类就能看到我们的红黑树节点了

image-20210128172234917

那么为什么会定义一个阈值8来限制使用红黑树,而不在一开始就使用呢?

  • 那么我们这时候就要知道红黑树的实现原理了,“左中右,对应小中大”

image-20210128172443336

  • 每一个节点的左叶子节点必定比父节点小,右节点必定比父节点大,根据这个特点我们很明显能看出查询数据会很快,下面这个动图可以很明显感觉到插入数据的繁琐。

在这里插入图片描述

  • 所以结论就是,“鱼与熊掌不可兼得”
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值