HashMap 原理之 HashMap 初始化(基于 JDK1.8)

HashMap 初始化详解:JDK1.8 版本
本文深入分析了JDK1.8中HashMap的初始化过程,包括默认容量、负载因子、扩容策略等。HashMap的容量始终为2的幂次方,初始化在put方法中进行,通过resize方法完成。当size超过阈值(容量*负载因子)时,HashMap会触发扩容。同时,文章探讨了不同负载因子和初始容量对扩容的影响,对于面试问题提供了详细解答。

在这里插入图片描述

基于 JDK1.8 进行分析

首先说几个问题,然后根据问题去分析具体情况

Q1. 默认容量是多少,负载因子是多少,扩容倍数?
Q2. 底层的存储数据结构?
Q3. 如何处理 hash 冲突?
Q4. 如何计算一个 key 的 hash 值?
Q5. 数组的长度为何是 2 的幂次方?
Q6. 扩容查找过程?

开始之前,先看一下 HashMap 的几个基本属性。

/**
 * 默认的初始化容量,必须是 2 的 幂次方
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16  二进制 1 向左移四位

/**
  * 最大容量,左移三十位,也就是 2 的 30 次方
  */
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
  * 默认的负载因子
  */
static final float DEFAULT_LOAD_FACTOR = 0.75f; //不指定的话,默认为 0.75f

/**
 * Node 节点数组, 用来表示一个 key-value
 * 内部类
 */
transient Node<K,V>[] table;

/**
 * map 中包含键值对映射的数量
 */
transient int size;

节点数组的存储使用了一个内部类 Node 来存储,该 Node 对象包含了当前对象 key 的 hash 值,key 值,value,以及下一个对象的指针。源码如下

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash; //节点 hash
    final K key; // key
    V value; // value
    Node<K,V> next; //下一个节点

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }

    public final K getKey()        { return key; }
    public final V getValue()      { return value; }
    public final String toString() { return key + "=" + value; }

    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }

    public final boolean equals(Object o) {
        if (o == this)
            return true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            if (Objects.equals(key, e.getKey()) &&
                Objects.equals(value, e.getValue()))
                return true;
        }
        return false;
    }
}

由上面的源码可以看出,每一个节点都存储了下一个节点,是一种链表形式的存储。注意到它重写了 hashCode 方法和 equals 方法。

HashMap 的初始化

在 hashMap 中,我们最常用的初始化方式就是使用默认构造方法 Map<String, Object> map = new HashMap<>(); 和使用已经定义好的初始化容量的构造方法 Map<String, Object> map = new HashMap<>(10);,先看一下不带容量的构造方法。

/**
 * 给定了一个默认的负载因子
 */
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
}

该构造方法啥也没做,只是指定了一个负载因子,默认为 0.75f。

再看一下指定了容量的构造方法。源码如下:

/**
 * @param initialCapacity 传进来的初始化容量
 * 指定了默认的负载因子
 */
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

发现它调用的实际上是自身的另一个构造方法,传入了两个参数,一个是我们指定的初始化容量,另一个是默认的负载因子(0.75f)。那就接着往下看它另一个构造方法

/**
 * 构造了一个空的 HashMap
 * @params initialCapacity 传进来的初始化容量
 * @params loadFactor 负载因子
 */
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)) // 负载因子小于 0,或者非浮点数则抛出非法参数异常
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor; //负载因子
  	// threshold 阈值
    this.threshold = tableSizeFor(initialCapacity);
}
  1. 对传入的容量做了基本的判断,如果小于 0,则抛出非法参数异常。如果大于最大容量(230-8),则将最大容量赋值给初始化容量
  2. 对负载因子做了基本的判断,如果负载因子小于等于 0、或者不是浮点数。则抛出非法参数异常。
  3. 负载因子赋值
  4. 调用 tableSizeFor 方法计算阈值

先不管阈值是啥意思,先看看 tableSizeFor 方法做的事情。源码如下

