HashMap 源码深度分析

HashMap 源码分析

在Map集合中, HashMap 则是最具有代表性的,也是我们最常使用到的 Map 集合。由于 HashMap 底层涉及了很多的知识点,可以比较好的考察一个人的Java的基本功,所以HashMap颇受面试官的青睐,所以我们接下来就去分析一下HashMap的源码,看看它到底有什么。

概述

首先对 HashMap 的知识点进行下概述:

  1. HashMap 存储数据是根据 键值对 存储数据的,并且存储多个数据时,数据的键不能相同,如果相同该键之前对应的值将被覆盖。注意如果想要保证 HashMap 能够正确的存储数据,请确保作为键的类,已经正确覆写了 equals() 方法。
  2. HashMap 存储数据的位置与添加数据的键的 hashCode() 返回值有关。所以在将元素使用 HashMap 存储的时候请确保你已经按照要求重写了 hashCode()方法。这里说有关是表示最终的存储位置不一定就是 hashCode 的返回值。
  3. HashMap 最多只允许一个键(Key)为 null,可允许多条数据的值(value)为 null。
  4. HashMap 存储数据的顺序是不确定的,并且可能会因为扩容导致元素存储位置改变。因此遍历顺序是不确定的。
  5. HashMap 是线程不安全的,如果需要在多线程的情况下使用可以用 Collections.synchronizedMap(Map map) 方法使 HashMap 具有线程安全的能力,或者使用 ConcurrentHashMap

1、HashMap 底层如何存储数据的

要分析 HashMap 源码,就要了解到HashMap在JDK1.8及以后和JDK1.7及之前是不同的,因为在 JDK 1.8 之后官方对于 HashMap 做了底层实现的改动。

1.1 JDK 1.7 之前的存储结构

通过上篇文章 equals 和 hashCode 详解 我们以及对 hash 表有所了解,我们了解到即使hashCode() 方法写的再好,终究还是有可能导致 「hash碰撞」,HashMap 作为使用 hashCode来决定元素存储位置的集合也是需要处理 hash 冲突的。在1.7之前JDK采用「拉链法」来存储数据,即数组和链表结合的方式:

img

「拉链法」用专业点的名词来说叫做链地址法。简单来说,就是数组加链表的结合。在每个数组元素上存储的都是一个链表。

我们之前说到不同的 key 可能经过 hash 运算可能会得到相同的地址,但是一个数组单位上只能存放一个元素,采用链地址法以后,如果遇到相同的 hash 值的 key 的时候,我们可以将它放到作为数组元素的链表上。待我们去取元素的时候通过 hash 运算的结果找到这个链表,再在链表中找到与 key 相同的节点,就能找到 key 相应的值了。

JDK1.7中新添加进来的元素总是放在数组相应的下标位置,而原来处于该下标的位置的节点作为 next 节点放到新节点的后边。稍后通过源码分析我们也能看到这一点。

1.2 JDK1.8中的存储结构

对于 JDK1.8 之后的HashMap底层在解决哈希冲突的时候,就不单单是使用数组加上单链表的组合了,因为当处理如果 hash 值冲突较多的情况下,链表的长度就会越来越长,此时通过单链表来寻找对应 Key 对应的 Value 的时候就会使得时间复杂度达到 O(n),因此在 JDK1.8 之后,在链表新增节点导致链表长度超过 TREEIFY_THRESHOLD = 8 的时候,就会在添加元素的同时将原来的单链表转化为红黑树。

对数据结构很在行的读者应该,知道红黑树是一种易于增删改查的二叉树,他对与数据的查询的时间复杂度是 O(logn) 级别,所以利用红黑树的特点就可以更高效的对 HashMap 中的元素进行操作。

img

JDK1.8 对于HashMap 底层存储结构优化在于:当链表新增节点导致链表长度为8且数组长度为64的时候,就会将原有的链表转为红黑树来存储数据。

2、关于 HashMap 源码中提到的几个重要概念

关于 HashMap 源码中分析的文章一般都会提及几个重要的概念:

2.1 重要参数

  1. 哈希桶(buckets):在 HashMap 的注释里使用哈希桶来形象的表示数组中每个地址位置。注意这里并不是数组本身,数组是装哈希桶的,他可以被称为哈希表

  2. 初始容量(initial capacity) : 这个很容易理解,就是哈希表中哈希桶初始的数量。如果我们没有通过构造方法修改这个容量值默认为DEFAULT_INITIAL_CAPACITY = 1<<4 即16,即初始数组大小为16。值得注意的是为了保证 HashMap 添加和查找的高效性,HashMap 的容量总是 2^n 的形式。

    /*默认初始容量*/
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    /*最大存储容量*/
    static final int MAXIMUM_CAPACITY = 1 << 30;
    //存储哈希桶的数组,哈希桶中装的是一个单链表或一颗红黑树,长度一定是 2^n
    transient Node<K,V>[] table;  
    
  3. 加载因子(load factor):加载因子是哈希表(散列表)在其容量自动增加之前被允许获得的最大数量的度量。当哈希表中的条目数量超过负载因子和当前容量的乘积时,散列表就会被重新映射(即重建内部数据结构),重新创建的散列表容量大约是之前散列表哈希桶数量的两倍。默认加载因子(0.75)在时间和空间成本之间提供了良好的折衷。加载因子过大会导致链表过长,加载因子很小又容易导致频繁的扩容。所以不要试着去改变这个默认值

    /*默认加载因子*/
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
  4. 扩容阈值(threshold):其实在说加载因子的时候已经提到了扩容阈值了,扩容阈值 = 哈希表容量 * 加载因子。哈希表的键值对总数 = 所有哈希桶中所有链表节点数的加和,扩容阈值比较的是键值对的个数而不是哈希表的数组中有多少个位置被占了。

    // 扩容阈值 = 容量 x 加载因子
    int threshold;
    
  5. 树化阀值(TREEIFY_THRESHOLD) :这个参数概念是在 JDK1.8后加入的,它的含义代表一个哈希桶中的节点个数大于该值(默认为8)的时候将会被转为红黑树行存储结构。

    /*默认树化阈值*/
    static final int TREEIFY_THRESHOLD = 8;
    
  6. 非树化阀值(UNTREEIFY_THRESHOLD): 与树化阈值相对应,表示当一个已经转化为数形存储结构的哈希桶中节点数量小于该值(默认为 6)的时候将再次改为单链表的格式存储。导致这种操作的原因可能有删除节点或者扩容。

    /*默认非树化阈值*/
    static final int UNTREEIFY_THRESHOLD = 6;
    
  7. 最小树化容量(MIN_TREEIFY_CAPACITY): 经过上边的介绍我们只知道,当链表的节点数超过8的时候就会转化为树化存储,其实对于转化还有一个要求就是哈希表的数量超过最小树化容量的要求(默认要求是 64),且为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD);在达到该要求之前优先选择扩容。扩容因为容量的变化可能会使单链表的长度改变。

    /*默认最小树化容量*/
    static final int MIN_TREEIFY_CAPACITY = 64;
    

