【Java集合框架】3 - 12 HashMap 源码分析

§3-12 HashMap 源码分析

3-12.1 HashMap 中定义的字段与类

HashMap 通过以下的类记录表中的数据结点:

  • NodeHashMap 的内部类,实现了 Map 接口中的 Entry 接口,用于记录大多数的条目;

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
    
        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
        
        ...
    }
    

    类中有字段分别用于记录结点的哈希值、键、值和后继结点。

  • TreeNodeHashMap 的内部类,继承自 LinkedHashMap 中的内部类 Entry,用于记录红黑树的结点;

    static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
        
        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }
        
        ...
    }
    

    类中有字段分别用于记录树结点的父结点、左右子结点以及颜色等。

  • LinkedHashMap.EntryLinkedHashMap 的内部类,继承自 HashMap.Node,用于普通 LinkedHashMap 的条目;

    static class Entry<K,V> extends HashMap.Node<K,V> {
        Entry<K,V> before, after;
        Entry(int hash, K key, V value, Node<K,V> next) {
            super(hash, key, value, next);
        }
    }
    

    该类无特殊字段与方法,直接继承父类的对应成员。

除此之外,HashMap 还具有以下字段:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;	//默认初始容量,即 16
static final float DEFAULT_LOAD_FACTOR = 0.75f;	//默认加载因子
static final int MAXIMUM_CAPACITY = 1 << 30;	//最大容量
static final int TREEIFY_THRESHOLD = 8;			//树形化阈值
transient Node<K,V>[] table;		//数组

解释

  1. 为什么选择以 \(2^n\) 为数组长度?

    \(2^n\) 作为容量,减少哈希冲突概率,实现键的均匀存放。若指定了初始容量,则实际容量将会调整为最接近指定容量的 2 的幂。

    对于哈希值以及索引计算等有关解释见后文(putVal 处)。

  2. 为什么默认加载因子为 0.75?

    加载因子就是哈希表中实际存储元素个数 \(n\) 与哈希表长度 \(m\) 的比,即 \(\alpha = {n\over m}\)

    加载因子越小,空间利用率越小,哈希冲突概率更小;加载因子越大,空间利用率越大,哈希冲突概率越大。实际一般使得 \(\alpha \in [0.6, 0.9]\)

    默认加载因子取 0.75 是因为查询在时间和空间上的平衡点为 0.75

3-12.2 无参构造与 put 添加元素

首先,我们调用无参构造器创建 HashMap 对象:

HashMap<String, Integer> hm = new HashMap<>();

该方法的代码实现为:

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

方法内部仅仅只是将数组的加载因子设置为默认加载因子,甚至并未初始化数组,此时 table == null

创建完成后,往表中添加第一个元素,put 方法的实现为:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

方法在内部调用了 putVal 方法,在解释这一方法前,先看看这些形参的意义。

putVal 方法的声明以及对这些形参的解释如下:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
  • int hash:键的哈希值,通过 hash 方法获得,该方法的解释见下文;
  • K key, V value:数据元素的键与值;
  • boolean onlyIfAbsent:若为 true,则不改变现存值;此处传递 false,导致了 put 方法的覆盖行为;
  • boolean evict:暂且忽略;
  • put 方法返回值是被覆盖的值;

3-12.3 putVal 底层实现

请记住,HashMap 在底层使用了哈希表这种数据结构,并通过单链表和红黑树解决哈希冲突的问题。在阅读源码时,应当时刻将这种数据结构记于心中,并能够通过源码呼应数据结构中的有关运算。

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)
        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 &&
            ((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);
                    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;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    
    return null;
}

在正式开始前,请牢记,数组当中的元素应当是情况讨论,可能是链表的头结点(Node),也可能是红黑树的头结点(TreeNode)。

接下来将逐句分析 putVal 的执行过程。

3-12.4 putVal 完整注释

此处将实现细节以单行注释的形式逐句表示在完整方法中,提高可读性。

