标签:hash HashMap 16 value next key put null 底层
前言:HashMap这种数据结构在大部分开发场景用处都非常多,因此我们在使用的时候也必须去了解其底层原理,方便我们可以在使用的时候能熟练根据其设计优化我们的程序,后面我就围绕几个问题展开讲解,尽量通俗易懂。
1、hash计算索引的过程是怎样实现的?设计上有哪些点值得我们学习
2、hashMap扩容底层实现原理?
3、我们知道hash初始化长度规定要16倍数的长度,为什么,如果不是16的倍数长度会造成什么影响?
4、尾插法解决什么问题?
如果你对这四个问题也感兴趣,请往下看,分享思路。
一、hash计算索引的过程是怎样实现的?设计上有哪些点值得我们学习
前置知识:hash存在冲突、二进制位移运算符
hash函数存在冲突是必然的,这个由它的算法基础决定,因此在hashMap存放数据的时候也会相应存在冲突、
二进制运算符,后续需要这部分知识作为基础,主要理解,异或^, 与&,或|和右移>>>的执行规则;
运算符 | 规则 |
^ | 0^0 = 0,01=1,10=1,1^1=0 参加位运算的两位只要相同为0,不同为1 |
& | 0&0 = 0, 0&1 = 0, 1&1=1.也就是说两位同时为1,结果为1,否则为0 例子: 3 & 5 = 1.(000011 & 000101 = 000001) |
| | 0|0 = 0,1|0 = 1,0|1 = 1,1|1 = 1 参加位运算的两位只要有一个为1,那么就为1 例子:3 | 5 = 7(0000011 | 00000101 = 0000111) |
>>> | 相当于除2 例子:16 >>> 3(10000 >>> 3 = 10) |
看着这个规则我们提出一个问题就是:与&这个运算符如果都为1才为1能不能用来取模?就比如整数类型12(1100)要对3(0011)取模,用&运算可行不,貌似是可行的,如果取模的值3(0011)不变,无论和谁做&运算得到的结果都不会大于3,想想这个原理。先抛一个问题大家思考一下,后续会用到。
源码分析:
接下来我们看看hashMap的源码是怎么计算索引的。主要看put的底层实现
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return the previous value associated with <tt>key</tt>, or
* <tt>null</tt> if there was no mapping for <tt>key</tt>.
* (A <tt>null</tt> return can also indicate that the map
* previously associated <tt>null</tt> with <tt>key</tt>.)
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
然后看看hash(key)的源码;
/**
* Computes key.hashCode() and spreads (XORs) higher bits of hash
* to lower. Because the table uses power-of-two masking, sets of
* hashes that vary only in bits above the current mask will
* always collide. (Among known examples are sets of Float keys
* holding consecutive whole numbers in small tables.) So we
* apply a transform that spreads the impact of higher bits
* downward. There is a tradeoff between speed, utility, and
* quality of bit-spreading. Because many common sets of hashes
* are already reasonably distributed (so don't benefit from
* spreading), and because we use trees to handle large sets of
* collisions in bins, we just XOR some shifted bits in the
* cheapest possible way to reduce systematic lossage, as well as
* to incorporate impact of the highest bits that would otherwise
* never be used in index calculations because of table bounds.
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
看到这里,我就会有一些疑问,hashCode怎么来的?为什么要和自身的右移16位做异或运算,优点是什么,能不能不做,如果不做会有什么问题。带着这些问题我们来看看设计上的巧妙之处。
前置知识:int数据是4个字节,也就是32位,最大2^31-1
如果key==null那么就将键值对存入索引为0的桶内,如果不为空则计算key的hashcode值,将h右移16位进行异或运算得到hash值。
为什么要右移16位?
其实是为了减少碰撞,进一步降低hash冲突的几率。int类型的数值是4个字节的,右移16位异或可以同时保留高16位于低16位的特征
为什么要异或运算?
首先将高16位无符号右移16位与低十六位做异或运算。如果不这样做,而是直接做&运算那么高十六位所代表的部分特征就可能被丢失 将高十六位无符号右移之后与低十六位做异或运算使得高十六位的特征与低十六位的特征进行了混合得到的新的数值中就高位与低位的信息都被保留了 ,而在这里采用异或运算而不采用& ,| 运算的原因是 异或运算能更好的保留各部分的特征,如果采用&运算计算出来的值会向1靠拢,采用|运算计算出来的值会向0靠拢
到这里我们继续看源码,hash到索引的计算。
/**
* Implements Map.put and related methods.
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
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;
// 计算索引,并把当前索引数据赋值给p,如果为空就代表当前位置不存在冲突,可以直接存放值
if ((p = tab[i = (n - 1) & hash]) == null)
// 直接赋值
tab[i] = newNode(hash, key, value, null);
else {
// 如果不为空说明存在冲突,需要链表处理或者链表升级
Node<K,V> e; K k;
// 如果当前索引数据和传入的hash相同并且key也一样,说明这是map的覆盖数据操作
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 将引用地址赋值给e,后续对e做值覆盖操作
e = p;
else if (p instanceof TreeNode)
// 如果p已经进化成红黑树,就插入树节点操作
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
// 这里就是链表操作,既不是书结构也不是值覆盖,如果p的后继是空
if ((e = p.next) == null) {
// 我们就将值赋值给p的后继
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 如果添加一个数据之后刚好大于7的最大链表长度就升级成红黑树
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 值覆盖
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
// 扩容
resize();
afterNodeInsertion(evict);
return null;
}
源码很容易就看到 tab[i = (n - 1) & hash] 很显然这里就是计算索引的地方,n初始默认16
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
上面就是n的取值,1左移4位,就二进制(0001 << 4 = 10000 = 16)
上面我们提到过&运算是否可以做到取模的效果,这里的源码就是对15(1111)取模,任意int类型数据和(1111)都不会大于(1111)且所有值都有可能涉及到,所以如果这里初始化如果不是16的倍数的话,比如初始化长度12(1100)我们会发现,这里&运算能计算出来的结果只有4种(1000,1100,0100,0000)所以这也是为什么初始化必须是16的倍数的原因。
1问题总结:
- hash右移16位和自身做异或运算为了减少冲突
- 通过&与运算取模,且长度必须是16的倍数,原因见上。
二、hashMap扩容底层实现原理?
我们先说猜测,然后看一下hashmap是不是这么实现的,扩容机制(hashmap会重新创建Node[],然后重新计算每个数据的索引,将值重新分配到新node数组上,直到最后一个数据完毕,以新代旧实现扩容)
源码:
/**
* Initializes or doubles table size. If null, allocates in
* accord with initial capacity target held in field threshold.
* Otherwise, because we are using power-of-two expansion, the
* elements from each bin must either stay at same index, or move
* with a power of two offset in the new table.
*
* @return the table
*/
final Node<K,V>[] resize() {
// 申明对象
Node<K,V>[] oldTab = table;
// 记录当前node数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 记录负载值
int oldThr = threshold;
int newCap, newThr = 0;
// 如果当前map有值
if (oldCap > 0) {
// 如果这个存放的数据已经疯了,接近int最大值,那么就放弃了,不扩容了,毁灭吧,累了
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 还有救、
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 负载扩大一倍、负载就是负载因子 * 最大长度
newThr = oldThr << 1; // double threshold
}
// 初始化
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 初始化
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 扩容实际操作
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 如果计算为0,还是原来的位置
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
// 如果是1则放到 1 + oldcap的位置
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;
}
}
}
}
}
return newTab;
}
这里虽然源码有点长,但是逻辑还是很清楚的,关键代码其实就一点点。看下面这个循环,这个循环就是替换的核心代码
for (int j = 0; j < oldCap; ++j) {
// 申明对象
Node<K,V> e;
// 如果当前索引数据有值,赋值给e
if ((e = oldTab[j]) != null) {
// 清空旧node[]当前索引数据
oldTab[j] = null;
if (e.next == null)
// 如果当前节点就一个,不存在冲突,重新计算索引,再分配
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 如果当前节点是树节点【源码我没看,哈哈,应该也不难,自己看】
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
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) {
// 1.1 如果和16(10000)做&运算得到0
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
// 2.1 如果和16(10000)做&运算得到1
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
// 1.2 则不改变其位置
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
// 2.2 则将其放到1 + 16的位置
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
看我的注释,这个和我们的猜测有差异,差异点在于它采用了一种机制,就是当前值hash和oldcap做&与运算,如果是0则不改变位置,如果是1则位置变为 原来的位置 + oldcap。这个设计也有点巧妙。我觉得可能是为了使新生成的map数据分布尽量平均,提升查询效率。
三、我们知道hash初始化长度规定要16倍数的长度,为什么,如果不是16的倍数长度会造成什么影响?
这个问题一问题我们已经解释过了
上面我们提到过&运算是否可以做到取模的效果,这里的源码就是对15(1111)取模,任意int类型数据和(1111)都不会大于(1111)且所有值都有可能涉及到,所以如果这里初始化如果不是16的倍数的话,比如初始化长度12(1100)我们会发现,这里&运算能计算出来的结果只有4种(1000,1100,0100,0000)所以这也是为什么初始化必须是16的倍数的原因。
hashmap长度为16的倍数是为了数据能在所有的索引都有落脚点,减少冲突,提升查询效率
4、尾插法解决什么问题?
解决了异步链表死循环问题,不做分析。
如果这篇文章对你有帮助,素质一连,点个赞。如果解释不当的地方可以评论区讨论
标签:hash,HashMap,16,value,next,key,put,null,底层 来源: https://blog.csdn.net/qq_36005199/article/details/120214711
本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享; 2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关; 3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关; 4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除; 5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。