JDK源码分析——HashMap

HashMap源码分析

HashMap集合简介

什么是HashMap

在这里插入图片描述

  • HashMap 是 Map 接口的实现类,基于哈希表结构实现的。其主要特点是以 Key-Value 形式存储数据,HashMap 的操作是不同步的,这就意味着它是线程不安全的。
  • 特点:
    • 无序性:存入和取出元素顺序不一致
    • 唯一性:Key是唯一的
    • 可存null:键和值都可以为 null,但是键为null的位置只有一个,首地址
    • 数据结构:控制的是 Key,而不是 Value
      • JDK 1.8 之前的数据结构是:数组 + 链表
      • JDK 1.8 之后的数据结构是:数组 + 链表 + 红黑树
      • 单链表阈值(边界值) 大于 8,并且数组长度大于 64,才将链表转为红黑树——目的是高效查询数据

红黑树(Red Black Tree):是一种自平衡二叉查找树,是在计算机科学中用到的一种数据结构,典型的用途是实现关联数组。红黑树是在1972年由Rudolf Bayer发明的,当时被称为平衡二叉B树(symmetric binary B-trees)

HashMap类的继承关系

在这里插入图片描述

  • Cloneable:空接口,表示可以克隆,创建并返回HashMap对象的一个副本。
  • Serializable:序列化接口,属于标记性接口,HashMap 对象可以被序列化和反序列化。
  • AbstractMap:父类提供了 Map 实现接口,以最大限度地减少实现此接口所需的工作。

补充:通过上述继承关系我们发现一个很奇怪的现象, 就是HashMap已经继承了AbstractMap,而 AbstractMap 类实现了Map接口,那为什么HashMap还要再实现Map接口呢?同样在ArrayList中LinkedList中都是这种结构。
据 java 集合框架的创始人Josh Bloch描述,这样的写法是一个失误。在java集合框架中,类似这样的写法很多,最开始写java集合框架的时候,他认为这样写,在某些地方可能是有价值的,直到他意识到错了。显然的,JDK的维护者,后来不认为这个小小的失误值得去修改,所以就这样存在下来了。

HashMap原理分析

哈希表简介

什么是哈希表?

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

HashMap中哈希表的数组的大小?
我们说,HashMap中的底层数据结构是哈希表,又说是数组+链表+红黑树?那么到底是怎么一回事呢?我接下来看HashMap的数据结构

在这里插入图片描述

  • 结论:哈希表的本质是一个数组,每个元素的索引位存储时链表。
  • 当创建HashMap集合对象时:
    • JDK8前,构造方法创建一个长度是16的数组Entry[] table 来存储键值对的对象;
    • JDK8后,不是在构造方法中创建对象数组,而是在第一调用put方法时创建长度是16的Node[] table数组,存储Node对象
  • 如果节点长度即链表长度大于阈值8,并且数组长度大于64则进行将链表变为红黑树。

在这里插入图片描述

HashMap存储数据过程

存储过程中相关属性
  • 加载因子:默认值是 0.75,决定了扩容条件
// 加载因子
final float loadFactor;
  • 扩容的临界值:计算方式为(容量 乘以 加载因子)
