【java集合系列】手写HashMap

前言

HashMap,键值对的一种数据结构,给定唯一的key,获取value。之前2篇文章说过ArrayList在随机访问的时候效率特别高。LinkedList做随机插入删除时候效率高。而jdk中的HashMap集成了这2种优点。

话说现在jdk11出来了。但我们工作中大多用的是jdk7。本人写的这一篇HashMap是在看了jdk7与jdk8后写的。2者的思想都有参考。(参考了jdk思想。并不是对jdk源码的讲解,希望不要对你造成误会。)

题外话:另外,希望还在用jdk8以下的,新开的项目尽量使用jdk8吧,对java的优化是一个质的飞跃。众所周知,jdk5开始是一个质的飞跃(引入了泛型,foreach,自动装箱/拆箱,变长参数等等),但jdk8对锁与集合性能有大幅提升。并且加入stream的处理集合。还有lamdba表达式的加入,使你写函数接口(如Runable)再也不用写那么冗长的代码了。

本文所写的HashMap也只是实现的一种简易版键值对集合帮助大家理解。在命名方面有部分参考过jdk的命名规则!

关注点
不只是HashMap,在任何集合中,都应关注以下几点:

是否可以为空:key允许有一个null,value你随意
是否有序(插入时候的顺序和读取时是否一致): hash码具有随机性,所以无序
是否允许重复:   key肯定不行,value你随意
是否线程安全: 线程不安全(jdk8以下会出现链表成环)
特性: 时间复杂度为常数级

原理

内部一个Entry(内部定义的类)数组,将新添加的元素计算hash值后与数组长度取余。然后放到数组对应的下标位置。如果计算出来的hash一样,此时对应的数组下标已有元素,怎么办??如LinkedList(此处为单向链表),以链表的方式追加到链表头部。如果你的数据就是这么巧,所有数据的下标都在同一个位置,那你就一直链下去吧。此时get(key)的时间复杂度则与链表相当,但这种情况不多(后文会详细分析hash算法)。但jdk8已对此处优化,如果链表的长度>=8,则转化为红黑树(红黑树的特性自行查找资料吧)。二叉查找树的平均时间复杂度是O(LogN)效率远高于链表的O(N)。(个人认为hash随机性很强,分布均匀,且数组扩容,所以链表长度>8的情况应该不多,而转化红黑树的代价也是有的,空间上的占用与链表转红黑树的性能开销)

实现

构造器:首先,定义我们的HashMap,内部存储我们定义Entry类。如上原理:我们默认数组的长度是16,有个LOAD_FACTOR负载因子,是扩容时候用的,默认为0.75,即当元素>16*0.75=12时,发生扩容。我们将这个12定义一个变量threshold阈值,每次扩容后修改这个值。如下实现:

/**
 * @author HK
 *    jdk7中,喜欢命名为Entry,而jdk8中,喜欢命名为Node
 */
public class HkHashMap {

    /**
     * table的默认初始长度
     */
    static final int DEFAULT_INITIAL_CAPACITY = 16;

    /**
     * 负载因子,当达到16*0.75时触发扩容
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    Entry[] table;

    int size;

    /**
     * 阈值,当达到此值时扩容。threshold=DEFAULT_INITIAL_CAPACITY*DEFAULT_LOAD_FACTOR
     */
    int threshold;


    /**
     * 无参构造器,当然了,指定初始长度和负载因子更好一些。
     */
    public HkHashMap() {
        threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
        table = new Entry[DEFAULT_INITIAL_CAPACITY];
    }



    //map的存储单元
    static class Entry{
        int hash;
        Object key;
        Object value;
        Entry next;
        public Entry(int hash, Object key, Object value, Entry next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }




    }

}

重点看一下这个内部类Entry,通过key,计算出hash值,来定位存储位置,因为有hash碰撞,所以用单链表的方式实现,定义next节点。如果table数组上某下标只有一个元素,那么该元素的next则为null。

增put(key)

其实,修改一个key的值也是用的这个api。既HashMap中只有增删查,如果key相同,则覆盖旧的value。接下来,着重看下这个put(key)方法

首先拿到key,获取key的hashCode码(jdk的实现会重写hashCode与equals),hashCode是Object类的一个native方法(相同对象不同虚拟机的值可能会不同),返回一个int类型的值。这个int类型的值不确定性很强,长度也不固定。如下

