源码分析_HashMap,扩容方法分析

本文详细剖析HashMap的数据结构,包括Node数组、链表与树结构的转换,重点讲解了容量初始化、扩容策略及哈希函数。同时揭示了为何初始容量选为16和负载因子设置为0.75的原因。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

HashMap

HashMap结构

transient Node<K,V>[] table;
...
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;// 哈希值
    final K key;	// 键值
    V value;		// 数据
    Node<K,V> next;// 下一个节点
    ...
}

HashMap内就是由多个(默认16个)链表元素组成的数组,

每个链表由由多个Node<K, V>对象组成,

每个Node包含上面四个属性。

HashMap常量

/**
 * 初始化时容器的大小,必须是2的幂,默认是16
 */
   static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/**
 * 容器最大容量
 */
   static final int MAXIMUM_CAPACITY = 1 << 30;

/**
 * 默认加载因子
 */
   static final float DEFAULT_LOAD_FACTOR = 0.75f;

/**
 * 树的阈值,当单链表达到阈值则开始进行树结构,JDK8开始
 */
   static final int TREEIFY_THRESHOLD = 8;

/**
 * 在哈希表扩容时,如果发现链表长度小于 6,则会由树重新退化为链表。
 */
   static final int UNTREEIFY_THRESHOLD = 6;

/**
 * 在转变成树之前,还会有一次判断,只有键值对数量大于 64 才会发生转换。
 */
static final int MIN_TREEIFY_CAPACITY = 64;

其他关键参数

transient Set<Map.Entry<K,V>> entrySet;  //储存 键对 的集合

transient int size;  //键对数

transient int modCount; //记录map的修改次数,为了线程安全
/*请注意,此异常并不总是表示对象已被其他线程同时修改。如果单个线程发出一系列违反对象约定的方法调用,则该对象可能会抛出此异常。例如,如果线程使用有fail-fast机制的迭代器在集合上迭代时修改了集合,迭代器将抛出此异常。
*/
int threshold; //数组扩容的阈值

final float loadFactor; //负载因子

函数源码

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

算法解释: key为空则返回0 , 否则取key的hash值赋值给h,h无符号右移16位 , h 和 h无符号右移16位 之后的值异或运算作为哈希值hash

(补充:java提供两种右移运算符

>> :算术右移运算符,也称符号右移。用最高位填充移位后左侧的空位。

>>>:逻辑右移运算符,也称无符号右移。只对位进行操作,用0填充左侧的空位)

getNode()
final Node<K,V> getNode(Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n, hash; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & (hash = hash(key))]) != null) {   //取出相应链表first
        if (first.hash == hash && // always check first node     //检查是否是对应链表
            ((k = first.key) == key || (key != null && key.equals(k)))) //查找首结点
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)     //树,则使用树的方法找
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);  //遍历查找链表
        }
    }
    return null;
}

简述过程:先用first = tab[(n - 1) & (hash = hash(key))])计算数组下标并取出该链表到first,

首先检查首节点,不符合要求则接下来若是树则调用树的((TreeNode<K,V>)first).getTreeNode(hash, key)方法查找Node,否则遍历查找链表查找Node

关键在于(n - 1) & (hash = hash(key)),计算出储存的对应链表,并保证了下标不会越界。

hash(key)返回的值仍非常大,因为n是2的倍数,是容量,

假如n为默认值16,n-1为15,化为二进制为00001111,再做按位与运算,保证了(n - 1) & (hash = hash(key))的范围在0-15,不会越界。