int threshold;
  • 容量capacity:初始化为 16
  • 扩容 resize:达到临界值就扩容,扩容后的 HashMap 容量是之前容量的两倍
  • 集合元素个数size:表示 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;
    // 1. 判断哈希表是否为空
    if ((tab = table) == null || (n = tab.length) == 0)
    	// 2. 如果为空,初始化容量为 16
        n = (tab = resize()).length;
    // 3. 如果不为空,则判断当前 key 的 hash 值对应的索引位置是否有元素
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 4. 如果没有元素,往当前索引位置放入一个新的节点
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 5. 如果有元素,判断当前索引位置的节点 hash 值和 equals 与 新 key 是否相等
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
        	// 如果相等,则覆盖 value
            e = p;
        // 6. 如果不相等,则判断是否是红黑树
        else if (p instanceof TreeNode)
        	// 如果是红黑树节点,则将元素存入红黑树节点
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
        	// 7. 如果不相等,也不是红黑树,则遍历所有链表节点
            for (int binCount = 0; ; ++binCount) {
            	// 如果到了最后一个节点还没有找到相等的节点
                if ((e = p.next) == null) {
                	// 则在尾部新增一个节点
                    p.next = newNode(hash, key, value, null);
                    // 8. 判断链表的长度是否大于 8
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                    	// 如果大于 8,直接将链表转为红黑树
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果遍历节点的hash值和equals值 与 新 key 值相同,则跳出循环
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 如果 key 存在,则直接覆盖 value 值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 判断 HashMap 中节点数是否大于临界值,如果大于则扩容到之前的两倍
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

HashMap底层数据结构

什么是数据结构
  • 数据结构是计算机存储、组织数据的方式。数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。通常情况下,精心选择的数据结构可以带来更高的运行或者存储效率。数据结构往往同高效的检索算法和索引技术有关。
  • 数据结构:就是存储数据的一种方式。比如:ArrayList、LinkedList。
HashMap的数据结构
  • 在 JDK1.8 之前,HashMap 是由数组 + 链表的数据结构组成。
  • 在 JDK1.8 之后,HashMap 是由**数组 + 链表 + 红黑树【哈希表】**的数据结构组成。
  • 数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)

什么是哈希冲突?
两个对象调用的 hashCode 方法计算得到的哈希码值相同,导致计算出来的数组索引值相同。

  • 在 JDK1.8 以后,解决哈希冲突有了较大的变化,当链表的长度大于阈值(或者红黑树的边界值,默认为 8),并且当前数组的长度大于 64,此时此索引位置上的所有数据改为使用红黑树进行存储。
  • JDK1.8引入红黑树大程度优化了HashMap的性能,那么对于我们来讲保证 HashMap 集合元素的唯一,其实就是根据对象的hashCode和equals方法来决定的。如果我们往集合中存放自定义的对象,那么保证其唯一,就必须复写hashCode和equals方法建立属于当前对象的比较方式。
  • JDK1.8在哈希表中引入红黑树的原因只是为了查找效率更高。
  • 简单的来说,哈希表是由数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的。如下图所示。

在这里插入图片描述

HashMap中数据结构的源码
  • table 用来初始化(必须是2的n次幂)【重点】
// 存储元素的数组
transient Node<K,V>[] table;
  • 缓存数组的对象:
// 存放具体元素的集合
transient Set<Map.Entry<K,V>> entrySet;
  • HashMap中存放元素的个数【重点】
// 存放元素的个数,并不等于数组的长度
transient int size;
  • 红黑树
TreeNode<K, V>
  • 链表
Node<K, V>

HashMap源码分析

HashMap的默认初始化容量

初始化容量
// 默认初始化容量 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
为什么初始化容量必须是2的次幂
  • 向 HashMap 中添加元素时,要根据 Key 的 hash 值去确定其在数组中的具体位置。HashMap 为了存取高效,要尽量减少碰撞,就要尽量把数据分配均匀,使每个链表的长度大致相同。那么怎么让元素均匀分配呢?这里用到的算法是 hash & (length - 1),即 hash 值与数组长度减一的位运算,该算法的本质作用就是取模 hash % length,在计算机中求余效率远不如位运算。
  • hash%length 取模效果操作等于hash&(length-1)的前提,length是2的n次幂!
  • 为什么这样能均匀分布减少碰撞呢?2的n次幂实际就是1后面n个0,2的n次幂-1 实际就是n个1。
  • 举例:位运算规则说明,按&位运算,相同的二进制数位上,都是1的时候,结果为1,否则为0。
例如 : 数组长度8时候,均匀分布在数组中,哈希碰撞的几率比较小
求余运算的结果:314924944 & (8-1) = 0
			00010010110001010101111110010000
		&	00000000000000000000000000000111
			--------------------------------------------------
			00000000000000000000000000000000 --> 结果为0
程序员计算器求解
314924944 & (8-1) = 0 
314924945 & (8-1) = 1 
314924946 & (8-1) = 2 
314924947 & (8-1) = 3 
314924948 & (8-1) = 4 
314924949 & (8-1) = 6 
314924950 & (8-1) = 7 
314924951 & (8-1) = 8 
314924952 & (8-1) = 0

结论是:数组索引存储的数据均匀分布了,减少哈希碰撞的几率
例如 : 数组长度10时候,没有均匀分布,碰撞几率比较大;
程序员计算器求解 : 
314924944 & (10-1) = 0 
314924945 & (10-1) = 1 
314924946 & (10-1) = 0
314924947 & (10-1) = 1 
314924948 & (10-1) = 0 
314924949 & (10-1) = 1 
314924950 & (10-1) = 0 
314924951 & (10-1) = 1 
314924952 & (10-1) = 0
结论是:数据全部分布在第一个和第二个索引位置上,大大增加了哈希碰撞的几率。效率低下
手动设置初始化容量
  • HashMap 构造方法还可以指定集合的初始化容量大小:
HashMap(int initialCapacity) // 构造一个带指定初始容量和默认加载因子 (0.75) 的空HashMap
  • 注意:如果不考虑效率问题,求余即可,就不需要长度必须是 2 的次幂了。但如果采用位运算,必须是 2 的次幂。
  • 那么来了,如果有那个蠢蛋不知道,瞎搞。HashMap也自带纠错能力。具备防蠢货功能。如果创建HashMap对象时,输入的数组长度不是2的n次幂,HashMap通过一通位移运算和或运算得到的肯定是2的幂次数,并且是离那个数最近的数字。
// 例如:创建HashMap集合的对象,指定数组长度是10,不是2的幂
// HashMap hashmap = new HashMap(10);
public HashMap(int initialCapacity) { // initialCapacity = 10
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) { // initialCapacity = 10
    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);
}
// 自动调整初始化容量,让其符合2的n次幂
// 如果传入值是10,会自动校正为最近的一个2的n次幂 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;
}
  • 举个例子:加入给的初始化容量为10,最终容量会变为最近的16

