容器-HashMap源码解析

 

概述

HashMap底层基于散列算法实现。HashMap允许null键和null值,在计算哈希键的哈希值拾,null键的哈希值为0。HashMap并不保证键值对的顺序,说明在进行某些操作后,键值对的顺序可能会发生改变。需要注意的是,HashMap是非线程安全类,在多线程环境下可能存在问题。

HashMap特点:

  • HashMap是基于哈希表的Map接口实现。
  • HashMap底层采用的是Entry数组和链表实现。
  • HashMap是采用key-value形式存储,其中key是可以允许为null但是只能是一个,并且key不允许重复(如果重复则新值覆盖旧值)。HashMap是线程不安全的。
  • HashMap存入的顺序和遍历的顺序有可能是不一致的。
  • HashMap保存数据的时候通过计算key的hash值来去决定存储的位置

底层实现

上面说到了HashMap底层是基于散列算法实现的,散列算法有两类:散列再探测和拉链法。HashMap就采用拉链法。并在某个链表长度过长时,会将该链表转换成红黑树提高性能(JDK1.8后引进,在链表长度大于8时会进行转换)。数据结构图如下所示:

对于拉链式的散列算法,其数据结构是数组加链表(或树形结构)组成的,在进行增删查操作的时候,首先定位到元素所在桶的位置,然后在链表中定位该元素。

HashMap的底层结构就是数组+链表+红黑树。

源码分析

1.继承与层次关系

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable 

2.源码分析

2.1 HashMap属性与其中的内部类

主要成员:

/**
  * 数组的默认初始长度,java规定hashMap的数组长度必须是2的次方
  * 扩展长度时也是当前长度 << 1。
  * 初始容量为2的4次方,16
  */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

// 数组的最大长度
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认负载因子,当元素个数超过这个比例则会执行数组扩充操作。
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 树形化阈值,当链表节点个大于等于TREEIFY_THRESHOLD - 1时,
// 会将该链表换成红黑树。
static final int TREEIFY_THRESHOLD = 8;

// 解除树形化阈值,当链表节点小于等于这个值时,会将红黑树转换成普通的链表。
static final int UNTREEIFY_THRESHOLD = 6;

// 最小树形化的容量,即:当内部数组长度小于64时,不会将链表转化成红黑树,而是优先扩充数组。
static final int MIN_TREEIFY_CAPACITY = 64;

// 这个就是hashMap的内部数组了,而Node则是链表节点对象。
transient Node<K,V>[] table;

// 下面三个容器类成员,作用相同,实际类型为HashMap的内部类KeySet、Values、EntrySet。
// 他们的作用并不是缓存所有的key或者所有的value,内部并没有持有任何元素。
// 而是通过他们内部定义的方法,从三个角度(视图)操作HashMap,更加方便的迭代。
// 关注点分别是键,值,映射。
transient Set<K>        keySet;  // AbstractMap的成员
transient Collection<V> values; // AbstractMap的成员
transient Set<Map.Entry<K,V>> entrySet;

// 元素个数,注意和内部数组长度区分开来。
transient int size;

// 容器结构的修改次数,fail-fast机制。
transient int modCount;

// 阈值,超过这个值时扩充数组。 threshold = capacity * load factor(容量*负载因子)。
int threshold;

// 装载因子,具体看上面的静态常量。
final float loadFactor;

在一般情况下,需要使用无参方法创建HashMap。但当我们对时间和空间复杂度有要求的时候,使用默认值可能达不到要求,需要手动调参。在HashMap的构造方法中,可以调参的参数有两个,一个是初始容量,另一个是负载因子。通过设定这两个参数,可以进一步影响阀值大小。但是初始阀值threshold仅由初始容量经过移位操作计算得出的。

负载因子(loadFactor)。对于 HashMap 来说,负载因子是一个很重要的参数,该参数反应了 HashMap 桶数组的使用情况(假设键值对节点均匀分布在桶数组中)。通过调节负载因子,可使 HashMap 时间和空间复杂度上有不同的表现。当我们调低负载因子时,HashMap 所能容纳的键值对数量变少。扩容时,重新将键值对存储新的桶数组里,键的键之间产生的碰撞会下降,链表长度变短。此时,HashMap 的增删改查等操作的效率将会变高,这里是典型的拿空间换时间。相反,如果增加负载因子(负载因子可以大于1),HashMap 所能容纳的键值对数量变多,空间利用率高,但碰撞率也高。这意味着链表长度变长,效率也随之降低,这种情况是拿时间换空间。至于负载因子怎么调节,这个看使用场景了。一般情况下,我们用默认值就可以了。

Node和TreeNode:

/** 
  * Node  = HashMap的内部类,实现了Map.Entry接口,本质是 = 一个映射(键值对)
  * 实现了getKey()、getValue()、equals(Object o)和hashCode()等方法
  **/  

  static class Node<K,V> implements Map.Entry<K,V> {

        final int hash; // 哈希值,HashMap根据该值确定记录的位置
        final K key; // key
        V value; // value
        Node<K,V> next;// 链表下一个节点

        // 构造方法
        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 V setValue(V newValue) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }

      /** 
        * hashCode() 
        */
        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

      /** 
        * equals()
        * 作用:判断2个Entry是否相等,必须key和value都相等,才返回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;
        }
    }

TreeNode是Node的子类,继承关系如下:Node是单向链表节点,Entry是双向链表节点,TreeNode是红黑树节点。下面会对实现红黑树进行讲解。

java.util.HashMap<K, V>.Node<K, V>
    java.util.LinkedMap<K, V>.Entry<K, V>
        java.util.HashMap<K, V>.TreeNOde<K, V>
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    
    // 略
}

2.2 构造方法

有四种构造方法,一般都是初始化一些重要变量,比如loadFactor和threshold。而底层结构则是延迟到插入键值对时再进行初始化:

/** 构造方法 1 */
//构造一个空的HashMap,具有默认的初始容量16和默认的负载因子0.75
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

/** 构造方法 2 */
//构造一个空的HashMap,具有指定的容量和默认的负载因子
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

/** 构造方法 3 */
//构造一个空的HashMap,具有指定的容量和负载因子
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

/** 构造方法 4 */
//将一个Map的映射拷贝到自己的存储结构
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

默认情况下,HashMap的初始容量为16,装载因子为0.75。没有默认阀值,原因是阀值可由容量*装载因子计算(threshold=capacity*loadFactor)。查看构造方法3,会发现阀值没有通过这个公式得到,而是通过一个方法tableSizeFor计算。后面在分析扩容方法时,再来解释这个问题。

tableSizefor方法:主要功能是返回一个比给定整数大且最接近的2的幂次方整数,如给定10,返回2的4次方16。

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

下面对这个方法进行解析:

    int n = cap - 1;

为什么要对cap减一呢?这是为了防止cap已经是2的次幂。如果cap已经是2的次幂,没有进行减一的操作,在经过下面几次无符号右移操作后,返回的capacity将是cap的2倍。

如果n此时为0(cap减一后),进行右移操作后依然为0,最后返回的capacity是1。

下面讨论不为0的情况:

第一次右移:

  n |= n >>> 1;

