zoukankan      html  css  js  c++  java
  • 学习笔记深入理解Java中的HashMap数据结构

    一:定义

      HashMap实现了Map接口,继承AbstractMap。其中Map接口定义了键映射到值的规则,而AbstractMap类提供 Map 接口的骨干实现,以最大限度地减少实现此接口所需的工作,其实AbstractMap类已经实现了Map。

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

    二:构造函数和数据结构

      HashMap提供了三个构造函数:

    1 HashMap() 
    2 
    3 HashMap(int initialCapacity)
    4  
    5 HashMap(int initialCapacity, float loadFactor)

    第一个:构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。

    第二个:构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。

    第三个:构造一个带指定初始容量和加载因子的空 HashMap。

    那么这两个参数有什么含义呢?在HashMap中有什么作用?我们先来看一下HashMap的数据结构深入理解之后在回过头来看。我们看每次调用map.put方法是,其实我们是往Entry<K,V>[] tab 这个数组里面添加元素的。那么Entry这个又是一个什么呢?

     1  static class Entry<K,V> implements Map.Entry<K,V> {
     2         final K key;
     3         V value;
     4         Entry<K,V> next;
     5         final int hash;
     6 
     7         /**
     8          * Creates new entry.
     9          */
    10         Entry(int h, K k, V v, Entry<K,V> n) {
    11             value = v;
    12             next = n;
    13             key = k;
    14             hash = h;
    15         }
    16 }

    很显然,这其实是一个链表的数据结构。所以我们分析一下,那么HashMap的数据结构是不是就是张的这个样子的呀?  

    其实呢,我们在开始的构造函数里面的initialCapacity这个参数,就是指的这个数组的长度了,我们在看一下具有两个参数的那个构造方法。

     1 public HashMap(int initialCapacity, float loadFactor) {
     2         //初始容量不能<0
     3         if (initialCapacity < 0)
     4             throw new IllegalArgumentException("Illegal initial capacity: "
     5                     + initialCapacity);
     6         //初始容量不能 > 最大容量值,HashMap的最大容量值为2^30
     7         if (initialCapacity > MAXIMUM_CAPACITY)
     8             initialCapacity = MAXIMUM_CAPACITY;
     9         //负载因子不能 < 0
    10         if (loadFactor <= 0 || Float.isNaN(loadFactor))
    11             throw new IllegalArgumentException("Illegal load factor: "
    12                     + loadFactor);
    13 
    14         // 计算出大于 initialCapacity 的最小的 2 的 n 次方值。
    15         int capacity = 1;
    16         while (capacity < initialCapacity)
    17             capacity <<= 1;
    18         
    19         this.loadFactor = loadFactor;
    20         //设置HashMap的容量极限,当HashMap的容量达到该极限时就会进行扩容操作
    21         threshold = (int) (capacity * loadFactor);
    22         //初始化table数组
    23         table = new Entry[capacity];
    24         init();
    25     }

    每次新建一个HashMap时,都会初始化一个table数组。table数组的元素为Entry节点。

    三,添加数据

      

     1 public V put(K key, V value) {
     2         //当key为null,调用putForNullKey方法,保存null与table第一个位置中,这是HashMap允许为null的原因
     3         if (key == null)
     4             return putForNullKey(value);
     5         //计算key的hash值
     6         int hash = hash(key.hashCode());                  ------(1)
     7         //计算key hash 值在 table 数组中的位置
     8         int i = indexFor(hash, table.length);             ------(2)
     9         //从i出开始迭代 e,找到 key 保存的位置
    10         for (Entry<K, V> e = table[i]; e != null; e = e.next) {
    11             Object k;
    12             //判断该条链上是否有hash值相同的(key相同)
    13             //若存在相同,则直接覆盖value,返回旧value
    14             if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
    15                 V oldValue = e.value;    //旧值 = 新值
    16                 e.value = value;
    17                 e.recordAccess(this);
    18                 return oldValue;     //返回旧值
    19             }
    20         }
    21         //修改次数增加1
    22         modCount++;
    23         //将key、value添加至i位置处
    24         addEntry(hash, key, value, i);
    25         return null;
    26     }

    重点来了,我们看一下上面代码第6,8行,一个是通过key计算hash值,一个是通过hash值定位到数组中的位置。

    1 static int hash(int h) {
    2         h ^= (h >>> 20) ^ (h >>> 12);
    3         return h ^ (h >>> 7) ^ (h >>> 4);
    4     }
    5     
    6 static int indexFor(int h, int length) {
    7         return h & (length-1);
    8     }

    对于HashMap的数组而言,我们需要他里面的数据分布的尽量均匀,最好是每一项都有元素。因为分布的太紧张查询蛮,分布的太分散浪费空间。因为我们在构造函数中capacity <<= 1;这样做总是能够保证HashMap的底层数组长度为2的n次方。所以index的方法就相当于是对lenght取模处理。那么我们为什么要取模处理呢?因为length是2的N次方,当length-1的时候,你会发现得到的结果值 进行地位&运算时候,值与原来的hash值相同,而进行高位运算时,其值等于其低位值。所以说当length = 2^n时,不同的hash值发生碰撞的概率比较小,这样就会使得数据在table数组中分布较均匀,查询速度也较快。

    那么所以整体上来讲,HashMap的put方法就是:首先会计算key的hash值,然后根据hash值确认在table中存储的位置。若该位置没有元素,则直接插入。否则迭代该处元素链表并依此比较其key的hash值。如果两个hash值相等且key值相等(e.hash == hash && ((k = e.key) == key || key.equals(k))),则用新的Entry的value覆盖原来节点的value。如果两个hash值相等但key值不等 ,则将该节点插入该链表的链头。

    这种解决hash冲突的方法叫做【链地址法】,还有其他的比如:再哈希法,开放定址法,建立一个公共溢出区。

    具体的实现过程见addEntry方法。

    1 void addEntry(int hash, K key, V value, int bucketIndex) {
    2         //获取bucketIndex处的Entry
    3         Entry<K, V> e = table[bucketIndex];
    4         //将新创建的 Entry 放入 bucketIndex 索引处,并让新的 Entry 指向原来的 Entry 
    5         table[bucketIndex] = new Entry<K, V>(hash, key, value, e);
    6         //若HashMap中元素的个数超过极限了,则容量扩大两倍
    7         if (size++ >= threshold)
    8             resize(2 * table.length);
    9     }

    首先,系统总是将新的Entry对象添加到bucketIndex处。如果bucketIndex处已经有了对象,那么新添加的Entry对象将指向原有的Entry对象,形成一条Entry链,但是若bucketIndex处没有Entry对象,也就是e==null,那么新添加的Entry对象指向null,也就不会产生Entry链了。

    然后是扩容,  随着HashMap中元素的数量越来越多,发生碰撞的概率就越来越大,所产生的链表长度就会越来越长,这样势必会影响HashMap的速度,为了保证HashMap的效率,系统必须要在某个临界点进行扩容处理。该临界点在当HashMap中元素的数量等于table数组长度*加载因子。但是扩容是一个非常耗时的过程,因为它需要重新计算这些数据在新table数组中的位置并进行复制处理。所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。具体扩容的代码很简单,

     1 void resize(int newCapacity) {
     2         Entry[] oldTable = table;
     3         int oldCapacity = oldTable.length;
     4         if (oldCapacity == MAXIMUM_CAPACITY) {
     5             threshold = Integer.MAX_VALUE;
     6             return;
     7         }
     8         Entry[] newTable = new Entry[newCapacity];
     9         boolean oldAltHashing = useAltHashing;
    10         useAltHashing |= sun.misc.VM.isBooted() &&
    11                 (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
    12         boolean rehash = oldAltHashing ^ useAltHashing;
    13         //transfer函数的调用
    14         transfer(newTable, rehash);  
    15         table = newTable;
    16         threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    17     } 
    18 
    19 void transfer(Entry[] newTable, boolean rehash) {
    20         int newCapacity = newTable.length;
    21         //这里才是问题出现的关键
    22         for (Entry<K,V> e : table) {
    23             //遍历旧的Entry数组的每个元素,
    24             while(null != e) {
    25                 //寻找到下一个节点..
    26                 Entry<K,V> next = e.next;  
    27                 if (rehash) {
    28                     e.hash = null == e.key ? 0 : hash(e.key);
    29                 }
    30                 //重新计算每个元素在数组中的位置
    31                 int i = indexFor(e.hash, newCapacity);  
    32                 e.next = newTable[i];  
    33                 newTable[i] = e;
    34                 e = next;
    35             }
    36         }

    四,读取数据

      通过key的hash值找到在table数组中的索引处的Entry,然后返回该key对应的value即可。

     1 public V get(Object key) {
     2         // 若为null,调用getForNullKey方法返回相对应的value
     3         if (key == null)
     4             return getForNullKey();
     5         // 根据该 key 的 hashCode 值计算它的 hash 码  
     6         int hash = hash(key.hashCode());
     7         // 取出 table 数组中指定索引处的值
     8         for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
     9             Object k;
    10             //若搜索的key与查找的key相同,则返回相对应的value
    11             if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
    12                 return e.value;
    13         }
    14         return null;
    15     }

    2018年5月2日更新:

    发现当时jdk1.7的版本和现在的版本中HashMap的实现变化还挺大的。所以这里在重新更新一下,把认识到的变化在这里再做一些补充下,避免后面大家看到了这个对大家产生误导。

    第一点:

    JDK1.8 之后的 HashMap 底层在解决哈希冲突的时候,就不单单是使用数组加上单链表的组合了,因为当处理如果 hash 值冲突较多的情况下,链表的长度就会越来越长,此时通过单链表来寻找对应 Key 对应的 Value 的时候就会使得时间复杂度达到 O(n),因此在 JDK1.8 之后,在链表新增节点导致链表长度超过 TREEIFY_THRESHOLD = 8 的时候,就会在添加元素的同时将原来的单链表转化为红黑树。我们知道红黑树是一种易于增删改查的二叉树,他对与数据的查询的时间复杂度是 O(logn) 级别,所以利用红黑树的特点就可以更高效的对 HashMap 中的元素进行操作。

    第二点:在hash方面,首先,在高位扰动方面,只是简单的h = h ^ (h >>> 16),没有再做那么多的扰动,就得到了hash值。其次,去掉了indexFor这个专门定位的函数,而是在put,get等操作中直接定位,可以看到这些函数中都有这两行。

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

    第三点:就是扩容机制,jdk1.7扩容是直接采用头插将老的数据遍历插入到新的table中。在jdk1.8新版本中,我们来看一下扩容机制。

     1  final Node<K,V>[] resize() {
     2         Node<K,V>[] oldTab = table;//首次初始化后table为Null
     3         int oldCap = (oldTab == null) ? 0 : oldTab.length;
     4         int oldThr = threshold;//默认构造器的情况下为0
     5         int newCap, newThr = 0;
     6         if (oldCap > 0) {//table扩容过
     7              //当前table容量大于最大值得时候返回当前table
     8              if (oldCap >= MAXIMUM_CAPACITY) {
     9                 threshold = Integer.MAX_VALUE;
    10                 return oldTab;
    11             }
    12             else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
    13                      oldCap >= DEFAULT_INITIAL_CAPACITY)
    14             //table的容量乘以2,threshold的值也乘以2           
    15             newThr = oldThr << 1; // double threshold
    16         }
    17         else if (oldThr > 0) // initial capacity was placed in threshold
    18         //使用带有初始容量的构造器时,table容量为初始化得到的threshold
    19         newCap = oldThr;
    20         else {  //默认构造器下进行扩容  
    21              // zero initial threshold signifies using defaults
    22             newCap = DEFAULT_INITIAL_CAPACITY;
    23             newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    24         }
    25         if (newThr == 0) {
    26         //使用带有初始容量的构造器在此处进行扩容
    27             float ft = (float)newCap * loadFactor;
    28             newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
    29                       (int)ft : Integer.MAX_VALUE);
    30         }
    31         threshold = newThr;
    32         @SuppressWarnings({"rawtypes","unchecked"})
    33         Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    34         table = newTab;
    35         if (oldTab != null) {
    36             for (int j = 0; j < oldCap; ++j) {
    37                 HashMap.Node<K,V> e;
    38                 if ((e = oldTab[j]) != null) {
    39                     // help gc
    40                     oldTab[j] = null;
    41                     if (e.next == null)
    42                         // 当前index没有发生hash冲突,直接对2取模,即移位运算hash &(2^n -1)
    43                         // 扩容都是按照2的幂次方扩容,因此newCap = 2^n
    44                         newTab[e.hash & (newCap - 1)] = e;
    45                     else if (e instanceof HashMap.TreeNode)
    46                         // 当前index对应的节点为红黑树,这里篇幅比较长且需要了解其数据结构跟算法,因此不进行详解,当树的个数小于等于UNTREEIFY_THRESHOLD则转成链表
    47                         ((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
    48                     else { // preserve order
    49                         // 把当前index对应的链表分成两个链表,减少扩容的迁移量
    50                         HashMap.Node<K,V> loHead = null, loTail = null;
    51                         HashMap.Node<K,V> hiHead = null, hiTail = null;
    52                         HashMap.Node<K,V> next;
    53                         do {
    54                             next = e.next;
    55                             if ((e.hash & oldCap) == 0) {
    56                                 // 扩容后不需要移动的链表
    57                                 if (loTail == null)
    58                                     loHead = e;
    59                                 else
    60                                     loTail.next = e;
    61                                 loTail = e;
    62                             }
    63                             else {
    64                                 // 扩容后需要移动的链表
    65                                 if (hiTail == null)
    66                                     hiHead = e;
    67                                 else
    68                                     hiTail.next = e;
    69                                 hiTail = e;
    70                             }
    71                         } while ((e = next) != null);
    72                         if (loTail != null) {
    73                             // help gc
    74                             loTail.next = null;
    75                             newTab[j] = loHead;
    76                         }
    77                         if (hiTail != null) {
    78                             // help gc
    79                             hiTail.next = null;
    80                             // 扩容长度为当前index位置+旧的容量
    81                             newTab[j + oldCap] = hiHead;
    82                         }
    83                     }
    84                 }
    85             }
    86         }
    87         return newTab;
    88     }

     

  • 相关阅读:
    Winform中多线程无法访问使用 Control.CheckForIllegalCrossThreadCalls = false;
    PV操作-生产者/消费者关系
    table表格长度超出屏幕范围,可滑动
    Koa2中间件计算响应总耗时/设置响应头/读取Json文件返回给客户端
    Koa2简介和搭建
    计算机浮点数的表示和运算
    CSS实现Loading加载中动画
    RPC
    Git常用命令
    如何解决 shell 脚本重复执行的问题
  • 原文地址:https://www.cnblogs.com/yangkangIT/p/4632431.html
Copyright © 2011-2022 走看看