HashMap底层特性全解析

Posted 毛奇志

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了HashMap底层特性全解析相关的知识,希望对你有一定的参考价值。

一、前言

二、HashMap

2.1 HashMap数据结构

学习的时候,先整体后细节,HashMap整体结构是 底层数组+链表 ,先记住,再开始看下面的

HashMap相关知识点:

(1) 底层数据结构:HashMap基于哈希散列表实现 ,可以实现对数据的读写。

(2) 插入逻辑put()方法:将键值对传递给put方法时,它调用键对象的hashCode()方法来计算hashCode,然后找到相应的bucket位置(即数组)来储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。

(3) 哈希冲突:插入逻辑中,如果发生哈希冲突,使用链表来解决hash冲突问题,即当发生冲突了,对象将会储存在链表的头节点中。HashMap在每个链表节点中储存键值对对象,当两个不同的键对象的hashCode相同时(HashMap在两个key-value,hashcode相同导致index相同,就认为发生哈希冲突,equals相同任务同一个,不重复插入,HashSet在hashcode和equals相同,认为同一个,不重复插入),它们会储存在同一个bucket位置的链表中,如果链表大小超过阈值(TREEIFY_THRESHOLD,8),链表就会被改造为树形结构。

(4) HashMap和HashSet:HashMap在两个key-value,hashcode相同导致index相同,哈希冲突,equals相同任务同一个,不重复插入,HashSet在hashcode和equals相同,认为同一个,不重复插入。

2.2 HashMap线程不安全

HashMap是应用更广泛的哈希表实现,而且大部分情况下,都能在常数时间性能的情况下进行put和get操作。但是,HashMap在多线程并发的情况下是不安全的,通过两个问题的回答来解释原因。

问题1:为什么说HashMap是线程不安全的?
回答1:在接近临界点时,若此时两个或者多个线程进行put操作,都会进行resize(扩容)和reHash(为key重新计算所在位置),而reHash在并发的情况下可能会形成链表环。即在多线程环境下,使用HashMap进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap。

问题2:为什么在并发执行put操作会引起死循环?
回答2:因为多线程会导致HashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。值得注意的是,JDK1.7的情况下,并发扩容时容易形成链表环,此情况在1.8时就好太多太多了,因为在1.8中当链表长度大于阈值(默认长度为8)时,链表会被改成树形(红黑树)结构。

2.3 哈希冲突

哈希冲突定义:若干Key的哈希值按数组大小取模后,如果落在同一个数组下标上,将组成一条Entry链,对Key的查找需要遍历Entry链上的每个元素执行equals()比较(tip:知道了HashMap的“数组+链表”结构,就很好懂哈希冲突了)。

加载因子:为了降低哈希冲突的概率,默认当HashMap中的键值对达到数组大小的75%时,即会触发扩容。因此,如果预估容量是100,即需要设定100/0.75=134的数组大小,又因为HashMap大小必须是2的整数次幂,所以是大于134的最小的2的整数次幂,即256。

减少哈希冲突两方法:降低加载因子,加大初始大小。

HashMap中数组扩容相关概念(size capacity size/capacity):

(1) size是当前现实的记录数;capacity是理论上的容量,第一次由初始化决定,之后由扩容决定;initial capacity是初始的capacity,第一次的capacity。

(2) 负载因子等于“size/capacity”,全称为当前负载因子,其作用是当负载因子达到负载极限的时候扩容。当负载因子为0,表示空的hash表;负载因子为0.5,表示半满的散列表,依此类推。轻负载的散列表具有冲突少、适宜插入与查询的特点(但是使用Iterator迭代元素时比较慢)。

(3) 负载极限:“负载极限”是一个0~1的数值,“负载极限”决定了hash表的最大填满程度。当hash表中的负载因子达到指定的“负载极限”时,hash表会自动成倍地增加容量(桶的数量),并将原有的对象重新分配,放入新的桶内,这称为rehashing。HashMap和HashTable的构造器允许指定一个负载极限,HashMap和HashTable默认的“负载极限”为0.75,这表明当该hash表的3/4已经被填满时,hash表会发生rehashing。

(4) “负载极限”的默认值(0.75)是时间和空间成本上的一种折中:

