通过手撸HashMap来了解底层原理

前言

        这一篇呢 , 我们来手写一个简单的HashMap,所谓HashMap,就是一个映射表。然后底层是数组加链表形式(Jdk1.7) 的存储形式 , 在Jdk1.8的时候 , 将这种存储形式更改成为了数组加链表加红黑树形式 , 相较于之前的改动是在链表达到一定阈值的时候 ,链表长度超过8时树化 . 将链表更改为查询速度更快的红黑树.

        今天我们主要从Jdk1.7入手 , 来实现一个简易版的HashMap  , 至于为什么是简易版的, 这里稍微做下说明, 原因是因为 , HashMap的源码中针对 put  get 还有扩容时候 , 采用了很多  位移、与运算、异或运算,  本篇咱们只需要做一个基础的即可 , 旨在了解HashMap的一个底层大致思路.

HashMap

        HashMap是Java中一中非常常用的数据结构,也基本是面试中的“必考题”。它实现了基于“K-V”形式的键值对的高效存取。JDK1.7之前,HashMap是基于数组+链表实现的,1.8以后,HashMap的底层实现中加入了红黑树用于提升查找效率。

手撸HashMap

  定义一个Map的接口类

/**
 * @author hxk
 * @version IMap: IMap.java, v 0.1 2022-07-07 14:49 hxk Exp $
 */
public interface IMap<K, V> {

    /**
     * put
     *
     * @param k key
     * @param v value
     * @return value 如果是已存在值 , 则返回 oldValue
     */
    V put(K k, V v);

    /**
     * get
     *
     * @param k key
     * @return value
     */
    V get(K k);

    /**
     * remove
     *
     * @param k key
     * @return value
     */
    V remove(K k);


    int size();

    /**
     * Map中数组结构主要数据类
     * @param <K>
     * @param <V>
     */
    interface Entry<K, V> {

        K getKey();


        V getValue();


        V setValue(V v);

    }


}

然后我们需要一个自定义实现类来继承该接口 , 并逐步实现里面的函数 , 在这之前我们先来看下,Jdk1.7的源码中的一些重要参数设定.

//初始容量是16,且容量必须是2的倍数
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

//最大容量是2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;

//负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;

static final Entry<?,?>[] EMPTY_TABLE = {};

//HashMap的主干是一个Entry数组,在需要的时候进行扩容,长度必须是2的被数
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;

//放置的key-value对的个数
transient int size;

//进行扩容的阈值,值为 capacity * load factor,即容量 * 负载因子
int threshold;

//负载因子
final float loadFactor;

        这里说一下threshold和loadFactor,threshold = capacity * load factor,即扩容的阈值=数组长度 * 负载因子,如果hashmap数组的长度为16,负载因子为0.75,则扩容阈值为16*0.75=12

  1. 负载因子越小,容易扩容,浪费空间,但查找效率高
  2. 负载因子越大,不易扩容,对空间的利用更加充分,查找效率低(链表拉长)        

自定义IHashMap类 , 并实现IMap接口 

@Getter
public class IHashMap<K, V> implements IMap<K, V>, Serializable {
    
     // 初始默认容量大小
    final static int DEFAULT_CAPACITY = 8;

    // 数组大小
    private int size = 0;

    // 数据存放处
    private Entry<K, V>[] entries = null;

    // 构造器, 初始化HashMap时指定HashMap默认初始容量大小
    public IHashMap() {
        entries = new Entry[DEFAULT_CAPACITY];
    }

    @Override
    public int size() {
        // HashMap 定义一个size 属性 , 每当put||remove时候对size值进行加减
        return size;
    }

    // 内部类 , 用来作为数组的实际存放数据的对象
    static class Entry<K, V> implements IMap.Entry<K, V> {

        K k;
        V v;
        Entry<K, V> next;

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

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

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

        @Override
        public V setValue(V v) {
            V oldV = this.v;
            this.v = v;
            return oldV;
        }
    }
}

put方法

