HashMap以及源码详解

天涯浪子 提交于 2020-01-23 10:28:30

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;
    }

扩容 : 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));
        
        }

HashMap面试题常问:

hashMap和hashTable

线程不安全效率高 和 线程安全效率低
Key和Value都可以为null,hashTable,Key 和 Value都不允许null

hashMap hashTable
线程不安全效率高 线程安全效率低
Key / Value都可以为 null Key / Value都不可以为 null
默认容量16 默认容量11
put 时才初始化 table 构造函数时就初始化table
2倍扩容 2倍+1扩容

hash算法区别:
HashMap:
hash = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16)
index = hash & table.length-1

HashTable: index = (e.hash & 0x7FFFFFFF) % Capacity;


为什么哈希数组(table)的长度是2 的 n 次幂?
因为方便我们计算索引的位置!key通过一系列计算最后在哈希数组中确定索引的位置。怎么确定?如果我们把 key 看作数字(例如 7)我们把这个数字对数组长度(例如初始值 16)进行取余,我们就确定了这个元素所对应的位置。但是我们在put源码里我们会发现,我们获得key对象的地址之后,我们进行的操作是这个数字 和 数组长度-1 与操作。经过验证,我们会发现这两个操作的结果是一摸一样的!为了取余更快,数字和长度-1进行与操作的核心原因,就是要让除第一位外,余下的数字全为1。
其次方便我们再扩容是快速填充元素,如果第一次初始化的值是16,那么resize时,我们就对比key地址的二进制位的第5位,如果第五位尾0,那么就放在原来的位置,如果为1,就放在原来的索引+原来的哈希数组长度 的位置。

总结:
一:因为长度为2的n次幂方便key的值做与运算,与运算的本质是为了内存地址取余长度,而与运算的速度比取余快非常多。
二:扩容是设计到元素的迁移,只需要判断之前二进制的前一位即可,如果为0,位置不变,如果为1 ,位置为 旧位置+旧长度。

在这里插入图片描述


LinkedHashMap:底层用链表实现,是有序的集合(按照插入顺序)

TreeMap:底层用红黑树实现,也是有序的集合(按自然顺序,例如,字符按照a,b,c,d,数字按照1,2,3,4)


易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!