2.2 基本存储单元

HashMap 在 JDK 1.7 中只有 Entry 一种存储单元,而在 JDK1.8 中由于有了红黑树的存在,就多了一种存储单元,而 Entry 也随之应景的改为名为 Node。我们先来看下单链表节点的表示方法 :

/**
 * 内部类 Node 实现基类的内部接口 Map.Entry<K,V>
 */
static class Node<K,V> implements Map.Entry<K,V> {
   //此值是在数组索引位置
   final int hash;//初始值为0
   //节点的键
   final K key;
   //节点的值
   V 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; }
   //节点的 hashCode 值通过 key 的哈希值和 value 的哈希值通过异或得到。
   public final int hashCode() {
       return Objects.hashCode(key) ^ Objects.hashCode(value);
   }
   //更新相同 key 对应的 Value 值
   public final V setValue(V newValue) {
       V oldValue = value;
       value = newValue;
       return oldValue;
   }
   //equals 方法,K,V值同时相同,节点才相同
   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;
   }
}

对于JDK1.8 新增的红黑树节点TreeNode:

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

可以看到就是个红黑树节点,有父亲、左右孩子、前一个元素的节点,还有个颜色值。

另外由于它继承自 LinkedHashMap.Entry ,而 LinkedHashMap.Entry 继承自 HashMap.Node ,因此还有额外的 6 个属性:

//继承 LinkedHashMap.Entry 的
Entry<K,V> before, after;

//HashMap.Node 的
final int hash;
final K key;
V value;
Node<K,V> next;

关于红黑树在hashMap中的具体实现,我们到后面put的时候再讲。

3、HashMap 构造方法

HashMap 构造方法一共有三个:

3.1 可以指定初始容量和加载因子的构造函数.

有了这两个值,我们就可以算出上边说到的 threshold 扩容阈值。其中加载因子不可以小于0,并没有规定不可以大于 1,但是不能等于无穷。

 public HashMap(int initialCapacity, float loadFactor) {
    // 指定期望初始容量小于0将会抛出非法参数异常
   if (initialCapacity < 0)
       throw new IllegalArgumentException("Illegal initial capacity: " +
                                          initialCapacity);
   // 期望初始容量不可以大于最大值 2^30                                    
   if (initialCapacity > MAXIMUM_CAPACITY)
       initialCapacity = MAXIMUM_CAPACITY;
  // 加载因子必须大于0,不能为无穷大   
   if (loadFactor <= 0 || Float.isNaN(loadFactor))
       throw new IllegalArgumentException("Illegal load factor: " +
                                          loadFactor);
   this.loadFactor = loadFactor;//初始化全局加载因子变量
   this.threshold = tableSizeFor(initialCapacity);//根据初始容量计算扩容阈值
}

咦?不是说好扩容阈值 = 哈希表容量 * 加载因子么?为什么还要用到下边这个方法呢?我们之前说了参数 initialCapacity 只是期望容量,不知道大家发现没我们这个构造函数并没有初始化 Node<K,V>[] table ,事实上真正指定哈希表容量总是在第一次添加元素的时候,等我们说到扩容机制的时候我们就可以看到相关代码了。

//根据期望容量返回一个 >= cap 的扩容阈值,并且这个阈值一定是 2^n 
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;
   //经过上述的 或和位移 运算, n 最终各位都是1 
   //最终结果 +1 也就保证了返回的肯定是 2^n 
   return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

我们以传入的参数为14来举例,计算这个过程。首先,14-1=13,如何就是一系列无符号右移运算。

//13的二进制
0000 0000 0000 0000 0000 0000 0000 1101 
//无右移1位,高位补0
0000 0000 0000 0000 0000 0000 0000 0110 
//然后把它和原来的13做或运算得到,此时的n值
0000 0000 0000 0000 0000 0000 0000 1111 
//再以上边的值,右移2位
0000 0000 0000 0000 0000 0000 0000 0011
//然后和第一次或运算之后的 n 值再做或运算,此时得到的n值
0000 0000 0000 0000 0000 0000 0000 1111
...
//我们会发现,再执行右移 4,8,16位,同样n的值不变
//当n小于0时,返回1,否则判断是否大于最大容量,是的话返回最大容量,否则返回 n+1
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
//很明显我们这里返回的是 n+1 的值,
0000 0000 0000 0000 0000 0000 0000 1111
+                                     1
0000 0000 0000 0000 0000 0000 0001 0000

将它转为十进制,就是 2^4 = 16 。我们会发现一个规律,以上的右移运算,最终会把最低位的值都转化为 1111 这样的结构,然后再加1,就是1 0000 这样的结构,它一定是 2的n次幂。因此,这个方法返回的就是大于当前传入值的最小(最接近当前值)的一个2的n次幂的值。

3.2 只指定初始容量的构造函数

这个就比较简单了,将指定的初始容量和默认加载因子传递给两个参数构造方法。这里就不在赘述。

public HashMap(int initialCapacity) {
   this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

3.3 无参数构造函数

这也是我们最常用的一个构造函数,该方法初始化了加载因子为默认值,并没有调动其他的构造方法,跟我们之前说的一样,哈希表的大小以及其他参数都会在第一次调用扩容函数的初始化为默认值。

public HashMap() {
   this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

3.4 传入一个 Map 集合的构造参数

该方法解释起来就比较麻烦了,因为他在初始化的时候就涉及了添加元素,扩容这两大重要的方法。这里先把它挂起来,紧接着我们讲完了扩容机制再回来看就好了。

public HashMap(Map<? extends K, ? extends V> m) {
   this.loadFactor = DEFAULT_LOAD_FACTOR;
   putMapEntries(m, false);
}

4、HashMap 如何确定添加元素的位置

在分析 HashMap 添加元素的方法之前,我们需要先来了解下,如何确定元素在 HashMap 中的位置的。我们知道 HashMap 底层是哈希表,哈希表依靠 hash 值去确定元素存储位置。HashMap 在 JDK 1.7 和 JDK1.8中采用的 hash 方法并不是完全相同。我们现在看下

4.1 JDK 1.7 中 hash 方法的实现:

这里提出一个概念:扰动函数,我们知道Map 中存放键值对的位置由键的 hash 值决定,但是键的 hashCode 函数返回值不一定满足哈希表长度的要求,所以在存储元素之前需要对 key 的 hash 值进行一步扰动处理。下面我们看一下JDK1.7 中的扰动函数:

//4次位运算 + 5次异或运算 
//这种算法可以防止低位不变,高位变化时,造成的 hash 冲突
static final int hash(Object k) {
   int h = 0;
   h ^= k.hashCode(); 
   h ^= (h >>> 20) ^ (h >>> 12);
   return h ^ (h >>> 7) ^ (h >>> 4);
}

4.2 JDK1.8 中 hash 方法的实现

JDK1.8中再次优化了这个哈希函数,把 key 的 hashCode 方法返回值右移16位,即丢弃低16位,高16位全为0 ,然后在于 hashCode 返回值做异或运算,即高 16 位与低 16 位进行异或运算,这么做可以在数组 table 的 length 比较小的时候,也能保证考虑到高低Bit都参与到 hash 的计算中,同时不会有太大的开销,扰动处理次数也从 4次位运算 + 5次异或运算 降低到 1次位运算 + 1次异或运算

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

进过上述的扰动函数只是得到了合适的 hash 值,但是还没有确定在 Node[] 数组中的下标,在 JDK1.7中存在一个函数,JDK1.8中虽然没有但是只是把这步运算放到了 put 方法中。我们就看下这个方法实现:

static int indexFor(int h, int length) {
     return h & (length-1);  // 取模运算
}

为了让 hash 值能够对应到现有数组中的位置,我们知道一个方法为 取模运算,即 hash % length,得到结果作为下标位置。但是 HashMap 就厉害了,连这一步取模运算的都优化了。我们需要知道一个计算机对于2进制的运算是要快于10进制的,取模算是10进制的运算了,而位与运算就要更高效一些了。

我们知道 HashMap 底层数组的长度总是 2^n ,转为二进制总是 1000 即1后边多个0的情况。此时一个数与 2^n 取模,等价于 一个数与 2^n - 1做位与运算。而 JDK 中就使用h & (length-1) 运算替代了对 length取模。我们根据图片来看一个具体的例子:

img

图片来自:https://tech.meituan.com/java-hashmap.html 侵删。

通过上边的分析我们可以到如下结论:

  • 在存储元素之前,HashMap 会对 key 的 hashCode 返回值做进一步扰动函数处理,1.7 中扰动函数使用了 4次位运算 + 5次异或运算,1.8 中降低到 1次位运算 + 1次异或运算
  • 扰动处理后的 hash 与 哈希表数组length -1 做位与运算得到最终元素储存的哈希桶下标位置。

5、HashMap 的添加元素

敲黑板了,重点来了。对于理解 HashMap 源码一方面要了解存储的数据结构,另一方面也要了解具体是如何添加元素的。下面我们就来看下 put(K key, V value) 函数。

// 可以看到具体的添加行为在 putVal 方法中进行
public V put(K key, V value) {
   return putVal(hash(key), key, value, false, true);
}

对于 putVal 前三个参数很好理解,第4个参数 onlyIfAbsent 表示只有当对应 key 的位置为空的时候替换元素,一般传 false,在 JDK1.8中新增方法 public V putIfAbsent(K key, V value) 传 true,第 5 个参数 evict 只有在方法 afterNodeInsertion(boolean evict) { }用到,可以看到它是一个空实现,因此不用关注这个参数。

//把hash值和当前的key,value传入进来
//这里onlyIfAbsent如果为true,表明不能修改已经存在的值,因此我们传入false
//evict只有在方法 afterNodeInsertion(boolean evict) { }用到,可以看到它是一个空实现,因此不用关注这个参数
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是否为空,如果空的话,会先调用resize扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //根据当前key的hash值找到它在数组中的下标,判断当前下标位置是否已经存在元素,
    //若没有,则把key、value包装成Node节点,直接添加到此位置。
    // i = (n - 1) & hash 是计算下标位置的
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else { 
        //如果当前位置已经有元素了,分为三种情况。
        Node<K,V> e; K k;
        //1.当前位置元素的hash值等于传过来的hash,并且他们的key值也相等,
        //则把p赋值给e,跳转到①处,后续需要做值的覆盖处理
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //2.如果当前是红黑树结构,则把它加入到红黑树 
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
        //3.说明此位置已存在元素,并且是普通链表结构,则采用尾插法,把新节点加入到链表尾部
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    //如果头结点的下一个节点为空,则插入新节点
                    p.next = newNode(hash, key, value, null);
                    //如果在插入的过程中,链表长度超过了8,则转化为红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    //插入成功之后,跳出循环,跳转到①处
                    break;
                }
                //若在链表中找到了相同key的话,直接退出循环,跳转到①处
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //① 此时e有两种情况
        //1.说明发生了碰撞,e代表的是旧值,因此节点位置不变,但是需要替换为新值
        //2.说明e是插入链表或者红黑树,成功后的新节点
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            //用新值替换旧值,并返回旧值。
            //oldValue为空,说明e是新增的节点或者也有可能旧值本来就是空的,因为hashmap可存空值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            //看方法名字即可知,这是在node被访问之后需要做的操作。其实此处是一个空实现,
            //只有在 LinkedHashMap才会实现,用于实现根据访问先后顺序对元素进行排序,hashmap不提供排序功能
            afterNodeAccess(e);
            return oldValue;
        }
    }
    //fail-fast机制
    ++modCount;
    //如果当前数组中的元素个数超过阈值,则扩容
    if (++size > threshold)
        resize();
    //空实现
    afterNodeInsertion(evict);
    return null;
}