较高的“负载极限”可以降低hash表所占用的内存空间,但会增加查询数据的时间开销,而查询是最频繁的操作(HashMap的get()与put()方法都要用到查询),所以会影响时间性能;

较低的“负载极限”会提高查询数据的性能,但会增加hash表所占用的内存开销,影响空间性能;

程序员可以根据实际情况来调整“负载极限”值。

以上专有名词连接起来是:size是记录数,capacity是桶数量,两者size/capacity得到负载因子,当负载因子达到理论设定的负载极限的时候扩容。专有名词(size、capacity、负载因子、负载极限、扩容(具体扩容逻辑)),所有的这些都可以在源码中找到逻辑。

三、JDK1.7中HashMap的实现

3.1 基本元素Entry

数组中的每一个元素其实就是Entry<K,V>[] table,Map中的key和value就是以Entry的形式存储的。Entry包含四个属性:key、value、hash值和用于单向链表的next。关于Entry<K,V>的具体定义参看如下源码:

static class Entry<K,V> implements Map.Entry<K,V> 
    final K key;
    V value;
    Entry<K,V> next;
    int hash;
 
    Entry(int h, K k, V v, Entry<K,V> n) 
        value = v;
        next = n;
        key = k;
        hash = h;
    
 
    public final K getKey() 
        return key;
    
 
    public final V getValue() 
        return value;
    
 
    public final V setValue(V newValue) 
        V oldValue = value;
        value = newValue;
        return oldValue;
    
 
    public final boolean equals(Object o) 
        if (!(o instanceof Map.Entry))
            return false;
        Map.Entry e = (Map.Entry)o;
        Object k1 = getKey();
        Object k2 = e.getKey();
        if (k1 == k2 || (k1 != null && k1.equals(k2))) 
            Object v1 = getValue();
            Object v2 = e.getValue();
            if (v1 == v2 || (v1 != null && v1.equals(v2)))
                return true;
        
        return false;
    
 
    public final int hashCode() 
        return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
    
 
    public final String toString() 
        return getKey() + "=" + getValue();
    
 
    /**
     * This method is invoked whenever the value in an entry is
     * overwritten by an invocation of put(k,v) for a key k that's already
     * in the HashMap.
     */
    void recordAccess(HashMap<K,V> m) 
    
 
    /**
     * This method is invoked whenever the entry is
     * removed from the table.
     */
    void recordRemoval(HashMap<K,V> m) 
    

JDK7中的HashMap,小结为以下几点:

(1) 基本元素为Entry,Entry包含四个属性:key、value、int类型的hash值和用于单向链表的Node类型的next;

(2) hash不是用于新插入的Entry和原有的链表节点的hashcode比较的,只是用于计算一下数组index的;

(3) key,value 是用于新插入的Entry和原有的链表的节点的 key,value 比较的,只有到equals和hashcode(index)比较都先相同,就认为是相同元素,被认为是相同就不会插入HashMap;

(4) next用于下一个链表中下一个节点。

3.2 插入逻辑

3.2.1 插入逻辑

HashMap插入逻辑由put()方法来完成,实际上是先执行hash()方法,再执行indexFor()方法,再执行equals()方法。即当向 HashMap 中 put一对键值时,它会根据 key的 hashCode 值计算出一个位置, 该位置就是此对象准备往数组中存放的位置。 该计算过程参看如下代码:

transient int hashSeed = 0;
final int hash(Object k) 
     int h = hashSeed;
     if (0 != h && k instanceof String) 
         return sun.misc.Hashing.stringHash32((String) k);
     
 
     h ^= k.hashCode();
 
     h ^= (h >>> 20) ^ (h >>> 12);
     return h ^ (h >>> 7) ^ (h >>> 4);
 
 
 static int indexFor(int h, int length) 
     return h & (length-1);
 

插入逻辑

第一步,确定数组下标:indexFor使用hash方法计算出来的值得到数组下标,hash(Object)方法是用来计算哈希值的,indexFor(hash,length)方法是用来计算数组下标的。 两者关系:indexFor方法根据hash(Object)方法的返回值作为实参来计算数组下标;

