HashMap底层原理及JDK8后的算法改进

前言:

jdk 1.8之前,hasmap内部是由数组+链表来实现的;

而jdk 1.8之后,对hashmap做了优化,对于链表长度超过8的链表将转储为红黑树,即采用数组+链表+红黑树。

jdk1.8对于HashMap碰撞处理的优化-引入红黑树。 在2014年,jdk1.8发布。Java8的HashMap对之前做了较大的优化,其中最重要的一个优化就是桶中的元素不再唯一按照链表组合,也可以使用红黑树进行存储,总之,目标只有一个,那就是在安全和功能性完备的情况下让其速度更快,提升性能。当链表长度太长(默认超过8)时,链表就转换为红黑树; 针对超长链的检查,时间复杂度从O(n)降到了O(log2n)
 

【*************** jdk 8之前***************】

HashMap中的数据结构

数据结构中有数组和链表来实现对数据的存储,但这两者基本上是两个极端。

  • 数组:数组存储区间是连续的,占用内存严重,故空间复杂的很大但数组的二分查找时间复杂度小,为O(1);数组的特点是:寻址容易,插入和删除困难;
  • 链表:链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)链表的特点是:寻址困难,插入和删除容易。

哈希表

那么我们能不能综合两者的特性,一种做出寻址容易,删除插入容易也。的数据结构?

答案是肯定的,这就是我们要提起的哈希表。

