HashMap源码分析
面试题+jdk7和jdk8比较
jdk7和jdk8比较
jdk7HashMap | jdk8HashMap | |
---|---|---|
数据结构 | 数组+链表 | 数组+链表+红黑树 |
插入方式 | 头插法 | 尾插法 |
扩容条件 | size>threshold且table[i]不为空即当前put进来的元素对应的数组下标位置有值才扩容 | size>threshold即扩容 或者链表长度大于8且数组长度小于64的时候会扩容 |
扩容时转移元素的方式 | 一个一个转移 | 通过高低位指针得到两条链再转移 |
Hash算法 | 更复杂 用很多位运算使生成的hashcode更散列 | 由于增加红黑树,查询性能得到保证,所以没有使用很复杂的hash算法,因为hash函数简单可以减少CPU损耗 |
是否rehash | 有可能会重新对key进行哈希(重新Hash跟哈希种子有关系) | 不会重新计算hash值 |
为什么jdk8使用红黑树?什么时候将链表转为红黑树?
当元素的个数小于一个阈值,链表的整体插入查询效率要高于红黑树;当元素个数大于阈值时,链表的整体插入查询效率要低于红黑树。因此jdk8引入红黑树,当链表长度大于8且数组长度大于等于64的时候将链表转为红黑树,提高整体的插入查询效率。
jdk7 数组+链表(头插法)
引入链表解决hash冲突,jdk7为头插法
属性
- HashMap的属性table就是用来存数组
- 加载因子
数组容量超过了75% 就会去扩容
为什么加载因子默认选择为75?根据牛顿二项式算的,基于空间与时间的折中考虑。
因为加载因子过小,也就是过早扩容会浪费很多空间;而加载因子过大,也就是数组都快占满了才扩容的话,这时候发生哈希冲突的概率多,链表就会过长,过长遍历速度就会慢。
- 阈值threshold
threshold=数组长度加载因子
默认为160.75=12 即HashMap中存的元素的个数为大于12就会扩容 - 数组最大长度
- 默认初始容量即数组长度
默认为16,必须是2的指数幂,如果不是则在初始化的时候会通过roundUpToPowerOf2(size)强行转成比大于等于size的最接近的2的指数幂
为什么数组大小必须是2的指数幂
- 通过与运算算index的时候,必须是2的指数幂,减1后进行与运算才能保证index取值范围在0~15之间
- size hashmap已经添加的元素个数
- modCount 记录操作的次数 put也会加1 remove也会加1
fast-fail 一种快速容错机制 能及时抛出异常
put()
HashMap1.7中插入一个元素的流程:
- 数组为空就创建一个Entry数组
- 通过key计算出一个hashcode
- 通过hashcode和length-1与操作得到对应的数组下标
- 如果当前位置有元素,则遍历这个链表,存在相同的key就替换value值,并返回oldValue
- 如果当前数组中元素个数大于阈值,则扩容。即创建一个长度原来2倍的数组,并把数据都转移到新数据中。转移方式就是一个一个转移。
- new一个Entry 用头插法插入链表
-
如果数组不存在,则创建inflateTable(threshold)
-
roundUpToPowerOf2 将toSize转成最接近的且大于他的二的幂次方数
二的幂次方数:二进制只有一个位上是1,其余全是0
因为highestOneBit得到的是小于等于这个数的最大二次幂,所以我们通过number-1再翻倍 传进去 ,就可以得到大于等于number的最小二次幂
highestOneBit 是找到<=这个数的二次幂
-
-
key为null putForNullKey()
key为null就会将这个entry存到hashmap的第0个位置,如果之前这个位置有entry的key为null,那么value就覆盖
这个位置不是说只存key为null的,只是key为null的一定是存在这里 -
hash(key) 通过key计算出一个hashcode
让高位也参与到后续求索引的过程,这样就可以尽量减少哈希冲突
-
indexFor(hash, table.length) 通过位运算计算索引
通过hashcode与“与操作”计算出一个数组下标 注意:这里需要数组长度是2的幂次
为什么是h & (length-1)
table.length是2的指数次幂,则对应二进制只有一个位是1,其余位置全是0。那这样和别人去做与运算,那只有两种情况,一种是它本身一种是0.
如果将table.length-1,比如说是15 则就是1111, 这样就可以保证任意一个值和他做与运算后结果都在0到15之间 -
遍历当前索引上原有的链表
key相同就替换其value,并返回原有值 -
addEntry()
resize() 扩容
-
resize() 扩容
当size大于threshold(threshold=数组容量*加载因子)并且当前index位置上有元素时就扩容,扩容为原来数组长度的2倍
- 创建一个新的数组
- initHashSeedAsNeeded 是否需要生成hash种子
transfer() 迁移数据
- 转移数据transfer()
转移过程就是先遍历数组的每一个位置,如果当前位置有值就遍历链表上的每一个Entry。用头插法移过去,这样和原有顺序就会颠倒。
rehash是false的话,新的索引只有两种情况:1、原位置2、原位置+oldTable.length
-
createEntry把 key,value封装为一个entry对象,使用头插法插入链表
头插法的结果:
jdk7 会采用头插法, 当多线程扩容时(每个线程扩容的时候都会自己new 一个新的数组)会出现循环链表问题,这样就会死锁。原因是:java扩容没有采用任何的同步操作,且为头插法 移到新数组的时候位置会调换,形成环的可能就大大增高。
-
jdk8 数组+链表+红黑树
红黑树
红黑树定义
- 节点是红色或黑色。
- 根节点是黑色。
- 每个叶子节点都是黑色的空节点(NIL节点)。
- 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
插入规则
插入的新结点只能是红色
从自己的祖孙三代开始递归执行
- 父节点是黑色的,不用调整
- 父节点是红色的
- 叔叔是空的,则旋转加变色(父节点和祖父结点)
- 叔叔不是空的
- 叔叔是红色的,父节点和叔叔都变为黑色,祖父变为红色
- 叔叔是黑色的,则旋转加变色
时间复杂度
查询和插入都是O(logn)
和ADL、BST对比
BST是二叉查找树,是不平衡的
ADL是完全平衡树,因为要求完全平衡所以插入的时间复杂度高
红黑树是不追求完全平衡的
balanceInsertion() 向红黑树插入结点
每次都是对祖孙三代进行操作,然后递归到root
x:要插入的结点
xp:x的parent
xpp: x的祖父
xppl:x祖父的左孩子
xppr: x祖父的右孩子
- x.parent为null,即x是根节点,则将x设置为黑 直接return x
- x.parent为黑色,或者x的祖父为null,则直接 return root
- xp是xpp的左孩子 xp == (xppl = xpp.left 为true
- 叔叔不为空且是红色的,父节点和叔叔都变为黑色,祖父变为红色
- 叔叔为空或是黑色的,则旋转加变色
先左旋再右旋- x是xp的右孩子,左旋xp rotateLeft(root, x = xp)
- xp不为null,右旋xpp rotateRight(root, xpp)
- xp是xpp的右孩子
区别在于这里的叔叔是xppl,是先右旋再左旋其余逻辑不变
rotateLeft() 左旋
rotateLeft(root, x = xp)
第二个参数为要进行选择的结点,即我们要将xp左旋
-
rl和p互相认亲:r的左孩子rl 作为p的右孩子,rl的父节点即为p
即把被旋转结点的右孩子的左结点作为被旋转结点的右孩子 -
r认pp为父亲:p的父节点pp作为r的父节点
即把被旋转结点的右孩子的父节点设置为被旋转结点的父节点
如果pp为空说明p是根节点,那这步转完后r就是根节点,所以要把r设置为root并变色 -
pp认r为孩子
- 如果p是pp的左孩子即上图情况,则pp的left就设置为r
因为r就是代替p,p原来是左孩子,r当然设置为p的左孩子 - 如果p是pp的右孩子,则pp的right就为r
- 如果p是pp的左孩子即上图情况,则pp的left就设置为r
-
r和p互相认亲
旋转后结果:
重要属性
- TREEIFY_THRESHOLD= 8 链表转红黑树的阈值
当链表长度大于8的时候,会优先看数组的长度是否大于等于64,如果大于64则将链表转为红黑树,否则优先扩容
为什么默认值设为8
通过泊松分布计算一个索引上链表为不同长度的概率,如下图链表长度为8的概率已经很低很低了,所以这个时候转为红黑树?
既然链表长度为8 的概率很低了,那其实遍历8个结点也不很费劲啊,为啥还要转成红黑树呢?
- DEFAULT_INITIAL_CAPACITY = 1 << 4; Hash表默认初始容量
- MAXIMUM_CAPACITY = 1 << 30; 最大Hash表容量
- DEFAULT_LOAD_FACTOR = 0.75f;默认加载因子
- TREEIFY_THRESHOLD = 8;链表转红黑树阈值
- UNTREEIFY_THRESHOLD = 6;红黑树转链表阈值
- MIN_TREEIFY_CAPACITY = 64;链表转红黑树时hash表最小容量阈值,达不到优先扩容。
TreeNode<K,V>
转为树的时候会把每个Node 转成TreeNode<K,V>
TreeNode<K,V>为内部类有如下属性:
- TreeNode<K,V> parent; // red-black tree links 父节点
- TreeNode<K,V> left; 左孩子
- TreeNode<K,V> right; 右孩子
- TreeNode<K,V> prev; // needed to unlink next upon deletion
- boolean red; 标记元素
Node<K,V> next
因为是继承的LinkedHashMap,所以也有next属性
TreeNode既有prev 又有nex 所以是双向链表
hash()
得到hash值
不像1.7一样有那么位运算 让他更散列,因为1.8加了红黑树,效率变高了,所以即使散列性没有那么好,插到一个位置影响也没那么大。
putVal()
HashMap1.8中 插入一个元素的流程:
- 数组为空就创建一个Node数组
- 通过hashcode和length-1与操作得到对应的数组下标
- 下标对应位置没有元素,直接newNode赋给当前位置
- 如果当前位置有元素,则有三种情况。
- 第一种是当前位置的key和插入元素的key相等;
- 第二种是当前位置是红黑树,则向红黑树插入当前结点。
- 第三种是链表,则遍历链表向链表的尾部插入元素。如果插入后链表的结点数大于8则且数组长度大于等于64则转成红黑树。
- 如果当前数组中元素个数大于阈值,则扩容。即创建一个长度原来2倍的数组,并把数据都转移到新数据中。转移也有三种情况:
- 如果当前位置是单个元素,则直接转移
- 如果当前位置是红黑树,则先通过高低指针方式将红黑树转成两条链,然后分别看两条链是转成链还是红黑树
会遍历该位置的双向链表,遍历双向链表统计哪些元素在扩容完之后还是原位置,哪些元素在扩容之后在新位置,这样遍历完双向链表后,就会得到两个子链表,一个放在原下标位置,一个放在新下标位置,如果原下标位置或新下标位置没有元素,则红黑树不用拆分,否则判断这两个子链表的长度,如果不小于6,则转成红黑树放到对应的位置,否则转化为单向链表放到对应的位置。 - 如果当前位置是链表,则通过高低指针方式转成两条链再移到新的位置。
- 数组是空的则resize()
- 通过hash值得到对应的索引
- 索引处为空,则new Node直接赋值
- 索引处不为空
- if 当前key的值和索引处key值相等
- else if 索引处对象为红黑树
putTreeVal 插入到红黑树 - else 即索引处对象为链表
遍历链表- 遍历的时候有key相等的,就修改value。
- 如果遍历到最后next为空,则new Node,并用尾插法插入。
- 链表长度大于8(加上要插入的结点共9个),则treeifyBin
数组为空 或 数组长度小于 64会去resize()扩容, 不会转成红黑树
- size>threshold 则 resize()扩容
treeifyBin 树化的准备工作
treeifyBin就是先看是否达到树化条件即数组为空或数组长度小于64的话会去扩容,而不会树化。如果达到树化条件,则做树化前的准备工作:遍历一遍当前索引的链表,把node都转为TreeNode,并且TreeNode是双向链表。然后再进行树化
- 数组为空 或 数组长度小于 64 则resize()扩容
- 根据当前key的hash算出索引,得到当前索引处的对象e
- 遍历链表,replacementTreeNode把每一个Node结点都转为TreeNode
- 将TreeNode prev和next都连上对应的TreeNode,即把单链表变为双向链表
- treeify() 进行树化
treeify() 进行树化
- 遍历TreeNode,判断新结点应该插到哪个位置
- 比较hash值
小于左移,大于右移 - hash值相等,CompareTo比较
比较 key对应的Class是否实现了Comparable接口,实现了则直接CompareTo() - CompareTo比较值相等或没有实现Comparable接口,比较Class名字是否相等
getClass().getName() - Class名字还是相等,则比较identityHashCode
System.identityHashCode() 这个函数不会调你自己重写的hashCode
- 比较hash值
- balanceInsertion 插入结点到红黑树,返回的root就是生成的红黑树的根节点
- moveRootToFront 把生成的红黑树root放到原来的位置上
由于root和之前链表的root不一样,而TreeNode是有next和prev 这个函数还通过这两个指针把红黑树的root也作为双向链表的root
resize() 初始化+扩容
这部分包括初始化和扩容
- 如果oldCap为0就初始化
- oldCap不为0就扩容:遍历每个tab
- 只有单个对象直接转移
split() 转移红黑树的元素
-
split() 转移红黑树的元素
由于红黑树的每个TreeNode都有prev和nex指针,所以这里转移的时候是把它视为双向链表转移-
遍历一遍,统计低位的个数lc和高位的个数hc
-
loHead不为null
- 低位链的个数小于UNTREEIFY_THRESHOLD默认为6, 则untreeify() 即TreeNode转为Node
- 否则还是红黑树,直接移到新位置index,若hiHead不为null,说明当前loHead的红黑树的结点要重新树化treeify
-
hiHead不为null
新位置上index+原数组大小
-
-
转移链表中的元素(尾插法)
通过高低位指针的方式 来扩容,遍历链表 将每个节点和原数组容量(默认16)做与运算,这样就只会有两种结果:16或0,我们把与完结果是0的用loHead、loTail去指,与完结果大于0的用hiHead、hiTail去指,这样遍历完这个index上的链表后就有两条链。
然后我们直接把loHead指的链移到新数组同样索引处,把hiHead指的链移到新数组索引+16处。这样就能保证get的时候还是能从对应索引获取到值。
get()
remove()
removeNode
先找到要删除的元素的位置,然后在看结构是红黑树还是链表进行对应的删除逻辑
-
找到元素node
-
删除元素node