深入解析JDK 8 HashMap

2023-10-11 09:45:18 浏览数 (1)

HashMap 简介


HashMap 主要用来存放键值对,它基于哈希表的 Map 接口实现,是常用的 Java 集合之一,是非线程安全的。

HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个

JDK1.8 之前 HashMap 由 数组 链表 组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。 JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于等于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。

HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。并且, HashMap 总是使用 2 的幂作为哈希表的大小。


数据结构

JDK 8版本的HashMap底层数据结构是数组 链表/红黑树结构,具体原因是:

代码语言:javascript复制
    /**
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     * (We also tolerate length zero in some operations to allow
     * bootstrapping mechanics that are currently not needed.)
     */
    transient Node<K,V>[] table;

这是 HashMap 类中的一个成员变量,它是一个存储桶数组。table 数组用于存储键值对的实际数据。

代码语言:javascript复制
    /**
     * Basic hash bin node, used for most entries.  (See below for
     * TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
     */
    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;
        }
}

这是 HashMap 内部定义的静态嵌套类 Node,它实现了 Map.Entry 接口。每个 Node 对象表示 HashMap 中的一个键值对,它包含键、值以及指向下一个节点的引用。

代码语言:javascript复制
/**
 * Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn
 * extends Node) so can be used as extension of either regular or
 * linked node.
 */
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);
    }

    /**
     * Returns root of tree containing this node.
     */
    final TreeNode<K,V> root() {
        for (TreeNode<K,V> r = this, p;;) {
            if ((p = r.parent) == null)
                return r;
            r = p;
        }
    }

}

这是 HashMap 内部定义的静态嵌套类 TreeNode,它是红黑树结构的节点。在 HashMap 中,当链表中的元素数量超过一定阈值时,会将链表转换为红黑树,以提高查找性能。

因此,transient Node<K,V>[] table; 存储了实际的数据,static class Node<K,V> 表示链表结构的节点,而 static final class TreeNode<K,V> 表示红黑树结构的节点。这些组合在一起实现了 HashMap 数据结构的数组 链表(红黑树)的形式。


Hash

如果说HashMap的数据结构是其实现功能的基础,那么HashMap的Hash方法则是HashMap实现查找、插入的保障。

HashMap 并不是直接获取 key 的 hashCode 作为 hash 值的,它会通过一个扰动函数(所谓扰动函数指的是HashMap的hash方法)进行一些列位运算和混合操作,使得最终的哈希值更加均匀的分布在哈希表的桶中。使用hash方法就是为了减少hash碰撞的概率且提高HashMap的性能。

代码语言:javascript复制
//扰动函数
static final int hash(Object key) {
	//用于存储key对应的hash码
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
代码语言:javascript复制
//等同于:
int h;
//当key是null时,hash值默认是0,即HashMap只能有一个为Null的key
if (key == null) {
    h = 0;
} else {
    h = key.hashCode();
}
//进行异或操作并将结果赋值给 h
return h = h ^ (h >>> 16);

构造方法

HashMap的类属性和构造方法:

代码语言:javascript复制
  private static final long serialVersionUID = 362498820763181265L;
    // 默认的初始容量是16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    // 最大容量
    static final int MAXIMUM_CAPACITY = 1 << 30;
    // 默认的负载因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    // 当桶(bucket)上的结点数大于等于这个值时会转成红黑树
    static final int TREEIFY_THRESHOLD = 8;
    // 当桶(bucket)上的结点数小于等于这个值时树转链表
    static final int UNTREEIFY_THRESHOLD = 6;
    // 桶中结构转化为红黑树对应的table的最小容量
    static final int MIN_TREEIFY_CAPACITY = 64;
    // 存储元素的数组,总是2的幂次倍
    transient Node<k,v>[] table;
    // 存放具体元素的集
    transient Set<map.entry<k,v>> entrySet;
    // 存放元素的个数,注意这个不等于数组的长度。
    transient int size;
    // 每次扩容和更改map结构的计数器
    transient int modCount;
    // 阈值(容量*负载因子) 当实际大小超过阈值(实际最大容量)时,会进行扩容
    int threshold;
    // 负载因子
    final float loadFactor;


    // ①:默认构造函数。
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all   other fields defaulted
     }


     // ②:包含另一个“Map”的构造函数
     public HashMap(Map<? extends K, ? extends V> m) {
         this.loadFactor = DEFAULT_LOAD_FACTOR;
         putMapEntries(m, false);//下面会分析到这个方法
     }

	
     // ③:指定“容量大小”的构造函数
     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;
         // 初始容量暂时存放到 threshold ,在resize中再赋值给 newCap 进行table初始化
         this.threshold = tableSizeFor(initialCapacity);
     }

