Map学习笔记——深入理解HashMap

138 阅读28分钟

drew-beamer-Vc1pJfvoQvY-unsplash.jpg

基于JDK8进行分析

HashMap是我们日常开发中使用频率最高的容器之一了,它具有如下几个特点:

  1. 存取是无序的
  2. 键和值都允许为null
  3. 底层的数据结构是:数组 + 链表 + 红黑树
  4. 当链表长度大于8并且数组长度大于64,才会将链表转换为红黑树,其目的是为了增加查询效率
  5. 是线程不安全的

尽量使用不可变对象来作为key,否则对象如果变化,重新计算的hash值可能和之前不一样,从而出现错误

组成结构简单如图看下: image.png

接下来我们就通过源码看下,HashMap是如何完成数据存储和扩容的。

1.HashMap的主要属性

 /**
  * 为什么继承了AbstractMap后还实现了Map接口呢?
  * 根据集合框架的作者Josh Bloch 所述,这样的写法是一个失误,在Java集合框架中,有很多集合
  * 都采用了这种写法,比如ArrayList, LinkedList ,起初作者这样写,在某些地方是有价值的,
  * 后来意识到这是一种错误写法,但是JDK的开发者认为这个小小的失误不值得他们去改,所以就一直
  * 沿用到了现在。
  */
public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

    private static final long serialVersionUID = 362498820763181265L;

    /**
     * 默认的初始容量:16(1 * 2^4), 必须是2的n次幂.
     * 为什么必须是2的n次幂呢?
     * >> 容量一定要是2的n次幂,是为了提高“计算元素放哪个桶”的效率,也是为了提高扩容效率(避免了扩容后再重复处理哈希碰撞问题)<<
     *
     * 后面看计算索引位置以及扩容的时候再说
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    /**
     * 数组的最大容量
     * MUST be a power of two <= 1<<30.
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * 默认的负载因子:默认值是 0.75
     * 它是用来判断是否需要扩容的
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /**
     * 当链表的长度超过阈值8,则转换成红黑树
     * 为什么达到8才转换成红黑树呢?
     * 在HashMap的文档注释中有提到,树节点占用的空间是普通节点的2倍,所以当每个桶(数组的节点)包含
     * 足够的节点(链表)时,才会转换成树节点,这个阈值就是8,注释中还提到,根据泊松分布统计
     * 链表达到8个的概率是0.00000006,这几乎是不可能事件。
     *
     * 由于树节点占用的空间是普通节点的2倍,并且在转换成树节点时还要左旋和右旋,如果节点数量少,
     * 一顿操作下来,比普通链表花费的开销还要大。
     *
     * 说白了,就是在时间和空间中不断权衡
     *
     * 还有资料表明:红黑树的平均查找长度是log(n),链表的平均查找长度是n/2, 如果长度是8,那么红黑树
     * 的平均查找长度是3,链表是4,这才有转换成树的必要,如果长度是6,那么红黑树的平均查找长度是2.6, 
     * 链表是3,虽然速度也是快的,但是转换成树结构和生成树的时间并不会短。
     */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     * 当红黑树的长度小于6,则将红黑树转换成链表
     */
    static final int UNTREEIFY_THRESHOLD = 6;

    /**
     * 当链表的长度超过8,"并且" "数组的长度"大于64,才会转换成红黑树,如果没有达到64,此时
     * 先进行扩容。(注意:是数组的length, 不是元素个数size)
     */
    static final int MIN_TREEIFY_CAPACITY = 64;
    
   
    /**
     * 元素个数(数组上的 + 链表上的 + 红黑树上的), 不是数组长度!!!
     */
    transient int size;

    /**
     * 记录HashMap的修改次数,和 ConcurrentModificationException 异常有关
     */
    transient int modCount;

    /**
     * 临界值:当实际大小(容量 * 负载因子)超过临界值时,就会扩容。
     * 扩容后的容量是原来的2倍,即也是2的N次幂
     */
    int threshold;

    /**
     * 
     *  负载因子,用来衡量HashMap满的程度。默认是0.75 (DEFAULT_LOAD_FACTOR)
     *  loadFactor = size/capacity(table.length)
     *
     *  为什么是0.75 呢 ?
     *  如果过小,比如0.3,那么 threshold = 16 * 0.3 = 4,当达到4就扩容,容易造成数组空间浪费,而且会导致频繁扩容,这将会带来性能损耗
     *  如果过大,比如0.9,那么 threshold = 16 * 0.9 = 14, 当达到14扩容,我们知道,数组是很难均匀填满的,这样当数据量多的时候就容易产生hash碰撞,导致链表过长
     */
    final float loadFactor;

}    