上面已经说过讨论的是不为0的情况,所有n的二进制表示中肯定有1bit不为0。假设最高位的1,通过无符号右移一位,则将最高位的1右移一位,再做或操作,使得n的二进制表示中与最高位紧邻的右边一位也为1,如000011xxxxxx。

第二次右移:

   n |= n >>> 2;

假设此时的n为000011xxxxxx,则n无符号右移两位,会将最高位的两个连续的1右移两位,再与原来的n做或操作,这样n的二进制表示中的高位会有4为连续的1。如00001111xxxx。

第三个右移:

 n |= n >>> 4;

和上面的操作步骤一样,最后会得到n的二进制表示中会有8个连续的1。

以此类推

容量最大为32bit的数,因次最后的 n |= n >>> 16;最多也就32个1(但是这已经是负数了。在执行tableSizeFor之前,对initialCapacity做了判断,如果大于MAXIMUM_CAPACITY(2 ^ 30),则取MAXIMUM_CAPACITY。如果等于MAXIMUM_CAPACITY(2 ^ 30),会执行移位操作。所以这里面的移位操作之后,最大30个1,不会大于等于MAXIMUM_CAPACITY。30个1,加1之后得2 ^ 30) 。

举个例子:

è¿éåå¾çæè¿°

2.3 查找

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 1. 定位键值对所在桶的位置
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
       ///找到了键值对在的桶的位置放到first中
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            // 2. 如果 first 是 TreeNode 类型,则调用黑红树查找方法
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                
            // 2. 对链表进行查找
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

首先先定位到键值对所在的桶位置,然后在这个桶里面查找,分为两种情况:一种是红黑树节点的查找,另一种是链表节点的查找。

这里说的定义键值对所在的桶位置,是这样计算的:first = tab[(n - 1) & hash]。原因是因为:“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率。

计算hash的方法:

/**
 * 计算键的 hash 值
 */
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

在这个方法中重新通过位运算重新计算hash,为什么不直接用键的hashCode方法产生的hash呢?查过资料之后解释如下(如下内容来自网络-知乎胖胖的答案):

这段代码叫“扰动函数”

大家都知道上面代码里的key.hashCode()函数调用的是key键值类型自带的哈希函数,返回int型散列值。理论上散列值是一个int型,如果直接拿散列值作为下标访问HashMap主数组的话,考虑到2进制32位带符号的int表值范围从-21474836482147483648。前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。

      但问题是一个40亿长度的数组,内存是放不下的。你想,HashMap扩容之前的数组初始大小才16。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来访问数组下标。源码中模运算是在这个indexFor( )函数里完成的。

bucketIndex = indexFor(hash, table.length);

indexFor的代码也很简单,就是把散列值和数组长度做一个"与"操作,

static int indexFor(int h, int length) {
        return h & (length-1);
}

顺便说一下,这也正好解释了为什么HashMap的数组长度要取2的整数幂。因为这样(数组长度-1)正好相当于一个“低位掩码”。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。

    10100101 11000100 00100101
&   00000000 00000000 00001111
----------------------------------
    00000000 00000000 00000101    //高位全部归零,只保留末四位

但这时候问题就来了,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。更要命的是如果散列本身做得不好,分布上成等差数列的漏洞,恰好使最后几个低位呈现规律性重复,就无比蛋疼。

例如:我们创建一个hashmap,其entry数组为默认大小16。现在有一个key、value的pair需要存储到hashmap里,该key的hashcode是0ABC0000(8个16进制数,共32位),如果不经过hash函数处理这个hashcode,这个pair过会儿将会被存放在entry数组中下标为0处。下标=ABCD0000 & (16-1) = 0。然后我们又要存储另外一个pair,其key的hashcode是0DEF0000,得到数组下标依然是0。想必你已经看出来了,这是个实现得很差的hash算法,因为hashcode的1位全集中在前16位了,导致算出来的数组下标一直是0。于是,明明key相差很大的pair,却存放在了同一个链表里,导致以后查询起来比较慢。

这个时候“扰动函数”的价值就体现出来了,说到这里大家应该猜出来了。看下面这个图,

右位移16位,正好是32bit的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。

这样重新计算hash有两个好处:一个是加大了低位信息的随机性,变相的让高位数据参与到计算中。另一个好处是可以增加 hash 的复杂度。当我们覆写 hashCode 方法时,可能会写出分布性不佳的 hashCode 方法,进而导致 hash 的冲突率比较高。通过移位和异或运算,可以让 hash 变得更复杂,进而影响 hash 的分布性。

2.4 遍历

1.遍历keySets:

for(Object key : map.keySet()) {
    // do something
}

2.遍历entrySet

for(HashMap.Entry entry : map.entrySet()) {
    // do something
}

一般都是对 HashMap 的 key 集合或 Entry 集合进行遍历。上面代码片段中用 foreach 遍历 keySet 方法产生的集合,在编译时会转换成用迭代器遍历,等价于第三种遍历方法。

3.迭代器遍历

Set keys = map.keySet();
Iterator ite = keys.iterator();
while (ite.hasNext()) {
    Object key = ite.next();
    // do something
}

下面以第一种遍历方法为例,来详细了解遍历的过程。

keySet方法:

public Set<K> keySet() {
        //keySet是在AbstractMap中定义的一个set值
        Set<K> ks = keySet;
        //如果keySet有值就返回,如果为null就返回KeySet的一个对象
        return (ks != null ? ks : (keySet = new KeySet()));
    }

keySet是在AbstractMap中定义的一个set值。如果keySet不为null,就返回keySet;如果keySet的值为null,就生成KeySet类的一个对象,把这个对象赋值给keySet,并作为结果返回。

KeySet类:

private final class KeySet extends AbstractSet<K> {
        //实现迭代器
        public Iterator<K> iterator() {
            return newKeyIterator();
        }
        //实现size()方法,直接返回HashMap的大小
        public int size() {
            return size;
        }
        //实现contains方法,直接调用HashMap的containsKey方法
        public boolean contains(Object o) {
            return containsKey(o);
        }
        //实现remove方法,直接调用HashMap的removeEntryForKey方法
        public boolean remove(Object o) {
            return HashMap.this.removeEntryForKey(o) != null;
        }
        //实现clear方法,直接调用HashMap的clear方法
        public void clear() {
            HashMap.this.clear();
        }
    }

在实现迭代器中,只是new了一个KeyIterator对象,并把它返回。

KeyIterator对象:

    private final class KeyIterator extends HashIterator<K> {
        public K next() {
            return nextEntry().getKey();
        }
    }

