HashMap以及源码详解

HashMap实现接口以及继承类

在这里插入图片描述
实现了Map,Cloneable,Serializable接口,继承自AbstractMap类。
允许 null 值和 null 键,无序,不允许重复的集合


HashMap底层结构

HashMap底层接口是哈希表,也就是所谓的散列表。
简单介绍一下散列表,散列表的出现是为了解决链表和数组的缺陷,链表增删快,查询慢,数组查询快,增删慢。而散列表基于数组和列表进行演变,使得查询和增删的速度都非常快。

散列表的结构如下。
在这里插入图片描述

hashMap中的散列表是用数组+链表+红黑树去实现的
在这里插入图片描述

好的散列方式,会把数据散列到不同的位置,哪怕散列到同一个位置(这就是所谓的哈希冲突),我们可以把它链起来变成链表(Java采用链地址法),只要这个链表足够的短,我们就可以最快的查询数据,因为遍历两三个节点的时间非常短,近似于O(1)。当链表足够长( 链表长度 >= 8)并且,节点足够多(节点数 >= 64)的时候,我们就把当前的链表变成红黑树。

(为什么节点 >=8 才变成红黑树,<=6变成链表? 因为根据泊松分布,当节点树大于等于 8 的时候,红黑树查询会比链表查询要快,而当节点数小于等于 6 的时候,会链表查询回避红黑树要快。7的时候是相当。)


HashMap常用方法以及源码解析

简单介绍以下变量以及初始值:
HashMap的最大容量(MAXIMUM_CAPACITY)为2的30次方
HashMap的默认负载因子(DEFAULT_LOAD_FACTOR)为0.75.
HashMap 的 threshold(阈值):当元素临近阈值(threshold)的时候我们就要进行扩容,扩容就意味着我们要重新hash要重新分配位置,元素越多,那么资源消耗就越大,所以当元素个数(size) = 容量(capacity) * 负载因子(loadfactor)时,就要进行扩容(resize())。

HashMap构造器

在这里插入图片描述
HashMap构造器有4个方法,除了第四个将Map集合添加到此集合之外,
其余的方法,在底层全部调用第一个构造方法,传入一个整形和一个浮点型,整形对应哈希表数组的大小,浮点型对应的时负载因子。
在这里插入图片描述
并且有趣的是我们经过查看源码发现,任何一个构造器只是判断以下初始化大小和负载因子的合法性判断并且赋值,并没有对数组进行初始化!因为创建归创建,有没有进行存储还两说,如果不存那么就会浪费16个格子的存储空间
那么什么时候才进行初始化?在put的时候!再进行创建!
在这里插入图片描述
如果当前的table 为null ,那么就进行resize()

默认初始化哈希数组的长度为 1 >> 4也就是16,并且默认的长度必须是2的n次幂,如果我们传入的值不是2的n次幂我们会调用 tableSizeFor 进行验算,并且变为2的n次幂。

在这里插入图片描述


int hash(Key)

在这里插入图片描述
hashMap中的hash算法:是将返回key这个对象的地址和这个地址本身带符号右移16位按位异或。
为什么要异或?
为了让高为和地位全部参与运算,使得返回的地址更为准确

(Tips:不同对象的hashCode有没有可能相同??
有!!因为hashCode实际上是key对象的地址,但是返回的类型是int,int是一个有限的集合。有可能hashCode的返回值就会越界。这就导致哈希值和对象并不完全是一一对应的关系,所以不同的对象hashCode可能相同!)


添加元素 :V put(K,V)

put方法是hashMap中的重中之重。
put()底层调用putVal方法。
我们来手动解析putVal方法。

//这是一个final修饰的静态方法
//参数: 
//@param hash hash for key 通过key对象得到的hash值
//@param key the key       key对象
//@param value the value to put   要添加的value
//@param onlyIfAbsent if true, don't change existing value   如果为真,那么不能修改value值
//@param evict if false, the table is in creation mode.  如果为假,这个哈希表处于创建模式
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
       //创建临时的Node数组,和Node节点,   n是哈希数组长度 i 是通过哈希算法得到的位置
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //如果当前哈希表不存在,那么就resize()
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //p位置table【哈希算法】
        //【哈希算法:得到key对象的地址后与哈希数组的长度-1进行与计算】
        //如果为null ,那么就说明此位置还没有任何元素添加过,那么直接在此位置创建新的节点即可。
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
        //如果不为null ,那么就证明此节点已经添加了位置,这个节点后可能是一个链表,或者红黑树。
            Node<K,V> e; K k;
            //对当前的元素进行判断,如果当前的节点的hash值和key,都与我们put进来的key和hash相等,那么就把当前的value进行覆盖
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //如果当前元素的key和hash与我们put进来的hash,key都不一样,
            //那么就判断这个节点是否是红黑树节点,如果是那么直接以红黑树的方式去put()。
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            //如果不是,那么就说明这个节点后是链表,我们要对链表进行遍历,
            //先找到是否存在这个key,如果不存在就在链表尾部添加节点,存在则覆盖。
            else {
           //binCount用来统计遍历的次数,
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
            //如果遍历到尾结点还没有重复的,那么就在尾节点后添加节点
                        p.next = newNode(hash, key, value, null);
            //如果说遍历的次数大于等于7,也就是说当前的节点数为8,
            //那么就直接将这个链表变为红黑树结构
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
            //找到相等的key和hash,对value进行覆盖
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //如果之前的节点不等于null,那么就返回之前的value
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
             //允许LinkedHashMap后,动作进行回调
                afterNodeAccess(e);
                return oldValue;
            }
        }
        //快速失败机制的modCount
        ++modCount;
        //如果现在的大小 大于了 threshold,那么就进行resize()操作
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

