通俗易懂的HashMap源码分析

据说给我点关注的都成了大佬,点关注的我都会私发一份好东西

前言

HashMap是我们最常用、最常见的一种数据结构。使用起来也非常的方便,主要是通过键值对的方式进行存储,简单明了。
本文将基于Java1.8版本的HashMap源码进行分析,适合Java、Android等初学者以及想了解源码的中高级工程师阅读交流。

① 数据结构
1.1 图示

在这里插入图片描述

上图即可简单示意HashMap的整体数据结构,主要是通过数组+链表(满足一定条件时,链表会切换成树)
数组定义为Node类型的数组:

transient Node<K,V>[] table;

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
        
        // 以下省略若干代码
}    

Node对象里第一个变量为key进行hash后的值,具体的hash过程为:

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

第二个变量为key,第三个变量为value,最后一个变量为指向链表的下一个对象(如果有的话)。

1.2 为什么这么设计

举个栗子:
我们都有手机吧,可以参考手机的联系人。
我们可以把人的名字看作key,名字的首字母认为是调用hash()函数的返回值,对应的手机号就是我们想要查找到的value。

现在,我们如果想查找“张三”的手机号,我们会怎么做?

我们当然是先确认张三的首字母 “Z”(类似于HashMap对key进行hash的过程),然后快速的滑到“Z”开头的联系人,找到“Z”之后,如果“Z”中只有一个刚好是张三,那就直接查找到了,如果有很多个,就需要再按顺序查找“张三”(类似于通过hash值找到数组中的一个链表,再通过key值找到这个链表中的“张三”这个key)。

所以这样设计存储和取值都比较快速
当我们拿到一个key,就可以快速的定位到数组中位置,因为数组的下标是通过key计算得来的,更准确的来说是通过key的hash后的值计算得到的。

当确定一个key以后,我们通过hash()计算得出一个hash值,然后按照下面的方法将hash值取模在0~size-1范围内,作为数组的下标:

hash & (size -1) // 相当于取模

这样取模比较高效,但是size必须为2的次幂(转换成二进制就是1后面跟n个0,减一后,就是1变成0,n个0变成n个1)。

那怎么保证size是2的次幂呢,这里先卖个关子。
所以通过key计算出数组的下标,我们就能够快速的定位到数组的位置进行取值,当然不同的key,hash之后通过取模后可能得到相同的值,这就是hash冲突,为了预防冲突,我们对key进行了二次hash(参考上文中hash函数),如果有冲突,那我们将使用链表将不同Key,哈希取模相同的进行存储,这样我们取值的时候需要遍历下链表。这个过程和我们查找手机联系人很相似。

② put的流程
2.1 为什么容量一定是2的次幂

上文中我们提到怎么保证size一定是2的次幂,假设我们使用的时候完全可以传一个非2的次幂大小呢。看源码怎么干的:

public HashMap(int initialCapacity, float loadFactor) {
        // 此处省略无关代码
        this.threshold = tableSizeFor(initialCapacity);
}

成员变量threshold本来是用来存储用来判断是否要进行扩容的一个阈值(容量 * 负载因子),但是在HashMap数组还没有创建的时候,这个变量会暂时用来存储初始化的容量值,所以当我们通过类似new HashMap(15)这种方式实例化HashMap时,实际容量的值是需要通过tableSizeFor(initialCapacity)方法处理后返回。那么我们传进去的15会被怎么处理返回呢?

static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

这个方法就一个目的,将我们传入的数字一顿操作给整成2的次幂。所以,我们在new HashMap的时候,无论我们怎么传,最后创建数组的时候大小都是2的次幂。到这里,我们很好的解释了为什么可以通过**hash & (size -1)**进行取模了。

2.2 put是如何实现的

其实上面多多少少讲到了怎么取数据,那么往里存储数据其实也差不多,也就是先根据key计算Hash值然后取模找到数组的下标,然后看当前下标的数组中是否有值,有的话,得再判断下这里面是否有和当前要存储的key相同的,相同的话就将值进行覆盖,如果没有相同的话,就新增一个放到链表的头的位置。
这里涉及到一个问题,就是HashMap中的数组是什么时候创建的?并不是在我们使用new HashMap的时候就创建哦,是在我们第一次put的时候,接下来我们稍微摘选看下源码:

public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
}

这个方法调了另一个方法:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
            
        // 省略其他代码    
}

可以看到当我们第一次put数据的时候,table是空的,那么就会调用resize()方法创建个数组,resize的主要职责是进行扩容的,下面会详细介绍,第一次调用的时候就是创建个数组。
数组创建完了就是将需要存入的key-value存入了:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
            
}            

