美文网首页
HashMap 1.8 源码分析

HashMap 1.8 源码分析

作者: wuchao226 | 来源:发表于2019-06-03 15:48 被阅读0次

    概述

    HashMap 是用于映射(键值对)处理的数据类型。随着 JDK(Java Developmet Kit)版本的更新,JDK1.8对HashMap底层的实现进行了优化,JDK1.8对 HashMap 低层的实现进行了优化,例如:引入红黑树的数据结构和扩容的优化等。

    概要

    HashMap 是一个关联数组、哈希表,是线程不安全的,允许key为null,value为null。遍历时无序。

    其底层数据结构是数组称之为哈希桶,每个桶里面放的是链表,链表中的每个节点就是哈希表中的每个元素

    在 JDK8 中,当链表长度达到8时会转化为红黑树,以提升它的查询、插入效率,它实现了Map<K,V>, Cloneable, Serializable接口。

    Java 为数据结构中的映射定义了一个接口 java.util.Map,此接口主要有四个常用的实现类,分别是 HashMap、Hashtable、LinkedHashMap 和 TreeMap,类继承关系如下图所示:


    1、HashMap:根据健的HashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序是不确定的。HashMap 最多只允许一条记录的健为null,允许多条记录的值为null。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections 的 synchronizedMap 方法使 HashMap 具有线程安全的能力,或者使用 ConcurrentHashMap。

    2、Hashtable:Hashtable 是遗留类,很多映射的常用功能与 HashMap 类似,不同的是它承自 Dictionary 类,并且是线程安全的,任一时间只有一个线程能写Hashtable,并发性不如 ConcurrentHashMap,因为 ConcurrentHashMap 引入了分段锁。Hashtable 不建议在新代码中使用,不需要线程安全的场合可以用 HashMap 替换,需要线程安全的场合可以用 ConcurrentHashMap 替换。

    3、 LinkedHashMap:LinkedHashMap是 HashMap 的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。

    4、 TreeMap:TreeMap 实现 SortedMap 接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用 Iterator 遍历TreeMap 时,得到的记录是排过序的。如果使用排序的映射,建议使用TreeMap。在使用TreeMap时,key必须实现Comparable接口或者在构造TreeMap传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException类型的异常。

    对于上述四种Map类型的类,要求映射中的key是不可变对象。不可变对象是该对象在创建后它的哈希值不会被改变。如果对象的哈希值发生变化,Map对象很可能就定位不到映射的位置了。

    HashMap 内部实现

    存储结构-字段

    从结构实现来讲,HashMap 是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的,如下图所示:


    (1) 从源码可知,HashMap类中有一个非常重要的字段,就是 Node[] table,即哈希桶数组,明显它是一个Node的数组。

        //链表节点
        static class Node<K,V> implements Map.Entry<K,V> {
            final int hash; //哈希值
            final K key; //key
            V value; //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; }
            //每一个节点的 hash 值,是将 key 的 hashCode 和 value 的 hashCode ^(异或)得到的
            public final int hashCode() {
                return Objects.hashCode(key) ^ Objects.hashCode(value);
            }
            //设置新的 value ,同时返回旧的 value
            public final V setValue(V newValue) {
                V oldValue = value;
                value = newValue;
                return oldValue;
            }
    
            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;
            }
        }
    

    由此可知,这是一个单链表~。每一个节点的 hash 值,是将 key的 hashCode 和 value 的 hashCode 异或得到的。
    Node 是 HashMap 的一个内部类,实现了Map.Entry接口,本质是就是一个映射(键值对)。上图中的每个黑色圆点就是一个Node对象。

    (2) HashMap就是使用哈希表来存储的。哈希表为解决冲突,可以采用开放地址法和链地址法等来解决问题,Java中HashMap采用了链地址法。链地址法,简单来说,就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被Hash后,得到数组下标,把数据放在对应下标元素的链表上。
    例如程序执行下面代码:

    map.put("示例","helloword");
    

    系统将调用"示例"这个key的 hashCode() 方法得到其 hashCode 值(该方法适用于每个Java对象),然后再通过 Hash 算法的后两步运算(高位运算和取模运算)来定位该键值对的存储位置,有时两个 key 会定位到相同的位置,表示发生了Hash碰撞。当然Hash算法计算结果越分散均匀,Hash碰撞的概率就越小,map的存取效率就会越高。

    源码分析

    构造方法

       /**
         * 初始化桶大小,因为底层是数组,所以这是数组默认的大小
         */
        static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    
        /**
         * 桶最大容量 2的30次方
         */
        static final int MAXIMUM_CAPACITY = 1 << 30;
    
        /**
         * 默认的加载因子
         */
        static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
        /**
         *用于判断是否需要将链表转换为红黑树的阈值。
         */
        static final int TREEIFY_THRESHOLD = 8;
    
        /**
         *哈希桶,存放链表。 长度是2的N次方,或者初始化时为0.
         */
        transient Node<K,V>[] table;
    
        /**
         * 哈希表内元素数量的阈值,当哈希表内元素数量超过阈值时,会发生扩容resize()。
         */
        int threshold;
    
        /**
         * 加载因子,用于计算哈希表元素数量的阈值。  threshold = 哈希桶.length * loadFactor;
         */
        final float loadFactor;
    
        //同时指定初始化容量 以及 加载因子, 用的很少,一般不会修改loadFactor
        public HashMap(int initialCapacity, float loadFactor) {
             //边界处理
            if (initialCapacity < 0)
                throw new IllegalArgumentException("Illegal initial capacity: " +
                                                   initialCapacity);
            //初始容量最大不能超过2的30次方
            if (initialCapacity > MAXIMUM_CAPACITY)
                initialCapacity = MAXIMUM_CAPACITY;
            //显然加载因子不能为负数
            if (loadFactor <= 0 || Float.isNaN(loadFactor))
                throw new IllegalArgumentException("Illegal load factor: " +
                                                   loadFactor);
            this.loadFactor = loadFactor;
            //设置阈值为  》=初始化容量的 2的n次方的值
            this.threshold = tableSizeFor(initialCapacity);
        }
    
        public HashMap(int initialCapacity) {
            //指定初始化容量的构造函数
            this(initialCapacity, DEFAULT_LOAD_FACTOR);
        }
    
        public HashMap() {
            //默认构造函数,赋值加载因子为默认的0.75f
            this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
        }
        //新建一个哈希表,同时将另一个map m 里的所有元素加入表中
        public HashMap(Map<? extends K, ? extends V> m) {
            this.loadFactor = DEFAULT_LOAD_FACTOR;
            putMapEntries(m, false);
        }
    
       /**
         *根据期望容量cap,返回2的n次方形式的 哈希桶的实际容量 length。 返回 
         *值一般会>=cap 
         */
        static final int tableSizeFor(int cap) {
            //经过下面的 或 和位移 运算, n最终各位都是1。
            int n = cap - 1;
            n |= n >>> 1;
            n |= n >>> 2;
            n |= n >>> 4;
            n |= n >>> 8;
            n |= n >>> 16;
            //判断n是否越界,返回 2 的 n 次方作为 table(哈希桶)的阈值
            return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
        }
    

    接下来分析一下这个方法,对于无符号右移运算符不了解的,可以看一下这篇文章了解一下(https://www.jianshu.com/p/927009730809

    第一步 int n = cap - 1; 这个操作,执行这个操作的主要原因是为了防止在cap已经是2的n次幂的情况下,经过运算后得到的结果是cap的二倍的结果,例如如果n为l6,经过一系列运算之后,得到的结果是0001 1111,此时最后一步n+1 执行之后,就会返回32。

        /**
         * 将另一个Map的所有元素加入表中,参数evict初始化时为false,其他情况为true
         */
        final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
            //拿到m的元素数量
            int s = m.size();
            //如果数量大于0
            if (s > 0) {
                //如果当前表是空的
                if (table == null) { // pre-size
                    //根据m的元素数量和当前表的加载因子,计算出阈值
                    float ft = ((float)s / loadFactor) + 1.0F;
                    //修正阈值的边界 不能超过MAXIMUM_CAPACITY
                    int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                             (int)ft : MAXIMUM_CAPACITY);
                    //如果新的阈值大于当前阈值
                    if (t > threshold)
                        //返回一个 》=新的阈值的 满足2的n次方的阈值
                        threshold = tableSizeFor(t);
                }
                 //如果当前元素表不是空的,但是 m 的元素数量大于阈值,说明一定要扩容。
                else if (s > threshold)
                    resize();
                //遍历 m 依次将元素加入当前表中。
                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);
                }
            }
        }
    

    下面看一下扩容函数

        /**
         * 初始化或加倍哈希桶大小。如果是当前哈希桶是null,分配符合当前阈值的初 
         * 始容量目标。否则,因为我们扩容成以前的两倍。
         * 在扩容时,要注意区分以前在哈希桶相同index的节点,现在是在以前的 
         * index里,还是index+oldlength 里
         */
        final Node<K,V>[] resize() {
            //oldTab 为当前表的哈希桶
            Node<K,V>[] oldTab = table;
            //当前哈希桶的容量 length
            int oldCap = (oldTab == null) ? 0 : oldTab.length;
            //当前的阈值
            int oldThr = threshold;
            //初始化新的容量和阈值为0
            int newCap, newThr = 0;
            //如果当前容量大于0
            if (oldCap > 0) {
                //如果当前容量已经到达上限
                if (oldCap >= MAXIMUM_CAPACITY) {
                    //则设置阈值是2的31次方-1
                    threshold = Integer.MAX_VALUE;
                    //同时返回当前的哈希桶,不再扩容
                    return oldTab;
                }
                //否则新的容量为旧的容量的两倍。 
                else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                         oldCap >= DEFAULT_INITIAL_CAPACITY)//如果旧的容量大于等于默认初始容量16
                    //那么新的阈值也等于旧的阈值的两倍
                    newThr = oldThr << 1; // double threshold
            }
            //如果当前表是空的,但是有阈值。代表是初始化时指定了容量、阈值的情况
            else if (oldThr > 0) // initial capacity was placed in threshold
                //那么新表的容量就等于旧的阈值
                newCap = oldThr;
            else { //如果当前表是空的,而且也没有阈值。代表是初始化时没有任何容量/阈值参数的情况               // zero initial threshold signifies using defaults
                //此时新表的容量为默认的容量 16
                newCap = DEFAULT_INITIAL_CAPACITY;
                //新的阈值为默认容量16 * 默认加载因子0.75f = 12
                newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
            }
            //如果新的阈值是0,对应的是  当前表是空的,但是有阈值的情况
            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<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
            //更新哈希桶引用
            table = newTab;
            //如果以前的哈希桶中有元素
            //下面开始将当前哈希桶中的所有节点转移到新的哈希桶中
            if (oldTab != null) {
                //遍历老的哈希桶
                for (int j = 0; j < oldCap; ++j) {
                    //取出当前的节点 e
                    Node<K,V> e;
                    //如果当前桶中有元素,则将链表赋值给e
                    if ((e = oldTab[j]) != null) {
                        //将原哈希桶置空以便GC
                        oldTab[j] = null;
                        //如果当前链表中就一个元素,(没有发生哈希碰撞)
                        if (e.next == null)
                            //直接将这个元素放置在新的哈希桶里。
                            //注意这里取下标 是用 哈希值 与 桶的长度-1 。 由于桶的长度是2的n次方,这么做其实是等于 一个模运算。但是效率更高
                            newTab[e.hash & (newCap - 1)] = e;
                         //如果发生过哈希碰撞 ,而且是节点数超过8个,转化成了红黑树
                        else if (e instanceof TreeNode)
                            ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                         //如果发生过哈希碰撞,节点数小于8个。则要根据链表上每个节点的哈希值,依次放入新哈希桶对应下标位置。
                        else { // preserve order
                            //因为扩容是容量翻倍,所以原链表上的每个节点,现在可能存放在原来的下标,即low位, 或者扩容后的下标,即high位。 high位=  low位+原哈希桶容量
                            //低位链表的头结点、尾节点
                            Node<K,V> loHead = null, loTail = null;
                            //高位链表的头节点、尾节点
                            Node<K,V> hiHead = null, hiTail = null;
                            //临时节点 存放e的下一个节点
                            Node<K,V> next;
                            do {
                                next = e.next;
                                //这里又是一个利用位运算 代替常规运算的高效点: 利用哈希值 与 旧的容量,可以得到哈希值去模后,是大于等于oldCap还是小于oldCap,等于0代表小于oldCap,应该存放在低位,否则存放在高位
                                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);//循环直到链表结束
                            //将低位链表存放在原index处,
                            if (loTail != null) {
                                loTail.next = null;
                                newTab[j] = loHead;
                            }
                             //将高位链表存放在新index处
                            if (hiTail != null) {
                                hiTail.next = null;
                                newTab[j + oldCap] = hiHead;
                            }
                        }
                    }
                }
            }
            return newTab;
        }
    

    resize方法逻辑比较复杂,需要静下心来一步步的分析,但是总的下来,分为以下几步:

    • 首先先判断当前table是否进行过初始化,如果没有进行过初始化,此处就解决了调用无参构造方法时候,threshold和initialCapacity 未初始化的问题,如果已经初始化过了,则进行扩容,容量为原来的二倍

    • 扩容后创建新的table,并对所有的数据进行遍历
      - 如果新计算的位置数据为空,则直接插入
      - 如果新计算的位置为链表,则通过hash算法重新计算下标,对链表进行分组
      - 如果是红黑树,则需要进行拆分操作

    往哈希表里插入一个节点的 putVal 函数,如果参数 onlyIfAbsent 是 true,那么不会覆盖相同 key 的值 value。如果 evict 是 false。那么表示是在初始化时调用的

     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) {
            //tab存放 当前的哈希桶, p用作临时链表节点  
            Node<K,V>[] tab; Node<K,V> p; int n, i;
            //如果当前哈希表是空的,代表是初始化
            if ((tab = table) == null || (n = tab.length) == 0)
                //那么直接去扩容哈希表,并且将扩容后的哈希桶长度赋值给n
                n = (tab = resize()).length;
            //如果当前index的节点是空的,表示没有发生哈希碰撞。 直接构建一个新节点Node,挂载在index处即可。
            //index 是利用 哈希值 & 哈希桶的长度-1,替代模运算
            if ((p = tab[i = (n - 1) & hash]) == null)
                tab[i] = newNode(hash, key, value, null);
            else {//否则 发生了哈希冲突。
                Node<K,V> e; K k;
                //如果哈希值相等,key也相等,则是覆盖value操作
                if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                    e = p;//将当前节点引用赋值给e
                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);
                            //如果追加节点后,链表数量》=8,则转化为红黑树
                            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;
                    }
                }
                //如果e不是null,说明有需要覆盖的节点,
                if (e != null) { // existing mapping for key
                     //则覆盖节点值,并返回原oldValue
                    V oldValue = e.value;
                    if (!onlyIfAbsent || oldValue == null)
                        e.value = value;
                     //这是一个空实现的函数,用作LinkedHashMap重写使用。
                    afterNodeAccess(e);
                    return oldValue;
                }
            }
            //如果执行到了这里,说明插入了一个新的节点,所以会修改modCount,以及返回null。
    
            //修改modCount
            ++modCount;
            //更新size,并判断是否需要扩容。
            if (++size > threshold)
                resize();
            //这是一个空实现的函数,用作LinkedHashMap重写使用。
            afterNodeInsertion(evict);
            return null;
        }
    

    newNode如下:构建一个链表节点

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

    从代码看,put方法分为三种情况:

    • table尚未初始化,对数据进行初始化
    • table已经初始化,且通过hash算法找到下标所在的位置数据为空,直接将数据存放到指定位置
    • table已经初始化,且通过hash算法找到下标所在的位置数据不为空,发生hash冲突(碰撞),发生碰撞后,会执行以下操作:
      - 判断插入的key如果等于当前位置的key的话,将 e 指向该键值对
      - 如果此时桶中数据类型为 treeNode,使用红黑树进行插入
      - 如果是链表,则进行循环判断, 如果链表中包含该节点,跳出循环,如果链表中不包含该节点,则把该节点插入到链表末尾,同时,如果链表长度超过树化阈值(TREEIFY_THRESHOLD)且table容量超过最小树化容量(MIN_TREEIFY_CAPACITY),则进行链表转红黑树(由于table容量越小,越容易发生hash冲突,因此在table容量<MIN_TREEIFY_CAPACITY 的时候,如果链表长度>TREEIFY_THRESHOLD,会优先选择扩容,否则会进行链表转红黑树操作)

    get方法,查找

    public V get(Object key) {
          Node<K,V> e;
          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;
            //不为空,表示key在数组中存在,接下来开始遍历链表获取红黑树,找出具体位置
            if ((tab = table) != null && (n = tab.length) > 0 &&
                (first = tab[(n - 1) & hash]) != null) {
                //如果链表或者红黑树的第一个节点 hash值,key相等,这个节点就是我们要找的,直接返回
                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);
                }
            }
            最后没有找到,直接返回null
            return null;
    }
    

    我们发现在链表中查找节点采用的是遍历的方式,所以一旦链表过长,查找性能就较慢,这也是为什么jdk1.8会在 链表长度超过阈值的时候将链表转换为红黑树的原因!(链表时间复杂度为O(n),红黑树为 O(logn).
    get方法相对于put来说,逻辑实在是简单太多了

    1. 根据hash值查找到指定位置的数据

    2. 校验指定位置第一个节点的数据是key是否为传入的key,如果是直接返回第一个节点,否则继续查找第二个节点

    3. 如果数据是TreeNode(红黑树结构),直接通过红黑树查找节点数据并返回

    4. 如果是链表结构,循环查找所有节点,返回数据

    5. 如果没有找到符合要求的节点,返回null

    在这个方法里面,需要注意的有两个地方:hash(key)和hash的取模运算 (n - 1) & hash

    ** hash(key)的源码**

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

    这段代码叫做扰动函数,也是hashMap中的hash运算,主要分为下面几步:

    • key.hashCode(),获取key的hashCode值,如果不进行重写的话返回的是根据内存地址得到的一个int值
    • key.hashCode() 获取到的hashcode无符号右移16位并和元hashCode进行^ ,这样做的目的是为了让高位与低进行混合,让两者都参与运算,以便让hash值分布更加均匀

    取模运算 (n - 1) & hash
    在hashMap的代码中,在很多地方都会看到类似的代码:

    first = tab[(n - 1) & hash])
    

    hash算法中,为了使元素分布的更加均匀,很多都会使用取模运算,在hashMap中并没有使用(n)%hash这样进行取模运算,而是使用(n - 1) & hash进行代替,原因是在计算机中,&的效率要远高于%;需要注意的是,只有容量为2的n次幂的时候,(n - 1) & hash 才能等效(n)%hash,这也是hashMap 初始化初始容量时,无论传入任何值,都会通过 tableSizeFor(int cap) 方法转化成2的n次幂的原因

    由HashMap哈希算法引出的求余%和与运算&转换问题
    https://www.cnblogs.com/ysocean/p/9054804.html

    HashMap 常见面试题
    http://www.importnew.com/31278.html

    相关文章

      网友评论

          本文标题:HashMap 1.8 源码分析

          本文链接:https://www.haomeiwen.com/subject/qgtgtctx.html