HashMap的构成与大小的确定

本文详细探讨了HashMap的内部结构,包括数组和链表的特点,以及HashMap如何结合两者实现高效查找和增删操作。HashMap在初始化时,默认容量为16,负载因子为0.75。在put元素时,如果未指定初始容量,会自动设置为16。HashMap的容量必须是2的幂次方,以优化下标计算。当元素数量达到容量*负载因子时,HashMap会进行扩容。在设置初始容量时,建议预估元素数量并除以负载因子,以减少扩容次数,提高性能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言
本文主要探究HashMap大小的确定,对于数组和链表的优缺点不做深入探讨

1.HashMap的构成

1.1数组的数据结构

如果想获取高效率的检索,我们一般会选择数组

数组的结构图:

在这里插入图片描述
由上图可以看到,由于数组给每个插入的数据都设置了下标,所以我们可以根据下标很轻松的去获取对应的值。
那为什么说数组的检索效率高呢,仅仅是因为它给每个元素都设置了下标吗?

我们来看看数组这种结构的储存特点:

  • 数组中存储的每个元素类型一致,也就是说每个元素占用的空间大小相同。
  • 数组中存储的每个元素在空间存储上,内存地址是连续状态的。
  • 通常首元素的内存地址作为整个数组对象的内存地址,可见我们是知道首元素内存地址的。
  • 数组中的元素是有下标的,有下标就可以计算出被查找的元素和首元素的偏移量。

综上所述,实际上在数组中查找元素是可以通过数学表达式计算被查找元素的内存地址的,通过内存地址可以直接定位该元素。也就是说数组中有100个元素和有100万个元素,实际上在查找方面效率是一样的。

那么数组的缺点是什么呢?
由于数组数据结构的特性,它有着高效的访问效率;但是对于插入和删除元素来说,就比较慢了。

我们来看一个插入的例子
在这里插入图片描述

可以看到,在插入新数据66以后,原下标为 [ 3,4,5 ] 的数据都发生了向右移位,这在内存中相当于修改了 [ 3,4,5 ] 下标对于数据的内存地址,这是非常消耗资源的。
同理,删除一个数组元素会造成从被删除的元素开始,之后的所有元素向左移位。

那么,如果想要获取高效的增删,该如何选择呢?

1.2链表的数据结构

链表的数据是以节点的方式存储的。

种类有三种:

  1. 单向链表
  2. 双向链表
  3. 链表

单链表的结构图:

在这里插入图片描述

双链表的结构图:

在这里插入图片描述

环形链表的结构图:

在这里插入图片描述
由上图可以看到,链表是以节点的方式存储数据。单链表持有上一个节点的引用,双向链表持有上一个节点和下一个节点的引用。在插入和删除数据的时候,只需要修改相对应的引用节点就行了。所以链表在内存中存储不需要和数组一样开辟一块连续的内存空间,他是通过引用地址将每个节点连接起来。

那链表的缺点是什么呢?
由于链表是通过引用将节点连接起来,在执行查询的时候,需要先从头节点开始,轮询检索下一个节点的应用,它的查找时间是O(n),效率比较低。

