文章首发于有间博客,欢迎大伙光临有间博客
虽然网络上有非常多的hashMap的源码教程,但是不自己深入去研究一遍,终究不是自己的。往往在学习的过程中又能发现很多原来没有观察到的疑点。
关于hashMap整理的几个问题
大伙们能看见这篇文章,肯定都是资深老程序员了,必然对hashMap知根知底,那这里提出几个问题考考大伙,如果都能答得上来,那后面文章也不用看了,右上角关闭即可。
1.hashMap的hash(key)计算方式是怎么样的?为什么这么计算?有实验证明全随机数的情况下直接用.hashcode()去使用和进行hash()方法再去使用基本没什么差别,所以为什么要用hash()呢?
2.hashMap在计算桶下标的时候为什么使用hash & size - 1 ,使用hash % size 它不香吗?
3.hashMap在get()或者put()确定桶的位置后为什么都需要先进行判断第一个节点,再进行判断后面的树节点或者是链表节点?
4.在put()方法中,onlyIfAbsent这个参数的作用是什么?modCount呢?
5.resize()方法的作用是什么? threshold参数呢? 在不同的时期它所代表的意义是什么?
6.扩容时将旧桶中的元素移到新桶的算法是怎么样的?
7.我们知道hashMap默认的负载因子是0.75,那它可不可以超过1呢,如果超过了会怎么样?
答案都在文中了…可能废话比较多.如果哪里解释的有问题…请一定一定在评论区告诉我…相互学习才能进步。
get()方法
//get和put方法可能是大伙在平时中用的最多的方法了,接下来来详细解读下
//get方法,传入对应的key值,取出对应的value值
public V get(Object key) {
//生成一个node值接收
Node<K,V> e;
//调用下方的getNode(hash, key)方法获取到node值,如果不为null,则返回value值
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
这里贴一下 getNode(hash(key), key)) 中的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.
* 翻译:
* 计算键.hashCode()并将哈希的较高位扩展到较低的位。因为该表使用两个掩蔽的幂次,
* 所以仅在当前掩码上方以位为单位变化的散列集将始终发生冲突。
* (在已知的例子中,有一组在小表中保存连续整数的浮点键)因此我们应用了一种将高位的影响向
* 下扩展的变换。在比特传播的速度、效用和质量之间存在一种折衷。
* 因为许多常见的散列集已经被合理地分布了(所以不能从传播中获益),
* 而且我们使用树来处理容器中的大组冲突,所以我们只需以最便宜的方式异或一些移位的比特来
* 减少系统损失,以及合并最高位的影响,否则由于表的限制,这些位永远不会用于索引计算
*
* 结论:
* 虽然有大佬经过测试,证明在全随机数的情况下,这种高位与地位异或防止只有低位碰撞的情况,和
* 直接取低位进行运算,随机的程度是一样的。但是主要是为了防止某些极端情况,比如330000,340000
* 这些数据组成的key,那么将会导致低位全是0的情况,影响结果,所以通过这种方式尽量避免极端的情况。
* 并且因为使用的是^,在0和1 的世界里,这种运算的速度是很快的,可以忽略这些损耗。
*
*/
//传入对应的key值
static final int hash(Object key) {
int h;
//如果key值为null,那么直接放入0下标的节点,不然通过key的hashcode与key的hashcode右移
//16位进行做异或运算,将高位的进制也一起加入运算,防止极端值的低位全0碰撞。
//会加长链表长度,影响性能。
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
//返回的就是计算后的hash值啦。
}
这是get()方法真正调用执行的方法,getNode(int hash, Object key);
get()方法唯一需要注意的是(n - 1) & hash;这个获取数组下标的方法,n是当前的数组长度,hash是通过上面方法得到的hash值。因为数组的长度一定是2的n次方。具体可以看下面的扩容过程。所以数组的长度n所对应的二进制数一定是这样的 ‘1000’ 或者 ‘10000’ 以此类推。所以n - 1就变成 ‘111’ 或 ‘1111’。然后通过n - 1 与hash做与运算,会变成什么呢? ‘1111’ & ‘10101101’ = ‘1101’ = 1 + 4 + 8 = 13 < 16 所以hashMap通过这种方式来确定数组也就是常说的桶的下标,为啥要使用&而不使用%呢?因为&快啊,这是二进制的运算,在计算机里底层都是二进制,所以运算起来快。
/**
* Implements Map.get and related methods
* 实现map的get方法和相关的方法
*
* @param hash hash for key 传入对key的hash运算后的值
* @param key the key 传入key
* @return the node, or null if none 返回节点或者null
*/
final Node<K,V> getNode(int hash, Object key) {
//tab:设置数组引用 first:第一个节点 e:第一个节点的下一个节点
// n:数组长度 k:first的key
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//将table赋值给tab并判断是否为空,设置n为数组长度且要大于0, 并且设置first获取数组
//对应的下标节点上的值不能为空,才能进行下面的环节,不然就直接return null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//判断数组表面第一个节点的hash值是否等于需要的hash值,并且需要key也和传入的key比较
//如果是字符串或者引用类型需要使用equals比较。
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
//如果比较相同,返回第一个节点值
//这里标注一下为什么一定要比较第一个节点的值而不是和链表一起比较,因为hashMap默认的
//负载因子是0.75,也就是说,负载容量是低于数组长度的,那么大概率就是每个key都能
//对应一个数组的下标,少数会进行重叠成链表,所以直接可以判断数组第一个元素。
return first;
//如果还有第二个值,并将下一个值赋给e
if ((e = first.next) != null) {
//判断第一个节点是否是树节点
if (first instanceof TreeNode)
//如果是树节点就在红黑树中进行二分查询
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//因为已经判定过确定有后续值,所以使用do while不进行多余的判断
do {
//对e的值进行比较,和之前一样
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
//返回e的值
return e;
//进行下一次判定与循环
} while ((e = e.next) != null);
}
}
//如果都没有找到或者就是空表,那么返回null。
return null;
}
put()方法
//put方法,传入对应的key值与value值
public V put(K key, V value) {
//调用下方的putVal()方法,传入key值,value值,判断是否仅对null值下手,以及一个LinkedHashMap
//用于lru最旧节点删除的判断,这个在hashMap中没有用到,用给LinkedHashMap继承使用
return putVal(hash(key), key, value, false, true);
}
/**
* 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 如果设置为true,将不再改变已经存在的值
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
//关于onlyIfAbsent这个参数,相当于是仅仅对map中key不存在的value进行插入,比如map中不存在key
//为3的节点,那么成功插入,但是如果map中已经存在key为3的节点,那么插入失败,对应具体的方法
//可以去参考putIfAbsent(key, value) , 动手试一试才是真的
//ecvit这个参数这里不做考虑,原因参上
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//tab定义一个数组承接table,p定义为后续判断的节点,n为数组长度,i为对应key的数组下标
Node<K,V>[] tab; Node<K,V> p; int n, i;
//table是原来有数据的数组,赋值给定义的tab
//并且判断数组是否初始化以及长度是否为0
if ((tab = table) == null || (n = tab.length) == 0)
//如果是的话进行数组的初始化并记录长度给n
n = (tab = resize()).length;
//通过数组长度-1与hash值区&运算获取对应的数组下标信息,取到对应节点赋值给p
if ((p = tab[i = (n - 1) & hash]) == null)
//如果对应下标的数组内的值为空,生成一个节点,将hash,key,value值存入
tab[i] = newNode(hash, key, value, null);
//如果当前对应数组下标中已经有值存在了
else {
//生成一个e节点用于接收需要替换值的节点,以及设置k来接收需要比较节点的key值
Node<K,V> e; K k;
//如果需要比较节点的hash值与外部传入的hash值一样
if (p.hash == hash && //并且节点的key与传入的key一直
((k = p.key) == key || (key != null && key.equals(k))))
//将p赋值给e,等之后一起替换
e = p;
//如果当前节点的类型是树节点,说明是红黑树
else if (p instanceof TreeNode)
//进入红黑树中找到对应需要替换的节点
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//不然就只剩下最后一个可能,是链表
else {
//进行循环,内部到尾部节点或者对应节点时会进行break跳出
for (int binCount = 0; ; ++binCount) {
//当p的下一个节点为null时,这个时候说明节点已经不存在了,
if ((e = p.next) == null) {
//直接生成一个新的节点设置进去
p.next = newNode(hash, key, value, null);
//判断链表的长度是否已经大于8, TREEIFY_THRESHOLD = 8,因为
//从0开始算,第一个节点不算,所以>=7
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//转化成红黑树后退出
treeifyBin(tab, hash);
break;
}
//如果遇到了一样hash一样key的节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
//退出循环,这时候返回的还是e,也就是上面需要替换的节点都是e,只需要修改e的value值就行
break;
//每次都将p等于e, 然后开头e = p.next 进行循环
p = e;
}
}
//得到的就是需要替换值的e节点,判断是否存在,如果不存在说明不用替换
if (e != null) { // existing mapping for key
//取出节点旧的值
V oldValue = e.value;
//判断onlyIfAbsent这个值,如果是true并且内部有值就不改变,不然就进行赋值
if (!onlyIfAbsent || oldValue == null)
//将节点的value值替换成传入的值
e.value = value;
//这个是LinkedHashMap的参数,这里没有啥意义,是个空方法
//在LinkedHash中对这个方法进行重写,插入新的节点到链表尾部,维护插入的顺序
afterNodeAccess(e);
//返回旧的值
return oldValue;
}
}
//modCount这个值相当于是一个版本号一样存在,在对hashmap遍历的时候需要对modCount这个值检查
//如果被修改了则会报ConcurrentModificationException异常,这也是集合类中就有的一种Fail-Fast的错误检测机制。
//在map中使用迭代器进行遍历时使用remove(),put增加新值的时候就会让modCount增加,
//和初始值不一样而导致报错。初始值是在迭代器初始化就定义的expectedModCount
++modCount;
//使总容量增加,如果大于负载容量
if (++size > threshold)
//进行扩容处理
resize();
//也是LinkedHashMap的内容。
afterNodeInsertion(evict);
//返回null
return null;
}
这是调用putIfAbsent()方法的一个试验,感兴趣的也可以自己试一试。这里再问一个问题,既然hashMap进行put操作会使modCount增加,为什么仅仅在增加值的时候会报错,而替换值的时候不会?
上题答案:因为如果是替换值,在增加前就return了,根本没进行到扩容和modCount阶段,没想明白的再去看一次。
resize() 扩容初始化方法
/**
* 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.
*
* 初始化或者获取双倍数组的大小。如果为空,则在符合范围的情况下初始化目标。
* 不然,因为我们要使用二次扩展的能力,每个bin中的元素们必须拥有相同的索引,
* 或者在新表中以两个偏移量的幂次移动。
*/
final Node<K,V>[] resize() {
//获取到老的需要扩容的数组
Node<K,V>[] oldTab = table;
//如果老的数组为空,则为oldCap赋值为0,不然则赋值为老的数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
/*
*设置oldThr接收阈值,这里说明一下这个threshold,如果是没有初始化过的
*数组,那么threshold会等于你外部传入的初始容器值并进行修改成2的n次方的值
*比如传10,那么会修改成2的4次方也就是16,这个threshould就是16.具体看下方
*的初始赋值方法。就在这个方法下面
*/
int oldThr = threshold;
//定义两个变量接收新产生的容量值和新的负载容量值(就是负载因子*容量,我统称负载容量值)
int newCap, newThr = 0;
//如果老的容量值大于0,这里代表如果有老的容量值,那么则不是初始化,而是扩容
if (oldCap > 0) {
//如果老的容量值大于等于2的30次
if (oldCap >= MAXIMUM_CAPACITY) {
//将负载容量值定义到证书最大值
threshold = Integer.MAX_VALUE;
//返回旧的数组
return oldTab;
}
//如果老的数组容量没有超过2的30次,则将老的容量*2赋值给新的数组长度
//然后判断新的数组长度是是否小于2的30次方,以及旧的容量是否有大于16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//将旧的负载容量*2 赋值给新的负载容量,结果和负载因子*新的数组长度是一致的
newThr = oldThr << 1; // double threshold
}
//这时候老的数组没有生成,是在初始化阶段,如果oldThr也就是我们初始化赋值的threshold
// 如果有进行初始化赋值的话
else if (oldThr > 0) // initial capacity was placed in threshold
//将新的容量 = 我们初始化赋值的容量
newCap = oldThr;
//再不然,就是初始化时没有进行赋值初始容量,那就按默认的初始容量
else { // zero initial threshold signifies using defaults
//static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// DEFAULT_INITIAL_CAPACITY 的值是16,也就是默认16个数组长度
newCap = DEFAULT_INITIAL_CAPACITY;
//static final float DEFAULT_LOAD_FACTOR = 0.75f;
//新的负载容量为负载因子*默认的长度 = 0.75 * 16 = 12
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//如果是我们有初始值初始化的情况下,新的负载容量是没有赋值的,所以需要为其赋值
if (newThr == 0) {
//将新的数组长度*负载因子
//好的那作为一名程序员,要有研究精神,我们知道负载因子是可以大于1的,如果大于1那会
//怎么样呢,我做了实验,我将负载因子设为10,但是数组长度设为2,结果得到了20的负载容量
//那么一样可以用,只是会让链表变得长而已,增加put和get的时间复杂度。
float ft = (float)newCap * loadFactor;
//如果新的数组长度 < 2的30次方 并且 算出来的负载容量 < 2的30次方
//那么就可以使用这个负载容量值,不然使用整数最大值作为负载容量
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//最后将计算出的负载容量值重新赋值给threshold,结束初始化数组长度和负载容量的过程
threshold = newThr;
//然后根据新获得的数组容量创建Node数组。
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//将新创建的数组赋值给table
table = newTab;
//如果老的数组不为空,则进行下面的扩容过程,如果是初始化,那么到这里就结束了,直接退出。
if (oldTab != null) {
//根据老的数组的长度,设置for循环
for (int j = 0; j < oldCap; ++j) {
//设置一个node节点e
Node<K,V> e;
//判断老的数组的j下标内的节点是否为空,如果不为空,赋值给e
if ((e = oldTab[j]) != null) {
//将原来老的数组的j下标赋为空,这时候数据已经在e节点那
oldTab[j] = null;
//如果e的下一个节点为空,则代表e是一个单节点
if (e.next == null)
//直接通过e的hash值与新数组长度取&运算得出新的数组下标位置存入
newTab[e.hash & (newCap - 1)] = e;
//如果e的节点类似是树节点,那么代表是红黑树
else if (e instanceof TreeNode)
//通过红黑树自定义的split方法进行判断节点迁移
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
//最后就是链表的迁移
//loHead是位子不需要变动的头节点
//loTail是位子不需要变动的当前节点,用来拼接下一个节点
//hiHead是位子需要变动的头节点
//hiTail是位子需要变动的当前节点,用来拼接下一个节点
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
//设置next节点代表下一个节点
Node<K,V> next;
//设置do While至少循环一次
do {
//通过next节点标注当前e的下一个节点
next = e.next;
//如果当前节点e的hash值 与 旧的桶数取&运算 == 0
//则代表这个节点在新的桶中位置没有发生变化
//可以直接迁移过去
if ((e.hash & oldCap) == 0) {
//如果loTail为空,代表还没有任何一个值
if (loTail == null)
//将e赋值给该链表的头节点
loHead = e;
//不然则代表有值
else
//将loTail的下一个赋值为当前节点
loTail.next = e;
//将loTail = e,进行下一次循环
loTail = e;
}
//如果哈希运算不为0则代表高位有值,那么在新的数组中需要发生变换位置
else {
//和之前一样,只是为了将链表分开而已
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
//如果next为空就跳出循环
} while ((e = next) != null);
//如果toTail不为空,这个是不需要变换位置的节点
if (loTail != null) {
//设置下一个节点为空
loTail.next = null;
//然后赋值到对应新数组的j下标中去,不需要变换位置
newTab[j] = loHead;
}
//这个是需要变换位置的节点
if (hiTail != null) {
//一样设置下一个为null
hiTail.next = null;
//因为新的数组的容量是旧的数组容量*2,所以老的数组的下标+老的数组长度
//就是等于新的数组的下标。
newTab[j + oldCap] = hiHead;
}
}
}
}
}
//最后返回新的数组
return newTab;
}
初始赋值方法
//构造方法:传入初始容量和负载因子
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//如果初始容量大于MAXIMUM_CAPACITY 代表2的30次,因为最大整数是2的31次-1,所以
//2的30次是顶峰,就将初始值赋给2的30次
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//将负载因子赋值
this.loadFactor = loadFactor;
//将我们传入的初始化容器值进行修改赋值给threshold
//这个方法会用精妙的方法将我们传入的初始值变为2的n次方
this.threshold = tableSizeFor(initialCapacity);
}
最后再来看一下对 threshold的官方解释
/**
* The next size value at which to resize (capacity * load factor).
* 要调整大小的下一个大小值(容量*负载因子)
* @serial
*/
// (The javadoc description is true upon serialization.
// Additionally, if the table array has not been allocated, this
// field holds the initial array capacity, or zero signifying
// DEFAULT_INITIAL_CAPACITY.)
//如果尚未分配表数组,则
//字段保留初始阵列容量,或零表示
//默认初始容量。
int threshold;
结束语
暂时就整理了这么多…其实一通百通的,只是一些细节上的东西可能会没有注意,看大佬们写的代码也是提升的一种方式。上文如果哪里讲的有问题,请在评论区告诉我(痛骂),接受批评!