15、HashMap工作原理和扩容机制

1. HashMap工作原理

HashMap作为优秀的Java集合框架中的一个重要的成员,在很多编程场景下为我们所用。HashMap作为数据结构散列表的一种实现,就其工作原理来讲单独列出一篇博客来讲都是不过分的。由于本文主要是简单总结其扩容机制,因此对于HashMap的实现原理仅做简单的概述。

HashMap内部实现是一个桶数组,每个桶中存放着一个单链表的头结点。其中每个结点存储的是一个键值对整体(Entry),HashMap采用拉链法解决哈希冲突(关于哈希冲突后面会介绍)。

由于Java8对HashMap的某些地方进行了优化,以下的总结和源码分析都是基于Java7。

示意图如下:


3290394-bea1bb72616a6599.png
20171119123859600.png

HashMap提供两个重要的基本操作,put(K, V)和get(K)。

  • 当调用put操作时,HashMap计算键值K的哈希值,然后将其对应到HashMap的某一个桶(bucket)上;此时找到以这个桶为头结点的一个单链表,然后顺序遍历该单链表找到某个节点的Entry中的Key是等于给定的参数K;若找到,则将其的old V替换为参数指定的V;否则直接在链表尾部插入一个新的Entry节点。

put函数大致的思路为:

1、对key的hashCode()做hash,然后再计算index;
2、如果没碰撞直接放到bucket里;
3、如果碰撞了,以链表的形式存在buckets后;
4、如果碰撞导致链表过长(大于等于5、TREEIFY_THRESHOLD),就把链表转换成红黑树;
5、如果节点已经存在就替换old value(保证key的唯一性)
6、如果bucket满了(超过load factor*current capacity),就要resize。

public V put(K key, V value) {
    // HashMap允许存放null键和null值。
    // 当key为null时,调用putForNullKey方法,将value放置在数组第一个位置。
    if (key == null)
        return putForNullKey(value);
    // 根据key的keyCode重新计算hash值。
    int hash = hash(key.hashCode());
    // 搜索指定hash值在对应桶中的索引。
    int i = indexFor(hash, table.length);
    // 如果 i 索引处的 Entry 不为 null,通过遍历桶外挂的单链表
    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;
        }
    }
    // 如果i索引处的Entry为null,表明此处还没有Entry。
    modCount++;
    //遍历单链表完毕,没有找到与键相对的Entry,需要新建一个Entry放在单链表中,
    //如果该桶不为空且数量达到筏值,有可能触发扩容
    addEntry(hash, key, value, i);
    return null;
}
  • 对于get(K)操作类似于put操作,HashMap通过计算键的哈希值,先找到对应的桶,然后遍历桶存放的单链表通过比照Entry的键来找到对应的值。

get函数大致的思路为

1、桶里的第一个节点(不存在单链表),直接获取;
2、如果有冲突,则通过key.equals(k)去查找对应的entry
若为树,则在树中通过key.equals(k)查找,O(logn);
若为链表,则在链表中通过key.equals(k)查找,O(n)。

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 直接命中
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 未命中
        if ((e = first.next) != null) {
            // 在树中get
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 在链表中get
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

频繁产生哈希冲突最重要的原因就像是要存储的Entry太多,而桶不够,这和供不应求的矛盾类似。因此,当HashMap中的存储的Entry较多的时候,我们就要考虑增加桶的数量,这样对于后续要存储的Entry来讲,就会大大缓解哈希冲突。

因此就涉及到HashMap的扩容,上面算是回答了为什么扩容,那么什么时候扩容?扩容多少?怎么扩容?便是第二部分要总结的了

2. HashMap扩容

2.1 HashMap的扩容时机

在使用HashMap的过程中,我们经常会遇到这样一个带参数的构造方法。

public HashMap(int initialCapacity, float loadFactor) ;

第一个参数:初始容量,指明初始的桶的个数;相当于桶数组的大小。
第二个参数:装载因子,是一个0-1之间的系数,根据它来确定需要扩容的阈值,默认值是0.75。
阿里巴巴开发手册中就有这么一条说明:


3290394-3ee93750f1335d54.png
image.png

还记得上面讲解put函数时,如果遍历单链表都没有找到对应的Entry吗?这时候调用了一个addEntry(hash, key, value, i)函数,在单链表中增加一个Entry。

 void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
          //当size大于等于某一个阈值thresholdde时候且该桶并不是一个空桶;
          /*这个这样说明比较好理解:因为size 已经大于等于阈 值了,说明Entry数量较多,哈希冲突严重, 
            那么若该Entry对应的桶不是一个空桶,这个Entry的加入必然会把原来的链表拉得更长,因此需要扩容;
            若对应的桶是一个空桶,那么此时没有必要扩容。*/
            //将容量扩容为原来的2倍
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            //扩容后的,该hash值对应的新的桶位置
            bucketIndex = indexFor(hash, table.length);
        }
        //在指定的桶位置上,创建一个新的Entry
        createEntry(hash, key, value, bucketIndex);
    }
    void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);//链表的头插法插入新建的Entry
       //更新size
        size++;
    }
  • size记录的是map中包含的Entry的数量

  • 而threshold记录的是需要resize的阈值 且 threshold = loadFactor * capacity

  • capacity 其实就是桶的长度

threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
因此现在总结出扩容的时机:

当map中包含的Entry的数量大于等于threshold = loadFactor * capacity的时候,且新建的Entry刚好落在一个非空的桶上,此刻触发扩容机制,将其容量扩大为2倍。

当size大于等于threshold的时候,并不一定会触发扩容机制(比如增加的entry对应的是一个空桶,那直接加载空桶里面,如果对应的不是空桶,会将链表拉长,就会触发扩容),但是会很可能就触发扩容机制,只要有一个新建的Entry出现哈希冲突,则立刻resize。

3. 总结

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

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

  2. 你知道HashMap的工作原理吗?
    HashMap 实际上是一个“链表散列”的数据结构,即数组和链表的结合体。它是基于哈希表的 Map 接口的非同步实现。
    他是基于hashing算法的原理,通过put(key,value)和get(key)方法储存和获取值的。

存:我们将键值对K/V 传递给put()方法,它调用K对象的hashCode()方法来计算hashCode从而得到bucket位置,之后储存Entry对象。(HashMap是在bucket中储存 键对象 和 值对象,作为Map.Entry)
取:获取对象时,我们传递 键给get()方法,然后调用K的hashCode()方法从而得到hashCode进而获取到bucket位置,再调用K的equals()方法从而确定键值对,返回值对象。

碰撞:当两个对象的hashcode相同时,它们的bucket位置相同,‘碰撞’就会发生。如何解决,就是利用链表结构进行存储,即HashMap使用LinkedList存储对象。但是当链表长度大于8(默认)时,就会把链表转换为红黑树,在红黑树中执行插入获取操作。

扩容:如果HashMap的大小超过了负载因子定义的容量,就会进行扩容。默认负载因子为0.75。就是说,当一个map填满了75%的bucket时候,将会创建原来HashMap大小的两倍的bucket数组(jdk1.6,但不超过最大容量),来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。

3、为什么扩容要以2的倍数扩容?
答案当然是为了性能。在HashMap通过键的哈希值进行定位桶位置的时候,调用了一个indexFor(hash, table.length);方法。

    /**
     * Returns index for hash code h.
     */
    static int indexFor(int h, int length) {
        return h & (length-1);
    }

&为位与运算符
可以看到这里是将哈希值h与桶数组的length-1(实际上也是map的容量-1)进行了一个与操作得出了对应的桶的位置,h & (length-1)。

但是为什么不采用h % length这种计算方式呢?
因为Java的%、/操作比&慢10倍左右,因此采用&运算会提高性能。

通过限制length是一个2的幂数,h & (length-1)和h % length结果是一致的。这就是为什么要限制容量必须是一个2的幂的原因。

举个简单的例子说明这两个操作的结果一致性:

假设有个hashcode是311,对应的二进制是(1 0011 0111)

length为16,对应的二进制位(1 0000)

%操作:311 = 16*19 + 7;所以结果为7,二进制位(0111);

&操作:(1 0011 0111) & (0111) = 0111 = 7, 二进制位(0111)

1 0011 0111 = (1 0011 0000) + (0111) = (12^4 + 1 2^5 + 02^6 + 02^7 + 12^8 ) + 7 = 2^4(1 + 2 + 0 + 0 + 16) + 7 = 16 * 19 + 7; 和%操作一致。
如果length是一个2的幂的数,那么length-1就会变成一个mask, 它会将hashcode低位取出来,hashcode的低位实际就是余数,和取余操作相比,与操作会将性能提升很多。
总体来说就是规定lengh是2的幂数,可以在调用indexFor方法获取桶的角标时,采用位与运算,位与运算比取余运算性能会高很多。

转载于:https://www.jianshu.com/p/c3633291ecda

猜你喜欢

转载自blog.csdn.net/weixin_34242658/article/details/91291092