栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

JavaSE集合实现原理——HashMap

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

JavaSE集合实现原理——HashMap

前言

        从毕业到现在,也有有一年之久了。曾经经常看到各种HashMap源码解析,实现原理解析等文章,但都是看了又忘的状态。那这次我也从HashMap开始,将Java标准库的集合工具类(包括JUC并发包)源码捋一遍,并做博客输出。

        集合工具类源码经过了精心设计,相信会让看过的人无不赞叹设计者Doug Lea(也是JUC的设计者)、Joshua J. Bloch的智慧,该设计者也出过一本书籍《Effective-Java》《Java并发编程实战》质量也相当不错,推荐看看。


类关系图

图(1)HashMap相关的类关系图

类解释(关键的类):

  • Map提供了将键映射到值的基本框架。
  • Entry是Map的内部类接口,提供了将键与值绑定的容器框架。
  • AbstractMap实现于Map,仅仅实现了最基础的查询/删除功能,它们都依赖于子类实现AbstractMap#entrySet()方法来获取Entry的Set集合与迭代器配合工作。
  • HashMap实现于Map并继承自AbstractMap,基于“散列表+链表/红黑树”实现的完整功能。
  • Node是HashMap的内部类,实现于Entry,基于“链表”的数据结构实现。建议不知道链表数据结构的同学去了解一下链表的原理,无非就是当前对象节点引用下一个对象节点,下一个对象节点再引用下一个对象节点,就像一根链条一样。
  • TreeNode间接继承(实际继承自linkedHashMap#Entry,而前者继承自HashMap#Node)自Node,是JDK1.8版本新增的基于“红黑树”的实现。红黑树数据结构相比链表的优点是查询速度比后者者较快(前者 O(logN)内 对比 后者O(N) ,N为元素个数),不过如果树节点不多时效率并不比链表高多少,反而插入效率 O(logN)内 相对链表O(1)是慢了的些,因此HashMap在链表长度满足大于等于阀值(HashMap#TREEIFY_THRESHOLD成员属性表示)8时才将链表替换为红黑树结构。
  • 其他类可以暂不关注,当前只会涉及到上面介绍的类。

 初始值解释
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    static final int MAXIMUM_CAPACITY = 1 << 30;

    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    transient Node[] table;

    transient int size;

    int threshold;
    
    final float loadFactor;

图(2) HashMap中的部分属性 

 分别表示:

  • DEFAULT_INITIAL_CAPACITY:默认的容器容量大小,默认为1<<4(位左移运算,十进制为16)
  • MAXIMUM_CAPACITY:最大容器容量大小。
  • DEFAULT_LOAD_FACTOR:默认负载因子。
  • table:一个由Node对象数组(亦称散列表)组成的数据容器。
  • size:当前数据容器table中保存的元素个数。
  • threshold:阀值,当size大于等于该值时将触发扩容,由table.lenght * loadFactor计算得来。
  • loadFactor:当前容器的负载因子。

从创建容器开始

一、构造方法

构造方法挑两个主要作解析:

  • public HashMap()
  • public HashMap(int initialCapacity, float loadFactor)
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

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

图(3) HashMap主要构造方法

        无参构造仅仅将常量DEFAULT_LOAD_FACTOR(默认负载因子)赋值给当前容器的负载因子属性,第二个构造方法则手动设置容器初始容量与负载因子。目前并未看到table[]数组变量进行初始化,这也意味着在创建容器对象时,容器并未分配元素空间,不会有任何额外开销。那么容器何时才会进行初始化呢?

        在深入之前,注意到构造方法调用了HashMap#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;
    }

图(4) HashMap#tableSizeFor()方法代码

你可以将该方法单独拿出来执行,你会发现这个算法得到的值永远都是当前给定值-1向后取得最近的2的N次幂(如果你深入研究一下这个算法,你会发现实际就是将最高权位以后的所有位权设置为1,再将结果加1后那么所有位权进1就得到了2的N次幂),将方法返回值赋值给threshold变量,这将在后期的初始化容器时作为初始化容量大小值。

