hashMap底层实现解析(负载因子0.75,初始容量16,键不重复)

本文详细解析了HashMap的底层实现,包括为何负载因子设为0.75、初始容量选16的原因,以及如何通过哈希值和equals()保证键唯一性。还深入探讨了扩容机制及hash计算方法,揭示了其在时间和空间效率上的平衡策略。

目录

HashMap

为什么负载因子默认0.75呢?

为什么初始容量设置为16呢 ?

hashMap 如何保证键不重复?


HashMap

        我们知道 , hashMap是双列集合 , 存储的是键值对 , 键不能重复,值可以重复 , 是Map接口下面的实现类 , 底层采用哈希表+链表+红黑树实现 ,另外哈希表默认长度16,负载因子0.75, 扩容2倍,链表长度为8自动转为红黑树等等 , 接着来一一解释它们

        我们先来了解 hashMap 的底层到底是怎样去存储元素的, 如下图

      如图所示, 当我们去 put(key, value) 一个键值对的时候, hashMap会先根据 key 去计算一个hash值, 然后通过计算得到的hash值来决定我们put 这个元素在数组中的索引index

这个计算表达式为 : index = (length-1) & hash ,后面会提到

     通过上图可以看到,  a 元素是一个键值对,通过 一系列计算得出 在哈希表索引为 1(index ==1), 而b 计算出的索引 为 2 , 但是这时我们加入了一个新元素c(键为"通话",如果此时两个元素键相同的话,那么这里会用新value替换旧value,但键只有一个),这个元素计算出的hash值与我们a 元素的相同(哈希碰撞:不同的数据计算出的哈希值相同)索引都是1,这时我们则将c元素通过尾插法连接到a元素后面 ,如果这时又来了一个d元素,碰巧又产生了哈希碰撞,然后继续往我们的链表上继续相连 , 但是我们知道,如果这样一直连下去,链表越来越长, 那么我们查找元素算法耗费的时间也会越长 , 所以当链表的长度达到一定阈值的时候 , 我们将它转化为一颗红黑树, 这样可以降低算法的时间复杂度(O(logn))

      红黑树是一颗自平衡的二叉搜索树(这里不过多介绍,后续数据结构里会提到)

看到这里, 相信你对hashMap储存元素的特点也有了一定的了解,接着我们来看源码是怎样去做的

先来看这几个参数的作用:

//默认容量16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认负载因子0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表转换红黑树的阈值,8
static final int TREEIFY_THRESHOLD = 8;
//红黑树元素减少到了6个后,就退化成链表
static final int UNTREEIFY_THRESHOLD = 6;
//链表转红黑树的另一条件,数组长度需要达到64
static final int MIN_TREEIFY_CAPACITY = 64;
//存放Node结点的数组
transient Node<K,V>[] table;
//存放键值对
transient Set<Map.Entry<K,V>> entrySet;
//数组中的元素个数
transient int size;
//数组扩容阈值
int threshold;
//加载因子
final float loadFactor;

单链表节点类如下 :

//单链表结点类
static class Node<K,V> implements Map.Entry<K,V> {    
     final int hash;   //hash值
     final K key;  
     V value;
     Node<K,V> next;  //next域指向下一个元素
     //构造
     Node(int hash, K key, V value, Node<K,V> next) {
         this.hash = hash;
         this.key = key;
         this.value = value;
         this.next = next;
     }
}

put(k,v)方法如下 :

public V put(K key, V value) {
    // 通过key计算hash值后调用putVal()方法
    return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //如果table为空则触发扩容: 通过resize()方法
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //先通过(n-1)&hash 计算得到的下标 得到在数组中的位置
        //如果为null 则直接构造一个新结点
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            // 此位置如果不为 null 则分这几种情况
            Node<K,V> e; K k;
            // 如果传入的hash值与此位置hash值相同,并且key也相同
            // 那么此时我们采用值覆盖的做法,用新value覆盖旧value
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            //如果这时此位置已经转换为了一颗红黑树
            //我们将它添加到红黑树中
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            // 普通链表形态,采用尾插法将元素连接
            else {
                for (int binCount = 0; ; ++binCount) {
                    // 如果结点后为null,则构建新结点连接
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // 链表长度达到阈值,此时转换为红黑树
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            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;
        //数组元素超过阈值,调用resize()扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

看完 put(k,v) 方法后,  我们便看见了扩容的问题,那么扩容机制是怎样的呢?

 这里我们先来看计算hash值方法

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

我们看到 , hashMap的键是可以取空值的

      我们在上面提到的计算索引值 :  index = (length-1) & hash , 如果发生了扩容,那么意味着length的值发生了改变,那么原先的index肯定是不能继续使用了,这时我们需要计算新的index将原来数组的元素分配到新数组中去

有了以上这些基础,我们再来回答下列问题

为什么负载因子默认0.75呢?

        这其实是时间与空间的权衡 , 如果设置成0.5, 那么空间利用率太小,造成空间浪费, 而如果设置成0.8,或者0.9 ,那么发生哈希碰撞的可能将会大大增加,影响效率

为什么初始容量设置为16呢 ?

        源码中是这样的 : static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

        我们发现, 它并没有直接写16,而是通过位运算的方式得出16,再联系上面的式子index = (length-1) & hash,  我们先随便计算一个hash值 ,通过hashCode()方法得出

 "通话"的二进制hash码 : 1 0001 1111 1111 0000 0011      length-1 =15   15的二进制码为1111

这时我们做 & 运算,可以发现最后得到的二进制码 就跟计算得出的hash后四位一样,这时我们发现这样一个规律,当容量为2的倍数时, length -1 的值的二进制所有位都是 1 ,那么这时计算出的index便可由 计算出的hash码决定 ,所以只要 hashCode本身分布比较均匀的话 , 那么发生哈希碰撞的可能就会大大减少

        那为啥不是8 或者 36呢?

因为 8 未免太小 , 扩容也需要将所有元素重排, 36太大,也可能空间浪费 , 所以权衡之下, 16作为一个比较标准的初始容量就保留了下来

hashMap 如何保证键不重复?

        hashCode() 与 equals() , 我们知道, 我们是通过 key(键),来计算哈希值的, 但是如果产生了哈希碰撞 , 那么此时便需要通过 equals() 来判断这两个键是否相等 , 所以我们一般在重写equals()时一般也重写hashCode(), 确保产生的哈希值合理

关于equals()与hashCode()可参考:认识和了解Object类_xx12321q的博客-CSDN博客

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值