就是将key、value、以及key的hash()后的值封装到Node里面,然后存储到数组里。这是第一次put的情况,如果不是第一次存储,也就是我上面描述的逻辑,无非就是替换还是新增。这里要强调一点,当一个hash坐标上单链表的长度超过一定的值,这个单链表会被切换成树来存储。

static final int TREEIFY_THRESHOLD = 8;
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    //省略不相关代码 
     
	if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
    	treeifyBin(tab, hash);
    
    // 省略	
}    	

就是单链表的长度达到8就会调用treeifyBin函数来进行切换数据结构。这个函数我们来简单研究一下:

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();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            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);
        }
    }

第一个判断条件是判断当前数组的大小是否达到需要调整为树结构的最小容量(MIN_TREEIFY_CAPACITY = 64),如果没有,优先进行扩容操作,而不是进行数据结构调整。
如果达到了,下面的代码也比较容易懂了,先通过hash值定位到数组的位置,并将这个值赋值给e,接着通过replacementTreeNode函数转换成TreeNode<K,V>对象,并将hd指向第一个树节点,将tl也指向第一个树节点,然后判断单链表的结点e是否有下一个,如果有还是取出来赋值给e,并将其转换成树节点赋值给p,这个时候因为之前tl已经赋值过了,所以不为null,所以会将p.pre指向tl,tl的next指向p,这样一轮循环就将单项链表变成了双向链表,再调用**treeify()**函数将双向链表变成红黑树。这个函数就不再详细展开了。

③ 扩容机制
3.1 重要参数

讲到扩容机制就不得先讲讲一些重要参数了:

// 默认的数组容量,1左移4位,相当于2的4次幂=16,当我们实例化HashMap不传容量大小时,默认为此值
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  

// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;  

// 默认的负载因子,负载因子✖️容量 就等于需要扩容的临界值了
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 存储的键值对数量
transient int size;

3.2 扩容实现

相信大家都了解过HashMap有个扩容的机制,当我们往HashMap中存储键值对时,如果达到了一定的数值(容量✖️负载因子),就被认为我们当前开辟的数组的容量即将不够用了,所以提前申请更大的数组容量,以便后续继续put更多的数据。

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        
        // 省略...
        
       Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];  
       
       if (oldTab != null) {
       	// 省略 ...将旧数组的数据转移到新的数组上
       }
       
       return newTab;
}

oldCap > 0时,才有扩容的事,所以我们看下这个if语句中的逻辑,如果旧的数组容量已经是最大容量了,这个时候我们也无法扩容了,返回原来的数组。
如果将旧的数组容量扩容一倍后还是小于最大容量的,并且旧数组的容量是大于或者等于默认容量的,就将旧的容量的阈值也扩大一倍。
然后将按照新的数组容量创建一个新的数组。
然后将旧的数组上的数据转移到新的数组上。最后返回新的数组及完成扩容。

④ 补充:fail-fast
4.1 简介

按照字面的意思翻译为:快速失败机制,他是Java集合中一种错误检测机制。操作集合的时候我们常常会去迭代循环操作,当在迭代操作的时候我们改变了数据结构,就有可能抛出ConcurrentModificationException异常,这个机制就是一个错误检测机制,为了防止我们不当的对集合操作带来不可预期的数据错误,所以,抛出这个异常用来提醒我们防止数据错误未及时发现而导致更严重的影响。但是,这个JDK也并不能保证100%能帮我们检测出来,借用源码中的一句话是**Fail-fast iterators

  • throw ConcurrentModificationException on a best-effort basis**。
4.2 如何实现
// 当HashMap里面数据的数量被改变或者数据结构发生改变的操作,都会被记录到这个变量上
transient int modCount;

这个变量就是作为判断抛出fail-fast的重要依据,当我们调用remove()、clear()等函数时会进行加一操作,那我们看看什么情况下会发生fail-fast呢?

if (map.modCount != expectedModCount)
	throw new ConcurrentModificationException();
	
if (modCount != mc)
	throw new ConcurrentModificationException();

主要就是上面两种类型的,第一种类型,对于第一种,expectedModCount在迭代器被实例的时候将当前的modCount赋值,如果使用迭代器的过程中modCount和实例化时候的值不一样就会抛出这个异常。
第二种类型也是类似的,mc的值也是将expectedModCount对其进行赋值的。

好的,到这里我们已经基本掌握HashMap的实现了,当然我们是忽略了一些实现细节的,看源码我们主要看下实现思想就行,太纠结细节是没有必要的(当然除了一些关键细节)。

推荐阅读:
漫画演示:HTTPS原理还能这么讲?太有趣了吧!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值