public static void main(String[] args) {
        String str =  "66";
        Integer i = 66;
        System.out.println("String的HashCode:"+str.hashCode());//1728
        System.out.println("Integer的HashCode:"+i.hashCode());//66
    }

所以,我们需要一个hash算法,来算出一个具有很强的随机性和长度相对固定的数字。此处,我们参考jdk8中,将hashCode高16与本身做异或运算。如下:可以看到,如果key是null,固定放到table[0]的位置

/**
     * HashCode做一次hash运算。以此更加保证散列性
     */
    static final int hash(Object key) {
        int h;
        //将hashCode右移16位与hashCode做“异或”运算,即高16位^hashCode(参考jdk8)。如果key为null,固定放到table[0]的位置
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

ok,有了hash值后,如何确定在数组上的位置呢?而又得保证在数组上的位置比较均匀的分布。我们将这个hash值对数组的长度取余,余数 = hash%table.length。然后将这个key对于的Entry放到table[余数]的位置。

hashMap中扩容有一个规定,table会扩容为原来的2倍,且建议table.length为2的N次幂。为什么这样呢?我们来看以下,2的N次幂数,比如8,16,32,他们的2进制分别是1000,10000,100000,将它们减1则变为111,1111,11111。

用下图来做一个讲解:随便添加一个key,“我是key”的hash值算出来结果假如是 int hash = “555”

在这里插入图片描述
我们进行与运算后,结果就是hash值为555这个数的二进制后4位。而一个4位二进制数永远也不可能大于15。同理,当table的长度变为32位时,31的二进制就是11111。此时与运算后结果肯定是一个小于等于31的数。

因此,如果hashMap长度不是2的N次幂,那么做与运算将又糟糕的结果。假如上图红色位置有一个是0,那么&操作后,将有一位永远是0,此时,table数组上有位置永远不可能存放元素,**或者说,如果红色位置0特别多的话,hashMap将只有一俩个table位置存放了数据,性能大打折扣!!!**之前没注意源码中有这个的优化之处,假如人工将初始长度改为不是2的N次幂,则会自动调整为2的N次幂(这句红色是修改的文章,之前误人子弟不好意思)。

下面代码我们定义出通过hash找到再table数组位置的方法,即类似求余数的方法:

/**
     * 任何一个key,都需要找到hash后对应的哈希桶位置
     */
    static int findBucketIndex(int h, int length) {
        //求余数的算法的结果是一样的。
        return h & (length-1);
    }

此时,我们再来写put方法

/**
     * 添加元素,如果key已存在,则替换
     */
    public Object put(Object key,Object value){
        if(null==key){
            return putNullKey(value);
        }
        int h=hash(key);
        int i = findBucketIndex(h,table.length);
        //如果已经有这个key,则替换
        for (Entry e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == h && ((k = e.key) == key || key.equals(k))) {
                Object oldValue = e.value;
                e.value = value;
                return oldValue;
            }
        }
        addEntry(h, key, value, i);
        return null;
    }

    void addEntry(int hash, Object key, Object value, int bucketIndex){
        Entry e = table[bucketIndex];
        table[bucketIndex] =new Entry(hash,key,value,e);
        size++;
        if(size>threshold){
            resize(table.length*2);
        }
    }

如上代码,其中,第5~7行,是对key位null的Entry做处理,我们将这个Entry固定放到table[0]的位置上,

第8行是通过key计算hash值,然后通过hash值与table的长度,计算这个key在hash桶中的位置。

11~18行是HashMap的修改操作,即如果这个key已存在,则进行替换操作。(定位到hash桶后,遍历这个位置的单向链表)。

jdk中的hashMap即put成功返回null,如果key是替换操作,则返回旧的值。

看一下23~30行的addEntry方法。

24行:将原hash桶的位置的Entry赋予e元素。显然如果原先是空位置,则这个e是null。

25行:新添加的这个元素放到table[i]的位置上,原先的元素链接到现在位置的下一个元素。

27~29为扩容相关。可见,当元素数量大于阈值时,扩容为原先的2倍。扩容详细分析,写最后边👇(扩容在不同的jdk中实现也是大不相同)

