Java集合源码----HashMap

近日无事看看源码,昨天看了ArrayList,今天看下HashMap。HashMap有多重要?只要去面试,Hashtable和Hashmap的区别基本是必问的知识点,HashMap是否线程安全以及并发情况下的concurrentHashMap也基本是必问的。跟看ArrayList一样,对于Hashmap的各种操作过程我也是通过一个小例子debug来查看每一步的运行情况。

package com.sgx.source;

import java.util.HashMap;

public class Test {
	public static void main(String[] args) {
		HashMap map = new HashMap();
		map.put("1","sgx");
		map.put("2", "value");
		map.remove("1");
	}
}


1.数据结构


紫色是table,每一个table都是一个链表的头(table[0],也是entry[0]),绿色是一个一个的Entry如果hash值计算的一样的话,entry会连接在一起,addEntry方法中会看到这是个单链表的插入,新的元素插在table[i]。图片出处:http://blog.csdn.net/sinat_32873711/article/details/54097992

Ctrl+左键跟入HashMap.class内部,我们发现HashMap定义如下:

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

    /**
     * The default initial capacity - MUST be a power of two.
     */
    static final int DEFAULT_INITIAL_CAPACITY = 16;

    /**
     * The maximum capacity, used if a higher value is implicitly specified
     * by either of the constructors with arguments.
     * MUST be a power of two <= 1<<30.
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * The load factor used when none specified in constructor.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /**
     * The table, resized as necessary. Length MUST Always be a power of two.
     */
    transient Entry[] table;

    /**
     * The number of key-value mappings contained in this map.
     */
    transient int size;

    /**
     * The next size value at which to resize (capacity * load factor).
     * @serial
     */
    int threshold;

    /**
     * The load factor for the hash table.
     *
     * @serial
     */
    final float loadFactor;

    /**
     * The number of times this HashMap has been structurally modified
     * Structural modifications are those that change the number of mappings in
     * the HashMap or otherwise modify its internal structure (e.g.,
     * rehash).  This field is used to make iterators on Collection-views of
     * the HashMap fail-fast.  (See ConcurrentModificationException).
     */
    transient volatile int modCount;

实现了Map接口,可克隆,可序列化,有一个默认初始容量(16,必须是2得N次幂),最大容量(1<<30=2^30),装载因子0.75,存储数据的是一个Entry类型的数组(长度为2^n),HashMap的size即存储得key-value对得数目。注意到table定义是transient,参考上一篇中的内容,可以知道这个Entry数组不能被直接序列化。threshold是重新分配空间时的大小,计算公式为容量(capacity*load factor);装载因子是一个为了重新分配空间大小设置的系数,默认是0.75,0.75是对时间和空间都相对优化的一个选择,装载因子过小会导致这个散列表很稀疏,空间造成浪费,如果装载因子过大,会resize扩容,每次扩充后的大小为原来的两倍,而且涉及到数组的复制,这样的话空间虽然够了但是查找效率会降低。


,modCount在jdk源码注释中这样解释:modCount是这个Hashmap已经被结构化修改的次数,这个参数主要是为了迭代器在遍历集合fail-fast的时候抛出ConcurrentModificationEception。


fail-fast(快速失败机制):是Java集合的一种错误检测机制。当多个线程操作同一个集合的时候,假如有AB两个Thread,Thread-A用迭代器遍历集合,Thread-B在这个过程中修改了集合的结构(是修改结构,不是增删元素),此时会抛出ConcurrentModificationEception异常,这就是快速失败机制。

下面我们看到实际HashMap存储数据的结构是一个Entry数组,Entry又是什么东西呢?点进去,如下:

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

看到这个很容易联想到一个数据结构,单链表,不过是域不同罢了,这个Entry就是个单链表。

看完结构,我们现在开始测试上面的程序为了加深印象,下面的源码我都会手敲下。


2.初始化

我们来看下new HashMap的时候发生了什么吧。调用了下面的构造方法:

public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
        table = new Entry[DEFAULT_INITIAL_CAPACITY];
        init();
    }

给装载因子,threshold以及table赋值,init()是个空方法。table在new的时候如下:

