HashMap底层实现

来源:互联网 发布:淘宝客采集软件多少钱 编辑:程序博客网 时间:2024/06/05 04:12

1、HashMap的数据结构 

在Java编程语言中,最基本的结构就是两种,一个是数组,另外一个是模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造的,HashMap也不例外,HashMap实际上是一个数组和链表的结合体(在数据结构中,一般称之为“链表散列“),请看下图(横排表示数组,纵排表示数组元素【实际上是一个链表】)





其中Entry<K,V>为键值对,源码如下:


static class Entry<K,V> implements Map.Entry<K,V> {          final K key;          V value;          final int hash;          Entry<K,V> next;  ..........  }  

注意:Java 8中当数组大小已经超过64并且链表中的元素个数超过默认设定(8个)时,将链表转化为红黑树(上图应作修改)


2、HashMap的put()方法与get()方法:

1)  put()方法:

源码如下:


public V put(K key, V value) {       // HashMap允许存放null键和null值。       // 当key为null时,调用putForNullKey方法,将value放置在数组第一个位置。       if (key == null)           return putForNullKey(value);       // 根据key的hashCode重新计算hash值。       int hash = hash(key.hashCode());       // 搜索指定hash值所对应table中的索引。       int i = indexFor(hash, table.length);       // 如果 i 索引处的 Entry 不为 null,通过循环不断遍历 e 元素的下一个元素。       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))) {               V oldValue = e.value;               e.value = value;               e.recordAccess(this);               return oldValue;           }       }       // 如果i索引处的Entry为null,表明此处还没有Entry。       // modCount记录HashMap中修改结构的次数       modCount++;       // 将key、value添加到i索引处。       addEntry(hash, key, value, i);       return null;   }  
private V putForNullKey(V value) {        for (Entry<K,V> e = table[0]; e != null; e = e.next) {            if (e.key == null) {                V oldValue = e.value;                e.value = value;                e.recordAccess(this);                return oldValue;            }        }        modCount++;        addEntry(0, null, value, 0);        return null;    }

当往hashmap中put元素的时候,先根据key的hash值得到这个元素在数组中的位置(即下标),然后就可以把这个元素放到对应的位置中了

如果这个元素所在的位子上已经存放有其他元素了,那么在同一个位子上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾

2)get()方法:

源码如下:

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;   } 
private V getForNullKey() {        for (Entry<K,V> e = table[0]; e != null; e = e.next) {            if (e.key == null)                return e.value;        }        return null;    }

从hashmap中get元素时,首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。由此可以得出:如果每个位置上的链表只有一个元素,那么hashmap的get效率将是最高的


c)总结:HashMap 在底层将 key-value 当成一个整体进行处理,这个整体即是一个 Entry 对象,HashMap 底层采用一个 Entry[] 数组来保存所有的 key-value ,当需要存一个 Entry ,会根据hash算法来决定其在数中的存位置,在根据equals方法决定其在位置上的表中


3、hash算法:

源码:

//table初始化大小:注意table初始化大小不是构造函数中的initialCapacity,而是>= initialCapacity的2^npublic HashMap(int initialCapacity, float loadFactor) {        .....        // Find a power of 2 >= initialCapacity        int capacity = 1;        while (capacity < initialCapacity)            capacity <<= 1;//capacity = capacity <<1        this.loadFactor = loadFactor;        threshold = (int)(capacity * loadFactor);//临界值        table = new Entry[capacity];        init();    }
确定数组索引:

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

解析:首先算根据key使用hashcode()方法得到hash值h,然后跟数组的长度-1做一次“与”运算(&)

如:数组的长度是2的4次方,那么hashcode就会和2的4次方-1做“与”运算。很多人都有这个疑问,为什么hashmap的数组初始化大小都是2的次方大小时,hashmap的效率最高,接下来以2的4次方举例,来解释一下为什么数组大小为2的幂时hashmap访问的性能最高

看下图


