您的位置:首页 > 移动开发 > Android开发

HashMap源码分析

2017-01-14 21:45 337 查看

1, 概念

HashMap就像是一个键值对,根据对应的键就可以获取相对应的值。



从图中可以看出, HashMap的结构比较简单

Cloneable和Serializable就不多论述了。

Map接口有一个内部接口,方法如下,



并且AbstractMap实现了Map.Entry接口和部分方法。

2, HashMap

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

    HashMap的底层主要是基于数组和链表来实现的,它之所以有相当快的查询速度主要是因为它是通过计算散列码来决定存储的位置。HashMap中主要是通过key的hashCode来计算hash值的,只要hashCode相同,计算出来的hash值就一样。如果存储的对象对多了,就有可能不同的对象所算出来的hash值是相同的,这就出现了所谓的hash冲突。学过数据结构的同学都知道,解决hash冲突的方法有很多,HashMap底层是通过链表来解决hash冲突的。

2.1, 主要变量

static final float DEFAULT_LOAD_FACTOR = .75F; // 默认的加载因子
transient HashMapEntry<K, V>[] table; //存储元素的实体数组
transient int size; //存放元素的个数
transient int modCount; //被修改的次数
private transient int threshold; //临界值,当实际大小超过临界值时,会进行扩容threshold = 加载因子*容量
transient HashMapEntry<K, V> entryForNullKey; // 保存key为null的元素

其中加载因子是表示Hash表中元素的填满的程度.

若:加载因子越大,填满的元素越多,好处是,空间利用率高了,但:冲突的机会加大了.链表长度会越来越长,查找效率降低。

反之,加载因子越小,填满的元素越少,好处是:冲突的机会减小了,但:空间浪费多了.表中的数据将过于稀疏(很多空间还没用,就开始扩容了)

冲突的机会越大,则查找的成本越高.

因此,必须在 "冲突的机会"与"空间利用率"之间寻找一种平衡与折衷. 这种平衡与折衷本质上是数据结构中有名的"时-空"矛盾的平衡与折衷.

如果机器内存足够,并且想要提高查询速度的话可以将加载因子设置小一点;相反如果机器内存紧张,并且对查询速度没有什么要求的话可以将加载因子设置大一点。不过一般我们都不用去设置它,让它取默认值0.75就好了。

以下是HashMapEntry的部分代码,

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

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。

2.2, 构造方法

HashMap一共有4个构造方法,

HashMap()

初始容量为2

HashMap(int capacity)

初始容量为不小于capacity最小的2的n次幂

HashMap(int capacity, float loadFactor)

和第二个构造方法一样,并不能设置加载因子

HashMap(Map<? extends K, ? extends V> map)

将map转化为HashMap

重点看下第二个构造方法,

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);
}

调用roundUpToPowerOfTwo方法得到不小于capacity的最小2的n次幂, roundUpToPowerOfTwo方法如下,

public static int roundUpToPowerOfTwo(int i) {
i--; // If input is a power of two, shift its high-order bit right.

// "Smear" the high-order bit all the way to the right.
i |= i >>> 1;
i |= i >>> 2;
i |= i >>> 4;
i |= i >>> 8;
i |= i >>> 16;

return i + 1;
}
为什么要把容量设置为2的n次幂呢?

2.3, put方法

Put方法主要向hashmap中添加键值,

public V put(K key, V value) {
if (key == null) {  //  若“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;
}
} // 如果哈希号相同并且key相同,则覆盖原来的值。

// 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;
}

这里面有几个重点,

1, “key为null”的添加方法putValueForNullKey

2, 添加元素的addNewEntry方法

3,进行扩容的doubleCapacity方法

4,计算存放的位置方法。

2.3.1 putValueForNullKey