由于添加元素中设计逻辑有点复杂,这里引用一张图来说明

图片来自:https://tech.meituan.com/java-hashmap.html

img

6、HashMap 的扩容过程

在上边说明 HashMap 的 putVal 方法时候,我们会发现,当数组为空的时候,会调用 resize 方法,当数组的 size 大于阈值的时候,也会调用 resize方法。 那么看下 resize 方法都做了哪些事情吧。扩容方法也是我们理解 HashMap 源码的重中之重。

final Node<K,V>[] resize() {
    //旧数组
    Node<K,V>[] oldTab = table;
    //旧数组的容量
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //旧数组的扩容阈值,注意看,这里取的是当前对象的 threshold 值,下边的第2种情况会用到。
    int oldThr = threshold;
    //初始化新数组的容量和阈值,分三种情况讨论。
    int newCap, newThr = 0;
    //1.当旧数组的容量大于0时,说明在这之前肯定调用过 resize扩容过一次,才会导致旧容量不为0。
    //需要注意的是,它返回的值是赋给了 threshold 而不是 capacity。
    //我们在这之前,压根就没有在任何地方看到过,它给 capacity 赋初始值。
    if (oldCap > 0) {
        //容量达到了最大值
        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
    }
    //2.到这里,说明 oldCap <= 0,并且 oldThr(threshold) > 0,这就是 map 初始化的时候,第一次调用 resize的情况
    //而 oldThr的值等于 threshold,此时的 threshold 是通过 tableSizeFor 方法得到的一个2的n次幂的值(我们以16为例)。
    //因此,需要把 oldThr 的值,也就是 threshold ,赋值给新数组的容量 newCap,以保证数组的容量是2的n次幂。
    //所以我们可以得出结论,当map第一次 put 元素的时候,就会走到这个分支,把数组的容量设置为正确的值(2的n次幂)
    //但是,此时 threshold 的值也是2的n次幂,这不对啊,它应该是数组的容量乘以加载因子才对。别着急,这个会在③处理。
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    //3.到这里,说明 oldCap 和 oldThr 都是小于等于0的。也说明我们的map是通过默认无参构造来创建的,
    //于是,数组的容量和阈值都取默认值就可以了,即 16 和 12。
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //③ 这里就是处理第2种情况,因为只有这种情况 newThr 才为0,
    //因此计算 newThr(用 newCap即16 乘以加载因子 0.75,得到 12) ,并把它赋值给 threshold
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    //赋予 threshold 正确的值,表示数组下次需要扩容的阈值(此时就把原来的 16 修正为了 12)。
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    //如果原来的数组不为空,那么我们就需要把原来数组中的元素重新分配到新的数组中
    //如果是第2种情况,由于是第一次调用resize,此时数组肯定是空的,因此也就不需要重新分配元素。
    if (oldTab != null) {
        //遍历旧数组
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            //取到当前下标的第一个元素,如果存在,则分三种情况重新分配位置
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                //1.如果当前元素的下一个元素为空,则说明此处只有一个元素
                //则直接用它的hash()值和新数组的容量取模就可以了,得到新的下标位置。
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                //2.如果是红黑树结构,则拆分红黑树,必要时有可能退化为链表
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                //3.到这里说明,这是一个长度大于 1 的普通链表,则需要计算并
                //判断当前位置的链表是否需要移动到新的位置
                else { // preserve order
                    // loHead 和 loTail 分别代表链表旧位置的头尾节点
                    Node<K,V> loHead = null, loTail = null;
                    // hiHead 和 hiTail 分别代表链表移动到新位置的头尾节点
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        //如果当前元素的hash值和oldCap做与运算为0,则原位置不变
                        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. 寻找扩容后数组的大小以及新的扩容阈值,2. 将原有哈希表拷贝到新的哈希表中

在上面的源码中,有个十分重要的判断,JDK 1.8 不像 JDK1.7中会重新计算每个节点在新哈希表中的位置,而是通过 (e.hash & oldCap) == 0是否等于0 就可以得出原来链表中的节点在新哈希表的位置。为什么可以这样高效的得出新位置呢?

因为扩容是容量翻倍,所以原链表上的每个节点,可能存放在新哈希表中在原来的下标位置, 或者扩容后的原位置偏移量为 oldCap 的位置上,下边举个例子 图片和叙述来自 https://tech.meituan.com/java-hashmap.html

图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。

img

元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

img

所以在 JDK1.8 中扩容后,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap

另外还需要注意的一点是 HashMap 在 1.7的时候扩容后,链表的节点顺序会倒置,1.8则不会出现这种情况。

7、HashMap 其他添加元素的方法

上边将构造函数的时候埋了个坑即使用:

public HashMap(Map<? extends K, ? extends V> m) {
   this.loadFactor = DEFAULT_LOAD_FACTOR;
   putMapEntries(m, false);
}

构造函数构建 HashMap 的时候,在这个方法里,除了赋值了默认的加载因子,并没有调用其他构造方法,而是通过批量添加元素的方法 putMapEntries 来构造了 HashMap。该方法为私有方法,真正批量添加的方法为putAll

public void putAll(Map<? extends K, ? extends V> m) {
   putMapEntries(m, true);
}
 final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
   int s = m.size();
   if (s > 0) {
        //如果哈希表为空则初始化参数扩容阈值
       if (table == null) { // pre-size
           float ft = ((float)s / loadFactor) + 1.0F;
           int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                    (int)ft : MAXIMUM_CAPACITY);
           if (t > threshold)
               threshold = tableSizeFor(t);
       }
       else if (s > threshold)//构造方法没有计算 threshold 默认为0 所以会走扩容函数
           resize();
        //将参数中的 map 键值对一次添加到 HashMap 中
       for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
           K key = e.getKey();
           V value = e.getValue();
           putVal(hash(key), key, value, false, evict);
       }
   }
}

JDK1.8 中还新增了一个添加方法,该方法调用 putVal 且第4个参数传了 true,代表只有哈希表中对应的key 的位置上元素为空的时候添加成功,否则返回原来 key 对应的 Value 值。

@Override
public V putIfAbsent(K key, V value) {
   return putVal(hash(key), key, value, true, true);
}

8、HashMap 查询元素

分析完了 put 方法后,接下来让我们看下 get 方法;

(1) 根据键值对的 key 去获取对应的 Value