在这里插入图片描述

小结
  • 根据 key 和 hash 确定存储位置时,数组长度是 2 的 n 次幂,可以保证数据的均匀插入,如果不是,会浪费数组的空间,降低集合性能。
  • 一般情况下我们通过求余 % 来均匀分散数据,只不过其性能不如位运算&。
  • length的值为2的n次幂,hash & (length - 1) 作用完全等同于hash % length。
  • HashMap 中初始化容量为 2 的次幂,原因是为了数组数据均匀分布,尽可能减少哈希冲突,提升集合性能。
  • 即便可以手动设置 HashMap 的初始化容量,但是最终还是会被重置为 2 的 n 次幂。

HashMap的加载因子和最大容量

加载因子相关属性
  • 哈希表的加载因子(重点)
// 加载因子
final float loadFactor;
  • 加载因子的默认值是 0.75,决定了扩容的条件
static final float DEFAULT_LOAD_FACTOR = 0.75f;
  • 集合的最大容量:10 7374 1824【10亿】
// 集合最大容量的上限是:2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;
  • 扩容的临界值:计算方式是【容量 乘以 加载因子】
// 临界值 当实际大小超过临界值时,会进行扩容
int threshold;
为什么加载因子设置为 0.75,初始化临界值是 12 ?
  • loadFactor 太大导致查找元素效率低,小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 0.75 是官方给出的一个比较好的临界值。
    • 加载因子越大趋近于1,数组中的存放数据(Node)也就越多,也就越稠密,也就是会让链表的长度增加。
    • 加载因子越小趋近于0,数组中的存放数据(Node)也就越少,也就越稀疏,也就是会让链表的长度不会太长。
  • 如果希望链表尽可能少些,性能更好。就要提前扩容,但导致的问题是的数组空间浪费,有些桶没有存储数据!典型的鱼与熊掌不可兼得!
  • 举个例子:
例如:加载因子是 0.4,那么 16 * 0.4 = 6,也就是数组中满 6 个空间就扩容,会导致数组利用率太低。
     加载因子是 0.9,那么 16 * 0.9 = 14,也就是数组中满 14 个空间就扩容,会导致链表有点多了,导致查找效率低。
  • 所以既兼顾数组利用率又考虑链表不要太多,经过大量测试0.75是最佳方案。
  • threshold 计算公式:
threshold = capacity(数组长度默认16) * loadFactor(加载因子默认0.75)
  • 这个值是当前已占用数组长度的最大值。当size>=threshold的时候,那么就要考虑对数组的resize(扩容)。 扩容后的 新HashMap 容量是之前容量的两倍。
修改加载因子
  • 同时在 HashMap 的构造器中可以定制 loadFactor,但是最好不要修改
HashMap(int initialCapacity, float loadFactor)

HashMap的红黑树转换边界值详解

边界转换相关属性
  • 转换边界值1:当链表长度超过转换边界值 8,就会转红黑树(JDK1.8),还有一个前提条件是数组长度大于等于64
// 当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
  • 转换边界值2:当 Map 里面的数量超过这个值时,表中的桶才能形成树形化,这个值不能小于 4 * TREEIFY_THRESHOLD (8)
// 桶中结构转化为红黑树对应的数组长度最小的值
static final int MIN_TREEIFY_CAPACITY = 64;
  • 桶(bucket):所谓的桶,指的是一个数组索引位中的所有元素。
  • 降级转换边界值:当链表的长度值小于 6,则会从红黑树转为链表
static final int UNTREEIFY_THRESHOLD = 6;
为什么Map桶中节点个数超过8才转为红黑树

HashMap的红黑树数据结构几乎不会被用到,本质上还是一个数组+链表!

  • 阈值8定义在HashMap中,针对这个成员变量,在源码的注释中只说明了8是bin(bin就是bucket(桶))从链表转成树的阈值,但是并没有说明为什么是8,在HashMap官方注释说明中:
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins.  In
* usages with well-distributed user hashCodes, tree bins are
* rarely used.  Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0:    0.60653066
* 1:    0.30326533
* 2:    0.07581633
* 3:    0.01263606
* 4:    0.00157952
* 5:    0.00015795
* 6:    0.00001316
* 7:    0.00000094
* 8:    0.00000006
* more: less than 1 in ten million
  • 翻译一下就是:
因为树节点的大小大约是普通节点的两倍,所以我们只在桶包含足够的节点时才使用树节点(参见TREEIFY_THRESHOLD)。当它们变得太小(由于删除或调整大小)时,就会被转换回普通的桶。在使用分布良好的用户hashcode时,很少使用树。
这种情况下,在随机哈希码下,桶中节点的频率服从泊松分布,默认调整阈值为0.75,平均参数约为0.5,尽管由于调整粒度的差异很大。忽略方差,列表大小k的预期出现次数是(exp(-0.5)*pow(0.5,k)/factorial(k))。
第一个值是:
0: 0.60653066 
1: 0.30326533 
2: 0.07581633 
3: 0.01263606 
4: 0.00157952 
5: 0.00015795 
6: 0.00001316 
7: 0.00000094 
8: 0.00000006 
more: less than 1 in ten million
  • 红黑树节点对象占用空间是普通链表节点的两倍,所以只有当桶中包含足够多的节点时才会转成红黑树。当桶中节点数变少时,又会转成普通链表。并且我们查看源码的时候发现,链表长度达到8就转成红黑树,当长度降到6就转成链表。
  • 这样就解释了为什么不是开始就将其转换为红黑树节点,而是达到数量才转。
  • 说白了就是空间和时间的权衡!
  • 官方还说了:**当hashCode哈希函数值离散性很好的情况下。红黑树被用到的概率非常小!**概率为0.00000006。
  • 理想的情况下,优秀的hash算法,会让所有桶的节点的分布频率会遵循泊松分布。我们可以看到,一个桶中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。因为数据被均匀分布在每个桶中,所以几乎不会有桶中的链表长度会达到阈值!
  • 所以,之所以选择8,不是随便决定的,而是根据概率统计决定的。由此可见,发展将近30年的Java每一项改动和优化都是非常严谨和科学的。 也就是说:选择8因为符合泊松分布,超过8的时候,概率已经非常小了,所以我们选择8这个数字。
  • 但是哈希函数【hashCode】是有用户控制,用户选择的hash函数,离散性可能会很差。JDK又不能阻止用户实现这种不好的hash算法。因此,就可能导致不均匀的数据分布。所以超过8了,就采用红黑树,来提升效率。

扩展 : Poisson分布(泊松分布),是一种统计与概率学里常见到的离散[概率分布]。泊松分布的概率函数为:
P(X=k) = (λk/k!)*e,k = 0, 1, …
泊松分布的参数λ是单位时间(或单位面积)内随机事件的平均发生次数。 泊松分布适合于描述单位时间内随机事件发生的次数。

HashMap的treeifyBin()方法详解-链表转红黑树

转换相关属性
  • 转换边界值1:当链表长度超过转换边界值 8,就会转红黑树(JDK1.8),还有一个前提条件是数组长度大于等于64
