Java 集合(三)| Map和HashMap详解 | 底层结构和源码深入分析(JDK1.8)

一、Map接口

1.接口简介

Map的意义
Map接口和Collection接口在集合中可以看成同一级别的接口,但Collection接口中保存的的数据全是单个对象,而在数据结构中除了可以对单个对象进行保存之外,还可以对二元偶对象(key:value键值对)的形式来存储,二元偶对象的核心在于,需要通过key来获取value

在开发之中,Collection集合保存数据的主要目的是为了输出,Map集合保存数据的目的是为了查询(通过key查找到value)

Map接口定义

public interface Map<K,V>{...}
  • K:Map映射的key类型
  • V:Map映射的value类型

可以看见Map接口像Collection接口一样,没有太多的继承关系,是一个独立的父接口

继承图解

在这里插入图片描述

2.接口中的常用方法

方法名说明
int size()Map中所含key-value映射的数量
boolean containsKey(Object key);Map中是否存在指定key的映射关系
boolean containsValue(Object value);Map中是否存在指定value的映射关系
V get(Object key)通过指定key获取映射关系中的value
V put(K key, V value)在Map集合中添加一条key-value映射
V remove(Object key)移除指定键的映射
void putAll(Map<? extends K, ? extends V> m);将指定Map集合中的所有映射添加到本Map集合中
Set keySet()得到Map中所有键的Set集合
Collection values()得到Map中所有值的Collection集合
Set<Map.Entry<K, V>> entrySet()返回Map中所有key-vaue键值对的Set集合

Map接口中的方法具体由子类实现

常用子类
HashMap、HashTable、TreeMap、LinkedHashMap

3.Map.Entry内部接口

该接口定义在Map接口内部

接口定义

interface Entry<K,V> {...}

K,V仍代表键和值的类型

接口说明

它表示Map集合中的一个实体,即一个键值对,所有key和value的数据都封装在Map.Entry接口之中,Map的实现类如HashMap中的Node节点类就是通过实现Map.Entry来存取键值对数据

这个接口有两个重要方法:

  • K getKey():获取key值
  • V getValue():获取value值

接口作用

  • 封装key-vaue数据的存取

二、HashMap

1.哈希表

要想搞懂HashMap的底层结构,必须对哈希表有清晰的认识

概念
哈希表(散列表),是基于关键码值(key的值)而直接进行访问的数据结构,它一般基于数组实现,特定情况结合链表。

我们可以把关键码通过映射方法映射到表中的一个位置,而不需要依次遍历表进行比较,就可以存取元素,达到快速访问的目的,这种快速定位是通过数组的下标访问特性实现的。

哈希函数
上面提到的映射方法就是哈希函数(散列函数),它通过一个函数,可以使我们传入的关键码定位到存储结构中一个唯一的存储地址。用数学表达式就是:h= f(k),其中h是散列值(数据的定位),k为我们的关键码,f为映射关系

因为我们一开始定义的存储空间不可能是无限的,而输入却有可能是无穷无尽的,所以哈希函数的核心就是将任意范围的输入变换成固定的范围的输出,这个输出就是散列值。但这种变换是一种压缩映射,散列值的范围一定是小于输入的范围的,所以不可能根据散列值来确定唯一的输入值,不同的输入可能会产生相同的输出,这就造成了hash冲突

哈希冲突
哈希冲突造成的原因是:不同的元素通过其关键码经过哈希函数的运算得到了同一个散列值,即定位到了散列表中的同一个位置,形成了hash冲突

注意这里说的是不同的元素的关键码,我们理想的条件肯定是不同的元素其关键码肯定是不同的,这样有可能会产生hash冲突,但如果不同的元素它们的关键码相同,那么必定会产生hash冲突,所以这里为hashCode()埋下了伏笔,我们应保证不同对象的hashCode()肯定不同

处理哈希冲突

对于哈希冲突,我们可以优化hash函数,设计良好的hash算法,使得对于两个不同的输入,经过hash函数运算后,其输出结果相同的概率尽可能小

但概率虽小,还是会出现相同的结果,那么我们应该如何做才能使定位到同一位置的不同元素得到区分呢?其实处理哈希冲突的方法很多,比如开放地址法、多哈希法、拉链法(链地址法),而HashMap中用到的处理方法为:拉链法