第二步,插入,如果指定的数组下标无对象存在,不发生哈希冲突,直接插入;如果指定的数组下标有对象存在,发生哈希冲突,使用equals对链条上对象比较,全部为false插入,其中一个为true,表示已经存在(hash和equals都相同就是存在)

put操作中的hashcode方法和equals方法:仅以put操作为例,插入操作中hash方法用来作为计算数组下标的输入,equals用于比较对象是否存在。

问题1:指定数组下标有值,哈希冲突后如何处理?
回答1:put操作的时候,当两个key通过hashCode计算相同时,则发生了hash冲突(碰撞),HashMap解决hash冲突的方式是用链表(拉链法),当发生hash冲突时,则将存放在数组中的Entry设置为新值的next(比如A和B都hash后都映射到下标i中,之前已经有A了,当map.put(B)时,将B放到下标i中,A则为B的next,所以新值存放在链表最头部的数组中,旧值在新值的链表上)。

问题2:哈希冲突发生后,为什么后插入的值要放在链表头部?
回答2:因为后插入的Entry是“热乎的”,被查找的可能性更大(因为get查询的时候会遍历整个链表),既然后插入的Entry是“热乎的”,那么这个后插入的Entry应该放在哪里呢?当然是放在链表头部,因为链表查找复杂度为O(n),插入和删除复杂度为O(1),如果将新值插在末尾,就需要先经过一轮遍历,这个开销大,如果是插在头结点,省去了遍历的开销,还发挥了链表插入性能高的优势。

3.2.2 新建节点添加到链表

HashMap中,新建节点添加到链表需要由addEntry()和createEntry()方法来完成,addEntry()表示链表中头插法插入节点和createEntry()表示新建一个Entry节点。

先找到数组下标后,先进行key判重,如果没有重复,就准备将新值放入到链表的表头。

void addEntry(int hash, K key, V value, int bucketIndex) 
    // addEntry方法中,如果当前 HashMap 大小已经达到了阈值,并且新值要插入的数组位置已经有元素了,那么要扩容
    if ((size >= threshold) && (null != table[bucketIndex])) 
        // 扩容
        resize(2 * table.length);
        // 扩容以后,重新计算 hashhash = (null != key) ? hash(key) : 0;
        // 重新计算扩容后的新的下标
        bucketIndex = indexFor(hash, table.length);
    
    // createEntry就是插入新值
    createEntry(hash, key, value, bucketIndex);  // key value由方法参数提供,未扩容,hash bucketIndex使用方法参数传递的,扩容,hash bucketIndex使用新计算的

// 这个很简单,其实就是将新值放到链表的表头,然后 size++
void createEntry(int hash, K key, V value, int bucketIndex) 
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);   
    size++;

上述代码解释了JDK7情况下的数组扩容流程:

(1) addEntry方法中,如果当前 HashMap 大小已经达到了阈值,并且新值要插入的数组位置已经有元素了,那么要扩容
(2) HashMap中数组扩容方式:两倍扩容
(3) 扩容后重新计算要已经插入了的key的数组下标:先hash,然后indexFor
(4) 新元素插入指定数组下标的链头,table[bucketIndex] = new Entry<>(hash, key, value, e); 新建一个Entry就是一个元素

这个方法的主要逻辑就是先判断是否需要扩容,需要带的话先扩容,然后再将这个新的数据插入到扩容后的数组的相应位置处的链表的表头,即先扩容再插入。

1.7中是先扩容后插入新值的,1.8中是先插值再扩容。

3.3 数组扩容逻辑

定义:扩容就是用一个新的大数组替换原来的小数组,并将原来数组中的值迁移到新的数组中,实现扩容的方法是resize()

由于是双倍扩容,迁移过程中,会将原来table[i]中的链表的所有节点,分拆到新的数组的newTable[i]和newTable[i+oldLength]位置上。比如:原来数组长度是16,那么扩容后,原来table[0]处的链表中的所有元素会被分配到新数组中newTable[0]和newTable[16]这两个位置。扩容期间,由于会新建一个新的空数组,并且用旧的项填充到这个新的数组中去。所以,在这个填充的过程中,如果有线程获取值,很可能会取到 null 值,而不是我们所希望的、原来添加的值。

我们对照HashMap的结构来说,如下:

