聊一聊个人认识的HashMap初始化大小指定容量的思考

深入探讨HashMap的工作原理,包括容量设定、负载因子的作用及扩容机制。分析不同初始化方式对性能的影响,以及如何合理设置HashMap容量避免不必要的扩容。

本文基于的前提是自己的一个疑惑,我们都知道如果我们在创建HashMap的时候如果明确知道自己要放入的元素数量的话,最好指定一下容量,避免进行多次扩容,而浪费性能。

那么我们也知道一个事情,HashMap是通过对key的hash来快速查找对象的,为了解决hash冲突的问题,就必须在容量和元素数量之间做一个取舍,因此有了loadFactor的概念,即虽然我用来存放链表的数组大小是16, 但是你却放不了16个元素,你只能放12个元素,那么问题来了,当我要放16个元素的时候,我写如下代码是不是就放不了16个元素呢(不扩容的情况下)?

Map<String, String> map = new HashMap<>(16);

基于上述思考,菜鸡思考了一上午,结果如下:

先不介绍对象中的几个属性的概念,直接从创建对象并开始使用讲起

Map<String, String> map = new HashMap<>();

如上代码,内部做了什么,我们可以确定哪些常识?


public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

/**
 * The load factor used when none specified in constructor.
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;

从代码看,目前已知的就是给一个属性loadFactor赋值,点开这个常量,默认为0.75f,暂时看不到什么用处。

现在我们往对象中放入一个键值对,看看内部是如何处理的,测试代码如下

map.put("test", "hello world");

源码很长也很难理解,我只关注自己当前代码走的业务逻辑,目前我只new了一个HashMap,现在是第一次放入元素,来看一下元素是如何放入的?

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

// 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;
    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);

    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

// table指向一个Node链表数组
transient Node<K,V>[] table;

// 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);
    }
    threshold = newThr;
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
}

在初始化的时候并没有初始化这个数组大小,这个table目前是一个null对象, 因此触发resize方法.从resize方法我们就可以确定的几个事情是

  1. 初始化HashMap的时候,并没有去初始化用来存放对象的链表数组即table属性
  2. 初始化放在第一次放入元素的时候,这个时候tablesize由于table本身是空,被三目运算给赋值为0, 还有一个属性threshold由于一直没有赋值,因此也是0
  3. 当判断继续往下执行的时候,我们看到针对上述的两种情况,程序执行了新的赋值语句,
    // 这个常量指向的值为16
    newCap = DEFAULT_INITIAL_CAPACITY;
    // 在前面看到过DEFAULT_LOAD_FACTOR为0.75,因此这里newThr的值为12
    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    // threshold被赋值为12
    threshold = newThr;
    
    /**
    * The default initial capacity - MUST be a power of two.
    */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
    
  4. table初始化并重新赋值,从这里就可以看到如果我们初始化HashMap不指定大小的话,默认数组大小为16
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
    

第一次放入元素resize方法执行完成之后,我们继续来看putVal方法剩下与我们有关的部分

    if (++size > threshold)
        resize();
    return null;

/**
* The number of key-value mappings contained in this map.
*/
transient int size;

每次放入一个元素,size就会+1, size代表我们当前已经存储的键值对的数量, 并且在放入完成后就会判断当前映射数量是否大于threshold这个值,如果大于就会重新出发resize扩容方法。而且还有一个需要注意的地方,上面明明初始化的时候我们给table的大小是16,但其实最终我们判断元素映射数量只判断是否大于threshold即12就会触发扩容,有一个优化原则是初始化HashMap的时候我们要指定大小,那么按照这段代码来说,那么如果我们要放16个元素,是否就应该写如下代码了呢?

Map<String, String> map = new HashMap<>((int) (16 / 0.75));

答案我其实也没看懂,但可以来理一理,我们再来看一下当我们指定大小初始化HashMap的时候程序做了哪些事情

public HashMap(int initialCapacity) {
    // DEFAULT_LOAD_FACTOR默认为0.75,我们暂时不去管这个值
    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);
}

/** 
 * 最大值
 * The maximum capacity, used if a higher value is implicitly specified
 * by either of the constructors with arguments.
 * MUST be a power of two <= 1<<30.
 */
static final int MAXIMUM_CAPACITY = 1 << 30;

首先要看到虽然支持我们自定义大小,但也不是无限制的,首先肯定是不能小于0,但其实还有一个最大值的判断,就是最大值不能大于2的30次方,当然
这个值已经足够大了,接着往下看,根据我们指定的容量,重新计算了threshold的值,这个值的大小就是和size用来对比的实际存储映射键值对的数量。

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

看不懂,没关系,不是还有注释吗,我也没看懂,反正注释说的是按照指定容量然后返回一个2的次幂的值,也就是如果我们按照上面说的,
比如我现在指定容量是14,那么14后面2次幂最近的数字就是16, 然后threshold值就会被重置为16, 本身这个计算方式就是计算threshold的值,
而不是tablelength的值,因此放入16个元素是不会触发扩容条件的。但事实果真如此吗?

继续往下看,目前构造方法结束之后, threshold的值为16, 按理说按照我们之前看到的putVal方法最后的判断if (++size > threshold) resize();是足够我们放入16个元素的,
在放入第17的时候才会触发扩容,但我们要从头再看一遍putVal方法把部分和扩容方法resize