面试题:HashMap中如何处理的哈希冲突?
处理哈希冲突有两种思路

  • 一是降低哈希冲突发生的概率,比如设计良好的哈希算法
  • 二是哈希冲突真的发生后该如何解决,比如利用链地址法、开放地址法

哈希算法

哈希算法用来构造哈希函数,哈希算法有很多,但我们应该知道什么是良好的哈希算法,它应该可以使出现hash冲突的概率降低,一个良好的hash算法应该满足:

  • 接收一个任意类型的参数
  • 一般情况返回一个整型值
  • 输出的哈希值均匀分布在整个哈希表的范围中
  • 两个相同的输入,必有相同的输出
  • 两个不同的输入,输出相同的概率应该做到尽可能小
  • 计算方法不能过于复杂,时间复杂度低

哈希算法也有很多种,比如:直接定址法、随机数法、平方取中法、折叠法、除留余数法,在HashMap中使用的哈希算法为除留余数法,但做了很多改进

总结

一个图来总结下哈希表:
在这里插入图片描述

清楚了关于哈希表的知识后,我们就可以进行对HashMap的分析了

2.HashMap底层结构

HashMap结构演化过程一

HashMap底层采用哈希表来实现,而哈希表是基于数组的数据结构,所以HashMap的初始结构就是一个哈希桶数组(位桶数组)

HashMap结构演化过程二

HashMap中的数据是一个个键值对,一个键值对用一个Node<K, V>节点存储,它是一个内部类,实现了Map接口中的Map.Entry接口,可以对键值对进行存取操作,所以,HashMap的结构变为:
在这里插入图片描述

HashMap结构演化过程三

用的哈希表就免不了哈希冲突,当发生哈希冲突时,HashMap结构如下:
在这里插入图片描述
虽然图是这么画的,但是我们都知道,一个数组元素中不可能存两个值,所以HashMap提供了哈希冲突解决方案,利用拉链法处理哈希冲突

HashMap结构演化过程四

关于拉链法:在原来的哈希桶数组发生哈希冲突的每个元素上拉出一个动态链表代替静态的顺序存储结构

既然要用链表,说明在HashMap的Node<K,V>中只存key和value是不行的,还要有一个指向下一个元素的指针,我们可以看看HashMap中Node类的部分源码:

static class Node<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Node<K,V> next;
		public final K getKey()        { return key; }
        public final V getValue()      { return value; }
}/**
省略了部分属性和方法
**/

可以看到,除了key,value之外,还有一个Node类型的对象next,它的作用是指向下一个Node节点(也可以看出这是一个单向链表)

加了链表后,HashMap结构如下:
在这里插入图片描述

你以为这就完了吗?并没有,上面的结构可以说是JDK1.7的HashMap底层结构,但JDK1.8改进了HashMap中关于链表的部分,当链表长度大于8时(且哈希桶数组的容量大于64),会将链表转化为红黑树

1.面试题:为什么JDK1.8要在使用红黑树存储?
在JDK1.7中,用链表存储有一个缺点,当数据很多的时候,哈希冲突也会很多,导致链表中存储的数据可能会很长,当我们存元素或取元素的时候如果存在链表,我们就要遍历链表(为什么要遍历后面会详细讲到),而链表的硬伤就是遍历慢,因为要移动指针,所以这会严重影响HashMap的存取效率
所以JDK1.8中用了红黑树来优化,当链表长度大于8的时候,会将链表转为红黑树,红黑树是二分查找,提高了查询的效率
2.面试题:为什么不直接使用红黑树,而是在一定的阈值才转化为红黑树?
因为红黑树是一种二叉平衡树,而它又是二叉平衡树的改进,主要是在增删方面,为了保持树的平衡,在增删数据后可能需要进行变色和旋转的操作,这种保持平衡是需要付出代价的,这个代价体现在红黑树节点的大小约是链表节点的两倍,但是在链表很长的情况下,该代价所损耗的资源要比遍历线性链表少,所以当链表长度大于8时,会考虑使用红黑树存储,而链表长度很短的情况下,根本用不着转化,转化了反而会使存取效率降低
3.面试题:为什么链表转化为红黑树的阈值为8,红黑树转化为链表的阈值为6?
1)由于红黑树节点的大小大约是常规链表节点的两倍,因此我们仅在容器包含足够的节点以保证使用时才使用它们转化为红黑树,而阈值为8是经过科学的分析计算了的,根据源码中的解释,理想情况下,由于存在扩容机制,我们使用随机的哈希码,节点分布在 hash 桶中的频率遵循泊松分布,按照泊松分布的公式计算,链表中节点个数为8时的概率为 0.00000006,这个概率十分小。并且链表长度到8时,红黑树此时的操作效率要高于链表,这个可根据时间复杂度公式来计算验证
2)至于红黑树转化为链表的阈值为什么是6,中间有个差值7可以防止链表和树之间频繁的转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低