public V get(Object key) {
   Node<K,V> e;
   //通过 getNode寻找 key 对应的 Value 如果没找到,或者找到的结果为 null 就会返回null 否则会返回对应的 Value
   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;
   //现根据 key 的 hash 值去找到对应的链表或者红黑树
   if ((tab = table) != null && (n = tab.length) > 0 &&
       (first = tab[(n - 1) & hash]) != null) {
       // 如果第一个节点就是那么直接返回
       if (first.hash == hash && // always check first node
           ((k = first.key) == key || (key != null && key.equals(k))))
           return first;
        //如果 对应的位置为红黑树调用红黑树的方法去寻找节点   
       if ((e = first.next) != null) {
           if (first instanceof TreeNode)
               return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            //遍历单链表找到对应的 key 和 Value   
           do {
               if (e.hash == hash &&
                   ((k = e.key) == key || (key != null && key.equals(k))))
                   return e;
           } while ((e = e.next) != null);
       }
   }
   return null;
}

(2) JDK 1.8新增 get 方法,在寻找 key 对应 Value 的时候如果没找大则返回指定默认值

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

9、HashMap 的删操作

HashMap 没有 set 方法,如果想要修改对应 key 映射的 Value ,只需要再次调用 put 方法就可以了。我们来看下如何移除 HashMap 中对应的节点的方法:

 public V remove(Object key) {
   Node<K,V> e;
   return (e = removeNode(hash(key), key, null, false, true)) == null ?
       null : e.value;
}
@Override
public boolean remove(Object key, Object value) {
   //这里传入了value 同时matchValue为true
   return removeNode(hash(key), key, value, true, true) != null;
}

这里有两个参数需要我们提起注意:

  • matchValue 如果这个值为 true 则表示只有当 Value 与第三个参数 Value 相同的时候才删除对一个的节点
  • movable 这个参数在红黑树中先删除节点时候使用 true 表示删除并其他数中的节点。
 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;
   //判断哈希表是否为空,长度是否大于0 对应的位置上是否有元素
   if ((tab = table) != null && (n = tab.length) > 0 &&
       (p = tab[index = (n - 1) & hash]) != null) {
       
       // node 用来存放要移除的节点, e 表示下个节点 k ,v 每个节点的键值
       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) {
            // 遍历红黑树找到对应的节点
           if (p instanceof TreeNode)
               node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
           else {
                //遍历对应的链表找到对应的节点
               do {
                   if (e.hash == hash &&
                       ((k = e.key) == key ||
                        (key != null && key.equals(k)))) {
                       node = e;
                       break;
                   }
                   p = e;
               } while ((e = e.next) != null);
           }
       }
       // 如果找到了节点
       // !matchValue 是否不删除节点
       // (v = node.value) == value ||
                            (value != null && value.equals(v))) 节点值是否相同,
       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;
}

10、HashMap 中关于红黑树的重要方法

首先,再次明确一下HashMap 中有三个关于红黑树的关键参数:

  • TREEIFY_THRESHOLD
  • UNTREEIFY_THRESHOLD
  • MIN_TREEIFY_CAPACITY

值及作用如下:

//一个桶的树化阈值
//当桶中元素个数超过这个值时,需要使用红黑树节点替换链表节点
static final int TREEIFY_THRESHOLD = 8;

//一个树的链表还原阈值
static final int UNTREEIFY_THRESHOLD = 6;

//哈希表的最小树形化容量
//为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;

10.1 桶的树形化 treeifyBin()

在Java 8 中,如果一个桶中的元素个数超过 TREEIFY_THRESHOLD(默认是 8 ),就调用treeifyBin方法;注意,在treeifBin方法中,并不会直接将桶树化,如果数组为空或数组长度小于树化阈值(64)则不会树化。

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //当数组为空 或者 数组长度 < 树化阈值(64)时需要执行resize方法,扩容
    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指的是head,tl指的是tail,分别指向红黑树的头、尾节点
        //从链表头节点开始遍历链表,头节点是存放在数组中的
        do {
            //新建一个树形节点,内容和当前链表节点e保持一致
            //此时next默认为null,会在后面按顺序重新对next赋值
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)//当尾节点为空,即当前节点应为头节点(因为就这一个节点)
                hd = p;
            else {
                p.prev = tl;//prev被赋值,主要是记录当前节点的上一个节点
                tl.next = p;//p指向之前尾节点的next,保持插入顺序
            }
            tl = p;//当前节点设置为尾节点,保持插入顺序
        } while ((e = e.next) != null);
        //桶内第一个元素即链表头节点,并放在数组中
        if ((tab[index] = hd) != null)
            hd.treeify(tab);//从头节点开始遍历,将整个桶树化
            //注意头节点并不一定是树的根节点:树化后的根节点会重新设置为头节点,即tab[index]=root
            //具体参见moveRootToFront()
    }
}
// For treeifyBin 新建一个树形节点
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
    return new TreeNode<>(p.hash, p.key, p.value, next);
}

上述操作做了这些事:

  • 根据哈希表中元素个数确定是扩容还是树形化
  • 如果是树形化
    • 遍历桶中的元素,创建相同个数的树形节点,复制内容,建立起联系
    • 然后让桶第一个元素指向新建的树头结点,替换桶的链表内容为树形内容

但是我们发现,之前的操作并没有设置红黑树的颜色值,现在得到的只能算是个二叉树。在 最后调用树形节点 hd.treeify(tab) 方法进行塑造红黑树,来看看代码:

/**
  * Forms tree of the nodes linked from this node. 
  * 塑造红黑树
  * @return root of tree 这里比较有意思,明明是void但有注释@return,不知大神们何意
  */
