【Java】集合框架源码学习(二)

HashMap是Java中常用的键值对存储结构,基于哈希表实现。本文详细介绍了HashMap的内部结构,包括红黑树、扩容机制、哈希函数以及Entry节点的定义。HashMap在扩容时,通过(e.hash&oldCap)==0判断是否需要转移节点,确保在新旧数组间保持原有的顺序或分布。此外,还探讨了负载因子和阈值对性能的影响。
摘要由CSDN通过智能技术生成

1. Map

在这里插入图片描述

2. AbstractMap

在这里插入图片描述

这两个类就偷懒了,因为是接口,对的都是通用方法,一般看名字就知道意思了

这里简单介绍下 Map,就是键值对的集合,每一组键值对,都封装成了一个 Entry,每个 Entry 包括 keyvalue

11. 红黑树简介

红黑树,Red-Black Tree 「RBT」是一个自平衡(不是绝对的平衡)的二叉查找树(BST),它的左右子树高差有可能大于 1,树上的每个结点都遵循下面的规则:

  1. 每个结点都有红色或黑色
  2. 树的根始终是黑色的 (黑土地孕育黑树根, )
  3. 所有叶子都是黑色(叶子是 null 结点)
  4. 没有两个相邻的红色节点(红色节点不能有红色父节点或红色子节点,并没有说不能出现连续的黑色节点
  5. 从节点(包括根)到其任何后代NULL节点(叶子结点下方挂的两个空节点,并且认为他们是黑色的)的每条路径都具有相同数量的黑色节点

在这里插入图片描述

12. HashMap

这个比较重要,就从最开始的注释开始看起了:


基于哈希表的 Map 接口实现。这个实现提供了所有可选的映射操作,并允许空值和空键。(HashMap 类大致相当于 Hashtable,只是它是非同步的,允许为空。)这个类不保证映射的顺序;特别是,它不能保证顺序会随时间保持不变。

假设哈希函数将元素适当地分散在桶中,这个实现为基本操作(get 和 put)提供了常数时间的性能。

如果迭代的性能要求较高,就不要将初始容量设置得太高(或负载因子过低)


HashMap 有 2 个参数会影响它的性能:

  • 初始容量(容量是哈希表中的桶数,初始容量就是创建哈希表时的容量)

  • 负载因子(负载因子是在自动增加容量之前允许哈希表满到什么程度的度量)

    当哈希表中的条目数超过负载因子和当前容量的乘积时,将会进行扰动,使得 Hash 表的桶数变成大约 2 倍

    默认的负载因子是 0.75,较高的值会减少空间开销,但会增加查找成本

    在设置映射的初始容量时,应该考虑映射中预期的条目数及其负载因子,以便最小化重哈希操作的数量

下面我们来看看源码,首先是一些阈值常量:

// 默认容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  // 其实就是 16

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

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

// 桶的链表有 8 个以上时就要使用树结点了,也就是说启用红黑树
static final int TREEIFY_THRESHOLD = 8;

// 如果已经是红黑树结构了,在结点数小于 6 个时,将还原成普通结点
static final int UNTREEIFY_THRESHOLD = 6;

// 最小树形化容量阈值,即当 Hash 表容量大于 64 时,才允许树形化链表
// 否则,若桶内元素太多时,则直接扩容,而不是树形化
// 为了避免进行扩容、树形化选择的冲突,这个值不能小于 4 * TREEIFY_THRESHOLD
static final int MIN_TREEIFY_CAPACITY = 64;

基本的几个属性已经看过了,下面看常规情况下的 Hash 表的存储单元

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;
    }

    public final K getKey()        { return key; }
    public final V getValue()      { return value; }
    public final String toString() { return key + "=" + value; }

    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }

    public final boolean equals(Object o) {
        if (o == this)
            return true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            if (Objects.equals(key, e.getKey()) &&
                Objects.equals(value, e.getValue()))
                return true;
        }
        return false;
    }
}

目前为止还好,这个结点定义的还算简单,继续…