// 当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
  • 转换边界值2:当 Map 里面的数量超过这个值时,表中的桶才能形成树形化,这个值不能小于 4 * TREEIFY_THRESHOLD (8)
// 桶中结构转化为红黑树对应的数组长度最小的值
static final int MIN_TREEIFY_CAPACITY = 64;
  • 桶(bucket):所谓的桶,指的是一个数组索引位中的所有元素。
  • 降级转换边界值:当链表的长度值小于 6,则会从红黑树转为链表
static final int UNTREEIFY_THRESHOLD = 6;
转换treeifyBin()方法源码分析
  • 节点添加完成之后,判断此时节点个数是否大于 TREEIFY_THRESHOLD 临界值 8,如果大于则将链表转换为红黑树,转换红黑树的方法是 treeifyBin,整体代码如下:
// 对 HashMap 集合中桶的链表转为红黑树,如果集合中数组太短,会对数组扩容
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    /*
	如果当前数组为空或者数组的长度小于进行树形化的阈值(MIN_TREEIFY_CAPACITY = 64)就去扩容,而不是将节点变为红黑树。
	目的: 如果数组很小,那么转换为红黑树,在进行遍历时效率要低一些。这时进行扩容,会重新计算哈希值,链表长度可能就会变短了,数据就会放到数组中,这样相对来说效率要高一些。
	*/
	// 判断数组是否满足转红黑树最小长度限制(原因:数组太短,转为红黑树不仅没有提高效率,反而降低了。)
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
    	// 如果不满足最小长度64,则对数组进行扩容
        resize();
    // 判断数组中桶内非空,并且获取桶中第一个节点
    else if ((e = tab[index = (n - 1) & hash]) != null) {
    	// 下面开始链表转红黑树,hd:红黑树的头节点,tl:红黑树的尾节点
        TreeNode<K,V> hd = null, tl = null;
        do {
        	// 新创建一个树的节点,内容和当前链表节点 e 一致。
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
            	// 将新创建的 p 节点赋值给红黑树的头节点
                hd = p;
            else {
                p.prev = tl; // 将上一个节点p赋值给现在的p的前一个节点
                tl.next = p; // 将现在节点p作为树的尾节点的下一个节点
            }
            tl = p;
            // e = e.next 将当前节点的下一个节点赋值给 e,如果下一个节点不等于 null,则回到上面继续取出链表中节点转换为红黑树
        } while ((e = e.next) != null);
   		// 将桶中的第一个元素,替换为红黑树节点
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}
  • 小结:HashMap集合中,链表节点红黑树节点的临界值是 8,前提是集合中数组的最大容量是 64 以上,否则会对数组进行扩容。

HashMap的扩容机制

  • 在不断的添加数据的过程中,会涉及到扩容问题,当超出临界值且要存放的的位置为空时进行扩容。默认扩容方式:扩容为原理容量的 2 倍,并将原有的数据复制过来。
扩容相关属性
  • 扩容计数器:用来记录 HashMap 的修改次数
// 每次扩容和更改map结构的计数器
transient int modCount;
  • 转换边界值1:当链表长度超过转换边界值 8,就会转红黑树(JDK1.8),还有一个前提条件是数组长度大于等于64
// 当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
  • 转换边界值2:当 Map 里面的数量超过这个值时,表中的桶才能形成树形化,这个值不能小于 4 * TREEIFY_THRESHOLD (8)
// 桶中结构转化为红黑树对应的数组长度最小的值
static final int MIN_TREEIFY_CAPACITY = 64;
  • 桶(bucket):所谓的桶,指的是一个数组索引位中的所有元素。
  • 哈希表的加载因子(重点):默认值是 0.75,决定了扩容的条件
final float loadFactor;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
  • 集合最大容量:10 7374 1824【10亿】
static final int MAXIMUM_CAPACITY = 1 << 30;
  • 扩容的临界值:计算方式为 容量 乘以 加载因子
int threshold;
扩容机制
  • 了解 HashMap 的扩容机制,需要搞懂下面的两个问题:

什么时候需要扩容?

  • 主要在两种情况下进行扩容:
  • 当 HashMap 集合中,实际存储元素个数超过临界值(threshold)时,会进行扩容,默认初始化为临界值是12。
  • 当 HashMap 集合中,单个桶的链表长度达到了 8,并且数组长度还没有到达 64,会进行扩容。