final void treeify(Node<K,V>[] tab) {
    TreeNode<K,V> root = null;//根节点需要排序后重新设置(之前链表的头节点不一定是树的根节点)
    //this指的是当前二叉树的头节点,从头节点开始遍历
    for (TreeNode<K,V> x = this, next; x != null; x = next) {
        next = (TreeNode<K,V>)x.next;
        x.left = x.right = null;
        //当根节点为空时,先设置根节点为黑色,同时当前节点先当作根节点(即自上而下插入)
        if (root == null) {
            x.parent = null;
            x.red = false;//红黑树的根节点为黑色
            root = x;
        }
        else {
        //后面进入循环走的逻辑,x 指向树中的某个节点
            K k = x.key;
            int h = x.hash;
            Class<?> kc = null;
            //重新循环,从根节点开始,遍历所有节点与当前节点x比较,重新调整位置,类似冒泡排序
            for (TreeNode<K,V> p = root;;) {
                int dir, ph;
                K pk = p.key;
                if ((ph = p.hash) > h)//如果比较节点的hash比当前节点的hash大,查左子树
                    dir = -1;
                else if (ph < h)
                    dir = 1;//如果比较节点的hash比当前节点的hash小,查右子树
                else if ((kc == null && (kc = comparableClassFor(k)) == null) || 
                                (dir = compareComparables(kc, k, pk)) == 0 )
                    //tieBreakOrder 用于hash相同时且key无法比较时,直接根据引用比较 
                    //这里指的是如果当前比较节点的哈希值比x大,返回-1,否则返回1
                    dir = tieBreakOrder(k, pk);
                //经过前面的计算,得到了当前节点和要插入节点x的一个大小关系
                //如果当前比较节点的哈希值比x大,x就是左子节点,否则x是右子节点 
                TreeNode<K,V> xp = p;
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    x.parent = xp;//把当前节点变成x的父节点
                    if (dir <= 0)
                        xp.left = x;
                    else
                        xp.right = x;
                    root = balanceInsertion(root, x);
                    break;
                }
            }
        }
    }
    moveRootToFront(tab, root);//将根节点设置为头节点
}

可以看到,将二叉树变为红黑树时,需要保证有序。这里有个双重循环,拿树中的所有节点和当前节点的哈希值进行对比(如果哈希值相等,就对比键,这里不用完全有序),然后根据比较结果确定在树中的位置。

10.2 红黑树中添加元素 putTreeVal()

上面介绍了如何把一个桶中的链表结构变成红黑树结构。

在添加时,如果一个桶中已经是红黑树结构,就要调用红黑树的添加元素方法 putTreeVal()。

/**
  * Tree version of putVal. 当桶内为红黑树时,查找该节点,
  * 若该节点不存在就新增,返回null
  * 若当前节点存在,返回当前节点,用于之后的值覆盖操作
  */
  //this, tab, hash, key, newValue
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,int h, K k, V v) {
    Class<?> kc = null;
    boolean searched = false;
    TreeNode<K,V> root = (parent != null) ? root() : this;//如果当前node非根节点,需要向上溯源找到根节点
    //双重for循环,确定节点位置
    for (TreeNode<K,V> p = root;;) {//从根节点开始遍历,确定键值对的位置
        int dir, ph; K pk;
        if ((ph = p.hash) > h)//对比当前节点和 比较节点的hash大小
            dir = -1;// 比较节点hash > 当前节点hash  找左子树
        else if (ph < h)
            dir = 1;// 比较节点hash < 当前节点hash   找右子树
        else if ((pk = p.key) == k || (pk != null && k.equals(pk)))
            return p;//如果该节点已经存在,直接返回该节点即可
        else if ((kc == null && (kc = comparableClassFor(k)) == null) ||
                        (dir = compareComparables(kc, k, pk)) == 0) {
            //如果当前节点和要添加的节点哈希值相等,但是两个节点的键不是一个类,只能挨个对比左右子节点
            if (!searched) {
                TreeNode<K,V> q, ch;
                searched = true;
                //左查 or 右查
                if (((ch = p.left) != null && (q = ch.find(h, k, kc)) != null) ||
                        ((ch = p.right) != null && (q = ch.find(h, k, kc)) != null))
                    return q;
            }
            dir = tieBreakOrder(k, pk);
        }
        //经过前面的计算,得到了比较节点p和要插入节点x的一个大小关系
        //如果比较节点p的哈希值比x大,x就是左子节点,否则x是右子节点 
        TreeNode<K,V> xp = p;
        //如果比较节点还没有左子节点或者右子节点时才能插入,否则就进入下一轮循环(因为是查找二叉树) 
        if ((p = (dir <= 0) ? p.left : p.right) == null) {
            //比较节点的next即是新节点的next,原因是当前x需要作为比较节点p的子节点(树的位置需要调整)
            Node<K,V> xpn = xp.next;
            TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);//新建一个树形节点
            if (dir <= 0)
                xp.left = x;//x的hash比比较节点小,即作为比较节点的左子节点
            else
                xp.right = x;//x的hash比比较节点大,即作为比较节点的右子节点
            xp.next = x;
            x.parent = x.prev = xp;//比较节点即是当前节点的x的父节点也是上一个节点
        if (xpn != null)//当比较节点的原next节点存在时,需要重新设置该节点的上一个节点指向新节点
            ((TreeNode<K,V>)xpn).prev = x;
        moveRootToFront(tab, balanceInsertion(root, x));//每次都要重新平衡并确定新的根节点
        return null;//新增节点返回null
        }
    }
}
/*
 * Tie-breaking utility for ordering insertions when equal hashCodes and non-comparable.
 * We don't require a total order, just a consistent insertion rule to maintain equivalence 
 * across rebalancings. Tie-breaking further than necessary simplifies testing a bit.
 *  当a和b哈希值相同但是无法比较时,直接根据两个引用的地址进行比较
 *  这个树里不要求完全有序,只要插入时使用相同的规则保持平衡即可
 */
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;
}

通过上面的代码可以知道,HashMap 中往红黑树中添加一个新节点 n 时,有以下操作:

  • 从根节点开始遍历当前红黑树中的元素 p,对比 n 和 p 的哈希值;
  • 如果哈希值相等并且键也相等,就判断为已经有这个元素(这里不清楚为什么不对比值);
  • 如果哈希值就通过其他信息,比如引用地址来给个大概比较结果,这里可以看到红黑树的比较并不是很准确,注释里也说了,只是保证个相对平衡即可;
  • 最后得到哈希值比较结果后,如果当前节点 p 还没有左孩子或者右孩子时才能插入,否则就进入下一轮循环;
  • 插入元素后还需要进行红黑树例行的平衡调整,还有确保根节点的领先地位。

10.3 红黑树中查找元素 getTreeNode()

HashMap 的查找方法是 get():

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

它通过计算指定 key 的哈希值后,调用内部方法 getNode();

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