//由 put 在内部调用 putVal,传入参数 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;			//尚未初始化,n 将表示数组长度,i 将表示索引
    
    //若为首次添加元素,空参构造并未初始化数组,此时数组为空,进入该分支内部
    if ((tab = table) == null || (n = tab.length) == 0)
        //调用 resize 方法调整数组大小,并将新数组长度赋予 n
        n = (tab = resize()).length;
    /*
    resize 方法的执行逻辑:
    1. 若为首次往空数组中添加元素,则将创建一个具有默认容量(16)和默认加载因子(0.75)的数组;
    2. 若数组不为空,则判断是否达到扩容阈值(阈值 = 数组容量 * 加载因子,当数组内元素个数达到该数目时扩容);
    3. 若达到扩容条件,则将数组扩容为原来的 2 倍,并将原数组的内容复制到新数组中;
    4. 若未达到扩容条件,则无需进行任何操作;
    */
    
    //计算待添加结点(数据元素)的哈希地址,并让 p 指向该地址
    //判断数组中该地址的元素是否为空
    if ((p = tab[i = (n - 1) & hash]) == null)
        //若该地址为空,则直接添加,newNode 底层实际上就是调用了 Node 的构造器
        tab[i] = newNode(hash, key, value, null);
    else {
        //数组中该地址不为空,已有元素占用
        Node<K,V> e;	//临时结点变量,尚未初始化,为 null
        K k;			//临时键变量,存储键内容,尚未初始化,为 null
        
        //判断哈希表中的 p 地址处元素是否重复(仅与键有关)
        if (p.hash == hash &&	//判断哈希值是否相同
            //再调用 equals 比较内部成员属性值(需要重写),尽可能避免哈希冲突
            ((k = p.key) == key || (key != null && key.equals(k))))
            //若发现重复元素,则让 e 指向该重复元素
            e = p;
        
        //若数组中元素不重复,则应进入链表或红黑树中进一步判断
        //判断是否位于红黑树根结点
        else if (p instanceof TreeNode)
            //若为红黑树根结点,则按照红黑规则遍历数组并用相同方法比较是否重复(仅与键有关)
            //若不重复,则添加到红黑树中,e 仍为 null
            //若重复,e 指向重复元素
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            //若为链表头结点,则遍历链表
            for (int binCount = 0; ; ++binCount) {
                //若未发现重复元素,则在表尾添加
                if ((e = p.next) == null) {
                    //e 和 p.next 在添加完成后不一致,e 为 null
                    p.next = newNode(hash, key, value, null);
                    //判断是否达到转换为红黑树的条件,此处先判断链表长度是否 大于8
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        //treeifyBin 内部还会进一步判断数组长度是否大于等于64
                        //二者同时满足才发生转换
                        treeifyBin(tab, hash);
                    //添加、转换完成,退出循环,e = null
                    break;
                }
                //检查是否重复
                if (e.hash == hash &&
                    //再调用 equals 比较内部成员属性值(需要重写),尽可能避免哈希冲突
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                
                //依赖这一语句实现循环的迭代,不断遍历链表
                p = e;
            }
        }
        
        //若 e = null,则未找到重复元素,无需覆盖
        //否则,则应当考虑是否覆盖
        if (e != null) { // existing mapping for key
            //找到重复元素
            V oldValue = e.value;	//记录重复项的旧值
            if (!onlyIfAbsent || oldValue == null)	//方法传入 onlyIfAbsent = false,应当覆盖
                e.value = value;	//修改重复项的值
            afterNodeAccess(e);		//方法具有空实现,忽略
            return oldValue;		//返回旧值
        }
    }
    
    //未找到重复元素,则方法执行到此处,为首次添加该元素
    ++modCount;		//修改结构性修改次数
    if (++size > threshold)	//更新哈希表元素个数,并判断是否需要扩容
        resize();
    afterNodeInsertion(evict);	//方法具有空实现,忽略
    
    return null;	//没有发生修改(覆盖),则返回空
}

