• 浅析Java源码之HashMap


      写这篇文章还是下了一定决心的,因为这个源码看的头疼得很。

      老规矩,源码来源于JRE1.8,java.util.HashMap,不讨论I/O及序列化相关内容。

      该数据结构简介:使用了散列码来进行快速搜索。(摘自Java编程思想)

      那么,文章的核心就探讨一下,内部是如何对搜索操作进行优化的。

      先来一张帅气的图片总览:

      预备知识:

    1、Map没有迭代器,但是可以通过Map.entry()生成一个Set容器,然后通过Set的迭代器遍历map元素。

    2、HashMap是乱序的。

    3、HashMap元素根据散列码分散在一个数组的不同索引中,利用了数组的快速搜索特性对get操作进行了优化。

    4、HashMap元素的保存形式为单向链表,是一个静态内部类。

      先过一遍这个内部类:

        static class Node<K,V> implements Map.Entry<K,V> {
            // hash值、key、value、后指针
            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;
            }
    
            public final K getKey()        { return key; }
            public final V getValue()      { return value; }
            public final String toString() { return key + "=" + value; }
    
            public final int hashCode() {
                return Objects.hashCode(key) ^ Objects.hashCode(value);
            }
    
            public final V setValue(V newValue) {
                // ...
            }
    
            public final boolean equals(Object o) {
                // ...
            }
        }

      代码非常简单,常规的get/set/equals,构造函数仅有一个指向下一个节点的指针,属于单向链表。

      还有一个新建Node的方法:

        Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
            return new Node<>(hash, key, value, next);
        }

      总览一下类的声明:

    public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
        // code...
    }

      其中AbstractMap类实现了大部分常规方法,诸如get、contain、remove、size等方法,但是put方法是一个没有实现的方法,仅抛出一个错误。

      至于Map接口,下载的源码包没有这个class的,所以暂时不知道内部的代码,不过影响不大。

      这里比较奇怪的是,类AbstractMap中实现了Map接口,这里HashMap又重新声明实现Map接口,不太懂为啥。

      

    变量

      HashMap中的变量比较多,如下:

        // 容器默认容量 必须为2的次方
        static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
        // 容器最大容量
        static final int MAXIMUM_CAPACITY = 1 << 30;
        // 默认负载参数
        static final float DEFAULT_LOAD_FACTOR = 0.75f;
        // 容器参数
        final float loadFactor;
        // 一个节点数组 HashMap的容器
        transient Node<K,V>[] table;
        // 保存所有map的Set容器 可以用来遍历、查询等
        transient Set<Map.Entry<K,V>> entrySet;
        // map对象数量
        transient int size;
        // 容量临界值 触发resize
        int threshold;
        // 将红黑树转换回链表的临界值
        static final int UNTREEIFY_THRESHOLD = 6;
        // 链表转树的临界值
        static final int TREEIFY_THRESHOLD = 8;
        // (感谢指正)当某一个数组索引处的Node数量大于此值时 触发resize并重新分配Node
        static final int MIN_TREEIFY_CAPACITY = 64;

      所有的容量与参数都是table相关,table就是开篇所讲的数组。

    构造函数

      

    1、无参构造函数

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

      简单的将默认负载参数赋值给负载参数。

    2、int单参数构造函数

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

      调用另外一个构造函数,第二个参数为默认的负载参数。

    3、int、float双参数构造函数

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

      错误处理就不管了,这里负载参数是正常的直接赋值,但是初始容器大小就不太一样了,是通过一个函数返回。

      这个函数很有意思:

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

      第一次看没搞懂,后面也没太看懂,于是尝试用个测试代码看一下输入值从0-100会输出什么。

      测试代码:

    public class suv {
        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 + 1;
        }
        public static void main(String[] args){
            for(int i=1;i<100;i++){
                System.out.print(tableSizeFor(i) + ",");
                if(i%20 == 0){System.out.println();}
            }
        }
    }

      输出如下:

      有非常明显的规律:

    1、输出均为2的次方

    2、输入值为大于该值的最小2次方数

      例如:输入5,大于5的最小2次方数为2的三次方8,所以输出为8。

      如果还不懂,可以看我自己写的方法,输出跟上面一样:

        static final int diyFn(int cap){
            int start = 1;
            for(;;){
                if(start >= cap){
                    return start;
                }
                start = start << 1;
            }
        }

      这里暂时不需要知道原因,只需要知道容量必须是2的次方。

    4、带有初始化集合的构造函数

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

      这里负载参数设置为默认的,然后调用putMapEntries方法初始化HashMap。

      这个方法会初始化一些参数,稍微看一下:

        final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
            int s = m.size();
            if (s > 0) {
                // 初始table为null
                if (table == null) { // pre-size
                    // 用负载参数进行计算
                    float ft = ((float)s / loadFactor) + 1.0F;
                    // 与最大容量作比较 返回对应的int类型值
                    int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                             (int)ft : MAXIMUM_CAPACITY);
                    // The next size value at which to resize (capacity * load factor).
                    if (t > threshold)
                        threshold = tableSizeFor(t);
                }
                // 扩容
                else if (s > threshold)
                    resize();
                // 插入处理
                for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
                    K key = e.getKey();
                    V value = e.getValue();
                    putVal(hash(key), key, value, false, evict);
                }
            }
        }

      这里的扩容类似于ArrayList的grow函数,不同的是这里扩容的算法是每次乘以2,并且存在一个负载参数来修正初次扩容的步数。

      threshold可以看注释,这是一个扩容临界值。当容器大小大于这个值时,就会进行resize扩容操作,临界值取决于当前容器容量与负载参数。

      接下来应该要进入resize函数,参照之前的ArrayList源码,这里也是先扩容得到一个新的数组,然后将所有节点进行转移。

      函数有点长,一步一步来:

        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;
                }
                // 容量与临界值同时<<1
                else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                         oldCap >= DEFAULT_INITIAL_CAPACITY)
                    newThr = oldThr << 1;
            }
            // 下面的else均代表旧数组为空
            else if (oldThr > 0)
                // 新容量设置为旧的临界值
                newCap = oldThr;
            else {
                // 当容器为空时 初始化所有参数
                newCap = DEFAULT_INITIAL_CAPACITY;
                newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
            }
            // 这里的情况是初始化一个空HashMap 然后调用putAll插入大量元素触发的resize
            // 新临界值为新容量与负载参数相乘
            if (newThr == 0) {
                float ft = (float)newCap * loadFactor;
                newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                          (int)ft : Integer.MAX_VALUE);
            }
            // 新临界值
            threshold = newThr;
            
            // ...数组操作
        }

      首先第一步是参数修正,包括临界值与容器容量。

      接下来就是数组操作,如下:

        final Node<K,V>[] resize() {
            // 参数修正
            
            @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;
                                // 两个分支都是执行链表的链接
                                // 由于数组扩容 所以对于(length-1) & hash的运算会改变 所以对原有的数组内容重新分配
                                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;
                                // oldCap为旧容量
                                newTab[j + oldCap] = hiHead;
                            }
                        }
                    }
                }
            }
            return newTab;
        }

      至此,可以看出,数组保存了一系列单向链表的第一个元素。

    核心讲解

      这里存在一个核心运算,即:

        newTab[e.hash & (newCap - 1)] = e;

      之前讲过扩容,每次扩容的容量都是2的次方,为什么必须是呢?这里就给出了答案。

      开篇讲过,该数据结构是通过hash值来优化搜索,这里就用到了hash值。但是hash值是不确定的,如何保证元素分配到的索引平均分配到数组的每一个索引,并且不会超过索引呢?

      答案就是这个运算,这里举一个例子:

      比如说容量为默认的16,此时的二进制表示为10000,减1后会得到01111。

      与运算应该都不陌生,两个都为1时才会返回1。

      由于高位会自动补0,所以任何数与01111做与运算时,高位都是0,范围限定在 00000 ~ 01111,十进制表示就是0 - 15,巧的是,容量为16的数组,索引恰好是[0] - [15]。

      这就解释了为什么容量必须为2的次方,而且元素是如何被平均分配到数组中的。

        (e.hash & oldCap) == 0

      这是用来区分lo、hi的运算,注释中已经解释了为什么需要做切割,这里给一个简图说明一下:

      

      首先,假设这个tab容量目前是8,而索引0中的节点太多了(这里应该是树,懒得画了),于是触发了resize,并将该索引每个节点的hash值按照上面的那个计算,判断是否需要移动。

      经过重分配,数组大概变成了这样:

      扩容后,会进行插入操作,留到下一部分解释。

      由于大体上的思想已经很明显了,下面看一下增删改查的API。

    方法

      按照增删改查的顺序。

      首先看一眼

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

      方法需要传入键值对,返回值。这里调用了内部的添加方法,其中散列码用的是key的,这里的hash并不是直接用hashCode方法,而是内部做了二次处理。

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

      这个运算没啥讲的,当成返回一个随机数就行了。

      下面是putVal的完整过程:

        final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
            Node<K, V>[] tab;
            Node<K, V> p;
            int n, i;
            // 初始化HashMap后第一次添加会调用resize初始化
            if ((tab = table) == null || (n = tab.length) == 0)
                // 返回扩容后的长度 默认情况下为1<<4
                n = (tab = resize()).length;
            // 又是位运算 这里代表该索引位没有链表 于是新建一个Node
            if ((p = tab[i = (n - 1) & hash]) == null)
                tab[i] = newNode(hash, key, value, null);
            else {
                Node<K, V> e;
                K k;
                // 传入元素的key与链表第一个元素的key相同
                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);
                            // 当链表的长度大于临界值时 调用treeifyBin
                            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;
        }

      这里的过程可以简述为:通过key的hash值计算出一个值作为索引,然后对索引处的链表进行插入或者修改操作

      但是这里还是有几个特殊的点:

    1、钩子函数

    2、当链表长度大于某个值时,会调用treeifyBin方法将链表转换为红黑树

      钩子函数是我自己取的名字,因为让我想到了vue生命周期的钩子函数。这两个方法都是本地已定义但是没有具体内容,是用来重写的函数。

      另外一个是treeifyBin方法,该方法将链表转换为红黑树结构保存:

        final void treeifyBin(Node<K,V>[] tab, int hash) {
            int n, index; Node<K,V> e;
            // 若小于最低树临界值 触发resize
            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比一般的链表还是牛逼一点,采用的键值对的泛型,而TreeNode本身是一个静态内部类,目前仅需要知道继承于LinkedHashMap.Entry,元素按照插入顺序进行排序。

      关于TreeNode转换的详解可以单独分一节讲了,这里暂时跳过吧。

      下面是

        public V remove(Object key) {
            Node<K,V> e;
            return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value;
        }

      直接看removeNode的实现:

        final Node<K,V> removeNode(int hash, Object key, Object value,boolean matchValue, boolean movable) {
            Node<K,V>[] tab; Node<K,V> p; int n, index;
            if ((tab = table) != null && (n = tab.length) > 0 &&
                (p = tab[index = (n - 1) & hash]) != null) {
                Node<K,V> node = null, e; K k; V v;
                // 当对应索引第一个链表元素就与key相等
                if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                    node = p;
                else if ((e = p.next) != null) {
                    // 红黑树删除
                    if (p instanceof TreeNode)
                        node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
                    else {
                        // 遍历链表对key做比较
                        do {
                            if (e.hash == hash &&
                                ((k = e.key) == key ||
                                 (key != null && key.equals(k)))) {
                                node = e;
                                break;
                            }
                            p = e;
                        } while ((e = e.next) != null);
                    }
                }
                if (node != null && (!matchValue || (v = node.value) == value ||
                                     (value != null && value.equals(v)))) {
                    // 红黑树结构删除节点
                    if (node instanceof TreeNode)
                        ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                    // 当第一个元素被删除时 下一个被指定为索引处元素
                    else if (node == p)
                        tab[index] = node.next;
                    // 重新链接next
                    else
                        p.next = node.next;
                    ++modCount;
                    --size;
                    // 钩子函数
                    afterNodeRemoval(node);
                    // 返回删除的节点
                    return node;
                }
            }
            return null;
        }

      这里很简答,通过hash值快速找到对应的索引处,遍历链表或者红黑树进行查询,找到就删除节点并重新执行next链接。

      同样,这里也有一个钩子函数,参数为被删除的节点。

      由于改的情况在增的情况中已经提及,所以这里就跳过。

      最后看一眼查:

        public V get(Object key) {
            Node<K,V> e;
            return (e = getNode(hash(key), key)) == null ? null : e.value;
        }
        public boolean containsKey(Object key) {
            return getNode(hash(key), key) != null;
        }

      一个获取,一个查询,都指向同一个方法,所以看getNode的实现:

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

      没啥营养,常规的找索引,遍历,返回节点或者null。

      至此,HashMap的基本内部实现已经完事,红黑树转换另外开一篇单独弄。

  • 相关阅读:
    Learning Deep CNN Denoiser Prior for Image Restoration阅读笔记
    图象恢复学习笔记(二)
    Alpha matting算法发展
    structure machine learning projects 课程笔记
    improve deep learning network 课程笔记
    convolutional neural network 课程笔记
    Ajax上传File对象到服务器
    SQL Server存储过程模拟HTTP请求POST和GET协议
    Jexus是一款Linux平台上的高性能WEB服务器和负载均衡网关
    Maven安装jar包到本地仓库
  • 原文地址:https://www.cnblogs.com/QH-Jimmy/p/7804356.html
Copyright © 2020-2023  润新知