这个 getNode() 方法就是根据哈希表元素个数与哈希值求模(使用的公式是 (n - 1) &hash)得到 key 所在的桶的头结点,如果头节点恰好是红黑树节点,就调用红黑树节点的 getTreeNode() 方法,否则就遍历链表节点。

/**
  * Calls find for root node.
  * 红黑树 总是从根节点开始查找
  */
final TreeNode<K,V> getTreeNode(int h, Object k) {
	return ((parent != null) ? root() : this).find(h, k, null);
}

getTreeNode 方法使通过调用树形节点的 find() 方法进行查找:

/**
  * Finds the node starting at root p with the given hash and key.
  * The kc argument caches comparableClassFor(key) upon first use comparing keys.
  * 查找指定key,并从根节点开始递归
  */
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
    TreeNode<K,V> p = this;//这里的this指的是root,参见getTreeNode
    do {
        int ph, dir; K pk;
        TreeNode<K,V> pl = p.left, pr = p.right, q;
        //查找原则:左子树都比自身小,右子树都比自身大,二分查找即可
        //比较hash,当前节点hash小,继续查左子树,否则查右子数
        if ((ph = p.hash) > h)
            p = pl;
        else if (ph < h)
            p = pr;
        else if ((pk = p.key) == k || (k != null && k.equals(pk)))
            return p;//若找到,直接返回
        //哪边子树是空,就查另一边子树
        else if (pl == null)
            p = pr;
        else if (pr == null)
            p = pl;
        //主要处理hash相同时,key又实现了Compareble的情况(即根据比较器比较)
        else if ((kc != null || (kc = comparableClassFor(k)) != null) && 
                    (dir = compareComparables(kc, k, pk)) != 0)
            p = (dir < 0) ? pl : pr;
        //先递归右子树,找不到再找左子树(此时左右子树都非空)    
        else if ((q = pr.find(h, k, kc)) != null)
            return q;
        else
            p = pl;
    } while (p != null);
    return null;//找不到则返回null
}

由于之前添加时已经保证这个树是有序的,因此查找时基本就是折半查找,效率很高。

这里和插入时一样,如果对比节点的哈希值和要查找的哈希值相等,就会判断 key 是否相等,相等就直接返回(也没有判断值哎);不相等就从子树中递归查找。

10.4 树形结构修剪 split()

HashMap 中, resize() 方法的作用就是初始化或者扩容哈希表。当扩容时,如果当前桶中元素结构是红黑树,并且元素个数小于链表还原阈值 UNTREEIFY_THRESHOLD (默认为 6),就会把桶中的树形结构缩小或者直接还原(切分)为链表结构,调用的就是 split():

/**
  * Splits nodes in a tree bin into lower and upper tree bins,or untreeifies if now too small. 
  * Called only from resize;see above discussion about split bits and indices.
  * 该方法主要有两个作用:
  *      1.将桶内元素分成低位链表和高位链表两个部分
  *      2.当该桶的元素数量太少时,会执行反树化操作(即链化操作)
  * 该方法只能被resize方法使用
  * @param map the map 当前map
  * @param tab the table for recording bin heads 这里代指newTab
  * @param index the index of the table being split 当前数组下标
  * @param bit the bit of hash to split on 这里代指oldCap
  */
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
    TreeNode<K,V> b = this;//当前node
    // Relink into lo and hi lists, preserving order
    TreeNode<K,V> loHead = null, loTail = null;//lo=low
    TreeNode<K,V> hiHead = null, hiTail = null;//hi=high
    int lc = 0, hc = 0;//lc=lowCount 即桶内低位元素个数 hc=highCount 即桶内高位元素个数
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        next = (TreeNode<K,V>)e.next;
        e.next = null;
        //(e.hash & bit) == 0 等价于 resize方法中的 (e.hash & oldCap) == 0,同时效果等效
        //即将桶内元素分成低位链表和高位链表两个部分,即红黑树一分为二成两个链表
        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) {
        //低位元素数量若<=链表还原阈值,那需要将反树化,将树重新变成链表结构
        if (lc <= UNTREEIFY_THRESHOLD)
            tab[index] = loHead.untreeify(map);
        else {
            tab[index] = loHead;//重新设置原桶头节点
            //若新桶头节点非空,原桶需要重新树化(因为重新分割了)
            if (hiHead != null) // (else is already treeified) 
                loHead.treeify(tab);
        }
    }
    //高位链表位置变动,变动到新桶,即[index+oldCap]位置
    if (hiHead != null) {
        //高位元素数量若<=链表还原阈值,那需要将反树化,将树重新变成链表结构
        if (hc <= UNTREEIFY_THRESHOLD)
            tab[index + bit] = hiHead.untreeify(map);
        else {
            tab[index + bit] = hiHead;//重新设置新桶头节点
            //若原桶头节点非空,新桶需要重新树化(因为重新分割了)
            if (loHead != null) 
                hiHead.treeify(tab);
        }
    }
}

从上述代码可以看到,HashMap 扩容时对红黑树节点的修剪主要分两部分,先分类、再根据元素个数决定是还原成链表还是精简一下元素仍保留红黑树结构。

1.分类

指定位置、指定范围,让指定位置中的元素 (hash & bit) == 0 的,放到 lXXX 树中,不相等的放到 hXXX 树中。

2.根据元素个数决定处理情况

符合要求的元素(即 lXXX 树),在元素个数小于 6 时还原成链表,最后让哈希表中修剪的痛 tab[index] 指向 lXXX 树;在元素个数大于 6 时,还是用红黑树,只不过是修剪了下枝叶;

不符合要求的元素(即 hXXX 树)也是一样的操作,只不过最后它是放在了修剪范围外 tab[index + bit]。

11、HashMap 的迭代器

我们都知道 Map 和 Set 有多重迭代方式,对于 Map 遍历方式这里不展开说了,因为我们要分析迭代器的源码,所以这里就给出一个使用迭代器遍历的方法:

public void test(){

    Map<String, Integer> map = new HashMap<>();
    
    ...
    
    Set<Map.Entry<String, Integer>> entrySet = map.entrySet();
    
    //通过迭代器:先获得 key-value 对(Entry)的Iterator,再循环遍历   
    Iterator iter1 = entrySet.iterator();
    while (iter1.hasNext()) {
    // 遍历时,需先获取entry,再分别获取key、value
    Map.Entry entry = (Map.Entry) iter1.next();
    System.out.print((String) entry.getKey());
    System.out.println((Integer) entry.getValue());
    }
}

