Java 基础篇--基础知识之集合HashMap

HashMap 简介

HashMap是基于哈希表实现的,采用key/value键值对的结构存储,每个key对应唯一的value,其存储位置是根据key的hashcode值来确定,而内部则通过单链表来解决hash冲突问题,容量不足(超过了阀值)时,同样会自动增长。
HashMap是非线程安全的,并且不保证元素存储的顺序;
HashMap的总体结构如下:
在这里插入图片描述

一、实现原理

HashMap采用Entry数据来存储key/value键值对,每个键值对组成一个Entry实体,而Entry类实际上是一个单向链表结构,它具有next指针,可以连接下一个Entry实体。简单来说,HashMap是由数组+链表(红黑树)组成哈希表,数组为主体,链表则是为了解决哈希冲突。其存取值原理如下:

  1. 对于HashMap中的每一个key,首先通过hash函数计算出一个hash值(int类型),然后通过的数组(Node<K,V>[] table)长度n(默认16)对这个hash值进行取模运算【(n - 1) & hash 等价于 n % hash】得到这个key在数组上的索引index,然后将这个<key, value>放到该位置;
  2. 若不同的key,通过hash函数计算出的hash值相同,那么就出现哈希冲突。HashMap采用单独链表法【链地址法】来解决冲突;
  3. 随着执行put(K key, V value),插入元素越来越多,发生冲突的概率就越来越大,每个索引位置中的链表就越来越长,一直到达某个阈值【树化阈值默认为8,单链表长大达到了7,并且数组长度达到了64则链表进行树化,否则做扩容操作】,链表将进行形态转换,变身红黑树;
  4. 在进行取值执行get(K key)方法,定位到该key在数组中位置index,然后遍历链表或树并比较key取得对应的<key, value>键值对。

二、源码分析

2.1 继承与实现关系

在这里插入图片描述
在这里插入图片描述

  1. 继承于AbstractMap【 提供 Map 接口的骨干实现】,实现了Map, 所以它是一个Map,即一个key-value集合;
  2. 实现了Cloneable接口,意味着它能被克隆;
  3. 实现了java.io.Serializable接口,意味着它支持序列化。

2.2 重要成员信息

在这里插入图片描述
1. DEFAULT_INITIAL_CAPACITY: 默认初始化容量大小,默认值16,且实际容量是2的整数幂;
2. MAXIMUM_CAPACITY:最大容量(传入容量过大将被这个值替换);
3. DEFAULT_LOAD_FACTOR:默认负载因子,默认值为0.75(当数组达到3/4满时,才会再散列),这个因子在时间和空间代价之间达到了先平衡。更高的因子可以降低表所需的空间,但是会增加查找代价,而查找是最频繁操作;
4. TREEIFY_THRESHOLD:链表树化阈值,即链表转成红黑树的阈值,在存储数据时,当链表长度 >= (8-1)时,则将链表转换成红黑树;
5. UNTREEIFY_THRESHOLD:链表还原阈值,即红黑树转为链表的阈值,当在扩容(resize())时(HashMap的数据存储位置会重新计算),在重新计算存储位置后,当原有的红黑树内数量 <= 6时,则将红黑树转换成链表;
6. MIN_TREEIFY_CAPACITY:最小树形化容量阈值,即当哈希表中的容量 > 该值时,才允许树形化链表 (即将链表转换成红黑树),小于该值时使用的是扩容。

补充1:为什么capacity要保持为2的幂次方
  1. 为了可以在计算索引值的时候采用二进制位操作 & 来替代 %,进而能够提高运算效率;即【(n - 1) & hash 等价于 n % hash】;
  2. 防止Hash冲突;
  3. 能保证索引值肯定在 capacity 中,不会超出数组长度。