解释

  1. 为什么要在链表长度大于等于 8、数组长度大于 64 时改用红黑树?

    红黑树插入的维护开销非常大,而每一次插入元素都有可能破坏其平衡,就需要在每次插入后进行维护,每时每刻都需要再次恢复平衡(改变结点颜色以及旋转,一般优先选择改变颜色)。当 HashMapput 操作非常多时,极有可能影响插入性能。

  2. 为什么哈希地址(哈希表中的数组索引)要如此计算(index = (arr.length - 1) & hash)?

    此部分内容引用自狂神的博客(现已删除),有修改。

    引用的内容截取自视频JavaSE总结_哔哩哔哩_bilibili

    一般而言构建哈希函数的方法有直接定址法和除留余数法等。而除留余数法计算简单,适用范围广,是经常使用的一种哈希函数。

    除留余数法的计算方式是用关键字 \(k\) 对一个不大于哈希表长度的整数 \(p\) 取余,即 \(h(k) = k \mod{p}\),一般而言,\(p\) 取质数效果较好。但在 HashMap 中,采用的是 hash % length。但这种运算不如位运算快,因此,源码做了优化,得到 (length - 1) & hash

    计算机做数值运算时,计算效率加减法 > 乘法 > 除法 > 取模,驱魔效率最低,因此要极力避免。而哈希地址是通过取模运算获得,考虑到 HashMap 在不停地扩容,扩容有会涉及到数组移动,每一次移动都要重新计算索引,迁移大量元素,就会大大影响效率。直接使用与运算,效率是远高于取模运算的。

    而采用以 2 为底的指数 n 作为数组长度,该整数转换为二进制后就是 1 其后跟上多个 0,在此基础上 -1,就得到了 n-1 个 1。

    例如数组长度为 8 时,3 & (8-1) = 35 & (8-1) = 5 无哈希冲突。但若长度为 9 的时候,3 & (9-1) = 05 & (9-1) = 0,发生了哈希冲突。因此,保证容量(数组长度)为 2 的幂,是为了保证做 length - 1 时,每一位都能 & 1(掩码)。

  3. HashMap 的扩容死循环问题是如何产生,又是如何解决的?

    简而言之,JDK 7 及以前采用头插法实现扩容,在高并发场景下扩容可能发生死循环问题。JDK 8 及以后采用尾插法解决了此问题。

3-12.5 hash 方法计算哈希值

putVal 的第一个参数就是键的哈希值,调用的是 hash 方法,实现如下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    //允许键为空,若为空则哈希值为 0
}

数据元素的哈希值完全仅基于键计算,与值毫无关系。内部还调用了 hashCode 方法,并基于 hashCode 方法所得值计算键最终的哈希值。

当键不为空时,将会执行后半部分的异或运算,其本质是高 16 位异或低 16 位,这样做是为了减少哈希冲突概率;

下文内容源自于狂神说的博客(现已删除),截取自视频JavaSE总结_哔哩哔哩_bilibili

hashCode: 1954974080 111 0100 1000 0110 1000 1001 1000 0000
2^4 - 1 = 15 (length -1) 000 0000 0000 0000 0000 0000 0000 1111
& 与运算 000 0000 0000 0000 0000 0000 0000 0000

而使用高低 16 位的异或运算后:

原 hashCode 1954974080 111 0100 1000 0110 1000 1001 1000 0000
>>> 16 无符号右移 16 位 29830 000 0000 0000 0000 0111 0100 1000 0110
^ 异或运算 1955003654 111 0100 1000 0110 1111 1101 0000 0110
2^4 - 1 = 15 (length - 1) 15 000 0000 0000 0000 0000 0000 0000 1111
& 与运算 6 000 0000 0000 0000 0000 0000 0000 0110

两种情况所计算出来的哈希地址明显不同,前者直接使用 hashCode & (length - 1) 得到 0,后者使用异或运算后得到 6,减少了碰撞概率。

3-12.6 resize 扩容

