zoukankan      html  css  js  c++  java
  • Java 数据结构

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

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

  • 相关阅读:
    010-spring事务管理
    009-事务管理
    008-ThreadLocal
    Bmob用户管理操作
    Textview下划线注册用户跳转实现
    Android中多个调用Activity的问题
    解决android:theme="@android:style/Theme.NoDisplay" 加入这句话后程序不能运行
    友盟自动更新
    友盟消息推送和更新XML配置
    Android 云服务器的搭建和友盟APP自动更新功能的实现
  • 原文地址:https://www.cnblogs.com/binarylei/p/12455142.html
Copyright © 2011-2022 走看看