KeyIterator 继承了HashIterator,而且只有一个简单的next方法。我们都知道,迭代器接口定义了三个方法,即next(),hasNext()和remove()。next()方法是得到容器里的下一个数据,hasNext()是判断容器里是否还有数据,remove()是删除容器中的数据。KeyIterator 只实现了next()方法,说明其他的方法肯定是在HashIterator中实现的。我们先看HashIterator的实现代码:
 

    private abstract class HashIterator<E> implements Iterator<E> {
        Entry<K,V> next;    //下一个Entry
        int expectedModCount;   //用于多线程中
        int index;      // 索引
        Entry<K,V> current; //  当前entry

        HashIterator() {
            expectedModCount = modCount;
            //构造函数,得到第一个Entry
            if (size > 0) {
                //table就是HashMap的底层数组 
                Entry[] t = table;
                //找到table中的第一个Entry并赋值给next
                while (index < t.length && (next = t[index++]) == null)
                    ;
            }
        }

        public final boolean hasNext() {
            //实现接口中的hasNext()方法,直接判断next是否等于null
            return next != null;
        }

        //寻找下一个Entry
        final Entry<K,V> nextEntry() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            //在构造函数中next已经指向第一个Entry,所以最开始e就是第一个Entry
            Entry<K,V> e = next;
            if (e == null)
                throw new NoSuchElementException();
            //寻找到下一个Entry并赋值给next
            if ((next = e.next) == null) {
                Entry[] t = table;
                while (index < t.length && (next = t[index++]) == null)
                    ;
            }
            current = e;
            //把e返回,如果一直调用此方法,相当于从第一个Entry开始,逐个返回
            return e;
        }

        //实现接口中的remove()方法,调用HashMap中的removeEntryForKey方法
        public void remove() {
            if (current == null)
                throw new IllegalStateException();
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            Object k = current.key;
            current = null;
            HashMap.this.removeEntryForKey(k);
            expectedModCount = modCount;
        }

    }

HashIterator定义了四个字段,expectedModCount忽略不管,next存储的是下一个Entry,index储存的是next值在table中索引的下一个值,current是当前Entry。remove和next方法比较简单,就不再详述了,主要是看构造函数和nextEntry方法。构造函数的作用就是找到table中存储的第一个Entry,并赋值给next。因为HashMap往table中存储是不连续的,所以找到table中第一个不为null的值,即是我们要找的。nextEntry()的作用是寻找下一个Entry,寻找方法同构造函数,并且会把当前的Entry返回。
      理解了HashIterator之后,我们再回过头来看KeyIterator,它只实现了next方法,next返回的即是当前Entry的key。所以,当我们用增强型for循环,去遍历KeySet的时候,就会调用hasNext()方法和next方法,去遍历table中的所有key。

在遍历 HashMap 的过程中会发现,多次对 HashMap 进行遍历时,遍历结果顺序都是一致的。但这个顺序和插入的顺序一般都是不一致的。产生上述行为的原因是怎样的呢?

package test;

import java.util.HashMap;

public class HashMapTest {

    public static void testTraversal() {
        HashMap<Integer, String> map = new HashMap(16);
        map.put(7, "");
        map.put(11, "");
        map.put(43, "");
        map.put(59, "");
        map.put(19, "");
        map.put(3, "");
        map.put(35, "");

        System.out.println("遍历结果:");
        for (Integer key : map.keySet()) {
            System.out.print(key + " -> ");
        }
    }

    public static void main(String[] args) {
        testTraversal();
    }
}

遍历结果为:

根据源码分析:遍历所有的键时,首先要获取键集合KeySet对象,然后再通过 KeySet 的迭代器KeyIterator进行遍历。KeyIterator 类继承自HashIterator类,核心逻辑也封装在 HashIterator 类中。HashIterator 在初始化时,会先遍历桶数组,找到包含链表节点引用的桶,对应图中就是3号桶。随后由 nextNode 方法遍历该桶所指向的链表。遍历完3号桶后,nextNode 方法继续寻找下一个不为空的桶,对应图中的7号桶。之后流程和上面类似,直至遍历完最后一个桶。以上就是 HashIterator 的核心逻辑的流程,对应下图:

2.4 插入

2.4.1插入逻辑分析

HashMap的插入流程是先定位要插入的键值对属于哪个桶,定位到桶后,再判断桶是否为空。如果为空的话,将键值对存入就行。不为空的话,则需要将键值对接在链表的最后位置(或者是放入红黑树中),或者更新键值对。在插入的过程中要考虑很多问题,例如:扩容和红黑树优化过长链表等问题。这些在下面会详细讲解。

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;
    // 初始化桶数组 table,table 被延迟到插入新数据时再进行初始化
    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);
    else {
        Node<K,V> e; K k;
        // 如果键的值以及节点 hash 等于链表中的第一个键值对节点时,则将 e 指向该键值对
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
            
        // 如果桶中的引用类型为 TreeNode,则调用红黑树的插入方法
        else if (p instanceof TreeNode)  
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 对链表进行遍历,并统计链表长度
            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;
                }
                
                // 条件为 true,表示当前链表包含要插入的键值对,终止遍历
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        
        // 判断要插入的键值对是否存在 HashMap 中
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // onlyIfAbsent 表示是否仅在 oldValue 为 null 的情况下更新键值对的值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 键值对数量超过阈值时,则进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

putval方法做了:

  1. 当桶数组table为空的时候,通过扩容的方式初始化table。
  2. 查找要插入的键值对是否已经存在,存在的话根据条件判断是否用新值替换旧值。
  3. 如果不存在,将键值对插入链表中,并根据链表长度决定是否将链表转换为红黑树。
  4. 判断键值对数量是否大于阀值,大于的话进行扩容。

2.4.2 扩容机制

在HashMap中,桶数组的长度均是2的幂,阀值大小为桶数组长度与负载因子的乘积。当HashMap中键值对数量超过阀值时,进行扩容。

HashMap进行扩容的时候是按照当前桶数组长度的二倍进行扩容,阀值也变为原来的2倍(如果计算过程中,阀值溢出为0,则按阀值公式重新计算)。扩容之后要重新计算键值对的位置,并把它们移动合适的位置上去。

源码实现:

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 如果 table 不为空,表明已经初始化过了
    if (oldCap > 0) {
        // 当 table 容量超过容量最大值,则不再扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        } 
        // 按旧容量和阈值的2倍计算新容量和阈值的大小
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    } else if (oldThr > 0) // initial capacity was placed in threshold
        /*
         * 初始化时,将 threshold 的值赋值给 newCap,
         * HashMap 使用 threshold 变量暂时保存 initialCapacity 参数的值
         */ 
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        /*
         * 调用无参构造方法时,桶数组容量为默认容量,
         * 阈值为默认容量与默认负载因子乘积
         */
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    // newThr 为 0 时,按阈值计算公式进行计算
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    // 创建新的桶数组,桶数组的初始化也是在这里完成的
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        // 如果旧的桶数组不为空,则遍历桶数组,并将键值对映射到新的桶数组中
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                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 { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 遍历链表,并将链表节点按原顺序进行分组
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 将分组后的链表映射到新桶中
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

上面源码的过程可以总结为:

  1. 计算新桶数组的容量newCap和新阀值newThr。
  2. 根据计算出的newCap创建新的桶数组,桶数组table也是在这里初始化的。
  3. 将键值对节点重新映射到新的桶数组里。如果节点是TreeNode类型,则需要拆分红黑树。如果是普通节点,则节点按原顺序进行分组。

下面对上面求容量和阀值得分支进行讲解:

第一个分支:

//第一个分支:
if (oldCap > 0) {
        // 当 table 容量超过容量最大值,则不再扩容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        } 
        // 按旧容量和阈值的2倍计算新容量和阈值的大小
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    } else if (oldThr > 0) // initial capacity was placed in threshold
        /*
         * 初始化时,将 threshold 的值赋值给 newCap,
         * HashMap 使用 threshold 变量暂时保存 initialCapacity 参数的值
         */ 
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        /*
         * 调用无参构造方法时,桶数组容量为默认容量,
         * 阈值为默认容量与默认负载因子乘积
         */
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }

    

分支一覆盖的情况有:

条件覆盖情况备注
oldCap>0桶数组table已经被初始化 
oldThr>0threshold>0,且桶数组没有被初始化调用构造方法HashMap(int)和HashMap(int,float)会产生这样的情况,此时情况下newCap=oldThr ,newThr会在第二个分支产生。
oldCap==0&&oldThr==0threshold=0,且桶数组未被初始化调用HashMap()构造方法会产生这样情况。

上面说明了会在调用两种构造方法会产生oldThr>0的情况,构造方法源码如下:

/** 构造方法 2 */
//构造一个空的HashMap,具有指定的容量和默认的负载因子
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

/** 构造方法 3 */
//构造一个空的HashMap,具有指定的容量和负载因子
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

在构造方法中会指定负载因子和阀值的值。然后在扩容的时候,会将oldThr赋值给NewCap,等价于newCap=threshold=taleSizefor(initialCapacity)。初始化时传入的initialCapacity参数经过threshold传递给newCap。这样就解决一个问题:initialCapacity参数没有被保存下来,却参与了桶数组的初始化。

第一个分支oldCap>0里有嵌套分支:

条件覆盖情况说明
oldCap>=2^30桶数组容量大于最大桶容量这种情况不可以扩容
newCap<2^30 && oldCap>16新桶数组容量小于最大值,旧桶数组容量大于16这种情况下新阀值newThread=oldThr<<1,移位可能会导致溢出

 

在嵌套分支中,可能因为移位导致溢出。当loadFactord小数位为0,整数位可以被2整除且大于等于8的时候,在某次计算的就可能会导致newThr溢出归零。如下图所示。

第二个分支:

  // newThr 为 0 时,按阈值计算公式进行计算
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
条件覆盖情况备注
newThr==0第一个分支未计算newThr或者嵌套分支在计算过程中导致newThr溢出为0 

理解了newCap和newThr的计算过程,下来看看键值对节点重新映射的过程。

源代码为:

 if (oldTab != null) {
        // 如果旧的桶数组不为空,则遍历桶数组,并将键值对映射到新的桶数组中
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                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 { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 遍历链表,并将链表节点按原顺序进行分组
                    do {
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 将分组后的链表映射到新桶中
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }

在向底层数据结构中插入节点时,一般都是通过模运算计算桶位置,接着把节点放入桶中。事实上,可以把重新映射看做插入操作。首先回忆下,hash求余的过程:

上图中,桶数组大小n为16,hash1与hash2不等。但是因为只有后四位参与求余,所以结果相等。当桶数组扩容后,桶数组大小从16变为32,对上面的hash值重新进行映射:

扩容后,参与模运算的位数从4位变成5位。由于两个hash值得第五位是不一样的,所以算出的结果是不一样的。继续分析:

假设对上面的桶数组进行扩容,扩容后数组大小n为16,重新映射的过程如下:

以此遍历链表(在不为null的情况下),并计算节点hash&oldCap的值,如下图所示:

如果oldCap&hash=0,将loHead和loTail指向这个节点。如果后面还有oldCap&hash为0的话,则将该节点链接到loHead指向的链表,并将loTail指向该节点。如果不为0的话,将hiHead和hiTail指向该节点。如果还有不为0的话,将该节点接到hiHead指向的链表。完成遍历后,可能会得到两条链表,此时就完成链表分组。

最后将这两条链表放入桶中,完成扩容。如下:

从上图可以发现,重新映射后,两条链表中节点的顺序没有发生改变,还是保持了扩容前的顺序。

2.4.3 链表树化,红黑树链化与拆分

链表树化的源代码:

static final int TREEIFY_THRESHOLD = 8;

/**
 * 当桶数组容量小于该值时,优先进行扩容,而不是树化
 */
static final int MIN_TREEIFY_CAPACITY = 64;

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }
}

/**
 * 将普通节点链表转换成树形节点链表
 */
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 桶数组容量小于 MIN_TREEIFY_CAPACITY,优先进行扩容而不是树化
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // hd 为头节点(head),tl 为尾节点(tail)
        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);
    }
}

TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next);
}

在扩容过程中,树化需要满足两个条件:

  • 链表长度大于等于TREEIFY_THRESHOLD
  • 桶数组容量大于等于MIN_TREEIFY_CAPACITY

满足第二个条件的原因:当桶数组容量比较小的时候,键值对节点hash的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立即树化。毕竟高碰撞率是因为桶数组容量比较小引起的,这是主因。容量小时,优先扩容可以避免一些不必要的树化过程。同时,桶容量比较小的时候,扩容会比较频繁,扩容的时候需要拆分红黑树并重新映射。所以在桶容量比较小的时候,将长链表转换成红黑树是一件吃力不讨好的事情。

treeifyBin方法:主要的作用是将普通链表转成由TreeNode型节点组成的链表,并在最后调用treeify方法将链表转换成红黑树。TreeNode继承Node类,所以TreeNode包含next引用,原链表节点顺序最后通过neext引用被保存下来。

treeify方法:将树形链表转换成红黑树。源码如下:

/*创建红黑树*/
    final void treeify(Node<K,V>[] tab) {
            TreeNode<K,V> root = null;// 定义红黑树根节点root
            for (TreeNode<K,V> x = this, next; x != null; x = next) {// 循环,定义x:循环变量,代表当前节点、next:当前节点的后继元
                next = (TreeNode<K,V>)x.next;
                x.left = x.right = null;// 初始化当前节点x的左子节点、右子节点
                if (root == null) {// 若root为空,表明首次循环此时x指向的节点为根节点
                    x.parent = null;// 根节点无父节点
                    x.red = false;// 根节点为黑色
                    root = x;
                }
                else {// 除首次循环外其余均走这个分支
                    K k = x.key;// 获取当前节点的key与hash值
                    int h = x.hash;
                    Class<?> kc = null;// 定义key的Class对象kc
                    for (TreeNode<K,V> p = root;;) {// 循环,每次循环从根节点开始,寻找位置
                        int dir, ph;// 定义节点相对位置、节点p的hash值
                        K pk = p.key;// 获取节点p的key
                        if ((ph = p.hash) > h)
                            dir = -1;// 当前节点在节点p的左子树
                        else if (ph < h)
                            dir = 1;// 当前节点在节点p的右子树
                        else if ((kc == null &&
                                  (kc = comparableClassFor(k)) == null) ||
                                 (dir = compareComparables(kc, k, pk)) == 0)
                            dir = tieBreakOrder(k, pk);// 当前节点与节点p的hash值相等,当前节点key并没有实现Comparable接口或者实现Comparable接口并且与节点pcompareTo相等,该方法是为了保证在特殊情况下节点添加的一致性用于维持红黑树的平衡

                        TreeNode<K,V> xp = p;
                        if ((p = (dir <= 0) ? p.left : p.right) == null) {// 根据dir判断添加位置也是节点p的左右节点,是否为空,若不为null在p的子树上进行下次循环
                            x.parent = xp;// 若添加位置为null,建立当前节点x与父节点xp之间的联系
                            if (dir <= 0)// 确定当前节点时xp的左节点还是右节点
                                xp.left = x;
                            else
                                xp.right = x;
                            root = balanceInsertion(root, x);// 对红黑是进行平衡操作并结束循环
                            break;
                        }
                    }
                }
            }
            moveRootToFront(tab, root);// 将红黑树根节点复位至数组头结点
        }