private V putValueForNullKey(V value) {
HashMapEntry<K, V> entry = entryForNullKey;
if (entry == null) { // key为null 不存在
addNewEntryForNullKey(value);
size++;
modCount++;
return null;
} else { // 已存在
preModify(entry);
V oldValue = entry.value;
entry.value = value;
return oldValue;
}
}
void addNewEntryForNullKey(V value) {
entryForNullKey = new HashMapEntry<K, V>(null, value, 0, null);
}

2.3.2 addNewEntry

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

由此可见,哈希号相同的元素组成一个单链表。

2.3.3 doubleCapacity

Threshold值一般为0.75*容量,如果hashmap中元素总数大于threshold,则需要进行扩容,将容量扩大为原来的2倍。

private HashMapEntry<K, V>[] doubleCapacity() {
HashMapEntry<K, V>[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
return oldTable;
}
int newCapacity = oldCapacity * 2;
HashMapEntry<K, V>[] newTable = makeTable(newCapacity);
if (size == 0) {
return newTable;
}

for (int j = 0; j < oldCapacity; j++) {
/*
* Rehash the bucket using the minimum number of field writes.
* This is the most subtle and delicate code in the class.
*/
HashMapEntry<K, V> e = oldTable[j];
if (e == null) {
continue;
}
int highBit = e.hash & oldCapacity;
HashMapEntry<K, V> broken = null;
newTable[j | highBit] = e;
for (HashMapEntry<K, V> n = e.next; n != null; e = n, n = n.next) {
int nextHighBit = n.hash & oldCapacity;
if (nextHighBit != highBit) {
if (broken == null)
newTable[j | nextHighBit] = n;
else
broken.next = n;
broken = e;
highBit = nextHighBit;
}
}
if (broken != null)
broken.next = null;
}
return newTable;
}

makeTable方法如下,

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,调整threshold值。当HashMap中的元素越来越多的时候,hash冲突的几率也就越来越高,因为数组的长度是固定的。所以为了提高查询的效率,就要对HashMap的数组进行扩容. 扩容是需要进行数组复制的,复制数组是非常消耗性能的操作,所以如果已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。

2.3.4  2的整数次幂

根据哈希号存放位置计算方法,

int index = hash & (tab.length - 1);
一般对哈希表的散列很自然地会想到用hash值对length取模(即除法散列法),Hashtable中也是这样实现的,这种方法基本能保证元素在哈希表中散列的比较均匀,但取模会用到除法运算,效率很低,HashMap中则通过hash & (tab.length - 1)的方法来代替取模,同样实现了均匀的散列,但效率要高很多

接下来,分析下为什么哈希表的容量一定要是2的整数次幂。首先,length为2的整数次幂的话,hash & (tab.length - 1)就相当于对length取模,这样便保证了散列的均匀,同时也提升了效率;其次,length为2的整数次幂的话,为偶数,这样tab.length-1为奇数,奇数的最后一位是1,这样便保证了hash & (tab.length - 1)的最后一位可能为0,也可能为1(这取决于h的值),即与后的结果可能为偶数,也可能为奇数,这样便可以保证散列的均匀性,而如果length为奇数的话,很明显tab.length-1为偶数,它的最后一位是0,这样hash
& (tab.length - 1)的最后一位肯定为0,即只能为偶数,这样任何hash值都只会被散列到数组的偶数下标位置上,这便浪费了近一半的空间,因此,length取2的整数次幂,是为了使不同hash值发生碰撞的概率较小,这样就能使元素在哈希表中均匀地散列。

这看上去很简单,其实比较有玄机的,举个例子来说明:

  假设数组长度分别为15和16,优化后的hash码分别为8和9,那么&运算后的结果如下: 

hash & (tab.length - 1)hash                          table.length-1                 结果                  
8 & (15-1)010011100100
9 & (15-1)010111100100
8 & (16-1)010011110100
9 & (16-1)010111110101
从上面的例子中可以看出:当它们和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相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小,相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。

2.4, get方法

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;
}

有了上面put时的hash算法作为基础,理解起来这段代码就很容易了。从上面的源代码中可以看出:从HashMap中get元素时,首先计算key的hashCode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息