// 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;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
}

// 扩容方法把部分源码,之前我们已经看过部分
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;
}

我们来分析一下上述代码, 通过前面已经知道我们指定了大小之后threshold的值为16,但是并没有去初始化table这个变量的值,因此在我们放入第一个元素的时候table
的值依然是空, 那么就会触发初始化的扩容条件,我们来看一下我们指定大小之后的扩容和之前有什么区别?

oldThr的值为threshold也就是16, if (oldCap > 0)这个条件并不满足, 然后满足条件else if (oldThr > 0),这个时候newCap的大小被赋值为16,newCap其实就是链表table数组的大小,与我们之前不指定大小的区别这就出来了, 因为之前我们知道不指定大小时threshold的大小是table的四分之三 newCap = DEFAULT_INITIAL_CAPACITY;newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY)
那么此时我们之前指定的大小明明赋值的变量是threshold,怎么现在给table初始化大小用去了,那我们的threshold怎么办?

看不懂的代码来了, 注意看扩容方法的如下代码

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;

由于newThr=0满足条件, 我们的threshold又被强制的给计算回table容量*loadFactor(0.75)了, 也就是12, 那么我们虽然指定了是16,那其实根本放不下16个元素。
不知道代码为什么要这么处理,浅显的认为把指定的大小给threshold,然后table的容量去除loadFactor(0.75)反推table大小不好吗?毕竟对于使用方来说,
我关注的就是我要存多少键值对啊,结果现在我指定了我要存这么,结果你又存不了那么多。当我存到第13个元素的时候, 就会触发一次扩容,这不是违反了我们
尝说的如果我们确定元素大小最好指定大小避免扩容吗?

如果按照下述改一下呢?

// 原代码就是这里,使用我们指定的threshold去计算table容量的大小,然后后面又将我们的threshold给缩水了
else if (oldThr > 0) // initial capacity was placed in threshold
    newCap = oldThr;

// 改成这样如何,反推容量,并且调用将大小重置为2的次幂
else if (oldThr > 0) // initial capacity was placed in threshold
    newCap = tableSizeFor((int) (oldThr / loadFactor));
// 不要再改变threshold的值了
threshold=threshold

这样的话,其实我完全是不需要进行一次扩容的, 从开始指定初始化大小之后, 直接table数组的大小经过计算直接就是32, threshold的值就是16,那么造成的结果就是我用了一个长度32的数组链表,最终却当我存第17个元素就要进行扩容,当然如果程序逻辑真这么设计,我们是避免了一次扩容,只是最终就是用了32长度的table最终存了16个元素,也只能存16个元素(不扩容的前提下),并且比JDK自己的实现少了一次扩容。

那么如果使用目前JDK自己的设计呢?我们已经知道当我们存第13个元素的时候就会触发一次扩容,我们来看一下扩容做了什么事情,还是来看扩容方法。
当然还是只关注我们需要关心的部分代码

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

我们来理一下上述逻辑, 按照JDK目前的设计, 当我们村第13个元素的时候由于size已经大于threshold,因此触发了上述扩容代码,
首先if (oldCap > 0)条件成立,然后内部if判断最大值不成立,然后我们来看扩容的第一步就是将tablesize左移一位,即将原始的容量扩大一倍增加至32, 然后如果条件满足的话,同时对threshold也进行扩容一倍的操作。那么目前这个逻辑呈现给我们的效果就是用了一个table大小为32的数组,最终可以存储24个元素。所以是比我们之前假设的修改的代码多存8个元素的,因为我们直接反推tableszie
是没有更改threshold的值的,所以同样的大小,我们自己改的代码是要比JDK实现的代码少存储元素的。

但是这个问题还是值得商量一下的,毕竟最终我要存16个元素这个前提是不变的, 而最终大家使用的内存大小也是一样的,只是你比我多存一些元素而已,
而且是在我明白告诉你我要存多少元素的情况下,你还要给我进行一次扩容才做到。虽然你能够多存元素,可是我不存啊,我就要存16个啊。你默认去优化内存和存储元素你去优化就好,但我已经明确告诉你了你还这样玩,设计很牛逼,却要多扩容一次,如果我要存储的元素很多呢?这不是违背了初始化容量这个值的含义了吗?

哎,菜逼搞不懂啊搞不懂。最终只能理解为扩容一次的代价,能够在table大小足够大的时候能够多存足够大数量的元素,现在只是元素少,如果在元素数量极大的情况下,这个存储元素的差距会越来越大,这个代价是值得的。嗯,嗯,嗯,好像是这样的。好吧,我多存几个还不行吗。

最后在来一个结论,如果我们要存16个元素,默认指定大小就是按元素大小指定的话,即我们即使指定了大小16去创建HashMap还是需要多进行一次扩容才能达到存16的目的,而如果我们不想进行一次扩容,还真得要按照之前我们假设的那样写的,不过,不知道自己的猜想对不对,毕竟这么多年,也从来没见人这么写过代码啊,请允许我做一个笑哭😂的表情

Map<String, String> node = new HashMap<>((int) (16 / 0.75));
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值