那么我们能不能综合两者的特性,做出一种寻址容易,插入删除也容易的数据结构?
答案是肯定的,这就是我们要提起的哈希表。哈希表((Hash table)既满足了数据的查找方便,同时不占用太多的内容空间,使用也十分方便。

哈希表有多种不同的实现方法,我接下来看最常用的一种方法—— 拉链法,我们可以理解为“链表的数组”,也就是HashMap的数据结构。

1.3 HashMap的数据结构

数据结构图

在这里插入图片描述
由上图可知,HashMap底层是以动态数组+链表的方式存储数据的(jdk1.7)

2.hashMap如何构建大小

以下源码均来自jdk1.8
我们先来看一看HashMap的构造方法

 /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    

方法的说明为:

  • 构建一个空的HashMap,它具有默认的初始容量16和默认的负载因子0.75

我们可以看到这里有两个参数,loadFactorDEFAULT_LOAD_FACTOR;,点进去看一下说明。

    /**
     * The load factor for the hash table.
     * 这是哈希表的负载因子
     * @serial
     */
    final float loadFactor;

loadFactor表示哈希表的负载因子

   /**
     * The load factor used when none specified in constructor.
     * 当构造函数中没有指定时,使用的负载因子。
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f; 

DEFAULT_LOAD_FACTOR 表示负载因子的默认值,大小为0.75F

有没有发现少了点什么,HashMap的空构造方法不是说具有默认的初始容量16和默认的负载因子0.75吗,这里仅仅指定了负载因子(loadFactor)的大小,那默认的数组长度又是什么时候指定的呢?

我们往上翻翻,看看有没有表示默认初始容量的变量

   /**
     * The default initial capacity - MUST be a power of two.
     * 默认的初始容量 - 必须是2的幂次方
     * 这里的初始值是 1 << 4 = 1 * 2^4 = 2^4 = 16
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

欸有了,继续找,看看DEFAULT_INITIAL_CAPACITY 是在哪使用到的

 /**
     * Initializes or doubles table size.  If null, allocates in
     * accord with initial capacity target held in field threshold.
     * Otherwise, because we are using power-of-two expansion, the
     * elements from each bin must either stay at same index, or move
     * with a power of two offset in the new table.
     * 
     * 初始化或加倍扩容表的大小。
     * 如果为空(这里指的是没有设置初始容量),则按照与这里阈值中持有的初始容量(这里指上文的DEFAULT_INITIAL_CAPACITY)目标一致。
     * 如果不为空则需要进行扩容,扩容的方式为以2的次方进行扩展。
     * 扩容后,原表中的元素要么在新表中保持相同的索引,要么在新表中以2的次方的偏移量进行索引的移动。
     * @return the table
     */
    final Node<K,V>[] resize() {
    ...
   }

函数的内容比较多,这里我们不做深入研究,简单总结一下resize()这个方法的信息

  1. 这个方法用于HashMap的初始化和扩容
  2. 如果没有设置初始容量,那么初始容量设置为16(DEFAULT_INITIAL_CAPACITY
  3. 如果有初始容量,代表此时调用这个方法是用于扩容,且扩容的方式是以以2的次方进行扩展
  4. 扩容后,需要重新计算元素的位置

信息有点多,我们挑对我们有用的来看,由第1点我们可以看到这个方法可以用来给HashMap初始化容量的,但是HashMap的空构造函数中并没有调用这个方法,只是赋予了默认的负载因子大小,我们找一找看看这个方法是在哪调用的。

  /**
     * Implements Map.put and related methods.
     * 
     * 实现了Map.put和相关方法
     * @param hash hash for key (key的hash值)
     * @param key the key	(key的值)
     * @param value the value to put (value的值)
     * @param onlyIfAbsent if true, don't change existing value (是否不需要进行hash干扰来改变现有的值)
     * @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;
        ...
    }

n = (tab = resize()).length
这里调用了 resize()方法,putVal()的信息同样很多,我们只挑对我们有用的

  • 这个方法是往HashMap里面put值的时候调用的

我们看看这个方法在哪执行

	/**
     * Associates the specified value with the specified key in this map.
     * If the map previously contained a mapping for the key, the old
     * value is replaced.
     * 将指定的值与这个图中指定的键联系起来。
     * 如果该图以前包含一个键的映射,那么旧的值被替换。
     * 
     * @param key key with which the specified value is to be associated
     * 指定值与之相关的键
     * @param value value to be associated with the specified key
     * 与指定键相关的值
     * @return the previous value associated with <tt>key</tt>, or
     *         <tt>null</tt> if there was no mapping for <tt>key</tt>.
     *         (A <tt>null</tt> return can also indicate that the map
     *         previously associated <tt>null</tt> with <tt>key</tt>.)
     */
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

可以看到这里调用了putVal()函数,这个put()函数就是平常我们平常往HashMap里添加数据的时候调用的方法。

经过上面的推论我们发现,HashMap给容量设置大小的函数是 resize()。在HashMap初始化(空构造函数)的时候,只是设置了默认的负载因子(0.75),并没有调用resize()。这个函数真正调用的时机是在put()被调用的时候,也就是往HashMap里存储数据的时候,会先判断HashMap的旧容量是否为空,如果为空就给与默认的容量大小(16)。

接下来我们来看看HashMap有参的构造函数

    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     * 构建一个空的HashMap,具有指定的初始容量和负载因子。
     *
     * @param  initialCapacity the initial capacity (指定的初始容量)
     * @param  loadFactor      the load factor (指定的负载因子)
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);//容量小于0抛出异常
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;//容量大于最大值把容量修改为最大值
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);//如果扩容因子小于等于0或者是非数字值,则抛出异常
        this.loadFactor = loadFactor;//把扩容因子修改为传进来的值
        this.threshold = tableSizeFor(initialCapacity); //进行数组长度的计算
    }
	