上图中,左边部分即代表哈希表,也称为哈希数组(默认数组大小是16,每对key-value键值对其实是存在map的内部类entry里的),数组的每个元素都是一个单链表的头节点,跟着的蓝色链表是用来解决冲突的,如果不同的key映射到了数组的同一位置处,就将其放入单链表中。

当size>=threshold( threshold等于“容量*负载因子”)时,会发生扩容。

void addEntry(int hash, K key, V value, int bucketIndex) 
    if ((size >= threshold) && (null != table[bucketIndex])) 
        resize(2 * table.length);  // 扩容
        hash = (null != key) ? hash(key) : 0;   // 扩容后要用新的hash,不能用参数的
        bucketIndex = indexFor(hash, table.length);   // 扩容后要用新的bucketIndex,不用用参数的
    
 
    createEntry(hash, key, value, bucketIndex);  // key value由方法参数提供,未扩容,hash bucketIndex使用方法参数传递的,扩容,hash bucketIndex使用新计算的

特别提示:JDK1.7中resize,只有当 size>=threshold 并且 table中的那个槽中已经有Entry时,才会发生resize。即有可能虽然size>=threshold,但是必须等到相应的槽至少有一个Entry时,才会触发扩容,可以通过上面的代码看到每次resize都会扩大一倍容量(2 * table.length)。

JDK7情况插入情况下的扩容
(1) 扩容方式:HashMap扩容方式:两倍扩容 newsize=oldsize *2
(2) 数组扩容的触发-两个条件:addEntry方法中,如果当前 HashMap 大小已经达到了阈值75%,并且新值要插入的数组位置已经有元素了,才执行扩容(两个条件,即有可能虽然size>=threshold,但是必须等到相应的槽至少有一个Entry时,才会扩容)
(3) 扩容后重新计算要已经插入了的key的数组下标:使用hash和tab.lenght计算数组下标index,准备插入,index=hash&(tab.length-1);
(4) 扩容后的插入,对于原来数组的位置:扩容就是用一个新的大数组替换原来的小数组,并将原来数组中的值迁移到新的数组中。由于是双倍扩容,迁移过程中,会将原来table[i]中的链表的所有节点,分拆到新的数组的newTable[i]和newTable[i+oldLength]位置上。如原来数组长度是16,那么扩容后,原来table[0]处的链表中的所有元素会被分配到新数组中newTable[0]和newTable[16]这两个位置,从而减小链表长度。
(5) 扩容后的新插入:equals比较全部为false,然后将新元素插入指定数组下标的链头table[bucketIndex] = new Entry<>(hash, key, value, e); 新建一个Entry就是一个元素。
(6) 扩容过程中的隐患:扩容期间,由于会新建一个新的空数组,并且用旧的项填充到这个新的数组中去。所以,在这个填充的过程中,如果有线程获取值,很可能会取到 null 值,而不是我们所希望的、原来添加的值。

tip:ArrayList 1.5倍扩容,Vector两倍扩容,HashTable newsize=2 * oldsize +1 ,HashMap newsize=2 * oldsize

3.4 null处理

前面说过HashMap的key是允许为null的,当出现这种情况时,会放到table[0]中。

private V putForNullKey(V value) 
    for (Entry<K,V> e = table[0]; e != null; e = e.next) 
        if (e.key == null) 
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        
    
    modCount++;
    addEntry(0, null, value, 0);
    return null;

3.5 辨析扩容、树化和哈希冲突

1、扩容:扩容的对象是数组
扩容两个条件:addEntry方法中,如果当前 HashMap 大小已经达到了阈值75%,且新值要插入的数组位置已经有元素了,才触发扩容(扩容的第二个条件一定要put操作才能满足)
扩容的时机:扩容一定要在put操作才会出现(因为扩容的第二个条件一定要put操作才能满足)

2、树化:树化的对象是链表
树化的条件:链表节点数达到8,且要求数组长度大于64
树化的时机:链表树化一定要在put操作才会出现,树链表化一定要在remove操作才会出现。

3、 哈希冲突:哈希冲突的定义是要插入的数组index位置有元素了。

