zoukankan      html  css  js  c++  java
  • HashMap的结构算法及代码分析

    HashMap算是日常开发中最长用的类之一了,我们应该了解它的结构跟算法:
    参考文章:

    数据结构中有数组核链表来实现对数据的存储,但这两者基本上是两个极端。
    数组:存储区间是连续的,占用内存严重,故空间复杂的很大。但数组的二分查找时间复杂度小,为O(1);数组的特点是:寻址容易,插入和删除困难;
    链表:存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)。链表的特点是:寻址困难,插入和删除容易。
    哈希表:那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?答案是肯定的,这就是我们要提起的哈希表。哈希表((Hash table)既满足了数据的查找方便,同时不占用太多的内容空间,使用也十分方便。
      哈希表有多种不同的实现方法,我接下来解释的是最常用的一种方法—— 拉链法,我们可以理解为“链表的数组” ,如图:

      从上图我们可以发现哈希表是由数组+链表组成的,一个长度为16的数组中,每个元素存储的是一个链表的头结点。那么这些元素是按照什么样的规则存储到数组中呢。一般情况是通过hash(key)%len获得,也就是元素的key的哈希值对数组长度取模得到。比如上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。所以12、28、108以及140都存储在数组下标为12的位置。
    HashMap其实也是一个线性的数组实现的,所以可以理解为其存储数据的容器就是一个线性数组。这可能让我们很不解,一个线性的数组怎么实现按键值对来存取数据呢?这里HashMap有做一些处理。
      首先HashMap里面实现一个静态内部类Entry(jdk8改为用Node了),其重要的属性有 key , value, next,从属性key,value我们就能很明显的看出来Entry就是HashMap键值对实现的一个基础bean,我们上面说到HashMap的基础就是一个线性数组,这个数组就是Entry[](jdk8就是Node[]),Map里面的内容都保存在Entry[]里面。

    ------------------------------------------------------------
    HashMap的hash方法:
    static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    所谓的高位参与运算,就是key的hashCode异或hashCode的高16位。
    HashMap的put方法:

    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;
        if ((tab = table) == null || (n = tab.length) == 0)  // entry数组为空,new HashMap的时候,并没有初始化数组
            n = (tab = resize()).length;  //初始化数组,稍后看resize()实现
        if ((p = tab[i = (n - 1) & hash]) == null)  //分配到的数组位置为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)))) // key跟链表第一个元素的key相同,这里比较的是hashcode跟equals,从本方法以及Node的构造方法来看,Node的hash就是其中键的hash值
                e = p;  // 注意,这里找到后并没有修改节点的值,节点的值是在后边修改的
            else if (p instanceof TreeNode) //是treenode,jdk8在链表长度超过8后将node改为红黑树进行优化
                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);  // 结合Node的构造方法,node的hash就是此处的hash
                        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))))  //链表有节点的hash跟新元素相同并且equals判断也相等
                        break;    //找到了,终止循环
                    p = e;  // 注意,这句不在if范围内,仅仅在for循环内而已,所以如果已经有对应节点已经有oldValue的情况下,并没有修改原来的值,修改的操作在后边
                }
            }
            if (e != null) { // existing mapping for key      //有oldValue的情况,e就是旧节点
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)   // 允许重写原来值,或者原来的值为null,注意,onlyIfAbsent,这个属性hashmap实际没用,一直是默认false,也就一直默认覆盖原来的值 
                    e.value = value;
                afterNodeAccess(e); // 值更新之后的操作,hashmap中此方法为空,无任何操作,该方法以及后边的afterNodeInsertion()都是给LinkedHashMap用的。
                return oldValue;
            }
        }
        ++modCount;  // 修改计数器,其实只有新增才修改了此值,覆盖原来值的操作,因为提前return 了,此值没有改变
        if (++size > threshold)  // hashMpa的容量达到了阈值
            resize();
        afterNodeInsertion(evict);
        return null;
    }
    // Callbacks to allow LinkedHashMap post-actions
    void afterNodeAccess(Node<K,V> p) { }
    void afterNodeInsertion(boolean evict) { }
    void afterNodeRemoval(Node<K,V> p) { }  

    链表的实现类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) { // 节点的hash就是所给元素的hash
            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) {
            V oldValue = value;
            value = newValue;
            return oldValue;
        }
        public final boolean equals(Object o) { // 
            if (o == this)  // 地址相等----------其实这个值不一定是内存地址,不同虚拟机实现不同,sun貌似是地址的一个hash值
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue()))  // 键跟值的equals都相等
                    return true;
            }
            return false;
        }
    } 
    

    非常重要的resize() 方法:

    /**
     * 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
     */
    
    大概意思是说,该方法用来初始化table或者使table的容量翻倍,如果原来table为空,则根据初始化参数进行初始化;否则,因为我们采用2的幂方案,每个链表的元素应该在容量翻倍后仍然位于新table的相同下标下或者移动到二倍下标位置。
    说明:这个地方jdk8比jdk7做了优化,如果不了解其原理的话,读起来非常费解。jdk7的时候,resize就是把数组扩展为2倍,遍历每个数组中的链表,
    进行重新hash取模找位置,而且链表元素添最先加的在后边,最后添加的在前边;jdk8对此做了优化,仍然扩展为2倍,遍历链表,但没有简单的进行hash取模找位置,
    而是采用了查看参与计算的新的一位的hash的值是1还是0的办法,是0则位置不变,是1则位置变为原index+size,而且新添加的元素放在了链表的尾部。
    其原理详解,摘抄自美团:https://tech.meituan.com/java-hashmap.html,写的非常好:
    下面举个例子说明下扩容过程(jdk7)。假设了我们的hash算法就是简单的用key mod 一下表的大小(也就是数组的长度)。其中的哈希桶数组table的size=2, 所以key = 3、7、5,put顺序依次为 5、7、3。在mod 2以后都冲突在table[1]这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小size 大于 table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize成4,然后所有的Node重新rehash的过程。 

    下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。 

    元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

    因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图: 

    这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了,当然图中显示的每隔一个分到相同下标,实际其实是无规律的。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。

    jdk8源码:

      1. final Node<K,V>[] resize() {
      2.     Node<K,V>[] oldTab = table;  //老的数组,也就是所谓的桶
      3.     int oldCap = (oldTab == null) ? 0 : oldTab.length;  // 老的容量
      4.     int oldThr = threshold;  // 阈值
      5.     int newCap, newThr = 0; 
      6.     if (oldCap > 0) { // 老的容量>0,说明map里已经有元素了
      7.         if (oldCap >= MAXIMUM_CAPACITY) {  // 老的容量已经很大了,直接把阈值扩展到最大,不再容量翻倍了,太费劲了,而且效率提升有限,此时容量2的29次方,大概在3亿
      8.             threshold = Integer.MAX_VALUE;
      9.             return oldTab;
      10.         }
      11.         else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) //老容量在2<<<28的时候还能翻倍,到了2<<<29就不翻倍了,凑合着用吧
      12.             newThr = oldThr << 1; // double threshold
      13.     }
      14.     else if (oldThr > 0) // initial capacity was placed in threshold  // 老容量==0,阈值>0,说明调用了new HashMap(容量=0,加载因子);
      15.         newCap = oldThr;
      16.     else {               // zero initial threshold signifies using defaults  //都是用默认值
      17.         newCap = DEFAULT_INITIAL_CAPACITY;    // 默认1<<<4 = 16
      18.         newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);  //默认16*0.75=12
      19.     }
      20.     if (newThr == 0) {  //上来就new HashMap(容量大于2<<<29);没有指定加载因子的情况,自己计算阈值
      21.         float ft = (float)newCap * loadFactor;  
      22.         newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
      23.                   (int)ft : Integer.MAX_VALUE);
      24.     }
      25.     threshold = newThr;
      26.     Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
      27.     table = newTab;
      28.     if (oldTab != null) {
      29.         for (int j = 0; j < oldCap; ++j) { // 重点来了,迁移老数组进新数组
      30.             Node<K,V> e;
      31.             if ((e = oldTab[j]) != null) {
      32.                 oldTab[j] = null;
      33.                 if (e.next == null) //原数组j下标下,就一个元素
      34.                     newTab[e.hash & (newCap - 1)] = e; //直接快速位与取模,找下标
      35.                 else if (e instanceof TreeNode)  //是红黑树节点
      36.                     ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
      37.                 else { // preserve order
      38.                     Node<K,V> loHead = null, loTail = null;  //这里分别lowHead tail跟highHead tail两个对象,稍后分析为啥这么写
      39.                     Node<K,V> hiHead = null, hiTail = null;
      40.                     Node<K,V> next;
      41.                     do { // j下标下有好多个链表的情况
      42.                         next = e.next;
      43.                         if ((e.hash & oldCap) == 0) {//hash中参与计算的新一位为0,下标不变; 计算方式参照上方图解;
      44.                             if (loTail == null)
      45.                                 loHead = e;
      46.                             else
      47.                                 loTail.next = e;
      48.                             loTail = e;
      49.                         }
      50.                         else {     // hash中参与计算的新一位为1,下标变为 原容量+原下标,不用重新计算了,省事儿,就是优化在这里了(实际这一步也是计算了,只是计算比原来简单了)
      51.                             if (hiTail == null)
      52.                                 hiHead = e;
      53.                             else
      54.                                 hiTail.next = e;
      55.                             hiTail = e;
      56.                         }
      57.                     } while ((e = next) != null);// 遍历下一个元素
      58.                     if (loTail != null) {   // 设置下标,放入新数组
      59.                         loTail.next = null;
      60.                         newTab[j] = loHead;
      61.                     }
      62.                     if (hiTail != null) {  // 设置下标,放入新数组
      63.                         hiTail.next = null;
      64.                         newTab[j + oldCap] = hiHead;
      65.                     }
      66.                 }
      67.             }
      68.         }
      69.     }
      70.     return newTab;
      71. }
    

    关于HashMap的初始容量:

    HashMap的几个构造方法最后都调用了这个构造方法:

      1. public HashMap(int initialCapacity, float loadFactor) {
      2.     if (initialCapacity < 0)   //给定初始长度<0则抛异常
      3.         throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
      4.     if (initialCapacity > MAXIMUM_CAPACITY)  //给定初始长度太大,则设为默认最大容量
      5.         initialCapacity = MAXIMUM_CAPACITY;
      6.     if (loadFactor <= 0 || Float.isNaN(loadFactor))   //给定负载因子<0或者不是数字,抛异常
      7.         throw new IllegalArgumentException("Illegal load factor: " +
      8.                                            loadFactor);
      9.     this.loadFactor = loadFactor;  //负载因子采用给定值
      10.     this.threshold = tableSizeFor(initialCapacity); //阈值利用初始容量,通过一个方法进行计算
      11. }
    

    计算方法:

      1. static final int tableSizeFor(int cap) { 
      2.     int n = cap - 1;   
      3.     n |= n >>> 1;   // n = n | n >>> 1  
      4.     n |= n >>> 2;
      5.     n |= n >>> 4;
      6.     n |= n >>> 8;
      7.     n |= n >>> 16;
      8.     return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
      9. }
    

      这个方法的作用就是无论初始容量是多少,最终的容量都将计算为不小于初始容量的2的最小次幂;比如初始容量为4,则经变化后初始容量仍为4,若初始容量为5,则变化后容量应该为8而不是5;之所以这么变化,是因为resize的机制中计算方式,table的长度必须为2的幂,否则计算方式会出错,而且计算起来会比现在复杂。

    HashMap的遍历:
      我们最常用的遍历方式为:
    Iterator<Map.Entry<String, String>> iterator;
    iterator = hashMap.entrySet().iterator();
    while (iterator.hasNext()) {
        Map.Entry<String, String> next = iterator.next();
        String key = next.getKey();
        String value = next.getValue();
    }
    

      我们通过map得到了一个entrySet对象,然后又获取了一个Iterator对象,最终就是通过这个Iterator来遍历的,那么这个Iterator是怎么个结构,如何实现遍历的呢?

    final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
        public final int size()                 { return size; }
        public final void clear()               { HashMap.this.clear(); }
        public final Iterator<Map.Entry<K,V>> iterator() {
            return new EntryIterator();
        } 
    //......
     }
    
      这个EntryIterator的代码:
    final class EntryIterator extends HashIterator
        implements Iterator<Map.Entry<K,V>> {
        public final Map.Entry<K,V> next() { return nextNode(); }
    }
    

      HashIterator的代码:

    abstract class HashIterator {
        Node<K,V> next;        // next entry to return
        Node<K,V> current;     // current entry
        int expectedModCount;  // for fast-fail
        int index;             // current slot
    
        HashIterator() { //构造方法
            expectedModCount = modCount;
            Node<K,V>[] t = table;  //map中哈希表的数组
            current = next = null;
            index = 0;
            if (t != null && size > 0) { // advance to first entry
                do {} while (index < t.length && (next = t[index++]) == null);//找到数组中第一个非空元素,赋值给next
            }
        }
    
        public final boolean hasNext() {
            return next != null;
        }
    
        final Node<K,V> nextNode() {
            Node<K,V>[] t;
            Node<K,V> e = next;
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
            if (e == null)
                throw new NoSuchElementException();
            if ((next = (current = e).next) == null && (t = table) != null) {//本链表找完了,顺着数组找下一个链表,然后遍历
                do {} while (index < t.length && (next = t[index++]) == null);
            }
            return e;
        }
    }
    

      总结,遍历的过程就是找到数组中第一链表开始的位置,顺着遍历完本链表,然后顺着数组找第二个链表的位置,然后遍历第二个链表,这样一条链表一条链表的顺着来的。

    ---------------------------------------------------------------------------------------------------------------------------------------
      ps:LinkedHashMap的结构跟HashMap相同,只是由单向链表改为了双向链表。
      本来准备再看一下HashSet的,结果,,,,这就是个限制了功能的HashMap,,,,https://blog.csdn.net/sugar_rainbow/article/details/68257208

      

  • 相关阅读:
    程序员的7中武器
    需要强化的知识
    微软中国联合小i推出MSN群Beta 不需任何插件
    XML Notepad 2006 v2.0
    Sandcastle August 2006 Community Technology Preview
    [推荐] TechNet 广播 SQL Server 2000完结篇
    《太空帝国 4》(Space Empires IV)以及 xxMod 英文版 中文版 TDM Mod 英文版 中文版
    IronPython 1.0 RC2 更新 1.0.60816
    Microsoft .NET Framework 3.0 RC1
    《Oracle Developer Suite 10g》(Oracle Developer Suite 10g)V10.1.2.0.2
  • 原文地址:https://www.cnblogs.com/nevermorewang/p/7841618.html
Copyright © 2011-2022 走看看