哈希表((哈希表)既满足了数据的查找方便,同时不占用太多的内容空间,使用也十分方便。

哈希表有多种不同的实现方法,我接下来解释的是最常用的一种方法 - 拉链法,我们可以理解为“ 链表的数组 ”,如图:


上从图产品我们可以发现哈希表的英文由【数组+链表】组成的一个长度为16的数组中,每个元素存储的是一个链表的头结点。

那么这些元素是按照什么样的规则存储到数组中呢?

一般情况是通过【散列(键)%LEN】获得,就是也。元素的密钥哈希的值对数组长度取模得到。

比如上述哈希表中,12%= 16%12,28 16 = 12108%16 = 12140%16 = 12。所以12,28,108以及140都存储在数组下标为12的位置。


HashMap中也可以理解为其存储数据的容器就是一个【线性数组】。

这可能让我们很不解,一个线性的数组怎么实现按键值对来存取数据呢?

这里HashMap中有做一些处理。首先HashMap的实现里面一个静态内部类条目,其重要的属性有键,值,下一个。从属性键,值我们就能很明显的看出来入口就是HashMap的键值对实现的一个基础豆,我们上面说到的HashMap的基础就是一个线性数组,这个数组就是入口[],地图里面的内容都保存在入口[]里面。

/ **表格,根据需要调整大小。长度必须始终是2的幂。* /

transient Entry []表;    

存数据的逻辑

?既然是线性数组,为什么能随机存取这里的HashMap用了一个小算法,大致是这样实现:

//存储时:

int hash = key.hashCode(); //每个key的hash是一个固定的int值

int index = hash%Entry [] .length; //去模运算,运算后的值肯定在0-length之间

输入[index] =值; //以去模后的值为索引,把值存进去


疑问:如果两个密钥通过散列%条目[]长度得到的指数相同,会不会有覆盖的危险?

这里HashMap中用到里面链式数据结构的一个概念。

上面我们提到过入门类里面有一个下一个属性,作用是指向下一个条目。

打个比方,第一个键值对A进来,通过计算其键的哈希得到的索引= 0,记做:条目[0] = A.

一会后又进来一个键值对B,通过计算其指数也等于0,现在怎么办?

HashMap会这样做:B.next = A,Entry[0] = B。

如果又进来C,index也等于0,那么C.next = B,Entry[0] = C;

这样我们发现index=0的地方其实存取了A,B,C三个键值对,他们通过next这个属性链接在一起。


    public V put(K key, V value) {

        if (key == nullreturn putForNullKey(value); //null总是放在数组的第一个链表中

        int hash = hash(key.hashCode());

        int i = indexFor(hash, table.length);

        //遍历链表

        for (Entry<K, V> e = table[i]; e != null; e = e.next) {

            Object k;

            //如果key在链表中已存在,则替换为新value(不要误解为是用新的值把旧的值覆盖了!)

            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;

    }

    void addEntry(int hash, K key, V value, int bucketIndex) {

        Entry<K, V> e = table[bucketIndex];

        table[bucketIndex] = new Entry<K, V>(hash, key, value, e); //参数e, 是Entry.next

        //如果size超过threshold,则扩充table大小。再散列

        if (size++ >= threshold) resize(2 * table.length);

    }


当然HashMap里面也包含一些优化方面的实现,比如:Entry[]的长度一定后,随着map里面数据的越来越长,这样同一个index的链就会很长,会不会影响性能?

HashMap里面设置一个因子,随着map的size越来越大,Entry[]会以一定的规则加长长度。

取数据的逻辑

//取值时:

int hash = key.hashCode();

int index = hash % Entry[].length;

return Entry[index];


    public V get(Object key) {

        if (key == nullreturn getForNullKey();

        int hash = hash(key.hashCode());

        //先定位到数组元素,再遍历该元素处的链表

        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.equals(k))) return e.value;

        }

        return null;

    }

其他逻辑

null key的存取

null key总是存放在Entry[]数组的第一个元素。

    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;

    }

    private V getForNullKey() {

        for (Entry<K, V> e = table[0]; e != null; e = e.next) {

            if (e.key == nullreturn e.value;

        }

        return null;

    }


确定数组index:hashcode % table.length取模

HashMap存取时,都需要计算当前key应该对应Entry[]数组哪个元素,即计算数组下标;算法如下:

    /** Returns index for hash code h. */

    static int indexFor(int h, int length) {

        return h & (length - 1);

    }

按位取并,作用上相当于取模mod或者取余%。

注意:不过的hashCode进行运算后的值可能相等,这意味着数组下标相同;但是,不要错误的理解为数组下标相同表示hashCode相同。


初始大小

public HashMap(int initialCapacity, float loadFactor) {

    .....

    // Find a power of 2 >= initialCapacity

    int capacity = 1;

    while (capacity < initialCapacity)

        capacity <<= 1;

    this.loadFactor = loadFactor;

    threshold = (int)(capacity * loadFactor);

    table = new Entry[capacity];

    init();

}

注意初始大小并不是构造函数中的initialCapacity!而是 >= initialCapacity的2的n次幂!!!!!

复制代码

 简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。

【***************jdk 8 之后***************】

HashMap 作为一种容器类型,无论你是否了解过其内部的实现原理,它的大名已经频频出现在各种互联网面试中了。从基本的使用角度来说,它很简单,但从其内部的实现来看(尤其是 Java 8 的改进以来),它又并非想象中那么容易。如果你一定要问了解其内部实现与否对于写程序究竟有多大影响,我不能给出一个确切的答案。但是作为一名合格程序员,对于这种遍地都在谈论的技术不应该不为所动。本篇文章主要从 jdk 1.8 的版本初步探寻 HashMap 的基本实现情况,主要涉及内容如下:

  • HashMap 的基本组成成员
  • put 方法的具体实现
  • remove 方法的具体实现
  • 其他一些基本方法的基本介绍

一、HashMap 的基本组成成员

首先,HashMap 是 Map 的一个实现类,它代表的是一种键值对的数据存储形式。Key 不允许重复出现,Value 随意。jdk 8 之前,其内部是由数组+链表来实现的,而 jdk 8 对于链表长度超过 8 的链表将转储为红黑树。大致的数据存储形式如下:

图片来自网络

下面分别对其中的基本成员属性进行说明:

//默认的容量,即默认的数组长度 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大的容量,即数组可定义的最大长度 
static final int MAXIMUM_CAPACITY = 1 << 30;

这就是上述提到的数组,数组的元素都是 Node 类型,数组中的每个 Node 元素都是一个链表的头结点,通过它可以访问连接在其后面的所有结点。其实你也应该发现,上述的容量指的就是这个数组的长度

transient Node<K,V>[] table;

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
}
//实际存储的键值对个数
transient int size;
//用于迭代防止结构性破坏的标量
transient int modCount;