get(key)方法

步骤一:通过key获取所在桶的第一个元素是否存在
代码语言:javascript复制
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 数组元素相等
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 桶中不止一个节点
        if ((e = first.next) != null) {
            // 在树中get
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 在链表中get
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

先来明确一下各个变量的意义:

  • key:要查询的指定key
  • table:当前HashMap哈希表实际数据存储,用于存储节点的桶
  • first:要查询的指定key匹配到某个桶的第一个节点
  • e:临时节点,用于遍历桶中的节点链表或树结构。在这段代码中,e 用于遍历桶中的节点以查找匹配的键值对。
  • n:这是一个整数,表示哈希表的长度,即桶的数量。
  • k:这是一个键对象,表示 first 节点的键,即指定key计算后hash值对应桶的第一个节点的键。

单独说明:(tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null)

(tab = table) != null:将table变量赋值给tab变量,并检查table是否为null,table用于存储实际的哈希表数据。

(n = tab.length) > 0:将tab.length赋值给n变量,并检查n是否大于0。n表示哈希表的长度,即桶的数量。

(first = tab[(n - 1) & hash]) != null:计算出哈希值hash对应桶的索引位置,并将该桶的第一个节点赋值给first变量。

(n - 1) & hash 的操作是根据哈希值 hash 和哈希表长度 n 计算出桶的索引位置。

  • n-1是为了确保索引在0到n-1之间的有效索引位置。
  • &是位运算中的按位与操作,用于将哈希值和n-1进行与运算,得到有效的桶索引。
  • 判断第一个元素是否为null

通过key定位到该key所在桶,获取该桶的第一个节点元素信息key , key.hash()。

步骤二:该节点的hash和key是否与要查询的hash和key匹配

当要查询的hash对应桶的第一个节点存在时,进一步检查该节点是否匹配指定的key。

单独说明:if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))) return first

first.hash == hash:判断第一个元素key的hash值是否恒等于要查询key的哈希值

((k = first.key) == key:将第一个元素的key赋值给变量k,避免后续的比较重多次访问节点的键值。

|| (key != null && key.equals(k)))):比较要查询的key不为空且与第一个节点的key是否引用同一块地址(相等)。

如果引用同一块内存地址,则说明要查询的key的hash值和对应桶的第一个元素key的hash值一致,即定位到了指定key的节点信息,返回该节点数据。

步骤三:当对应桶中不止一个节点时,根据不同节点类型查询
代码语言:javascript复制
// 在树中get
if (first instanceof TreeNode)
    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 在链表中get
do {
	if (e.hash == hash &&
        ((k = e.key) == key || (key != null && key.equals(k))))
        return e;
} while ((e = e.next) != null);

红黑树搜索

代码语言:javascript复制
        /**
         * Calls find for root node.
         */
        final TreeNode<K,V> getTreeNode(int h, Object k) {
            return ((parent != null) ? root() : this).find(h, k, null);
        }
  • 当第一个节点的数据类型是红黑树时,则说明该桶已经树化,需要根据红黑树的逻辑进行定位
  • 当第一个节点的数据类型是链表时,通过当前临时节点的hash与待查询key的hash对比循环遍历即可。

put(key,value)

HashMap只对外提供了put一个方法用于添加元素,putVal是put方法的实现,但是并没有对外开放。

