上一篇说到的仅仅是JDK7在极端情况下读减少碰撞概率的一些优化,以及hash函数采用的“扰动函数”思想。本篇将分析HashMap的主要方法,同时也是核心方法,是学习HashMap必然要理解的部分。话不多少,Let's do it!
一、插入元素
简述以下HashMap插入元素的大致的过程:
- 判断键值是否为空,若为空,调用putForNullKey()方法。所有键值为空的键值对都存储在Entry[]数组索引为0的bucket上;
- 键值不为空,继续。调用hash()计算哈希值;
- 根据Entry[]数组长度对哈希值取模,得到该键值在Entry[]数组上的索引(也就是在哪个bucket上);
- 遍历该bucket上链表的每个元素,判断添加的键值对是否已存在,如存在则用新元素替换旧元素,返回旧元素;若不存在,则在表尾添加新元素。
ok,对插入元素差不多有个整体印象了吧,如果有不解的,接下来再仔细研究一下源码。
public V put(K key, V value) {
if (key == null)
return putForNullKey(value);
int hash = hash(key);
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;
}
- 对应第一步。来看看对于键值为空的情况putForNullKey()方法是如何操作的:
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;
}
键值为null的时候,桶的索引直接定位到Entry[]数组table的0索引处,然后开始遍历0处的链表,若已存在键为null的元素,则用新元素的value替换旧元素的value,并返回旧元素的value,若0处链表不存在键为null的元素,则调用addEntry()方法在链表尾部添加新元素。
小结:
由此段源码可以分析得出,键为null的元素必然存放在数组的0索引处,且0索引处只允许有且只有一个键为null的元素。新添加进来的键为null的元素其对应的value会替换原有null键的value。
还有一点,数组索引0处不是只存储键为null的元素,也会存储其他键不为null的元素,但键为null的必定在数组索引0处。
- 对应第2步。调用hash()计算哈希值。这一步我们在上一篇已经讲过了,没看过的朋友可以翻到上一篇查阅。
- 对应第3步。对第二步得到的哈希值取模,得到元素在数组中对应的索引。
static int indexFor(int h, int length) {
return h & (length-1);
}
为什么要这么做呢?得到哈希值不就好了吗,不就可以直接定位了吗?事实并非这么简单。我们知道哈希值是一个int型的,可以用32位二进制码来表示,换算成十进制数其范围是-2的32次方~2的32次方-1,试想想系统会开辟一块连续的4亿左右大的内存空间吗?不可能,而且HashMap的初始容量为16,也就意味着开始只有16个bucket,而后扩容慢慢加。所以需要对得到的哈希值进行进一步的处理,也就是对数组长度取模,使其范围在数组长度之内。
源码中并没有使用简单的取模运算(h%length),而是使用与运算( h & (length-1)),应该是出于效率问题才选择后者。而且,在length为2的幂方时才是等价的!这也就说明的为什么数组长度要为2的幂方。
- 对应第4步。在得到对应的数组索引之后,并不是简单的在该桶上添加元素,还要进行一些判断操作,这些操作是很重要的,需要仔细研读。下面看看if判断语句的内容吧:
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))) {
......
}
}
if语句中所做的判断是把要添加进去的元素与该bucket上遍历的元素进行比较,比较哈希值是否一样(当然一样啦,不一样怎么会在同一个bucket上);比较键是否相同,若相同则替换,若不同则在尾部添加新元素。
引起注意!这里的“相同”有两层含义,第一层是对比的两个键引用同一个对象,第二层是对比的两个键满足equals()方法。
首先来看第一层,(k = e.key) == key:
若满足这个条件式,说明两个键是引用同一个对象,也就是两个键实质上是同一个东西。
第二层:key.equals(k):
equals方法可以是开发者自行覆盖重写的,如果满足这个条件式,说明两个键在开发者的定义上是“相同的”,比如说内容相同。
有关于==和equals的区别可以参见笔者的这篇分析:形象描述Java中equals与==的区别
二、获取键的对应值
HashMap中通过查询键来获取相应的value,因为在插入元素的时候也是根据键来插入的。
简述以下HashMap获取键的对应值的大致过程:
- 判断键是否为null,若是,调用getForNullKey()方法,该方法将在数组索引为0的地方遍历查询;
- 若键不为null,继续。依然是按照插入元素的过程一样,计算key的哈希值,找到对应的数组索引,再在链表中遍历查询。
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
- 第一步就不多说了,与插入元素的的第一步类似;
- 第二步是调用getEntry()方法,来看看源码吧:
final Entry<K,V> getEntry(Object key) {
int hash = (key == null) ? 0 : hash(key);
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 != null && key.equals(k))))
return e;
}
return null;
}
再次判断key是否为null,若为null,哈希值为0,若不为null,调用hash()方法获得哈希值。得到哈希值hash后,调用indexFor()得到数组索引,也就是桶的位置,然后就开始遍历链表了。
遍历链表同样是判断键是否相同,用到的也是==和equals()方法,参照上面的插入元素的第4步。
找到对应的key之后,得到key所在的Entry,调用Entry.getValue()获得value。
可以看出,只要掌握了插入元素的具体过程,获得元素的过程也就很好理解了。
四、删除元素
删除元素的过程与查找元素的过程几乎一致,只是在删除元素的环节多了链表中的前驱后继引用的改变而已,并没有什么新的知识点。就不再赘述了。
五、扩容
在添加元素的时候会进行一个阈值条件判断,若满足条件则会进行扩容。
还记得在添加元素时的最后一步是调用一个addEntry()方法了吗,忘记的可以看看源码,下面放上addEntry()的源码瞧瞧:
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
if条件语句中这样写的:
if ((size >= threshold) && (null != table[bucketIndex]))
- size >= threshold。我们先来看几个变量的含义,size指的是HashMap内键值对Entry的个数,capacity指的是HashMap中桶的个数,threshold=capacity*负载因子。键值对个数需大于等于threshold,而不是已占用的桶的数量超过阈值threshold,这点需要注意。
- null != table[bucketIndex]。在满足前一个条件下,仍需满足该指定索引处的桶有元素占用的条件。bucketIndex指的是要添加的元素的索引。
当满足两者的时候就可以调用resize()方法进行扩容,扩容的倍数是原来容量的两倍。下面看看resize()的实习原理。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing;
transfer(newTable, rehash);
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
这段代码中比较难懂的一个地方就是:
boolean rehash = oldAltHashing ^ useAltHashing;关于useAltHashing在上一篇有过说明,是JDK7对极端情况下的一个减少碰撞概率的优化,正常情况下是不会用到的。所以rehash普遍情况下是false。不用太过深究。
接下去的一个重点就是函数transfer(),这个函数将将原来的数组转移到新开辟的数组上,其源码如下:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);//计算新的索引
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
这段代码的主要工作是遍历每个桶上的每一个元素,并重新计算其在新容量数组下的索引值,然后根据新的索引值重新添加到新的数组中去。
其实只要把之前所说的添加元素的原理搞明白,这段transfer()代码也没有什么新的知识点,不过我们可以适当思考一下,哪些元素的索引位置会变,哪些不会变呢?
int i = indexFor(e.hash, newCapacity);
看看这句代码,已知newCapacity = 2*oldCapacity,试想想:
若hash小于等于oldCapacity,有
indexFor(e.hash, newCapacity) = indexFor(e.hash, newCapacity);
若hash大于等于newCapacity,有
indexFor(e.hash, newCapacity) = indexFor(e.hash, newCapacity);
所以!索引位置改变的不就是hash值在区间(oldCapacity,newCapacity)的元素吗?
再所以!这些元素又会散列到新的桶上,再次减少了碰撞几率。
并且有newIndex = oldIndex + oldcapacity。
(这些仅仅是笔者的一些头脑风暴,如有不对之处,可以提出来交流)
再来看看这两行:
e.next = newTable[i];
newTable[i] = e;
我们可以发现对于没有改变索引桶位置的元素,其所在链表的元素排列方式发生了反转。假设之前链表元素按照:e1,e2,e3,e4顺序排列,桶数组转移后就变成:e4,e3,e2,e1。
网友评论