下面是一堆静态工具代码

  • hash()

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

    这里首先是三目运算:(key == null) ? 0 : (h = key.hashCode()),键值为 null hash 值固定为 0,否则,令 h 等于 key 的 hash 值,然后执行 h ^ (h >>> 16)。这里注意一下,>>> 是无符号右移,高维填充的都是 0,这里实际上就是将hash 值的高 16 位右移,作为低 16 位,与原 hash 值进行抑或运算,其结果作为最终的 hash 值。

  • comparableClassFor()

    当 put 一个新元素时,如果该元素键的 hash 值小于当前节点的 hash 值的时候,就会作为当前节点的左节点;hash 值大于当前节点hash 值得时候作为当前节点的右节点。那么 hash 值相同的时候呢?这时还是会先尝试看是否能够通过 Comparable 进行比较一下两个对象(当前节点的键对象和新元素的键对象),要想看看是否能基于 Comparable 进行比较的话,首先要看该元素键是否实现了 Comparable 接口,此时就需要用到 comparableClassFor() 方法来获取该元素键的 Class,然后再通过compareComparables() 方法来比较两个对象的大小。

    /**
    * 如果对象 x 的类是 C,如果 C 实现了 Comparable<C> 接口,那么返回 C,否则返回 null
    */
    static Class<?> comparableClassFor(Object x) {
        if (x instanceof Comparable) {
            Class<?> c; Type[] ts, as; Type t; ParameterizedType p;
            if ((c = x.getClass()) == String.class) // 如果x是个字符串对象
                return c; // 返回String.class
            /*
             * 为什么如果 x 是个字符串就直接返回 c 了呢 ? 因为 String 实现了 Comparable 接口,可参考如下String类的定义
             * public final class String implements java.io.Serializable, Comparable<String>, CharSequence
             */ 
     
            // 如果 c 不是字符串类,获取 c 直接实现的接口(如果是泛型接口则附带泛型信息)    
            if ((ts = c.getGenericInterfaces()) != null) {
                for (int i = 0; i < ts.length; ++i) { // 遍历接口数组
                    // 如果当前接口 t 是个泛型接口 
                    // 如果该泛型接口 t 的原始类型 p 是 Comparable 接口
                    // 如果该 Comparable 接口 p 只定义了一个泛型参数
                    // 如果这一个泛型参数的类型就是 c,那么返回 c
                    if (((t = ts[i]) instanceof ParameterizedType) &&
                        ((p = (ParameterizedType)t).getRawType() ==
                            Comparable.class) &&
                        (as = p.getActualTypeArguments()) != null &&
                        as.length == 1 && as[0] == c) // type arg is c
                        return c;
                }
                // 上面for循环的目的就是为了看看 x 的 class 是否 implements  Comparable<x的class>
            }
        }
        return null; // 如果c并没有实现 Comparable<c> 那么返回空
    }
    
  • compareComparables()

    /**
    * 如果x所属的类是kc,返回k.compareTo(x)的比较结果
    * 如果x为空,或者其所属的类不是kc,返回0
    */
    @SuppressWarnings({"rawtypes","unchecked"}) // for cast to Comparable
    static int compareComparables(Class<?> kc, Object k, Object x) {
        return (x == null || x.getClass() != kc ? 0 :
                ((Comparable)k).compareTo(x));
    }
    
  • tableSizeFor()

    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
    

    初看是不是一脸懵逼 0.0,我们拆开看就清楚了:

    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        int temp;
        
        temp = n >>> 1
        n |= temp;
        
        temp = n >>> 2
        n |= temp;
        
        temp = n >>> 4;
        n |= temp;
        
        temp = n >>> 8;
        n |= temp;
        
        temp = n >>> 16;
        n |= temp;
        
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
    

    虽然过程明白了,但是它想干嘛呢?

    给定一个 int 类型的整数 n,如何求出不小于它的最接近的 2 的整数幂 m,比如给定 10 得出 16,给定 25 得出 32?

    我估计大家还是

    在这里插入图片描述

    回顾 HashMap 中的需求我们知道,这个方法属于很基础的方法,将在初始化或者添加时被大量执行,这就要求方法本身一定要高效。

    言归正传,对任意十进制数可以表示成:

    在这里插入图片描述

    其中 X 表示 0 或 1,如果想求大于它的最小 2 的整数次幂,应该是这样:

    在这里插入图片描述

    而下面的这个是怎么得到的呢?

    在这里插入图片描述

    这也就解释为啥开头要先减一了!

    然后中间的过程就是通过不断右移加或运算,使得从最高位 1 开始之后的每一位全变成 1

    最后的三目运算做了一些异常处理:

    (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1
    

    n < 0 表明溢出了,直接返回 1,如果正常范围,那就执行:

    (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1
    

    这里是和最大容量对比,显然不能超过最大容量的,如果在范围内,则进行加 1,得到 2 进制的整数幂

    可能有人纠结为啥要减一,直接操作不好吗 ,但是这样会有意外,比如输入 8 ,如果不减 1,结果是 16,可实际上应该是 8

再往下就是属性了:

// Hash表,第一次使用时初始化,并根据需要调整大小。当分配时,长度总是 2 的整数幂
transient Node<K,V>[] table;

transient Set<Map.Entry<K,V>> entrySet;  // 保存缓存的 entrySet()

transient int size;  // 键值对个数

transient int modCount;  // 修改操作次数

// 要调整大小的下一个大小值 (capacity * load factor)
// 就是指如果数据量增加到该值时,需要重新扰动扩容
// 此外,如果 table 还没有分配空间,该属性保存初始数组容量,或默认的 DEFAULT_INITIAL_CAPACITY = 1 << 4 = 16
int threshold;

// The load factor for the hash table.
final float loadFactor;

之后就是对外的 API 了,首先是构造函数:

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);
}

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR); // 0.75f
}

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // 0.75f
}

