目录
4/5/6、TREEIFY_THRESHOLD && UNTREEIFY_THRESHOLD && MIN_TREEIFY_CAPACITY
构造方法 HashMap(int initialCapacity, float loadFactor)
static final int hash(Object key)
static final int tableSizeFor(int cap)
什么是HashMap
HashMap是Java中常用的一种数据结构,它是一种基于哈希表(也称散列表)的Map接口的实现类,底层采用数组加链表/红黑树的方式来存储和管理数据。
散列表结构图
简单来说,HashMap是一种基于哈希表实现的Map接口,可以通过key-value的形式存储和获取数据。其核心思想是将key通过哈希函数转换为哈希值,并将该值作为下标存储在数组中,然后将value存储在对应的位置。通过哈希函数和数组的结构,可以实现快速的增删改查操作。
但只是了解这些显然是不够的,下面我们来系统地学习HashMap的底层原理。
HashMap的成员变量
HashMap的源码可以从idea中查看,我们先从HashMap的成员变量开始学习。
1、DEFAULT_INITIAL_CAPACITY
// 默认初始容量 - 必须是2的幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
有的小伙伴可能不知道1 << 4 是什么。1
<< 4
是位运算符的一种,表示将二进制数 1
左移 4 位,即在二进制数 00000001
的右侧填充四个 0
,得到二进制数 00010000
,也就是十进制数 16
。在 Java 中,位运算符 << 表示左移操作,其左侧操作数是要移位的数字,右侧操作数是要移动的位数。
在HashMap中,DEFAULT_INITIAL_CAPACITY = 1 << 4 的意义是将HashMap的默认初始容量设置为16,这是因为16是一个比较适合的初始值,既不会浪费太多空间,也不会过于拥挤。在实际使用中,如果需要存储的键值对比较多,可以通过修改初始容量来提高HashMap的性能,减少扩容操作的次数(通过 HashMap 的构造函数或者
initialCapacity
属性进行设置)。
那我们可以设置容量不为2的幂吗?不是完全可以,因为 HashMap 在内部会使用tableSizeFor(int cap)将容量自动调整为大于等于指定容量的最小 2 的幂次方,详情请看本章下文。
2、MAXIMUM_CAPACITY
/*
最大容量,如果其中一个构造函数使用参数隐式
指定了更高的值,则使用该容量。必须是2的幂<= 1<<30
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
MAXIMUM_CAPACITY
是HashMap
内部规定的最大容量,其值为1 << 30
,即 2的30次方。这是由于 Java 数组的最大长度是Integer.MAX_VALUE
,而HashMap
在内部是以数组的形式存储元素的,因此其容量不能超过Integer.MAX_VALUE
。在
HashMap
内部,当尝试将容量扩展到大于MAXIMUM_CAPACITY
的值时,会将容量设为MAXIMUM_CAPACITY
,而不是更大的值。这是为了防止数组长度溢出,同时也是因为过大的容量会导致哈希函数的散列值无法正确映射到数组的索引位置,从而影响HashMap
的性能。
3、DEFAULT_LOAD_FACTOR
// 在构造函数中没有指定负载因子时使用的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
DEFAULT_LOAD_FACTOR
是HashMap
类的一个静态常量,用于指定默认的负载因子,其值为0.75f
。在
HashMap
中,负载因子是指哈希表在自动扩容之前可以达到多满的程度。当哈希表中的元素数量达到了容量与负载因子的乘积时,就需要将哈希表的容量扩大一倍。这个过程称为“rehashing”。因为
DEFAULT_LOAD_FACTOR
的值为 0.75,所以当哈希表中的元素数量达到容量的 75% 时,就需要进行扩容操作。这个值是经过一些实验和分析得出的一个比较好的默认值,可以在时间和空间消耗之间找到一个平衡点。如果设置的值过大,会导致空间浪费;如果设置的值过小,会导致哈希表在元素数量较小的时候就需要频繁扩容,增加时间开销。
可以通过调用 HashMap
的构造函数来手动设置负载因子。HashMap
的构造函数有两个参数:初始容量和负载因子。第二个参数指定了负载因子,即在什么时候需要调整哈希表的容量。例如,如果负载因子为 0.75,则哈希表的容量将在填充 75% 后自动调整。
4/5/6、TREEIFY_THRESHOLD && UNTREEIFY_THRESHOLD && MIN_TREEIFY_CAPACITY
/*
容器计数阈值,用于对容器使用树而不是列表。
垃圾箱转换为树时向至少有这么多节点的bin中添加一个元素。
取值必须大于2且应该至少有8个网格的假设在树移除转换回普通的箱子收缩。
*/
static final int TREEIFY_THRESHOLD = 8;
/*
在调整大小操作期间取消树状化(分割)bin的bin数阈值。
应该小于TREEIFY_THRESHOLD,最多6个网格,去除收缩检测。
*/
static final int UNTREEIFY_THRESHOLD = 6;
/*
可以树形化的容器的最小表容量(否则,如果一个bin中有太多节点,则会调整表的大小。)。
应该至少为4*TREEIFY_THRESHOLD,以避免调整大小和树状化阈值之间的冲突。
*/
static final int MIN_TREEIFY_CAPACITY = 64;
TREEIFY_THRESHOLD是一个阈值,用于指定一个桶(bucket)中链表长度大于等于该阈值时,这个桶中的所有节点会被重新组织为一个红黑树,以提高查找效率。
当HashMap中的元素数量很大时,各个桶(bucket)中的链表长度也会越来越长,这会导致元素的查找效率下降。为了避免这种情况,当链表长度达到TREEIFY_THRESHOLD时,如果桶数量超过
MIN_TREEIFY_CAPACITY,
HashMap会自动将这个桶中的所有节点转化为一个红黑树,以提高查找效率。反之,当元素数量减少,红黑树中的节点数量小于UNTREEIFY_THRESHOLD时,HashMap会自动将红黑树转化为链表,以节省空间。
MIN_TREEIFY_CAPACITY
是一个常量,其默认值为 64。它表示当哈希表的大小(即容量)达到此阈值时,如果桶中的节点数大于等于 8,就会将链表转化为红黑树。在
HashMap
中,当一个桶中的元素个数超过了 8 个时,会将该桶转换为红黑树以提高查询效率。但是,如果桶中的元素个数过少,使用红黑树反而会浪费更多的内存,因为红黑树需要维护额外的节点信息。因此,
MIN_TREEIFY_CAPACITY
的作用就是在哈希表的大小达到一定程度时(默认为 64),确保桶中的节点数至少为 8,以便在将链表转化为红黑树时能够发挥更好的性能。如果桶中的节点数不足 8,即使哈希表的大小达到了阈值,也不会进行转换。
成员变量小结
看到这里,小伙伴们对HashMap已经有了一个大概的理解。
具体来说,HashMap底层维护了一个数组,数组中每个元素称为桶(bucket),每个桶存储一个Entry对象,一个Entry对象包含一个键值对(K,V)和指向下一个Entry的指针。当我们调用put()方法添加一个键值对时,会首先根据键的hashCode值来计算出其在数组中的索引位置,然后在该位置上的桶中添加一个Entry对象,如果该位置上已经有一个Entry对象,那么就把新的Entry对象放在链表的头部,并把原有的Entry对象挂在新的Entry对象后面,形成一个链表结构。
当链表长度达到一定阈值(默认是8)时,如果桶数量大于一定阈值(默认是64),链表就会转化为红黑树,从而提高查询效率。当链表长度降到一定阈值(默认是6)时,红黑树又会转换回链表。
既然成员变量已经了解了,那下面我们来研究一下 HashMap 的一些常用方法。
HashMap 常见的方法
构造方法 HashMap(int initialCapacity, float loadFactor)
其中,initialCapacity
指定初始容量,loadFactor
指定负载因子。因此我们可以手动设置负载因子。
通过源码我们还能知道当
initialCapacity
小于0时还会抛出一个异常,当initialCapacity
大于最大容量 MAXIMUM_CAPACITY 时直接让initialCapacity 等于
MAXIMUM_CAPACITY ,这也验证了前面的说法。当我们设置的初始容量(initialCapacity)超过HashMap的最高容量(MAXIMUM_CAPACITY,2的30次方),会被强制设置为2的30次方。
当 负载因子小于等于0 或者 不是数字 ,也会抛出一个异常。嗯,这很合理。
注:NaN 代表着一个不是数字的值。它通常由以下情况产生:
- 一个浮点数除以0,得到的结果是NaN
- 对负数求平方根,得到的结果是NaN
- 计算结果超出了 IEEE 754 浮点数规范所能表示的范围,得到的结果是NaN
static final int hash(Object key)
hash(Object key)
是HashMap
中用来计算键的哈希值的静态方法。它接收一个键对象作为参数,然后返回该键的哈希值。哈希值在HashMap
中用于确定键的桶位置,使得可以快速地进行查找操作。在
hash(Object key)
方法中,它首先检查传入的key
是否为null
,如果是则返回哈希值为 0,否则它会通过调用key.hashCode()
方法计算出键的哈希值。hash(Object key)
方法还会将这个哈希值和一个位运算的常数进行异或运算,以产生更好的分布效果,最终返回一个整数值作为键的哈希值。
static final int tableSizeFor(int cap)
书接上回!在 HashMap 内部,容量调整为大于等于指定容量的最小 2 的幂次方数的过程是通过
tableSizeFor(int cap)
方法实现的。
tableSizeFor(int cap)
方法会传入初始容量 cap,然后先将 cap - 1 中的所有二进制位都置为 1,例如当 cap = 10 时,cap - 1 = 9,二进制为 0b1001,将其所有二进制位都置为 1 后得到 0b1111。接着,这个值再经过一系列右移操作,最终得到大于等于 cap 的最小 2 的幂次方数。这个过程是基于二进制的,因为 HashMap 内部是使用二进制位运算来计算 hash 值和确定 key 的位置的。
所以不要觉得构造方法给机会就可以为所欲为了哈!HashMap 会悄咪咪地将设置的容量改成2的幂。
HashMap的扩容、链表转化红黑树机制
扩容
HashMap在不同版本中有不同的扩容策略
Java7版本之前
在jdk7之前, HashMap 采用的是“头插法”(即新元素插入在链表头部)来解决哈希冲突的问题,所以新链表和旧链表的顺序是反的。但是这种方式就会导致当链表过长时,查询效率会变得很低。
HashMap 的头插法实现是非线程安全的。当多个线程同时调用
put
方法向同一个桶中添加元素时,如果多个线程在同一时刻同时对一个桶进行添加元素,可能会出现多个节点同时指向同一个桶中的下一个节点,导致链表断裂,形成链表环,这就是所谓的“死链”。因为 HashMap 的扩容和重新哈希操作非常耗时,因为需要重新计算每个元素的哈希值,并将其放入新的桶中。如果哈希表中的元素数量很大,这个过程可能需要花费很长时间。所以在使用HashMap之前最好先估计好元素的数量大小再给HashMap提前设置容量。
Java8版本开始之后
在 JDK 1.8 中,HashMap 改为了“尾插法”,解决了“死链”问题。此外,JDK 1.8 中的 ConcurrentHashMap 也使用了类似的尾插法实现方式,保证了多线程并发添加元素时的线程安全性。
在 JDK 1.8 中,HashMap 的扩容机制与 JDK 1.7 的扩容机制有所不同。JDK 1.8 中的 HashMap 内部还是由数组和链表组成,但是,当链表长度超过一定阈值时(TREEIFY_THRESHOLD,值为 8),会将链表转化为红黑树,以提高查找效率。
当 HashMap 中元素数量超过负载因子和容量的乘积(LOAD_FACTOR * capacity)时,就会进行扩容。扩容后,HashMap 的容量会变为原来的 2 倍,并且进行 rehash,重新计算元素在新的数组中的位置。因为容量是 2 的幂次方,所以 rehash 的过程可以通过位运算来实现,效率更高。
在进行扩容时,HashMap 会新建一个两倍容量的数组,并将原来数组中的元素重新散列到新数组中。在将元素插入到新数组中时,如果发现某个位置中已经存在元素,就会采用链表的形式将该元素添加到链表的尾部。如果链表长度超过 TREEIFY_THRESHOLD,就会将链表转化为红黑树。
要注意,无论是哪个版本的HashMap,它的容量最高只能达到2的30次方,即使设置初始容量超过这个数,java最终也会将其设置成2的30次方,并且不会再扩容。
链表转化红黑树
这里要强调一下,哈希表发生链表转化为红黑树需要同时满足两个条件:
- 桶数量超过 MIN_TREEIFY_CAPACITY(默认为64)
- 桶中节点数量超过 TREEIFY_THRESHOLD (默认为8)
否则只会发生扩容!下面我们来跟踪源码看看是怎么实现的。
先看看每次使用到 TREEIFY_THRESHOLD 时是什么情况:
我们可以看见每次判断 binCount >= TREEIFY_THRESHOLD - 1 时,下面都会接着一个 treeifyBin(tab, hash) 。点进去看看 treeifyBin(tab, hash) 的源码:
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
从第 756 和 757行的代码可以看到当以上判断通过时才会发生 resize() 方法的调用,也就是哈希表扩容(增加桶数量),否则就开始链表和红黑树的转化。
因此我们可以了解到链表和红黑树的转化的条件是什么样的。
课外联想:有的小伙伴可能就会觉得,为什么要同时满足两个条件才会发生链表转化为红黑树,不可以只满足当 桶数量超过 TREEIFY_THRESHOLD 这个条件就发送转化吗?
如果只有这一个条件,可能会导致哈希表中大量桶变成红黑树,这会占用大量的内存空间。而在实际应用中,桶的数量往往很大,但是每个桶中的元素数量很少,因此只有在同时满足两个时,才会将一个桶中的链表转化为红黑树,以兼顾时间和空间的效率。
总结
最后总结一下,HashMap是一种基于哈希表实现的Map接口,可以通过key-value的形式存储和获取数据。其核心思想是将key通过哈希函数转换为哈希值,并将该值作为下标存储在数组中,然后将value存储在对应的位置。通过哈希函数和数组的结构,可以实现快速的增删改查操作。
注意事项
在使用HashMap时,为了提高性能,需要注意以下几点:
-
线程安全性:
HashMap
是非线程安全的,如果需要在多线程环境下使用HashMap
,可以考虑使用ConcurrentHashMap
或者手动加锁来保证线程安全。 -
对象的
hashcode
方法:作为一个基于哈希表实现的容器,HashMap
依赖于对象的hashCode()
方法来计算哈希值。因此,在重写对象的hashCode()
方法时要保证满足两个对象相等时它们的哈希值也相等,避免出现哈希冲突,影响HashMap
的性能。 -
对象的
equals
方法:在HashMap
中,当两个键的哈希值相同时,会调用它们的equals()
方法来判断它们是否相等。因此,在重写equals()
方法时要保证满足两个对象相等时它们的哈希值也相等,避免出现哈希冲突,影响HashMap
的性能。 -
初始容量和负载因子:初始化时需要考虑容量和负载因子的选择。如果初始容量过小,会导致
HashMap
频繁扩容,影响性能;如果负载因子过高,会导致链表过长,也会影响性能。一般情况下,可以根据数据量大小和数据类型的特性来选择初始容量和负载因子。 -
并发修改异常:在使用
HashMap
进行遍历、添加、删除等操作时,如果在遍历过程中进行了添加、删除等操作,就有可能出现并发修改异常。为了避免这种情况,可以使用ConcurrentHashMap
或者使用迭代器的remove()
方法进行操作。 -
键和值的类型:在使用
HashMap
时,需要注意键和值的类型,尽量使用不可变类型,避免因为修改导致哈希值和相等性的变化。
综上所述,使用 HashMap
时,需要注意线程安全、哈希值的计算、哈希冲突的处理、初始化容量和负载因子的选择、并发修改异常以及键和值类型的选择等问题。
关注作者,获取更多精彩内容!