深入解读HashMap

HashMap集合简介

HashMap基于哈希表的Map接口实现,是以key-value存储形式存在,即主要用来存放键值对。HashMap的实现不是同步的,这意味着它不是线程安全的。它的key、value都可以为null。此外,HashMap中的映射不是有序的。

  • JDK1.8之前HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突(两个对象调用的hashCode方法计算的哈希码值—致导致计算的数组索引值相同)而存在的(“拉链法"解决冲突)JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为8)并且当前数组的长度大于64时,此时此索引位置上的所有数据改为使用红黑树存储。

特点:

  • 存取无序的
  • 键和值位置都可以是null,但是键位置只能是一个null,value可以有多个null值。
  • 键位置是唯一的,底层的数据结构控制键的
  • jdk1.8前数据结构是∶链表+数组jdk1.8之后是:链表+数组+红黑树
  • 阈值(边界值)>8并且数组长度大于64,才将链表转换为红黑树,变为红黑树的目的是为了高效的查询。

HashMap集合底层的数据结构

存储过程

在这里插入图片描述

什么叫哈希碰撞,如何解决?

只要两个元素的key计算的哈希码值相同就会发生哈希碰撞,jdk8之前使用链表解决;jdk8之后使用链表+红黑树解决!

如果两个键的hashcode相同,如何存储键值对?

hashcode相同,通过equals比较内容是否相同。相同:则新的value覆盖之前的value;不相同:则将新的键值对添加到哈希表中。

但是这样的话问题来了,传统hashMap的缺点,1.8为什么引入红黑树?这样结构的话不是更麻烦了吗,为何阀值大于8换成红黑树?

JDK1.8以前HashMap的实现是数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当HashMap中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候HashMap就相当于一个单链表,假如单链表有n个元素,遍历的时间复杂度就是.p(n),完全失去了它的优势。针对这种情况,JDK1.8中引入了红黑树(查找时间复杂度为O(logn))来优化这个问题。当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断变长,肯定会对查询性能有一定的影响,所以才需要转成树。

hashmap的put方法逻辑

在这里插入图片描述
需要值得注意的是:
1.size表示hashMap中K-V的实时数量,注意不等于数组的长度。
2.threshold(临界值) =capacity(容量)*loadFactor(加载因子)。这个值是当前已占用数组长度的最大值。size超过这个临界值就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍。加载因子默认设置为0.75.

HasnMap的继承关系

在这里插入图片描述
查看源码我们可以发现HashMap实现了Map;同时也继承AbstractMap,而AbstractMap也实现了Map。为什么会这样?
这是一个官方bug!在Java集合中类似这样的写法还有很多,如ArrayList、LinkedList等等都是类似的。

HashMap集合类的成员

1.序列版本号

private static final long serialVersionUID = 362498820763181265L;

2.集合的初始化容量

 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
引申:为什么必须是2的n次幂,不能是其他呢如10?

HashMap构造方法还可以指定集合的初始化容量大小:

  • HashMap(int initialcapacity)构造一个带指定初始容量和黑t认加载因子(0.75)的空HashMap。

根据上述讲解我们已经知道,当向HashMap中添加一个元素的时候,需要根据key的hash值,去确定其在数组中的具体位置。HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法。
这个算法实际就是取模,hash%length,计算机中直接求余效率不如位移运算(这点上述已经讲解)。所以源码中做了优化,使用hash&(length-1),而实际上hash%length等于hash&(length-1)的前提是length是2的n次幂
这种情况下,index = key值HashCode&(length-1),index的结果等同于key值的HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。

为什么这样能均匀分布减少碰撞个呢?
因为2的n次方实际就是后面n个0,2的n次方-1,实际就是n个1.

小结:
1.由上面可以看出,当我们根据key的hash确定其在数组的位置时,如果n为2的幂次方,可以保证数据的均匀插入,如果n不是2的幂次方,可能数组的一些位置永远不会插入数据,浪费数组的空间,加大hash冲突。
2.另一方面,一般我们可能会想通过%求余来确定位置,这样也可以,只不过性能不如&运算。而且当n是2的幂次方时: hash & (length-1)== hash % length。
3.因此,HashMap容量为2次幂的原因,就是为了数据的的均匀分布,减少hash冲突,毕竟hash冲突越大,代表数组中—个链的长度越大,这样的话会降低hashmap的性能。
4.如果创建HashMap对象时,输入的数组长度是10,不是2的幂,HashMap通过位移运算和或运算得到的肯定是2的幂次数,并且是离那个数最近的数字。HashMap的tableSizeFor方法做了处理,能保证n永远都是2次幂。

为什么Map桶中节点个数超过8才转为红黑树?

8这个阈值定义在HashMap中,针对这个成员变星,在源码的注释中只说明了8是bin (bin就是bucket(桶))从链表转成树的阈值,但是并没有说明为什么是8:
在HashMap中有一段注释:

Because TreeNodes are about twice the size of regular nodes, we
      use them only when bins contain enough nodes to warrant use
      (see TREEIFY_THRESHOLD). And when they become too small (due to
      removal or resizing) they are converted back to plain bins.  In
     usages with well-distributed user hashCodes, tree bins are
     rarely used.  Ideally, under random hashCodes, the frequency of
      nodes in bins follows a Poisson distribution

翻译过来的大概意思如下:
因为树节点的大小大约是普通节点的两倍(即红黑树的节点大小大约是链表节点的两倍),所以我们只在箱子包含足够的节点时才使用树节点(参见TREEIFY_THRESHOLD)。当它们变得太小(由于删除或调整大小)时,就会被转换回普通的桶。在使用分布良好的用户hashcode时,很少使用树箱。理想情况下,在随机哈希码下,箱子中节点的频率服从泊松分布。

0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million

通过上面可以发现一个bin中链表长度达到8的概率只有 0.00000006,是非常小的。
TreeNodes占用空间是普通Nodes的两倍,所以只有当bin(链表)包含足够多的节点时才会转成TreeNodes,而是否足够
多就是由TREEIFY_THRESHOLD的值决定的。当bin中节点数变少时,又会转成普通的bin。并且我们查看源码的时候发现,链表长度达到8就转成红黑树,当长度降到6就转成普通bin。
这样就解释了为什么不是一开始就将其转换为TreeNodes,而是需要一定节点数才转为TreeNodes,说白了就是权衡,空间和时间的权衡。

当链表的值小于6则会从红黑树转回链表。
 static final int UNTREEIFY_THRESHOLD = 6;
当Map里面的数量超过这个值时,表中的桶才能进行树形化,否则桶内元素太多时会扩容,而不是树形化为了避免进行扩容、树形化选择的冲突,这个值不能小于4*TREEIFY_THRESHOLD(8)
 static final int MIN_TREEIFY_CAPACITY = 64;
哈希表的加载因子
  • loadFactor加载因子,是用来衡量HashMap满的程度,表示HashMap的疏密程度,影响hash操作到同一个数组位置的概率,计算HashMap的实时加载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。capacity是桶的数量,也就是table(数组)的长度length。
  • loadFactor太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor的默认值为
    0.75f是官方给出的一个比较好的临界值。
  • 当HashMap里面容纳的元素已经达到HashMap数组长度的75%时,表示HashMap太挤了,需要扩容,而扩容这个过程涉及到rehash、复制数据等操作,非常消耗性能。,所以开发中尽量减少扩容的次数,可以通过创建HashMap集合对象时指定初始容量来尽量避免。
  • 同时在HashMap的构造器中可以定制IoadFactor。

即负载因子越大,发生hash冲突的概率也越大,负载因子越小,空间利用率越低。

HashMap的成员方法

增加方法

put方法是比较复杂的,实现步骤大致如下:

  1. 先通过hash值计算出key映射到哪个桶(数组空间);
  2. 如果桶上没有碰撞冲突,则直接插入;
  3. 如果出现碰撞冲突了,则需要处理冲突:
    a:如果该桶使用红黑树处理冲突,则调用红黑树的方法插入数据;
    b:否则采用传统的链式方法插入。如果链的长度达到临界值,则把链转变为红黑树;
  4. 如果桶中存在重复的键,则为该键替换新值value;
  5. 如果size大于阈值threshold,则进行扩容; +

具体的方法源码如下:
在这里插入图片描述

说明:

  1. HashMap只提供了put用于添加元素,putVal方法只是给put方法调用的一个方法,并没有提供给用户使
    用。所以我们重点看putVal方法。
  2. 我们可以看到putVal()方法中key在这里执行了一下hash()方法,来看一下Hash方法是如何实现的。

在这里插入图片描述
这里可以得出当key为null时,value值为0。
在putVal函数中使用了上述hash函数计算的哈希值:
在这里插入图片描述
这里的n表示的是数组长度。
下面是一个简单的计算流程
在这里插入图片描述
简单的说就是:高16 bit不变,低16 bit和高16 bit做了一个异或(得到的 hashcode转化为32位二进制,前16位和后16位低
16 bit和高16 bit做了一个异或);再用数组的长度减一与hash值做与运算!
问题:为什么需要这样操作?
如果当n即数组长度很小,假设是16的话,那么n-1即为—》1111,这样的值和hashCode()直接做按位与操作,实际上只使用了哈希值的后4位。如果当哈希值的高位变化很大,低位变化很小,这样就很容易造成哈希冲突了,所以这里把高低位都利用起来,从而解决了这个问题。

HashMap的增加方法中putVal方法

在这里插入图片描述

hashMap集合将链表转换为红黑树的terrifyBin方法

源码内容如下:在putVal方法中

 for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);//转换红黑树的方法!
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
//tab是前面的数组,hash就是hash值
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();//当数组的长度小于64的时候,扩容!
        else if ((e = tab[index = (n - 1) & hash]) != null) {//n表示数组长度!
            TreeNode<K,V> hd = null, tl = null;//hd红黑树的头节点;tl是红黑树的尾节点。
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

可以参考别人的文章:参考的文章

扩容方法 resize

1.什么时候需要扩容?
当HashMap中的元素个数超过数组大小(数组长度)*loadFactor(负载因子)时,就会进行数组扩容,loadFactor的默认值(DEFAULT_LOAD_FACTOR)是0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中的元素个数超过16×0.75=12(这个值就是阈值或者边界值threshold值)的时候,就把数组的大小扩展为2×16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预知元素的个数能够有效的提高HashMap的性能。
补充
当HashMap中的其中一个链表的对象个数如果达到了8个,此时如果数组长度没有达到64,那么HashMap会先扩容解决,如果已经达到了64,那么这个链表会变成红黑树,节点类型由Node变成TreeNode类型。当然,如果映射关系被移除后,下次执行resize方法时判断树的节点个数低于6,也会再把树转换为链表。
2.HashMap的扩容是什么?
进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的。在编写程序中,要尽量避免resize(扩容)。

HashMap在进行扩容时,使用的rehash方式非常巧妙,因为每次扩容都是翻倍,与原来计算的(n-1)&hash的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到"原位置+旧容量"这个位置。比如一开始在5这个索引,那么扩容之后可能是5也能使5加上之前的容量值的位置即21.

  • 计算信的索引高位是0那么存储到原来索引位置
  • 如果高位是1,存储到原来索引+旧的数组长度位置。

因此,我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就可以了,是0的话索引没变,是1的话索引变成"原索引+oldCap(原位置+旧容量)"。可以看看下图为16扩充为32的resize示意图:
在这里插入图片描述

删除方法 remove

删除的话就是首先先找到元素的位置,如果是链表就遍历链表找到元素之后删除。如果是用红黑树就遍历树然后找到之后做删除,树小于6的时候要转链表。

遍历hashMap的几种方式

创建HashMap

public class OutOfOrderExecution {
    public static void main(String[] args) {
        HashMap<Integer,Integer> hm = new HashMap<>();
        hm.put(1,5);
        hm.put(2,6);
        hm.put(3,7);
        hm.put(4,8);
        hm.put(5,1);
        method1(hm);
    }

1.分别遍历Key和Values

    private static void method1(HashMap<Integer, Integer> hm) {
        Set<Integer> m =  hm.keySet();
        for (Integer n:m){
            System.out.println(n);
        }
        Collection<Integer> values = hm.values();
        for (Integer V:values){
            System.out.println(V);
        }
    }

2.使用Iterator迭代器

    private static void method1(HashMap<Integer, Integer> hm) {
        Iterator<Map.Entry<Integer,Integer>> iterator = hm.entrySet().iterator();
        while(iterator.hasNext()){
            Map.Entry<Integer, Integer> next = iterator.next();
            System.out.println(next.getKey()+"======"+next.getValue());
        }
    }

3.通过get方式,不建议使用,效率低,因为迭代了两次!

    private static void method1(HashMap<Integer, Integer> hm) {
    Set<Integer> keySet = hm.keySet();
    for(Integer key:keySet){
        System.out.println(key + "===" + hm.get(key));
    }

4.JDK8以后使用map的默认方法

/** default void forEach(BiConsumer<? super K, ? super V> action)
 
 对此映射中的每个条目执行给定的操作,直到所有条目都被处理或操作引发异常。
参数:
 BiConsumer 消费接口
 抽象方法: void accept (T t ,U u)对给定的参数执行此操作.
            参数:
                t key
                u value
 */
    private static void method1(HashMap<Integer, Integer> hm) {
    hm.forEach((key,value) ->{
        System.out.println(key + "==" + value);
    });
    }
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值