/**
 * Returns a power of two size for the given target capacity.
 */
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;
}

这里做了位运算,该方法的作用是为了返回一个比给定容量最接近的 2n 的值
举个例子

  • 如果给定容量是 3,最接近的值就是 22 = 4
  • 如果给定容量是 5,最接近的值就是 23 = 8
  • 如果给定容量是 13,最接近的值就是 24 = 16
    由此可以得出规律:该算法的作用就是把最高位 1 后面的数值全部变成 1,最后将计算出来的结果 + 1

例如 3 二进制 为 11,经过计算 21 + 20 = 3 + 1
例如 7 二进制为 111,经过计算 22 + 21 + 20 = 7 + 1
例如 13 二进制为 1101,转换后 1111,经过计算 23 + 22 + 21 + 20 = 8 + 4 + 2 + 1 = 15 + 1 = 16

依次类推,这里把阈值设置成与给定容量最接近的一个 2 的幂次方(包含等于),也就是说 HashMap 的容量始终都是 2 的幂次方

HashMap 初始化时机

到这里,构造方法做的事情就算完了,但是存在一个问题,就是我们以上的分析计算了阈值(更何况阈值是啥作用目前还不清除),但是我们发现构造方法里面并没有对 Map 进行初始化的动作。

那么初始化的动作在哪里呢?不难想到,我们 new HashMap<>() 这个对象,下面的操作应该就是要对 Map 进行 crud 操作了。那么它的初始化动作最有可能发生在哪里呢?我们说了 HashMap 可以动态扩容,如果要触发扩容,必然是在添加元素的时候检测发现不够存储了,才会去触发扩容。显而易见,Map 的初始化放在了 put 方法里面,源码如下:

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

发现它调用了一个 putVal 方法,那我们接着往下看 putVal 方法。源码如下:

/**
 * @param hash // key 的 hash 值
 * @param key  // key 值
 * @param value // key 对应的 value
 * @param onlyIfAbsent if true, don't change existing value // 如果设置为 true,则不会改变已经存在的值,put 方法传进来时,默认传入的是 false,如果放进来的值已经存在,它就会对值进行覆盖
 * @param evict if false, the table is in creation mode. 
 * @return previous value, or null if none
 */
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;  //这里调用了 resize 方法
    if ((p = tab[i = (n - 1) & hash]) == null) //判断要存放的位置的值是否为 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;
}

首次调用 putVal 方法的时候,table 这个数组一定为空,那么它一定会进入到 n = (tab = resize()).length; 中去。
在这里插入图片描述
在 resize 方法里面,应该是做了初始化可扩容的操作。进 resize 方法看看,源码如下:

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);
     }
     threshold = newThr;
     @SuppressWarnings({"rawtypes","unchecked"})
         Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
     table = newTab;
     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;
                 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) {
                         loTail.next = null;
                         newTab[j] = loHead;
                     }
                     if (hiTail != null) {
                         hiTail.next = null;
                         newTab[j + oldCap] = hiHead;
                     }
                 }
             }
         }
     }
     return newTab;
 }

代码有点长,我们分两部分来看(为了方便分析,这里截图说明)
先看第一部分
图一
这里有两组数据,一组是旧容量(oldCap)和旧阈值(oldThr),另外会根据旧容量和旧阈值计算出一组新容量(oldCap)和新阈值(newThr)出来

在我们未指定容量的时候,table 最开始是为 null 的,所以旧容量和旧阈值都是 0,所以会进入第二步中,第二步将默认的容量(16)赋值给了新容量(newCap),将默认容量 x 默认负载因子计算出来的值赋值给了新阈值(Thr)。

所以:如果我们指定容量,HashMap 默认容量为 16,默认阈值为 16 * 0.75f = 12

第三步只是做了一个保守的判断,预防计算出出来的新阈值(newThr)为 0, 如果为 0 ,则重新计算新阈值。

