Java 数据结构 - HashMap 源码解读:如何设计工业级的散列表

Posted binarylei

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java 数据结构 - HashMap 源码解读:如何设计工业级的散列表相关的知识,希望对你有一定的参考价值。

Java 数据结构 - HashMap 源码解读:如何设计工业级的散列表

数据结构与算法目录(https://www.cnblogs.com/binarylei/p/10115867.html)

Java 数据结构 - 散列表原理一文中,提到评价一个散列表的标准有三个:散列函数、散列冲突、加载因子(动态扩容)三个指标。那像 HashMap 这样工业级的散列表应该具有哪些特性?

  • 支持快速的查询、插入、删除操作,时间复杂度为 O(1);
  • 内存占用合理,不能浪费过多的内存空间;
  • 性能稳定,极端情况下,散列表的性能也不会退化到 O(n),以至于无法接受。

1. HashMap 三大指标分析

1.1 如何设计散列函数

散列函数追求的是简单高效、分布均匀。

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

说明: HashMap 使用最简单的余数法作为散列函数,使用位运算来提高执行效率。

  1. 将 hashCode 的高 16 位和低 16 位进行异或运算,进一步保证哈希函数的随机性和均匀性。
  2. 散列表的长度必须是 2^n,直接使用位运算进行求余 i = (n - 1) & hash(kye)

其中,hashCode() 返回的是 Java 对象的 hash code。比如 String 类型的对象的 hashCode() 就是下面这样:

public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

1.2 如何选择散列冲突解决方法

散列冲突解决方案有开放地址探测法和拉链法,两种方案的基本使用场景如下:

  • 线性探测法:数据量比较小、装载因子小。当装载因子 loadfactor 接近 1 时,散列冲突会非常严重。
  • 拉链法:存储大对象、大数据量。对装载因子较大的容忍度高。

像 ThreadLocalMap 数据量小,可以直接使用线性探测法。 HashMap 的数据量可能非常大,只能使用拉链法解决散列冲突。即使负载因子和散列函数设计得再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响 HashMap 的性能。

于是,在 JDK1.8 中,HashMap 引入了红黑树。

  • 当链表长度太长(默认超过 8)时,链表就转换为红黑树。我们可以利用红黑树快速增删改查的特点,提高 HashMap 的性能。

  • 当红黑树结点个数少于 8 个的时候,又会将红黑树转化为链表。因为在数据量较小的情况下,红黑树要维护平衡,比起链表来,性能上的优势并不明显。但 HashMap 也不是直接使用链表,当链表的长度大于 8 时会转换会红黑树。

1.3 装载因子多大合适:什么时候触发动态扩容

装载因子(loadFactor)的计算公式如下:

散列表的装载因子 = 表中的元素个数 / 散列表的长度

装载因子实际的含义是如何动态扩容的问题。其阈值的设置要权衡时间、空间复杂度。如果内存空间不紧张,对执行效率要求很高,可以降低负载因子的阈值;相反,如果内存空间紧张,对执行效率要求又不高,可以增加负载因子的值,甚至可以大于 1。

如果元素个数超过装载因子阈值就会触发散列表的扩容,当然我们也可以只扩容不搬运数据,当插入时从老容器中搬移部分数据。不过这会浪费内存,这相当于将一只扩容的时间复杂度摊还到多次插入过程中。HashMap 在扩容进会一次性的将数据从老容器中搬移到新容器中。

HashMap 中装载因子动态扩容问题:

  • 装载因子和动态扩容。最大装载因子默认是 loadFactor = 0.75,当 HashMap 中元素个数超过 0.75 * capacity(capacity 表示散列表的容量)的时候,就会启动扩容,每次扩容都会扩容为原来的两倍大小。

  • 初始大小。HashMap 默认的初始大小是 16。如果事先知道大概的数据量有多大,可以通过修改默认初始大小,减少动态扩容的次数,这样会大大提高 HashMap 的性能。

2. 源码解读

2.1 重要属性

(1)扩容相关属性

static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final int MAXIMUM_CAPACITY = 1 << 30;

transient Node<K,V>[] table;  // 表示hash数组,数组长度必须为 2^n
transient int size;           // 表示当前表中元素的个数

// 当threshold>size时扩容。threshold = table.length * loadFactor
int threshold;
final float loadFactor;  

(2)红黑树相关属性

static final int TREEIFY_THRESHOLD = 8;    // 链表转红黑树的阀值
static final int UNTREEIFY_THRESHOLD = 6;  // 红黑树转链表的阀值
// 如果HashMap的容量小于64先启动扩容,只有容量大于64才会转红黑树
static final int MIN_TREEIFY_CAPACITY = 64;

2.2 插入

HashMap 中插入 key 时,有以下几种可能:

  1. hash(key) 对应的数组没有元素。如插入 key = d1 的元素。
  2. 有元素已经存在,并且是红黑树。按红黑树处理即可,红黑树不是本文分析的重点。
  3. 有元素已经存在,并且结构是链表。这时有两种情况:
    • key 已经存在,需要替换这个 key。如插入 key = g2 的元素。
    • key 不存在,直接插入到链表尾。这时还需要判断链表的长度是否大于 8,否则还需要将链表转红黑树。如插入 key = a4 的元素。
/**
 * @param onlyIfAbsent 只有元素不存在时才插入
 * @param evict 数组是否在扩容状态,LinkedHashMap中有使用
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 1. 初始化数组
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 2. 有空闲位置,直接存储即可
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 3. 没有空闲位置,二种情况:可能是hash碰撞,也可能是该key对应的元素已经存在
    else {
        Node<K,V> e; K k;
        // 3.1 元素已经存在,e变量保存旧值
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 3.2 红黑树,先不管
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 3.3 遍历链表,如果key已经存在则保存到e中,如果不存在则直接追加到链表尾
        else {
            for (int binCount = 0; ; ++binCount) {
                // 3.3.1 key不存在,直接追加不链表尾
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 判断链表是否要转红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 3.3.2 key存在,保存到到e中
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 3.4 key已经存在,判断是否替换旧值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    // 4. 判断是否需要扩容
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

说明: HashMap 允许 key 值为 null,因为判断一个 key 是否已经存在的条件是:p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))

链表中 hash(key) 对应的数组没有元素时处理很简单,我们重点分析一下如果存在链表结构怎么处理。递归遍历链表,如果找到相同 key 的结点就退出循环,直接替换这个 key。如果遍历完链表都没有找到,则直接追加到链表尾,并判断是否需要转红黑树(长度大于 8)。

我们思考一下,HashMap 为什么要将链表的第一个元素单独进行判断,即第一个 if 语句。我想这是因为 HashMap 的加载因子 loadFactor = 0.75,也就是说数组容器的长度是大于 HashMap 中元素的个数。在绝大多数情况下,即如果不发生 hash 碰撞的理想情况,链表中都只有一个元素,这样可以快速返回。

2.3 查找

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // hash(key) 对应的数组中存在数据,需要进一步查找对应的 key
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 1. 判断链表的第一个结点是不是指定的 key
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 2. 链表或红黑树递归查找 key
        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;
}

说明: 同样 HashMap 查找元素时,也先单独判断链表的第一个元素。

2.4 删除

删除的代码也很简单,只是需要注意,要删除的元素是不是链表的头结点,因为数组的引用是指向这个头结点的。如删除结点 a1 时,需要将数组的引用指向新的头结点。

2.5 扩容

HashMap 扩容首先要确定扩容后的数组长度,再进行数据搬移。

HashMap 默认按原数组的 2 倍扩容。如果数组未初始化,则需要先进行初始化。初始化分如果没有指定数组初始化容量,按默认容量 16 进行初始化。如果指定了初始化容器 threshold(一定是 2n),则按照 threshold 初始化,并根据加载因子重新计算扩容阀值 threshold = newCap * loadFactor

下面,我们在看一下数据搬移,数据的搬移非常巧妙。由于数组的容量是 oldCap = 2n,扩容后数组的长度为原来的 2 倍 newThr = oldThr << 1。在 hash(key) 值不变的情况下,原先 arr[k] 重新 rehash 后只能在新数组的 arr[k] 或 arr[k + oldCap] 两个位置。如下图所示,原先数组长度为 8,a1 ~ a8 的 key 全部落到 arr[1] 上,重新 rehash 后部分 key 落到 arr[1] 部分落到 arr[9] 上:

final Node<K,V>[] resize() {
    // 1. 确定数组扩容后的长度,默认的按2倍扩容
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 1.1 如果数组已经初始化,按原数组的2倍大小扩容
    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
    // 1.2 原数组未初始化,但设置初始化长度,按初始化长度进行初始化
    } else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // 1.3 原数组未初始化,按默认大小初始化数组
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 1.4 初始化扩容的阀值threshold=newCap * loadFactor
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    
    // 2. 数据搬移
    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;
}

每天用心记录一点点。内容也许不重要,但习惯很重要!

以上是关于Java 数据结构 - HashMap 源码解读:如何设计工业级的散列表的主要内容,如果未能解决你的问题,请参考以下文章

Java Review - HashMap & HashSet 源码解读

Java Review - HashMap & HashSet 源码解读

最通俗易懂的 HashMap 源码分析解读

逐行解读HashMap源码

HashTable 源码解读

JDK1.8 Java小白的源码学习系列:HashMap