HashMap的容量必须是2的N次幂,为什么这样设计呢?
容量一定要是2的n次幂,是为了提高“计算元素放哪个桶”的效率,也是为了提高扩容效率(避免了扩容后再重复处理哈希碰撞问题)

2.初始容量

2.1如果初始容量不是2的N次幂会怎样?

在属性的注释中有说明,数组的容量必须是2的N次幂,那如果不是2的N次幂会怎样呢?

结论:它就取大于当前指定容量的最小的2的N次幂
6 --> 8 (2^3)
9 --> 16 (2^4)
16 --> 16 (2^4)

我们通过有参构造器看下,它是如何做到的:

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;
    //核心方法:将初始容量指定为2的N次幂
    //这里的threshold临界值是16,并不是 初始容量capacity * 0.75 = 12 ? 写错了吗?
    //其实并没有,当第一次put的时候,它会将threshold修改为上述规则
    this.threshold = tableSizeFor(initialCapacity);
}

从构造器中可以看到,容量最大能取的值就是2^30次方,假如我指定的初始容量是 10, 那么我们看下他是如何计算出16的:

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;
}

核心就是无符号右移 + 按位或运算
按位与:相同的二进制数位上,都是1的时候结果为1,否则为0
按位或:相同的二进制数位上,都是0的时候结果为0,否则为1(有1就是1)

我指定的初始容量是10,那么就带进来,看下他是如何一步一步工作的:

int n = cap - 1;  // n = 10 - 1 = 9
n |= n >>> 1; 
 
1.无符号右移1位:
n=9        00000000 00000000 00000000 00001001  (int: 4字节,32位)
右移1位     00000000 00000000 00000000 00000100  结果是:4
-------------------------------------------------------
按位或      00000000 00000000 00000000 00001101  结果是: 13(按位或:有1就是1)

第一次无符号右移1位后按位或得到结果是13,"把高位的相邻1位也会置为1",因为高位向右移动了1位


2.无符号右移2位:
n |= n >>> 2; 
n=13        00000000 00000000 00000000 00001101 
右移2位      00000000 00000000 00000000 00000011  结果是:3
-------------------------------------------------------
按位或       00000000 00000000 00000000 00001111  结果是: 15


3.无符号右移4位:
n |= n >>> 4; 
n=15        00000000 00000000 00000000 00001111 
右移4位      00000000 00000000 00000000 00000000  结果是:0
-------------------------------------------------------
按位或       00000000 00000000 00000000 00001111  结果是: 依然是15


4.无符号右移8位:
n |= n >>> 8; 
n=15        00000000 00000000 00000000 00001111 
右移8位      00000000 00000000 00000000 00000000  结果是:0
-------------------------------------------------------
按位或       00000000 00000000 00000000 00001111  结果是: 依然是15


5.无符号右移16位:
n |= n >>> 16; 
n=16         00000000 00000000 00000000 00001111 
右移16位      00000000 00000000 00000000 00000000  结果是:0
-------------------------------------------------------
按位或        00000000 00000000 00000000 00001111  结果是: 依然是15

6.返回 // 15 + 1 = 16
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;

从上述的无符号右移和按位或运算,可以得出一个现象,假如高位中有连续的4个1,那么经过右移2位,在按位或运算后,他会变成6个1,那如果是右移4位,在做按位或运算,就会变成8个1。以此类推...

我们知道,int类型的容量最大可取的值是32bit的正数,而且最后一步中的 n |= n >>> 16 最多可以产生32个1,但此时这已经是负数了,因此在执行 tableSizeFor(initialCapacity) 方法之前,对 initialCapacity 进行了判断,如果 initialCapacity > MAXIMUM_CAPACITY(2^30), 则 initialCapacityMAXIMUM_CAPACITY,如果等于 MAXIMUM_CAPACITY(2^30),那么 int n = cap - 1 就等于 2^30 -1, 移位操作最多可以得到30个1,此时是小于MAXIMUM_CAPACITY的,30个1加上1之后,得到 2^30,也就是 MAXIMUM_CAPACITY。(类比:4个1是15,加上一个1后是16,也就变成了2的4次方)

在进行移位和按位或操作前要把容量减1呢?

