深度剖析HashMap(二)——基于JDK7

上一篇说到的仅仅是JDK7在极端情况下读减少碰撞概率的一些优化,以及hash函数采用的“扰动函数”思想。本篇将分析HashMap的主要方法,同时也是核心方法,是学习HashMap必然要理解的部分。话不多少,Let’s do it!

一、插入元素

简述以下HashMap插入元素的大致的过程:

  1. 判断键值是否为空,若为空,调用putForNullKey()方法。所有键值为空的键值对都存储在Entry[]数组索引为0的bucket上;
  2. 键值不为空,继续。调用hash()计算哈希值;
  3. 根据Entry[]数组长度对哈希值取模,得到该键值在Entry[]数组上的索引(也就是在哪个bucket上);
  4. 遍历该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获取键的对应值的大致过程:

  1. 判断键是否为null,若是,调用getForNullKey()方法,该方法将在数组索引为0的地方遍历查询;
  2. 若键不为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。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值