JDK7(涉及哈希冲突、数组扩容):
(1)如果未发生哈希冲突,直接放到数组中;
(2)如果哈希冲突,没有达到阈值的75%,插入到链表前面(头插法);
(3)如果哈希冲突,达到阈值75%,数组扩容操作,扩容后原数组元素和新插入的数组元素都要变动的;

JDK8(涉及哈希冲突、数组扩容、链表树化):
(1)如果未发生哈希冲突,直接放到数组中;
(2)如果哈希冲突,链表节点数未达到8,但是数组长度小于64,尾插法放在链表后面,插入完成后判断阈值是否扩容(数组阈值是否达到75%);
(3)如果哈希冲突,链表节点数未达到8,但是数组长度大于等于64,尾插法放在链表后面,插入完成后判断阈值是否扩容(数组阈值是否达到75%);
(4)如果哈希冲突,链表节点数达到8,但是数组长度小于64,尾插法放在链表后面,插入完成后判断阈值是否扩容(数组阈值是否达到75%);
(5)如果哈希冲突,链表节点数达到8,且要求数组长度大于等于64,尾插法插入到链表后面,链表树化,插入完成后判断阈值是否扩容(数组阈值是否达到75%)。

小结:扩容、树化、哈希冲突都是在put操作出现,但是三种不同。扩容和树化都是put操作发生哈希冲突导致的,put操作如果不哈希冲突啥事没有,所以只最重要的是设计分布均衡的哈希算法,数组扩容和链表树化都是缓解措施。JDK7是put涉及哈希冲突、数组扩容,JDK8是put涉及哈希冲突、数组扩容、链表树化,JDK8中remove可以涉及树链表化。

解释:扩容和树化都是put操作发生哈希冲突导致的
因为扩容的一个条件是要插入的数组位置已经有元素了,树化的条件是链表节点数大于8,链表节点数都达到8了,数组位置也一定有元素,而哈希冲突的定义是要插入的数组位置已经有元素。

四、JDK1.8中HashMap的实现

4.1 基本元素Node

HashMap底层维护的是数组+链表,我们可以通过一小段源码来看看:

 /**
  * The default initial capacity - MUST be a power of two.
  *  即 默认初始大小,值为16
  */
 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

 /**
  * The maximum capacity, used if a higher value is implicitly specified
  * by either of the constructors with arguments.
  * MUST be a power of two <= 1<<30.
  *  即 最大容量,必须为2^30
  */
 static final int MAXIMUM_CAPACITY = 1 << 30;

 /**
  * The load factor used when none specified in constructor.
  * 负载因子为0.75
  */
 static final float DEFAULT_LOAD_FACTOR = 0.75f;

 /**
  * The bin count threshold for using a tree rather than list for a
  * bin.  Bins are converted to trees when adding an element to a
  * bin with at least this many nodes. The value must be greater
  * than 2 and should be at least 8 to mesh with assumptions in
  * tree removal about conversion back to plain bins upon
  * shrinkage.
  * 大致意思就是说hash冲突默认采用单链表存储,当单链表节点个数大于8时,会转化为红黑树存储
  */
 static final int TREEIFY_THRESHOLD = 8;

 /**
  * The bin count threshold for untreeifying a (split) bin during a
  * resize operation. Should be less than TREEIFY_THRESHOLD, and at
  * most 6 to mesh with shrinkage detection under removal.
  * hash冲突默认采用单链表存储,当单链表节点个数大于8时,会转化 
     为红黑树存储。
* 当红黑树中节点少于6时,则转化为单链表存储
  */
 static final int UNTREEIFY_THRESHOLD = 6;

 /**
  * The smallest table capacity for which bins may be treeified.
  * (Otherwise the table is resized if too many nodes in a bin.)
  * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
  * between resizing and treeification thresholds.
  * hash冲突默认采用单链表存储,当单链表节点个数大于8时,会转化为红黑树存储。
  * 但是有一个前提:要求数组长度大于64,否则不会进行转化
  */
 static final int MIN_TREEIFY_CAPACITY = 64;

通过以上代码可以看出初始容量(16)、负载因子以及对数组的说明。

HashMap相关的变量:
(1) 初始化默认大小是16 initial capacity 16 JDK7+JDK8都一样
(2) 最大容量,必须为2^30 JDK7+JDK8都一样
(3) 默认负载因子为0.75 达到0.75就扩容,JDK7+JDK8都一样
(4) 树化阈值为8,链表化阈值为6 JDK8新增