int n = cap - 1;

这是为了避免指定的容量本身就是2的N次幂,经过移位和按位或操作后变成2倍的2的N次幂,假如,初始容量为16,如果不减1,那么经过移位和按位或操作后,就会变成32,如果减1后,得到的结果就是16.

2.2 初始容量指定多少合适?

这一点在阿里巴巴的java规约手册中有提到 image.png

其实这个很好理解,假如我要存放的元素有7个,那么数组的容量是8,但是当存储6个时候它就会扩容了,所以为了避免扩容,我们应该存在的元素是 7 / 0.75 + 1 = 10,这样数组的长度是16,虽然浪费了一点空间,但问题不大。

3.构造器分析

  1. 无参构造器
public HashMap() {
   this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

仅指定默认的加载因子,不创建存储元素的table数组,当put的时候再去创建数组。

2.指定初始容量,使用默认的加载因子(加载因子推荐使用默认值)

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

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);
}
  1. 参数是Map
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    //关注这个方法
    putMapEntries(m, false);
}
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    int s = m.size();
    if (s > 0) {
        //table默认值刚开始是null
        if (table == null) { // pre-size
            /**
             * 为什么要+1呢?
             * 加1的目的是尽量减少扩容的次数。
             * 假如新添加到Map有6个元素,那么如果不+1,那么 t = 8,threshold计算出来也是8
             * 然后开始put元素,当到达阈值6(put的时候重新计算threshold)的时候,他就会扩容
             * 那如果 +1,t=9, threshold计算出来是16,这样put的时候就不用扩容了
             */
            float ft = ((float)s / loadFactor) + 1.0F;
            int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                     (int)ft : MAXIMUM_CAPACITY);
            if (t > threshold)
                threshold = tableSizeFor(t);
        }
        else if (s > threshold)
            resize();
        for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
            K key = e.getKey();
            V value = e.getValue();
            putVal(hash(key), key, value, false, evict);
        }
    }
}

4.重要方法分析

4.1 put()方法

整体步骤大致是:

  1. 先通过hash值计算出key映射到哪个桶(桶就是数组的空间)
  2. 如果桶上没有碰撞,则直接插入
  3. 如果桶上有碰撞,则通过equals方法判断key是否相同,如果相同,则覆盖旧值value, 如果不相同,则处理冲突

    3.1): 如果该桶是红黑树,则调用红黑树的方法插入数据
    3.2): 如果该桶是链表,则通过尾插法插入到链表尾部,如果长度超过8,则转换成红黑树

  4. 如果size 大于 阈值threshold,则扩容

源码:

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

//核心方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //计算桶的索引位置    
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = 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);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

4.1.1 计算key的哈希值

static final int hash(Object key) {
    int h;
    /**
     * 如果key = null,那么它的hash值为0,这样经过按位与运算后得到的桶的索引仍然是0,所以
     * key=null的元素会存在数组的index=0的位置。
     *
     * 如果 key != null, 那么先计算出key的hash值赋给h,然后将h无符号右移16位,在与h按位异或
     * 得到最终的hash值
     */
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

我们简单看下:

假设key的hashcode计算出来是h

01111111 11111111 11110000 11101010   h
00000000 00000000 01111111 11111111   h >>> 16
-------------------------------------------------
01111111 11111111 10001111 00010101   ^(按位异或:相同为0,不同为1)
00000000 00000000 00000000 00001111   15(table.length - 1)
---------------------------------------------------
00000000 00000000 00000000 00000101   5  这样就计算出了key在桶的位置

那从效果上看,它和 hashcode % table.length 是一样的。

按位异或:相同的二进制数位上,相同为0,不同为1

不过,为什么要对 h 进行无符号右移后再与h取按位异或运算呢?

如果数组长度很小,比如capacity = 16, 16 - 1 = 15, 换算成二进制就是 1111, 这样的值直接与hashcode值进行按位与操作,实际上只使用了hash值的后4位,如果hash值高位变化很大,低位变化很小或没有变化,这样很容易造成hash冲突,所以这里通过无符号右移和按位异或操作来将高低位都利用起来,从而减少hash碰撞。

我们来演示一下:

①第一次存储key并计算出的hash值:

01111111 11111111 11110000 11101010   h
00000000 00000000 00000000 00001111   15(table.length - 1)
-------------------------------------------------------------
00000000 00000000 00000000 00001010   10(按位与操作的结果)

②第二次存储key并计算出的hash值:

                                
01000111 10100111 11110000 11101010   h (高位变化大,低位变化小)
00000000 00000000 00000000 00001111   15(table.length - 1)
-------------------------------------------------------------
00000000 00000000 00000000 00001010   10(按位与操作的结果)

不难发现,高位变化很大,低位变化很小(或者没有变化) 很容易发生hash冲突。

4.1.2 计算key所在桶的位置

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    //......
    
    //n: 数组长度
    //hash: key的hash值
    if ((p = tab[i = (n - 1) & hash]) == null)
       
    //......    
        
 }        