下面这三个属性是相关的,threshold 代表的是一个阈值,通常小于数组的实际长度。伴随着元素不断的被添加进数组,一旦数组中的元素数量达到这个阈值,那么表明数组应该被扩容而不应该继续任由元素加入。而这个阈值的具体值则由负载因子(loadFactor)和数组容量来决定,公式:threshold = capacity * loadFactor。

int threshold;
final float loadFactor;
//HashMap 中默认负载因子为 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;

好了,有关 HashMap 的基本属性大致介绍如上。下面我们看看它的几个重载的构造函数。

public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

这是一个最基本的构造函数,需要调用方传入两个参数,initialCapacity 和 loadFactor。程序的大部分代码在判断传入参数的合法性,initialCapacity 小于零将抛出异常,大于 MAXIMUM_CAPACITY 将被限定为 MAXIMUM_CAPACITY。loadFactor 如果小于等于零或者非数字类型也会抛出异常。

整个构造函数的核心在对 threshold 的初始化操作:

static final int tableSizeFor(int cap) {
    int n = cap - 1;
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

这是一个小巧但精妙的方法,这里通过异或的位运算将两个字节的 n 打造成比 cap 大但最接近 2 的 n 次幂的一个数值。例如:

这里写图片描述

这里我们表示 n 的时候使用了 7 个 x,所以无论 x 为 0 或者 1,n 的值都是大于 2 的 7 次幂的。我们从最终结果可以看到,最后的 n 被打造为 8 个 1,也就是 2 的 8 次幂减一。

所以从宏观上看,传入的容量无论是处于任何范围,最终都会被打造成比该值大并且比最近的一个 2 的 n 次幂小一的值。为什么这么做?因为 2 的 n 次幂小一的值在二进制角度看全为 1,将有利于 HashMap 中的元素搜索,这一点我们后续将介绍

那么通过该方法,我们将获得一个 2 的整数次幂的容量的值,此处存放至 threshold,实际上我们获取的是一个有关数组容量的值,不应该存放至阈值 threshold 中,但在后续实际初始化数组的时候并不会受到影响,这里可能是写 jdk 的大神偷了一次懒吧。

那么我们对于这个最基本的构造函数的介绍就已经结束了,当然,HashMap 中还有很多的重载构造函数,但几乎都是基于上述的构造函数的。例如:

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
}

最后需要说明一点的是,以上的一些构造函数都没有直接的创建一个切实存在的数组,他们都是在为创建数组需要的一些参数做初始化,所以有些在构造函数中并没有被初始化的属性都会在实际初始化数组的时候用默认值替换。

二、put 方法的具体实现

put 方法的源码分析是本篇的一个重点,因为通过该方法我们可以窥探到 HashMap 在内部是如何进行数据存储的,所谓的数组+链表+红黑树的存储结构是如何形成的,又是在何种情况下将链表转换成红黑树来优化性能的。带着一系列的疑问,我们看这个 put 方法:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

hashMapputæ¹æ³æ§è¡æμç¨å¾

