上篇文章介绍了HashMap在JDK 1.8前后的四大变化,今天就进入到put方法的源码解析。HashMap的设计非常巧妙,细节也很多,今天来看看部分细节,后续的文章会一一介绍。
ps:学习源码的目的不仅仅是为了了解它的运行机制,更重要的是学习它的思想和编码技巧,每一行的源码都可能都经过了“千锤百炼”,才得以呈现在大家眼前。
一、put方法流程图
先上流程图,如下:
二、put方法源码注释
ps:以下代码,JDK版本均为1.8,如有别的版本会有说明。
2.1 几个重要的参数
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
private static final long serialVersionUID = 362498820763181265L;
//默认长度为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大长度为2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认的负载因子为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表-->红黑树条件之一:链表长度大于等于8
static final int TREEIFY_THRESHOLD = 8;
//链表-->红黑树另一个条件:数组长度大于等于64
static final int MIN_TREEIFY_CAPACITY = 64;
//红黑树-->链表条件:链表长度小于等于6
static final int UNTREEIFY_THRESHOLD = 6;
//存储元素的数组,总是2^n
transient Node<K,V>[] table;
//存放具体元素的集合
transient Set<Map.Entry<K,V>> entrySet;
//存放元素的个数,注意这个不等于数组的长度
transient int size;
//修改次数
transient int modCount;
//临界值,当实际大小(容量*负载因子)超过这个值,会进行扩容
int threshold;
//加载因子
final float loadFactor;
}
2.2 put()方法
put方法很简单,就一行代码:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
核心是putVal方法,在执行putVal方法之前先调用了hash(key)方法获取了一下hashCode。
我们来看下putVal方法:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//当前hash散列表的引用
Node<K,V>[] tab;
//散列表中的元素
Node<K,V> p;
//n:数组长度,i:数组索引(寻址的结果)
int n, i;
if ((tab = table) == null || (n = tab.length) == 0){
//说明table还没初始化,调用resize进行扩容
//懒加载:如果在初始化的时候就创建散列表,势必造成空间的浪费
n = (tab = resize()).length;
}
if ((p = tab[i = (n - 1) & hash]) == null){
//说明寻址到的桶的位置没有元素,说明没出现hash冲突
//那么就直接将key-value封装到Node中并放到下标为i的位置。
tab[i] = newNode(hash, key, value, null);
} else {
//说明该位置有数据了,也就是产生hash冲突了
//看看散列表中的元素的key值是否和插入的key一样
//一样就赋值,不一样就为null(下面要用)
Node<K,V> e;
//临时的key
K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))){
//说明当前桶的key值与要插入的key值一样,给e赋值
e = p;
} else if (p instanceof TreeNode){
//说明已经树化了。
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
} else {
//链表
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//说明是最后一个元素了
//也说明元素在链表中也没有重复,那么就直接添加到链表尾部
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1){
//当前元素已经是7了,再来一个就是8
//那么就需要进行扩容或者转为红黑树了
treeifyBin(tab, hash);
}
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))){
//说明找到了和插入元素一样的元素了,直接结束循环
break;
}
p = e;
}
}
if (e != null) {
//赋值为原来旧值(也就是散列表中的值)
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null){
//onlyIfAbsent为false
//替换为新插入的value值
//ps:putIfAbsent()方法该参数为true
e.value = value;
}
afterNodeAccess(e);
return oldValue;
}
}
//修改一次散列表结构,那么modCount++
++modCount;
//ps:并发场景下++操作会导致size小于真实个数
if (++size > threshold){
//添加后元素个数大于扩容阈值,进行扩容
resize();
}
//啥也没干(空方法)
afterNodeInsertion(evict);
//原位置没有值,返回null
return null;
}
三、hash()方法解读
该方法的功能是根据key的hashCode来定位传入的K-V在数组的索引位置,最简单的办法就是调用Object的hashCode()的方法,然后根据返回值再对数组长度-1进行取模(%)就行。
但是HashMap没有这么做(当然也不会这么做😂),下面我们来看看HashMap 1.7和1.8的实现:
//JDK 1.7
final int hash(object k) {
int h = 0;
if (useAltHashing) {
if (k instanceof string) {
return sun.misc.Hashing.stringHash32((String) k);
}
h = hashSeed;
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
//1.7计算索引位置是单独一个方法
static int indexFor(int h,int length){
return h & (length-1)
}
//JDK 1.8
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
为了提高hash方法的效率,主要采用了两种手段。
3.1 使用&代替%
先说一下计算索引位置的优化,也就是hash&(length-1)。
我们知道位运算(&)是直接对内存数据进行操作,不需要转为十进制,所以效率高的多。但是,位运算真的能实现取模运算吗?
有这样一个公式——X%2^n = X&(2^n-1)
也就是说,一个数对2的n次幂取模就 == 这个数与2的n次幂-1进行与运算。
假设X=10,n=3,则10%8=2,10&7=2:
记住这个公式就行,大家也可以去多试试加深印象。
ps:所以,这也是为什么HashMap的容量要设为2^n,因为不是2^n的话就不能用位运算来计算索引的位置了。(后续的文章再聊)
除了性能之外,还有一个好处就是可以很好的解决负数的问题:我们知道hashCode的结果是int类型,而它的取值范围是-2^31~2^31-1,这里面是包含负数的,如果用取模处理负数是很麻烦的,而如果用位运算,length-1一定是的正数,所以它的第一位一定是0,这就保证了h&(length-1)的结果一定是个正数。
3.2 扰动计算
经过3.1的介绍,现在我们的公式就变为key.hashCode() & (length-1),显然HashMap也不是这样做的,取的是将key的hashCode右移+异或运算(^)的结果。
那么为啥要这样做,如果直接用key.hashCode的呢?我们举个例子:
假设数组长度为8,如上图,那么结果只取决于hash值的低三位,无论高位如何变化,结果都是一样的,所以产生hash冲突的几率就比较大。
而如果我们把高位参与运算,则索引的计算结果就不会仅取决于低位,如下图:
可以看到的到的结果就不一样了,所以不论是JDK 1.7还是JDK1.8的扰动计算,目的都是为了让高位参与运算,尽量减少hash冲突。
四、如何解决hash冲突
hash冲突是不可避免的,那么通常怎么解决呢?这里简单介绍5种常用的方法,感兴趣的可以去深入了解一下:
开放定址法(ThreadLocalMap):一旦发生冲突,就去找下一个为空的散列地址,直到找到位置。
链地址法(HashMap):每个哈希桶指向一个链表,发生冲突时,新的元素会挂到链表末尾或放到红黑树相应的位置。
再哈希:当发生冲突时,使用其它函数计算另一个哈希函数地址,直到没冲突。
建立公共溢出区:将哈希表分为基本表和移除表两部分,发生冲突的元素都放在溢出表中。
一致性哈希:通过将数据均匀分布到多个节点来减少冲突。
End:希望对大家有所帮助,如果有纰漏或者更好的想法,请您一定不要吝啬你的赐教🙋。