左边两组是数组长度为16(2的4次方),右边两组是数组长度为15。假定两组的hashcode均为8和9,

但是很明显,当它们和1110“与”的时候,产生了相同的结果都是0100,也就是说它们会定位到数组中的同一个位置上去,

这就产生了碰撞,8和9会被放到同一个链表上,那么查询的时候就需要遍历这个链表,得到8或者9,这样就降低了查询的效率。

同时,我们也可以发现,当数组长度为15的时候,hashcode的值会与14(1110)进行“与”,那么最后一位永远是0,

而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,

更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率! 


// Find a power of 2 >= initialCapacity          int capacity = 1;          while (capacity < initialCapacity)               capacity <<= 1;  
这段代码保证初始化时HashMap的容量总是2的n次方,即底层数组的长度总是为2的n次方


总结:当数组长度为2的n次幂的时候,不同的key算得得index相同的几率较小,那么数据在数组上分布就比较均匀,

也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了


3、hashmap的resize 

当hashmap中的元素越来越多的时候,碰撞的几率也就越来越高(因为数组的长度是固定的),所以为了提高查询的效率,就要对hashmap的数组进行扩容,数组扩容这个操作也会出现在ArrayList中,所以这是一个通用的操作,而在hashmap数组扩容之后,最消耗性能的点就出现了:原数组中的数据必须重新计算其在新数组中的位置,并放进去,这就是resize。 

        那么hashmap什么时候进行扩容呢?

当hashmap中的元素个数超过数组大小*loadFactor时,就会进行数组扩容,Java 8中当数组大小已经超过64并且链表中的元素个数超过默认设定(8个)时,将链表转化为红黑树

loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12的时候,就把数组的大小扩展为2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知hashmap中元素的个数,那么预设元素的个数能够有效的提高hashmap的性能。

比如说,我们有1000个元素newHashMap(1000), 但是理论上来讲new HashMap(1024)更合适,然而即使是1000,hashmap也自动会将其设置为1024。但是new HashMap(1024)还不是更合适的,因为0.75*1024 < 1000, 也就是说为了让0.75 * size > 1000, 我们必须这样newHashMap(2048)才最合适,既考虑了&的问题,也避免了resize的问题。 

 

4、key的hashcode与equals方法改写 

在第一部分hashmap的数据结构中,get方法的过程:首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。所以,hashcode与equals方法对于找到对应元素是两个关键方法。 

Hashmap的key可以是任何类型的对象,例如User这种对象,为了保证两个具有相同属性的user的hashcode相同,我们就需要改写hashcode方法,比方把hashcode值的计算与User对象的id关联起来,那么只要user对象拥有相同id,那么他们的hashcode也能保持一致了,这样就可以找到在hashmap数组中的位置了。

如果这个位置上有多个元素,还需要用key的equals方法在对应位置的链表中找到需要的元素,所以只改写了hashcode方法是不够的,equals方法也是需要改写,按正常思维逻辑,equals方法一般都会根据实际的业务内容来定义,例如根据user对象的id来判断两个user是否相等。 

在改写equals方法的时候,需要满足以下三点: 

(1)自反性:就是说a.equals(a)必须为true。 

(2)对称性:就是说a.equals(b)=true的话,b.equals(a)也必须为true。 

(3)传递性:就是说a.equals(b)=true,并且b.equals(c)=true的话,a.equals(c)也必须为true。 

通过改写key对象的equals和hashcode方法,我们可以将任意的业务对象作为map的key(前提是你确实有这样的需要)。 

 

5.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,以降低实际的负载因子(也就是说虽然数组长度是capacity,但其扩容的临界值确是threshold)。默认的的负载因子0.75是对空间和时间效率的一个平衡选择。当容量超出此最大容量时, resize后的HashMap容量是容量的两倍:

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

参考博客:

1http://www.iteye.com/topic/539465

2http://blog.csdn.net/ustcbob/article/details/23709589