添加一个元素只需要传入一个键和一个值即可,putVal 方法是关键,我已经在该方法中进行了基本的注释,具体的细节稍后详细说明,先从这些注释中大体上建立一个直观的感受。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //如果 table 还未被初始化,那么初始化它
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //根据键的 hash 值找到该键对应到数组中存储的索引
    //如果为 null,那么说明此索引位置并没有被占用
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    //不为 null,说明此处已经被占用,只需要将构建一个节点插入到这个链表的尾部即可
    else {
        Node<K,V> e; K k;
        //当前结点和将要插入的结点的 hash 和 key 相同,说明这是一次修改操作
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //如果 p 这个头结点是红黑树结点的话,以红黑树的插入形式进行插入
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        //遍历此条链表,将构建一个节点插入到该链表的尾部
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //如果插入后链表长度大于等于 8 ,将链表裂变成红黑树
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        treeifyBin(tab, hash);
                    break;
                }
                //遍历的过程中,如果发现与某个结点的 hash和key,这依然是一次修改操作 
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //e 不是 null,说明当前的 put 操作是一次修改操作并且e指向的就是需要被修改的结点
        if (e != null) { 
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    //如果添加后,数组容量达到阈值,进行扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

从整体上来看,该方法的大致处理逻辑已如上述注释说明,下面我们针对其中的细节进行详细的解释。

首先,我们看 resize 这个方法是如何对 table 进行初始化的,代码比较多,分两部分进行解析:

//第一部分
final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        //拿到旧数组的长度
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        //说明旧数组已经被初始化完成了,此处需要给旧数组扩容
        if (oldCap > 0) {
            //极限的限定,达到容量限定的极限将不再扩容
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //未达到极限,将数组容量扩大两倍,阈值也扩大两倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; 
        }
        //数组未初始化,但阈值不为 0,为什么不为 0 ?
        //上述提到 jdk 大神偷懒的事情就指的这,构造函数根据传入的容量打造了一个合适的数组容量暂存在阈值中
        //这里直接使用
        else if (oldThr > 0) 
            newCap = oldThr;
        //数组未初始化并且阈值也为0,说明一切都以默认值进行构造
        else {
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        //这里也是在他偷懒的后续弥补
        //newCap = oldThr 之后并没有计算阈值,所以 newThr = 0
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
****************后续代码......*******

这一部分代码结束后,无论是初始化数组还是扩容,总之,必需的数组容量和阈值都已经计算完成了。下面看后续的代码:

************第一部分代码.....************
//根据新的容量初始化一个数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//旧数组不为 null,这次的 resize 是一次扩容行为
if (oldTab != null) {
    //将旧数组中的每个节点位置相对静止地拷贝值新数组中
    for (int j = 0; j < oldCap; ++j) {
        Node<K,V> e;
        //获取头结点
        if ((e = oldTab[j]) != null) {
            oldTab[j] = null;
            //说明链表或者红黑树只有一个头结点,转移至新表
            if (e.next == null)
                newTab[e.hash & (newCap - 1)] = e;
            //如果 e 是红黑树结点,红黑树分裂,转移至新表
            else if (e instanceof TreeNode)
                ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
            //这部分是将链表中的各个节点原序地转移至新表中,我们后续会详细说明
            else { 
                Node<K,V> loHead = null, loTail = null;
                Node<K,V> hiHead = null, hiTail = null;
                Node<K,V> next;
                do {
                    next = e.next;
                    if ((e.hash & oldCap) == 0) {
                        if (loTail == null)
                            loHead = e;
                        else
                            loTail.next = e;
                        loTail = e;
                    }
                    else {
                        if (hiTail == null)
                            hiHead = e;
                        else
                            hiTail.next = e;
                        hiTail = e;
                    }
                } while ((e = next) != null);
                if (loTail != null) {
                    loTail.next = null;
                    newTab[j] = loHead;
                }
                if (hiTail != null) {
                    hiTail.next = null;
                newTab[j + oldCap] = hiHead;
                }
            }
        }
    }
}
//不论你是扩容还是初始化,都可以返回 newTab
return newTab;

对于第二部分的代码段来说,主要完成的是将旧链表中的各个节点按照原序地复制到新数组中。关于头结点是红黑树的情况我们暂时不去涉及,下面重点介绍下链表的拷贝和优化代码块,这部分代码不再重复贴出,此处直接进行分析,有需要的可以参照上述列出的代码块或者自己的 jdk 进行理解。

这部分其实是一个优化操作,将当前链表上的一些结点移出来向刚扩容的另一半存储空间放

一般我们有如下公式:

index = e.hash & (oldCap - 1)

这里写图片描述

随便举个例子,此时的 e 在容量扩大两倍以后的索引值没有变化,所以这部分结点是不需要移动的,那么程序如何判断扩容前后的 index 是否相等呢?

//oldCap 一定是 100...000 的形式
if ((e.hash & oldCap) == 0)

如果原 oldCap 为 10000 的话,那么扩容后的 newCap 则为 100000,会比原来多出一位。所以我们只要知道原索引值的前一位是 0 还是 1 即可,如果是 0,那么它和新容量与后还是 0 并不改变索引的值,如果是 1 的话,那么索引值会增加 oldCap。

这样就分两步拆分当前链表,一条链表是不需要移动的,依然保存在当前索引值的结点上,另一条则需要变动到 index + oldCap 的索引位置上。

这里我们只介绍了普通链表的分裂情况,至于红黑树的裂变其实是类似的,依然分出一些结点到 index + oldCap 的索引位置上,只不过遍历的方式不同而已。

这样,我们对于 resize 这个扩容的方法已经解析完成了,下面接着看 putVal 方法,篇幅比较长,该方法的源码已经在介绍 resize 之前贴出,建议读者根据自己的 jdk 对照着理解。

上面我们说到,如果在 put 一个元素的时候判断内部的 table 数组还未初始化,那么调用 resize 根据相应的参数信息初始化数组。接下来的这个判断语句就很简单了:

if ((p = tab[i = (n - 1) & hash]) == null)
   tab[i] = newNode(hash, key, value, null);

根据键的 hash 值找到对应的索引位置,如果该位置为 null,说明还没有头结点,于是 newNode 并存储在该位置上。

否则的话说明该位置已经有头结点了,或者说已经存在一个链表或红黑树了,那么我们要做的只是新建一个节点添加到链表或者红黑树的最后位置即可。

第一步,

if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
      e = p;

p 指向当前节点,如果我们要插入的节点的键以及键所对应的 hash 值和 p 节点完全一样的话,那么说明这次 put 是一次修改操作,新建一个引用指向这个需要修改的节点。

第二步,

else if (p instanceof TreeNode)
     e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

如果当前 p 节点是红黑树结点,那么需要调用不同于链表的的添加节点的方法来添加一个节点到红黑树中。(主要是维持平衡,建议读者去了解下红黑树,此处没有深谈是限于它的复杂度和文章篇幅)。

第三步,

else {
     for (int binCount = 0; ; ++binCount) {
     if ((e = p.next) == null) {
         p.next = newNode(hash, key, value, null);
         if (binCount >= TREEIFY_THRESHOLD - 1) 
             treeifyBin(tab, hash);
         break;
     }
    if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))
         break;
    p = e;
    }
}