/**
     * hashMap允许键为空,对这个空键单独处理
     */
    private Object putNullKey(Object value){
        //如果已经有这个Null的Key,则替换
        for (Entry e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                Object oldValue = e.value;
                e.value = value;
                return oldValue;
            }
        }
        addEntry(0, null, value, 0);
        return null;
    }

这个是对key为null的情况单独处理,不解释!

查找get(key)

有了对put方法的描述,get就相对简单许多了。核心思想就是:根据key,先找到hash桶的位置,然后遍历hash桶位置上的链表,如果找不到,返回null。不做过多解释。看如下代码即可!

/**
     *     获取元素
     */
    public Object get(Object key){
        int h = hash(key);
        for(Entry e = table[findBucketIndex(h,table.length)];e!=null;e=e.next){
            if(e.hash==h&&(key==e.key||key.equals(e.key))){
                return e.value;
            }
        }
        return null;
    }

删除remove(key)

根据key删除一个元素,核心思想是定位这个key再table数组的位置,定位到后,遍历这个位置的链表,找到Entry e 后,切断e与前后链表的关系,将前后节点连接起来,与LinkedListd的核心思想一样

细节问题,代码注释已说明,不再阐述。如下:

public Object remove(Object key){
        return removeEntryForKey(key);
    }

    private Entry removeEntryForKey (Object key){
        int hash = hash(key.hashCode());
        int i = findBucketIndex(hash,table.length);
        //先定义一个prev表示待删除的元素的上一个
        Entry prev = table[i];
        Entry e=prev;
        while(e!=null){
            //定位出e的下一个元素,当找到我们要删除的元素时,将链表切断,将prev和next连接即可。参考linkedList的删除即可
            Entry next = e.next;
            //在链表上定位到这个元素,先判断hash值是否相等,再判断key的equals是否相等!注意,这里如果==返回true则说明是一个引用,这样可省略equals的判断。
            if(e.hash==hash&&   (e.key==key||(key!=null&&key.equals(e.key))) ){
                size--;//此时已有元素被定位到,注意while外面是判断了hash值相等的情况。hash值相等时候equals不一定相等。
                //这里,还得判断一个东西,即定位到的这个Entry是否是table[i],因为table[i]上的删除和链表上的删除有区别
                if(prev==e){
                    table[i]=next;//尽管则个next可能为空
                }else{
                    prev.next=next;//如果不是table位置的,则是链表后边的元素,此时,将prev的下一个置为e的next即可,此时,e已被切断,e的前后Entry被连接起来
                }
                return e;
            }
            //上边的if没进去,则执行下一次循环,指针向后移动一位!
            prev=e;
            e=next;
        }
        //直到while结束,如果没找到元素,return null
        return null;
    }

扩容

ok,接下来,我们说一下扩容,扩容再jdk8中与之前版本有较大区别,jdk8解决了链表成环的问题,且jdk8扩容中还有红黑树,所以肯定会有较大区别。我们主要讲以下jdk7中的扩容(头插入,即扩容后会将原链表倒置)。

/**
     * 扩容,传入新容量
     */
    void resize(int newCapacity){
        Entry[] newTable = new Entry[newCapacity];
        //将所有旧元素换到新table中start
        Entry[] oldTable = table;
        //遍历所有的位置
        for (int j = 0; j < oldTable.length; j++){
            Entry e = oldTable[j];
            if(null!=e){
                oldTable[j]=null;//循环结束后,会将oldTable引用全部置空,GC回收
                do{
                    Entry next = e.next;//先记住e的链表下一个是谁
                    int i = findBucketIndex(e.hash,newTable.length);//找到新的哈希桶位置
                    e.next=newTable[i];//注意这里是循环,newTable[i]上是可能有元素的。将现在newTable[i]上的元素置为e的下一个,
                    newTable[i]=e;//赋值,所以,jdk7中的扩容是链表头插入。
                    e=next;//下一次循环使用
                }while(null!=e);
            }
        }
        table=newTable;
        //将所有旧元素换到新table中end
        threshold=(int)(newCapacity*DEFAULT_LOAD_FACTOR);
    }

定义一个新的数组,newTable,扩容传入的参数一般是原数组的2倍长度

