Java集合:HashMap源码剖析

 

1、HashMap概述
2、HashMap的数据结构
3、HashMap源码分析
     一、关键属性
     二、构造方法
     三、存储数据
     四、调整大小html

     五、数据读取java

              六、HashMap的性能参数
              七、Fail-Fast机制
算法

 

1、HashMap概述

  HashMap基于哈希表的 Map 接口的实现。此实现提供全部可选的映射操做,并容许使用 null 值和 null 键。(除了不一样步和容许使用 null 以外,HashMap 类与 Hashtable 大体相同。)此类不保证映射的顺序,特别是它不保证该顺序恒久不变。数组

  值得注意的是HashMap不是线程安全的,若是想要线程安全的HashMap,能够经过Collections类的静态方法synchronizedMap得到线程安全的HashMap。安全

 Map map = Collections.synchronizedMap(new HashMap());

 

2、HashMap的数据结构

  HashMap的底层主要是基于数组和链表来实现的,它之因此有至关快的查询速度主要是由于它是经过计算散列码来决定存储的位置。HashMap中主要是经过key的hashCode来计算hash值的,只要hashCode相同,计算出来的hash值就同样。若是存储的对象对多了,就有可能不一样的对象所算出来的hash值是相同的,这就出现了所谓的hash冲突。学过数据结构的同窗都知道,解决hash冲突的方法有不少,HashMap底层是经过链表来解决hash冲突的。数据结构

 

 图中,0~15部分即表明哈希表,也称为哈希数组,数组的每一个元素都是一个单链表的头节点,链表是用来解决冲突的,若是不一样的key映射到了数组的同一位置处,就将其放入单链表中。并发

 

从上图咱们能够发现哈希表是由数组+链表组成的,一个长度为16的数组中,每一个元素存储的是一个链表的头结点Bucket桶。那么这些元素是按照什么样的规则存储到数组中呢。通常状况是经过hash(key)%len得到,也就是元素的key的哈希值对数组长度取模获得。好比上述哈希表中,12%16=12,28%16=12,108%16=12,140%16=12。因此十二、2八、108以及140都存储在数组下标为12的位置。

 

HashMap其实也是一个线性的数组实现的,因此能够理解为其存储数据的容器就是一个线性数组。这可能让咱们很不解,一个线性的数组怎么实现按键值对来存取数据呢?这里HashMap有作一些处理。

 

  首先HashMap里面实现一个静态内部类Entry,其重要的属性有 key , value, next,从属性key,value咱们就能很明显的看出来Entry就是HashMap键值对实现的一个基础bean,咱们上面说到HashMap的基础就是一个线性数组,这个数组就是Entry[],Map里面的内容都保存在Entry[]里面。

 

咱们看看HashMap中Entry类的代码:app

 

    /** Entry是单向链表。 * 它是 “HashMap链式存储法”对应的链表。 *它实现了Map.Entry 接口,即实现getKey(), getValue(), setValue(V value), equals(Object o), hashCode()这些函数 **/ static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; // 指向下一个节点 
        Entry<K,V> next; final int hash; // 构造函数。 // 输入参数包括"哈希值(h)", "键(k)", "值(v)", "下一节点(n)" 
        Entry(int h, K k, V v, Entry<K,V> n) { value = v; next = n; key = k; hash = h; } public final K getKey() { return key; } public final V getValue() { return value; } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } // 判断两个Entry是否相等 // 若两个Entry的“key”和“value”都相等,则返回true。 // 不然,返回false 
 public final boolean equals(Object o) { if (!(o instanceof Map.Entry)) return false; Map.Entry e = (Map.Entry)o; Object k1 = getKey(); Object k2 = e.getKey(); if (k1 == k2 || (k1 != null && k1.equals(k2))) { Object v1 = getValue(); Object v2 = e.getValue(); if (v1 == v2 || (v1 != null && v1.equals(v2))) return true; } return false; } // 实现hashCode() 
        public final int hashCode() { return (key==null   ? 0 : key.hashCode()) ^ (value==null ? 0 : value.hashCode()); } public final String toString() { return getKey() + "=" + getValue(); } // 当向HashMap中添加元素时,绘调用recordAccess()。 // 这里不作任何处理 
        void recordAccess(HashMap<K,V> m) { } // 当从HashMap中删除元素时,绘调用recordRemoval()。 // 这里不作任何处理 
        void recordRemoval(HashMap<K,V> m) { } }

 

