美文网首页
深度剖析HashMap(二)——基于JDK7

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

作者: Wangheguan | 来源:发表于2018-05-04 20:40 被阅读0次

    上一篇说到的仅仅是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。

    相关文章

      网友评论

          本文标题:深度剖析HashMap(二)——基于JDK7

          本文链接:https://www.haomeiwen.com/subject/bjzlrftx.html