一下是简化版的put方法 , 部分去掉/部分简化 , 还是那句话 , 该篇文章旨在了解HashMap底层原理

    @Override
    public V put(K k, V v) {
        // 拿key的hashcode 对entries.length -1 取模
        int index = k.hashCode() % (entries.length - 1);
        // 拿到当前下标的entry
        Entry<K, V> curEntry = entries[index];
        // 如果当前位置有值
        if (curEntry != null) {
            // 遍历当前索引位置链表
            while (curEntry != null) {
                if (k.equals(curEntry.k)) {
                    V oldV = curEntry.v;
                    curEntry.v = v;
                    return oldV;
                }
                curEntry = curEntry.next;
            }
            //链表中没有相同的元素,采用头插法,直接插在头部
            //元素进来的时候,让他先指向原来的数组上的值,然后再
            //把当前数组赋值给我们新的元素的next,这样就达到了插在头部的操作。
            entries[index] = new Entry<K, V>(k, v, entries[index]);
            size++;
            return v;
        }
        // 如果当前位置不存在侧直接进行插入,直接插入的数据是没有next的
        entries[index] = new Entry<K, V>(k, v, null);
        size++;
        return v;
    }

        首先我们要明白  , put方法中,我们通过传入的K-V值构建一个Entry对象,然后根据key的hash 对 数组长度-1 取模 后的index下标来判断它应该被放在数组的那个位置 , 但是 当两个key算出来HashCode相同的情况时,就会产生冲突, 也就是Hash冲突. 

        目前我们的Map类中,底层的数组长度默认值20(真正的HashMap默认值是16),当存入的数据足够多并且不进行扩容的话,Hash碰撞是必然的。所谓Hash碰撞,就是比如说两个key明明是不同的,但是经过hash算法后,hash值竟然是相同的。那么另一个key的value就会覆盖之前的,从而引起错误。

        这个时候就运用到了链表 , 当出现Hash冲突时 , 就直接在链表中加一个节点, 当下次来取元素时候, 我们首先遍历这个链表(长度为1也视作链表),如果存在key与我们存入的key相等,则替换并返回旧值;如果不存在,则将新节点插入链表。

        但是如果想要提高HashMap的效率,最重要的就是尽量避免生成链表,或者说尽量减少链表的长度 , 想要达到这一点,我们需要Entry对象尽可能均匀地散布在数组table中,且index不能超过table的长度,很明显,之前所说的取模运算就很符合我们的需求  int index = k.hashCode() % table.length。  关于这一点,Jdk1.7的HashMap中也使用了一种效率更高的方法——通过&运算完成key的散列.

static int indexFor(int h, int length) {
	// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
	return h & (length-1);
}

插入链表又有两种做法:头插法和尾插法。

        如果使用尾插法,我们需要遍历这个链表,将新节点插入末尾;如果使用头插法,我们只需要将table[index]的引用指向新节点,然后将新节点的next引用指向原来table[index]位置的节点即可,这也是jdk1.7的HashMap做法。而jdk1.8则使用的是尾插法.

        原因是JDK1.7中HashMap在扩容时,对每个元素的rehash之后,都会插入到新数组对应索引的链表头,这就导致原链表顺序为A->B->C,扩容rehash之后的链表可能为C->B->A,元素的顺序发生了变化。如果在并发场景下,这种扩容时可能会导致出现循环链表的情况。所以JDK1.8从头插入改成尾插入元素的顺序不变,是为了避免出现循环链表的情况。
 

Get方法

        调用get方法时,我们根据key的hashcode计算它对应的index,然后直接去table中的对应位置查找即可,如果有链表就遍历。整体思路跟put基本一致,  其实从一个put就能看出来一大部分HashMap对于底层数据的处理方式了 , get只不过是反转了一下.

    @Override
    public V get(K k) {
        // 拿key的hashcode 对entries.length -1 取模
        int index = k.hashCode() % (entries.length - 1);
        // 拿到当前下标的entry
        Entry<K, V> curEntry = entries[index];
        // 遍历链表
        while (curEntry != null) {
            if (k.equals(curEntry.k)) {
                return curEntry.v;
            }
            // 指针下移
            curEntry = curEntry.next;
        }

        return null;
    }

Remove方法

    @Override
    public V remove(K k) {
        // k 的hashcode 对 entries.length 取模
        int i = k.hashCode() & (entries.length - 1);
        // 定位到所在位置
        Entry<K, V> entry = entries[i];

        // 如果k直接命中当前索引位置的值, 那么直接拿next覆盖当前值, 无论之null 或者有值都可以覆盖这里
        if (entry.k.equals(k)) {
            // 指针后移
            entries[i] = entries[i].next;
            size--;
            return entry.v;
        }
        // 遍历链表
        while (entry.next != null) {
            if (entry.next.k.equals(k)) {
                V oldV = entry.v;
                entry.next = entry.next.next;
                size--;
                return oldV;
            }
            // 指针后移
            entry = entry.next;
        }
        return null;
    }

        移除某个节点时,如果该key对应的index处没有形成链表,那么直接置为null。如果存在链表,我们需要将目标节点的前驱节点的next引用指向目标节点的后继节点。由于我们的Entry节点没有previous引用,因此我们要基于目标节点的前驱节点进行操作,即:

current.next = current.next.next;

current代表我们要删除的节点的前驱节点。

还有一些简单的isEmpty()等方法都很简单,这里就不再赘述。现在,我们自定义的IHashMap基本可以使用了。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值