HashMap集合
HashMap为Key-Value键值对存储的集合,本文不会说出HashMap所有的原理,尤其是红黑树的原理,我没去看,太麻烦了,其他的我觉得也够用了!一起来看!墨迹起来。。。
关键字段及含义
//Map默认的开发的容量大小16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
//负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//没看
static final int TREEIFY_THRESHOLD = 8;
//没看
static final int UNTREEIFY_THRESHOLD = 6;
//没看
static final int MIN_TREEIFY_CAPACITY = 64;
1. 为什么默认16个长度?
查询了好多文章,没有一个特别有说服力的,做一个简单的总结吧,加上个人想法!!
1.1 先说为什么容量一直是2的N次方?
第一点是2的N次方,可以保证Hash值的二进制位都是1,HashMap在计算元素索引的时候,代码是这个样子滴:(n - 1) & hash,假设n=16,那么n-1=1111,可以保证n-1的二进制数都是1。为什么要保证都为1呢?这就涉及到一个概率的问题,也就是常说的为了HashMap中的元素能够均匀的分布到每个槽位上,至于算法概率上,不太好举例子,只能做一个简单的假设:
- 假如新加入一个元素,Key的hash值的后四位为1001,那么与1111做运算,1001 & 1111 = 1001;
- 再加入一个元素,key的hash值后四位是1011,做运算,1011 & 1111 = 1011
- 可以知道,这两次的索引计算结果不同。再看下面的假设
- 假设我们规定Hash的默认长度为10,那么10-1=9=1001
- 然后我们分别看一下计算结果
长度 key的hash值 结果
1001 1101 1001
1001 1011 1001
可以看出来,不同的hash值,计算的结果一样,这就是一个概率的问题,为了尽量让元素均匀分布,如果长度值为16,那么二进制 全是1,则可以缩小索引碰撞的几率。
1.2 为什么是16长度,为什么不是4、32、64?
这就是一个“合适”的问题,可能觉得4太小,32有点浪费!其他我想不出来有什么原因了!4的话,没加入几个元素就要再次扩容,32的话,不一定有这么多元素会被设置到map中,所以取了16作为默认长度。
2. 负载因子作用是什么?为什么是0.75?
负载因子是为了计算阈值的,阈值就是Map扩充的一个判断值,当元素的数量达到阈值,会扩容。初始化时:阈值=负载因子*默认容量;扩容时:阈值=当前阈值<<1,即2倍
2.1为什么是0.75?
探讨这个问题,首先要知道如果负载因子大了或者小了会有什么问题!
1. 假如负载因子为1,那么会导致下面的情况:
Map中的元素达到当前Map的容量时才会扩容,那么此时,会出现一个效率问题,什么问题呢?第一点,因为没有提前扩容,哈希碰撞的几率增加,链表深度过长。这样就带来两个问题,由于碰撞几率增加,插入会变慢!链表深度过长,查询会变慢!
2. 假如负载因子过小,那么会导致提前扩容,hash冲突几率倒是变小了,但在没有更多的元素加入情况下,空间是一种浪费!
好,到这里,那么为啥子非要是0.75呢?为啥子不是0.5、0.6呢?答:泊松分布!!!面试官要特么问我这个,我日他大爷,老子不会。
HashMap扩容
1. 什么时候会扩容?
直接上代码看:HashMap的putVal方法,主要两个地方涉及到扩容!
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//省略部分代码,看重点
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
resize为HashMap的扩容方法!
- 如果当前Map没有元素,则扩容
- 如果当前Map的元素大于阈值,则扩容。阈值=负载因子*当前Map容量
- 还有树化的时候,也会扩容,没看
2. 扩容机制及原理
那就必须撸代码啦!上!直接在代码上注释啦!先说简单的部分代码:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//定义当前容量,为空则是0
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//定义当前阈值
int oldThr = threshold;
//定义新的容量和新的阈值
int newCap, newThr = 0;
//如果当前容量大于0
if (oldCap > 0) {
//如果当前容量大于最大容量,2的30次方
if (oldCap >= MAXIMUM_CAPACITY) {
//阈值设置为2的31次方
threshold = Integer.MAX_VALUE;
//返回旧的容量大小,不再扩充容量
return oldTab;
}
//如果旧容量的2倍小于最大容量,并且旧容量大于默认的容量16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//扩充阈值为原来的2倍
newThr = oldThr << 1; // double threshold
}
//如果旧的阈值大于0,则新的容量等于旧的阈值,一般不走到这里,在初始化Map的时候指定容量和负载因子的时候,第二次扩容会走到这。
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//如果是无参构造创建Map,则走这里
else { // zero initial threshold signifies using defaults
//新容量=默认容量16
newCap = DEFAULT_INITIAL_CAPACITY;
//新的阈值=负载因子*默认容量=0.75*16=12
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//经过上面的判断,如果新的阈值还是0,也就是说通过指定容量和负载因子初始化Map的时候,会走到这
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//将新的阈值设置到threshold
threshold = newThr;
}
总结:
- 通过无参构造初始化Map,默认开发16长度的容量,负载因子0.75,阈值12
- 当Map新增元素后,size达到12后,进行扩容,容量提升2倍,阈值提升2倍
- 指定构造方法初始化Map,容量和负载因子由用户指定,扩容条件还是和上述一样,大于阈值才会扩容,算法也是一样的,只是初始容量和负载因子不同而已。
接着往下看:
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
- 创建一个临时变量,Node E节点,来保存当前循环节点的链表,oldTab[j]就是当前索引下的链表体。
- 如果当前索引不为空,则将当前的索引置为空
- 如果节点E没有后续节点,则进行rehash,就是重新计算该元素的索引位置。这里就比较有意思了,不经赞叹开发者对算法的高超啊。如果E有后续节点,后续再说。
- 那么rehash是不是一定会变更索引的位置呢,答:不一定!存在两种情况,要么保持原有的位置不动,要么一当前索引为基准向后移动旧容量数目。
- 看下面计算,我们向map中设置一个key为42,对应代码 newTab[e.hash & (newCap - 1)]来看,4次扩容后的索引位置变化。注意:如果newCap-1的二进制位高位不够则补0,比如第一次计算1010101&1111=101010&001111,也可以说newCap-1有多少二进制位,e.hash的后几位才算有效的。
e(十进制) e.hash newCap-1 newCap-1(二进制) 扩容后索引位置
42 101010 15 1111 1010 = 10
42 101010 31 11111 1010 = 10
42 101010 63 111111 101010 = 42
42 101010 127 1111111 101010 = 42
2.1 扩容索引移位规律
根据上述计算可以得知,rehash的索引位置移动存在两种情况,要么保持原有的位置不动,要么以当前索引为基准向后移动旧容量数目。比如第三次扩容的时候,索引向后移动了2的5次方,即是32(我们原来的旧容量大小)。这是一个规律,因为每次扩容量是2倍,那么在二进制位看来,也就是前面的一个位多了一个1,好比第三次的扩容计算,容量由32扩充到64,这样一来高位多了一个1,高位多出来的1与e.hash做运算,e.hash高位的1就保留了下来,而恰巧这个高位的1就是2的5次方=我们的旧容量大小32。所以如果我们key的哈希值过短,则在几次扩容之后,不会再变更索引位置;如果够长,则可能还会变更位置。
好了!!上述代码已经解析完毕,接下来的情况,就是索引上的节点存在深度为2以上的链表,这种情况是怎么rehash的呢?分两种情况:
第一种情况:树化节点的rehash,这个要看红黑树TreeNode!!!太恶心,略过!!!
第二种情况:普通链表的rehash, 那么前提我们要知道,元素在put的时候,发生了什么,put是一个怎么样的操作,put一个元素后,数据结构是怎样的!!请先看put方法解析,在来看多节点链表的重组过程!
3. 深度节点扩容过程
接着上面的扩容代码说:
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
这个代码,是扩容中else的代码,也就是说如果索引处存在2个以上的节点,会走到这里。来看一下下面两个图,初始容量16, 位置为0索引处的节点有3个,节点分别用ABC代替:
我们假设有一种情况,注意这是逻辑的条件,看懂了:
- 我们打算扩容到32
- 扩容到32位之后,会重新计算每个key的索引
- 假设A和C通过e.hash & oldCap == 0 为真,则说明A和C的索引不变
- 假设B通过e.hash & oldCap == 0 为假,则说明B的索引要变化
- B的索引变化,就是j+oldCap。
- 所以扩容之后各节点的结构如下图
这里可以得知,e.hash & oldCap是用来判定索引是否要发生变化的,又是算法的巧妙。总之不论哪种方式,索引要么不变,要不移动旧数量的数目
put方法解析
代码就不贴上了,直接看HashMap的putVal方法
- 如果当前map是空的,则调用扩容方法进行初次扩容
- 扩容后,计算元素在数祖上的索引,公式index=(容量-1)&hash值
- 如果当前索引处没有节点,则new一个Node节点存放key和value,再把这个Node设置到当前索引处
- 如果当前索引处存在节点是普通节点,取出第一个节点Node,与新添加的元素相比较,如果hash值和equals方法都是相同的,则替换该元素的value
- 如果当前索引处存在节点是树节点,调用putTreeVal方法
- 如果当前索引处不是树节点,也与新加入的元素不相等(hash和equals不相同),则进入死循环
- 死循环跳出的条件:一:后续再无节点,在最后的一个Node尾部插入新节点。二:循环的当前节点与新加入的元素相等,替换当前节点的Value。两种情况任意一种,可break当前循环。
- ++modCount, 这个和迭代有关,迭代器迭代时候不会有异常,就是因为这个。
- 如果++size>threshold,也就是新加入节点后,size大于当前阈值,则调用扩容。
可以看一个put之后Map的数据结构:
这样put操作就完成了!!!咱们回头可以看扩容的剩余部分代码啦!看上一节。。
hash算法
粘贴来的,了解下
- 加法Hash;把输入元素一个一个的加起来构成最后的结果
- 位运算Hash;这类型Hash函数通过利用各种位运算(常见的是移位和异或)来充分的混合输入元素
- 乘法Hash;这种类型的Hash函数利用了乘法的不相关性(乘法的这种性质,最有名的莫过于平方取头尾的随机数生成算法,虽然这种算法效果并不好);jdk5.0里面的String类的hashCode()方法也使用乘法Hash;32位FNV算法
- 除法Hash;除法和乘法一样,同样具有表面上看起来的不相关性。不过,因为除法太慢,这种方式几乎找不到真正的应用
- 查表Hash;查表Hash最有名的例子莫过于CRC系列算法。虽然CRC系列算法本身并不是查表,但是,查表是它的一种最快的实现方式。查表Hash中有名的例子有:Universal Hashing和Zobrist Hashing。他们的表格都是随机生成的。
- 混合Hash;混合Hash算法利用了以上各种方式。各种常见的Hash算法,比如MD5、Tiger都属于这个范围。它们一般很少在面向查找的Hash函数里面使用
HashMap的hash算法
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } |