java jdk 中HashMap的源码解读

HashMap是我们在日常写代码时最常用到的一个数据结构,它为我们提供key-value形式的数据存储。同时,它的查询,插入效率都非常高。 

在之前的排序算法总结里面里,我大致学习了HashMap的实现原理,并制作了一个简化版本的HashMap。 今天,趁着项目的间歇期,我又仔细阅读了Java中的HashMap的实现。 

 

HashMap的初始化: 

Java代码 
  1. public HashMap(int initialCapacity, float loadFactor)  
  2.   
  3. public HashMap(int initialCapacity)  
  4.   
  5. public HashMap()  
  6.   
  7. public HashMap(Map<? extends K, ? extends V> m)  

最近看到几篇精彩的文章:

存取之美 —— HashMap原理、源码、实践

Hash碰撞与拒绝服务攻击

这些文章让我收获良多, 但是有些地方说的不够详细, 在此写下本人对上述文章的总结和理解, 希望可以给需要的朋友带来一些帮助.

 

1. 概述

HashMap在底层采用数组+链表的形式存储键值对.

在HashMap中定义了一个内部类Entry<K, V>, 该内部类是对key-value的抽象. Entry类包含4个成员: key, value, hash, next. key和value的意义很清晰, hash表示key的hash值, next是指向下一个Entry对象的引用.

HashMap内部维护了一个Entry<K, V>[] table, 数组table中的Entry元素是一个Entry链表的头结点(理解这一点很重要). 

 

2. put/get方法

向HashMap中添加键值对时, 程序会根据key的hashCode值计算出hash值, 然后对hash值取模, 模数是table.length. 假如取模的结果为index, 则取出table[index]. table[index]可能为null, 也可能是一个Entry对象. 如果为null, 则直接存储. 否则计算key.equals(table[index].key), 如果为false, 就取出table[index].next, 继续调用key的equals方法, 直到equals方法返回true, 或者比较完链表中所有Entry对象.

Java代码 
  1. public V put(K key, V value) {  
  2.     if (key == null)  
  3.         return putForNullKey(value);  
  4.     // 对hashCode值进行二次hash得到最终的hash值  
  5.     int hash = hash(key.hashCode());  
  6.     // 根据hash值定位数组中的索引位置  
  7.     int i = indexFor(hash, table.length);  
  8.     // 遍历table[i]位置处的链表  
  9.     for (Entry<K, V> e = table[i]; e != null; e = e.next) {  
  10.         Object k;  
  11.         // 如果hash值相同且equals返回true, 则替换原来的value值  
  12.         if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {  
  13.             V oldValue = e.value;  
  14.             e.value = value;  
  15.             e.recordAccess(this);  
  16.             return oldValue;  
  17.         }  
  18.     }  
  19.   
  20.     modCount++;  
  21.     // 如果之前函数没有return, 将该键值对插入table[i]链表中  
  22.     addEntry(hash, key, value, i);  
  23.     return null;  
  24. }  

理解了put方法, 那么get方法就会很容易理解:

Java代码 
  1. public V get(Object key) {  
  2.     if (key == null)  
  3.         return getForNullKey();  
  4.     int hash = hash(key.hashCode());  
  5.     // 首先根据hash值计算index, 然后取出index处的链表的头结点. 遍历链表.  
  6.     for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {  
  7.         Object k;  
  8.         if (e.hash == hash && ((k = e.key) == key || key.equals(k)))  
  9.             return e.value;  
  10.     }  
  11.     return null;  
  12. }  

  

3. HashMap的容量和索引位置确定

前面没有叙述HashMap的容量问题, 是因为容量是与索引位置计算紧密相关的.

理解HashMap的容量就需要关注成员变量size, loadFactor, threshold.

size表示HashMap中实际包含的键值对个数.

loadFactor表示负载因子, loadFactor的值越大, 则对table数组的利用率越大, 相当于节省内存空间. 但是loadFactor的值增大, 同时也会导致hash冲突的概率增加, 从而使得程序效率降低. loadFactor的取值应该兼顾内存空间和效率, 默认值为0.75.

threshold表示极限容量, 计算公式为threshold = (int)(capacity * loadFactor);  当size达到threshold时, 就需要对table数组扩容.

HashMap的容量大小就是table.length. 由于java中取模是一个效率低下的操作, 所以出于性能的考虑, HashMap的容量被设计为2的N次方. 如此hash%table.length就可以转换为hash&(table.length-1). 与运算的效率比取模运算高效很多.

Java代码 
  1. public HashMap(int initialCapacity, float loadFactor) {  
  2.     if (initialCapacity < 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))  
  7.         throw new IllegalArgumentException("Illegal load factor: " + loadFactor);  
  8.   
  9.     // 计算大于initialCapacity的最小的2的N次方数  
  10.     int capacity = 1;  
  11.     while (capacity < initialCapacity)  
  12.         capacity <<= 1;  
  13.   
  14.     this.loadFactor = loadFactor;  
  15.     // 求出极限容量  
  16.     threshold = (int) (capacity * loadFactor);  
  17.     // table的容量被设计为2的N次方  
  18.     table = new Entry[capacity];  
  19.     init();  
  20. }  