前面我们有提过,HashMap数组的长度必须是2的N次幂,其中它就和计算桶的位置以及扩容有关,那么我们就先来看下他和桶的位置的关系。

简单来讲,一个元素放到哪个桶中,是通过 “hash % capacity” 取模运算得到的余数来确定的,但是HashMap用另一种更高效方式来替代取模运算,就是位运算(capacity - 1) & hash
如果想要 hash % capacity == (capacity - 1) & hash, 那前提条件就是capacity必须是2的N次幂才可以。(至于效率自己可以测试看下), 我们先看下通过位运算计算索引
假设 capacity = 8, hash = 3
(8 - 1) & 3 = 3

        00000111    ---> 7
        00000011    ---> 3
        -----------
 按位与: 00000011     --> 3

假设 capacity = 8, hash = 2
(8 - 1) & 2 = 2

        00000111    ---> 7
        00000010    ---> 2
        -----------
 按位与: 00000010     --> 2

取模运算:

  3 % 8 = 3
  2 % 8 = 2

假设 capacity 不是2的N次幂, capacity = 9, hash = 3

        00001000    ---> 8
        00000011    ---> 3
        -----------
 按位与: 00000000     --> 0

假设 capacity 不是2的N次幂, capacity = 9, hash = 2

        00001000    ---> 8
        00000010    ---> 2
        -----------
 按位与: 00000000     --> 0

不难发现,如果不是2的N次幂,非常容易发生哈希碰撞。
所以,通过 (capacity - 1) & hash 按位与的方式计算索引时,要比取模快,但前提是容量必须是2的N次幂。

