HashMap详细解释+全站最硬核手撕源码分析

Posted ascto

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了HashMap详细解释+全站最硬核手撕源码分析相关的知识,希望对你有一定的参考价值。

HashMap详细解释+全站最硬核手撕源码分析

1.HashMap基础入门

1.数组的优势/劣势

优势

1.按照索引查询元素速度很快

2.能存储大量数据

3.按照索引遍历数组方便

劣势

1.根据内容查询元素速度慢

2.数组的大小一旦给定就不能改变

3.数组只能存储一种类型的数据

4.增加、删除元素效率慢

2.链表的优势/劣势

优势

1.插入删除速度快

2.内容利用率高,不会浪费内存

3.大小不固定,扩展性灵活

劣势

1.不支持随即查找,必须从第一个开始遍历,查找效率低

2.链表中存储元素需要更多的内存,因为在链表中每个结点都包含一个指针,他需要额外的内存

3.有没有一种方式整合两种数据结构的优势?

散列表

4.什么是散列表?散列表有什么特点?

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

给定表M,存在函数f(key),对任意给定的关键字值key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表M为哈希(Hash)表,函数f(key)为哈希(Hash) 函数

特点

1.访问速度很快:由于散列表有散列函数,可以将指定的 Key 都映射到一个地址上,所以在访问一个 Key(键)对应的 Value(值)时,根本不需要一个一个地进行查找,可以直接跳到那个地址。所以我们在对散列表进行添加、删除、修改、查找等任何操作时,速度都很快。

2.需要额外的空间:首先,散列表实际上是存不满的,如果一个散列表刚好能够存满,那么肯定是个巧合。而且当散列表中元素的使用率越来越高时,性能会下降,所以一般会选择扩容来解决这个问题。另外,如果有冲突的话,则也是需要额外的空间去存储的,比如链地址法,不但需要额外的空间,甚至需要使用其他数据结构。

3.无序:散列表还有一个非常明显的特点,那就是无序。为了能够更快地访问元素,散列表是根据散列函数直接找到存储地址的,这样我们的访问速度就能够更快,但是对于有序访问却没有办法应对。

4.可能会产生碰撞(冲突):没有完美的散列函数,无论如何总会产生冲突,这时就需要采用冲突解决方案,这也使散列表更加复杂。通常在不同的高级语言的实现中,对于冲突的解决方案不一定一样。

5.什么是哈希?哈希的特点

核心理论:Hash也称散列、哈希,对应的英文都是Hash。基本原理就是把任意长度的输入,通过Hash算法变成固定长度的输出。这个映射的规则就是对应的Hash算法,而原始数据映射后的二进制串就是哈希值。

特点

1.从hash值不可以反向推导出原始的数据

2.输入数据的微小变化会得到完全不同的hash值,相同的数据会得到相同的值

3.哈希算法的执行效率要高效,长的文本也能快速地计算出哈希值

4.hash算法的冲突概率要小

注:由于hash的原理是将输入空间的值映射成hash空间内,而hash值的空间远小于输入的空间。根据抽屉原理,一定会存在不同的输入被映射到相同的输出的情况。

2.HashMap原理讲解

1.HashMap的继承体系是什么样的?

2.Node数据结构分析

Node类是HashMap的一个静态内部类,实现了 Map.Entry<K,V>接口。在调用put方法创建一个新的键值对时,会调用newNode方法来创建Node对象。

node中包含一个next变量,这个就是链表的关键点,hash结果相同的元素就是通过这个next进行关联的。

static class Node<K,V> implements Map.Entry<K,V> 
    //继承自Entry,Entry主要有getKey():K,getValue():V,setValue(V):V
        final int hash;
        final K key;
        V value;
        Node<K,V> next;//构造函数Hash值 键 值 下一个节点

        Node(int hash, K key, V value, Node<K,V> next) 
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        

        public final K getKey()         return key; 
        public final V getValue()       return value; 
        public final String toString()  return key + "=" + value; 

        public final int hashCode() 
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        

        public final V setValue(V newValue) 
            V oldValue = value;
            value = newValue;
            return oldValue;
        
       //判断两个node是否相等,若key和value都相等,返回true。可以与自身比较为true
        public final boolean equals(Object o) 
            if (o == this)
                return true;
            if (o instanceof Map.Entry) 
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            
            return false;
        
    

3.底层存储结构介绍

4.put数据原理分析

5.什么是Hash冲突?

哈希是通过对数据进行再压缩,提高效率的一种解决方法。但由于通过哈希函数产生的哈希值是有限的,而数据可能比较多,导致经过哈希函数处理后仍然有不同的数据对应相同的值。这时候就产生了哈希冲突。

6.拉链法

解决冲突的方法有很种,拉链法是其中之一。

拉链法解决冲突的做法是: 将所有关键字为同义词的结点链接在同一个单链表中 。若选定的散列表长度为m,则可将散列表定义为一个由m个头指针组成的指针数 组T[0…m-1]。凡是散列地址为i的结点,均插入到以T[i]为头指针的单链表中。T中各分量的初值均应为空指针。在拉链法中,装填因子α可以大于 1,但一般均取α≤1。

7.HashMap在jdk8为什么引入红黑树?

在jdk1.8版本后,Java对HashMap做了改进,在链表长度大于8(并且桶数量大于64)的时候,数据将会存在红黑树中,以加快检索速度。

红黑树的插入、删除、查找各种操作性能都比较稳定,使得链表(内存使用率高)+红黑树(高校检索)这种数据结构很强大。

红黑树的具体内容较多,后续可能会专门更新我对红黑树的理解。

8.HashMap扩容原理

我们都知道Java中数组是无法自动扩容的,HashMap的方法是使用一个新的数组代替原有的数组,对原数组的所有数据进行重新计算插入新数组,之后指向新数组;如果扩容前数组已经达到最大了,那么将直接将阈值设置成最大整形return。扩容是为了使查找的均摊复杂度将为O(1)。

HashMap每次扩容增长一倍,例如HashMap初始容量为16,加载因子0.75,当容量达到12的时候进行扩容,扩容到2的5次幂。

3.HashMap构造方法源码分析

//我们首先需要弄清楚几个定义值的含义

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;//16
//默认table大小为16
static final int MAXIMUM_CAPACITY = 1 << 30;//1073741824
//table最大长度:1 073 741 824
/*
左移的运算规则:按二进制形式把所有的数字向左移动对应的位数,高位移出(舍弃),低位的空位补零。

计算1<<30,首先把1转为二进制数字 0000 0000 0000 0000 0000 0000 0000 0001

然后将上面的二进制数字向左移动30位后面补0得到 0010 0000 0000 0000 0000 0000 0000 0000
二进制再转化为十进制得到1 073 741 824
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//默认负载因子大小:0.75
static final int TREEIFY_THRESHOLD = 8;
//树化阈值
static final int UNTREEIFY_THRESHOLD = 6;
//树降级成为链表的阈值
static final int MIN_TREEIFY_CAPACITY = 64;
//树化的另一个参数:数组长度达到64时(桶的数量)

transient int size;//当前哈希表种元素个数

transient int modCount;//当前哈希表结构修改次数

int threshold;//扩容阈值,当你的哈希表中的元素超过阈值时,就会触发扩容

final float loadFactor;//负载因子  threshold = capacity * loadFactor
//在jdk1.8版本中HashMap有四个构造方法,根据参数内容我们可以发现实质上就是一个套娃

public HashMap(int initialCapacity, float loadFactor) 
        //做了一些逻辑校验,capacity必须大于0&&最大值不可以超过MAXIMUM_CAPACITY
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //loadFactor(负载因子)必须大于零&&必须是个数不能是NaN
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;//初始化填充因子
        this.threshold = tableSizeFor(initialCapacity);
    /*分析tableSizeFor方法的源码可以知道(有兴趣的小伙伴可以自己试着分析下,里面主要运用到了移位运算符),该方法的返回值必须是一个大于等于当前参数的一个数字并且这个数字一定是2的次方数(这样的数有助于提高hash函数的执行效率),如传入7会返回8(2的三次方),传入9会返回16(2的4次方)。*/
    
    
public HashMap(int initialCapacity) 
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    
public HashMap() 
        this.loadFactor = DEFAULT_LOAD_FACTOR; // 所有其他字段均为默认值
    
public HashMap(Map<? extends K, ? extends V> m) 
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    
//putMapEntries(Map<? extends K, ? extends V> m, boolean evict)函数将m的所有元素存入本HashMap实例中。

4.HashMap put方法源码分析

public V put(K key, V value) 
        return putVal(hash(key), key, value, false, true);
    
