HashMap第2讲——put方法源码及细节

上篇文章介绍了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:希望对大家有所帮助,如果有纰漏或者更好的想法,请您一定不要吝啬你的赐教🙋。

  • 14
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

橡 皮 人

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值