文章目录
- HashMap 简介
- 一、实现原理
- 二、源码分析
- 2.1 继承与实现关系
- 2.2 重要成员信息
- 补充1:为什么capacity要保持为2的幂次方
- 补充2:为什么默认负载因子是0.75,而不是1或0.5
- 补充3:为什么树化阈值是8,去树化阈值是6
- 2.3 构造方法
- 2.4 数据结构
- 2.5 容量值【capacity】归化为2的幂方
- 2.6 重新hash函数,起扰动效果
- 2.7 扩容resize
- 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 机制
- 2.10 HashMap中红黑树
- 2.10.1 红黑树的定义(性质)
- 2.10.2 左旋转
- 2.10.3 右旋转
- 2.10.4 红黑树新增节点的五种情况(所有的新增可能)
- 2.10.5 插入节点实列
- 2.10.6HashMap中红黑树操作
HashMap 简介
HashMap是基于哈希表实现的,采用key/value键值对的结构存储,每个key对应唯一的value,其存储位置是根据key的hashcode值来确定,而内部则通过单链表来解决hash冲突问题,容量不足(超过了阀值)时,同样会自动增长。
HashMap是非线程安全的,并且不保证元素存储的顺序;
HashMap的总体结构如下:
一、实现原理
HashMap采用Entry数据来存储key/value键值对,每个键值对组成一个Entry实体,而Entry类实际上是一个单向链表结构,它具有next指针,可以连接下一个Entry实体。简单来说,HashMap是由数组+链表(红黑树)组成哈希表,数组为主体,链表则是为了解决哈希冲突。其存取值原理如下:
- 对于HashMap中的每一个key,首先通过hash函数计算出一个hash值(int类型),然后通过的数组(Node<K,V>[] table)长度n(默认16)对这个hash值进行取模运算【(n - 1) & hash 等价于 n % hash】得到这个key在数组上的索引index,然后将这个<key, value>放到该位置;
- 若不同的key,通过hash函数计算出的hash值相同,那么就出现哈希冲突。HashMap采用单独链表法【链地址法】来解决冲突;
- 随着执行put(K key, V value),插入元素越来越多,发生冲突的概率就越来越大,每个索引位置中的链表就越来越长,一直到达某个阈值【树化阈值默认为8,单链表长大达到了7,并且数组长度达到了64则链表进行树化,否则做扩容操作】,链表将进行形态转换,变身红黑树;
- 在进行取值执行get(K key)方法,定位到该key在数组中位置index,然后遍历链表或树并比较key取得对应的<key, value>键值对。
二、源码分析
2.1 继承与实现关系
- 继承于AbstractMap【 提供 Map 接口的骨干实现】,实现了Map, 所以它是一个Map,即一个key-value集合;
- 实现了Cloneable接口,意味着它能被克隆;
- 实现了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的幂次方
- 为了可以在计算索引值的时候采用二进制位操作 & 来替代 %,进而能够提高运算效率;即【(n - 1) & hash 等价于 n % hash】;
- 防止Hash冲突;
- 能保证索引值肯定在 capacity 中,不会超出数组长度。
补充2:为什么默认负载因子是0.75,而不是1或0.5
- 阈值(threadhold)= 负载因子(loadFactor) x 容量(capacity) ,依据HashMap的扩容机制,它会保证容量(capacity)始终是2的幂次。而为保证这个阈值是个整数,0.75是比较合理的。
- 负载因子若是1的时候,意味着要等到数组容量全部填充了,才进行扩容。这样会出现大量Hash冲突,会产生大量链表或红黑树,对于查询效率极其不利。这种情况就是牺牲了时间来保证空间的利用率。负载因子若为0.5,意味着数组中元素达到了一半就开始扩容,这样填充的元素少了,Hash冲突也会减少,对应链表或红黑树数量和高度都会降低,查询效率就会增加,但空间利用率大大降低。所以0.75是其折衷值,是在一个时间和空间成本上的一个折衷。
补充3:为什么树化阈值是8,去树化阈值是6
- 原理层面:理想情况下随机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的原因 - 从查找效率层面:
- 链表是顺序查找,其查询时间复杂度为n/2,红黑树是二分查找,其查询时间复杂度为logn。可见,随长度的增加,链表的查找复杂度远高于红黑树的查找复杂度。
- 红黑树的平均查找长度为log(n),长度为8时,查找长度为log(8)=3。链表的平均查找长度为n/2,长度为8时,查找长度为8/2 = 4。链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。最终树化值取了中间的差值7,这个值可以有效防止链表和树频繁转换。
- 成员变量
- table:存储键值对数据的数组,数组的长度一定是2的次幂;
- entrySet:键值的集合;
- size:实际存储的key-value键值对的个数;
- modCoun:被改变的次数;
- threshold:扩容阈值;
- 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之所以选择红黑树,而不是其他结构,是因为:
- 在链表长度比较长的时候,转变成红黑树会有显著的效率提高;(为什么要链表转成树结构?)
- 普通二叉排序树在添加元素的时候极端情况下会出现线性结构;(为什么不使用普通二叉树结构?)
- 完全平衡树,虽然结构直观,效率更高,但根据不同情况,旋转的次数要比红黑树要多,维护慢,开销大。(为什么不使用完全平衡二叉树结构?)
- 红黑树是一种部分平衡二叉树,而且不追求“完全平衡”,任何不平衡都会在三次旋转之内解决,它能保证在最坏的情况下,基本动态集合操作时间为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并非根节点。因为21和22链接出现红色,不符合规则4,所以把22红变黑:
但这样还是不符合性质五(性质五:从任一节点到其没个叶节点的所有路径都包含相同数目的黑色节点;),所以需要把25黑变红,如下图:
而25和27又是两个连续的红色节点(性质四:每个红色节点的两个子节点都是黑色的),所以需要将27红变黑。
至此把节点25以及下方的节点变色,该节点下符合红黑树节点所有性质。
2)旋转
由于17和25是连续的两个红色节点,那么把节点17变黑吗?这样是不行的,你想这样一来不就打破了性质四(性质四:每个红色节点的两个子节点都是黑色的)了吗,而且根据性质二(性质二:根节点是黑色),也不可能吧13变成红色。变色已经无法解决问题了,所以只能进行旋转了。13当成X,17当成Y,左旋转(以13为中心)试试看:
由于根节点必须是黑色,所以需要变色,结果如下图
继续,其中有两条路径(17->?->8->?->6->NULL)的黑色节点个数不是3,是4不符合性质(性质五:从任一节点到其没个叶节点的所有路径都包含相同数目的黑色节点)。
这个时候需要把13当做X,8当做Y,进行右旋转:
最后根据规则变色:
至此,经过调整之后符合红黑树性质。总结变色和旋转规则: