android-22之HashMap学习

来源:互联网 发布:飞机大战游戏设计java 编辑:程序博客网 时间:2024/06/18 12:25

最近在学习数据结构和算法,对于一开始就接触java语言的我来说,感觉数据结构离java很远(当然是jdk封装的好啦)。为了更好的结合所学语言理解数据结构,就决定学习一下java中hashmap的实现原理。

先声明一下本人所看的源码是android-22的hashmap源码

简单介绍下哈希表

哈希表

Hash table,也叫散列表,是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

切入正题 HashMap

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

hashmap是继承了abstractmap,并且实现了serializable、cloneable接口。源码中的NOTE主要有以下几点:
1、任何元素都可以当作map中的key和value,当然null也是可以的。
2、hashmap是线程不安全的类,当多个线程对同一个hashmap进行操作时,需要同步处理。
3、hashmap在使用迭代器的时候是不确定的(重新迭代出的键值对的顺序与原来的不一致),LinkedHashMap是迭代时是一致的。

hashmap的字段

首先先介绍下hashmap中最重要的两个参数:初始容量和加载因子。容量是哈希表中桶(Entry数组)的数量,初始容量只是哈希表在创建时的容量。加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,通过调用 rehash 方法将容量翻倍。

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

默认的最小的容量
private static final int MINIMUM_CAPACITY = 4
默认的最大的容量
private static final int MAXIMUM_CAPACITY = 1 << 30

注意hashmap的容量都是2的次幂数,这里默认的最小容量为2^2=4 最大容量为2^30。

实例化一个EMPTY_TABLE 大小为最小容量的一半,默认的构造函数的表就是这个。
private static final Entry[] EMPTY_TABLE = new HashMapEntry[MINIMUM_CAPACITY >>> 1]

默认的加载因子为0.75
static final float DEFAULT_LOAD_FACTOR =0 .75F

threshold = capacity*load_factor当map的size大于此值得时候就需要进行rehash动作
private transient int threshold

上面列举的是比较重要的字段,其他字段就不多做解释(像size、modCount等)

构造函数

这里写图片描述

先看无参构造函数

 public HashMap() {        table = (HashMapEntry<K, V>[]) EMPTY_TABLE;        threshold = -1; // Forces first put invocation to replace EMPTY_TABLE    }

其中EMPTY_TABLE早就实例化好了,它是一个具有默认初始容量(2)和默认的加载因子(0.75)的空hashmap.

再看两个参数的构造函数

 public HashMap(int capacity, float loadFactor) {        this(capacity);        if (loadFactor <= 0 || Float.isNaN(loadFactor)) {            throw new IllegalArgumentException("Load factor: " + loadFactor);        }    }

这个构造函数虽说有loadFactor这个参数,但是从源代码来看,说白了不会对加载因子的值做修改,仅仅对加载因子进行判断,保证它的值是数并且大于0。其实这个构造函数的作用和public HashMap(int capacity) 一样的。

public HashMap(int capacity) {        if (capacity < 0) {            throw new IllegalArgumentException("Capacity: " + capacity);        }        if (capacity == 0) {            @SuppressWarnings("unchecked")            HashMapEntry<K, V>[] tab = (HashMapEntry<K, V>[]) EMPTY_TABLE;            table = tab;            threshold = -1; // Forces first put() to replace EMPTY_TABLE            return;        }        if (capacity < MINIMUM_CAPACITY) {            capacity = MINIMUM_CAPACITY;        } else if (capacity > MAXIMUM_CAPACITY) {            capacity = MAXIMUM_CAPACITY;        } else {            capacity = Collections.roundUpToPowerOfTwo(capacity);        }        makeTable(capacity);    }

这个构造函数先是对capacity的值进行一系列的判断,如下图:
这里写图片描述

最终通过 makeTable(capacity)进行建表工作。

private HashMapEntry<K, V>[] makeTable(int newCapacity) {        @SuppressWarnings("unchecked") HashMapEntry<K, V>[] newTable                = (HashMapEntry<K, V>[]) new HashMapEntry[newCapacity];        table = newTable;        threshold = (newCapacity >> 1) + (newCapacity >> 2); // 3/4 capacity        return newTable;    }

HashMapEntry

从上面的makeTable(capacity)可以看出其实hashmap中维护着一个HashMapEntry的数组,那么我们先来看看HashMapEntry这个类的具体实现。HashMapEntry是实现entry这个接口。构造函数如下:

HashMapEntry(K key, V value, int hash, HashMapEntry<K, V> next) {            this.key = key;            this.value = value;            this.hash = hash;            this.next = next;        }

HashMapEntry的实现是使用单链表的方式,保存了三个字段,key、value、hash值,并用next链接到下一个HashMapEntry上。
HashMapEntry提供的方法有:

这里写图片描述

看一下hashcode方法

public final int hashCode() {  return (key == null ? 0 : key.hashCode())^(value == null ? 0 : value.hashCode());}

可以看出HashMapEntry的hash值是通过key和value两个的hash值计算的。
再来看一下equals方法

public final boolean equals(Object o) {  if (!(o instanceof Entry)) {     return false;  }  Entry<?, ?> e = (Entry<?, ?>) o;  return Objects.equal(e.getKey(), key)  && Objects.equal(e.getValue(), value);}

HashMapEntry的equals方法是通过key对象的equals方法和valuey对象的equals方法决定的。

接下看看集合类的通用方法:对对象的添加、删除、查找和修改。

添加键值对

public V put(K key, V value) {        if (key == null) {            return putValueForNullKey(value);        }        int hash = Collections.secondaryHash(key);        HashMapEntry<K, V>[] tab = table;        int index = hash & (tab.length - 1);        for (HashMapEntry<K, V> e = tab[index]; e != null; e = e.next) {            if (e.hash == hash && key.equals(e.key)) {                preModify(e);                V oldValue = e.value;                e.value = value;                return oldValue;            }        }        // No entry for (non-null) key is present; create one        modCount++;        if (size++ > threshold) {            tab = doubleCapacity();            index = hash & (tab.length - 1);        }        addNewEntry(key, value, hash, index);        return null;    }

可以看出对于添加键值对的操作,hashmap的做法主要是通过对key值的哈希值进行映射,最后寻找到键值对在数组中存储的位置。主要分为两步骤:
1、int hash = Collections.secondaryHash(key);
2、 int index = hash & (tab.length - 1);

先看看第一步中干了什么。

public static int secondaryHash(Object key) {        return secondaryHash(key.hashCode());    }  private static int secondaryHash(int h) {        // Spread bits to regularize both segment and index locations,        // using variant of single-word Wang/Jenkins hash.        h += (h <<  15) ^ 0xffffcd7d;        h ^= (h >>> 10);        h += (h <<   3);        h ^= (h >>>  6);        h += (h <<   2) + (h << 14);        return h ^ (h >>> 16);    }

secondaryHash中首先获取到key的哈希值,之后利用Wang/Jenkins hash算法计算出对应的哈希值。

第二步其实很简单,但是此处另有玄机。

经过上面的源码分析,我们已经知道HashMap中的数据结构是数组+单链表的组合,我们希望的是元素存放的更均匀,最理想的效果是,Entry数组中每个位置都只有一个元素,这样,查询的时候效率最高,不需要遍历单链表,也不需要通过equals去比较K,而且空间利用率最大。那如何计算才会分布最均匀呢?我们首先想到的就是%运算。而源码里并非使用直接的%运算,而是采用位运算而对上一步计算出来的值进行处理。
int index = hash & (tab.length - 1);
上文中说hashmap的大小一定是2的次幂数,这里好像解释了这个问题。只有当hashmap的大小为2的次幂数时,位运算的值才会等于取余的值。那么问题又来了。

Q:既然使用取余就可以让hashmap的大小没有额外的规定(这里指的是大小必须是2的次幂数),为什么不用取余运算呢?
A:我猜测,由于位运算的效率要远远高于取余运算,而hashmap在进行各种操作(添加、删除、查找、修改等)都会将key值的哈希值进行映射,那就必须要进行上面的步骤,这种频繁的工作,使用效率高一点的要好很多。这是一个很好的使用空间换时间的例子。

继续看添加键值对的源代码。利用位运算的结果(对应着entry数组的下角标)就可以找到数据要存放的位置了。其中的逻辑我配图简单说明一下:

这里写图片描述

先判断entry数组中相应下角标的值是否为空,如果不为空的就开始遍历其中的单链表,例如图中的1位置中的entry链表,这其中的元素,他们通过Wang/Jenkins hash算法计算出的值是一样的,这就需要通过key是否相等来判断是否是同一个entry。如果是的话,就直接修改entry中的value。不是的话,就插入到此单链表中(这里是从头插入)。而对于entry数组中相应下角标的值为空的话,也就直接插入(同样也是从头插入)。所以插入的动作可以一起完成,只需要对特定的条件(通过Wang/Jenkins hash算法计算出的值是一样的并且key的值也是一样的)进行特定的修改动作。

void addNewEntry(K key, V value, int hash, int index) {        table[index] = new HashMapEntry<K, V>(key, value, hash, table[index]);    }

至此就将hashmap中的添加键值对的过程解释清楚了。这其中也就包含了修改的过程(通过Wang/Jenkins hash算法计算出的值是一样的并且key的值也是一样的,此时的添加过程就是修改的过程)知道了添加的过程,接下来的查看与删除的过程就简单了。

删除键值对

map中的查看有以下三种:
1、通过键值查找对应的value:get(Object key)
2、判断是否有此key值:containsKey(Object key)
3、判断是否有此value值:containsValue(Object value)

public V get(Object key) {        if (key == null) {            HashMapEntry<K, V> e = entryForNullKey;            return e == null ? null : e.value;        }        int hash = Collections.secondaryHash(key);        HashMapEntry<K, V>[] tab = table;        for (HashMapEntry<K, V> e = tab[hash & (tab.length - 1)];                e != null; e = e.next) {            K eKey = e.key;            if (eKey == key || (e.hash == hash && key.equals(eKey))) {                return e.value;            }        }        return null;    }
public boolean containsKey(Object key) {        if (key == null) {            return entryForNullKey != null;        }        int hash = Collections.secondaryHash(key);        HashMapEntry<K, V>[] tab = table;        for (HashMapEntry<K, V> e = tab[hash & (tab.length - 1)];                e != null; e = e.next) {            K eKey = e.key;            if (eKey == key || (e.hash == hash && key.equals(eKey))) {                return true;            }        }        return false;    }
public boolean containsValue(Object value) {        HashMapEntry[] tab = table;        int len = tab.length;        if (value == null) {            for (int i = 0; i < len; i++) {                for (HashMapEntry e = tab[i]; e != null; e = e.next) {                    if (e.value == null) {                        return true;                    }                }            }            return entryForNullKey != null && entryForNullKey.value == null;        }        // value is non-null        for (int i = 0; i < len; i++) {            for (HashMapEntry e = tab[i]; e != null; e = e.next) {                if (value.equals(e.value)) {                    return true;                }            }        }        return entryForNullKey != null && value.equals(entryForNullKey.value);    }

不管是get、containsKey还是containsValue他们的实现逻辑都差不多,这里就不解释了。不得提一句containsValue的查找速度要慢很多,主要是map在存储的时候使用的是key的哈希值经过运算作为数组的索引的,所以对于key的搜索要快很多。而对于value的搜索则需要将数组中的每个链表都要遍历一次(最坏的打算)。

删除键值对

删除源代码

public V remove(Object key) {        if (key == null) {            return removeNullKey();        }        int hash = Collections.secondaryHash(key);        HashMapEntry<K, V>[] tab = table;        int index = hash & (tab.length - 1);        for (HashMapEntry<K, V> e = tab[index], prev = null;                e != null; prev = e, e = e.next) {            if (e.hash == hash && key.equals(e.key)) {                if (prev == null) {                    tab[index] = e.next;                } else {                    prev.next = e.next;                }                modCount++;                size--;                postRemove(e);                return e.value;            }        }        return null;    }

这里主要是链表的操作,因为是单链表的原因,遍历的时候,需要记录删除元素的上一个元素,以便将他的next指向删除元素的下一个元素。

上述详细介绍了hashMap中的正常键值对的操作,对于key=null,value!=null、key!=null,value=null、key=null,value=null这三种情况的操作请大家自行分析。

错误之处,请大家指正。

1 0
原创粉丝点击