HashMap刚开始设计的时候,没有考虑到后面后引入红黑树进行优化。所以并没有像TreeMap一样,要求键类实现comparable接口或者提供相应的比较器。由于树化的过程中需要比较两个键对象的大小,在键类没有实现comparable接口的情况下,HashMap做了三部处理,确保可以比较两个键的大小,如下:

  1. 比较键与键之间的hash的大小。如果hash相同,继续向下比较。
  2. 检测键类是否实现了comparable接口,如果实现调用compareTo方法进行比较。
  3. 如果仍旧没有比较出大小,就需要仲裁了,仲裁的方法为tieBreakOrder

源代码如下:

/**
* 如果对象x的类是C,如果C实现了Comparable<C>接口,那么返回C,否则返回null
*/
static Class<?> comparableClassFor(Object x) {
    if (x instanceof Comparable) {
        Class<?> c; Type[] ts, as; Type t; ParameterizedType p;
        if ((c = x.getClass()) == String.class) // 如果x是个字符串对象
            return c; // 返回String.class
        /*
         * 为什么如果x是个字符串就直接返回c了呢 ? 因为String  实现了 Comparable 接口,可参考如下String类的定义
         * public final class String implements java.io.Serializable, Comparable<String>, CharSequence
         */ 
 
        // 如果 c 不是字符串类,获取c直接实现的接口(如果是泛型接口则附带泛型信息)    
        if ((ts = c.getGenericInterfaces()) != null) {
            for (int i = 0; i < ts.length; ++i) { // 遍历接口数组
                // 如果当前接口t是个泛型接口 
                // 如果该泛型接口t的原始类型p 是 Comparable 接口
                // 如果该Comparable接口p只定义了一个泛型参数
                // 如果这一个泛型参数的类型就是c,那么返回c
                if (((t = ts[i]) instanceof ParameterizedType) &&
                    ((p = (ParameterizedType)t).getRawType() ==
                        Comparable.class) &&
                    (as = p.getActualTypeArguments()) != null &&
                    as.length == 1 && as[0] == c) // type arg is c
                    return c;
            }
            // 上面for循环的目的就是为了看看x的class是否 implements  Comparable<x的class>
        }
    }
    return null; // 如果c并没有实现 Comparable<c> 那么返回空
}

/**
* 如果x所属的类是kc,返回k.compareTo(x)的比较结果
* 如果x为空,或者其所属的类不是kc,返回0
*/
@SuppressWarnings({"rawtypes","unchecked"}) // for cast to Comparable
static int compareComparables(Class<?> kc, Object k, Object x) {
    return (x == null || x.getClass() != kc ? 0 :
            ((Comparable)k).compareTo(x));
}

/**
* 用这个方法来比较两个对象,返回值要么大于0,要么小于0,不会为0
* 也就是说这一步一定能确定要插入的节点要么是树的左节点,要么是右节点,不然就无法继续满足二叉树结构了
* 
* 先比较两个对象的类名,类名是字符串对象,就按字符串的比较规则
* 如果两个对象是同一个类型,那么调用本地方法为两个对象生成hashCode值,再进行比较,hashCode相等的话返回-1
*/
static int tieBreakOrder(Object a, Object b) {
    int d;
    if (a == null || b == null ||
        (d = a.getClass().getName().
            compareTo(b.getClass().getName())) == 0)
        d = (System.identityHashCode(a) <= System.identityHashCode(b) ?
                -1 : 1);
    return d;
}

在转换成红黑树后,要对现在形成红黑树进行平衡,balanceInsertion就是对红黑树进行平衡的作用,源码如下(红黑树新插入节点的情况和旋转方法在TreeMap的源码在中有详细讲解):

/**
 * 红黑树添加节点后,平衡的过程:
 * 1.新插入的节点的父节点为null,将新节点颜色设置为黑色
 * 2.新插入的节点的父节点颜色为black,则不需要调整
 * 3.新插入的节点的父节点是祖父节点的左孩子
 *     1).叔父节点(祖父节点的右孩子)不为空且颜色为red,将新插入的节点的父节点和叔父节点颜色设置 
 * 为black,祖父节点颜色设置为red,将祖父节点设置为新插入的节点,重新开始判断
 *     2).叔父节点(祖父节点的右孩子)为空或者颜色为black,新增节点为父节点的右孩子,对父亲节点进 
 * 行左旋,将父亲节点作为新插 入的节点,再次进行调整
 *     3).新增节点是父节点的左孩子,父亲节点颜色设为black,祖父节点颜色设为red,对祖父节点进行右旋
 * 4.新插入的节点的父节点是祖父节点的右孩子
 *     1).叔父节点(祖父节点的左孩子)不为空且颜色为red,将新插入的节点的父节点和叔父节点颜色设置 
 * 为black,祖父节点颜色设置为red,将祖父节点设置为新插入的节点,重新开始判断
 *     2).叔父节点(祖父节点的左孩子)为空或颜色为black,新增节点为父节点的左孩子,对父节点进行
 * 右旋,将父亲节点作为新插 入的节点,再次进行调整
 *     3).新增节点是父节点的右孩子,父亲节点颜色设为black,祖父节点颜色设为red,对祖父节点进行右旋
 */  