HashMap结构演化过程五

用红黑树优化后的HashMap结构如下
在这里插入图片描述
这就是JDK1.8中HashMap的最终结构了,接下来我们再来深入分析一下HashMap的哈希算法、put、get、扩容原理吧,由于JDK1.8加入红黑树后,源码的难度又上升了一个档次,所以我们应该清楚红黑树的特点和规则,并且能够用代码完成红黑树数据结构的构造,否则,HashMap的源码看起来会有点困难

关于红黑树的详细介绍参考:看了这么多篇红黑树,你真的了解了吗?

3.HashMap的哈希算法

好的哈希算法是降低哈希碰撞的有效手段,我们来分析一下,HashMap中是如何巧妙的设计哈希算法的吧

确定哈希函数的输入

我们上面已经知道了,一个哈希函数,应该保证不同的输入尽可能有不同的输出,不同的输入是为了区分不同的对象(关键码),若是关键码相同,那么就必定会生成相同的散列值,造成哈希冲突。

所以怎么保证不同的对象有有不同的输入呢?每个对象的hashCode()方法似乎就是专门为这而设计的,hashCode()方法规约保证了相同的对象必有相同的返回值(整形),不同的对象hashcode()尽可能不同,所以hashCode()的返回值用来作哈希函数的输入是再好不过的

1.面试题:重写了equals()方法后,为什么要重写hashCode()?
关于该面试题的答案以及hashCode()方法的详细介绍请参考我的另一篇博文:Java 中hashCode() 、equals() 和==深入解析
2.关于hashCode()的规范
hashCode()方法规约只是一种约定,编程人员可以不用强制遵守,但我们应该知道如果不遵守这些规约,将会造成严重的哈希冲突(特别是数据量多的情况下)

但是,我们来看一下HashMap的源码,它是直接使用的对象的hashCode()方法吗?

扰动函数

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

这个方法的返回值会用做哈希函数的输入,关于哈希函数后面会提到。我们这里一看就会发现,当对象不为null的时候,这里并不是直接返回对象的hashCode(),而是进行了对hashcode的位运算

其实这里的hash()方法叫做扰动函数,它的目的是为了让hashcode在参与位运算时,让它转化为二进制后的各个位都尽可能参与运算,这句话背后的意思,就是hashcode参与位运算时有可能有一些位不会参与运算,而有些位不会参与运算的结果会导致什么呢?

这里先抛出个结果:它会导致哈希冲突的概率加大,具体我们分析了哈希函数后再举例说明这里的原因

HashMap中的哈希函数

前面也已经提到,HashMap中用到的哈希算法为除留余数法,关于除留余数法,它实现简单,运用的也比较多,它的公式如下:

f(key)=key mod p (p<=m)
(其中,key为关键码,mod为取模、m为哈希表的长度)

在HashMap中,p值选的是哈希表(哈希桶数组)的长度,而哈希表长度在HashMap中始终为2n,且取模运算在一定条件下可以转化为位运算(&):

key mod 2n = key & (2n-1)
(其中,key为关键码,mod为取模运算、&为按位与运算)

于是,HashMap中的哈希算法如下:

int index = (n - 1) & hash//n为哈希表长度,hash为hash()方法的返回值,index为定位到哈希桶数组中的索引位置

面试题:为什么HashMap中哈希表的长度要设置为2n?
有两个原因:

  • 一是上面提到的如果哈希表长度为2n,那么就可以将哈希算法中取模运算转化为位运算,提高运算效率,这是运算速度上的优化
  • 二是在保证了哈希表长度为2n时,在HashMap 扩容后,同一个索引位置的节点重新hash运算后,最终的索引最多分布在新哈希表的两个位置:“原索引位置” 和 “(原索引 + 旧哈希表容量)位置”。至于原因,会在下文的HashMap扩容原理那一节进行讲解

