[转] HashMap的存取之美

本文转自 http://www.nowamagic.net/librarys/veda/detail/1202html

 

HashMap是一种十分经常使用的数据结构,做为一个应用开发人员,对其原理、实现的加深理解有助于更高效地进行数据存取。本文所用的jdk版本为1.5。前端

使用HashMap

《Effective JAVA》中认为,99%的状况下,当你覆盖了equals方法后,请务必覆盖hashCode方法。默认状况下,这二者会采用Object的“原生”实现方式,即:java

1 protected native int hashCode(); 
2 public boolean equals(Object obj) { 
3   return (this == obj); 
4 }
 

hashCode方法的定义用到了native关键字,表示它是由C或C++采用较为底层的方式来实现的,你能够认为它返回了该对象的内存地址;而缺省equals则认为,只有当二者引用同一个对象时,才认为它们是相等的。若是你只是覆盖了equals()而没有从新定义hashCode(),在读取HashMap的时候,除非你使用一个与你保存时引用彻底相同的对象做为key值,不然你将得不到该key所对应的值。算法

另外一方面,你应该尽可能避免使用“可变”的类做为HashMap的键。若是你将一个对象做为键值并保存在HashMap中,以后又改变了其状态,那么HashMap就会产生混乱,你所保存的值可能丢失(尽管遍历集合可能能够找到)。数据库

HashMap存取机制

Hashmap其实是一个数组和链表的结合体,利用数组来模拟一个个桶(相似于Bucket Sort)以快速存取不一样hashCode的key,对于相同hashCode的不一样key,再调用其equals方法从List中提取出和key所相对应的value。编程

Java中hashMap的初始化主要是为initialCapacity和loadFactor这两个属性赋值。前者表示hashMap中用来区分不一样hash值的key空间长度,后者是指定了当hashMap中的元素超过多少的时候,开始自动扩容,。默认状况下initialCapacity为16,loadFactor为0.75,它表示一开始hashMap能够存放16个不一样的hashCode,当填充到第12个的时候,hashMap会自动将其key空间的长度扩容到32,以此类推;这点能够从源码中看出来:数组

1 void addEntry(int hash, K key, V value, int bucketIndex) {  
2     Entry<K,V> e = table[bucketIndex];  
3         table[bucketIndex] = new Entry<K,V>(hash, key, value, e);  
4         if (size++ >= threshold)  
5             resize(2 * table.length);  
6 }  

而每当hashMap扩容后,内部的每一个元素存放的位置都会发生变化(由于元素的最终位置是其hashCode对key空间长度取模而得),所以resize方法中又会调用transfer函数,用来从新分配内部的元素;这个过程成为rehash,是十分消耗性能的,所以在可预知元素的个数的状况下,通常应该避免使用缺省的initialCapacity,而是经过构造函数为其指定一个值。例如咱们可能会想要将数据库查询所得1000条记录以某个特定字段(好比ID)为key缓存在hashMap中,为了提升效率、避免rehash,能够直接指定initialCapacity为2048。缓存

另外一个值得注意的地方是,hashMap其key空间的长度必定为2的N次方,这一点能够从一下源码中看出来:安全

 1 int capacity = 1; 数据结构

 2 while (capacity < initialCapacity)

 3   capacity <<= 1;

即便咱们在构造函数中指定的initialCapacity不是2的平方数,capacity仍是会被赋值为2的N次方。

为何Sun Microsystem的工程师要将hashMap key空间的长度设为2的N次方呢?这里参考R.W.Floyed给出的衡量散列思想的三个标准: 一个好的hash算法的计算应该是很是快的, 一个好的hash算法应该是冲突极小化, 若是存在冲突,应该是冲突均匀化。