put的流程图
在这里插入图片描述


扩容 : resize()

HashMap的扩容也非常重要,由于源码太长,我给大家简述一下resize()的过程.
首先我们先判断哈希数组是否为null,如果为null,也就是说要这个HashMap是刚刚创建的。我们要对这个哈希数组进行初始化,大小为16。
如果不为null,那么新的数组长度就是原来的两倍。原因很简单,保证长度是2的n次幂。
其次是 threshold 。如果之前的threshold > 0 也就是说已经被初始化了,那么值不变;如果不是,那么就值就是默认长度*默认负载因子(16 X 0.75);
HashMap扩容,就会让数组再次填充到新的长度中,这里我们又要提到为什么长度是2 的 n 次幂了。
我们做一个演示,我们在5好下标有3个元素,key1,key2,key3.他们的后四位都是0101也就是5,当我们 resize()的时候我们把长度 x 2我们再进行与操作(取余)是不是要对比第5位了。那么我们想一下,之前我们已经把后四位相同的放在了同一个元素上,如果后5位相等,并且第5位为0,那么就说明扩容一次之后,他们再次取余结果还是相同那么我们就在原地址保存第五位为0的即可。那么第五位为1 的放在那里呢?还需要再哈希么?不需要!直接放在 5 + 16 的位置即可,5是之前的索引,16是之前的长度! 所以说2的幂次方好处也在这,我们可以快速的扩容!
在这里插入图片描述
并且,再遍历当前节点时也就是key1,key2,key3,到了新的哈希数组中,位置会变为key3,key2,key1,因为我们是遍历时插入,而插入的方法时头插法。

JDK1.8中的resize()的实际操作时遍历当前树或者链表,把放在原来位置的链再一起,loHead标记头节点,loTail标记尾结点;放在新的位置的链在一起,newHead标记头节点,newTail标记尾结点。
放在原来位置的数据链:
key1-》key3
loHead-》key1 (头)
loTail-》key3 (尾)

放在新位置(旧位置+旧长度)的数据链:
key2
newHead->key2
newTail->key2


删:V remove(Object Key ):通过Key删除节点
clear(),删除所有节点。

改:replace(Key,OldValue,newValue),把newValue覆盖到OldValue上

查:
V get(Key):通过Key 得到Value
boolean containsKey(Object Key ):HashMap中是否含有这个Key,

HashMap遍历

HashMap的遍历与普通集合的遍历不太一样,因为它没有下标,它不像数组可以通过下标去访问,它也不像列表,找到它的头就可以直接一直访问到尾。
在HashMap中如果要查找一个Value,必须要有它的Key,所以我们如果得到Key的集合,那么我们就可以得到它的整个集合的Value了不是么。
Map接口里提供了KeySet方法,KeySet返回的是当前集合的Key的集合,以及values(),提供的是所有的Value集合(无法通过Value得到Key),还有EntrySet,提供的是所有节点的集合,并且KeySet,values,EntrySet都提供了Iterator接口,我们也可以通过这些Iterator接口去遍历访问。

以下就是HashMap常用的遍历方法

  public static void main(String[] args) {
        HashMap<Integer,String> hashmap = new HashMap<Integer,String>();
        hashmap.put(1,"a");
        hashmap.put(2,"b");
        hashmap.put(3,"c");
        hashmap.put(4,"d");

        //使用entrySet遍历
        for ( Map.Entry<Integer,String> entry : hashmap.entrySet()){
            System.out.println(entry.getKey() + " ->" + entry.getValue());
        }

        //使用keySet遍历
        for(int key : hashmap.keySet()){
            System.out.println(key + " -> "+hashmap.get(key));
        }

        //使用values遍历Value
        for (String value : hashmap.values()){
            System.out.println(value);
        }

        //使用entrySet对应的Iterator进行遍历
        Iterator iterator1 = hashmap.entrySet().iterator();
        while(iterator1.hasNext()){
            Map.Entry<Integer,String> entrys = (Map.Entry<Integer, String>) iterator1.next();
            System.out.println(entrys.getKey() + " -> " + entrys.getValue());
        }

        //使用KeySet对应的Iterator进行遍历
        Iterator iterator2 = hashmap.keySet().iterator();
        while(iterator2.hasNext()){
            int key = (int)iterator2.next();
            System.out.println(key +" -> "+hashmap.get(key));
        }


        //使用values遍历
        Iterator iterator3 = hashmap.values().iterator();
        while(iterator3.hasNext()){
            System.out.println(iterator3.next());
        }
        //使用JDK 1.8 的forEach遍历
        hashmap.forEach((k,v) -> System.out.println("key: "+k+" value:"+v));
        
        }

Hash冲突

键(key)经过hash函数得到的结果作为地址去存放当前的键值对(key-value)(hashmap的存值方式),但是却发现该地址已经有值了,就会产生冲突。这个冲突就是hash冲突了。

换句话说就是:如果两个不同对象的hashCode相同,这种现象称为hash冲突。
(因为hashCode返回的是int数据,是一个有限的集合,所以有可能内存地址不同,但是hashCode相同)

无论多好的Hash算法都会引起Hash冲突,而JDK1.8采用的是链地址法
链地址法本质就是:把相同的hash值的数据链成链表。

参考文献:java 解决Hash(散列)冲突的四种方法–开放定址法(线性探测,二次探测,伪随机探测)、链地址法、再哈希、建立公共溢出区
参考文献:Java 8系列之重新认识HashMap
这两篇文章有大量的图解,看起来会非常轻松舒适。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值