JDK源码解析集合篇--HashMap无敌全解析

前两篇写了Collection体系的List下的ArrayList与LinkedList,另外一部分是Set集合下的集合类,但是Set集合的实现类基本是由Map集合的实现类实现的,所以先分析一下重要的HashMap。
数组存储区间是连续的,占用内存严重,故空间复杂的很大。但数组的二分查找时间复杂度小,为O(1);数组的特点是:寻址容易,插入和删除困难;链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)。链表的特点是:寻址困难,插入和删除容易。综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构。
由第一篇综述JDK源码解析集合篇–综述 可以看到HashMap实现了Map接口。
类定义为:

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable

在上边的定义,我们可能会很奇怪,为什么HashMap继承了AbstractMap还要去实现Map接口呢,而且在HashSet,LinkedHashSet,LinkedHashMap都出现了这种定义,对于这个问题有很多解释,但集合类的作者Josh Bloch 承认这是个错误,具体可以看StackOverFlower上的解释Why does LinkedHashSet extend HashSet and implement Set
对于Map集合,我们常见的也就:HashMap,LinkedHashMap,Hashtable与TreeMap。由于HashMap中知识点很多,所以在面试中经常会考HashMap的实现原理。
HashMap是一种非常常见、方便和有用的集合,是一种键值对(K-V)形式(哈希表)的存储结构,下面将还是用图示的方式解读HashMap的实现原理,
下边关于HashMap的特点可做个总结:
HashMap是否允许空 ———– Key和Value都允许为空
HashMap是否允许重复数据——— Key重复会覆盖、Value允许重复(但hash可能会重复。冲突)
HashMap是否有序 ———无序,特别说明这个无序指的是遍历HashMap的时候,得到的元素的顺序基本不可能是put的顺序
HashMap是否线程安全———非线程安全(可能会出现死循环)

HashMap源码

阅读其实现,我们首先要看它的底层存储结构是什么,从结构实现来讲,HashMap是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的,如下如所示。
这里写图片描述
其实也就是利用哈希表(散列表)这种数据结构来实现的。关于哈希表:散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。这也就是我们的table数组(包括用来解决冲突的链表结构)。
下图给出HashMap的字段:
这里写图片描述

从HashMap的属性值,我们可以很好理解上边的说法。我们可以看一下Node的定义:

//但链表结构
 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) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
  }

要注意:在JDK1.8后,加入了红黑树进行优化,存储红黑树的是TreeNode:

    static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }
    }
    static class Entry<K,V> extends HashMap.Node<K,V> {
        Entry<K,V> before, after;
        Entry(int hash, K key, V value, Node<K,V> next) {
            super(hash, key, value, next);
        }
    }

我们从代码中可以看到,TreeNode加入相应的红黑树的定义属性,TreeNode是Node的子类,所以table数组就定义的是Node[],这并不会影响后边转红黑树的操作。

字段分析

HashMap就是使用哈希表来存储的。哈希表为解决冲突,可以采用开放地址法和链地址法等来解决问题,Java中HashMap采用了链地址法。链地址法,简单来说,就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被Hash后,得到数组下标,把数据放在对应下标元素的链表上。
注意:如果哈希桶数组很大,即使较差的Hash算法也会比较分散,如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少Hash碰撞。那么通过什么方式来控制map使得Hash碰撞的概率又小,哈希桶数组(Node[] table)占用空间又少呢?答案就是好的Hash算法和扩容机制。

    //初始化node数组大小为16    
    //The default initial capacity - MUST be a power of two.  这跟hash算法有关
    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;//填充比
    //当add一个元素到某个位桶,其链表长度达到8时将链表转换为红黑树
    static final int TREEIFY_THRESHOLD = 8;
    //由树转换成链表的阈值UNTREEIFY_THRESHOLD  当进行删除操作时,转成链表的的阈值
    static final int UNTREEIFY_THRESHOLD = 6;
    //在转变成树之前,还会有一次判断,只有键值对数量(size)大于 64 才会发生转换。这是为了避免在哈希表建立初期,
    //多个键值对恰好被放入了同一个链表中而导致不必要的转化。这个至少是TREEIFY_THRESHOLD 的4倍
    static final int MIN_TREEIFY_CAPACITY = 64;

    transient Node<k,v>[] table;//存储元素的数组

    transient Set<map.entry<k,v>> entrySet;
    //存放元素node的个数
    transient int size;
    //被修改的次数fast-fail机制  记录结构变化的次数(主要是删除添加)
    transient int modCount;
    //临界值 当实际大小(容量*填充比)超过临界值时,会进行扩容  也就是。
    //threshold = length * Load factor 当size>threshold 时,进行扩容
    int threshold;
    final float loadFactor;  //负载因子