/**
 * 红黑树插入节点后,需要重新平衡
 * root 当前根节点
 * x 新插入的节点
 * 返回重新平衡后的根节点
 */
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
                                                    TreeNode<K,V> x) {
    x.red = true; // 新插入的节点标为红色
 
    /*
     * 这一步即定义了变量,又开起了循环,循环没有控制条件,只能从内部跳出
     * xp:当前节点的父节点、xpp:爷爷节点、xppl:左叔叔节点、xppr:右叔叔节点
     */
    for (TreeNode<K,V> xp, xpp, xppl, xppr;;) { 
 
        // 如果父节点为空、说明当前节点就是根节点,那么把当前节点标为黑色,返回当前节点
        if ((xp = x.parent) == null) { 
            x.red = false;
            return x;
        }
 
        // 父节点不为空
        // 如果父节点为黑色 或者 【(父节点为红色 但是 爷爷节点为空)】
        else if (!xp.red || (xpp = xp.parent) == null) 
            return root;
        if (xp == (xppl = xpp.left)) { // 如果父节点是爷爷节点的左孩子  
            if ((xppr = xpp.right) != null && xppr.red) { // 如果右叔叔不为空 并且 为红色  // L3_1
                xppr.red = false; // 右叔叔置为黑色
                xp.red = false; // 父节点置为黑色
                xpp.red = true; // 爷爷节点置为红色
                x = xpp; // 运行到这里之后,就又会进行下一轮的循环了,将爷爷节点当做处理的起始节点 
            }
            else { // 如果右叔叔为空 或者 为黑色 
                if (x == xp.right) { // 如果当前节点是父节点的右孩子 
                    root = rotateLeft(root, x = xp); // 父节点左旋
                    xpp = (xp = x.parent) == null ? null : xp.parent; // 获取爷爷节点
                }
                if (xp != null) { // 如果父节点不为空 
                    xp.red = false; // 父节点 置为黑色
                    if (xpp != null) { // 爷爷节点不为空
                        xpp.red = true; // 爷爷节点置为 红色
                        root = rotateRight(root, xpp);  //爷爷节点右旋,见下文右旋方法解析
                    }
                }
            }
        }
        else { // 如果父节点是爷爷节点的右孩子 
            if (xppl != null && xppl.red) { // 如果左叔叔是红色 
                xppl.red = false; // 左叔叔置为 黑色
                xp.red = false; // 父节点置为黑色
                xpp.red = true; // 爷爷置为红色
                x = xpp; // 运行到这里之后,就又会进行下一轮的循环了,将爷爷节点当做处理的起始节点 
            }
            else { // 如果左叔叔为空或者是黑色
                if (x == xp.left) { // 如果当前节点是个左孩子
                    root = rotateRight(root, x = xp); // 针对父节点做右旋,见下文右旋方法解析
                    xpp = (xp = x.parent) == null ? null : xp.parent; // 获取爷爷节点
                }
                if (xp != null) { // 如果父节点不为空 
                    xp.red = false; // 父节点置为黑色
                    if (xpp != null) { //如果爷爷节点不为空
                        xpp.red = true; // 爷爷节点置为红色
                        root = rotateLeft(root, xpp); // 针对爷爷节点做左旋
                    }
                }
            }
        }
    }
}
 
 
/**
 * 节点左旋
 * root 根节点
 * p 要左旋的节点
 */
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,
                                              TreeNode<K,V> p) {
    TreeNode<K,V> r, pp, rl;
    if (p != null && (r = p.right) != null) { // 要左旋的节点以及要左旋的节点的右孩子不为空
        if ((rl = p.right = r.left) != null) // 要左旋的节点的右孩子的左节点 赋给 要左旋的节点的右孩子 节点为:rl
            rl.parent = p; // 设置rl和要左旋的节点的父子关系【之前只是爹认了孩子,孩子还没有答应,这一步孩子也认了爹】
 
        // 将要左旋的节点的右孩子的父节点  指向 要左旋的节点的父节点,相当于右孩子提升了一层,
        // 此时如果父节点为空, 说明r 已经是顶层节点了,应该作为root 并且标为黑色
        if ((pp = r.parent = p.parent) == null) 
            (root = r).red = false;
        else if (pp.left == p) // 如果父节点不为空 并且 要左旋的节点是个左孩子
            pp.left = r; // 设置r和父节点的父子关系【之前只是孩子认了爹,爹还没有答应,这一步爹也认了孩子】
        else // 要左旋的节点是个右孩子
            pp.right = r; 
        r.left = p; // 要左旋的节点  作为 他的右孩子的左节点
        p.parent = r; // 要左旋的节点的右孩子  作为  他的父节点
    }
    return root; // 返回根节点
}
 
/**
 * 节点右旋
 * root 根节点
 * p 要右旋的节点
 */
static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root,
                                               TreeNode<K,V> p) {
    TreeNode<K,V> l, pp, lr;
    if (p != null && (l = p.left) != null) { // 要右旋的节点不为空以及要右旋的节点的左孩子不为空
        if ((lr = p.left = l.right) != null) // 要右旋的节点的左孩子的右节点 赋给 要右旋节点的左孩子 节点为:lr
            lr.parent = p; // 设置lr和要右旋的节点的父子关系【之前只是爹认了孩子,孩子还没有答应,这一步孩子也认了爹】
 
        // 将要右旋的节点的左孩子的父节点  指向 要右旋的节点的父节点,相当于左孩子提升了一层,
        // 此时如果父节点为空, 说明l 已经是顶层节点了,应该作为root 并且标为黑色
        if ((pp = l.parent = p.parent) == null) 
            (root = l).red = false;
        else if (pp.right == p) // 如果父节点不为空 并且 要右旋的节点是个右孩子
            pp.right = l; // 设置l和父节点的父子关系【之前只是孩子认了爹,爹还没有答应,这一步爹也认了孩子】
        else // 要右旋的节点是个左孩子
            pp.left = l; // 同上
        l.right = p; // 要右旋的节点 作为 他左孩子的右节点
        p.parent = l; // 要右旋的节点的父节点 指向 他的左孩子
    }
    return root;
}

TreeNode在增加或删除节点后,都需要对整个树重新进行平衡,平衡之后的根节点也许就会发生变化,此时为了保证:如果HashMap元素数组根据下标取得的元素是一个TreeNode类型,那么这个TreeNode节点一定要是这颗树的根节点,同时也要是整个链表的首节点。moveRootToFront方法就是实现根节点是链表的首节点,源代码如下:

/**
 * 把红黑树的根节点设为  其所在的数组槽 的第一个元素
 * 首先明确:TreeNode既是一个红黑树结构,也是一个双链表结构
 * 这个方法里做的事情,就是保证树的根节点一定也要成为链表的首节点
 */

  //如果当前索引节点不是root节点,则将root节点的下一个节点和上一个节点进行关联
  //把root节点放到头节点位置,原头节点放到root节点的next位置

static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) {
    int n;
    if (root != null && tab != null && (n = tab.length) > 0) { // 根节点不为空 并且 HashMap的元素数组不为空
        int index = (n - 1) & root.hash; // 根据根节点的Hash值 和 HashMap的元素数组长度  取得根节点在数组中的位置
        TreeNode<K,V> first = (TreeNode<K,V>)tab[index]; // 首先取得该位置上的第一个节点对象
        if (root != first) { // 如果该节点对象 与 根节点对象 不同
            Node<K,V> rn; // 定义根节点的后一个节点
            tab[index] = root; // 把元素数组index位置的元素替换为根节点对象
            TreeNode<K,V> rp = root.prev; // 获取根节点对象的前一个节点
            if ((rn = root.next) != null) // 如果后节点不为空 
                ((TreeNode<K,V>)rn).prev = rp; // root后节点的前节点  指向到 root的前节点,相当于把root从链表中摘除
            if (rp != null) // 如果root的前节点不为空
                rp.next = rn; // root前节点的后节点 指向到 root的后节点
            if (first != null) // 如果数组该位置上原来的元素不为空
                first.prev = root; // 这个原有的元素的 前节点 指向到 root,相当于root目前位于链表的首位
            root.next = first; // 原来的第一个节点现在作为root的下一个节点,变成了第二个节点
            root.prev = null; // 首节点没有前节点
        }
 
        /*
         * 这一步是防御性的编程
         * 校验TreeNode对象是否满足红黑树和双链表的特性
         * 如果这个方法校验不通过:可能是因为用户编程失误,破坏了结构(例如:并发场景下);也可能是TreeNode的实现有问题(这个是理论上的以防万一);
         */ 
        assert checkInvariants(root); 
    }
}