可以看到这个有个threshold 参数,我们点进去看看这个参数的作用

   /**
     * The next size value at which to resize (capacity * load factor).
     *	调整尺寸的下一个尺寸值(容量*负载因子)。
     * @serial
     */
    // (The javadoc description is true upon serialization.
    // Additionally, if the table array has not been allocated, this
    // field holds the initial array capacity, or zero signifying
    // DEFAULT_INITIAL_CAPACITY.)
    //在序列化时,javadoc文件的描述为真。
	//此外,如果表数组还没有被分配这个字段持有初始数组容量,则0表示 default_initial_capacity(默认容量)。
    int threshold;

可以看到,这个参数表示的是HashMap当前触发扩容的阈值,计算公式为【当前元素数量 == 容量*负载因子】
我们回到上一步,看看 tableSizeFor()是怎样计算数组的大小的1

 	/**
     * Returns a power of two size for the given target capacity.
     * 返回离给定目标容量最接近的2次方大小
     * 例如:
     * cap=3,则返回2^2=4;
     * cap=8,则返回2^3=8;
     */
    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;
    }

我们来模拟下这个函数的运行,假设我们传进来的值是11,既cap = 11。
第1步:

int n = cap - 1;(关于此处为什么-1我们在后面进行探讨)2

	n = 11-1;
 	  = 10;
 	  = 0000 1010(二进制)

PS:后续步骤涉及到二进制计算符,不懂的小伙伴可以先看这篇文章《 String源码中的hashCode算法

第2步:

n | = n >>> 1;

看到这里有些小伙伴可能会疑惑, 什么是 | =?
举个例子

c = a | b;
a = c;
即 a | = b;表示把 a | b 计算后的值再赋予a。

则第2步运算为:

	n = 0000 1010 | (0000 1010 >>> 1)
	  = 0000 1010 | 0000 0101
	  = 0000 1111

第3步:

n | = n >>> 2;

	n = 0000 1111 | (0000 1111 >>> 2)
	  = 0000 1111 | 0000 0011
	  = 0000 1111

第4步:

n | = n >>>4;

	n = 0000 1111 | (0000 1111 >>> 4)
	  = 0000 1111 | 0000 0000
	  = 0000 1111

第5步:

n | = n >>>8;

	n = 0000 1111 | (0000 1111 >>> 8)
	  = 0000 1111 | 0000 0000
	  = 0000 1111

第6步:

n | = n >>>16;

	n = 0000 1111 | (0000 1111 >>> 16)
	  = 0000 1111 | 0000 0000
	  = 0000 1111
	  = 15 (十进制)

第7步:

return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY3) ? MAXIMUM_CAPACITY : n + 14;

	因为 n = 15,n > 0,且 n < MAXIMUM_CAPACITY(1 << 30);
	则最后的返回值为 n + 1 = 15 + 1 =16;

有兴趣的小伙伴可以自行运行以下这个函数,看看最后的结果是不是一样;
是不是感觉很神奇,函数tableSizeFor()经过移位运算就能获得离目标值最接近的2次方大小。

我们来分析一下这个函数:
第1步:

int n = cap - 1;

此时cap的值有三种可能:

  • 正数
  • 负数
  • 0

对应产生的n值也有三种情况:

  1. 正数(cap > 1)
  2. 负数(cap < 1)
  3. 0(cap = 1)

当n的值<=0时,即cap < =1时,函数tableSizeFor()会返回容量的最小值1

return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;

当n的值>0时,即cap>1时,产生的n值为正数,此时n值有两种可能:

  • 偶数
  • 奇数

不论n的值是奇数还是偶数,只要n的值>0,在转化成二进制后,n的有效数位的最高位一定是1(除符号位外,第一位值为1的位)

我们来分别看一下这两种可能

n为偶数:
假设我们传递的cap=10;

int n = cap - 1;

	n = 10- 1
	  = 9
	  = 0000 1001(二进制) 

在这里插入图片描述

第2步:

n | = n >>> 1;

	n = 0000 1001 | (0000 1001 >>> 1)
	  = 0000 1001 | 0000 0100
	  = 0000 1101

执行流程为:

n >>> 1;
在这里插入图片描述
n | (n >>> 1)
在这里插入图片描述

