Back
Featured image of post HashMap源码与扩容机制分析

HashMap源码与扩容机制分析

本文主要针对JDK1.8进行分析

四个构造方法

// 默认构造函数。
public HashMap() {
   this.loadFactor = DEFAULT_LOAD_FACTOR; // 其他字段都是默认值
}

// 包含另一个“Map”的构造函数
public HashMap(Map<? extends K, ? extends V> m) {
   this.loadFactor = DEFAULT_LOAD_FACTOR;
   putMapEntries(m, false);
}

// 指定“容量大小”的构造函数
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;
     // tableSizeFor方法是返回一个大于等于传入值的一个2的幂的数
   // 所以经过这个构造方法的hashmap的threshold是2的幂,在扩容时的某种情况下可以赋给新的容量(详见下面resize分析)
   this.threshold = tableSizeFor(initialCapacity);
}

为什么负载因子默认是0.75f?

因为如果负载因子太大,也就是越接近于1,数据就会比较密集,链化会比较严重,查询效率就会比较低;如果负载因子太小,就会导致数组比较松散,数组的空间利用率低。所以默认0.75是官方对空间和时间效率的一个平衡选择。

给定的默认容量为 16,负载因子为 0.75。Map 在使用过程中不断的往里面存放数据,当数量达到了 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容,而扩容这个过程涉及到 rehash、复制数据等操作,所以非常消耗性能。

与 HashTable 的比较

  1. HashMap是Hashtable的轻量级实现,HashMap允许key和value为null,但最多允许一条记录的key为null。而HashTable不允许。
  2. HashTable中的方法是线程安全的,使用 synchronized 来进行同步,而HashMap不是线程安全的。
  3. HashMap具有fail-fast机制

fail-fast机制

fail-fast 机制是 java 集合(Collection)中的一种错误机制。 当多个线程对同一个集合的内容进行操作时,就可能会产生 fail-fast 事件。

这一策略在源码中的实现是通过成员变量modCount,表示修改次数,每次对 HashMap 内容(当然不仅仅是 HashMap 才会有,其他例如 ArrayList 也会)的修改都会进行modCount++,那么在迭代器初始化过程中会将这个值赋给迭代器的 expectedModCount。在操作过程中判断 modCount 跟 expectedModCount 是否相等,如果不相等就表示已经有其他线程修改了容器内容,就会抛出ConcurrentModificationException

if (modCount != expectedModCount)
    throw new ConcurrentModificationException();

为什么modCount不用volatile来修饰?

理论上我们为了各线程都保证对modCount的可见性,但是实际上即使modCount用volatile关键字修饰,还是保证不了多线程下记录正确的modcount,因为valotile是弱同步机制保证不了线程安全,所以也无法保证每次多线程修改都可以触发ConcurrentModificationException ,同时还会增加额外的开销,所以就没有必要用volatile修饰modCount了。

为什么数组的长度总是2的幂

因为我们寻址的话一般就是对hash值进行取模,也就是hash%length ,如果length是2的次方数,那么取模的公式其实是等于hash&(length-1) ,而使用位运算的话CPU计算会快一点,提高了性能。

hash%length == hash&(length-1) 的原因是当length为2的幂时,假如是16,那二进制就为10000,减去1就是01111,任何数与其相与都是保留其低4位,也就是0到15这样一个范围,效果自然就和取模是一样的了。

hash方法