补充2:为什么默认负载因子是0.75,而不是1或0.5
  1. 阈值(threadhold)= 负载因子(loadFactor) x 容量(capacity) ,依据HashMap的扩容机制,它会保证容量(capacity)始终是2的幂次。而为保证这个阈值是个整数,0.75是比较合理的。
    在这里插入图片描述
  2. 负载因子若是1的时候,意味着要等到数组容量全部填充了,才进行扩容。这样会出现大量Hash冲突,会产生大量链表或红黑树,对于查询效率极其不利。这种情况就是牺牲了时间来保证空间的利用率。负载因子若为0.5,意味着数组中元素达到了一半就开始扩容,这样填充的元素少了,Hash冲突也会减少,对应链表或红黑树数量和高度都会降低,查询效率就会增加,但空间利用率大大降低。所以0.75是其折衷值,是在一个时间和空间成本上的一个折衷。
补充3:为什么树化阈值是8,去树化阈值是6
  1. 原理层面:理想情况下随机hashCode算法下所有bin中节点的分布频率会遵循泊松分布,根据泊松分布:单位时间内,独立事件发生的次数遵循:
    在这里插入图片描述
    因此,用泊松分布表示:P 表示概率,N 表示某种函数关系,t 表示时间,n 表示数量,每1秒key发送碰撞的次数为k,就表示为 P(N(1) = k) 。等号的右边,λ 表示事件的频率。关于一个key是否发生碰撞的概率为0.5。则:
    e^-0.5 = 0.6065
    把相应数值代入泊松分布公式:
    在这里插入图片描述
    当k=9时,也就是发生的碰撞次数为9次时,概率为亿分之三,碰撞的概率已经无限接近0。
    再者JDK中HashMap注释:
    在这里插入图片描述
    因此可知链表变树的阈值为8的原因
  2. 从查找效率层面:
    • 链表是顺序查找,其查询时间复杂度为n/2,红黑树是二分查找,其查询时间复杂度为logn。可见,随长度的增加,链表的查找复杂度远高于红黑树的查找复杂度。
      在这里插入图片描述
    • 红黑树的平均查找长度为log(n),长度为8时,查找长度为log(8)=3。链表的平均查找长度为n/2,长度为8时,查找长度为8/2 = 4。链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。最终树化值取了中间的差值7,这个值可以有效防止链表和树频繁转换。
  • 成员变量
    在这里插入图片描述
    1. table:存储键值对数据的数组,数组的长度一定是2的次幂;
    2. entrySet:键值的集合;
    3. size:实际存储的key-value键值对的个数;
    4. modCoun:被改变的次数;
    5. threshold:扩容阈值;
    6. loadFactor:负载因子。

2.3 构造方法

在这里插入图片描述
构造方法中有两个重要参数,初始化大小(initialCapacity)和加载因子(loadFactor),一般情况下,我们只使用默认的即可。

2.4 数据结构

Node<K,V>[] table;
- Node部分源码:
在这里插入图片描述
可以看出HashMap使用了java语言的两种最基本的数据结构:数组和引用。
- TreeNode部分源码:
在这里插入图片描述
- LinkedHashMap的Entry部分源码:
在这里插入图片描述
可见:HashMap是一种“链表散列”的数据结构,即数组和链表(树)的结合体。

2.5 容量值【capacity】归化为2的幂方

tableSizeFor返回给定目标容量为2的幂次方
在这里插入图片描述
Integer.numberOfLeadingZeros在指定 int 值(无符号值)的二进制补码表示形式中最高位(最左边)的 1 位之前,返回零位的数量。如果指定值在其二进制补码表示形式中不存在 1 位,换句话说,如果它等于零,则返回 32。
实现原理:应用了典型的二分查找,先把32位整形分为高16位和低16位查找非零数,在对高16位进行或低16位进行二分。
在JVM中一个int类型的数据占4个字节,共32位,其实就相当于一个长度为32的数组。 我们要计算首部0的个数,就是从左边第一个位开始累加0的个数,直到遇到一个非零值。
在这里插入图片描述
集合扩容大小规划为2的n次方大小,即2、4、8、16.、32、…、2^n, 而 tableSizeFor函数方法中 Integer.numberOfLeadingZeros(cap - 1) 返回无符号整数(cap - 1)的最高非0位前面的0的个数,包括符号位在内;如果(cap - 1)为负数,这个方法将会返回0,符号位为1(比如10的二进制表示为 0000 0000 0000 0000 0000 0000 0000 1010,java的整型长度为32位。那么这个方法返回的就是28)。那么 int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1)得到的值即为(2n)-1。所以函数tableSizeFor最终返回值为2n,即实现了容量值【capacity】归化为2的n次方目的。
numberOfLeadingZeros(10) 执行过程:
在这里插入图片描述
int n = -1 >>> Integer.numberOfLeadingZeros(11-1)执行过程:
在这里插入图片描述