putVal()
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)// 首次插入数组为空,或者数组长度为0
        n = (tab = resize()).length;  //扩容
    if ((p = tab[i = (n - 1) & hash]) == null)// 数组中该下标的第一个链表节点,若是空的则新建节点
        tab[i] = newNode(hash, key, value, null);
    else {  //不空,哈希碰撞
        Node<K,V> e; K k;
        if (p.hash == hash &&  // key相同则替换原有数据
            ((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) {
                if ((e = p.next) == null) {  //插入链表末尾
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);// 如果哈希表长度大于等于8,则转换为树操作
                    break;
                }
                if (e.hash == hash &&// key相同情况,覆盖原值
                    ((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;
}

关键在于p = tab[i = (n - 1) & hash]) == null找出key对应的链表。

流程也大致与getNode相同,先找出对应链表,再检查首个结点,接着检查是否为树,最后遍历链表,不存在相同key就插入末尾,存在相同key就覆盖原值。

最后检查元素大于阈值,进行扩容操作resize();

每次put之后,会检测一下是否需要扩容,size超过了 总容量 * 负载因子,则会扩容。默认情况下,16 * 0.75 = 12个。

Node<K,V>[] resize()
    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;//首次初始化后table为Null
        int oldCap = (oldTab == null) ? 0 : oldTab.length;  //记录扩容前长度
        int oldThr = threshold;//默认构造器的情况下为0
        int newCap, newThr = 0;
        if (oldCap > 0) {//table扩容过(首次初始化时oldCap为0)
             //当前table容量大于最大值得时候返回当前table,并更新阈值为最大值
             if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
            //table的容量乘以2,threshold的值也乘以2           
            newThr = oldThr << 1; // double threshold
        }
        //table没扩容过,刚带有初始容量的初始化
        else if (oldThr > 0) // initial capacity was placed in threshold
        //使用带有初始容量的构造器时,table容量为初始化得到的threshold
        newCap = oldThr;
        //table没扩容过,刚默认构造器下进行扩容
        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) {
                HashMap.Node<K,V> e;
                if ((e = oldTab[j]) != null) {  //取出链表到e
                    // help gc
                    oldTab[j] = null;  
                    if (e.next == null)
                        // 当前index没有发生hash冲突,直接对2取模,即移位运算hash &(2^n -1)
                        // 扩容都是按照2的幂次方扩容,因此newCap = 2^n
                        newTab[e.hash & (newCap - 1)] = e;  //放在新的哈希表,e.hash & (newCap - 1)是新的哈希表的位置的下标
                    else if (e instanceof HashMap.TreeNode)
                        // 当前index对应的节点为红黑树,这里篇幅比较长且需要了解其数据结构跟算法,因此不进行详解,当树的个数小于等于UNTREEIFY_THRESHOLD则转成链表
                        ((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        // 把当前index对应的链表分成两个链表,减少扩容的迁移量
                        HashMap.Node<K,V> loHead = null, loTail = null;
                        HashMap.Node<K,V> hiHead = null, hiTail = null;
                        HashMap.Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                // 扩容后不需要移动的链表
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                // 扩容后需要移动的链表
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            // help gc
                            loTail.next = null;
                            newTab[j] = loHead;  //不需移动的链表复制引用
                        }
                        if (hiTail != null) {
                            // help gc
                            hiTail.next = null;
                            // 扩容长度为当前index位置+旧的容量
                            newTab[j + oldCap] = hiHead;  //需要移动的链表复制引用
                        }
                    }
                }
            }
        }
        return newTab;
    }

HashMap扩容可以分为三种情况:
1 ) : 使用默认构造方法初始化HashMap。HashMap在一开始初始化的时候会返回一个空的table,并且thershold为0。因此第一次扩容的容量为默认值DEFAULT_INITIAL_CAPACITY也就是16。同时阈值
newThr = DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR = 12

        else {  //默认构造器下进行扩容  
             // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);    
     }

2):指定初始容量的构造方法初始化HashMap。那么新阈值newThr = 当前的容量(newCap) * DEFAULT_LOAD_FACTOR(loadFactor)。

    if (newThr == 0) {
    //使用带有初始容量的构造器在此处进行扩容
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }

3):HashMap不是第一次扩容。如果HashMap已经扩容过的话,那么每次table的容量以及threshold量为原有的两倍。

    if (oldCap > 0) {//table扩容过
         //当前table容量大于等于最大值得时候返回当前table不再扩容,阈值调到最大
         if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
         else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
            oldCap >= DEFAULT_INITIAL_CAPACITY)
            //table的容量乘以2,threshold的值也乘以2           
            newThr = oldThr << 1; // double threshold
        }

复制过程

根据hash值把一个链表拆成两个链表,一个不需移动,另一个需要移动。

原来下标的范围0-15,取低四位即e.hash&(oldCap-1),

扩容后下标的范围0-31,取低5位即即e.hash&(newCap-1),若新取得第五位是1,就要移动,否则跟原下标值一样,不需移动。

比如原来下标为15的链表,现在拆成下标为15和31(+oldCap)的两个链表。