第3步:

n | = n >>> 2;

	n = 0000 1101 | (0000 1101 >>> 2)
	  = 0000 1101 | 0000 0011
	  = 0000 1111

执行流程为:

n >>> 2
在这里插入图片描述
n | ( n >>> 2 )
在这里插入图片描述
后续步骤运算结果无变化,故此处不做展示;
最后返回的值为 :0000 1111 +1 = 0001 0000 = 16;

n为奇数:
假设cap = 7;
第1步:

	n = cap -1;
	  = 7 -1;
 	  = 6;
 	  = 0000 0110;

第2步:

	n = n | n >>> 1;
	  = 0000 0110 | (0000 0110 >>> 1);
	  = 0000 0110 | 0000 0011;
	  = 0000 0111;

第3-6步:

	n = 0000 0111
	  = 7;

第7步:

	return n + 1 = 7 + 1 = 8;

细心的小伙伴是不是发现了什么规律,无论cap的值是奇数还是偶数,在经过1-6的步骤运算后,最后都会转化为一个由数个相邻的1位组成的二进制
cap = 10 时,n = cap -1 = 9 ,最终 n = 0000 1111;(cap为偶数)
cap = 7 时,n = cap -1 = 6,最终 n = 0000 1111;(cap为奇数)

在函数返回的时候;

 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
 static final int MAXIMUM_CAPACITY = 1 << 30;

n的值为正整数,且n的范围为[ 1 ,1<< 30];

推论:
我们假设传递进来的cap的值是一个>0并且<=MAXIMUM_CAPACITY的正整数(因为传递一个>MAXIMUM_CAPACITY的正整数没有意义,最后会被设置成MAXIMUM_CAPACITY,故此处不考虑这种可能);
这个正整数可以转化为 01xx xxxx的二进制(x的值可能为1或0),x的数量不确定,最多能有30个x;

PS:此处为什么最多能有30个x
MAXIMUM_CAPACITY = 1 << 30 = 0100 0000 0000 0000 0000 0000 0000 0000 ,因为int占4个字节,1字节=8bit,则int最多能有4*8=32个二进制位,第一位为符号位,假设第二位值为1,则后面最多还能有32-1-1=30位,即30个x。

第一步计算: int n = cap - 1

	1xxx xxxx xxxx(N个x,x可能为0可能为1,此处省略符号位0) cap的二进制值
	01xx xxxx xxxx(N个x)  n的值(cap - 1)