static final int hash(Object key) {
     int h;
     // h = key.hashCode() 为第一步 取hashCode值
     // h ^ (h >>> 16)  为第二步 高位参与运算
     return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。

上面代码是JDK1.8的实现,相比1.7减少了扰动次数,优化了一点性能。

put方法过程

所以put主要有四种情况:

  1. 对应桶位为null就可以直接插入

  2. 对应key已经存在,直接覆盖value

  3. 对应位置已经树化,那就用插入到红黑树中

  4. 对应位置已经链化,进行链表的插入。此时如果链表长度大于8,再判断如果当前数组的长度小于 64,那么会选择先进行数组扩容,而如果数组长度大于64了才转换为红黑树。

HashMap的懒加载机制:table在初始化的时候默认是null,是在第一次put数据的时候才进行初始化的,这也是所谓的懒加载

扩容机制

为什么需要扩容?

为了解决数据量大的情况下哈希冲突严重导致大量节点链化,影响查询的效率(链表查询为O(n)),扩容可以缓解该问题。

扩容时机

由上面的put方法流程图中可以看出,在一开始table为空或者length为0,或者当size超过threshold的时候会触发resize方法进行扩容,而threshold = 容积 * 负载因子

resize方法源码分析

这里把resize方法拆成两部分进行分析

  • 第一部分:主要是为了计算出新数组的长度以及新的扩容阈值
final Node<K,V>[] resize() {
    // oldTab:引用扩容前的哈希表
    Node<K,V>[] oldTab = table;
    // oldCap:表示扩容之前table数组的长度
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // oldThr:表示扩容之前的扩容阈值,也就是触发此次扩容的阈值
    int oldThr = threshold;
    // newCap:扩容之后table数组的长度
    // newThr:扩容之后的扩容阈值,也就是下一次触发扩容的条件
    int newCap, newThr = 0;

    // 判断如果hashmap已经初始化过了,这是一次正常的扩容
    if (oldCap > 0) {
        // 扩容之前发现table数组大小已经达到了最大容量限制,不扩容,并把阈值设置为int的最大值(非常少数的情况)
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // oldCap左移一位实现数值翻倍(2倍),并赋给newCap,如果newCap小于最大容量限制且oldCap大于等于16
        // 则让下一次扩容的阈值等于当前阈值的翻倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }

    // 此时oldCap==0, 说明hashmap中的散列表为null,就是通过一下三种情况进行初始化的
    // 1. new HashMap(initialCapacity, loadFactor);
    // 2. new HashMap(initialCapacity);
    // 3. new HashMap(map); 通过一个含有数组的map进行初始化
    else if (oldThr > 0) // initial capacity was placed in threshold
        // 此时的oldThr也就是threshold一定是2的幂,保证了newCap也是2的次方
        // 因为这里都是tableSizeFor()方法设置的threshold
        newCap = oldThr;

    // 此时oldCap==0 且 oldThr==0
    // 也就是无参构造出来的 new HashMap();
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY; // 16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 12
    }

    // newThr为零,这种情况一般就是初始化时自己指定了一个数组大小且这个值小于默认的16比如为8
    // 所以就导致上面的newThr赋值语句都没有执行到
    if (newThr == 0) {
        // newThr = 容量 * 负载因子
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                (int)ft : Integer.MAX_VALUE);
    }
    // 赋值给成员变量threshold
    threshold = newThr;

    // === 这是分界线,上面前半段都是为了计算出newCap和newThr ===
  • 第二部分:rehash过程,主要是把原来的链表拆分成高低位链表
    // 这里开始是rehash过程
    @SuppressWarnings({"rawtypes","unchecked"})
    // 用newCap创建了一个更大的数组
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    // 新数组赋给table数组
    table = newTab;

    // 说明本次扩容之前table数组不为null
    if (oldTab != null) {
        // 去遍历原数组里的元素
        for (int j = 0; j < oldCap; ++j) {
            // 当前的node节点
            Node<K,V> e;
            // 说明当前桶位有数据,具体为单节点还是链表还是红黑树就分情况去处理
            if ((e = oldTab[j]) != null) {
                // 置为null方便JVM GC时回收内存
                oldTab[j] = null;

                // 第一种情况:单节点,也就是从未发生过碰撞
                if (e.next == null)
                    // 直接找到新桶位然后赋值进去
                    // 这里新桶位也是不会发生冲突的,因为同一个桶位映射到新数组中也是一一对应的
                    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 {
                        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) {
                        // 虽然拆成两部分了,但是在存在于原链表中的next指针可能还连着,所以这里要去除一下
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        // 这里就是高位链表下标 = 原数组下标 + 原数组长度
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

rehash的过程分为了三种情况:

  1. 当前节点是单节点

    原数组中桶位是单节点,在新数组中虽然可能在高桶位或低桶位(见下面第3点),但是也是和原数组下标一一对应的,在新数组中不会与别的节点冲突,所以直接放到新桶位就行不用其他操作。

  2. 当前节点树化了

    本质上和第三种情况一样也是拆分成两块放在新数组的不同位置,只不过此时是红黑树,而第三种情况是链表。具体源码就不分析了,比较麻烦(其实是因为我也没去仔细看doge),大概过程如下:

    1. 当低位区小红黑树元素个数小于等于6时,开始去树化untreeify操作;
    2. 当低位区小红黑树元素个数大于6且高位区红黑树不为null时,开始树化操作(赋予红黑树的特性)。
  3. 当前节点链化了

    这里拆成高低位两部分,拆分的依据就是,这个节点的hash值的二进制值在新长度二进制值为1的那个位,是1还是0。

    这样说比较抽象,举个例子,原来数组长度是16,扩容之后就变成了32,对应的二进制就分别是10000100000 ,就是多了一个0,这个1所在的位置就高了一位。

    现在有2个hash值分别为10(01010)和26(11010)的节点,他们在长度为16的数组中是同一个桶位,根据公式hash&(length-1) 计算可得。但是在长度为32中,由于最高位的不同,计算结果也不同,从而被分成了两类,而高位的桶位下标 = 低位桶位下标 + 原数组长度(本例中是16),也正是多了最前面那个1 (如下图)

    拆分成高低位链表过程看下图更明显(蓝色节点为低位链表,绿色为高位):

与JDK1.7相比

JDK1.7的HashMap中的链表采用的是头插法,当在扩容时需要对原数组的链表迁移到新数组的正确位置时,采用头插法就会使得链表元素的顺序倒过来了。而且,在多线程的情况下,可能在迁移过程中出现循环链表的情况,导致在之后遍历这个链表时出现死循环,问题就很大了。

所以JDK1.8时采用的时尾插法避免了这种情况,但是无论如何在并发情况下还是不建议直接使用普通的HashMap的,因为它的线程不安全的。需要线程安全的场合建议用ConcurrentHashMap

参考

HashMap全B站最细致源码分析课程,看完月薪最少涨5k!_哔哩哔哩_bilibili

Java 8系列之重新认识HashMap

HashMap源码&底层数据结构分析

comments powered by Disqus
一辈子热爱技术
Built with Hugo
Theme Stack designed by Jimmy
gopher