二、扩容方法

        可能有人注意到构造方法中初始化容量值是赋值给threshload变量的。疑问来了,threshold不是一个阀值么,怎么没有乘于loadFactor后再赋值给这个阀值变量呢?答案就在HashMap#resize()方法中,代码如下:

    
    final Node[] resize() {
        Node[] 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[] newTab = (Node[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node 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)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        Node loHead = null, loTail = null;
                        Node hiHead = null, hiTail = null;
                        Node 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;
    }

        图(4) HashMap#resize()方法代码

代码解析:

        首先,咱们看到方法上的文档注释:“Initializes or doubles table size.”,翻译过来就是“初始化或加倍扩容表容量大小”,从而得知容器的初始化工作与扩容将在该方法中进行。另外这个注释中还提到:“ 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.”,翻译过来就是“因我们使用2的幂进行扩张,在新表中所有的元素要么在以前的位置,要么以2倍偏移”。发没发现和上面的HashMap#tableSizeFor()方法永远饭回2的幂有诸多联系呢。

        深入到内部逻辑中大概以下步骤:

  1. 判断当前是否已经初始化容器(判断table变量是否为空或数组lenght是否为0)。
    1.1.如果未初始化,判断threshold是否大于0(是否手动设置了初始化容量)
           1.1.1.如果大于0,则将该值作为初始化容量。(这表明构造中对其赋值只是为了设置初始容量)
           1.1.2.反之,使用默认DEFAULT_INITIAL_CAPACITY常量作为初始化容量。
    1.2.反之,将旧的容量(oldCap)向左位移1位(2的N+1次幂)
  2. 使用新的容量值(newCap)* loadFactor计算得出的值赋值给阀值threshold。
  3. 将旧的table备份(oldTab),使用新的容量值(newCap)重新创建Node数组并赋值给table。
  4. 将旧的table备份(oldTab)拷贝到新的容器table中,拷贝时根据当前元素是链表还是红黑树分别进行处理。  

你有没有想过,为什么HashMap只支持扩容,而不去支持缩容呢? 

三、添加元素

        目前为止,咱们虽然知道容器是如何扩容的,但还不知道容器什么时候进行扩容。仔细一想创建容器后都是直接HashMap#put(),那前者方法中应该可能有调用HashMap#resize()吧。事实如此,代码如下:

     public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }


    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node[] tab; Node 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 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)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;
    }

         图(5) HashMap#put()以及相关方法代码

         从中可以总结下列逻辑:

  1. 计算Key的hash值(稍后解析HashMap#hash()方法)
  2. 判断散列表table(Node数组)是否为分配空间,如果未分配则调用HashMap#resize()进行初始化。
  3. 根据hash值与表容量 - 1 进行位与计算(效果等于hash对表容量取余运算)得到当前Key在表中的位置。
    3.1.索引处没有元素时,直接新建一个Node 。
    3.2.否则索引有元素,判断该元素是否有子节点。
        3.2.1.无子节点时,替换当前Node旧的Value。
        3.2.2.有子节点时,判断节点是否为红黑树TreeNode。
            3.2.2.1.是红黑树,进行红黑树的节点插入操作。
            3.2.2.2.非红黑树,在链表尾部新建一个Node,如果当前链表长度大于等于TREEIFY_THRESHOLD阀值(默认值为8),则调用HashMap#treeifyBin()进行链表转红黑树操作
  4. 元素个数size记录值+1,判断是否超过阀值threshold,如果超过将调用HashMap#resize()进行扩容操作。

另外,可以看到逻辑中还调用了一些回调方法,包括HashMap#remove()代码逻辑中也调用了:

    // Callbacks to allow linkedHashMap post-actions
    void afterNodeAccess(Node p) { }
    void afterNodeInsertion(boolean evict) { }
    void afterNodeRemoval(Node p) { }

图(6) HashMap中的模版方法 

这在设计模式的术语叫“模板方法模式(Template Pattern)”,这几个方法实际上如注释所明是提供给linkedHashMap使用的,可用来实现LRU(一种缓存淘汰策略)功能特性。

那么是否也可以利用这几个模版方法对HashMap增加缩容机制呢?

四、HashMap#hash()方法与2次幂容量的关系

        先贴出hash()代码:

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

图(7) HashMap#hash()方法 

这段代码非常精炼。疑问来了,为什么不直接使用key.hashCode()获取的值 ?而是再次对它的高16位(无符号右移16位)进行亦或运算呢?为什么是亦或不是或(|)、与(&)呢?

1、为什么要与高16位进行亦或运算?

先给出答案:让索引分布更均匀,让所有位参与到运算当中。

解答:
        在前面的代码里,我们会看到计算一个Key的索引使用的公式为:tab[].lenght - 1 & hash,与(&)运算实际上只会让hash与lenght表示相对应的位进行有效运算,那么hash相对应lenght最高位以前的位都将被抛弃,那么被抛弃的位将无法影响到索引值,没有有效参与到运算当中。因hash是一个int值,int值在java中固定占用4个字节(32位),故为让更多位参与到运算当中,使用前16位与后16位进行位运算,使得可能的结果分布更均匀。当然这仅仅是我的个人理解,也有其他许多解释。

2、为什么使用亦或而非其他呢?

先给出答案:保证1和0比例均匀。

解答:
        看到过一个非常好的例子,位只会有两种值:1或0,那么它们进行运算的可能有4种:1和1,1和0,0和1,0和0。

  • 使用 & 运算符得到的结果分别是:1,0,0,0  ;1与0比例为 1/3
  • 使用 | 运算符得到的结果分别是:1,1,1,0    ;1与0比例为3/1
  • 使用 ^ 运算符得到的结果分别是:1,0,0,1   ;1与0比例为2/2

很直观,亦或运算得到的值更均匀。

  3、为什么扩容一定要是2的幂倍增长?

先给出答案:更好的复用以前的索引计算结果

解答:
        前面的HashMap#resize()方法解析提到过:“在新表中所有的元素要么在以前的位置,要么以2倍偏移”,从代码逻辑中也可以看到:

        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node 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)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        Node loHead = null, loTail = null;
                        Node hiHead = null, hiTail = null;
                        Node 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;
                        }
                    }
                }
            }

图(8)HashMap#resize()中扩容后的数据复制代码摘要 

 只需要重组对应索引下的链表或红黑树再将它们分别放置原位(loTail)或2倍原位(hiTail)中。


尾声

        目前我觉得HashMap中重要的知识和逻辑就以上这些,设计相当精巧,代码利落不啰嗦。值得去研究研究。

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/274730.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号