Java集合(6)之 HashMap 源码解析

628 阅读13分钟

HashMap 在日常开发中非常常用,它基于哈希表实现,以 key-value 形式存储。本文通过 JDK1.8 的源码,分析一下 HashMap 的内部结构和实现原理。

HashMap 概述

JDK1.7 之前,HashMap 底层由数组 + 链表实现,也就是链表散列。当向 HashMap 中添加一个键值对时,首先计算 keyhash 值,以此确定插入数组中的位置,但可能会碰撞冲突,将其转换为链表存储。

而从 JDK1.8 开始,增加了红黑树,由数组 + 链表 + 红黑树实现,当链表长度超过 8 时,链表转换为红黑树以提高性能。它的存储方式如下:

定义属性

静态常量

HashMap 的几个静态常量如下:

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

    // 默认初始容量为 16
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;  
    
    // 最大容量为 2^30
    static final int MAXIMUM_CAPACITY = 1 << 30;        
    
    // 默认负载因子为 0.75
    static final float DEFAULT_LOAD_FACTOR = 0.75f;     
    
    // 默认链表中元素大于 8 时转为红黑树
    static final int TREEIFY_THRESHOLD = 8;             
    
    // 扩容时,链表中元素小于这个值就会还原为链表
    static final int UNTREEIFY_THRESHOLD = 6;    
    
    // 数组的容量大于 64 时才允许被树形化
    static final int MIN_TREEIFY_CAPACITY = 64;
    ···
}

重要变量

下面是 HashMap 中几个重要的变量:

transient Node<K,V>[] table; // 存储元素数组
transient Set<Map.Entry<K,V>> entrySet; // 缓存 entry 返回的 Set 
transient int size; // 键值对个数
transient int modCount; // 内部结构修改次数
int threshold; // 临界值
final float loadFactor; // 负载因子

Node<K,V>[] table

Node<K,V>[] table 数组用来存储具体的元素,是 HashMap 底层数组和链表的组成元素。在第一次使用时初始化(默认初始化容量为 16),并在必要的时候进行扩容。

一般来说,由于素数导致冲突的概率较小,所以哈希表数组大小为素数。但 JavaHashMap 中采用非常规设计,数组的长度总是 2n 次方,这样做可以在取模和扩容时做优化,同时也能减少碰撞冲突。

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) { ··· }
    public final K getKey()        { ··· }
    public final V getValue()      { ··· }
    public final String toString() { ··· }
    
    // 重写了 hashCode 和 equals 方法
    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }
    public final V setValue(V newValue) { ··· }
    
    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;
    }
}

entrySet

entrySet 用于缓存 entrySet() 方法返回的 Set。后面会详细分析。

size

sizeHashMap 中键值对的数量。注意,键值对的数量 size 和哈希表数组的长度 capacity不同。

modCount

modCount 用于记录 HashMap 内部结构发生变化的次数,用于使用迭代器遍历集合时修改内部结构,而快速失败。需要注意的是,这里指的是结构发生变化,例如增加或删除一个键值对或者扩容,但是修改键值对的值不属于结构变化。

threshold 和 loadFactor

thresholdHashMap 能容纳的最大键值对个数,loadFactor 是负载因子,默认为 0.75。有如下等式(capacity 是数组容量):

threshold = capacity * loadFactor;

可以得出,在数组长度定义好之后,负载因子越大,所能容纳键值对越多。如果存储元素个数大于 threshold,就要进行扩容,扩容后的容量是之前的两倍。

TreeNode

当链表长度超过 8(阈值)时,将链表转换为红黑树存储,以提高查找的效率。下面是 TreeNode 的定义:

static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // 父节点
    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);
    }
    // 返回当前节点的根节点
    final TreeNode<K,V> root() {
        for (TreeNode<K,V> r = this, p;;) {
            if ((p = r.parent) == null)
                return r;
            r = p;
        }
    }
    ······
}

构造方法

HashMap 主要提供了四种构造方法:

1). 构造一个默认初始容量 16 和默认加载因子 0.75 的空 HashMap

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

2). 构造一个指定的初始容量和默认加载因子 0.75 的空 HashMap

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

3). 构造一个指定的初始容量和加载因子的空 HashMap

public HashMap(int initialCapacity, float loadFactor) {
    // check
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

4). 使用给定的 map 构造一个新 HashMap

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