红黑树拆分

扩容后,普通节点需要重新映射,红黑树节点也不例外,按照一般思路,可以将红黑树转换成链表,之后再重新映射链表即可。不同一般的处理思路,在将普通链表转换成红黑树时,HashMap通过两个额外的引用next和prev保留了原链表的节点顺序。这样再对红黑树进行映射时,完全可以按照映射链表的方式进行,提高了效率。源码实现如下:

// 红黑树转链表阈值
static final int UNTREEIFY_THRESHOLD = 6;

final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
    TreeNode<K,V> b = this;
    // Relink into lo and hi lists, preserving order
    TreeNode<K,V> loHead = null, loTail = null;
    TreeNode<K,V> hiHead = null, hiTail = null;
    int lc = 0, hc = 0;
    /* 
     * 红黑树节点仍然保留了 next 引用,故仍可以按链表方式遍历红黑树。
     * 下面的循环是对红黑树节点进行分组,与上面类似
     */
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        next = (TreeNode<K,V>)e.next;
        e.next = null;
        if ((e.hash & bit) == 0) {
            if ((e.prev = loTail) == null)
                loHead = e;
            else
                loTail.next = e;
            loTail = e;
            ++lc;
        }
        else {
            if ((e.prev = hiTail) == null)
                hiHead = e;
            else
                hiTail.next = e;
            hiTail = e;
            ++hc;
        }
    }

    if (loHead != null) {
        // 如果 loHead 不为空,且链表长度小于等于 6,则将红黑树转成链表
        if (lc <= UNTREEIFY_THRESHOLD)
            tab[index] = loHead.untreeify(map);
        else {
            tab[index] = loHead;
            /* 
             * hiHead == null 时,表明扩容后,
             * 所有节点仍在原位置,树结构不变,无需重新树化
             */
            if (hiHead != null) 
                loHead.treeify(tab);
        }
    }
    // 与上面类似
    if (hiHead != null) {
        if (hc <= UNTREEIFY_THRESHOLD)
            tab[index + bit] = hiHead.untreeify(map);
        else {
            tab[index + bit] = hiHead;
            if (loHead != null)
                hiHead.treeify(tab);
        }
    }
}

从源码可以看出,重新映射红黑树的逻辑与重新映射链表的逻辑基本一致。不同的地方在于,重新映射后,会将红黑树拆分成两条由TreeNode组成的链表。如果链表长度小于 UNTREEIFY_THRESHOLD,则将链表转换成普通链表。否则根据条件重新将 TreeNode 链表树化。

红黑树链化

红黑树保留了原链表的节点顺序,将红黑树转换成链表只需要将TreeNode链表转换成Node链表。源码如下:

final Node<K,V> untreeify(HashMap<K,V> map) {
    Node<K,V> hd = null, tl = null;
    // 遍历 TreeNode 链表,并用 Node 替换
    for (Node<K,V> q = this; q != null; q = q.next) {
        // 替换节点类型
        Node<K,V> p = map.replacementNode(q, null);
        if (tl == null)
            hd = p;
        else
            tl.next = p;
        tl = p;
    }
    return hd;
}

Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
    return new Node<>(p.hash, p.key, p.value, next);
}

2.5 删除

HashMap的删除需要三个步骤。第一步是定位桶位置,第二步遍历链表并找到键值相等的节点,第三步删除节点。源码如下:

public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        // 1. 定位桶位置
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        // 如果键的值与链表第一个节点相等,则将 node 指向该节点
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {  
            // 如果是 TreeNode 类型,调用红黑树的查找逻辑定位待删除节点
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                // 2. 遍历链表,找到待删除节点
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        
        // 3. 删除节点,并修复链表或红黑树
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

因为要删除的节点有可能是TreeNode类型,所以调用红黑树查找逻辑寻找待删除的节点,源码如下:

/*获取红黑树的指定节点*/
        final TreeNode<K,V> getTreeNode(int h, Object k) {
            return ((parent != null) ? root() : this).find(h, k, null);// 从根节点开始查询
        }
        
        /*获取红黑树指定节点*/
        final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
            TreeNode<K,V> p = this;// 此节点p就是根节点,进入循环后p代表当前节点
            do {
                int ph, dir; K pk;// 定义当前节点p的hash值ph、相对位置dir、key
                TreeNode<K,V> pl = p.left, pr = p.right, q// 获取当前节点的左子节点、右子节点
                if ((ph = p.hash) > h)// 表明目标节点在当前节点的左子节点
                    p = pl;
                else if (ph < h)// 表明目标节点在当前节点的右子节点
                    p = pr;
                else if ((pk = p.key) == k || (k != null && k.equals(pk)))// 当前节点的hash值与目标节点hash值相等,且当前节点的key与目标key相等(equals)
                    return p;
                else if (pl == null)// 当前节点的hash值与目标节点hash值相等,且当前节点的key与目标key不相等(equals)
                    p = pr;
                else if (pr == null)
                    p = pl;
                else if ((kc != null ||
                          (kc = comparableClassFor(k)) != null) &&
                         (dir = compareComparables(kc, k, pk)) != 0)// 当前节点的hash值与目标节点hash值相等,且当前节点的key与目标key不相等(equals),且左子节点与右子节点均不为null,目标key实现Comparable接口,且与当前节点比较不为0
                    p = (dir < 0) ? pl : pr;
                else if ((q = pr.find(h, k, kc)) != null)// 当前节点的hash值与目标节点hash值相等,且当前节点的key与目标key不相等(equals),且左子节点与右子节点均不为null,目标key没有实现Comparable接口,则直接在右子树中查询,这个方法并没有在左子树中循环,因为这是一个递归方法,先遍历右子树并判断是否查找到,若无则将左子树根节点作为当前节点,不用遍历左子树依然可以覆盖全部情况
                    return q;
                else
                    p = pl;
            } while (p != null);
            return null;// 未找到,返回null
        }

删除红黑树节点的逻辑与TreeMap中讲到的逻辑基本一致。但是在HashMap中,如果删除节点后节点太少会将红黑树链表转换成普通链表。

删除逻辑分析:

在进行删除前,满足四个情况需要:root==null,root.right==null,root.left==null,root.left.left=null。下面以七个节点的红黑树进行分析,A为根节点。

 所以进入这个方法主要有以下几种情况,我们一种一种的来分析当满足要求时,节点的个数。(这里默认认为知道红黑树的5个特点,主要是黑平衡)

  当这四种情况都满足时,我们可以看出最多节点有如上图所示个数,可以为7个。(大于8个不考虑,因为大于8会变成红黑树)。

    1.最多节点情况:当我们删除节点D时,只满足root.left.left==null这个条件,这棵树仍可以维持红黑树的特点,这时的最大节点数为6.

    2.最少节点情况:当EFG不存在时,在A,B,C,D中删除任意一个节点,都会满足上述四种规则中的一种。则存在最少节点情况,有3个节点。

  以上情况都是会将树转化成链表,此时的节点是 3<= nodes <=6 ,由此可以看出,当节点数在小于6时,是可能转化成链表,但不是绝对情况, 所以使用定义的变量(固定数量6)也不正确。只好通过判断去动态获取节点数。