代码语言:javascript复制
    /**
     * Associates the specified value with the specified key in this map.
     * If the map previously contained a mapping for the key, the old
     * value is replaced.
     *
     * @param key key with which the specified value is to be associated
     * @param value value to be associated with the specified key
     * @return the previous value associated with <tt>key</tt>, or
     *         <tt>null</tt> if there was no mapping for <tt>key</tt>.
     *         (A <tt>null</tt> return can also indicate that the map
     *         previously associated <tt>null</tt> with <tt>key</tt>.)
     */
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    /**
     * Implements Map.put and related methods.
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    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;
    }

链表树化代码:

代码语言:javascript复制
    /**
     * Replaces all linked nodes in bin at index for given hash unless
     * table is too small, in which case resizes instead.
     */
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        // ⑧
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        // ⑨ 
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

先来明确一下各个变量的意义:

  • Node<K,V>[] tab:HashMap实际存储数据的单位
  • Node<K,V> p:Node节点类型的变量,表示当前要插入的节点
  • int n:表示当前HashMap中的节点数量
  • int i:临时变量,用于辅助计算节点的插入位置

下面说明putVal的简要步骤:

  1. 当前HashMap存储哈希表数据的table为空时,首先对其进行扩容
  2. 计算出要插入节点的哈希值在数据tab中的位置 i
  3. 当要插入节点的位置为空时,直接在该位置创建新的节点即可
  4. 比较待插入节点与p的哈希值是否等于并且判断节点p的key与要插入节点的key是否相等,如果满足这两个条件时,说明发生了哈希碰撞,即要插入的键已经存在于HashMap中,随后用新的value覆盖原值
  5. 判断该节点的类型,该节点是TreeNode红黑树时,红黑树直接插入键值对
  6. 该节点是Node链表时,开始准备遍历链表准备插入
  7. 判断链表长度是否大于8
  8. 当链表长度大于8时,执行链表树化逻辑,前提是,当前桶(bucket)中的节点数量大于64,如果小于64,优先给链表扩容,当链表不满足树化条件时,链表中插入新的元素,若key存在于当前列表,则直接覆盖原来的值
  9. 满足树化条件,将链表转为红黑树,插入新的键值对

以上简要步骤需要对应上文中代码的序号。

引用美团技术团队的HashMap图片以更清晰的理解put过程:

为什么树化?

处于性能和安全角度考虑选择树化,在元素放置的过程中,如果一个对象哈希冲突,都会放置到同一个桶里,形成一个链表,链表查询是线性的,时间复杂度是O(n)会严重影响读取性能,数据量大的话,会导致服务端资源大量占用。


resize-扩容

HashMap中的数组到达指定阈值长度后插入数据,需要对HashMap进行扩容,由于数组长度不可变的局限性,因此在扩容时,需要创建新的HashMap,然后原HashMap的数据复制到新的HashMap中。就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。

代码语言:javascript复制
    /**
     * 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.
     *
     * @return the 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;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        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) {
                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 { // preserve order
                        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 {
                                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;
    }

先来明确下各个变量的含义:

  • bin:bucket,数组中存储Entry位置
  • MAXIMUM_CAPACITY:最大容量(2的30次幂,1073741824)
  • DEFAULT_LOAD_FACTOR:负载因子(默认0.75)
  • TREEIFY_THRESHOLD:链表转红黑树阈值
  • DEFAULT_INITIAL_CAPACITY:默认初始容量(2的4次幂16)

所以,HashMap不指定容量时,最大容纳数据量是 16 * 0.75 = 12。

  1. 当 HashMap 中的元素数量超过负载因子(load factor)与当前容量的乘积时,就会触发扩容操作。负载因子是一个表示 HashMap 充满程度的比例因子,默认为 0.75。
  2. 创建一个新的、两倍大小的数组,作为扩容后的容器。
  3. 遍历原来的数组,将每个元素重新计算哈希值,并放入新的数组中的对应位置。这涉及到重新计算元素在新数组中的索引位置,以及处理可能的哈希碰撞。
  4. 在重新计算元素索引的过程中,如果发现某个位置上有多个元素(发生了哈希碰撞),则会使用链表或红黑树来存储这些元素,以提高查找效率。
  5. 当所有元素都重新放入新的数组后,原来的数组会被丢弃,成为垃圾数据等待被垃圾回收器回收。

0 人点赞