此时,我们需要从旧数组中挨个位置来遍历,从table[j]开始入手,对table[j]上有hash碰撞的Entry再进行单独计算hash桶,重新放到新数组的新位置。(因为原先长度在一个Hash桶的那些元素,现在不一定在一个hash桶中)

最后,将table引用到newTable

阈值重新计算,方便下一次扩容。

总结:

如果你对数据量有一个预估值,建议在创建Map的时候指定长度,以减少hashMap的扩容!扩容是一个很消耗性能的操作。最好自己手工指定为2的N次幂。
HashMap线程不安全,例如链表成环问题,只有在多线程情况下出现。但这并不是jdk设计的缺陷。所以如果你的集合要在多线程下访问,请禁止使用HashMap。建议使用Juc包下的HashMap。有关ConcurrentHashMap的详细讲解,会在后续多线程并发模块给大家详细讲解。个人认为hashTable应该淘汰了!

疑问

hashMap中modCount变量是干什么的?答:java中所有数据结果都有此变量,主要用在fail—fast快速失败。即遍历时并发修改集合的时候,通过此变量可判断在遍历时是否进行修改。
jdk源码中table数组变量为什么是transient修饰的?我们知道,此修饰符指序列化时忽略此字段。HashMap为什么要进行此设置呢?答:因为hashMap严重依赖hashCode,然而不同虚拟机的hashCode并不相同!意思就是A机器上序列化后的集合,到B机器上反序列化后,HashMap极有可能已不能正常使用。因为hash算法已无法定位到table上的位置!
对于上一个疑问,hashMap的解决方式就是重写了自己序列号的方式writeObject方法来进行自己的序列号方式。仅将key和value写到了一个文件中,并不参与hash的存储!还请大家自行详细研究吧!

完整代码

package my.hashmap.mymap.Utils;

import lombok.Data;

@Data
public class HkHashMap <K,V>{

    /**
     * table的默认初始长度
     */
    static final int DEFAULT_INITIAL_CAPACITY = 16;

    /**
     * 负载因子,当达到16*0.75时触发扩容
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    private Entry<K,V>[] table;

    int size;

//    K key;
    V value;
    /**
     * 阈值,当达到此值时扩容。threshold=DEFAULT_INITIAL_CAPACITY*DEFAULT_LOAD_FACTOR
     */
    int threshold;


    /**
     * 无参构造器,当然了,指定初始长度和负载因子更好一些。
     */
    public HkHashMap() {
        threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
        table = new Entry[DEFAULT_INITIAL_CAPACITY];
    }

    /**
     *     获取元素
     */
    public V get(K key){
        int h = hash(key);
        for(Entry<K,V> e = table[findBucketIndex(h,table.length)];e!=null;e=e.next){
            if(e.hash==h&&(key==e.key||key.equals(e.key))){
                return e.value;
            }
        }
        return null;
    }