如果使用无参的构造函数创建HashMap, 则容量默认为16, 负载因子默认为0.75.

indexFor函数用于确定索引位置:

Java代码 
  1. static int indexFor(int h, int length) {  
  2.     // 当length为2的N次方时相当于h%table.length, 但效率要高效很多  
  3.     return h & (length - 1);  
  4. }  

 

4. rehash

前面提到过, 当size达到threshold时, 就需要对table数组扩容. 调用put函数向HashMap中插入一个键值对时会调用到addEntry(hash, key, value, i)方法:

Java代码 
  1. void addEntry(int hash, K key, V value, int bucketIndex) {  
  2.     // 取出索引处的Entry对象  
  3.     Entry<K, V> e = table[bucketIndex];  
  4.     // 更新索引处链表的头结点, 并使新的头结点的next属性指向原来的头结点  
  5.     table[bucketIndex] = new Entry<K, V>(hash, key, value, e);  
  6.     // 当size大于threshold时扩容数组, 容量增加至原来的2倍. 保证table的容量始终是2的N次方  
  7.     if (size++ >= threshold)  
  8.         resize(2 * table.length);  
  9. }  

resize用于扩容数组. 数组的length增加大了, 那么HashMap中已有的键值对就必须重新进行hash, 这就是rehash. 如果不进行rehash, 就会使得put和get时table数组的length不同, 从而导致get方法无法取出原先put存入的键值对.

Java代码 
  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.   
  9.     Entry[] newTable = new Entry[newCapacity];  
  10.     transfer(newTable);  
  11.     table = newTable;  
  12.     threshold = (int) (newCapacity * loadFactor);  
  13. }  
  14. void transfer(Entry[] newTable) {  
  15.     Entry[] src = table;  
  16.     int newCapacity = newTable.length;  
  17.     // 对已有的键值对进行rehash  
  18.     for (int j = 0; j < src.length; j++) {  
  19.         // 得到j处的链表的头结点  
  20.         Entry<K, V> e = src[j];  
  21.         // 遍历链表  
  22.         if (e != null) {  
  23.             src[j] = null;  
  24.             do {  
  25.                 // 进行rehash  
  26.                 Entry<K, V> next = e.next;  
  27.                 int i = indexFor(e.hash, newCapacity);  
  28.                 e.next = newTable[i];  
  29.                 newTable[i] = e;  
  30.                 e = next;  
  31.             } while (e != null);  
  32.         }  
  33.     }  
  34. }  

从源码可以看出, rehash对性能的影响是非常大的, 因此我们应该尽量避免rehash的发生. 这就要求我们预估需要存入HashMap的键值对的数量, 根据数量在创建HashMap对象时指定合适的容量和负载因子.

 

5. hash碰撞和HashMap的退化

hash碰撞在HashMap中的表现为: 不同的key, 计算出相同的index. 如果对所有的key调用indexFor方法的返回值都是相同的, 那么HashMap就退化为链表, 这对性能的影响也是非常大的. 几个月前的闹得沸沸扬扬的hash碰撞攻击就是基于这样的原理.

常用的web框架都会将请求中的参数保存在HashMap(或HashTable)中, 如果客户端根据Web应用框架采用的Hash函数来通过某种Hash攻击的方式获得大量的碰撞, 那么HashMap就会退化为链表, 服务器有可能处理一次请求要花上十几分钟甚至几个小时的时间...

 

6. 线程安全

HashMap是线程不安全的, 如果遍历HashMap的过程中修改了HashMap, 那么就会抛出java.util.ConcurrentModificationException异常:

Java代码 
  1. final Entry<K, V> nextEntry() {  
  2.     if (modCount != expectedModCount)  
  3.         throw new ConcurrentModificationException();  
  4.     Entry<K, V> e = next;  
  5.     if (e == null)  
  6.         throw new NoSuchElementException();  
  7.   
  8.     if ((next = e.next) == null) {  
  9.         Entry[] t = table;  
  10.         while (index < t.length && (next = t[index++]) == null)  
  11.             ;  
  12.     }  
  13.     current = e;  
  14.     return e;  
  15. }  

modCount是HashMap的成员变量, 用于表示HashMap的状态. expectedModCount是遍历初始时modCount的值. 如果在遍历过程中改变了modCount的值就会导致modCount和expectedModCount不相等, 从而抛出异常. put, clear, remove等方法都会导致modCount的值改变.

posted @ 2015-09-17 21:48  wzhanke  阅读(235)  评论(0编辑  收藏  举报