结合负载因子的定义公式可知,threshold就是在此Load factor和length(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍。默认的负载因子0.75是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下,如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。
当在创建HashMap时,可以指定数组的初始化大小和负载因子。其构造器源码为:

    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

看上边的代码时,不知道大家有没有想,到底table数组和threshold都在哪初始化的,我在看的时候也是找了好久,由构造器我们可以看到,这时候还没有进行数组初始化工作,当我们添加元素时:

    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<K,V>[] tab; Node<K,V> p; int n, i;
        //当table还没初始化时,就进行扩容操作,也就是在这里进行的初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
     }

在resize()函数中,你可以看到起初始化工作,在这里我不详细解释其代码,在后边扩容讲解时,在详细介绍。其实就是下边的代码:

      newCap = DEFAULT_INITIAL_CAPACITY;
      newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
      threshold = newThr;
      @SuppressWarnings({"rawtypes","unchecked"})
      Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
      table = newTab;

hash原理

为了减少冲突,设计好的哈希函数是非常必要的。如果完全没冲突,则HashMap增删该查的时间复杂度将为O(1)。这里还要强调一下:当使用某个位置的链表长度大于8时,转化为红黑树,使查找/删除/添加的时间复杂度是O(logn),而不会是O(n)。 在冲突的那个bin上插入的时间复杂度是O(n),源码是插入到链表最后,因为它要先寻址,因为它先要查找是否有重复的key,再执行插入。
为了减少冲突:在HashMap中,哈希桶数组table的长度length大小必须为2的n次方(一定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数,具体证明可以参考http://blog.csdn.net/liuqiyao_01/article/details/14475159,Hashtable初始化桶大小为11,就是桶大小设计为素数的应用(Hashtable扩容后不能保证还是素数)。HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。
为什么要把length设置为2的n次方,这在后边的哈希算法中有应用:JDK1.8的哈希函数为:

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

这里的哈希函数就是3步:1 取key的hashcode值 2 将高位与低位进行运算(通过hashCode()的高16位异或低16位实现的,主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。) 3 取模定位到数组位置
我们会思考,为什么这里没有定位取模这一步呢,这是因为这一步运算又被融合到了put操作中,我们可以单独取出这一个操作:

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

我们这里的取模运算非常巧妙地运用了数组长度也就是n只能是2的n次幂的特点,利用&操作代替mod操作,提高了效率。具体的解释可以看下图:
这里写图片描述

put方法解析

添加元素的流程图为:
这里写图片描述

下边结合代码来解释一下此过程:

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

  /**
     * Implements Map.put and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        //table为null,则说明table数组还没有进行初始化,在resize中初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        //如果定位到table[i]没元素,则直接放入到该位置,并符p赋值
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        //如果已经有元素,则进行冲突解决
        else {
            Node<K,V> e; K k;
            //p是table[i]的Node值,如果插入的Node的key与此Node值相等(hash和equals相等)
            //则将e赋值为p
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
           //判断是否是TreeNode,是的话执行红黑树插入,不是的话执行链表插入
            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;
                    }
                    //若key相等,则e是不为null的
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            //e不为null,说明有key重复,则直接覆盖e指向的node的值
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        //size+1,看是否需要扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

上边的源码中,逻辑非常清楚,实现也非常巧妙,看完很有感觉。从源码中,可以看到,新put的node,如果有冲突,会放到链表尾部。
上边的红黑树插入,以及树化操作时红黑树的内容,较为复杂,这里先不进行解释。关于红黑树可参看:
红黑树概念、红黑树的插入及旋转操作详细解读红黑树的移除节点操作
下边我们重点看一下HashMap的扩容过程。

扩容机制

下边是JDK1.8 hashMap的扩容代码:在代码中进行注释解释。
在开始之前,因为涉及rehash过程,在解释代码之前,必须要搞明白jdk1.8的一个优化点:在进行rehash时,1.7是重新计算hash然后进行&(n-1)定位的,但是jdk1.8进行了优化,解决了重新计算hash进行定位的计算。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,相当于多了一个高位1。
这里写图片描述
这里写图片描述
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。
其实我觉得这样也并没有进行多少优化,因为1.7进行定位时:hash也是知道的,只是进行hash&(n-1)进行定位的,与hash&n == 1{i+n} 都是位运算,也差不多。
但这也是为什么长度取2的幂的另一个应用。

 /**
     * Initializes or doubles table size.  If null, allocates in
     * accord with initial capacity target held in field threshold.
     * Otherwise, 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.
     *
     * @return the table
     */
    //完成扩容(容量变为原来的2倍),且完成rehash
    final Node<K,V>[] resize() {
        //获得原来的table数组
        Node<K,V>[] oldTab = table;
        //原table数组的容量
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        //原扩容阈值
        int oldThr = threshold;
        //定义新容量与阈值
        int newCap, newThr = 0;
        //如果原容量>0
        if (oldCap > 0) {
            //如果原容量已经达到最大了1<<30,则不进行扩容,只调整阈值为最大,随其碰撞了
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //如果没达到最大,则变为原来容量的2倍
            //其实这句可分解
            //newCap = oldCap << 1
            //如果扩容后的容量小于最大容量才会将阈值变为原来的2倍
            //else if (newCap < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
            //         newThr = oldThr << 1; // double threshold
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        //如果newCap = 0,oldThr > 0 这是适用于不同的构造函数的
        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);
        }
        //如果扩容后的容量大于最大容量了1<<30
        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;
        //完成rehash
        if (oldTab != null) {
            //对原数组的没一个位置   这是一个遍历   所以rehash过程的是很耗费时间的
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                //e = oldTab[j])
                if ((e = oldTab[j]) != null) {
                    //将原位置设为null
                    oldTab[j] = null;
                    //如果没有碰撞,也就是只有这一个元素,直接定位设置到新数组的位置
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    //如果当前节点是TreeNode类型,说明已经树化了,红黑树的rehash过程
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    //表明当前节点冲突是链表存储的,完成rehash   
                    //注意:这是1.8的优化点,这也是容量声明为2的次幂的另一个应用
                    else { // preserve order
                        //rehash后还是原位置
                        Node<K,V> loHead = null, loTail = null;
                        //rehash后变为j+oldCap位置
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            //如果扩大的那一位hash还是0,则说明它还是在原位置
                            if ((e.hash & oldCap) == 0) {
                                //如果它是第一个被加入的
                                if (loTail == null)
                                    loHead = e;
                                //进行链接
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            //定位到j+oldCap位置
                            else {
                                //如果它是第一个被加入的
                                if (hiTail == null)
                                    hiHead = e;
                               //进行链接     
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        //在newTab相应位置设置完成rehash的链表
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

从我上边的代码注释中,我相信大家已经很容易理解此过程,但是我们没有涉及红黑树的相应操作,单单看其链表的操作,我们就可以看到其代码写的确实很好。

与JDK1.7的区别

这是基于jdk1.8的,那它与1.7有什么区别呢?最大的区别当然是关于红黑树的那部分操作,另一部分是hash定位的算法,这在上边已经分析过了。还有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从1.8的源码可以看出,JDK1.8不会倒置。
JDK1.8中当元素定位的哈希桶是一个链表时,则采用尾插入法。首先从头遍历链表,根据equals和hashCode来比较key是否相同。因此作为hashMap的key必须同时重载equals方法和hashCode方法。
JDK 1.7的链表操作采用了头插入法,即新的元素插入到链表头部。在JDK1.8中采用了尾插入法。插入以后如果链表长度大于8,那么就会将链表转换为红黑树。因为如果链表长度过长会导致元素的增删改查效率低下,呈现线性搜索时间。JDK1.8采用采用红黑树进行优化,进而提高HashMap性能。
如果哈希桶是一个红黑树,则直接使用红黑树插入方式直接插入到红黑树中。
下边是jdk1.7的rehash过程。因为jdk1.7使用的是头插法,它依次遍历数组每个bin上的链表,完成rehash。
我们从代码中就不难理解,由于1.7采用的是头插法,所以JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置。(后在前,前在后)

  void transfer(Entry[] newTable) {
     Entry[] src = table;                   //src引用了旧的Entry数组
     int newCapacity = newTable.length;
     for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
         Entry<K,V> e = src[j];             //取得旧Entry数组的每个元素
         if (e != null) {
             src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
             do {
                 Entry<K,V> next = e.next;
                 int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
                 e.next = newTable[i]; //标记[1]
                 newTable[i] = e;      //将元素放在数组上
                 e = next;             //访问下一个Entry链上的元素
             } while (e != null);
         }
     }
 }

JDK1.8与JDK1.7对比,当hash冲突较多时,显然是JDK1.8效率更高。

线程安全性

HashMap线程不安全的体现在哪?主要是resize和迭代器的fail-fast上
在多线程使用场景中,应该尽量避免使用线程不安全的HashMap,而使用线程安全的ConcurrentHashMap。那么为什么说HashMap是线程不安全的,下面举例子说明在并发的多线程使用场景中使用HashMap可能造成死循环。resize死循环(会形成环形链表)。
jdk1.7出现的resize死循环比较好理解,可以参看这篇文章:谈谈HashMap线程不安全的体现 。但因为1.8保持了链表原来的顺序不变,JDK 1.8 是否会出现类似于 JDK 1.7中那样的死循环呢??这个有点不理解啊啊,求解答。。。。。。。

HashMap与Hashtable的区别

HashMap和Hashtable都实现了Map接口,但决定用哪一个之前先要弄清楚它们之间的分别。主要的区别有:线程安全性,同步(synchronization),以及速度。
第一、继承不同
第一个不同主要是历史原因。Hashtable是基于陈旧的Dictionary类的,HashMap是Java 1.2引进的Map接口的一个实现。但后来Hashtable也实现了map接口。
第二、线程安全不一样
Hashtable 中的方法是同步的(利用synchronized关键字实现),而HashMap中的方法在默认情况下是非同步的。在多线程并发的环境下,可以直接使用Hashtable,但是要使用HashMap的话就要自己增加同步处理了。
第三、允不允许null值
从上面的put()方法源码可以看到,Hashtable中,key和value都不允许出现null值,否则会抛出NullPointerException异常。
而在HashMap中,null可以作为键,这样的键只有一个;可以有一个或多个键所对应的值为null。当get()方法返回null值时,即可以表示 HashMap中没有该键,也可以表示该键所对应的值为null。因此,在HashMap中不能由get()方法来判断HashMap中是否存在某个键, 而应该用containsKey()方法来判断。
第四、遍历方式的内部实现上不同
Hashtable、HashMap都使用了 Iterator。而由于历史原因,Hashtable还使用了Enumeration的方式 。
第五、哈希值的使用不同
HashTable直接使用对象的hashCode。而HashMap重新计算hash值。

         //hashtable
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;

第六、内部实现方式的数组的初始大小和扩容的方式不一样
HashTable中的hash数组初始大小是11,增加的方式是 old*2+1。HashMap中hash数组的默认大小是16,而且一定是2的指数。

HashMap的遍历方法

HashMap有多种遍历方式,主要是依赖于迭代器(因为for-each也是利用迭代器实现的),这里就不详细介绍了,可以参看Java Map遍历方式的选择HashMap循环遍历方式及其性能对比
HashMap内容太多了,但搞懂还是有必要的,另外要想彻底搞定HashMap,ConcurrentHashMap更是并发学习的经典,在后边并发包的源码解析中再进行介绍。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章