java.util.HashMap

来源:互联网 发布:西南交通大学网络教学 编辑:程序博客网 时间:2024/06/06 14:24

HashMap类详解

         HashMap是我们使用非常多的Collection,它是基于哈希表的Map接口的实现,以key-value的形式存在。在HashMap中,key-value总是会当做一个整体来处理,系统会根据hash算法来来计算key-value的存储位置,我们总是可以通过key快速地存、取value。


定义

public class HashMap<K,V>

extends AbstractMap<K,V>

implements Map<K,V>, Cloneable, Serializable

        基于哈希表的Map接口的实现。此实现提供所有可选的映射操作,并允许使用null值和null键。(除了非同步和允许使用null之外,HashMap类与Hashtable大致相同。)此类不保证映射的顺序,特别是它不保证该顺序恒久不变。

        此实现假定哈希函数将元素适当地分布在各桶之间,可为基本操作(get和put)提供稳定的性能。迭代collection视图所需的时间与HashMap实例的容量(桶的数量)及其大小(键-值映射关系数)成比例。所以,如果迭代性能很重要,则不要将初始容量设置得太高(或将加载因子设置得太低)。

        HashMap的实例有两个参数影响其性能:初始容量和加载因子。容量是哈希表中桶的数量,初始容量只是哈希表在创建时的容量。加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,则要对该哈希表进行rehash操作(即重建内部数据结构),从而哈希表将具有大约两倍的桶数。

        通常,默认加载因子(0.75)在时间和空间成本上寻求一种折衷。加载因子过高虽然减少了空间开销,但同时也增加了查询成本(在大多数HashMap类的操作中,包括get和put操作,都反映了这一点)。在设置初始容量时应该考虑到映射中所需的条目数及其加载因子,以便最大限度地减少rehash操作次数。如果初始容量大于最大条目数除以加载因子,则不会发生rehash操作。

        如果很多映射关系要存储在HashMap实例中,则相对于按需执行自动的rehash操作以增大表的容量来说,使用足够大的初始容量创建它将使得映射关系能更有效地存储。

        注意,此实现不是同步的。如果多个线程同时访问一个哈希映射,而其中至少一个线程从结构上修改了该映射,则它必须保持外部同步。(结构上的修改是指添加或删除一个或多个映射关系的任何操作;仅改变与实例已经包含的键关联的值不是结构上的修改。)这一般通过对自然封装该映射的对象进行同步操作来完成。如果不存在这样的对象,则应该使用                                                       Collections.synchronizedMap方法来“包装”该映射。最好在创建时完成这一操作,以防止对映射进行意外的非同步访问,

        如下所示:

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

        由所有此类的”collection视图方法”所返回的迭代器都是快速失败的:在迭代器创建之后,如果从结构上对映射进行修改,除非通过迭代器本身的remove方法,其他任何时间任何方式的修改,迭代器都将抛出ConcurrentModificationException。因此,面对并发的修改,迭代器很快就会完全失败,而不冒在将来不确定的时间发生任意不确定行为的风险。

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


构造函数

        HashMap提供了三个构造函数:

        HashMap():构造一个具有默认初始容量(16)和默认加载因子(0.75)的空HashMap。

        HashMap(int initialCapacity):构造一个带指定初始容量和默认加载因子(0.75)的空 HashMap。

        HashMap(int initialCapacity, float loadFactor):构造一个带指定初始容量和加载因子的空 HashMap。

        在这里提到了两个参数:初始容量,加载因子。这两个参数是影响HashMap性能的重要参数,其中容量表示哈希表中桶的数量,初始容量是创建哈希表时的容量,加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75,一般情况下我们是无需修改的。


数据结构

        可以把HashMap的底层数据结构看出如下图所示,其中参数initialCapacity就代表了该数组的长度。


下面是HashMap构造器的源码:

