- 1.8之后为什么要引入红黑树?
- HashMap 中 hash 函数是怎么实现的?还有哪些hash函数的实现方式?
- 当两个对象的 hashCode 相等时会怎么样?
- 什么是哈希碰撞,如何解决哈希碰撞?
- 如果两个键的 hashCode 相同,如何存储键值对?
- 那我自定义给的容量不是2的N次方呢?
- 3.负载因子(加载因子)
- 5.树化阈值(转红黑树的边界值)
- 6.取消数化阈值(树转链表)
- 3.指定容量和负载因子
- 2-3-4树和红黑树的关系
- 红黑树对于插入节点的分析
- 红黑树对于删除节点的分析
- 扩容方法中JDK8对JDK7的优化
- 4.不带泛型的map的entrySet的迭代器进行遍历
HashMap 基于哈希表的 Map 接口实现,是以 key-value 存储形式存在,即主要用来存放键值对。HashMap 的实现不是同步的,这意味着它不是线程安全的。它的 key、value 都可以为 null,此外,HashMap 中的映射不是有序的
hash算法又叫摘要算法(Digest),是将给定数据转化为固定长度的不规则值的函数
- 输出值的数据长度固定(同一hash函数下)
- 相同的输入必定得到相同的输出值
- 相似的数据得到的输出值的差距也许会有点大
- 输入不同的数据有可能得到一致的输出值(哈希碰撞,也叫哈希冲突)
使用native关键字说明这个方法是原生函数,也就是这个方法是用C/C++语言实现的,并且被编译成了DLL,由java去调用
既然是hash算法,那么碰撞就不可能完全避免的
因为碰撞的概率关系到了算法的安全性,所以一个安全的哈希算法必须满足
- 不能通过哈希值算出原值
根据碰撞概率,哈希算法的输出长度越长,就越难产生碰撞,也就越安全
jdk1.8之后:数组+链表+红黑树
大致过程大家应该都知道,简单讲就是hashcode结合数组长度,得到数组索引,空数组则放入,有元素则比对hash值,hash值相同则发生哈希碰撞,用equals比对key是否一致,一致则取到对应的值(put则覆盖),不一致就继续比对,到尾结点了就补链,链长度过长影响效率,默认链表长度大于为8且数组长度大于64时转红黑树
1.8之后为什么要引入红黑树?
哈希算法再好也无法避免哈希碰撞,当碰撞过多,接入链表时,前期数据少,效率还行
但是后期的不断碰撞,导致链表过长,读取需要遍历,时间消耗O(n)
而引入红黑树后,查找时间O(logn),提高了效率
//扩容条件2:size是否大于了边界值第一个条件没啥好说的,空的就扩呗
第二个条件就需要多注意一下了
-
size表示hashmap中k-v的实际数量,并不是数组的长度
如果老边界值为0则给初始值
链表长度阈值8,数组长度大小64
链表阈值至少大于2且至少为8,才能符合红黑树在收缩的时候能转为普通链表
数组长度至少为4倍的链表阈值以避免冲突
HashMap 中 hash 函数是怎么实现的?还有哪些hash函数的实现方式?
答:对于 key 的 hashCode 做 hash 操作,无符号右移 16 位然后做异或运算。还有平方取中法,伪随机数法和取余数法。这三种效率都比较低。而无符号右移 16 位异或运算效率是最高的。
当两个对象的 hashCode 相等时会怎么样?
答:会产生哈希碰撞。若 key 值内容相同则替换旧的 value,不然连接到链表后面,链表长度超过阈值 8 就转换为红黑树存储。
什么是哈希碰撞,如何解决哈希碰撞?
答:只要两个元素的 key 计算的哈希码值相同就会发生哈希碰撞。jdk8 之前使用链表解决哈希碰撞。jdk8之后使用链表 + 红黑树解决哈希碰撞。
如果两个键的 hashCode 相同,如何存储键值对?
答:通过 equals 比较内容是否相同。相同:则新的 value 覆盖之前的 value。不相同:则将新的键值对添加到哈希表中。
这两个接口都是一个声明接口
序列化的含义、意义及使用场景
- 序列化:将对象写入到IO流中
- 反序列化:从IO流中恢复对象
- 意义:序列化机制允许将实现序列化的Java对象转换位字节序列,这些字节序列可以保存在磁盘上,或通过网络传输,以达到以后恢复成原来的对象。序列化机制使得对象可以脱离程序的运行而独立存在
默认初始容量 - 必须是 2 的幂
,它为什么必须是2的幂呢?
大家知道hashmap为了存取高效,要尽量减少哈希碰撞,即:让数据分配均匀,数组空间都占据,链表长度接近
而hashmap的算法就是取模hash%length
,计算机直接取余的效率不如位运算
&:按位与运算,两个当且仅当都为1的时候结果才为1
扩容是非常消耗性能的,默认的容量不一定适用,所以需要创建的就指定一个适合的大小才是正确的
那我自定义给的容量不是2的N次方呢?
会给一个比自定义容量更大一些的2n的数,例如14时就给16,20时就给32
3.负载因子(加载因子)
上文已经讲得很清楚了,这里不再赘述
最大容量是230,左移30位
5.树化阈值(转红黑树的边界值)
当链表长度大于8时会转为红黑树
(其实也需要数组长度大于64,上文也提到了)
源码中的讲解是:到8转为红黑树 到6转为链表
红黑树的占用空间比链表要大很多,所以一开始还是链表会更好 ->空间和时间的权衡
为什么转化为红黑树的阈值8和转化为链表的阈值6不一样,是为了避免频繁来回转化
红黑树的平均查找长度是log(n),如果长度为8,平均查找长度为log(8)=3,链表的平均查找长度为n/2,当长为8时,平均查找长度为8/2=4,这才有转换成树的必要;链表长度如果是小于等于6,6/2=3, 而log(6)=2. 6,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。
6.取消数化阈值(树转链表)
到8转为红黑树 到6转为链表
该值必须大于 2 并且应该至少为 8 以符合树移除中关于在收缩时转换回普通 bin 的假设。
我们可以看见收缩转回普通bin
,证明hashmap会从红黑树收缩为链表
用的最多的,值都是默认值,也许并不是最好的选择
因为扩容是非常消耗性能的,所以我们可以自定义容量
可以看到其实指定容量,就是把自定义的容量和默认的负载因子传给的下面的方法↓
3.指定容量和负载因子
指定的容量必须为2n,上文也提到了那我自定义给的容量不是2的N次方呢?
的问题,这里不再赘述
一句话:为了得到更大的容量来减少扩容,提高效率
s/loadFactor的结果是小数,加1.0F与(int)ft相当于是对小数做一 个向上取整以尽可能的保证更大容量,更大的容量能够减少resize的调用次数
所以+1.0F是为了获取更大的容量
例如:原来集合的元素个数是6个,那么6/0.75是8, 是2的n次幂,那么新的数组大小就是8了。然后原来数组的数据就会存储到长度是8的新的数组中了,这样会导致在存储元素的时候,容量不够,还得继续扩容,那么性能降低了
而如果+1呢,数组长度直接变为16了,这样可以减少数组的扩容
- 先通过hash值计算出key映射到哪个桶(数组索引)
- 比对hash,如果桶上没有碰撞冲突,则直接插入
- 如果出现碰撞冲突了,则需要处理冲突
- 如果该桶使用红黑树处理冲突,则调用红黑树的方法插入数据
- 否则采用传统的链式方法插入。如果链的长度达到临界值,则把链转变为红黑树
- 比对key,如果桶中存在重复的键,则为该键替换新值value
有点意思的是,hashmap此处key为null时返回索引为0
并不是,而是随着第一次putVal进行了判空而创建的(调用了扩容方法)
上文提到了,这里再复制一遍
大家知道hashmap为了存取高效,要尽量减少哈希碰撞,即:让数据分配均匀,数组空间都占据,链表长度接近
而hashmap的算法就是取模hash%length
,计算机直接取余的效率不如位运算
除非表太小,否则替换给定哈希索引处bin中的所有链接节点,在这种情况下改为调整大小
//putVal方法中,循环链表,判断count>=链表阈值-1时,进行转化
在讲解转化方法之前,先讲一讲什么是2-3-4树和红黑树
这里推荐一个数据结构学习网站
2-3-4树是四阶的B树(Balance Tree),他属于一种多路查找树,它的结构有以下限制:
所有叶子节点都拥有相同的深度
节点只能是2-节点、3-节点、4-节点
- 2-节点:包含1个元素的节点,有2个子节点
- 3-节点:包含2个元素的节点,有3个子节点
- 4-节点:包含3个元素的节点,有4个子节点
所有节点必须至少包含1个元素
元素始终保持排序顺序,整体上保持二叉查找树的性质,即父结点大于左子结点,小于右子结点
而且结点有多个元素时,每个元素必须大于它左边的和它的左子树中元素
2-3-4树的查询操作像普通的二叉搜索树一-样,非常简单,但由于其结点元素数不确定,在一些编程语言中实现起来并不方便, 实现一般使用它的等同——红黑树
2-3-4树和红黑树的关系
- 3节点 - 左倾/右倾
红黑树Red-Black-Tree [RBT] ,是一个自平衡的二叉查找树,每个节点都遵守下面的原则
-
每个节点要么是黑色,要么是红色
-
-
一棵树当中没有子结点(即度为0)的结点称为叶子结点
-
-
每个红色节点的两个子节点一定都是黑色
- 图中红色节点没有子节点是因为默认隐藏掉了为null的黑色节点
-
任意一个节点到每个叶子节点的路径都包含数量相等的黑节点
红黑树能自平衡靠的是什么?左旋、右旋、变色
左旋:子左给父右,父子交换
以某个结点作为支点(旋转结点),其右子结点变为旋转结点的父结点,
右子结点的左子结点变为旋转结点的右子结点,左子结点保持不变。
右旋:子右给父左,父子交换
以某个结点作为支点(旋转结点),其左子结点变为旋转结点的父结点,
左子结点的右子结点变为旋转结点的左子结点,右子结点保持不变。
节点颜色由黑边红或者由红变黑
红黑树对于插入节点的分析
对于一颗普通的二叉搜索树来说,就是左小右大
红黑树的插入也是这样的,只是插入完可能是需要做平衡处理(旋转、变色)
- 2-3-4树2节点新增一个元素直接合并为3节点
- 红黑树:新增一个红色节点这个红色节点会添加在黑色节点下:不需要调整
- 2-3-4树3节点新增个元素 合并为一个4节点
- 红黑树:就会有6种情况两种(左中右)的不需要调整
- 根左左 根右右 旋转1次
- 根左右 根右左 旋转2次
- 2-3-4树4节点新增一个元素4节点中间元素升级为父节点新增元素和剩下节点合并
- 红黑树:新增节点是红色+爷爷节点是黑色父 节点和叔叔节点为红色
- 调整为爷爷节点变为红色,父亲和叔叔节点变为黑色,如果爷爷节点为root节点则调整为黑色
红黑树对于删除节点的分析
我们先看一颗普通的二叉树
前驱节点:对一棵二叉树进行中序遍历,遍历后的顺序,当前节点的前一个节点为该节点的前驱节点
后继节点:对一棵二叉树进行中序遍历,遍历后的顺序,当前节点的后一个节点为该节点的后继节点
即:1节点的前驱节点为:5,后继节点为:6
而删除操作就是有3种情况
- 删除叶子节点,直接删除
- 删除的节点有一个子节点,那么用子节点来替代
- 如果删除的节点右两个子节点,此时需要找到前驱节点或者后继节点来替代可以转换为1、2的情况
//传入数组tab和hash值用于确定下标
//n是数组长度,index是下标,e是根据hash值从数组中取的节点
//判断数组还有没有容量及是否大于64的数组阈值,没则扩容
//桶中节点不为空则循环转换红黑树节点
//hd链表头结点,tl尾结点
//将普通节点转为树节点
//链表处理变treeNode后放置回桶中
//将红黑树节点链表进行平衡操作,才是最终变为红黑树
dir = -1; // 标识当前链表节点会放到当前树节点的左侧 * 如果两个节点的key的hash值相等,那么还要通过其他方式再进行比较 * 如果当前链表节点的key实现了comparable接口,并且当前树节点和链表节点是相同Class的实例,那么通过comparable的方式再比较两者。 * 如果还是相等,最后再通过tieBreakOrder比较一次 *
如果dir 小于等于0 : 当前链表节点一定放置在当前树节点的左侧,但不一定是该树节点的左孩子,也可能是左孩子的右孩子 或者 更深层次的节点。 * 如果dir 大于0 : 当前链表节点一定放置在当前树节点的右侧,但不一定是该树节点的右孩子,也可能是右孩子的左孩子 或者 更深层次的节点。 * 如果当前树节点不是叶子节点,那么最终会以当前树节点的左孩子或者右孩子 为 起始节点 再从GOTO1 处开始
重新寻找自己(当前链表节点)的位置 * 如果当前树节点就是叶子节点,那么根据dir的值,就可以把当前链表节点挂载到当前树节点的左或者右侧了。 * 挂载之后,还需要重新把树进行平衡。平衡之后,就可以针对下一个链表节点进行处理了。 // 把所有的链表节点都遍历完之后,最终构造出来的树可能经历多个平衡操作,根节点目前到底是链表的哪一个节点是不确定的 // 因为我们要基于树来做查找,所以就应该把
tab[N] 得到的对象一定根是节点对象,而目前只是链表的第一个节点对象,所以要做相应的处理。 //把红黑树的根节点设为 其所在的数组槽 的第一个元素 //首先明确:TreeNode既是一个红黑树结构,也是一个双链表结构 //这个方法里做的事情,就是保证树的根节点一定也要成为链表的首节点
扩容机制在上文讲述了,这里不再赘述
//获取扩容之前的元素列表 //获取之前的列表长度 //如果原来的容量已经大于了最大容量,则把threshold设置成int的最大值 //如果走下面这个else,则说明是第一次put元素,也是第一次进行扩展
//到此为止,如果是第一次扩容的时候,也就是原来没有元素,下面的代码不会运行
//如果原来有元素,则将原来的元素,进行放到新扩容的数组里面
//循环原来的列表,将旧数组放置新数组中
//如果列表里面只有一个元素,直接将e的key的hash与新容量重新计算下标
//如果是红黑树,则进行红黑树的复制
这一块是处理原来的HashMap冲突的,因为容量扩容了
原来通过 & 运算出来的hash冲突的key,与新的容量 & 可能位置会发生改变
所以需要重新计算下标,通过与oldCap 进行 & 运算 分为低位和高位,因为和oldCap &运算只有两种结果,一种是0 另一种是oldCap
将结果=0的设置成低位,将等于oldCap的保存到高位,
低位的hash值肯定小于oldCap,所以下标还是在原来的位置
高位的hash一定大于oldCap,在二进制最左至少会多一个1(有可能还有前面还有其他二进制数字,但运算结果都是一样的)计算正好是原来位置加上新的容量
//获取当前节点的下一个元素
//如果不是第一个元素,就把他加到后面
//将原来下标指向低位的链表
//下标 j + oldCap 计算出来的新下标正好是原来位置索引加上新的容量
扩容方法中JDK8对JDK7的优化
在jdk7中,所有元素按照索引算法全部重新计算,效率比较低
可以看到在1.7中,借助transfer()方法(jdk1.8中已移除),在扩容的时候会重新计算threshold,数组长度并进行rehash,这样的效率是偏低的
//判断是否有超出扩容的最大值,如果达到最大值则不进行扩容操作 // transfer()方法把原数组中的值放到新数组中 //设置hashmap扩容后为新的数组引用 //设置hashmap扩容新的阈值 //通过key值的hash值和新数组的大小算出在当前数组中的存放位置在jdk8中,在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就行
正是因为这样巧妙的rehash方式,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认
为是随机的,在resize的过程中保证了rehash之后每个桶上的节点数一定小于等于原来桶上的节点数,保证了
rehash之后不会出现更严重的hash冲突,均匀的把之前的冲突的节点分散到新的桶中了。
//扩容后的位置=原位置 //扩容后的位置 = 原位置 + 旧容量从上面的源码中可以分析出扩容分为三种情况:
第二种是如果oldCap大于0,也就是扩容过的话,每次table的容量和threshold都会扩围原来的两倍
和put一样,为用户提供一个方法,实际上调用的removeNode
传递key的hash值选中位置,传递key以选中具体元素进行删除
判断删除的节点存不存在,存在则删除并返回节点的值value,反之返回null
* 方法为final,不可被覆写,子类可以通过实现afterNodeRemoval方法来增加自己的处理逻辑(解析中有描述) * @return 返回被删除的节点对象,如果没有删除任何节点则返回null * 如果 节点数组tab不为空、数组长度n大于0、根据hash定位到的节点对象p(该节点为 树的根节点 或 链表的首节点)不为空 * 需要从该节点p向下遍历,找到那个和key匹配的节点对象 // 如果当前节点的键和key相等,那么当前节点就是要删除的节点,赋值给node * 到这一步说明首节点没有匹配上,那么检查下是否有next节点 * 如果没有next节点,就说明该节点所在位置上没有发生hash碰撞, 就一个节点并且还没匹配上,也就没得删了,最终也就返回null了 * 如果存在next节点,就说明该数组位置上发生了hash碰撞,此时可能存在一个链表,也可能是一颗红黑树 // 如果当前节点是TreeNode类型,说明已经是一个红黑树,那么调用getTreeNode方法从树结构中查找满足条件的节点 // 如果不是树节点,那么就是一个链表,只需要从头到尾逐个节点比对即可 // 如果e节点的键是否和key相等,e节点就是要删除的节点,赋值给node变量,调出循环 // 走到这里,说明e也没有匹配上 p = e; // 把当前节点p指向e,这一步是让p存储的永远下一次循环里e的父节点,如果下一次e匹配上了,那么p就是node的父节点 } while ((e = e.next) != null); // 如果e存在下一个节点,那么继续去匹配下一个节点。直到匹配到某个节点跳出 或者 遍历完链表所有节点 * 如果node不为空,说明根据key匹配到了要删除的节点 * 如果不需要对比value值 或者 需要对比value值但是value值也相等 * 那么就可以删除该node节点了 tab[index] = node.next; // 由于删除的是首节点,那么直接将节点数组对应位置指向到第二个节点即可 else // 如果node节点不是首节点,此时p是node的父节点,由于要删除node,所有只需要把p的下一个节点指向到node的下一个节点即可把node从链表中删除了和put与remove一样,提供一个对外的方法,实际上是调用的getNode方法
如果有值返回值,无值返回null
也就是比对key是否一致,一致则返回
否则就先判断是不是红黑树,是则用getTreeNode获取并返回
不是红黑树,则循环链表并和之前一样比对key并判断
4.不带泛型的map的entrySet的迭代器进行遍历
和第3种方法差不多,区别只有不带泛型,取值需要强转