zoukankan      html  css  js  c++  java
  • HashMap源码分析

    Overview

      HashMap是Java编程中最常用的数据结构之一,本文基于JDK1.8从源码角度来分析HashMap的存储结构和常用操作。HashMap实现了Map接口,Map接口的实现类还有Hashtable、LinkedListHashMap和TreeMap。具体的继承结构请参考JDK Document。

      学过数据结构的同学都知道Hash表的实现方式,其实HashMap就是Hash表的一个实现。HashMap是key-value结构的,根据key的hashCode可以快速访问到key对应的value,访问操作的时间复杂度为O(1)。但HashMap在多线程的场景下并不能保证数据的一致性,如果要在多线程的场景下使用Map结构,可以考虑使用Collections工具类的synchronizedMap方法使HashMap变为线程安全的,同时也可以考虑使用ConcurrentHashMap。

      那HashMap和其他几个Map接口的实现类有什么区别呢?

      和Hashtable的区别:Hashtable是线程安全的,是JDK的遗留类,内部实现使用synchronized关键字对方法加锁,效率和并发性不好。在线程安全的场景下可以使用ConcurrentHashMap替代,ConcurrentHashMap内部实现使用了分段锁,效率和并发性都要比Hashtable好。另一个区别是HashMap可以有有个null键和多个null值,Hashtable是不可以的。

      和LinkedHashMap的区别:LinkedHashMap是Map的实现类同时也是HashMap子类,与HashMap不同的地方在于LinkedHashMap底层使用链表实现,因此LinkedHashMap能够维护记录插入顺序,能够按次序访问,而HashMap的key是无序的,这一点和HashSet一致。

      和TreeMap的区别:TreeMap实现了Map的同时也实现了SortedMap接口,底层基于RB-Tree(红黑树)实现,TreeMap能够根据自然序或者给定的比较器维护记录的存储顺序。需要注意的是,在使用TreeMap的时候key对象需要实现Comparable接口或者在构造TreeMap时传入自定义Comparator,否则会在运行时抛出java.lang.ClassCastException异常。

      在使用Map时,需要确保key对象是不可变的,也就是说key的hash是不会改变的,如果key的hash发生变化,就会出现key访问不到value的情况。需要保证equals()方法和hashCode()方法所描述的对象是一致的,即两个对象的equals()方法返回true那么这两个对象的hashCode()方法也要返回相同的值。这也是重写equals()方法通常也要重写hashCode()方法的原因。

    存储结构

      HashMap的结构是数组、链表和RB-Tree的组合,总体来说是数组用来进行hash寻址,用链表存储hash冲突的Entry,在冲突多时用RB-Tree来提高存取效率。

        在HashMap的结构中存储的是key-value实体Entry<K,V>,更准确的说是存储的Node<K,V>,Node<K,V>是HashMap的一个静态内部类,实现了Map.Entry接口。是key-value的包装类。

    static class Node<K,V> implements Map.Entry<K,V> {
            final int hash;
            final K key;
            V value;
            Node<K,V> next;
    
            Node(int hash, K key, V value, Node<K,V> 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) {
                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;
            }
        }

      HashMap中有一个Node[]类型的字段,用来当做hash桶,Node中hash字段用来快速定位hash桶的索引。

    transient Node<K,V>[] table; //(transient关键字作用是在序列化时过滤掉此字段)

      除此之外,HashMap还有几个比较重要的字段。

    //HashMap中所有key-value实体的集合
    transient Set<Map.Entry<K,V>> entrySet;
    //当前HashMap的大小(k-v实体个数)
    transient int size;
    //整个HashMap结构变化的次数
    transient int modCount;
    //在下次扩容之前能容纳k-v实体的最大值,threshold=(capacity * load factor)。
    int threshold;
    //负载因子
    final float loadFactor;

    初始化和扩容

      HashMap的初始化时把HashMap所需要的数据结构和字段构造出来,并给定初始字段值。比如构造Node数组,设定初始化容量和负载因子等。这些可以通过HashMap的构造方法来实现。如果构造HashMap时不指定initialCapacity和loadFactor就会使用默认值,initialCapacity的默认值是16,HashMap的最大容量是2^30;默认的loadFactor值为0.75,含义是在存储数量达到当前Node[]数组长度的75%时进行下一次扩容。默认0.75也是hash冲突和空间利用率之间的权衡。

      注意,loadFactor的值是可以大于1的,因为threshold=capacity * load factor,这里的capacity是Node[]数组的长度,除Node[]数组外使用链表和红黑树来存储冲突的记录,所以理论上整个HashMap对象存储的记录数可以大于capacity,也就是说size并不被capacity所限制。

      当HashMap存储的记录数达到threshold=capacity * load factor后就要进行一次扩容,把容量扩大到之前的2倍,具体方法使创建一个新的长度为原来2倍的Node[]数组替换掉之前的Node[]数组。替换数组并不是简单的拷贝而是要把记录分散在新的数组中。在JDK1.8以前是采用rehash的方法,JDK1.8对此做了优化,避免了重新计算hash而且能将记录均匀的分散在新的Node[]数组中。具体做法是,在Node[]数组扩容到原来的2倍时,key的hash长度在原来的基础上多出一位,那么这一位可以是0也可以是1,当是0时索引不变,1时索引变为原索引+原容量。因为0和1是可以认为是随机的所以均匀分布的效果和rehash理论上是一致的。

      来欣赏一下JDK1.8优化后的resize代码,简直是艺术品。

        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) {
                //超过2^30就不能再扩容了,把threshold设置为int最大值,就不会再扩容。
                if (oldCap >= MAXIMUM_CAPACITY) {
                    threshold = Integer.MAX_VALUE;
                    return oldTab;
                }
                //没有超过最大值就扩容到原先的2倍
                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
                //第一次初始化没有指定容量,使用默认容量16
                newCap = DEFAULT_INITIAL_CAPACITY;
                newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
            }
            if (newThr == 0) {
                //计算新的threshold
                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) {
                    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 { // 如果是链表节点,保留链表顺序
                            Node<K,V> loHead = null, loTail = null;
                            Node<K,V> hiHead = null, hiTail = null;
                            Node<K,V> next;
                            do {
                                next = e.next;
                                //如果新增高位为0,索引位置不变
                                if ((e.hash & oldCap) == 0) {
                                    if (loTail == null)
                                        loHead = e;
                                    else
                                        loTail.next = e;
                                    loTail = e;
                                }
                                //如果新增高位为1,索引位置变为原索引+oldCap
                                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;
                            }
                            //放置原索引+oldCap位置
                            if (hiTail != null) {
                                hiTail.next = null;
                                newTab[j + oldCap] = hiHead;
                            }
                        }
                    }
                }
            }
            return newTab;
        }

    put()方法分析

      弄清楚了HashMap的结构和扩容机制,put()和get()操作直接按照步骤来分析就可以了。put()操作的主要是如下几个步骤:

    • 首先判断Node[]数组table是否为空或null,如果是空那么进行一次resize,这次resize只是起到了一次初始化的作用。
    • 根据key的值计算hash得到在table中的索引i,如果table[i]==null则添加新节点到table[i],然后判断size是否超过了容量限制threshold,如果超过进行扩容。
    • 如果在上一步table[i]不为null时,判断table[i]节点是否和当前添加节点相同(这里使用hash和equals判断,因此需要保证hashCode()方法和equals()方法描述的一致性),如果相同则覆盖该节点的value。
    • 如果上一步判断table[i]和当前节点不同,那么判断table[i]是否为红黑树节点,如果是红黑树节点则在红黑树中添加此key-value。
    • 如果上一步判断table[i]不是红黑树节点则遍历table[i]链表,判断链表长度是否超过8,如果超过则转为红黑树存储,如果没有超过则在链表中插入此key-value。(jdk1.8以前使用头插法插入)。在遍历过程中,如果发现有相同的节点(比较hash和equals)就覆盖value。
    • 维护modCount和size等其他字段。
        public V put(K key, V value) {
            //传入key的hash值,对hashCode值做位运算
            return putVal(hash(key), key, value, false, true);
        }
    
        final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                       boolean evict) {
            Node<K,V>[] tab; Node<K,V> p; int n, i;
            //如果tab为null,则通过resize初始化
            if ((tab = table) == null || (n = tab.length) == 0)
                n = (tab = resize()).length;
            //计算key的索引,如果为当前位置为null,直接赋值
            if ((p = tab[i = (n - 1) & hash]) == null)
                tab[i] = newNode(hash, key, value, null);
            else {
                //如果当前位置不为null
                Node<K,V> 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<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);
                            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;
                }
            }
            //结构变化次数+1
            ++modCount;
            //如果size超过最大限制,扩容
            if (++size > threshold)
                resize();
            afterNodeInsertion(evict);
            return null;
        }

    get()方法分析

      明确了put()方法,get()方法的分析就变得非常容易了,首先看一下如何通过hash确定key在桶中的索引位置。

    static final int hash(Object key) {   //jdk1.8 & jdk1.7
         int h;
         // h = key.hashCode() 为第一步 取hashCode值
         // h ^ (h >>> 16)  为第二步 高位参与运算
         return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    //jdk1.8已经把这个方法省略了,但是在访问时直接使用这个计算策略。
    static int indexFor(int h, int length) {
         return h & (length-1);  //第三步 取模运算
    }

      如下就是get()方法的具体分析:

       public V get(Object key) {
            Node<K,V> e;
            //传入key的hash
            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;
            //这里访问(n - 1) & hash其实就是jdk1.7中indexFor方法的作用
            if ((tab = table) != null && (n = tab.length) > 0 &&
                (first = tab[(n - 1) & hash]) != null) {
                //判断桶索引位置的节点是不是相同(通过hash和equals判断),如果相同返回此节点
                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;
        }

    补充

       除以上的分析以外,HashMap还有许多其他方法,包括判空、删除、清空、替换、遍历以及JDK1.8新增的函数式语法和Lambda表达式的内容。代码总行数多达两千多行,如果感兴趣或遇到相应问题可以具体分析。已经了解了HashMap的存储结构和关键操作的步骤,再去分析其他方法就比较容易了。

    小结

      从以上的对HashMap源码的分析,可以得出一些使用上的技巧和有用的结论。

    • HashMap不是线程安全的,多线程的场景推荐使用ConcurrentHashMap。
    • JDK1.8对HashMap做了大量优化,值得尝试。
    • 在初始化时最好能够给出估算的容量大小,避免频繁扩容影响使用效率。
    • 负载因子是可以修改的,但是0.75是容量和冲突之间的权衡,如果不是目的特别明确不要轻易修改。
    • 重写equals()方法的同时也要重写hashCode()方法。
    • HashMap源码写的真棒:)

      参考资料:

      Java 8系列之重新认识HashMap

      Java™ Platform, Standard Edition 8 API Specification

      java.util.HashMap源码

      

      

  • 相关阅读:
    信号signal的监听与处理
    oracle 12cR1&12cR2核心高实用性新特性
    Tomcat 7服务器线程模型
    抓取awr、语句级awr、ashrpt
    从percona server 5.7换到mariadb 10.2
    关于typeid和typeof
    mysql查询INFORMATION_SCHEMA表很慢的性能优化
    使用ccache大幅度加速gcc编译速度至少1倍以上(不需要修改任何编译选项)
    c++ linux下输出中文
    visual studio 2015下使用gcc调试linux c++开发环境搭建完整详解
  • 原文地址:https://www.cnblogs.com/wxisme/p/8474672.html
Copyright © 2011-2022 走看看