HashMap 的扩容做了哪些事?

  • 将原数组中桶内的节点,均匀分散在了新的数组的桶中。
  • HashMap 扩容时,分散使用的 rehash 方式非常巧妙,并没有进行 hash 函数调用。由于每次扩容都是翻倍,与原来计算的 (n - 1) & hash 的结果相比,只是多了一个 bit 位。所以节点要么就在原来的位置,要么就被分配到“原位置+旧容量”的位置。
  • 怎么理解?下面举个例子,例如从 16 扩容为 32,具体变化如下:
"娜扎"的哈希值740274,然后根据公式 : (n-1) & hash
		740274 & (16-1) = 2
String key = "天乐"; // hashCode值 727623
String key = "德华"; // hashCode值 780919
计算hash		
		727623 & (16-1) = 7
		780919 & (16-1) = 7
扩容之后,巧妙的计算 rehash【更高效】
		727623 & (32-1) = 7
		780919 & (32-1) = 7 + 16 = 23
结论:原位置+旧容量
  • 画图说明:下面是 16 扩充为 32 的resize示意图,将原数组中桶内的节点,均匀分散在了新的数组的桶中。

在这里插入图片描述

  • 注意 : 扩容必定伴随rehash操作,遍历hash表中所有元素。这种操作比较耗时!在编程中,超大HashMap要尽量避免resize, 避免的方法之一就是初始化固定HashMap大小!