HashMap其实就是一个Entry数组,Entry对象中包含了键和值,其中next也是一个Entry对象,它就是用来处理hash冲突的,造成一个链表。函数

 

3、HashMap源码分析

 

       一、关键属性

  先看看HashMap类中的一些关键属性:源码分析

 

 transient Entry[] table;//存储元素的实体数组
  
 transient int size;//存放元素的个数
  
 int threshold; //临界值 当实际大小超过临界值时,会进行扩容threshold = 加载因子*容量
 
 final float loadFactor; //加载因子
  
 transient int modCount;//被修改的次数

 

其中loadFactor加载因子是表示Hsah表中元素的填满的程度.

若:加载因子越大,填满的元素越多,好处是,空间利用率高了,但:冲突的机会加大了.链表长度会愈来愈长,查找效率下降。

反之,加载因子越小,填满的元素越少,好处是:冲突的机会减少了,但:空间浪费多了.表中的数据将过于稀疏(不少空间还没用,就开始扩容了)

冲突的机会越大,则查找的成本越高.

所以,必须在 "冲突的机会"与"空间利用率"之间寻找一种平衡与折衷. 这种平衡与折衷本质上是数据结构中有名的"时-空"矛盾的平衡与折衷.

  若是机器内存足够,而且想要提升查询速度的话能够将加载因子设置小一点;相反若是机器内存紧张,而且对查询速度没有什么要求的话能够将加载因子设置大一点。不过通常咱们都不用去设置它,让它取默认值0.75就行了。

 

二、构造方法

下面看看HashMap的几个构造方法:

 

public HashMap(int initialCapacity, float loadFactor) {          //确保数字合法
          if (initialCapacity < 0)              throw new IllegalArgumentException("Illegal initial capacity: " +
  initialCapacity);          if (initialCapacity > MAXIMUM_CAPACITY)              initialCapacity = MAXIMUM_CAPACITY;          if (loadFactor <= 0 || Float.isNaN(loadFactor))              throw new IllegalArgumentException("Illegal load factor: " +
 loadFactor);  
         // Find a power of 2 >= initialCapacity
         int capacity = 1;   //初始容量
         while (capacity < initialCapacity)   //确保容量为2的n次幂,使capacity为大于initialCapacity的最小的2的n次幂
             capacity <<= 1;  
         this.loadFactor = loadFactor;          threshold = (int)(capacity * loadFactor);          table = new Entry[capacity];  init();  }  
     public HashMap(int initialCapacity) {          this(initialCapacity, DEFAULT_LOAD_FACTOR);  }  
     public HashMap() {          this.loadFactor = DEFAULT_LOAD_FACTOR;          threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);          table = new Entry[DEFAULT_INITIAL_CAPACITY];  init();      }

 

咱们能够看到在构造HashMap的时候若是咱们指定了加载因子和初始容量的话就调用第一个构造方法,不然的话就是用默认的。默认初始容量为16,默认加载因子为0.75。咱们能够看到上面代码中13-15行,这段代码的做用是确保容量为2的n次幂,使capacity为大于initialCapacity的最小的2的n次幂,至于为何要把容量设置为2的n次幂,咱们等下再看。

 

重点分析下HashMap中用的最多的两个方法put和get

       三、存储数据

  下面看看HashMap存储数据的过程是怎样的,首先看看HashMap的put方法:

  

public V put(K key, V value) { // 若“key为null”,则将该键值对添加到table[0]中。
         if (key == null) return putForNullKey(value); // 若“key不为null”,则计算该key的哈希值,而后将其添加到该哈希值对应的链表中。
         int hash = hash(key.hashCode()); //搜索指定hash值在对应table中的索引
         int i = indexFor(hash, table.length); // 循环遍历Entry数组,若“该key”对应的键值对已经存在,则用新的value取代旧的value。而后退出!
         for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { //若是key相同则覆盖并返回旧值
                  V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } //修改次数+1
         modCount++; //将key-value添加到table[i]处
 addEntry(hash, key, value, i); return null; }

 

上面程序中用到了一个重要的内部接口:Map.Entry,每一个 Map.Entry 其实就是一个 key-value 对。从上面程序中能够看出:当系统决定存储 HashMap 中的 key-value 对时,彻底没有考虑 Entry 中的 value,仅仅只是根据 key 来计算并决定每一个 Entry 的存储位置。这也说明了前面的结论:咱们彻底能够把 Map 集合中的 value 当成 key 的附属,当系统决定了 key 的存储位置以后,value 随之保存在那里便可。