第二步计算: n |= n >>> 1

	01xx xxxx xxxx(N个x)  n的值
	001x xxxx xxxx(N个x)  n无符号右移1位的二进制值
	011x xxxx xxxx(N个x)  或计算的二进制结果(|:或运算,只要有一个11,否则为0

第三步计算: n |= n >>> 2

	011x xxxx xxxx(N个x)  n的值
	0001 1xxx xxxx(N个x)  n无符号右移2位的二进制值
	0111 1xxx xxxx(N个x)  或计算的二进制结果

第四步计算: n |= n >>> 4

	0111 1xxx xxxx(N个x)  n的值
	0000 0111 1xxx(N个x)  n无符号右移2位的二进制值
	0111 1111 1xxx(N个x)  或计算的二进制结果

依次类推,到第6步计算 n |= n >>> 16 后,我们会获得一个由N个相邻的数位1组成的二进制值

	011x xxxx xxxx(N个x,x可能为0可能为1) n的原值
	0111 1111 1111(N1)  n的现值

可以看到,这6步计算的目的是要把n的原二进制值01xx xxxx(N个x)变为0111 1111(N个1),即除最高位(符号位不算)的其它位都变为1,且每一步执行完成后,都可以得到上一步结果双倍的数位1。

第一次移位计算执行完,最高位和第2位为1(011x xxxx)
第二次移位计算执行完,最高位和第2、3、4位为1(0111 1xxx) 依次类推
所以n需要和n右移1,2,4,8,16位进行或计算,本质就是把除最高位的其它位都变成1

第七步计算: n = n+1

	0111 1111 1111(N1)  n的值
	1000 0000 0000(N0)  n的值(n+1)

最终得到的这个数为2的N次方;

总结一下规律:

  • 2的N次方可以转化为一个二进制数,这个数为 01xx xxxx (N个x,x的值只可能为0或空,且x<= 30)
  • 2的N次方-1可以转化为一个二进制数,这个数为 001x xxxx(N个x,x的值只可能为1或空,且x<= 29)

现在知道为什么最后return的时候需要把n+1了吧。

那为什么第一步需要执行cap -1 呢?
如果cap不 -1,假设我传进来的cap值为8,运算过程为:

第1步:

	n = cap ;
	  = 8;
 	  = 0000 1000;

第2步:

	n = n | n >>> 1;
	  = 0000 1000 | (0000 1000 >>> 1);
	  = 0000 1000 | 0000 0100;
	  = 0000 1100;

第3-6步:

	n = 0000 1111
	  = 15;

第7步:

	return n + 1 = 15 + 1 = 16;

可以发现,本来我们传递的cap值已经符合hashMap的要求了(2的次方),在不-1的情况下,数组依旧会扩容2被,这不符合我们的期望值,而且会造成空间上的浪费,所以第一步需要执行n = cap -1 。

那么为什么HashMap的容量大小要设计成2的次方呢?
这里和值的存取有关,在put或get值的时候,有一个找下标的操作,源码中为:(p = tab[i = (n - 1) & hash])
这个操作本质是用HashMap当前容量-1和key的hash值进行与(&)运算,如果容量为2的幂次方,-1后可以转化为1xxx xxxx(N个x,x=1),在进行与运算的时候可以保证更多的散列结果(&[与]:两个1为1,否则为0)。

为什么n的最大值是1 << 30而不是Int的最大值呢?

我们可以在源码中看到,HashMap 的容量是用int来储存的;
Int的最大长度为1<<31-1,转换为二进制为:0111 1111 1111 1111 1111 1111 1111 1111 ;
在执行找下标操作的时候;
数组长度 - 1 = Integer.MAX_VALUE - 1 = 0111 1111 1111 1111 1111 1111 1111 1110;
因为Int的最大值并不是2的次方,所以-1之后不能转化为1xxx xxxx(N个x,x=1);
这会造成在后续计算下标的时候,有些散列结果永远不可能出现。
所以把容量上限设置为Int最大的值没有意义,超过Int 的最大值同样没有意义。

3.使用hashMap该如何设置初始容量大小

我们在使用HashMap的时候,如何设置初始容量大小最优呢?

   /**
     * The next size value at which to resize (capacity * load factor).
     *	调整尺寸的下一个尺寸值(容量*负载因子)。
     * @serial
     */
    // (The javadoc description is true upon serialization.
    // Additionally, if the table array has not been allocated, this
    // field holds the initial array capacity, or zero signifying
    // DEFAULT_INITIAL_CAPACITY.)
    //在序列化时,javadoc文件的描述为真。
	//此外,如果表数组还没有被分配这个字段持有初始数组容量,则0表示 default_initial_capacity(默认容量)。
    int threshold;

我们可以看到,HashMap的扩容条件为 size = (capacity * load factor );
size表示元素个数,capacity 表示数组当前容量, load factor表示负载因子
所以我们首先得知道我们的数据量大概是多少;
假设我们的数据量为1000,且负载因子=0.75 ,那么我们所需要的初始长度为:

capacity = size / load factor
		 =1000 / 0.75 
		 = 1333.33...1334

即我们只需要设置HashMap的初始长度capacity = 1334,就可以减少put元素的过程中的扩容操作。

5.总结

  • HashMap结构是以动态数组+单链表+红黑树组成
  • 在调用空构造方法初始化的时候,*HashMap *并不会立即给容量设置大小,只是给负载因子设置了默认值(0.75);在插入第一条数据的时候,才会去给容量设置为默认值。
  • 使用带参构造函数初始化HashMap时,建议定义的容量大小为[元素总个数/负载因子],从而避免在插入数据的时候多次扩容,损耗程序的性能。

  1. 关于tableSizeFor()函数为什么要设计成移位运算,而不是直接对cap求以2为底的对数,有兴趣的小伙伴可以看这篇文章《 HashMap之tableSizeFor方法图解》,设计成移位运算是为了获取更好的性能 ↩︎

  2. 为什么 -1 :如果不-1,在cap的值已经是2的n次方情况下,最后计算的结果会变成2的(n+1次方),造成长度上的浪费。 ↩︎

  3. 为什么 n 的最大值为 MAXIMUM_CAPACITY :见上文 ↩︎

  4. 为什么 +1 :保证最后的结果为2的次方 ↩︎

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值