为了将各元素的hashCode保存至长度为Length的key数组中,通常采用取模的方式,即index = hashCode % Length。不可避免的,存在多个不一样对象的hashCode被安排在同一位置,这就是咱们平时所谓的“冲突”。若是仅仅是考虑元素均匀化与冲突极小化,彷佛应该将Length取为素数(尽管没有明显的理论来支持这一点,但数学家们经过大量的实践得出结论,对素数取模的产生结果的无关性要大于其它数字)。为此,Craig Larman and Rhett Guthrie《Java Performence》中对此也大加抨击。为了弄清楚这个问题,Bruce Eckel(Thinking in JAVA的做者)专程采访了java.util.hashMap的做者Joshua Bloch,并将他采用这种设计的缘由放到了网上(http://www.roseindia.net/javatutorials/javahashmap.shtml) 。

上述设计的缘由在于,取模运算在包括Java在内的大多数语言中的效率都十分低下,而当除数为2的N次方时,取模运算将退化为最简单的位运算,其效率明显提高(按照Bruce Eckel给出的数据,大约能够提高5~8倍) 。看看JDK中是如何实现的:

1 static int indexFor(int h, int length) {  
2     return h & (length-1);  
3 }    

当key空间长度为2的N次方时,计算hashCode为h的元素的索引能够用简单的与操做来代替笨拙的取模操做!假设某个对象的hashCode为35(二进制为100011),而hashMap采用默认的initialCapacity(16),那么indexFor计算所得结果将会是100011 & 1111 = 11,即十进制的3,是否是刚好是35 Mod 16。

上面的方法有一个问题,就是它的计算结果仅有对象hashCode的低位决定,而高位被通通屏蔽了;以上面为例,19(10011)、35(100011)、67(1000011)等就具备相同的结果。针对这个问题, Joshua Bloch采用了“防护性编程”的解决方法,在使用各对象的hashCode以前对其进行二次Hash,参看JDK中的源码:

1 static int hash(Object x) {  
2         int h = x.hashCode();  
3         h += ~(h << 9);  
4         h ^=  (h >>> 14);  
5         h +=  (h << 4);  
6         h ^=  (h >>> 10);  
7         return h;  
8     }   

采用这种旋转Hash函数的主要目的是让原有hashCode的高位信息也能被充分利用,且兼顾计算效率以及数据统计的特性,其具体的原理已超出了本文的领域。

加快Hash效率的另外一个有效途径是编写良好的自定义对象的HashCode,String的实现采用了以下的计算方法:

 1 for (int i = 0; i < len; i++) { 2 h = 31*h + val[off++]; 3 } 4 hash = h;  

这种方法HashCode的计算方法可能最先出如今Brian W. Kernighan和Dennis M. Ritchie的《The C Programming Language》中,被认为是性价比最高的算法(又被称为times33算法,由于C中乘数常量为33,JAVA中改成31),实际上,包括List在内的大多数的对象都是用这种方法计算Hash值。

另外一种比较特殊的hash算法称为布隆过滤器,它以牺牲细微精度为代价,换来存储空间的大量节俭,经常使用于诸如判断用户名重复、是否在黑名单上等等。

Fail-Fast机制

众所周知,HashMap不是线程安全的集合类。但在某些容错能力较好的应用中,若是你不想仅仅由于1%的可能性而去承受hashTable的同步开销,则能够考虑利用一下HashMap的Fail-Fast机制,其具体实现以下:

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

其中modCount为HashMap的一个实例变量,而且被声明为volatile,表示任何线程均可以看到该变量被其它线程修改的结果(根据JVM内存模型的优化,每个线程都会存一份本身的工做内存,此工做内存的内容与本地内存并不是时时刻刻都同步,所以可能会出现线程间的修改不可见的问题) 。使用Iterator开始迭代时,会将modCount的赋值给expectedModCount,在迭代过程当中,经过每次比较二者是否相等来判断HashMap是否在内部或被其它线程修改。HashMap的大多数修改方法都会改变ModCount,参考下面的源码:

 1 public V put(K key, V value) {  
 2     K k = maskNull(key);  
 3         int hash = hash(k);  
 4         int i = indexFor(hash, table.length);  
 5         for (Entry<K,V> e = table[i]; e != null; e = e.next) {  
 6             if (e.hash == hash && eq(k, e.key)) {  
 7                 V oldValue = e.value;  
 8                 e.value = value;  
 9                 e.recordAccess(this);  
10                 return oldValue;  
11             }  
12         }  
13         modCount++;  
14         addEntry(hash, k, value, i);  
15         return null;  
16     }    

以put方法为例,每次往HashMap中添加元素都会致使modCount自增。其它诸如remove、clear方法也都包含相似的操做。 从上面能够看出,HashMap所采用的Fail-Fast机制本质上是一种乐观锁机制,经过检查状态——没有问题则忽略——有问题则抛出异常的方式,来避免线程同步的开销,下面给出一个在单线程环境下发生Fast-Fail的例子:

 1 class Test {    
 2     public static void main(String[] args) {               
 3         java.util.HashMap<Object,String> map=new java.util.HashMap<Object,String>();    
 4        map.put(new Object(), "a");    
 5        map.put(new Object(), "b");    
 6        java.util.Iterator<Object> it=map.keySet().iterator();    
 7        while(it.hasNext()){    
 8            it.next();    
 9            map.put("", "");         
10         System.out.println(map.size());    
11     }    
12 }  
 

运行上面的代码会抛出java.util.ConcurrentModificationException,由于在迭代过程当中修改了HashMap内部的元素致使modCount自增。若将上面代码中 map.put(new Object(), "b") 这句注释掉,程序会顺利经过,由于此时HashMap中只包含一个元素,通过一次迭代后已到了尾部,因此不会出现问题,也就没有抛出异常的必要了。

在一般并发环境下,仍是建议采用同步机制。这通常经过对天然封装该映射的对象进行同步操做来完成。若是不存在这样的对象,则应该使用 Collections.synchronizedMap 方法来“包装”该映射。最好在建立时完成这一操做,以防止意外的非同步访问。

LinkedHashMap

遍历HashMap所获得的数据是杂乱无章的,这在某些状况下客户须要特定遍历顺序时是十分有用的。好比,这种数据结构很适合构建 LRU 缓存。调用 put 或 get 方法将会访问相应的条目(假定调用完成后它还存在)。putAll 方法以指定映射的条目集合迭代器提供的键-值映射关系的顺序,为指定映射的每一个映射关系生成一个条目访问。Sun提供的J2SE说明文档特别规定任何其余方法均不生成条目访问,尤为,collection 集合类的操做不会影响底层映射的迭代顺序。

LinkedHashMap的实现与 HashMap 的不一样之处在于,前者维护着一个运行于全部条目的双重连接列表。此连接列表定义了迭代顺序,该迭代顺序一般就是集合中元素的插入顺序。该类定义了header、before与after三个属性来表示该集合类的头与先后“指针”,其具体用法相似于数据结构中的双链表,以删除某个元素为例:

1 private void remove() {  
2        before.after = after;  
3        after.before = before;  
4 }    

实际上就是改变先后指针所指向的元素。

显然,因为增长了维护连接列表的开支,其性能要比 HashMap 稍逊一筹,不过有一点例外:LinkedHashMap的迭代所需时间与其的所包含的元素成比例;而HashMap 迭代时间极可能开支较大,由于它所须要的时间与其容量(分配给Key空间的长度)成比例。一言以蔽之,随机存取用HashMap,顺序存取或是遍历用LinkedHashMap。

LinkedHashMap还重写了removeEldestEntry方法以实现自动清除过时数据的功能,这在HashMap中是没法实现的,由于后者其内部的元素是无序的。默认状况下,LinkedHashMap中的removeEldestEntry的做用被关闭,其具体实现以下:

1 protected boolean removeEldestEntry(Map.Entry<k,v> eldest) { 
2     return false; 
3 } 

可使用以下的代码覆盖removeEldestEntry:

1 private static final int MAX_ENTRIES = 100;  
2   
3 protected boolean removeEldestEntry(Map.Entry eldest) {  
4     return size() > MAX_ENTRIES;  
5 }    

它表示,刚开始,LinkedHashMap中的元素不断增加;当它内部的元素超过MAX_ENTRIES(100)后,每当有新的元素被插入时,都会自动删除双链表中最前端(最旧)的元素,从而保持LinkedHashMap的长度稳定。

缺省状况下,LinkedHashMap采起的更新策略是相似于队列的FIFO,若是你想实现更复杂的更新逻辑好比LRU(最近最少使用) 等,能够在构造函数中指定其accessOrder为true,由于的访问元素的方法(get)内部会调用一个“钩子”,即recordAccess,其具体实现以下:

1 void recordAccess(HashMap<K,V> m) {  
2     LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;  
3     if (lm.accessOrder) {  
4         lm.modCount++;  
5         remove();  
6         addBefore(lm.header);  
7     }  
8 }   
 

上述代码主要实现了这样的功能:若是accessOrder被设置为true,则每次访问元素时,都将该元素移至headr的前面,即链表的尾部。将removeEldestEntry与accessOrder一块儿使用,就能够实现最基本的内存缓存,具体代码可参考http://bluepopopo.javaeye.com/blog/180236。

WeakHashMap

99%的Java教材教导咱们不要去干预JVM的垃圾回收机制,但JAVA中确实存在着与其密切相关的四种引用:强引用、软引用、弱引用以及幻象引用。

Java中默认的HashMap采用的是采用相似于强引用的强键来管理的,这意味着即便做为key的对象已经不存在了(指没有任何一个引用指向它),也仍然会保留在HashMap中,在某些状况下(例如内存缓存)中,这些过时的条目可能会形成内存泄漏等问题。

WeakHashMap采用的策略是,只要做为key的对象已经不存在了(超出生命周期),就不会阻止垃圾收集器清空此条目,即便当前机器的内存并不紧张。不过,因为GC是一个优先级很低的线程,所以不必定会很快发现那些只具备弱引用的对象,除非你显示地调用它,能够参考下面的例子:

 1 public static void main(String[] args) {  
 2     Map<String, String>map = new WeakHashMap<String, String>();  
 3     map.put(new String("Alibaba"), "alibaba");  
 4     while (map.containsKey("Alibaba")) {  
 5         try {  
 6             Thread.sleep(500);  
 7          } catch (InterruptedException ignored) {  
 8          }  
 9          System.out.println("Checking for empty");  
10          System.gc();  
11     }    
 

上述代码输出一次Checking for empty就退出了主线程,意味着GC在最近的一次垃圾回收周期中清除了new String(“Alibaba”),同时WeakHashMap也作出了及时的反应,将该键对应的条目删除了。若是将map的类型改成HashMap的话,因为其内部采用的是强引用机制,所以即便GC被显示调用,map中的条目依然存在,程序会不断地打出Checking for empty字样。另外,在使用WeakHashMap的状况下,如果将 map.put(new String("Alibaba"), "alibaba"); 改成 map.put("Alibaba", "alibaba"); 程序仍是会不断输出Checking for empty。这与前面咱们分析的WeakHashMap的弱引用机制并不矛盾,由于JVM为了减少重复建立和维护多个相同String的开销,其内部采用了蝇量模式(《Java与模式》),此时的“Alibaba”是存放在常量池而非堆中的,所以即便没有对象指向“Alibaba”,它也不会被GC回收。弱引用特别适合如下对象:占用大量内存,但经过垃圾回收功能回收之后很容易从新建立。

介于HashMap和WeakHashMap之中的是SoftHashMap,它所采用的软引用的策略指的是,垃圾收集器并不像其收集弱可及的对象同样尽可能地收集软可及的对象,相反,它只在真正 “须要” 内存时才收集软可及的对象。软引用对于垃圾收集器来讲是一种“睁一只眼,闭一只眼”方式,即 “只要内存不太紧张,我就会保留该对象。可是若是内存变得真正紧张了,我就会去收集并处理这个对象。” 就这一点看,它其实要比WeakHashMap更适合于实现缓存机制。遗憾的是,JAVA中并无实现相关的SoftHashMap类(Apache和Google提供了第三方的实现),但它倒是提供了两个十分重要的类java.lang.ref.SoftReference以及ReferenceQueue,能够在对象应用状态发生改变是获得通知,能够参考com.alibaba.common.collection.SofthashMap中processQueue方法的实现:

 1 private ReferenceQueue queue = new ReferenceQueue(); 
 2 ValueCell vc; 
 3 Map hash = new HashMap(initialCapacity, loadFactor); 
 4 …… 
 5 while ((vc = (ValueCell) queue.poll()) != null) { 
 6 if (vc.isValid()) { 
 7           hash.remove(vc.key); 
 8            } else { 
 9              valueCell.dropped--; 
10            } 
11 } 
12 }   
 

processQueue方法会在几乎全部SoftHashMap的方法中被调用到,JVM会经过ReferenceQueue的poll方法通知该对象已通过期而且当前的内存现状须要将它释放,此时咱们就能够将其从hashMap中剔除。事实上,默认状况下,Alibaba的MemoryCache所使用的就是SoftHashMap。