基本方法

HashMap 内部功能实现很多,这里主要从 hash 方法、put 方法、get 方法、resize 方法和 entrySet 方法进行分析。

hash 方法

HashMap 中,增删改查都需要用 hash 算法来计算元素在数组中的位置,所以 hash 算法是否均匀高效,对性能影响很大。看一下它的实现:

static final int hash(Object key) {
    int h;
    // 优化了高位运算算法
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

// tab[i = (n - 1) & hash] 取模
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                boolean evict) {
    ···
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    ···
}

hash 算法计算对象的保存位置,分为三步:取 keyhashCode 值、高位运算、取模运算。

由于取模元素消耗较大,HashMap 中用了一个很巧妙的方法,利用的就是底层数组长度总是 2n 次方。通过 hash & (table.length - 1) 就可以得到对象的保存位置,相较于对 length 取模效率更高。

JDK1.8 中优化了高位运算的算法,通过 hashCode 的高 16 位异或低 16 位实现。下面举例说明,ntable 的长度:

put 方法

来看一下 HashMapput 方法:

public V put(K key, V value) {
    // 调用 hash 计算 key 的哈希值
    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 为空或长度为 0,则调用 resize 进行扩容
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 根据 key 的 hash 计算数组索引值,如果当前位置为 null,则直接创建新节点插入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        // table[i] 不为空
        Node<K,V> e; K k;
        // 如果 table[i] 的首元素和传入的 key 相等(hashCode 和 equals),则直接覆盖,这里容许 key 和 value 为 null
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 判断 table[i] 是否为 treeNode,即 table[i] 是否为红黑树,如果是则在树中插入
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 否则遍历链表
        else {
            for (int binCount = 0; ; ++binCount) {
                // 如果 key 不存在
                if ((e = p.next) == null) {
                    // 则新建一个结点
                    p.next = newNode(hash, key, value, null);
                    // 如果长度大于8,则转为红黑树处理
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果 key 已经存在,则直接覆盖
                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;
}

下面是 put 方法的几个步骤::

  • 判断哈希表数组 table[] 为空或者长度为 0,如果是则调用 resize() 进行扩容;
  • 通过 hash & (table.length - 1) 计算插入的数组索引值,如果当前位置为 null,则直接创建节点插入
  • 判断 table[i] 的首个元素是否和 key 相等(hashCodeequals),如果相等则直接覆盖 value
  • 判断 table[i] 是否为 treeNode,即 table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对;
  • 否则遍历链表,如果 key 不存在,则直接创建节点插入,并判断链表长度是否大于 8,如果是红黑树则转为红黑树处理;如果遍历中发现 key 已经存在,则直接覆盖即可;
  • 插入成功后,判断实际存在键值对是否超过了最大容量,如果是则进行扩容;

HashMapput 方法可以通过下图理解:

get 方法

来看一下 HashMapget 方法:

public V get(Object key) {
    Node<K,V> e;
    // 调用 getNode 方法,如果通过 key 获取的 Node 为 null,则返回 null;否则返回 node.value
    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;
    // 如果数组不为空,数组长度大于 0
    // 通过 hash & (length - 1) 计算数组的索引值,并且对应的位置不为 null
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 如果桶中第一个元素与 key 相等,则直接返回
        if (first.hash == hash && 
            ((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;
}

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;
    // 如果扩容前容量 > 0
    if (oldCap > 0) {
        // 如果数组大小已经达到最大 2^30,则修改阈值为最大值 2^31-1,以后也就不会再扩容
        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) // 如果扩容前容量 <= 0,旧临界值 > 0
        // 将数组的新容量设置为 旧数组扩容的临界值
        newCap = oldThr;
    else { // 容量 <= 0,旧临界值 <= 0          
        // 否则设置为默认值
        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;
    
    // 创建新的 table,容量为 newCap
    @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;
                // 如果旧桶中只有一个 node
                if (e.next == null)
                    // 则将 oldTab[j] 放入新哈希表中 e.hash & (newCap - 1) 的位置
                    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; // 将下标增加 oldCapaciry 的节点组织成另一条链表
                    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 {
                            // 原索引 + oldCap
                            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;
}

resize 方法在扩容时,由于每次数组的长度变为原先的 2 倍,所以元素要么在原位置,要么在“原始位置 + 原数组长度”的位置。通过计算 e.hash & oldCap 来判断是否需要移动。

看下图,ntable 的长度,图 (a) 为扩容前的 key1key2 确定索引位置的示例,图 (b) 为扩容后的 key1key2 确定索引位置的示例,其中 key1(hash1)key1 对应的哈希与高位运算的结果:

元素在重新计算 hash 后,因为 n 变为 2 倍,那么 n - 1mask 的范围(红色)在高位多 1bit,因此新的 index 就会这样变化:

因此,在扩容时,只需看看原来的 hash 值新增的 bit 位是 1 还是 0,如果是 0,索引不变,否则变成 "原索引 + oldCapacity",可以看看下图 16 扩充为 32 的示意图:

entrySet 方法

HashMap 的一种遍历方式就是使用 entrySet 方法返回的迭代器进行遍历。先来看一下 entrySet 方法:

public Set<Map.Entry<K,V>> entrySet() {
    Set<Map.Entry<K,V>> es;
    return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}

可以看到,如果缓存 map 中键值对的 Set 不为 null,则直接返回,否则会创建一个 EntrySet 对象。

EntrySet 类的 iterator 方法会返回一个 EntryIterator 迭代器对象,另外还有两个迭代器 KeyIteratorValueIterator

final class EntryIterator extends HashIterator
        implements Iterator<Map.Entry<K,V>> {
    public final Map.Entry<K,V> next() { return nextNode(); }
}

final class KeyIterator extends HashIterator
        implements Iterator<K> {
    public final K next() { return nextNode().key; }
}
final class ValueIterator extends HashIterator
    implements Iterator<V> {
    public final V next() { return nextNode().value; }
}

它们三个都继承自 HashIterator,分别用于键遍历、值遍历、键值对遍历,它们都重写了 Iteratornext 方法,其中调用了 HashIteratornextNode 方法。

HashIterator 是一个抽象类,实现了迭代器的大部分方法:

abstract class HashIterator {
    Node<K,V> next;        // next entry to return
    Node<K,V> current;     // current entry
    int expectedModCount;  // for fast-fail
    int index;             // current slot

    HashIterator() {
        expectedModCount = modCount;
        Node<K,V>[] t = table;
        current = next = null;
        index = 0;
        if (t != null && size > 0) { // advance to first entry
            do {} while (index < t.length && (next = t[index++]) == null);
        }
    }

    public final boolean hasNext() {
        return next != null;
    }

    final Node<K,V> nextNode() {
        Node<K,V>[] t;
        Node<K,V> e = next;
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        if (e == null)
            throw new NoSuchElementException();
        if ((next = (current = e).next) == null && (t = table) != null) {
            do {} while (index < t.length && (next = t[index++]) == null);
        }
        return e;
    }

    public final void remove() { ··· }
}

可以看出 HashIterator 迭代器的默认构造器中,将 current 设置为 null,然后循环在数组中查找不为 null 的桶, 让 next 指向第一个桶中的第一个节点 Node

在遍历时,next 方法会调用 nextNode() 方法,这个方法首先把 next 赋给 e 以稍后返回,并把 e 赋给 current。然后判断 next 是否为空,如果不为空,返回 e 即可。

如果为空,就在数组中继续查找不为空的桶,找到后退出循环,最后返回 e。这样就能都遍历出来了。

小结

HashMap 的特点主要有:

  • HashMap 根据键的 hashCode 值来存储数据,大多数情况下可以直接定位它的值,因而访问速度很快。
  • HashMap 不保证插入的顺序。
  • 扩容是一个特别耗能的操作,在使用 HashMap 时,最好估算 map 的大小,初始化时给定一个大致的数值,避免进行频繁的扩容。
  • threshold = capacity * loadFactor; 如果存储元素个数大于 threshold,就要进行扩容,扩容后的容量是之前的两倍。
  • 默认的负载因子 0.75 是时间和空间之间的一个平衡,一般不建议修改。
  • HashMapkeyvalue 允许为 null,最多允许一条记录的键为 null,允许多条记录的值为 null
  • 它是非线程安全的。如果需要线程安全,可以使用 CollectionssynchronizedMap 方法使 HashMap 具有线程安全的能力,或使用 ConcurrentHashMap

参考资料