扩容方法resize()源码解读
final Node<K,V>[] resize() {
	// 得到当前数组
    Node<K,V>[] oldTab = table;
    // 若当前数组等于null,则长度返回0;否则返回当前数组的长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length; // 原始数组容量
	// 当前阈值点 默认是 12 (16*0.75)
    int oldThr = threshold; // 初始阈值
    // 新的数组容量newCap, 新的阈值点 newThr
    int newCap, newThr = 0;
    // 如果老的数组长度大于 0
    if (oldCap > 0) {
    	// 超过最大值就不再扩容了,就只好碰撞了
        if (oldCap >= MAXIMUM_CAPACITY) {
        	// 修改阈值为 int 的最大值
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 没超过最大值,就扩充为原来的 2 倍
        // (newCap = oldCap << 1) < MAXIMUM_CAPACITY: 扩大到 2 倍之后容量要小于最大容量
        // oldCap >= DEFAULT_INITIAL_CAPACITY: 原来数组长度大于数组初始化长度 16
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 阈值扩大一倍
            newThr = oldThr << 1; // double threshold
    }
    // 如果原始数组为空,且原始的扩容阈值大于 0,原始阈值 赋值给 新的数组容量
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        // 如果原始数组为空,扩容阈值为0,则设置为默认值
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 如果新的扩容阈值为0,则重新计算新的 resize 最大上限
    if (newThr == 0) {
    	// 计算公式:新数组容量 * 加载因子
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    // 赋值新的扩容阈值
    threshold = newThr;
    // 创建新的哈希表,创建新的数组,容量是之前的2倍。newCap是新的数组长度
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 过滤,排除旧数组为空的情况
    if (oldTab != null) {
    	// 遍历旧的哈希表的每个桶,重新计算桶里元素的新位置,把每个bucket都移动到新的buckets中
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
            	// 将原来的数据赋值为null,便于 GC 回收
                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;
                        // 这里来判断如果等于 true,e这个节点在 resize 之后不需要移动位置
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 原索引 + oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 原索引放到 bucket 里
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 原索引+oldCap放到bucket里
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

HashMap容量初始化最佳策略

HashMap的初始化问题描述
  • 《阿里巴巴Java开发手册》中建议初始化HashMap的容量。

为什么要建议初始化 HashMap 容量?

  • 防止自动扩容,影响效率。
  • 当然阿里的建议是有理论支撑的。我们上面介绍过HashMap的扩容机制,就是当达到扩容条件时会进行扩容。HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。在HashMap中,threshold = loadFactor * capacity。所以,如果我们没有设置初始容量大小,随着元素的不断增加,HashMap会有可能发生多次扩容,而HashMap中的扩容机制决定了每次扩容都需要重建hash表,是非常影响性能的。
  • 设置初始化容量,数值不同性能也不一样!当已知HashMap中,将存放的键值对个数时,容量设置成多少合适呢?是直接设置键值个数吗?并不是,我接下来看
HashMap中容量初始化多少合适
  • 假如现在HashMap集合要存入 16 个元素,如果你初始化16个。集合总容量是16,扩容阈值是12。最终HashMap集合在存入到12个元素时,会进行一次扩容操作。这样会导致性能损耗。

有没有更好的办法?

  • 《阿里巴巴Java开发手册》有以下建议:

在这里插入图片描述

  • 如果我们通过initialCapacity/ 0.75F + 1.0F计算 : 16/0.75 + 1 = 22 。22经过Jdk处理之后【2的n次幂】,集合的初始化容量会被设置成32。
  • 集合总容量是32,扩容阈值是24。最终HashMap集合在存入到16个元素时,完全不会进行扩容。榨取最后一滴性能!
  • 有利必有弊,这样的做法会增加数组的无效容量,牺牲一小部分内存。出于对性能的极值追求,这部分牺牲是值得的!

HashMap面试题

HashMap中hash函数是怎么实现的

  • 底层采用的是 key 的 hashCode 方法 + 异或(^) + 无符号右移(>>>)操作计算出的 hash 值。
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 而哈希表中,计算数组索引的方法是位运算,代码如下,保证所有的 hash 最终落在数组最大索引范围之内。
i = (n - 1) & hash
  • 还可以采用:取余法、平方取中法、伪随机数法。
  • 取余方式比较常见,但是与位运算相比,效率较低。

当两个对象的hashCode相等时会怎么样

  • 会产生哈希碰撞
  • 若 key 值相同,则替换掉旧的 value;否则连接到链表的后面,链表长度超过阈值 8,数组长度大于 64 则自动转为红黑树存储。

何时发生哈希碰撞和什么是哈希碰撞,如何解决哈希碰撞

  • 只要两个元素的 key 计算的哈希码值相同就会发生哈希碰撞
  • JDK 8 之前使用链表来解决哈希碰撞
  • JDK 8 及以后使用链表+红黑树解决哈希碰撞

hashCode和equals方法有何重要性

非常重要

  • HashMap 使用 key 对象的 hashCode() 和 equals(Object obj) 方法去决定键值对的存储位置。当从 HashMap 中获取值时,也会被用到。
    • 如果两个方法没有被正确的实现,两个不同的 key 也许会产生相同的 hashCode 和 equals 的结果,这就会导致元素存储位置的混乱,降低 HashMap 的性能。
  • **hashCode() 和 equals(Object obj) 保证了集合元素的唯一性。**所有不允许存储重复数据的集合类,都使用这两个方法,所以正确实现它们非常重要。
    • 如果 o1.equals(o2),那么 o1.hashCode() == o2.hashCode() 总是 true。
    • 如果 o1.hashCode() == o2.hashCode(),并不意味着 o1.equals(o2) 会为true。

HashMap默认容量是多少

  • 默认容量都是 16,加载因子都是 0.75。
  • 当 HashMap 填充了 75% 就会扩容,最小扩容阈值是:16 * 0.75 = 12。
  • 扩容一般为原容量的 2 倍。

HashMap的长度为什么是2的n次幂

  • 为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀分散在数组中,这个问题的关键在于存储数组索引位的计算【hash & (length - 1)】。
  • 这个算法可以如何设计呢?
    • 一般我们可能会想到取余(%),但是效率太低。
    • 在计算机运算中,位运算效率高于取余操作,而位运算能够做到与取余相同的效果的前提是,数组长度是 2 的 n 次幂。这就解释了 HashMap 的长度为什么是 2 的次幂。

加载因子值的大小对HashMap有什么影响

  • 加载因子的大小决定了 HashMap 的数据密度。
    • 加载因子越大,数据密度越大,发生碰撞的概率越高,数组桶中链表越长,查询或插入时的比较次数增多,从而导致性能下降。
    • 加载因子越小,数据密度越小,发生碰撞的概率越低,数组桶中链表越短,查询或插入时的比较次数减少,从而性能更高。
      • 加载因子越小,越容易触发扩容,会影响性能;
      • 加载因子越小,存储数据量越少,会浪费内存空间。
  • 总之,鱼和熊掌不可兼得!按照其他语言的参考及研究经验,会考虑将加载因子设置为0.7到0.75
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

讲文明的喜羊羊拒绝pua

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值