HashMap源码分析

Overview

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

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

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

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

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

  和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源码

相关文章
相关标签/搜索