4.1.3 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赋给tab,判断是否为空,如果为空,则扩容(第一次的话就是初始化table数组)
     * ②如果table不为空,说明已经初始化了,然后将table.lenght赋给n
     */
    if ((tab = table) == null || (n = tab.length) == 0)
        //扩容(每次扩容为原来的2倍)
        n = (tab = resize()).length;
    /**
     * 步骤二:
     * ①通过hash值按位与(数组长度-1)计算当前key在桶的索引,并取出值赋给 p
     *   a.如果 p == null, 说明数组的当前索引位置没有存放元素,然后直接存储即可
     *   b.如果 p != null, 说明数组的当前索引位置已经存放了元素,继续走else判断
     */
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        //定义两个临时存储的变量
        Node<K,V> e; K k;
        /**
         * 步骤三:
         * ①判断p的hash值是否和当前key的hash值相等,如果相等,则判断key的值是否相等
         * ②如果都是true,说明key重复了,则将p赋给e,执行步骤六
         * ③如果是false,则执行步骤四
         */
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        /**
         * 步骤四:
         * ①判断p节点是否是树节点,如果是,则插入到红黑树中,其中p是红黑树的根节点
         * ②如果不是,则执行步骤五
         */
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        /**
         * 步骤五:
         *  ②进入for循环
         *  ①将p节点的next取出来赋给e,判断是否为空,就是看下当前节点p是否有下一个节点(链表)
         *     a.如果不为空,那么,将p的next节点指向新插入的元素,也就是链表
         *     b.判断链表的节点数量是否大于8,如果大于8则转成红黑树
         *  ②如果e不为空,则判断链表中的元素e的hash值是否和当前key的hash值相等,以及key是否相同
         *     a.如果整体结果是true,则跳出for循环,说明key重复了,则执行步骤六
         *     b.如果整体结果是false,则将e再次赋给p,继续寻找链表中的下一个节点,进行判断
         *
         *  ③整个for循环,最多能执行8次,超过8次后,将转成红黑树并跳出循环
         */
        else {
            for (int binCount = 0; ; ++binCount) {
                //完成了对e的赋值操作
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        
        /**
         * 步骤六:
         * 如果e不为空,也就意味着key重复了,则用后插入key的value覆盖已经存在key的value
         */
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

我们简单通过栗子演示下(先跳过红黑树):

HashMap<String, Integer> map = new HashMap<>();
map.put("刘备", 50); //假设:hash=2
map.put("孙权", 18); //假设:hash=5
map.put("曹操", 40); //假设:hash=7
map.put("孔明", 30); //假设:hash=2
map.put("张飞", 32); //假设:hash=2
map.put("孔明", 31);

HashMap1.8Put元素流程分析.jpg

接下来我们看下转成红黑树的逻辑:

4.1.3.1 链表转成红黑树

当链表长度大于8(其中不包含数组中的节点,如果算上数组上的元素共计9个) 会将链表转成红黑树。

/**
 * tab: 是HashMap中存储元素的数组
 * hash: 是链表中最后一个元素的hash值,也就是整个链表中所有元素的hash值
 */
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    /**
     * 当链表长度大于8,一定会转成红黑树吗?
     * 从源码中可以看到,即使链表长度大于8,但是如果数组长度小于64,此时也不会转成红黑树,而是扩容。
     * 所以:只有当链表长度大于8并且数组长度大于等于64才会转成红黑树。
     */
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    /**
     * 这个hash传进来的时候是链表中最后一个元素的hash值,但是这个整个链表中包括数组中的元素
     * 他们的hash值都是一样的,不然也不会产生hash碰撞形成链表了。
     * 
     * tab[index = (n - 1) & hash]): 很熟悉了,根据hash取出数组的元素(可以理解为链表的第一个元素)
     */
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        /**
         *
         * 整个do-while就是将原来的单向链表,转成双向链表
         */
        do {
            //将普通的Node节点,转成TreeNode节点
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        /**
         * 这里将双向链表转成红黑树
         */
        if ((tab[index] = hd) != null)
            hd.treeify(tab);
    }
}

NOTE: 只有当链表长度大于8并且数组长度大于等于64才会转成红黑树,否则将对数组扩容。

转成红黑树前,先把单向链表转成双向链表

链表长度.jpg

然后调用TreeNode头结点,将双向链表转成红黑树

红黑树在线演示1
红黑树在线演示2

4.2 resize()扩容方法

4.2.1 什么时候扩容?

当HashMap中的元素个数(size)超过数组长度(table.length) * 负载因子(loadFactor)时,就会扩容。loadFactor 默认是0.75,是一个折中的值。默认情况下,数组长度是16,那么当HashMap中元素个数size超过12(16 * 0.75 = 12,这个是12是阈值边界threshold)时,HashMap的数组就会扩容 为原来的2倍(16 * 2 = 32),然后再重新计算每个元素在数组中的位置.

链表的长度大于8并且数组长度小于64,此时HashMap会先通过扩容来解决问题,如果数组长度达到了64,那么此时会将链表转成红黑树;当移除红黑树上的元素后,如果树中节点的数量小于6,那么此时会将红黑树转成链表。

4.2.2 扩容

HashMap在扩容时,由于每次扩容都是原来的2倍(也就是2的N次方),与原来计算的 (n-1) & hash 结果相比,只是多了一个bit位,所以节点要么在原位置,要么就被分配到”原位置+旧容量“这个位置。

00000000 0000000 00000000 00010000 ---> 16
00000000 0000000 00000000 00100000 ---> 32
00000000 0000000 00000000 01000000 ---> 64
00000000 0000000 00000000 10000000 ---> 128
扩容后多了一位

数组长度是16的情况: n = 16
(n - 1) & hash
             0000 0000 0000 0000 0000 0000 0000 1111    n - 1 = 15
hash1(key1)  0111 1111 1111 1111 0000 1111 0000 0101
-------------------------------------------------------------------
             0000 0000 0000 0000 0000 0000 0000 0101    index = 5


             0000 0000 0000 0000 0000 0000 0000 1111    n - 1 = 15
hash2(key2)  0111 1111 1111 1111 0000 1111 0001 0101
-------------------------------------------------------------------
             0000 0000 0000 0000 0000 0000 0000 0101    index = 5

*****************************************************************************
扩容后:n = 32    
(n - 1) & hash
             0000 0000 0000 0000 0000 0000 0001 1111    n - 1 = 31
hash1(key1)  0111 1111 1111 1111 0000 1111 0000 0101
-------------------------------------------------------------------
             0000 0000 0000 0000 0000 0000 0000 0101    index = 5


             0000 0000 0000 0000 0000 0000 0001 1111    n - 1 = 31
hash2(key2)  0111 1111 1111 1111 0000 1111 0001 0101
-------------------------------------------------------------------
             0000 0000 0000 0000 0000 0000 0001 0101    index = 5 + 16 (原位置 + 旧容量)

计算出新的索引高位如果是0,则存储到原位置,如果高位是1,则存储到原来索引+旧的数组长度的位置

image.png

因此HashMap在扩容的时候,根本不需要在重新计算hash,只需要看原来的hash值新增的那个bit位是0还是1就可以了,是0的索引位置不变,依然是原位置,如果是1的话,索引的位置为”原位置 + 旧数组容量“

image.png

这是由于这种巧妙的rehash方式,既省去了重新计算hash值的时间,而且由于新增的一个bit位到底是0还是1这是随机的,这样在resize()的过程中rehash之后每个桶上的节点数一定小于等于原来桶上的节点数,保证了rehash之后不会出现更严重的hash冲突,均匀的把之前冲突的节点分散到新的桶中了。

接下来我们看下源码:

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; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    
    
    //新数组的阈值是 32 * 0.75 = 24 (我假设初始了16个长度已经不够用了,现在开始扩容)
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    //新的数组,长度是32
    table = newTab;
    //老的数组,接下来将老的数组中的数据放到新的数组中
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            //新老的数组中的第一个节点的数据赋给 e, 并判断是否为空,就是看数组的第一个位置有没有数据
            if ((e = oldTab[j]) != null) {
                //如果有数据,则将老的数组的第一个位置数据置为null,便于GC
                oldTab[j] = null;
                //判断是否有链表,如果没有,则将老的数组中的第一个元素放到新的数组中
                //它会放到新数组的第0个位置或者第16(0 + oldCap)的位置
                if (e.next == null)
                    /**
                     * e.hash & (newCap - 1) : 要么放到原位置,要么放到【原位置 + oldCap】的位置
                     */
                    newTab[e.hash & (newCap - 1)] = e;
                //判断是否为树节点    
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                /**
                 * 处理链表
                 */
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    //整个do-while循环结束,也就是链表移动结束了,然后跳出do-while循环后在进行真正的移位,移动到新的数组中
                    do {
                        //第一次进来:将桶中第一个元素的下一个元素赋给next,循环条件
                        next = e.next;
                        /**
                         * 当前元素的hash值&旧数组容量的值 如果是0,那么表示当前元素
                         * 会放到新数组的原位置(在旧数组的哪个位置,就放到新数组的哪个位置)
                         *
                         * 如果不是0,那就会放到新数组(旧数组的原位置 + 旧数组容量)的位置
                         *
                         * 整个if-else的逻辑是:将整个链表分组
                         *  1)将计算出放到新数组原位置的,先放到loHead--loTail 链表中
                         *  2)将计算出放到新数组[原位置+oldCap)位置的,先放到 hiHead--hiTail 链表中
                         * 参考下面的链表拆分图
                         *  
                         */
                        if ((e.hash & oldCap) == 0) { //原位置
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else { //原位置 + oldCapacity       
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    //处理放到原位置的链表
                    if (loTail != null) {
                        loTail.next = null;
                        //将放到原位置的链表数据整体放到新数组的newTab[j]的位置
                        newTab[j] = loHead;
                    }
                    //处理放到【原位置+oldCap】位置的链表
                    if (hiTail != null) {
                        hiTail.next = null;
                        //将放到【原位置 + oldCap】位置的链表数据整体放到新数组的newTab[j + oldCap]的位置
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

do-while的逻辑如图所示: image.png

do-while的逻辑就是将原来的链表进行分组,扩容后放到原位置的为一组链表,放到【原位置+oldCap】位置的为一组链表。

将链表分组好后,然后将他们各自放到新的数组的桶中,整体如图所示:

image.png

这里说一下,如果是树节点在扩容时它该如何处理?

它是这样做的,首先遍历树的所有节点,将其转成双向链表,然后按照【hash & oldCap】是否为0,分成2组,为0的一组放到新数组的【原位置】,不为0的一组放到新数组的【原位置+oldCap】位置。

image.png

前面我们在分析put的时候又看到,如果链表长度大于8并且数组长度大于64,此时会将链表转成红黑树,但是,在真正转成红黑树之前他会先将单向链表转成双向链表

4.3 remove()移除方法

理解了put()方法后,再来看remove()方法就相对简单了,具体做法是:先根据key的hash值找到元素的位置,如果是链表则遍历链表找到元素后删除;如果是红黑树则遍历树找到元素后删除,删除后如果树节点树太小,则将红黑树转成链表。

public V remove(Object key) {
    Node<K,V> e;
    //hash(key):计算当前key的hash值
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

核心逻辑:

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;
    
    /**
     * 如果数组为空,或者 tab[index = (n - 1) & hash] 没有元素,则直接返回退出
     * 
     * 注意:p = tab[index = (n - 1) & hash] 已经完成了对p的赋值
     */
    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=true,说明数组中的元素(p)就是将要删除的元素,将p赋给node
         * 如果if=false, 说明p不是要删除的元素,然后看eles if
         */
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        /**
         * 看下p.next 是否有点,如果有,可能是树节点,还有可能是链表
         *  1)如果是树节点,则根据hash和key获取节点数据
         *  2) 如果不是树节点,则遍历链表,找到链表节点数据
         */
        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;
                    }
                    /**
                     * 如果if=true,则 p是e(node)的前一个
                     * 如果if=false,则 p就是e
                     */
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        
        //以上操作,已经获得了node节点(当然前提是这个key存在,因为有可能这个key的hash一样,但是key并不存在)
        
        //如果node == null, 说明当前要删除的key不存在,直接退出
        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);
            /**
             * 如果 node == p, 说明数组中的节点就是要移除的节点,直接将数组节点的下一个
             * 放到数组中,至于下一个是否为空,并不关心
             */
            else if (node == p)
                tab[index] = node.next;
            /**
             * 来到这里,说明要移除的元素是链表
             * 将直接将p.next 指向node的next, 跳过node即可
             */
            else
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

4.4 get()方法

理解了put()方法后,再来看get()方法就相对简单了,整个过程如下:

  1. 根据key计算hash值,然后通过hash值获取映射到的桶
  2. 如果桶上的key就是要查找的key,则直接返回这个key对应的value
  3. 如果桶上的key不是要查找的key,则查看后续的节点:

    a. 如果后续的节点是红黑树节点,则遍历红黑树获取当前key对应的value
    b. 如果后续的节点是链表,则遍历链表获取当前key对应的value

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;
     /**
     * 如果数组为空,或者 tab[index = (n - 1) & hash] 没有元素,则直接返回退出
     * 
     * 注意:p = tab[index = (n - 1) & hash] 已经完成了对p的赋值
     */
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        //如果桶中的元素就是要查找的key,则直接返回
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        //桶中的节点有下一个(next)元素,说明不是链表就是红黑树    
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                //遍历红黑树查找
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                //遍历链表查找
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

5.多线程下的数据覆盖(丢失)

我们知道,JDK7中put链表元素时采用的"头插法",容易出现死循环,至于原因,我就不看了,可以参考文档

而JDK8中put元素时采用的尾插法,避免了死循环的问题,但是仍然会出现数据覆盖的问题。我们看下代码:

image.png

假设线程t1判断桶中元素为null,然后成功进入,但是当t1在设置元素之前,CPU切到线程t2,t2也判断当前桶位是null,于是也成功进入,然后CPU时间片切回t1,t1成功设置数据,然后CPU又切到t2,然后t2也put元素,然后就把t1的数据给覆盖了。

6.总结

  1. 数组的长度是2的N次幂,如果指定的容量不是2的N次幂,底层通过位运算取大于指定容量最小的2的N次幂。(比如 7 ---> 8, 10 ---> 16)
  2. 数组容量计算:initialCapacity = (需要存储的元素个数 / 负载因子) + 1。注意负载因子(即 loader factor)默认为 0.75,如果暂时无法确定初始值大小,请设置为 16(即默认值)。
  3. 当链表长度大于8并且数组长度大于64,才会将链表转成红黑树
  4. 当红黑树的元素小于6,则将红黑树转成链表
  5. 由于数组容量是2的N次幂,通过位运算将大大提高“元素放哪个桶”的效率(位运算的效率高于取模),同时也避免了扩容后重新计算hash;扩容后元素要么放到新数组的【原位置】,要么放到新数组的【原位置 + oldCap】位置。

好了,关于HashMap的知识就整理到这里吧。

限于作者水平,文中难免有错误之处,欢迎指正,勿喷,感谢感谢