public HashMap(int initialCapacity, float loadFactor) {        //初始容量不能<0        if (initialCapacity < 0)            throw new IllegalArgumentException("Illegal initial capacity: "                    + initialCapacity);        //初始容量不能 > 最大容量值,HashMap的最大容量值为2^30        if (initialCapacity > MAXIMUM_CAPACITY)            initialCapacity = MAXIMUM_CAPACITY;        //负载因子不能 < 0        if (loadFactor <= 0 || Float.isNaN(loadFactor))            throw new IllegalArgumentException("Illegal load factor: "                    + loadFactor);        // 计算出大于 initialCapacity 的最小的 2 的 n 次方值。        int capacity = 1;        while (capacity < initialCapacity)            capacity <<= 1;                this.loadFactor = loadFactor;        //设置HashMap的容量极限,当HashMap的容量达到该极限时就会进行扩容操作        threshold = (int) (capacity * loadFactor);        //初始化table数组        table = new Entry[capacity];        init();}

   从源码中可以看出,每次新建一个HashMap时,都会初始化一个table数组。table数组的元素为Entry节点。

static class Entry<K,V> implementsMap.Entry<K,V> {       final K key;       V value;        Entry<K,V> next;       final int hash;        // Creates new entry.       Entry(int h, K k, V v, Entry<K,V> n) {           value = v;           next = n;           key = k;           hash = h;       }       .......    }

        其中Entry为HashMap的内部类,它包含了键key、值value、下一个节点next,以及hash值,这是非常重要的,正是由于Entry才构成了table数组的项为链表。


存储实现:put(key, vlaue)

HashMap利用put方法存储键值对,下面是put方法的源代码:

public V put(K key, V value) {        //当key为null,调用putForNullKey方法,保存null与table第一个位置中,这是HashMap允许为null的原因        if (key == null)            return putForNullKey(value);        //计算key的hash值        int hash = hash(key.hashCode());                  ------(1)        //计算key hash 值在 table 数组中的位置        int i = indexFor(hash, table.length);             ------(2)        //从i出开始迭代 e,找到 key 保存的位置        for (Entry<K, V> e = table[i]; e != null; e = e.next) {            Object k;            //判断该条链上是否有hash值相同的(key相同)            //若存在相同,则直接覆盖value,返回旧value            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {                V oldValue = e.value;    //旧值 = 新值                e.value = value;                e.recordAccess(this);                return oldValue;     //返回旧值            }        }        //修改次数增加1        modCount++;        //将key、value添加至i位置处        addEntry(hash, key, value, i);        return null;    }

        通过源码我们可以清晰看到HashMap保存数据的过程为:首先判断key是否为null,若为null,则直接调用putForNullKey方法。若不为空则先计算key的hash值,然后根据hash值搜索在table数组中的索引位置,如果table数组在该位置处有元素,则通过比较是否存在相同的key,若存在则覆盖原来key的value,否则将该元素保存在链头(最先保存的元素放在链尾)。若table在该处没有元素,则直接保存。

        1、 先看迭代处。此处迭代原因就是为了防止存在相同的key值,若发现两个hash值(key)相同时,HashMap的处理方式是用新value替换旧value,这里并没有处理key,这就解释了HashMap中没有两个相同的key。

        2、 再看(1)、(2)处。这里是HashMap的精华所在。首先是hash方法,该方法为一个纯粹的数学计算,就是计算h的hash值。

static int hash(int h) {        h ^= (h >>> 20) ^ (h >>> 12);        return h ^ (h >>> 7) ^ (h >>> 4);    }

        我们知道对于HashMap的table而言,数据分布需要均匀(最好每项都只有一个元素,这样就可以直接找到),不能太紧也不能太松,太紧会导致查询速度慢,太松则浪费空间。计算hash值后,怎么才能保证table元素分布均与呢?我们会想到取模,但是由于取模的消耗较大,HashMap是这样处理的:调用indexFor方法。

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

        HashMap的底层数组长度总是2的n次方,在构造函数中存在:capacity<<= 1;这样做总是能够保证HashMap的底层数组长度为2的n次方。当length为2的n次方时,h&(length - 1)就相当于对length取模,而且速度比直接取模快得多,这是HashMap在速度上的一个优化。

        当length总是2的倍数时,h & (length-1)将是一个非常巧妙的设计:假设h=5,length=16,那么h& length - 1将得到5;如果h=6,length=16, 那么h & length - 1将得到 6 ……如果 h=15, length=16, 那么h & length - 1将得到15;但是当h=16时 , length=16时,那么 h & length - 1 将得到0了;当h=17时, length=16时,那么h & length - 1将得到1了……这样保证计算得到的索引值总是位于table数组的索引之内。

        总结put的流程:当我们想一个HashMap中添加一对key-value时,系统首先会计算key的hash值,然后根据hash值确认在table中存储的位置。若该位置没有元素,则直接插入。否则迭代该处元素链表并依此比较其key的hash值。如果两个hash值相等且key值相等(e.hash == hash && ((k = e.key) == key ||key.equals(k))),则用新的Entry的value覆盖原来节点的value。如果两个hash值相等但key值不等,则将该节点插入该链表的链头。具体的实现过程见addEntry方法:

void addEntry(int hash, K key, V value, int bucketIndex) {        //获取bucketIndex处的Entry        Entry<K, V> e = table[bucketIndex];        //将新创建的 Entry 放入 bucketIndex 索引处,并让新的 Entry 指向原来的 Entry         table[bucketIndex] = new Entry<K, V>(hash, key, value, e);        //若HashMap中元素的个数超过极限了,则容量扩大两倍        if (size++ >= threshold)            resize(2 * table.length);}

         首先是程序中的两个变量:size:该变量保存了该HashMap中所包含的key-value对的数量。 threshold:该变量包含了HashMap能容纳的key-value对的极限,它的值等于HashMap的容量乘以负载因子(load factor)。

        系统总是将新的Entry对象添加到bucketIndex处。如果bucketIndex处已经有了对象,那么新添加的Entry对象将指向原有的Entry对象,形成一条Entry链,但是若bucketIndex处没有Entry对象,也就是e==null,那么新添加的Entry对象指向null,也就不会产生Entry链了。

        随着HashMap中元素的数量越来越多,发生碰撞的概率就越来越大,所产生的链表长度就会越来越长,这样势必会影响HashMap的速度,为了保证HashMap的效率,系统必须要在超过临界点(threshold)时进行扩容处理。但是扩容是一个非常耗时的过程,因为它需要重新计算这些数据在新table数组中的位置并进行复制处理。所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。


读取实现:get(key)

通过key的hash值找到在table数组中的索引处的Entry,然后返回该key对应的value即可。

public V get(Object key) {       // 若为null,调用getForNullKey方法返回相对应的value       if (key == null)           return getForNullKey();       // 根据该 key 的hashCode 值计算它的 hash 码        int hash = hash(key.hashCode());       // 取出 table 数组中指定索引处的值       for (Entry<K, V> e = table[indexFor(hash,table.length)]; e != null; e = e.next) {            Object k;           //若搜索的key与查找的key相同,则返回相对应的value           if (e.hash == hash && ((k = e.key) ==key || key.equals(k)))                returne.value;       }       return null;    }

        在这里能够根据key快速的取到value除了和HashMap的数据结构密不可分外,还和Entry有莫大的关系,HashMap在存储过程中并没有将key,value分开来存储,而是当做一个整体key-value来处理的,这个整体就是Entry对象。同时value也只相当于key的附属而已。在存储的过程中,系统根据key的hashcode来决定Entry在table数组中的存储位置,在取的过程中同样根据key的hashcode取出相对应的Entry对象。

原创粉丝点击