特别地,还可以利用已有的 Map 来构造:

public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;  // 0.75f
    putMapEntries(m, false);
}

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    /**
     * evict:初始化时为 false,其他情况为 true
     */
    int s = m.size();
    if (s > 0) {
        if (table == null) { 
            // 这里启示是根据传入的 Map 的元素个数和负载因子计算容量,由于容量得是 2 的幂次方,
            // 所以要用 tableSizeFor() 计算出最小的 2 的幂次方
            float ft = ((float)s / loadFactor) + 1.0F; // +1 是因为前面向下取整了,可能容量不够
            int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY);
            if (t > threshold)
                threshold = tableSizeFor(t);
        }
        else if (s > threshold)
            resize(); // 超过阈值要重新扰动
        // 复制元素到本数组的存储容器中
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            // 插入值同时会自动判断是否需要树形化并在必要时候转换红黑树结点
            putVal(hash(key), key, value, false, evict);
        }
    }
}

下面看看超过阈值时是如何扰动的:

/**
 * Initializes or doubles table size. 
 * If null, allocates in accord with initial capacity target held in field threshold. 
 * Otherwise, because we are using power-of-two expansion, the elements from each bin 
 * must either stay at same index, or move with a power of two offset in the new table.
 */
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 对于 table 有数据的处理
    if (oldCap > 0) {
        // 如果已有的容量已经大于等于最大容量了,不会进行扰动,而是令 threshold = Integer.MAX_VALUE
        // 返回原来的 table
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 如果原始容量×2后没超过最大容量,并且原来的容量大于等于默认容量 DEFAULT_INITIAL_CAPACITY=16
        // 就将 oldThr×2,为什么这里不是扰动阈值而是容量呢?因为这就是扰动后初次赋值,初次赋值就是容量大小
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // table 里没数据,但是之前有过 threshold,新容量那就是之前的 threshold
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // 啥都不是说明的确是第一次初始化
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY; 
        // 新的 threshold 就是 负载因子×容量
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);  
    }
    // 这一步是针对上面的 else if (oldThr > 0) 的,重新计算 threshold
    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;  // 为了 GC
                // 没有 hash 冲突
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 已经转成红黑树结点了
                else if (e instanceof TreeNode)
                    // Splits nodes in a tree bin into lower and upper tree bins, 
                    // or untreeifies if now too small. Called only from resize
                    // 讲人话,其实和后面的处理意思一样,只是这是针对树结点的,并且可能出现红黑树退化成链表
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 普通冲突时,是链表存储的
                else { // preserve order
                    // 初始化低位的头指针和尾指针
                    Node<K,V> loHead = null, loTail = null;
                    //初始化高位的头指针和尾指针
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 这里是针对 hash 冲突时的链表情况
                    // 将结点分为坐标改变和没改变 2 组,低位存储没改动的,高位存储改动的
                    // 并且保证在各自的链表中原来的相对顺序没变
                    do {
                        next = e.next;
                        // 如果在新数组中索引没有变化
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 如果在新数组中索引增加了 oldCap
                        else {
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 新数组赋值
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    // 新数组赋值,但是由于位置变化,索引加了 oldCap
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

【注解】为什么要判断 e.hash & oldCap == 0

HashMap 在扩容时,需要先创建一个新数组,然后再将旧数组中的数据转移到新数组上来。此时,旧数组上的数据就会根据 (e.hash & oldCap) 是否等于 0 这个算法,被很巧妙地分为 2 类:

  • 等于 0 时,则将该头节点放到新数组时的索引位置等于其在旧数组时的索引位置,记为低位区链表(lo 开头表示 low)
  • 不等于 0 时,则将该头节点放到新数组时的索引位置等于其在旧数组时的索引位置再加上旧数组长度,记为高位区链表(hi 开头表示high)

前提

  • e.hash 代表的是旧数组中结点或元素或数据 ehash 值,该 hash 值是根据 key 确定过的:

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

    首先,理解什么是 h >>> 16,其实就是取出 h 的高 16 位(hashcode 为 4 字节 32 位)

    大家都知道上面代码里的 key.hashCode() 函数调用的是 key 键值类型自带的哈希函数,返回 int 型散列值。

    理论上散列值是一个 int 型,如果直接拿散列值作为下标访问 HashMap 主数组的话,考虑到 2 进制 32 位带符号的 int 表值范围从 -21474836482147483648。前后加起来大概 40 亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。

    但问题是一个 40 亿长度的数组,内存是放不下的。你想,HashMap 扩容之前的数组初始大小才 16。所以这个散列值是不能直接拿来用的。用之前还要先做对数组的长度取模运算,得到的余数才能用来访问数组下标,即:index = h & (capacity - 1);

    顺便说一下,这也正好解释了为什么 HashMap 的数组长度要取 2 的整次幂。因为这样(数组长度 - 1)正好相当于一个“低位掩码”。“与” 操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。以初始长度 16 为例,16-1=15,二进制表示是00000000 00000000 00001111 和某散列值做“与”操作如下,结果就是截取了最低的四位值。

            10100101 11000100 00100101
    &       00000000 00000000 00001111
    ----------------------------------
            00000000 00000000 00000101    // 高位全部归零,只保留末四位
    

    但这时候问题就来了,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。更要命的是如果散列本身做得不好,分布上成等差数列的漏洞,恰好使最后几个低位呈现规律性重复,就无比蛋疼。

    这时候“扰动函数”的价值就体现出来了,说到这里大家应该猜出来了。看下面这个图,

    在这里插入图片描述

    右位移 16 位,正好是 32 bit 的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。

  • oldCap 为旧数组的数组长度,是 2 的 n 次幂的整数,即 e.hash & 2^n 的值为 0

    这个简单,就是:

            1000...000
    &           1xxxxx
    --------------------
            0000...000
    

推导:

1、因为 oldCap 是 2 的 n 次幂的整数,其二进制表达为 1 个 1 后面跟 n 个0:1000…0,若想要 e.hash & oldCap 的结果为 0,则e.hash 的二进制形式中与对应 oldCap 的二进制的 1 的位置一定为 0,其他位置的可以随意,这样即可保证结果为 0。

2、假设 oldCap = 2^3,其二进制为 1000,则 e.hash 可以是 0101,满足 e.hash & oldCap 的结果为 0

              1000
&             0101
--------------------
              0000

同时,它在原数组下的索引为 e.hash & (oldCap - 1) = 5

              0111
&             0101
--------------------
              0101

3、在 oldCap = 2^3 基础上扩容一倍,即 newCap = 2^4,二进制为 10000,此时的 e.hash & newCap 的结果为:

              10000
&             00101
--------------------
              00000

同时,它在新数组下的索引为 e.hash & (newCap - 1) = 5

              01111
&             00101
--------------------
              00101

坐标并没有变化!


4、回头看看 e.hash & oldCap 的结果不为 0 时的情况,例如 e.hash 可以是 1101

              1000
&             1101
--------------------
              1000

此时,它在原数组下的索引为 e.hash & (oldCap - 1) = 5

              0111
&             1101
--------------------
              0101

看似好像和之前一样,扩容后的 e.hash & newCap 的结果为:

              10000
&             01101
--------------------
              00000

此时的结果是 0,我们再看看新数组中的索引为 e.hash & (newCap - 1) = 13

              01111
&             01101
--------------------
              01101

多试几次可以看出来,新数组的下标 = 就数组的下标 + oldCap

其他的就不记录了,个人觉得不是重点,比如红黑树,代码实现一时半会还真不会,但是专门花时间,一周内肯定可以写出来,但是如果会这个,不懂 HashMap 的扰动机制,真实屁用没有。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值