接下来就可以解释下hash()扰动函数设计的原因了

hash()扰动函数的作用

好的哈希算法应该尽可能使散列值均匀分布在哈希表的长度范围内,而不是集中在一个或几个位置,极端的情况下就是散列值永远定位到数组的同一个位置,导致这个位置上的链表十分长。所以做到散列值均匀分布会使哈希冲突的概率减少,提高存取效率,而hash()扰动函数就有这样的作用

这里我把扰动函数再次贴出来,方便对照:

(h = key.hashCode()) ^ (h >>> 16)
(其中,key为关键码,h为对象的hashcode、^为按位异或运算,>>>为无符号右移)

">>“和”>>>"的区别:

  • ">>"为带符号右移,左边补符号位
  • ">>>"为无符号右移,左边补0

当没有使用hash()扰动函数,直接用hashcode来当作哈希函数的输入时,在哈希表长度较小的情况下可能就会出现像下面这样严重的问题,没有做到输出的散列值均匀分布:(图中,哈希表的长度n=16,为HashMap没有被指定哈希表长度时,默认构造的长度)

在这里插入图片描述
但是,当我们使用了hash()扰动函数后,我们会将hashcode的高位也参与运算,使得最终的结果不止是hashcode的低位参与了运算的结果,这样经过哈希函数算出来的结果也会大概率不同,作到了均匀分布,降低了哈希冲突的概率,图解如下:
在这里插入图片描述

1.hash()扰动函数中为什么选择将hashcode右移16位?
16位正好是32位hashcode的一半,自己的高16位和低16位作异或,可以使hashcode的所有位都参与运算,使运算最终得到的结果的低16位加大了随机性;而原来的高16位和移位所补的0作异或,最终结果还是取决于高16位,因此不会对hashcode的高16位产生影响
2.hash()扰动函数中为什么要采用异或运算?
因为^(异或)运算,在各种0和1的运算中得到1或者0的概率都是1/2,而&(与)运算得到0的概率较大为75%,| (或)运算得到1的概率较大为75%,读者可以自行验证。因此使用异或运算时,只要32位hashcode的一位发生变化,最终的结果就会改变,尽可能的减少碰撞
3.上面的总结下来就是,它们最终可以使哈希函数的输入更加随机不同,使得我们哈希函数的输出的范围也更加均匀,降低了哈希冲突的概率

4.HashMap中的基本属性和结构

// 默认初始化容量:16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子:0.75,用于与当前最大容量相乘计算扩容阈值
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 链表转化为二叉树阈值:8
static final int TREEIFY_THRESHOLD = 8;
// 二叉树转化为链表阈值:6
static final int UNTREEIFY_THRESHOLD = 6;
// 转化为二叉树时的最小容量:64
static final int MIN_TREEIFY_CAPACITY = 64;

// 存储键值对信息的链表节点类
static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;// 通过key根据hash()方法得到的值
        final K key;// 键
        V value;// 值
        Node<K,V> next;// 指向下一个节点
        ...
}

// 哈希桶数组,其中元素是Node节点
transient Node<K,V>[] table;
// HashMap中的元素个数
transient int size;
// fail-fast机制的统计变量
transient int modCount;
// 扩容阈值:等于当前HashMap的容量*loadFactory
int threshold;
// 加载因子,用以确定扩容阈值
final float loadFactor;
...

5.get方法

  • get(Object key)