通过上述遍历过程我们可以使用 map.entrySet() 获取之前我们最初提及的 entrySet

public Set<Map.Entry<K,V>> entrySet() {
   Set<Map.Entry<K,V>> es;
   return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}
// 我们来看下 EntrySet 是一个 set 存储的元素是 Map 的键值对
final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
   // size 放回 Map 中键值对个数
   public final int size()                 { return size; }
   //清除键值对
   public final void clear()               { HashMap.this.clear(); }
   // 获取迭代器
   public final Iterator<Map.Entry<K,V>> iterator() {
       return new EntryIterator();
   }
   
   //通过 getNode 方法获取对一个及对应 key 对应的节点 这里必须传入
   // Map.Entry 键值对类型的对象 否则直接返回 false
   public final boolean contains(Object o) {
       if (!(o instanceof Map.Entry))
           return false;
       Map.Entry<?,?> e = (Map.Entry<?,?>) o;
       Object key = e.getKey();
       Node<K,V> candidate = getNode(hash(key), key);
       return candidate != null && candidate.equals(e);
   }
   // 滴啊用之前讲得 removeNode 方法 删除节点
   public final boolean remove(Object o) {
       if (o instanceof Map.Entry) {
           Map.Entry<?,?> e = (Map.Entry<?,?>) o;
           Object key = e.getKey();
           Object value = e.getValue();
           return removeNode(hash(key), key, value, true, true) != null;
       }
       return false;
   }
   ...
}
//EntryIterator 继承自 HashIterator
final class EntryIterator extends HashIterator
   implements Iterator<Map.Entry<K,V>> {
   // 这里可能是因为大家使用适配器的习惯添加了这个 next 方法
   public final Map.Entry<K,V> next() { return nextNode(); }
}

   
abstract class HashIterator {
        Node<K,V> next;        // next entry to return
        Node<K,V> current;     // current entry
        int expectedModCount;  // for fast-fail
        int index;             // current slot

        HashIterator() {
            //初始化操作数 Fast-fail 
            expectedModCount = modCount;
            // 将 Map 中的哈希表赋值给 t
            Node<K,V>[] t = table;
            current = next = null;
            index = 0;
            //从table 第一个不为空的 index 开始获取 entry
            if (t != null && size > 0) { // advance to first entry
                do {} while (index < t.length && (next = t[index++]) == null);
            }
        }
        
        public final boolean hasNext() {
            return next != null;
        }

        final Node<K,V> nextNode() {
            Node<K,V>[] t;
            Node<K,V> e = next;
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            if (e == null)
                throw new NoSuchElementException();
             //如果当前链表节点遍历完了,则取哈希桶下一个不为null的链表头   
            if ((next = (current = e).next) == null && (t = table) != null) {
                do {} while (index < t.length && (next = t[index++]) == null);
            }
            return e;
        }
        //这里还是调用 removeNode 函数不在赘述
        public final void remove() {
            Node<K,V> p = current;
            if (p == null)
                throw new IllegalStateException();
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            current = null;
            K key = p.key;
            removeNode(hash(key), key, null, false, false);
            expectedModCount = modCount;
        }
    }

除了 EntryIterator 以外还有 KeyIteratorValueIterator 也都继承了HashIterator 也代表了 HashMap 的三种不同的迭代器遍历方式。

final class KeyIterator extends HashIterator
   implements Iterator<K> {
   public final K next() { return nextNode().key; }
}

final class ValueIterator extends HashIterator
   implements Iterator<V> {
   public final V next() { return nextNode().value; }
}

可以看出无论哪种迭代器,都是通过遍历 table 表来获取下一个节点的,遍历过程可以理解为一种深度优先遍历,即优先遍历链表节点(或者红黑树),然后在遍历其他数组位置。

12、HashTable 的区别

面试的时候面试官总是问完 HashMap 后会问 HashTable 其实 HashTable 也算是比较古老的类了。翻看 HashTable 的源码可以发现有如下区别:

  1. HashMap 是线程不安全的,HashTable是线程安全的。
  2. HashMap 允许 key 和 Vale 是 null,但是只允许一个 key 为 null,且这个元素存放在哈希表 0 角标位置。 HashTable 不允许key、value 是 null
  3. HashMap 内部使用hash(Object key)扰动函数对 key 的 hashCode 进行扰动后作为 hash 值。HashTable 是直接使用 key 的 hashCode() 返回值作为 hash 值。
  4. HashMap默认容量为 2^4 且容量一定是 2^n ; HashTable 默认容量是11,不一定是 2^n
  5. HashTable 取哈希桶下标是直接用模运算,扩容时新容量是原来的2倍+1。HashMap 在扩容的时候是原来的两倍,且哈希桶的下标使用 &运算代替了取模。

13、部分HasHMap的面试题

13.1 HashMap底层原理?

只要把上面看会了,就差不多能扯好久了。

13.2 HashMap如何解决Hash碰撞?

注意JDK1.7和JDK1.8的区别。

13.3 为什么数组容量必须是2^n?

为了让添加的元素均匀分布在HashMap的数组上,减少hash碰撞。

只有数组长度为2^n,才能保证n-1的低位的值全为1,这样元素就可以更均匀的分散在数组上。

13.4 树化条件是什么?

需要满足两个条件:链表长度为8且数组长度为64

13.5 HashMap扩容是怎么做的?

去看第六节。

13.6 HashMap是否有序?

无序,存放元素的时候虽然是会经过一系列的运算去确定的它的位置,但是并没有大小前后这样的顺序。如果要有序,可以使用LinkedHashMap和TreeMap。这样就会扯到LinkedHashMap和TreeMap,然后连环炮,牵出一系列的问题。

13.7 HashMap 是否线程安全?

线程不安全。多线程在去put()的时候,有可能造成数据覆盖,扩容的时候也可能会。要做到线程安全,Java提供了其他的方法:HashTable、Collections.synchronizedMap()、ConcurrentHashMap。这里也是一个连环坑,问这个问题的,一般希望你说一下ConcurrentHashMap原理,还会扯到多线程同步问题,锁机制,互斥锁、自旋锁、悲观锁、乐观锁、等等。

参考

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值