resize 用于扩容数组,其实现为:

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;		//记录旧数组
    int oldCap = (oldTab == null) ? 0 : oldTab.length;	//记录旧容量
    int oldThr = threshold;			//记录旧阈值
    int newCap, newThr = 0;			//新容量、新阈值
    
    //若旧数组不为空,即长度不为零
    if (oldCap > 0) {
        //若数组已达到最大限制,不扩容,修改阈值为整型最大值返回旧数组
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //数组未达到最大限制,初始化新容量为旧的 2 倍
        //若新容量位于最大容量和默认容量间
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // 加倍阈值
    }
    //若数组为空,即长度为零
    else if (oldThr > 0) //初始容量存储在阈值中,则将新容量初始化为原有的阈值
        newCap = oldThr;
    else {               //若有零阈值(旧的),则使用默认配置
        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) {
            Node<K,V> e;	//临时变量,用于记录数组中结点
            
            //初始化临时变量,指向所遍历到的数组中结点
            //若不为空
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;		//旧数组中的指针与结点对象脱离
                //判断是否存在链表
                if (e.next == null)
                    //不存在链表,重新计算哈希地址放进新数组中
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    //存在红黑树(属于树的根结点),将整棵树移动到新数组中
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // 保留顺序
                    //存在链表
                    Node<K,V> loHead = null, loTail = null;	//低链表
                    Node<K,V> hiHead = null, hiTail = null;	//高链表
                    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 {							//为 1,高链表
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);	//遍历链表
                    
                    if (loTail != null) {
                        loTail.next = null;	//将低链表的尾结点指针设为空
                        newTab[j] = loHead;	//将低链表放在新数组中原数组的位置
                    }
                    if (hiTail != null) {
                        hiTail.next = null;	//将高链表的尾结点指针设为空
                        newTab[j + oldCap] = hiHead;	//将高链表放在新数组中原数组的位置 + 旧数组容量处
                    }
                }
            }
        }
    }
    
    //返回新数组
    return newTab;
}

3-12.7 指定初始容量的构造方法

可以往构造器中传入一个整型参数指定映射表的初始容量,该方法的实现为:

public HashMap(int initialCapacity) {
    //调用了指定初始容量和加载因子的构造方法
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

方法调用了另一个双参数的重载,该重载的实现为:

public HashMap(int initialCapacity, float loadFactor) {
    //判断初始容量是否非法
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    
    //若超出最大容量限制,则将初始容量设为最大容量
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    
    //判断加载因子是否非法
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    
    this.loadFactor = loadFactor;		//设置加载因子
    this.threshold = tableSizeFor(initialCapacity);	//设置扩容阈值
}

设置阈值时,调用了 tableSizeFor,追踪该方法:

/**
 * Returns a power of two size for the given target capacity.
 * 返回一个接近目标容量的 2 的幂。
 */
static final int tableSizeFor(int cap) {
    //计算二进制表示下,指定数字的开头零个数
    int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

计算开头零个数的方法实现如下:

public static int numberOfLeadingZeros(int i) {
    // HD, Count leading 0's
    if (i <= 0)
        //为零则直接返回 32(整型位数),负数返回 0
        return i == 0 ? 32 : 0;
    
    //对于一个正数
    int n = 31;		//至多有 31 位个 0
    //通过不断缩小范围,减去自首个非零位开始的位数,确认个数
    if (i >= 1 << 16) { n -= 16; i >>>= 16; }
    if (i >= 1 <<  8) { n -=  8; i >>>=  8; }
    if (i >= 1 <<  4) { n -=  4; i >>>=  4; }
    if (i >= 1 <<  2) { n -=  2; i >>>=  2; }
    
    //最后在减去 1,即得到开头零的个数
    return n - (i >>> 1);
}

注意,方法传入的实际为 cap - 1,实际上回到 tableSizeFor 时,目标就是让找到的目标值大于等于原来指定的值。

至此,对象创建完毕。首次添加元素时会调用 resize 方法对空数组扩容,原数组为空,则用原数组的阈值视为新数组的容量。

因此,若指定了容量,实际容量会调整为最接近该值的 2 的幂,使得数组长度始终保持为 2 的幂。

posted @ 2023-08-12 13:07  Zebt  阅读(20)  评论(0)    收藏  举报