咱们慢慢的来分析这个函数,第2和3行的做用就是处理key值为null的状况,咱们看看putForNullKey(value)方法:

 

 private V putForNullKey(V value) {          for (Entry<K,V> e = table[0]; e != null; e = e.next) {              if (e.key == null) {   //若是有key为null的对象存在,则覆盖掉
                  V oldValue = e.value;                  e.value = value;                  e.recordAccess(this);                  return oldValue;  }  }          modCount++;          addEntry(0, null, value, 0); //若是键为null的话,则hash值为0
         return null;  }

 

注意:若是key为null的话,hash值为0,对象存储在数组中索引为0的位置。即table[0]

咱们再回去看看put方法中第4行,它是经过key的hashCode值计算hash码,下面是计算hash码的函数:

 

  //计算hash值的方法 经过键的hashCode来计算
     static int hash(int h) {          // This function ensures that hashCodes that differ only by
         // constant multiples at each bit position have a bounded
         // number of collisions (approximately 8 at default load factor).
         h ^= (h >>> 20) ^ (h >>> 12);          return h ^ (h >>> 7) ^ (h >>> 4);      }

 

获得hash码以后就会经过hash码去计算出应该存储在数组中的索引,计算索引的函数以下:

 

     static int indexFor(int h, int length) { //根据hash值和数组长度算出索引值
         return h & (length-1);  //这里不能随便算取,用hash&(length-1)是有缘由的,这样能够确保算出来的索引是在数组大小范围内,不会超出
     }

 

这个咱们要重点说下,咱们通常对哈希表的散列很天然地会想到用hash值对length取模(即除法散列法),Hashtable中也是这样实现的,这种方法基本能保证元素在哈希表中散列的比较均匀,但取模会用到除法运算,效率很低,HashMap中则经过h&(length-1)的方法来代替取模,一样实现了均匀的散列,但效率要高不少,这也是HashMap对Hashtable的一个改进。

 

    接下来,咱们分析下为何哈希表的容量必定要是2的整数次幂。首先,length为2的整数次幂的话,h&(length-1)就至关于对length取模,这样便保证了散列的均匀,同时也提高了效率;其次,length为2的整数次幂的话,为偶数,这样length-1为奇数,奇数的最后一位是1,这样便保证了h&(length-1)的最后一位可能为0,也可能为1(这取决于h的值),即与后的结果可能为偶数,也可能为奇数,这样即可以保证散列的均匀性,而若是length为奇数的话,很明显length-1为偶数,它的最后一位是0,这样h&(length-1)的最后一位确定为0,即只能为偶数,这样任何hash值都只会被散列到数组的偶数下标位置上,这便浪费了近一半的空间,所以,length取2的整数次幂,是为了使不一样hash值发生碰撞的几率较小,这样就能使元素在哈希表中均匀地散列。

 

  这看上去很简单,其实比较有玄机的,咱们举个例子来讲明:

  假设数组长度分别为15和16,优化后的hash码分别为8和9,那么&运算后的结果以下: 

 

         h & (table.length-1)                     hash                             table.length-1
       8 & (15-1):                                 1000                   &              1110                   =                1000
       9 & (15-1):                                 1001                   &              1110                   =                1000
       -----------------------------------------------------------------------------------------------------------------------
       8 & (16-1):                                 1000                   &              1111                   =                1000
       9 & (16-1):                                 1001                   &              1111                   =                1001

 

 

 

 

  从上面的例子中能够看出:

  当它们和15-1(1110)“与”的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞,8和9会被放到数组中的同一个位置上造成链表,那么查询的时候就须要遍历这个链表,获得8或者9,这样就下降了查询的效率。同时,咱们也能够发现,当数组长度为15的时候,hash值会与15-1(1110)进行“与”,那么 最后一位永远是0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费至关大,更糟的是这种状况中,数组可使用的位置比数组长度小了不少,这意味着进一步增长了碰撞的概率,减慢了查询的效率!

  而当数组长度为16时,即为2的n次方时,2n-1获得的二进制数的每一个位上的值都为1,这使得在低位上&时,获得的和原hash的低位相同,加之hash(int h)方法对key的hashCode的进一步优化,加入了高位计算,就使得只有相同的hash值的两个值才会被放到数组中的同一个位置上造成链表。

   因此说,当数组长度为2的n次幂的时候,不一样的key算得得index相同的概率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的概率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。

   

    根据上面 put 方法的源代码能够看出,当程序试图将一个key-value对放入HashMap中时,程序首先根据该 key 的 hashCode() 返回值决定该 Entry 的存储位置:

  •   若是两个 Entry 的 key 的 hashCode() 返回值相同,那它们的存储位置相同。
  •   若是这两个 Entry 的 key 经过 equals 比较返回 true,新添加 Entry 的 value 将覆盖集合中原有 Entry 的 value,但key不会覆盖。  
  •   若是这两个 Entry 的 key 经过 equals 比较返回 false,新添加的 Entry 将与集合中原有 Entry 造成 Entry 链,并且新添加的 Entry 位于 Entry 链的头部——

  具体说明继续看 addEntry() 方法的说明。

 

 void addEntry(int hash, K key, V value, int bucketIndex) {          Entry<K,V> e = table[bucketIndex]; //若是要加入的位置有值,将该位置原先的值设置为新entry的next,也就是新entry链表的下一个节点
         table[bucketIndex] = new Entry<>(hash, key, value, e);          if (size++ >= threshold) //若是大于临界值就扩容
             resize(2 * table.length); //以2的倍数扩容
 }

 

