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

      

  • 相关阅读:
    nginx 报错 upstream timed out (110: Connection timed out)解决方案
    mysql 数据库缓存调优之解决The total number of locks exceeds the lock table size错误
    阿里云ECS主机内核调优
    安装Python3.6.x
    CentOS 下 LNMP 环境配置
    Walle代码发布系统
    Ansible 运维自动化 ( 配置管理工具 )
    Kafka消息的时间戳
    Linux内存分析
    H3C 查看路由器的硬件信息
  • 原文地址:https://www.cnblogs.com/nevermorewang/p/7841618.html
Copyright © 2011-2022 走看看