static class Entry<K,V> implements Map.Entry<K, V>{
		final K key;
		V value;
		Entry<K,V>next;
		final int hash;
		
		Entry(int h,K k,V v,Entry<K,V>n){
			value = v;
			next = n;
			key = k;
			hash = h;
		}
	}

就是给Entry中的几个元素赋值,key,value,下一个节点,以及hash值。


3.添加

执行map.put(),我们跟进方法中查看put的源码如下:

public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key.hashCode());
        int i = indexFor(hash, table.length);
        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;
            }
        }

        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }

先会判断key是否为空,为空的话会把空值放在table的第0个位置上。这也是Hashtable和HashMap第一点不同,Hashtable不允许为空,Hashmap是允许存空值的。

不为空的话会去检查table[i]上面有没有相同key的元素,如果有的话直接更新值返回即可。没有的话进入addEntry方法。

然后第二步是获取Hash值,这一步是为了让带插入的Entry均匀分布,以Key(是个Object)的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结果,然后为这个结果来确定在table中的位置;

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

得到结果为i,那么当前元素Entry就插在table[i]头部的位置。下面是addEntry:

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

先拿到第i个元素e,然后再第i个位置new一个Entry,赋值,并且让这个Entry指向e,就完成了一次插入操作,跟单链表的操作是一样的。值得注意的是,size比较大的时候

size>capacity*loadfactor(16*0.75=12)的时候会扩容。扩容涉及到复制片段,类似ArrayList,比较影响效率的。

两次插入完成后得到的结果为:

[null,2=value,1=sgx,null,,null,null,null,null,null,null,null,null,null,null,null,null]

4.删除

下面执行删除操作,remove的时候我们继续debug调试,执行如下方法:

public V remove(Object key) {
        Entry<K,V> e = removeEntryForKey(key);
        return (e == null ? null : e.value);
    }

final Entry<K,V> removeEntryForKey(Object key) {
        int hash = (key == null) ? 0 : hash(key.hashCode());
        int i = indexFor(hash, table.length);
        Entry<K,V> prev = table[i];
        Entry<K,V> e = prev;

        while (e != null) {
            Entry<K,V> next = e.next;
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k)))) {
                modCount++;
                size--;
                if (prev == e)
                    table[i] = next;
                else
                    prev.next = next;
                e.recordRemoval(this);
                return e;
            }
            prev = e;
            e = next;
        }

        return e;
    }

我们执行了map.remove("1");此时key不为空,还是会先对key本身的hashcode做一次hash,然后把hash的结果利用indexFor求出下标,下面就是单链表的删除操作;

两个节点prev和e均指向table[i],从e开始往后遍历,如果e的key和要删除的key一致(注意比较的过程:先比较整数哈希值,在比较key,因为有短路表达式有&&运算符,一个不成立直接跳过)那么就让当前的table[i]指向他的下一个节点,不一致的话,就让e的前一个Entry也就是prev,prev的next指向e的后一个节点即可,相当于把pre往后移动一个Entry继续循环比较。

5.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;   
}  
可以看到,传入参数Object K,返回value

(1)key==null,getForNullKey()即可;
(2)key!=null的时候,先是hash求取下标,然后遍历table[i]开头的这一条链表,在这一片链表上找到key相同的Entry返回value就行了。所有的操作都跟hash离不开。效率很高,不管查询还是插入。

5.Hashtable和HashMap区别

(1)HashMap允许空值,Hashtable会报空指针异常;

(2)Hashmap是非线程安全的,因为方法均没有同步,并发情况下用到Java.util.concurrent包下面的concurrentHashmap;

Hashtable是线程安全的。


6.Hash算法(以下内容转自点击打开链接)

我们一般对哈希表的散列很自然地会想到用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):                                 0100                   &              1110                   =                0100
       9 & (15-1):                                 0101                   &              1110                   =                0100
       -----------------------------------------------------------------------------------------------------------------------
       8 & (16-1):                                 0100                   &              1111                   =                0100
       9 & (16-1):                                 0101                   &              1111                   =                0101
复制代码

 

从上面的例子中可以看出:当它们和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相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值