重点来了,在第一次进入的时候,执行到第四步出现了我们心心念念的初始化动作 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
看到这里,基本上 HashMap 的初始化已经搞清楚了。

第一个问题基本上可以回答一半了。剩下就差扩容倍数了。

扩容时机

到这里,可能有同学会会有一问,这个 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; 不是每次进来都会被调用吗?
是的,这个 new 动作确实是每次 put 的时候都会被调用,也就是每执行一次 put 动作的时候,HashMap 都会重新生成一个新的 Node[] 数组,然后把数据从旧的数组中拷贝过去

拷贝的原理这篇文章先不展开讲,重点还是关注如何触发扩容。
回到我们的 putVal 方法中
在这里插入图片描述
我们发现他 resize 方法在 putVal 里面除了初始化时会被,在方法的最底部也会被调用一次(上图中的第二步),注意看条件

if(++size > threshold){
	resize();
}

size 是 HashMap 的 key value 的数量,也就是说当 size + 1 > threshold 的时候触发了扩容,这个时候我们就能理解阈值的作用了。

阈值 = 容量 * 负载因子

当添加元素时,会先去预判断添加之后的 size 是否大于阈值(threshold),如果大于,就扩容。
回到我们上面截图 resize 方法的上半部分
之前我们说了 resize 方法的第一步如果是第一次 put,则第一步不会执行。
那么如果是非第一次 put,那么就会进入第一步的 判断中去。

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
}

只看这一部分代码,旧容量显然大于 0,

  • 判断旧容量是否达到了最大容量,达到了就会把 Integer.MAX_VALUE 值赋值给阈值,返回旧的 oldTab
  • 如果没达到,那么就判断旧容量左移一位(乘以 2)之后是否小于最大容量 && 旧容量是否大于默认初始化容量
  • 满足条件则新阈值 = 旧阈值左移一位 (乘以 2),新容量也等于旧容量左移一位(乘以 2)

至此,第一个问题的所有答案都出来了

总结

  • HashMap 默认容量是 16,默认的负载因子是 0.75f
  • 构造方法只会初始化一个空的 Node 数组,真正的初始化过程是在 put 方法中的 putVal 里面调用的,主要核心方法是 resize 方法
  • HashMap 的容量固定为 2n 次方,也就是如果我们传入了初始化容量,HashMap 不会使用我们传入的容量,而是会帮我们计算与我们传进去的参数最接近(大于等于)的一个 2n 的值作为容量
  • HashMap 内部会存在一个阈值(threshold),该值为容量和负载因子的乘积,当 HashMap 里面的容量大于等于阈值时,会触发扩容。
  • 因为扩容是一个拷贝数组的过程,比较耗费资源。建议在初始化的时候指定容量,才能最大限度的利用 HashMap 的性能。

面试题扩展

如果初始化传入容量为 1w,在存储第 1w 的键值对时,会触发扩容吗?

答:需要考虑两种情况,使用默认负载因子和自定义的负载因子

  • 如果使用默认负载因子 0.75,初始化容量为 1w,那么 HashMap 帮我们生成的容量就是 214 次方为 16384,
    根据默认负载因子计算出来的扩容阈值为 12288
    也就是说,只有当 put 到第 12288 个元素的时候,才会触发 HashMap 的扩容,而存储第 1w 个元素时,还未到达阈值,不会触发扩容机制。
  • 如果使用自定义的负载因子,那么就要根据具体情况来分析了,比如使用 0.5 作为负载因子,那么阈值计算出来就是 8192,显然会触发扩容

如果初始化传入容量为 1k 呢?会触发扩容吗?
答:根据上面的分析,按照默认负载因子来计算:初始化容量为 1k,HashMap 生成的容量就是 210 = 1024,阈值就是 1024 * 0.75 = 768,实际上不用 put 到 1000 个元素,put 到 768 个元素时就会触发扩容。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值