树化的两个条件: 链表节点数达到8,且要求数组长度大于64。

HashMap中最重要的两个操作是数组扩容和插入元素时数组哈希冲突,但是要注意以下几点:
(1) 扩容是数组扩容,哈希冲突是要插入的数组下标已有元素,两者的对象都是数组,两者的关系是扩容是为了减低负载因子,减少哈希冲突;
(2) 减少哈希冲突两个设计:设计一个好的哈希算法 + 数组扩容;
(3) 如果put操作真正发生了哈希冲突:使用equals方法比较,如果为true不插入,否则JDK7是使用头插法插入链表,JDK8使用尾插法插入链表,JDK8额外添加了树化逻辑;

从JDK7到JDK8,Entry的名字变成了Node,原因是和红黑树的实现TreeNode相关联。1.8与1.7最大的不同就是利用了红黑树,即由数组+链表(或红黑树)组成。JDK1.8中,当同一个hash值的节点数不小于8时,将不再以单链表的形式存储了,会被调整成一颗红黑树(上图中null节点没画)。这就是JDK1.7与JDK1.8中HashMap实现的最大区别。

transient Node<K,V>[] table;

(1) 在JDK1.8中HashMap的内部结构可以看作是数组(Node<K,V>[] table)和链表的复合结构。

(2) 数组被分为一个个桶(bucket),通过哈希值决定了键值对在这个数组中的寻址(哈希值相同的键值对,就是哈希冲突,则以链表形式存储。

(3) 如果链表大小超过阈值(TREEIFY_THRESHOLD,8),图中的链表就会被改造为树形(红黑树)结构。

在分析JDK1.7中HashMap的哈希冲突时,不知大家是否有个疑问就是万一发生碰撞的节点非常多怎么办?如果说成百上千个节点在hash时发生碰撞,存储一个链表中,那么如果要查找其中一个节点,那就不可避免的花费O(N)的查找时间,这将是多么大的性能损失。这个问题终于在JDK1.8中得到了解决,在最坏的情况下,链表查找的时间复杂度为O(n),而红黑树一直是O(logn),这样会提高HashMap的效率。

JDK1.7中HashMap采用的是位桶+链表的方式,即我们常说的散列链表的方式;
JDK1.8中采用的是位桶+链表/红黑树的方式,虽然加快链表查找效率,但是也是非线程安全的,因为只有当某个位桶的链表的长度达到某个阀值的时候,这个链表才会转换成红黑树。

从JDK7的Entry变为JDK8的Node
(1) 基本元素使用Node,意为红黑树的节点,Node包含四个属性:key、value、hash值和用于单向链表的next,和JDK7的Entry节点一样;
(2) hash不是用于新插入的Entry和原有的链表节点的hashcode比较的,只是用于计算一下数组index的;
(3) key,value 是用于新插入的Entry和原有的链表的节点的 key,value 比较的,只有到equals和hashcode(index)比较都先相同,就认为是相同元素,被认为是相同元素的不插入HashMap;
(4) next用于下一个链表中下一个节点。

4.2 插入逻辑

通过分析put方法的源码,可以让这种区别更直观:

static final int TREEIFY_THRESHOLD = 8;   // 树化
 
public V put(K key, V value) 
        return putVal(hash(key), key, value, false, true);   // 调用putVal
 
  
  
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) 
        Node<K,V>[] tab;
    Node<K,V> p;
    int n, i;
    //如果当前map中无数据,执行resize方法。并且返回n    JDK7先扩容再插入,可能无效扩容,JDK8先插入再扩容
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
     //如果要插入的键值对要存放的这个位置刚好没有元素,那么把他封装成Node对象,放在这个位置上即可,插入的时候没有哈希冲突
        if ((p = tab[i = (n - 1) & hash]) == null)   // 这里对p赋值,就是新的要插入的节点
            tab[i] = newNode(hash, key, value, null);  // 插入
    //否则的话,说明这数组上面有元素,插入的时候发生哈希冲突
        else 
            Node<K,V> e; K k;
        //如果这个元素的key与要插入的一样,那么就替换一下。
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;   // 直接将p赋值给局部变量e
        // 1.如果这个元素的key与要插入的不一样,如果当前节点是TreeNode类型的数据,执行putTreeVal方法
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else   // else表示如果这个元素的key与要插入的不一样,如果还是遍历这条链子上的数据,跟JDK7没什么区别
                for (int binCount = 0; ; ++binCount)   // 循环
                    if ((e = p.next) == null)    // 循环找到一个空位置的就插入链表
                        p.next = newNode(hash, key, value, null);
            //2.完成了操作后多做了一件事情,判断,并且可能执行treeifyBin方法
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);   
                        break;  // 插入并判断是否树化,break;
                    
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;    // 如果链表上已经存在了,直接break;这里不用树化了,应该根本没插入
                    p = e;   // 不断将e赋值给p,更新p,就是p在链条上不断往后移动
                
            
            // e 不为null 要么第一个if替换,要么else if树插入,要么链表插入,总之插入成功了,返回oldValue
            if (e != null)  // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null) //true || --
                    e.value = value;
           //3.
                afterNodeAccess(e);
                return oldValue;
            
        
        ++modCount;
      //判断阈值,决定是否数组扩容   插入后决定是否扩容
        if (++size > threshold)
            resize();
        //4.  插入之后的操作
        afterNodeInsertion(evict);   
        return null;
    