    /**
     * 添加元素,如果key已存在,则替换
     */
    public V put(K key,V value){
        if(null==key){
            return putNullKey(value);
        }
        int h=hash(key);
        int i = findBucketIndex(h,table.length);
        //如果已经有这个key,则替换
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            K k;
            if (e.hash == h && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                return oldValue;
            }
        }
        addEntry(h, key, value, i);
        return null;
    }

    public Object remove(K key){
        return removeEntryForKey(key);
    }

    private Entry removeEntryForKey (K key){
        int hash = hash(key.hashCode());
        int i = findBucketIndex(hash,table.length);
        //先定义一个prev表示待删除的元素的上一个
        Entry prev = table[i];
        Entry e=prev;
        while(e!=null){
            //定位出e的下一个元素,当找到我们要删除的元素时,将链表切断,将prev和next连接即可。参考linkedList的删除即可
            Entry next = e.next;
            //在链表上定位到这个元素,先判断hash值是否相等,再判断key的equals是否相等!注意,这里如果==返回true则说明是一个引用,这样可省略equals的判断。
            if(e.hash==hash&&   (e.key==key||(key!=null&&key.equals(e.key))) ){
                size--;//此时已有元素被定位到,注意while外面是判断了hash值相等的情况。hash值相等时候equals不一定相等。
                //这里,还得判断一个东西,即定位到的这个Entry是否是table[i],因为table[i]上的删除和链表上的删除有区别
                if(prev==e){
                    table[i]=next;//尽管则个next可能为空
                }else{
                    prev.next=next;//如果不是table位置的,则是链表后边的元素,此时,将prev的下一个置为e的next即可,此时,e已被切断,e的前后Entry被连接起来
                }
                return e;
            }
            //上边的if没进去,则执行下一次循环,指针向后移动一位!
            prev=e;
            e=next;
        }
        //直到while结束,如果没找到元素,return null
        return null;
    }


    /**
     * hashMap允许键为空,对这个空键单独处理
     */
    private V putNullKey(V value){
        //如果已经有这个Null的Key,则替换
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null) {
                V oldValue = e.value;
                e.value = value;
                return oldValue;
            }
        }
        addEntry(0, null, value, 0);
        return null;
    }

    void addEntry(int hash, K key, V value, int bucketIndex){
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] =new Entry(hash,key,value,e);
        size++;
        if(size>threshold){
            resize(table.length*2);
        }
    }

    /**
     * 扩容,传入新容量
     */
    void resize(int newCapacity){
        Entry[] newTable = new Entry[newCapacity];
        //将所有旧元素换到新table中start
        Entry[] oldTable = table;
        //遍历所有的位置
        for (int j = 0; j < oldTable.length; j++){
            Entry e = oldTable[j];
            if(null!=e){
                oldTable[j]=null;//循环结束后,会将oldTable引用全部置空,GC回收
                do{
                    Entry next = e.next;//先记住e的链表下一个是谁
                    int i = findBucketIndex(e.hash,newTable.length);//找到新的哈希桶位置
                    e.next=newTable[i];//注意这里是循环,newTable[i]上是可能有元素的。将现在newTable[i]上的元素置为e的下一个,
                    newTable[i]=e;//赋值,所以,jdk7中的扩容是链表头插入。
                    e=next;//下一次循环使用
                }while(null!=e);
            }
        }
        table=newTable;
        //将所有旧元素换到新table中end
        threshold=(int)(newCapacity*DEFAULT_LOAD_FACTOR);
    }



    /**
     * HashCode做一次hash运算。以此更加保证散列性
     */
    static final int hash(Object key) {
        int h;
        //将hashCode右移16位与hashCode做“异或”运算,即高16位^16位(参考jdk8)。如果key为null,固定放到table[0]的位置
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

    /**
     * 任何一个key,都需要找到hash后对应的哈希桶位置
     */
    static int findBucketIndex(int h, int length) {
        //求余数的算法的结果是一样的。位运算效率高(装逼一点)
        return h & (length-1);
    }


    //map的存储单元
   private static class Entry<K,V>{
        int hash;
        K key;
        V value;
        Entry next;
        public Entry(int hash, K key, V value, Entry next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }

}

测试

public class MyMap {

    public static void main(String[] args) {
       HkHashMap<String,Person> myMap = new HkHashMap();
        myMap.put("张三", new Person("张三",20) );
        myMap.put("李四", new Person("李四",21) );
        myMap.put("王五", new Person("王五",22) );
        myMap.put("赵六", new Person("赵六",23) );

        myMap.put("1", new Person("张三",20) );
        myMap.put("2", new Person("李四",21) );
        myMap.put("3", new Person("王五",22) );
        myMap.put("4", new Person("赵六",23) );

        myMap.put("5", new Person("张三",20) );
        myMap.put("6", new Person("李四",21) );
        myMap.put("7", new Person("王五",22) );
        myMap.put("8", new Person("赵六",23) );

        myMap.put("9", new Person("张三",20) );
        myMap.put("10", new Person("李四",21) );
        myMap.put("11", new Person("王五",22) );
        myMap.put("12", new Person("赵六",23) );
        myMap.remove("1");

        System.out.println("张三的年龄是:"+myMap.get("张三").getAge());
        System.out.println("赵六的年龄是:"+myMap.get("赵六").getAge());
    }
}

输出结果:

Connected to the target VM, address: '127.0.0.1:14375', transport: 'socket'
张三的年龄是:20
赵六的年龄是:23
Disconnected from the target VM, address: '127.0.0.1:14375', transport: 'socket'

随着put元素的增加,resize后 threshold也会变大。
在这里插入图片描述
参考文章:
https://www.cnblogs.com/hkblogs/p/9151160.html

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值