2.6 重新hash函数,起扰动效果

在这里插入图片描述
扰动函数(int hash(Object key):让32bit的hash值的高半区16位与低半区16位异或而降低hash冲突碰撞,其中h = key.hashCode()) ^ (h >>> 16) 操作时将key的hashCode的高16位移到低16位。
而寻址或者插入元素时,采用hash函数计算出来的hashcode与散列表table的大小减一做位运算(即散列表table的大小对hashcode取模),确定脚标index。
在这里插入图片描述

2.7 扩容resize

在这里插入图片描述
旧存储桶节点向新存储桶转存时,转存节点位链表的时候,原来的链表拆分成高/低位两个链表, 并将这两个链表分别放到新的table的 j 位置和 j+oldCap 上, j位置就是原链表在原table中的位置, 拆分的标准就是: (e.hash & oldCap) == 0
在这里插入图片描述
扩容流程逻辑图如下:
在这里插入图片描述

  • 简单总结下扩容流程:
    step1:判断是否可以进行扩容,即原来容量是否达到最大值;
    step2:确定新的容量和新的扩容阈值,不出意外情况下,都是扩容到原来的2倍;
    step3:确定了先扩容阈值和新容量,则构建新的buckets并赋值;
    step4:原buckets中元素转移到新buckets中。

  • 关于 (e.hash & oldCap) == 0、 j 以及 j+oldCap
    上面我们已经弄懂了链表拆分的代码, 但是这个拆分条件稍微解释一下:
    首先我们要明确三点:
    1)oldCap一定是2的整数次幂, 这里假设是2^m
    2)newCap是oldCap的两倍, 则会是2^(m+1)
    3)hash对数组大小取模【hash % n】等价于【(n - 1) & hash】其实就是取hash的低m位
    例如:
    我们假设 oldCap = 16, 即 2^4,16 - 1 = 15, 二进制表示为
    0000 0000 0000 0000 0000 0000 0000 1111
    可见除了低4位, 其他位置都是0(简洁起见,高位的0后面就不写了), 则 (16-1) & hash 自然就是取hash值的低4位,我们假设它为 abcd。
    以此类推, 当我们将oldCap扩大两倍后, 新的index的位置就变成了 (32-1) & hash, 其实就是取 hash值的低5位. 那么对于同一个Node, 低5位的值无外乎下面两种情况:
    0abcd
    1abcd
    其中, 0abcd与原来的index值一致,而1abcd = 0abcd + 10000 = 0abcd + oldCap。
    故虽然数组大小扩大了一倍,但是同一个key在新旧table中对应的index却存在一定联系: 要么一致,要么相差一个 oldCap。而新旧index是否一致就体现在hash值的第4位(我们把最低为称作第0位,怎么拿到这一位的值呢, 只要:
    hash & 0000 0000 0000 0000 0000 0000 0001 0000
    上式就等效于
    hash & oldCap
    故得出结论:如果 (e.hash & oldCap) == 0 则该节点在新表的下标位置与旧表一致都为 j, 如果 (e.hash & oldCap) == 1 则该节点在新表的下标位置 j + oldCap。根据这个条件, 我们将原位置的链表拆分成两个链表, 然后一次性将整个链表放到新的Table对应的位置上。

2.8 重要方法

2.8.1 存储 put(K key, V value)

在这里插入图片描述
在这里插入图片描述
存值流程逻辑图如下:
在这里插入图片描述

2.8.2 读取 get(Object key)

在这里插入图片描述
取值流程逻辑图如下:
在这里插入图片描述

2.8.3 移除 remove(Object key)

在这里插入图片描述
移除流程逻辑图如下:
在这里插入图片描述

2.9 Fail-Fast 机制

在执行put(…)【putVal(…)】、computeIfAbsent(…)、compute(…)、merge(…)、clear()等方法是,都会导致modCount值得变动,而在集合迭代遍历(Iterator)时会比较modCount前后是否一致,若不一致,则抛出ConcurrentModificationException即进入Fail-Fast。

2.10 HashMap中红黑树

红黑树是一种常见的自平衡二叉查找树,常用于关联数组、字典,在各种语言的底层实现中被广泛应用,Java 的 TreeMap 和 TreeSet 就是基于红黑树实现的。而HashMap之所以选择红黑树,而不是其他结构,是因为:

  1. 在链表长度比较长的时候,转变成红黑树会有显著的效率提高;(为什么要链表转成树结构?)
  2. 普通二叉排序树在添加元素的时候极端情况下会出现线性结构;(为什么不使用普通二叉树结构?)
  3. 完全平衡树,虽然结构直观,效率更高,但根据不同情况,旋转的次数要比红黑树要多,维护慢,开销大。(为什么不使用完全平衡二叉树结构?)
  4. 红黑树是一种部分平衡二叉树,而且不追求“完全平衡”,任何不平衡都会在三次旋转之内解决,它能保证在最坏的情况下,基本动态集合操作时间为O(lgn),效率略低与完全平衡树,但维护强于完全平衡树。

在这里插入图片描述

2.10.1 红黑树的定义(性质)

红黑树需要满足的五条性质:
性质一:节点是红色或者是黑色;
在树里面的节点不是红色的就是黑色的,没有其他颜色。
性质二:根节点是黑色;
根节点总是黑色的。它不能为红。
性质三:每个叶节点(NIL或空节点)是黑色;
性质四:每个红色节点的两个子节点都是黑色的(即不存在两个连续的红色点);
就是连续的两个节点不能是连续的红色,连续的两个节点的意思就是父节点与子节点不能是连续的红色。
性质五:从任一节点到其末个叶节点的所有路径都包含相同数目的黑色节点;
性质六:新加入到红黑树的节点为红色节点;

2.10.2 左旋转

也就是逆时针旋转两个节点,以某个节点作为支点(旋转节点,比如X),其右子节点变为旋转节点的父节点,右子节点的左子节点变为旋转节点的右子节点,旋转节点的左子节点保持不变。右子节点的左子节点相当于从右子节点上“断开”,重新连接到旋转节点上。
左旋时机:父亲是红色,叔叔是黑色,当前是右子树,则直接以父亲为支点进行左旋;
如下图:
在这里插入图片描述
动态左旋图:
在这里插入图片描述
如图,左旋转就是将S点旋转到根(父)节点,S节点的左节点都挂到E节点的右边

2.10.3 右旋转

顺时针旋转两个节点(X、Y),以某个节点作为支点(旋转节点,比如X),其左子节点变为旋转节点的父节点,左子节点的右子节点变为旋转节点的左子节点,旋转节点的右子节点保持不变。左子节点的右子节点相当于从左子节点上“断开”,重新连接到旋转节点上;
右旋时机::父亲是红色,叔叔是黑色,当前是左子树,则把父亲变成黑色,爷爷变成红色,再以爷爷为支点进行右旋;
如下图:
在这里插入图片描述
动态右旋图:
在这里插入图片描述
如图,右旋转就是将E点旋转到根(父)节点,E节点的右节点都挂到S节点的左边

2.10.4 红黑树新增节点的五种情况(所有的新增可能)
  • 为跟节点:若新增节点N没有父节点,则直接当作跟节点插入,同时将颜色设置为黑色;
    在这里插入图片描述
  • 父节点P为黑色:这种情况,新增节点N同样直接插入,同时将颜色设置为红色,由于性质四,它会存在两个黑色叶子节点,且值为null。同时由于新增节点N为红色,所以通过它的节点路径依然会保存着相同数量的黑色节点,同样满足性质五
    在这里插入图片描述
  • 若父节点P和P的兄弟节点U都为红色:这种情况,若直接插入会出现违反性质四现象。可以将P、U节点变黑,G节点变红。此时由于经过P、U节点的路径都必须经过G节点,所以这些路径上的黑色节点树还是相同的,但经过上面处理后,G节点的父节点也可能也是红色,违反了性质四,此时我们需要将G节点当作新增节点递归处理;
    在这里插入图片描述
  • 若父节点P为红色,叔父节点U为黑色或者缺少,且新增节点N为P节点的右孩子:这种情况,我们对新增节点N和其父节点P进行一次左旋转(以P为中心),此时产生的结果其实并没有完成,依旧不平衡,违反了性质四,需要再次进行相应下一步处理;
    在这里插入图片描述
  • 父节点P为红色,叔父节点U为黑色或者缺少,新增节点N为父节点P左孩子:这种情况可能是前面步骤产生的,也可能不是。对于这种情况,先以G节点为中心进行右旋转,在旋转后的树中,节点P是节点N、G的父节点。但这棵树并不规范,它违反了性质四,所以我们将P、G节点颜色进行交换,使之满足规范。开始时所有路径都需要经过G节点,他们的黑色节点树是一样的,但现在所有路径改为经过P,且P为整棵树的唯一黑色节点,所以调整后的树同样满足性质五,最后再将U置为黑色即可。
    在这里插入图片描述
2.10.5 插入节点实列

已知红黑树元素[1,6,8,11,13,17,25,22,27],红黑树结构如下:
在这里插入图片描述
插入一个节点21,根据平衡二叉树性质,可知该节点插入节点为红黑树节点22下面,且根据 性质三:每个叶节点(NIL或空节点)是黑色; 可知节点21为红色节点。
在这里插入图片描述
由于节点21和其父节点22都是红色节点,打破了 性质四:每个红色节点的两个子节点都是黑色的,必须做出调整。调整方式:变色和旋转(左旋和右旋)。
1)变色
为了符合红黑树的性质,会把节点红变黑或者黑变红。下图展示的是红黑树的部分,需要注意节点25并非根节点。因为2122链接出现红色,不符合规则4,所以把22红变黑:
在这里插入图片描述
但这样还是不符合性质五(性质五:从任一节点到其没个叶节点的所有路径都包含相同数目的黑色节点;),所以需要把25黑变红,如下图:
在这里插入图片描述
2527又是两个连续的红色节点(性质四:每个红色节点的两个子节点都是黑色的),所以需要将27红变黑。
在这里插入图片描述
至此把节点25以及下方的节点变色,该节点下符合红黑树节点所有性质。
2)旋转
在这里插入图片描述
由于17和25是连续的两个红色节点,那么把节点17变黑吗?这样是不行的,你想这样一来不就打破了性质四(性质四:每个红色节点的两个子节点都是黑色的)了吗,而且根据性质二(性质二:根节点是黑色),也不可能吧13变成红色。变色已经无法解决问题了,所以只能进行旋转了。13当成X,17当成Y,左旋转(以13为中心)试试看:
在这里插入图片描述
在这里插入图片描述
由于根节点必须是黑色,所以需要变色,结果如下图
在这里插入图片描述
继续,其中有两条路径(17->?->8->?->6->NULL)的黑色节点个数不是3,是4不符合性质(性质五:从任一节点到其没个叶节点的所有路径都包含相同数目的黑色节点)。
这个时候需要把13当做X,8当做Y,进行右旋转:
在这里插入图片描述
在这里插入图片描述
最后根据规则变色:
在这里插入图片描述
至此,经过调整之后符合红黑树性质。

总结变色和旋转规则:
在这里插入图片描述

2.10.6HashMap中红黑树操作
  • 红黑树查询节点
    源码:
    在这里插入图片描述
    树查找流程逻辑图如下:
    在这里插入图片描述
  • 红黑树插入节点
    源码:
    在这里插入图片描述
  • 红黑树删除节点
    源码:
    在这里插入图片描述
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值