------------------------------------------------------------------------
static final int hash(Object key) 
    /*扰动函数:让key的hash值的高16位也参与路由运算(上图左下角有具体运算方法)
    作用:减少哈希冲突的概率
    异或:相同返回0,不同返回1
    h = 0b 0010 0101 1010 1100 0011 1111 0010 1110
    0b 0010 0101 1010 1100 0011 1111 0010 1110 
    ^ 
    0b 0000 0000 0000 0000 0010 0101 1010 1100
    => 0010 0101 1010 1100 0001 1010 1000 0010
    */
    //简单来说:让高位参与运算后,再 & length-1 让 index 更加均匀散列,这样就可以减少冲突
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    
---------------------------------------------------------------------------------
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) 
        Node<K,V>[] tab; //引用当前hashMap的散列表
        Node<K,V> p; //表示当前散列表的元素
        int n, i;//n表示散列表数组的长度,i表示路由寻址 结果
        if ((tab = table) == null || (n = tab.length) == 0)//延迟初始化逻辑,第一次调用putVal时会初始化hashMap对象中的最耗费内存的散列表
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)//最简单的一种情况:寻址找到的桶位刚好是null,这个时候,直接将当前的node扔进去就行了。
            tab[i] = newNode(hash, key, value, null);
        else 
            //e:不为null的话,找到一个与当前要插入的key-value一致的key的元素,k:表示临时的一个key
            Node<K,V> e; K k;
            //表示桶位中的该元素,与你当前插入的元素的key完全一致,后续需要替换操作
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)//红黑树呗
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else //链表的情况,而且链表的头元素与我们要插入的key不一致。(尾插法)
                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);//前面有8个元素,要树化了~
                        break;
                    
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    //条件成立的话,说明找到了相同key的node元素,需要进行替换操作
                    p = e;
                
            
            if (e != null) 
         // e!=null,条件成立说明。找到了一个与你插入元素key完全一致的数据,需要进行替换。
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            
        
        ++modCount;//表示散列表结构被修改的次数,替换node元素的value不计数
        if (++size > threshold)//假如容量不够,就扩容
            resize();
        afterNodeInsertion(evict);
        return null;
    
附一张流程图:

图片转载自:

[https://www.cnblogs.com/xiaoxi/p/7233201.html]:

5.HashMap resize扩容方法源码分析

为什么需要扩容:为了解决哈希冲突导致的链化影响查询效率(空间换时间)极限思维:假如桶的数量很少,而存入的内容又很多,那么在查找的时候就和在链表中查找没啥区别了,所以一定要扩容,增加桶的数量,减少拥挤。

final Node<K,V>[] resize() 
        Node<K,V>[] oldTab = table;
    //oldTab:引用扩容前的哈希表
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
         //oldCap表示扩容之前table数组的长度
        int oldThr = threshold;//表示扩容之前的扩容阈值,触发本此扩容的阈值
        int newCap, newThr = 0;//newCap表示扩容之后table数组的小大
        //newThr:扩容之后,下次再次触发扩容的条件
    
        //条件如果成立说明 hashmap中的散列表已经初始化过了,是一次正常的扩容
        if (oldCap > 0) 
            if (oldCap >= MAXIMUM_CAPACITY) 
                //比最大的长度还大,无法扩容
                threshold = Integer.MAX_VALUE;
                return oldTab;
            
            //newCap = oldCap << 1  左移一位实现数值翻倍,并且赋值给newCap,newCap小于数组最大值限制且扩容之前的阈值>=16,这种情况下,则下一次扩容的阈值等于当前阈值翻倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; //阈值跟着翻倍
        
    //oldCap==0,说明hashmap中的散列表是null
        else if (oldThr > 0) 
            newCap = oldThr;
    //oldCap==0
        else                
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        
    //newThr为零时,通过newCap和loadFactor计算出一个newThr
        if (newThr == 0) 
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        
    
        threshold = newThr;//下一次触发的阈值
        //创建出一个更大更长的数组
        @SuppressWarnings("rawtypes","unchecked")
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
    //说明,hashmap本此扩容之前,table不为null
        if (oldTab != null) 
            for (int j = 0; j < oldCap; ++j) 
                Node<K,V> e;//当前node节点
                if ((e = oldTab[j]) != null) 
                    //说明当前桶位中有数据,但是数据具体是单个数据,链表,红黑树,还不知道。
                    oldTab[j] = null;//置空,方便JVM GC回收内存
                    if (e.next == null)//第一种情况:当前桶内只有一个元素,从未发生过碰撞,这种情况直接计算出当前元素应该存放在新数组中的位置,然后扔进去就可以了
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)//当前节点已经树化
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else  // 链表情况
                        
 //低位链表:存放在扩容之后的数组的下标位置与当前数组的下标位置一致
 //高位链表:存放在扩容之后的数组的下标位置为当前数组下标位置+扩容之前的数组长度
 //举个例子:当前容量为16 则扩容后对应的容量为32,而原本15号位置(最后一个,因为从0开始)的链表中的数据在新哈希表中可能位于15(低位链表),可能位于31(15+16高位链表)。具体用到的还是位运算符,有兴趣的读者可以自己研究一下。
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do 
                            next 以上是关于HashMap详细解释+全站最硬核手撕源码分析的主要内容,如果未能解决你的问题,请参考以下文章

全网最硬核的源码分析之——HashMap源码分析上

全站最硬核 百万字强肝RocketMq源码 火热更新中~(七十三)

全站最硬核 百万字强肝RocketMq源码 火热更新中~(一百一十二)NameSrv的作用及高性能顺序写盘

全网最硬核的源码分析之——String源码分析

Spring Boot 2从入门到入坟 | 请求参数处理篇:全网最硬核的请求映射原理的源码分析

线程池源码分析_01 FutureTask源码分析