/**
 * key:我们需要传入的键
 * return:V,根据键找到的值
 * /
public V get(Object key) {
	      Node<K,V> e;
	      // 调用getNode()方法
	      return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
  • getNode(int hash, Object key)
/**
 * hash:键key根据hash()方法得到的值
 * key:我们传入的键
 * return:根据键key所找到的Node节点
 * /
final Node<K,V> getNode(int hash, Object key) {
		// 声明临时存储变量
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        // 1.注意这里在条件判断的同时也进行了赋值操作,这是类集源码最为常见的操作
        // 2.判断table(即此hashmap的位桶数组)是否为空、长度是否大于0,且根据hash算法得到的在table中的索引位置是否为空
        // 3.如果满足条件进行下一步,如果不满足直接返回null
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            // 1.这里判断头节点first是否就是我们要get的节点
            // 2.首先判断first节点的hash是否和传入的hash一致,如果一致则判断后面的条件,后面的条件也满足的话就返回first节点;如果不一致,则直接进入下一个代码块
            // 3.首先进行hash判断的原因是因为hashcode的特性:hash不同的两个对象肯定不同,这样就加快了判断的速度,如果不同就直接跳过,而不用进行equals判断。但hash相同的两个对象不一定相同,所以还要进行后面的equals判断
            // 4.至于为什么不直接使用equals,应该是equals是个方法,它内部的判断条件可能会很复杂,直接使用它会使效率下降
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            // 1.走到这一步说明first节点不是我们需要get的节点,所以需要找first的下一个节点
            // 2.这里判断first的下一个节点是否为空,若为空则直接返沪null;若不为空,则进行内部代码块
            if ((e = first.next) != null) {
            	// 这里判断我们定位到的节点是否是红黑树节点,如果是则直接调用红黑树的查找节点方法,并返回相应结果
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                // 1.走到这一步说明我们定位到的节点是属于链表节点的,所以进行链表的循环遍历
                // 2.这是一个do-while结构,目的是依次遍历链表中的所有节点,找到我们需要get的节点就返回;没有找到,该代码块执行结素便会走最后的return null语句
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

get流程图
get流程图

6.put方法

  • put(K key, V value)
/**
 * key:要存储的键
 * value:与键对应的值
 * return:如果存在键的覆盖,则返回原来键对应的值;如果没有覆盖情况则直接返回null
 * /
public V put(K key, V value) {
		// 调用putVal()方法
        return putVal(hash(key), key, value, false, true);
    }
  • putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
/**
 * hash:键key根据hash()方法得到的值
 * key:传入的键
 * value:传入的值
 * onlyIfAbsent:如果为true,当存在相同的键时就不进行覆盖操作,默认为false
 * evict:创建模式的标志,用于LinkedHashMap,保持插入的顺序
 * /
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的长度为0,则调用resize()扩容方法
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 1.这里确定我们的节点要put的位置
        // 2.根据哈希算法:(n-1)&hash得到在table中的索引位置
        // 3.如果索引位置上的节点为空,则直接调用newNode()方法在该位置上插入(key,value)节点
        // 4.如果索引位置上的节点不为空,则需要进行链表或者红黑树的遍历操作,找到合适的位置,再插入
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            // 1.走到这里说明索引位置节点不为空
            // 2.首先要判断索引位置节点的key和我们要插入的key是否相同,如果相同则需要走到最后一个代码块进行覆盖操作;如果不同则走下面代码块进行遍历操作
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 判断节点是否为红黑树节点,如果是则进行红黑树节点的操作
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            // 走到这里说明不是红黑树节点,需要进行链表的遍历操作,看是否存在节点的key和我们要插入的key值相同
            else {
                for (int binCount = 0; ; ++binCount) {
                	// 如果遍历到链表的末尾还是没有找到节点有相同的key值,则直接在链表末尾进行newNode()的插入操作
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // 1.这里判断链表是否能够转换成红黑树
                        // 2.这里阈值-1是因为p.next是从我们定位节点的下一个节点开始的,而这里要用>=而不是>是因为binCount是从0开始的。即总节点>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;
                }
            }
            // 覆盖操作:前提是存在相同的节点key和我们要插入的key相同
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                // 还要进行判断是否覆盖:onlyIfAbsent的标志为false或者原来key对应的值为null时,就会进行覆盖操作
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                // 这是LinkedHashMap维持访问顺序的方法
                afterNodeAccess(e);
                // 返回覆盖前的旧值
                return oldValue;
            }
        }
        // 这是fast-fail机制的判断变量
        ++modCount;
        // 扩容判断:看size是否超过了扩容阈值
        if (++size > threshold)
            resize();
        // 这是LinkedHashMap维持插入顺序的方法,在HashMap中是个空方法,会被LinkedHashMap重写
        afterNodeInsertion(evict);
        return null;
    }
  • newNode(int hash, K key, V value, Node<K,V> next)
/**
 * hash:key的hash值
 * key:要存储的键
 * value:键对应的值
 * next:下一个node节点
 * return:保存有存储数据的node节点
 * /
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
        return new Node<>(hash, key, value, next);
    }

put流程图
put流程图

7.扩容原理

当HashMap的容量达到一定阈值时,就会触发扩容机制

  • resize()
final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        // 定义旧表容量和扩容阈值
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        // 新表容量和阈值设为0
        int newCap, newThr = 0;
        // 1.如果旧表容量大于0
        if (oldCap > 0) {
        	// 1.1如果旧表容量大于最大容量,则只将阈值设为Integer的最大值,返回旧表,不进行扩容处理,因为此时无法进行扩容
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 1.2走到这里说明旧表容量没有超过允许的最大容量
            // 将新表容量设为旧表的两倍
            // 如果同时新表容量小于允许的最大容量并且旧表容量大于等于默认初始化容量16,那么就会设置新的扩容阈值为旧阈值的两倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        // 2.走到这里说明旧表的容量等于0,如果此时旧表的扩容阈值大于0(这种情况是初始化HashMap时给定了初始容量,但是HashMap结构没有capacity属性,所以将初始容量暂时赋值给threshold属性了)
        else if (oldThr > 0) // initial capacity was placed in threshold
        	// 2.1直接将旧表阈值赋值给新表容量
            newCap = oldThr;
       	// 3.走到这里说明旧表容量和阈值都为0(这种情况是初始化HashMap时没有给定初始化容量)
        else {               // zero initial threshold signifies using defaults
        	// 3.1将默认初始化容量16和默认计算得到的阈值分别赋值给新表容量和新表阈值
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        // 4.当新表阈值为0时(这种情况是满足上面1.2和2条件的情况时,那时并没有赋值新表阈值,所以这里要给定)
        if (newThr == 0) {
        	// 4.1将新表容量和加载因子的乘积作为新表阈值
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        // 将新表阈值赋值给HashMap的阈值属性:threshold
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        // 将具有新容量的新表赋值给HashMap的表属性
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        // 5.下面进行节点的转移:将旧表节点复制到新表中
        // 如果旧表不为空
        if (oldTab != null) {
        	// 循环遍历旧表上的每个索引位置
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                // 如果此索引位置上的节点不为空(说明该位置上存在元素)
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null; // 将旧表该索引位置置为null,便于回收
                    // 5.1如果该节点没有指向下一个节点,则直接根据哈希算法确定该节点在新表中的索引位置
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    // 5.2如果该节点是红黑树节点,则进行红黑树的重哈希分布
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    // 5.3走到这一步说明该索引位置上存在多个节点,且构成的是链表
                    else { // preserve order
                        // 到这一步我们需要理解的是旧表中的节点转移到新表中,索引位置只会是“原来的索引值”或者“原来的索引值+旧表容量”,原理下文解析
                        Node<K,V> loHead = null, loTail = null;//存储索引位置在“原来的索引值”的头节点(loHead)和中间节点(loTail,到最后会变成尾节点)
                        Node<K,V> hiHead = null, hiTail = null;// 存储索引位置在“原来的索引值+旧表容量”的头节点(hiHead)和中间节点(hiTail)
                        Node<K,V> next; // 存储下一个节点的中间节点变量
                        // do-while循环遍历该索引位置上的节点
                        do {
                            next = e.next;
                            // 5.4当e.hash&oldCap的值为0时说明该节点在新表中的索引位置为“原索引位置”
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            // 5.5当e.hash&oldCap的值为1时说明该节点在新表中的索引位置为“原索引位置+oldCap”
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        // 5.6loTail不为空,说明存在在新表的索引位置为“原索引位置”的节点
                        if (loTail != null) {
                        	// 将尾节点的下一个节点置为null
                            loTail.next = null;
                            // 将头节点赋值于该索引位置上
                            newTab[j] = loHead;
                        }
                        // 5.7loHead不为空,说明存在在新表的索引位置为“原索引位置+oldCap”的节点
                        if (hiTail != null) {
                        	// 将尾节点的下一个节点置为null
                            hiTail.next = null;
                            // 将头节点赋值于新索引位置上
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        // 返回新表(新的哈希桶数组)
        return newTab;
    }

在新表中的索引位置只存在“原索引位置”和“原索引位置+旧表容量”的原理图解
索引图解
扩容流程图
在这里插入图片描述

8.面试汇总

参见:Java 集合(五)| 集合面试汇总


– – 虽然我们无法改变人生,但可以改变人生观。虽然我们无法改变环境,但我们可以改变心境。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值