2.6 其他细节

2.6.1被transient所修饰table变量

仔细阅读源码,会发现桶数组 table 被申明为 transient。transient 表示易变的意思,在 Java 中,被该关键字修饰的变量不会被默认的序列化机制序列化。我们再回到源码中,考虑一个问题:桶数组 table 是 HashMap 底层重要的数据结构,不序列化的话,别人还怎么还原呢?

这里简单说明一下吧,HashMap 并没有使用默认的序列化机制,而是通过实现readObject/writeObject两个方法自定义了序列化的内容。这样做是有原因的,试问一句,HashMap 中存储的内容是什么?不用说,大家也知道是键值对。所以只要我们把键值对序列化了,我们就可以根据键值对数据重建 HashMap。有的朋友可能会想,序列化 table 不是可以一步到位,后面直接还原不就行了吗?这样一想,倒也是合理。但序列化 talbe 存在着两个问题:

  1. table 多数情况下是无法被存满的,序列化未使用的部分,浪费空间
  2. 同一个键值对在不同 JVM 下,所处的桶位置可能是不同的,在不同的 JVM 下反序列化 table 可能会发生错误。

以上两个问题中,第一个问题比较好理解,第二个问题解释一下。HashMap 的get/put/remove等方法第一步就是根据 hash 找到键所在的桶位置,但如果键没有覆写 hashCode 方法,计算 hash 时最终调用 Object 中的 hashCode 方法。但 Object 中的 hashCode 方法是 native 型的,不同的 JVM 下,可能会有不同的实现,产生的 hash 可能也是不一样的。也就是说同一个键在不同平台下可能会产生不同的 hash,此时再对在同一个 table 继续操作,就会出现问题。

综上所述,大家应该能明白 HashMap 不序列化 table 的原因了。

2.6.2为什么在小于6的时候可能转换成链表,而在大于8的时候转化成红黑树?

HashMap在JDK1.8及以后的版本中引入了红黑树结构,若桶中链表元素个数大于等于8时,链表转换成树结构;若桶中链表元素个数小于等于6时,树结构还原成链表。因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要。链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。

还有选择6和8,中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

2.6.3 哈希表是如何解决hash冲突

2.6.4 为什么HashMap具备下述特点:键-值(key-value)都允许为空、线程不安全、不保证有序、存储位置随时间变化

  • HashMap线程不安全的一个重要原因:多线程容易出现resize()死循环。本质是并发执行put()操作导致触发扩容行为,从而导致环形链表,使得在获取数据遍历链表时形成死循环。
/**
   * 源码分析:resize(2 * table.length)
   * 作用:当容量不足时(容量 > 阈值),则扩容(扩到2倍)
   */ 
   void resize(int newCapacity) {  
    
    // 1. 保存旧数组(old table) 
    Entry[] oldTable = table;  

    // 2. 保存旧容量(old capacity ),即数组长度
    int oldCapacity = oldTable.length; 

    // 3. 若旧容量已经是系统默认最大容量了,那么将阈值设置成整型的最大值,退出    
    if (oldCapacity == MAXIMUM_CAPACITY) {  
        threshold = Integer.MAX_VALUE;  
        return;  
    }  
  
    // 4. 根据新容量(2倍容量)新建1个数组,即新table  
    Entry[] newTable = new Entry[newCapacity];  

    // 5. (重点分析)将旧数组上的数据(键值对)转移到新table中,从而完成扩容 ->>分析1.1 
    transfer(newTable); 

    // 6. 新数组table引用到HashMap的table属性上
    table = newTable;  

    // 7. 重新设置阈值  
    threshold = (int)(newCapacity * loadFactor); 
} 

 /**
   * 分析1.1:transfer(newTable); 
   * 作用:将旧数组上的数据(键值对)转移到新table中,从而完成扩容
   * 过程:按旧链表的正序遍历链表、在新链表的头部依次插入
   */ 
void transfer(Entry[] newTable) {
      // 1. src引用了旧数组
      Entry[] src = table; 

      // 2. 获取新数组的大小 = 获取新容量大小                 
      int newCapacity = newTable.length;

      // 3. 通过遍历 旧数组,将旧数组上的数据(键值对)转移到新数组中
      for (int j = 0; j < src.length; j++) { 
          // 3.1 取得旧数组的每个元素  
          Entry<K,V> e = src[j];           
          if (e != null) {
              // 3.2 释放旧数组的对象引用(for循环后,旧数组不再引用任何对象)
              src[j] = null; 

              do { 
                  // 3.3 遍历 以该数组元素为首 的链表
                  // 注:转移链表时,因是单链表,故要保存下1个结点,否则转移后链表会断开
                  Entry<K,V> next = e.next; 
                 // 3.3 重新计算每个元素的存储位置
                 int i = indexFor(e.hash, newCapacity); 
                 // 3.4 将元素放在数组上:采用单链表的头插入方式 = 在链表头上存放数据 = 将数组位置的原有数据放在后1个指针、将需放入的数据放到数组位置中
                 // 即 扩容后,可能出现逆序:按旧链表的正序遍历链表、在新链表的头部依次插入
                 e.next = newTable[i]; 
                 newTable[i] = e;  
                 // 访问下1个Entry链上的元素,如此不断循环,直到遍历完该链表上的所有节点
                 e = next;             
             } while (e != null);
             // 如此不断循环,直到遍历完数组上的所有数据元素
         }
     }
 }

可以看出在:在扩容的过程中,在将旧数组的数据转移到新数组上时,转移数组操作=按旧链表顺序遍历链表,在新链表头部插入。在转移数据,扩容后,容易出现链表逆序的情况。

设重新计算存储位置后不变,即扩容前 = 1->2->3,扩容后 = 3->2->1。

此时若(多线程)并发执行 put()操作,一旦出现扩容情况,则 容易出现 环形链表,从而在获取数据、遍历链表时 形成死循环(Infinite Loop),即 死锁的状态,具体请看下图:

注:由于 JDK 1.8 转移数据操作 = 按旧链表的正序遍历链表、在新链表的尾部依次插入,所以不会出现链表 逆序、倒置的情况,故不容易出现环形链表的情况。但 JDK 1.8 还是线程不安全,因为 无加同步锁保护

2.6.5 为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键

2.6.6 HashMap 中的 key若 Object类型, 则需实现哪些方法?

上面的图片来源于Java源码分析:HashMap 1.8 相对于1.7 到底更新了什么?可以看看JDK1.7 和1.8 的HashMap的差异。

参考文章:

HashMap源码注解 之 静态工具方法hash()、tableSizeFor()(四)

Java容器HashMap遍历方法和源代码解析

JDK 源码中 HashMap 的 hash 方法原理是什么?

HashMap 源码详细分析(JDK1.8)

JDK8:HashMap源码解析:TreeNode类的moveRootToFront方法

HashMap1.8之节点删除分析

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值