这里主要处理的是向普通链表的末尾添加一个新的结点,e 不断地往后移动,如果发现 e 为 null,那么说明已经到链表的末尾了,那么新建一个节点添加到链表的末尾即可,因为 p 是 e 的父节点,所以直接让 p.next 指向新节点即可。添加之后,如果发现链表长度超过 8,那么将链表转储成红黑树。

在遍历的过程中,如果发现 e 所指向的当前结点和我们即将插入的节点信息完全匹配,那么也说明这是一次修改操作,由于 e 已经指向了该需要被修改的结点,所以直接 break 即可。

那么最终,无论是第一步中找到的头节点即需要被修改的节点,还是第三步在遍历中找到的需要被修改的节点,它们的引用都是 e,此时我们只需要用传入的 Value 值替换 e 指向的节点的 value 即可。正如这段代码一样:

if (e != null) { // existing mapping for key
     V oldValue = e.value;
     if (!onlyIfAbsent || oldValue == null)
          e.value = value;
     afterNodeAccess(e);
     return oldValue;
 }

如果 e 为 null,那更简单了,说明此次 put 是添加新元素并且新元素也已经在上述代码中被添加到 HashMap 中了,我们只需要关心下,新加入一个元素后是否达到数组的阈值,如果是则调用 resize 方法扩大数组容量。该方法已经详细阐述过,此处不再赘述。

所以,这个 put 方法是集添加与修改一体的一个方法,如果执行的是添加操作则会返回 null,是修改操作则会返回旧结点的 value 值。

那么至此,我们对添加操作的内部实现想必已经了解的不错了,接下来看看删除操作的内部实现。

三、remove 方法的具体实现

删除操作就是一个查找+删除的过程,相对于添加操作其实容易一些,但那是你基于上述添加方法理解的不错的前提下。

public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

根据键值删除指定节点,这是一个最常见的操作了。显然,removeNode 方法是核心。