以上代码中的特别之处如下:

if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
       treeifyBin(tab, hash);

treeifyBin()就是将链表转换成红黑树。

源码解析putVal()操作(语言组织),如下:

扩容:如果当前map中无数据,执行resize方法。并且返回n    JDK8先扩容再插入,JDK7先插入再扩容,可能无效扩容
没有哈希冲突:如果要插入的键值对要存放的这个位置刚好没有元素,那么把他封装成Node对象,放在这个位置上即可,插入的时候没有哈希冲突
   这里对p赋值,就是新的要插入的节点
  else 表示 否则的话,说明这数组上面有元素,插入的时候发生哈希冲突
  if表示 //如果这个元素的key与要插入的一样,那么就替换一下。
   else if 表示 1.如果这个元素的key与要插入的不一样,如果当前节点是TreeNode类型的数据,执行putTreeVal方法
    else表示如果这个元素的key与要插入的不一样,如果还是遍历这条链子上的数据,跟JDK7没什么区别
    // 循环
    // 循环找到一个空位置的就插入链表
          //2.完成了操作后多做了一件事情,判断,并且可能执行treeifyBin方法
          // 插入并判断是否树化,break;
     // 如果链表上已经存在了,直接break;这里不用树化了,应该根本没插入
            // 不断将e赋值给p,更新p,就是p在链条上不断往后移动
  返回oldValue: e 不为null 要么第一个if替换,要么else if树插入,要么链表插入,总之插入成功了,返回oldValue
   插入后判断是否扩容:判断阈值,决定是否数组扩容   插入后决定是否扩容
      最后 插入之后的操作
 完成了。

关于putVal(),注意以下三点:
(1) 树化有个要求就是数组长度必须大于等于MIN_TREEIFY_CAPACITY(64),否则继续采用扩容策略;
(2) resize方法兼顾两个职责,创建初始存储表格,或者在容量不满足需求的时候;
(3) 在JDK1.8中取消了indefFor()方法,直接用(tab.length-1)&hash计算出下标,所以看到这个,代表的就是数组的下角标。

4.3 链表树化逻辑

树化操作的过程有点复杂,可以结合源码来看看。将原本的单链表转化为双向链表,再遍历这个双向链表转化为红黑树。

final void treeifyBin(Node<K,V>[] tab, int hash) 
     int n, index; Node<K,V> e;
     //树形化还有一个要求就是数组长度必须大于等于64,否则继续采用扩容策略
     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;//hd指向首节点,tl指向尾节点
         do 
             TreeNode<K,V> p = replacementTree

以上是关于HashMap底层特性全解析的主要内容,如果未能解决你的问题,请参考以下文章

HashMap底层特性全解析

hashmap解析

hashmap解析

HashMap底层源码解析上(超详细图解+面试题)

HashMap底层源码解析下(超详细图解)

深度解析HashMap底层实现架构