而当(e.hash & oldCap) == 0(取低位第5个)时该节点就是不用移动的。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LQBC6wLp-1660665682159)(D:\学习资料\笔记\watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl8zOTY2Nzc4Nw==,size_16,color_FFFFFF,t_70.png)]

当前是链表, JAVA7时还需要重新计算hash位(即e.hash&(newCap-1)), 但是JAVA8做了优化, 通过(e.hash & oldCap) == 0(正好取低第五位)来判断是否需要移位; 如果为真则在原位不动, 否则则需要移动到当前hash槽位 + oldCap的位置;

HashMap::resize的核心就是上图, 链表与红黑树的resize过程大同小异: 红黑树是把构建新链表的过程变为构建两颗新的红黑树, 定位table中的index都是用的 e.hash & oldCap == 0 来判断;
再来看下 e.hash & oldCap == 0为什么可以判断当前节点是否需要移位, 而不是再次计算hash;
仍然是原始长度为16举例:

   old:
   10: 0000 1010
   15: 0000 1111
    &: 0000 1010    
    
   new:
   10: 0000 1010
   31: 0001 1111
    &: 0001 1010    

从上面的示例可以很轻易的看出,e.hash & oldCap == 0取低位第5个1, 而这个变化的1刚好是oldCap, 那么只需要判断原key的hash这个位上是否为1: 若是1, 则需要移动至oldCap + i的槽位, 若为0, 则不需要移动;

而e.hash & (oldCap-1)取低四位,限制下标范围在0-15

这也是HashMap的长度必须保证是2的倍数的原因, 正因为这种环环相扣的设计, HashMap.loadFactor的选值是3/4就能理解了, table.length * 3/4可以被优化为(table.length >> 2) << 2) - (table.length >> 2) == table.length - (table.lenght >> 2), JAVA的位运算比乘除的效率更高, 所以取3/4在保证hash冲突小的情况下兼顾了效率;

红黑树部分暂时没看

注意的点

HashMap是不同步的,需要外部的同步机制

HashMap设计时就是没有保证线程安全的, 所以在多线程环境请使用ConcurrentHashMap;

HashMap无序的,但内部按照 Hash 码排序,每次输出顺序不变,不可指定排序方式

初始容量为16,容量必须为2的幂 ,默认负载系数(0.75)

1、为什么初始容量是16

当容量为2的幂时,上述n -1 对应的二进制数全为1,这样才能保证它和key的hashcode做&运算后,能够均匀分布,这样才能减少hash碰撞的次数。

至于默认值为什么是16,而不是2 、4、8,或者32、64、1024等,我想应该就是个折中处理,过小会导致放不下几个元素,就要进行扩容了,而扩容是一个很消耗性能的操作。取值过大的话,无疑会浪费更多的内存空间。因此在日常开发中,如果可以预估HashMap会存入节点的数量,则应该在初始化时,指定其容量。

2、为什么负载因子是0.75

也是一个综合考虑,如果设置过小,HashMap每put少量的数据,都要进行一次扩容,而扩容操作会消耗大量的性能。如果设置过大的话,如果设成1,容量还是16,假设现在数组上已经占用的15个,再要put数据进来,计算数组index时,发生hash碰撞的概率将达到15/16,这违背的HashMap减少hash碰撞的原则。

常用方法

  • HashMap() 构造一个空的 HashMap ,默认初始容量(16)和默认负载系数(0.75)。
  • HashMap(Map<? extends K,? extends V> m) 构造一个新的 HashMap与指定的相同的映射 Map
  • containsKey(Object key) 如果此映射包含指定键的映射,则返回 true
  • containsValue(Object value) 如果此地图将一个或多个键映射到指定值,则返回 true
  • entrySet() 返回此地图中包含的映射的Set视图。
  • keySet() 返回此地图中包含的键的Set视图。
  • values() 返回此地图中包含的值的Collection视图。
  • get(Object key) 返回到指定键所映射的值,或 null如果此映射包含该键的映射
  • getOrDefault(Object key, V defaultValue) 返回到指定键所映射的值,不存在则返回 defaultValue
  • put(K key, V value) 将指定的值与此映射中的指定键相关联。
  • putAll(Map<? extends K,? extends V> m) 将指定地图的所有映射复制到此地图
  • remove(Object key) 从该地图中删除指定键的映射(如果存在)。
  • replace(K key, V value) 只有当目标映射到某个值时,才能替换指定键的条目。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值