参数bucketIndex就是indexFor函数计算出来的索引值,第2行代码是取得数组中索引为bucketIndex的Entry对象,第3行就是用hash、key、value构建一个新的Entry对象放到索引为bucketIndex的位置,而且将该位置原先的对象设置为新对象的next构成链表。

  第4行和第5行就是判断put后size是否达到了临界值threshold,若是达到了临界值就要进行扩容,HashMap扩容是扩为原来的两倍。

 

四、调整大小

resize()方法以下:

 从新调整HashMap的大小,newCapacity是调整后的单位

     void resize(int newCapacity) {  Entry[] oldTable = table;  int oldCapacity = oldTable.length;  if (oldCapacity == MAXIMUM_CAPACITY) {  threshold = Integer.MAX_VALUE;  return;  }  Entry[] newTable = new Entry[newCapacity];  transfer(newTable);//用来将原先table的元素所有移到newTable里面 table = newTable; //再将newTable赋值给table threshold = (int)(newCapacity * loadFactor);//从新计算临界值 }

 

  新建了一个HashMap的底层数组,上面代码中第10行为调用transfer方法,将HashMap的所有元素添加到新的HashMap中,并从新计算元素在新的数组中的索引位置

 

  当HashMap中的元素愈来愈多的时候,hash冲突的概率也就愈来愈高,由于数组的长度是固定的。因此为了提升查询的效率,就要对HashMap的数组进行扩容,数组扩容这个操做也会出如今ArrayList中,这是一个经常使用的操做,而在HashMap数组扩容以后,最消耗性能的点就出现了:原数组中的数据必须从新计算其在新数组中的位置,并放进去,这就是resize

 

   那么HashMap何时进行扩容呢?当HashMap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认状况下,数组大小为16,那么当HashMap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为 2*16=32,即扩大一倍,而后从新计算每一个元素在数组中的位置,扩容是须要进行数组复制的,复制数组是很是消耗性能的操做,因此若是咱们已经预知HashMap中元素的个数,那么预设元素的个数可以有效的提升HashMap的性能。

 

 

 五、数据读取

 

 

public V get(Object key) {     if (key == null)         return getForNullKey();     int hash = hash(key.hashCode());     for (Entry<K,V> e = table[indexFor(hash, table.length)];         e != null;         e = e.next) {  Object k;         if (e.hash == hash && ((k = e.key) == key || key.equals(k)))             return e.value;  }     return null; }  

  有了上面存储时的hash算法做为基础,理解起来这段代码就很容易了。从上面的源代码中能够看出:从HashMap中get元素时,首先计算key的hashCode,找到数组中对应位置的某一元素,而后经过key的equals方法在对应位置的链表中找到须要的元素。

 

    概括起来简单地说,HashMap 在底层将 key-value 当成一个总体进行处理,这个总体就是一个 Entry 对象。HashMap 底层采用一个 Entry[] 数组来保存全部的 key-value 对,当须要存储一个 Entry 对象时,会根据hash算法来决定其在数组中的存储位置,在根据equals方法决定其在该数组位置上的链表中的存储位置;当须要取出一个Entry时,也会根据hash算法找到其在数组中的存储位置,再根据equals方法从该位置上的链表中取出该Entry。

 

六、HashMap的性能参数:

 

   HashMap 包含以下几个构造器:

   HashMap():构建一个初始容量为 16,负载因子为 0.75 的 HashMap。

   HashMap(int initialCapacity):构建一个初始容量为 initialCapacity,负载因子为 0.75 的 HashMap。

   HashMap(int initialCapacity, float loadFactor):以指定初始容量、指定的负载因子建立一个 HashMap。

   HashMap的基础构造器HashMap(int initialCapacity, float loadFactor)带有两个参数,它们是初始容量initialCapacity和加载因子loadFactor。

   initialCapacity:HashMap的最大容量,即为底层数组的长度。

   loadFactor:负载因子loadFactor定义为:散列表的实际元素数目(n)/ 散列表的容量(m)。

   负载因子衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来讲,查找一个元素的平均时间是O(1+a),所以若是负载因子越大,对空间的利用更充分,然然后果是查找效率的下降;若是负载因子过小,那么散列表的数据将过于稀疏,对空间形成严重浪费。

   HashMap的实现中,经过threshold字段来判断HashMap的最大容量:

 

threshold = (int)(capacity * loadFactor);  
 

   结合负载因子的定义公式可知,threshold就是在此loadFactor和capacity对应下容许的最大元素数目,超过这个数目就从新resize,以下降实际的负载因子。默认的的负载因子0.75是对空间和时间效率的一个平衡选择。当容量超出此最大容量时, resize后的HashMap容量是容量的两倍:

 

if (size++ >= threshold) resize(2 * table.length);

七、Fail-Fast机制:

   咱们知道java.util.HashMap不是线程安全的,所以若是在使用迭代器的过程当中有其余线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。

   这一策略在源码中的实现是经过modCount域,modCount顾名思义就是修改次数,对HashMap内容的修改都将增长这个值,那么在迭代器初始化过程当中会将这个值赋给迭代器的expectedModCount。

private abstract class HashIterator<E> implements Iterator<E> { Entry<K,V> next;    // next entry to return
        int expectedModCount;    // For fast-fail
        int index;        // current slot
        Entry<K,V> current;    // current entry
 HashIterator() { expectedModCount = modCount; if (size > 0) { // advance to first entry
                Entry[] t = table; while (index < t.length && (next = t[index++]) == null) ; } } public final boolean hasNext() { return next != null; } final Entry<K,V> nextEntry() { if (modCount != expectedModCount) throw new ConcurrentModificationException(); Entry<K,V> e = next; if (e == null) throw new NoSuchElementException(); if ((next = e.next) == null) { Entry[] t = table; while (index < t.length && (next = t[index++]) == null) ; } current = e; return e; } public void remove() { if (current == null) throw new IllegalStateException(); if (modCount != expectedModCount) throw new ConcurrentModificationException(); Object k = current.key; current = null; HashMap.this.removeEntryForKey(k); expectedModCount = modCount; } }

 

在迭代过程当中,判断modCount跟expectedModCount是否相等,若是不相等就表示已经有其余线程修改了Map:

   注意到modCount声明为volatile,保证线程之间修改的可见性。

final Entry<K,V> nextEntry() {     if (modCount != expectedModCount)         throw new ConcurrentModificationException();  

在HashMap的API中指出:

   由全部HashMap类的“collection 视图方法”所返回的迭代器都是快速失败的:在迭代器建立以后,若是从结构上对映射进行修改,除非经过迭代器自己的 remove 方法,其余任什么时候间任何方式的修改,迭代器都将抛出 ConcurrentModificationException。所以,面对并发的修改,迭代器很快就会彻底失败,而不冒在未来不肯定的时间发生任意不肯定行为的风险。

   注意,迭代器的快速失败行为不能获得保证,通常来讲,存在非同步的并发修改时,不可能做出任何坚定的保证。快速失败迭代器尽最大努力抛出 ConcurrentModificationException。所以,编写依赖于此异常的程序的作法是错误的,正确作法是:迭代器的快速失败行为应该仅用于检测程序错误

 

参考连接:

深刻Java集合学习系列:HashMap的实现原理

相关文章
相关标签/搜索