final Node<K,V> removeNode(int hash, Object key, Object value,boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        if (node != null && (!matchValue || (v = node.value) == value ||(value != null && value.equals(v)))) {
            if (node instanceof TreeNode)                                                                     ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

删除操作需要保证在表不为空的情况下进行,并且 p 节点根据键的 hash 值对应到数组的索引,在该索引处必定有节点,如果为 null ,那么间接说明此键所对应的结点并不存在于整个 HashMap 中,这是不合法的,所以首先要在这两个大前提下才能进行删除结点的操作。

第一步,

if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
     node = p;

需要删除的结点就是这个头节点,让 node 引用指向它。否则说明待删除的结点在当前 p 所指向的头节点的链表或红黑树中,于是需要我们遍历查找。

第二步,

else if ((e = p.next) != null) {
     if (p instanceof TreeNode)
          node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
     else {
         do {
              if (e.hash == hash &&((k = e.key) == key ||(key != null && key.equals(k)))) {
                     node = e;
              break;
         }
         p = e;
         } while ((e = e.next) != null);
     }
}

如果头节点是红黑树结点,那么调用红黑树自己的遍历方法去得到这个待删结点。否则就是普通链表,我们使用 do while 循环去遍历找到待删结点。找到节点之后,接下来就是删除操作了。

第三步,

if (node != null && (!matchValue || (v = node.value) == value ||(value != null && value.equals(v)))) {
       if (node instanceof TreeNode)
                    ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
       else if (node == p)
            tab[index] = node.next;
       else
            p.next = node.next;
       ++modCount;
       --size;
       afterNodeRemoval(node);
       return node;
 }

删除操作也很简单,如果是红黑树结点的删除,直接调用红黑树的删除方法进行删除即可,如果是待删结点就是一个头节点,那么用它的 next 结点顶替它作为头节点存放在 table[index] 中,如果删除的是普通链表中的一个节点,用该结点的前一个节点直接跳过该待删结点指向它的 next 结点即可。

最后,如果 removeNode 方法删除成功将返回被删结点,否则返回 null。

这样,相对复杂的 put 和 remove 方法的内部实现,我们已经完成解析了。下面看看其他常用的方法实现,它们或多或少都于这两个方法有所关联。

四、其他常用的方法介绍

除了常用的 put 和 remove 两个方法外,HashMap 中还有一些好用的方法,下面我们简单的学习下它们。

1、clear

public void clear() {
    Node<K,V>[] tab;
    modCount++;
    if ((tab = table) != null && size > 0) {
        size = 0;
        for (int i = 0; i < tab.length; ++i)
            tab[i] = null;
    }
}

该方法调用结束后将清除 HashMap 中存储的所有元素。

2、keySet

//实例属性 keySet
transient volatile Set<K>        keySet;

public Set<K> keySet() {
    Set<K> ks;
    return (ks = keySet) == null ? (keySet = new KeySet()) : ks;
}
final class KeySet extends AbstractSet<K> {
    public final int size()                 { return size; }
    public final void clear()               { HashMap.this.clear(); }
    public final Iterator<K> iterator()     { return new KeyIterator(); }
    public final boolean contains(Object o) { return containsKey(o); }
    public final boolean remove(Object key) {
        return removeNode(hash(key), key, null, false, true) != null;
    }
    public final Spliterator<K> spliterator() {
        return new KeySpliterator<>(HashMap.this, 0, -1, 0, 0);
    }
}

HashMap 中定义了一个 keySet 的实例属性,它保存的是整个 HashMap 中所有键的集合。上述所列出的 KeySet 类是 Set 的一个实现类,它负责为我们提供有关 HashMap 中所有对键的操作。

可以看到,KeySet 中的所有的实例方法都依赖当前的 HashMap 实例,也就是说,我们对返回的 keySet 集中的任意一个操作都会直接映射到当前 HashMap 实例中,例如你执行删除一个键的操作,那么 HashMap 中将会少一个节点。

3、values

public Collection<V> values() {
    Collection<V> vs;
    return (vs = values) == null ? (values = new Values()) : vs;
}

values 方法其实和 keySet 方法类似,它返回了所有节点的 value 属性所构成的 Collection 集合,此处不再赘述。

4、entrySet

public Set<Map.Entry<K,V>> entrySet() {
    Set<Map.Entry<K,V>> es;
    return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}

它返回的是所有节点的集合,或者说是所有的键值对集合。

5、get

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

get 方法的内部实现其实是我们介绍过的 put 方法中的一部分,所以此处也不再赘述。

三、为何HashMap的数组长度一定是2的次幂?

数组进行扩容,数组长度发生变化,而存储位置 index = h&(length-1),index也可能会发生变化,需要重新计算index:

将老数组中的数据逐个链表地遍历,扔到新的扩容后的数组中,我们的数组索引位置的计算是通过 对key值的hashcode进行hash函数运算后,再通过和 length-1进行与运算。

1、保证得到的新的数组索引和老数组索引一致

16的二进制表示为 10000,那么length-1就是15,二进制为01111,同理扩容后的数组长度为32,二进制表示为100000,length-1为31,二进制表示为011111。从下图可以我们也能看到这样会保证低位全为1,而扩容后只有一位差异,也就是多出了最左位的1,这样在通过 h & (length-1)的时候,只要h对应的最左边的那一个差异位为0,就能保证得到的新的数组索引和老数组索引一致(大大减少了之前已经散列良好的老数组的数据位置重新调换)。

2、获得的数组索引index更加均匀

数组长度保持2的次幂,length-1的低位都为1

3、唯一性

&运算,高位是不会对结果产生影响的,所以只关注低位,如果低位全部为1,那么对于h低位部分来说,任何一位的变化都会对结果产生影响,也就是说,要得到index=21这个存储位置,h的低位只有这一种组合。
如果不是2的次幂,也就是低位不是全为1此时,要使得index=21,h的低位部分不再具有唯一性了,哈希冲突的几率会变的更大,同时,index对应的这个bit位无论如何不会等于1了,而对应的那些数组位置也就被白白浪费了

我们现在可以回答开始的几个问题,加深对HashMap的理解:

  1. 什么时候会使用HashMap?他有什么特点? 
    是基于Map接口的实现,存储键值对时,它可以接收null的键值,是非同步的,HashMap存储着Entry(hash, key, value, next)对象。

  2. 你知道HashMap的工作原理吗? 
    通过hash的方法,通过put和get存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储,HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,Hashmap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。

  3. 你知道get和put的原理吗?equals()和hashCode()的都有什么作用? 
    通过对key的hashCode()进行hashing,并计算下标( n-1 & hash),从而获得buckets的位置。如果产生碰撞,则利用key.equals()方法去链表或树中去查找对应的节点

  4. 你知道hash的实现吗?为什么要这样实现? 
    在Java 1.8的实现中,是通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在bucket的n比较小的时候,也能保证考虑到高低bit都参与到hash的计算中,同时不会有太大的开销。

  5. 如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办? 
    如果超过了负载因子(默认0.75),则会重新resize一个原来长度两倍的HashMap,并且重新调用hash方法。

关于Java的的集合小抄中的英文这样描述的:

  • 以输入[]数组实现的哈希桶数组,用密钥的哈希值取模桶数组的大小可得到数组下标。
  • 插入元素时,如果两条重点落在同一个桶(比如哈希值1和17取模16后都属于第一个哈希桶),我们称之为哈希冲突。
  • JDK的做法是链表法,输入用一个下一个属性实现多个入口以单向链表存放。查找哈希值为17的钥匙时,先定位到哈希桶,然后链表遍历桶里所有元素,逐个比较其哈希值然后关键值。
  • 在JDK8里,新增默认为8的阈值,当一个桶里的入境超过阀值,就不以单向链表而以红黑树来存放以加快重点的查找速度。
  • 当然,最好还是桶里只有一个元素,不用去比较。所以默认当条目数量达到桶数量的75%时,哈希冲突已比较严重,就会成倍扩容桶数组,并重新分配所有原来的条目。扩容成本不低,所以也最好有个预估值。
  • 取模用与操作(hash&(arrayLength-1))会比较快,所以数组的大小永远是2的N次方,你随便给一个初始值比如17会转为32.默认第一次放入元素时的初始值是16。
  • 迭代器()时顺着哈希桶数组来遍历,看起来是个乱序

猜你喜欢

转载自blog.csdn.net/lusa1314/article/details/84301455
今日推荐