目录
内容核心:通过实际代码为大家分析讲解HashMap的容量、以及扩容的过程。不过这篇文章不会给大家过于理论详细的说明扩容背后的一个原理和过程。本文主要是让大家直观感受扩容和HashMap实际存储背后的真相面纱。
1、数据结构
HashMap是一个数组+链表的数据存储结构。详细的不多说,画张图给大家看下就当作是这篇文章开始之前的一个基础知识的回顾和复习。
上图所示:
1、HashMap的默认初始化的数组长度是16。
- 为什么是16?
- 如何证明他初始化就是16?
- 又如何自定义数组的长度?
本文章后面章节会给大家做相应的分析。这里先记下问题。
2、每个数组槽位下面会有存放的元素Node,每个Node从java对象的维度来说具有4个属性:key、value、hash、next。
- key put方法中传入的key值。
- value put方法具体传入的值。
- hash HashMap内部对key值的hash值。这个值证明算的?
- next 每个链表中的node元素对他下一个node的引用指针。
2、为什么数组的初始长度是16?
两个维度分析,为什么数组的长度是16。
- 源码角度
DEFAULT_INITIAL_CAPACITY
这个属性很好理解,从属性名就可以见名知意。意思是默认初始容量为1 << 4。
1 << 4的意思就是一个简单是java中的位运算等价于:1 << 4 = 1*2的4次方,结果也就是16。
为什么不直接写DEFAULT_INITIAL_CAPACITY=16?这个一方面是为了从计算机角度提高可读性,以及位运算的方式比较亲和计算机也就是大家常说的性能会优于普通的加减乘除运算。
大家有仔细看这张图里面这个属性的描述吗?如下图黄色部分:
power of two什么意思?因为意思就是2的n次方。也就是说DEFAULT_INITIAL_CAPACITY这个值永远都会是一个类似:2、4、8、16、32…这些值,那么为什么会有这个约定呢?这里可以给大家子节说下结论:
第1 power of two有利于提高hash值的离散度,简单点就是可以有效的降低key的hash冲突。第2 power of two有利于提高HashMap扩容迁移rehash的性能。
具体细节就不展开说了,想了解的后续可以根据大家的诉求单独文章给大家一起分享一下。
- DEBUG分析
首先debug前,大家需要从源码上知道HashMap的数组是存储在哪里的,或者证明存储的?
从上图可以知道HashMap的数组是存储在一个table数组里,数组的每个元素是Node。具体的Node这边贴个图看下就好了,不重点分析了。
既然我们现在知道了HashMap的数组是存储在Node[] table数组中,那我们是不是可以通过debug获取对象中的table属性,以及查看table的长度不就可以证明HashMap的运行中数组的一个长度以及变换情况了?
开始实验,准备如下简单代码:
public static void main(String[] args) {
// 创建一个HashMap对象
HashMap<String, String> map = new HashMap<>();
// 获取HashMap的Class对象
Class<?> hashMapClass = HashMap.class;
// 获取table字段
Field tableField = hashMapClass.getDeclaredField("table");
// 设置table字段为可访问,因为它通常是私有的
tableField.setAccessible(true);
// 获取table字段的值,这应该是一个Entry对象的数组
Object[] tableEntries = (Object[]) tableField.get(map);
System.out.println("tableEntries: " + tableEntries.length);
}
以上代码分析,大概可以看出是利用反射的方式来获取HashMap对象中的table属性,因为table属性他不是一个public修饰的,所以对外是不可见的智能通过反射的方式来获取。
实验过程遇到一个问题:
上图的错误是因为本人实验的环境是JDK21(如Java 9及以上)版本,Java的模块系统阻止了对私有或受保护的内部实现的访问。要解决这个问题,你可能需要在启动JVM时添加额外的命令行参数来开放模块间的访问权限。对于java.base模块,可以使用–add-opens选项:
--add-opens java.base/java.util=ALL-UNNAMED
按照上面给本地idea添加上这行JVM运行参数,可以正常执行。正常执行如下:
从上图大家可以很奇怪的看到tableEntries也就是我们上面说的table属性为空,为什么是空?不是说默认table的数组长度是16吗?从上面代码11行来看,我们只是初始化了一个HashMap示例,并没有往里面put数据,通过个人实验发现原来HashMap中的数组table是一个懒加载
的过程,意思是当我们初始化一个HashMap没有往里面put任何数据的时候,table数组是不会被初始化的。顺着这个思路,我现在随便玩这个HahMap中put一个元素,然后再看看效果。
果然如上图,我往里面put一个key1=value1的元素,这个时候可以看到table的length=16了。所以通过上面我们得出一个结论,没有put元素的时候HashMap为了优化空间用的是一个懒加载的策略。也从debug角度证明了HashMap的数组初始长度是16。
3、如何自定义数组的长度?
要想知道我们开发这如何去自定义控制HashMap初始的时候的数组table长度,我们可以看一下他的构造方法。有些人会说为什么要我要去控制数组的长度,用它自己定义的不就好了吗?其实从空间优化的角度,如果我们很明确当前HashMap我只存1-3个元素,我就没必要去初始话一个16长度的数组浪费空间。不过这里有个小知识点HashMap里面有一个叫扩容因子:DEFAULT_LOAD_FACTOR,不过这个属性在源码里面也能看的到,如下图:
从上图可以看到DEFAULT_LOAD_FACTOR=0.75f。那么这个DEFAULT_LOAD_FACTOR和table的长度有什么关系呢?
关系就是这两个属性决定了当前HashMap是否出发扩容的阈值,什么意思?举个简单的例子,假如说我现在初始化一个table=4长度的一个HashMap,而且当前的DEFAULT_LOAD_FACTOR=0.75f,那么这个时候HashMap当存储多少个元素或者超过多少个元素就会触发扩容,我们要怎么去推断。结论就是当超过:table.length*DEFAULT_LOAD_FACTOR
的时候就会扩容。
拿当前的例子来说table.length=4,DEFAULT_LOAD_FACTOR=0.75f。那么4*0.75=3,也是当我put的元素超过3个,当前的HashMap就会触发扩容。
好了,我门知道这个结论。那我们先回到上面的问题,如何自定义table.length的长度,我们来看下HashMap的构造函数:
//initialCapacity参数构造
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//空参构造
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
从上面的代码我们可以清楚的看到,他有常见的两个构造函数。一个是空参数默认16个长度的table,一个是可以自定义initialCapacity参数值的构造函数。很明显,这个initialCapacity参数就是用来指定table初始话的长度?那我们考虑一个问题,是不是我们设置多少initialCapacity的值那么table的长度就是多少呢?答案肯定是不对的,因为我们上面说过table.length他是一个power of two的一个值,这里不清楚可以看下文章上面前部分的解释。
有了这个前提结论,我门就可以开始来实验一下,当用户指定的initialCapacity值,最终table的长度会是多少?
initialCapacity | table.length |
---|---|
0 | 2 |
1 | 2 |
2 | 4 |
3 | 4 |
4 | 4 |
5 | 8 |
6 | 8 |
7 | 8 |
8 | 8 |
9 | 16 |
10 | 16 |
贴2张实验图,这里就不所有的实验贴出来,很简单大家自己实验的时候修改构造函数的入参数initialCapacity值(0-10)就好了。
从上面可以看出,有些时候initialCapacity值并不会和最终的table.length值相同。这里大家应该可以看的出来,当你的initialCapacity的值不是一个power of two的值,那么HashMap会帮我们自动推算出当前initialCapacity最接近的一个2的n次方的一个值。比如说上面的initialCapacity=0,table.length的值是2,以为0本身不是一个2的n次方的一个值,所以向上推算自由2的1次方是最近0的一个数,所以最终的table.length的值被程序定义为了2。同理initialCapacity=3本身不是一个2的n次方的值,最接近3的一个2的n次方的书就是4,也就是2的2次方,所以最终的table.length=4。
那么这个寻找最接近的2的n次方的过程,HashMap是怎么做到的?看下面HashMap的一个源码:
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;
//这里就是用来处理推算最接近2的n次方的过程
this.threshold = tableSizeFor(initialCapacity);
}
具体看下上面tableSizeFor(initialCapacity)这个方法:
static final int tableSizeFor(int cap) {
int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
该函数的作用是根据给定的目标容量计算一个2的幂次大小的容量。
首先,通过Integer.numberOfLeadingZeros(cap - 1)计算出cap - 1的二进制表示中最高位1之前的0的个数。
然后,将-1右移这个位数得到一个数值n。
最后,根据n的值返回相应的容量值。如果n小于0,返回1;如果n大于等于MAXIMUM_CAPACITY,返回MAXIMUM_CAPACITY;否则返回n + 1。
这个函数的目的是为了找到一个最接近目标容量且为2的幂次的大小,用于初始化哈希表等数据结构。
4、如何查看table存储的元素和table槽位对应的关系?
实验前提,初始化一个table.length=4的一个HashMap对象。然后像这个HashMap存储3个元素
(张三,1)、(王二,2)、(小明,3)。
实验代码:
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
// 创建一个HashMap对象。table.length的长度是4。
HashMap<String, String> map = new HashMap<>(4);
map.put("张三", "1");
map.put("王二", "2");
map.put("小明", "3");
// 获取HashMap的Class对象
Class<?> hashMapClass = HashMap.class;
// 获取table字段
Field tableField = hashMapClass.getDeclaredField("table");
// 设置table字段为可访问,因为它通常是私有的
tableField.setAccessible(true);
// 获取table字段的值,这应该是一个Entry对象的数组
Object[] tableEntries = (Object[]) tableField.get(map);
System.out.println("table.length: " + tableEntries.length);
// 注意:在JDK 21中,如果HashMap使用了新的数据结构,这一步可能需要调整
// 如果table不是数组,可能是某种更复杂的数据结构,如链表或树等
// 在这种情况下,你需要了解新的实现细节来正确遍历
int index = 0;
// 遍历table并打印键值对
for (Object entry : tableEntries) {
System.out.println("槽位:" + index++);
if (entry != null) {
// Entry是HashMap的一个内部类,需要强制转换
@SuppressWarnings("unchecked")
Map.Entry<String, String> entryInstance = (Map.Entry<String, String>) entry;
System.out.println("Key: " + entryInstance.getKey() + ", Value: " + entryInstance.getValue());
}
}
}
上述代码输出结果如下:
从上图输出结果可以看到,table.length=4符合我们预期设置的值,然后"小明"是落在槽位为0的数组索引上,"张三"是落在槽位为2的数组索引上,"王二"是落在槽位为3的数组索引上。画张图大家可以更清楚的知道,这个存储的实际情况。如下图:
5、key的hash如何计算的?
从上面的存储布局我们可以清楚的知道小明、张三、王二在table.length=4的一个数组中存储位置。那么HashMap是如何计算key的hash值?以及根据hash值怎么明确当前key存储在哪个数组的槽位上?
5.1 hash的计算
从put源码分析如下:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
第一个参数:hash(key)这里就是key的hash算法:
static final int hash(Object key) {
//具体拿张三来分析
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
5.2 槽位的算法
槽位的意思是对应数组table的索引序号。
上图所示:
张三的Hash=617891954,然后(n-1)& hash = (4-1)& 617891954 = 2。
这里的2就是张三对应HashMap中table里面的槽位值。也正确符合下面图的存储位置:
好这里有小知识点,现在回到上面说的槽位计算公式:(n-1)& hash。
我们知道:hash(张三) = 617891954。我们来做个实验,观察一下下面两个算式的结果:
-
算式一
index = (n-1)& hash = (4-1)& 617891954 = 2。下图计算器也证明了:
-
算式二(取模运算)
index = hash % n = 617891954%4 = 2。下图计算器也证明了:
通过实验我门发现(n-1)& hash
的计算方式和hash%n
的结果是一样的,也就是张三
的最终的槽位落地都能推算出是在2的索引table三存储着。
那么我门想一下为什么HashMap采用的是非取模
的计算方式,而是(n-1)& hash
的方式?
答案还是回到&位运算本身和计算机亲和运算效率的问题,也就是取模的计算性能不如&的位运算。而且大家有没有发现为什么这么巧,&的位运算恰好和取模的效果是一样的?这个就要回到上面说的table.length无论在什么条件下他都是一个2的n次方的一个结果。正是因为这个特点才成就了(n-1)& hash = hash%n
。其实也就方面回答了面试官
为什么table.length是一个2的n次方的问题。当然table.length是一个2的n次方的问题,还有一个意义就是可以提升加速HashMap扩容迁移的性能。
6、亲密体验扩容
实验环境:准备一个初始化table的长度为2,然后不断的实验玩里面put数据。观察put进去的数据和最终扩容后的一个状态。
- 实验代码
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException, InterruptedException {
// 创建一个HashMap对象
HashMap<String, String> map = new HashMap<>(2);
for (int a = 0; a < 10; a++) {
map.put("张三" + a, "" + a);
// 获取HashMap的Class对象
Class<?> hashMapClass = HashMap.class;
// 获取table字段
Field tableField = hashMapClass.getDeclaredField("table");
// 设置table字段为可访问,因为它通常是私有的
tableField.setAccessible(true);
// 获取table字段的值,这应该是一个Entry对象的数组
Object[] tableEntries = (Object[]) tableField.get(map);
System.out.println("table.length: " + tableEntries.length);
// 注意:在JDK 21中,如果HashMap使用了新的数据结构,这一步可能需要调整
// 如果table不是数组,可能是某种更复杂的数据结构,如链表或树等
// 在这种情况下,你需要了解新的实现细节来正确遍历
int index = 0;
// 遍历table并打印键值对
for (Object entry : tableEntries) {
System.out.println("槽位:" + index++);
if (entry != null) {
// Entry是HashMap的一个内部类,需要强制转换
@SuppressWarnings("unchecked")
Map.Entry<String, String> entryInstance = (Map.Entry<String, String>) entry;
System.out.println("Key: " + entryInstance.getKey() + ", Value: " + entryInstance.getValue());
}
}
Thread.sleep(2000);
System.out.println("================插入"+a+"================");
}
}
- 输出结果
table.length: 2
槽位:0
Key: 张三1, Value: 1
槽位:1
================插入1个元素================
table.length: 4
槽位:0
槽位:1
槽位:2
Key: 张三1, Value: 1
槽位:3
Key: 张三2, Value: 2
================插入2个元素================
table.length: 4
槽位:0
Key: 张三3, Value: 3
槽位:1
槽位:2
Key: 张三1, Value: 1
槽位:3
Key: 张三2, Value: 2
================插入3个元素================
table.length: 8
槽位:0
槽位:1
槽位:2
槽位:3
槽位:4
Key: 张三3, Value: 3
槽位:5
Key: 张三4, Value: 4
槽位:6
Key: 张三1, Value: 1
槽位:7
Key: 张三2, Value: 2
================插入4个元素================
table.length: 8
槽位:0
槽位:1
槽位:2
Key: 张三5, Value: 5
槽位:3
槽位:4
Key: 张三3, Value: 3
槽位:5
Key: 张三4, Value: 4
槽位:6
Key: 张三1, Value: 1
槽位:7
Key: 张三2, Value: 2
================插入5个元素================
table.length: 8
槽位:0
槽位:1
槽位:2
Key: 张三5, Value: 5
槽位:3
Key: 张三6, Value: 6
槽位:4
Key: 张三3, Value: 3
槽位:5
Key: 张三4, Value: 4
槽位:6
Key: 张三1, Value: 1
槽位:7
Key: 张三2, Value: 2
================插入6个元素================
table.length: 16
槽位:0
Key: 张三7, Value: 7
槽位:1
槽位:2
Key: 张三5, Value: 5
槽位:3
Key: 张三6, Value: 6
槽位:4
Key: 张三3, Value: 3
槽位:5
Key: 张三4, Value: 4
槽位:6
Key: 张三1, Value: 1
槽位:7
Key: 张三2, Value: 2
槽位:8
槽位:9
槽位:10
槽位:11
槽位:12
槽位:13
槽位:14
槽位:15
================插入7个元素================
table.length: 16
槽位:0
Key: 张三7, Value: 7
槽位:1
Key: 张三8, Value: 8
槽位:2
Key: 张三5, Value: 5
槽位:3
Key: 张三6, Value: 6
槽位:4
Key: 张三3, Value: 3
槽位:5
Key: 张三4, Value: 4
槽位:6
Key: 张三1, Value: 1
槽位:7
Key: 张三2, Value: 2
槽位:8
槽位:9
槽位:10
槽位:11
槽位:12
槽位:13
槽位:14
槽位:15
================插入8个元素================
table.length: 16
槽位:0
Key: 张三7, Value: 7
槽位:1
Key: 张三8, Value: 8
槽位:2
Key: 张三5, Value: 5
槽位:3
Key: 张三6, Value: 6
槽位:4
Key: 张三3, Value: 3
槽位:5
Key: 张三4, Value: 4
槽位:6
Key: 张三1, Value: 1
槽位:7
Key: 张三2, Value: 2
槽位:8
槽位:9
槽位:10
槽位:11
槽位:12
槽位:13
槽位:14
Key: 张三9, Value: 9
槽位:15
================插入9个元素================
table.length: 16
槽位:0
Key: 张三7, Value: 7
槽位:1
Key: 张三8, Value: 8
槽位:2
Key: 张三5, Value: 5
槽位:3
Key: 张三6, Value: 6
槽位:4
Key: 张三3, Value: 3
槽位:5
Key: 张三4, Value: 4
槽位:6
Key: 张三1, Value: 1
槽位:7
Key: 张三2, Value: 2
槽位:8
槽位:9
槽位:10
Key: 张三10, Value: 10
槽位:11
槽位:12
槽位:13
槽位:14
Key: 张三9, Value: 9
槽位:15
================插入10个元素================
大家可以关注下,随着不断往HashMap中put数据,每个轮次table.length的变化。可以发现table.length随着put元素数量不断的增加,每次扩容后原来元素存储的位置都有在发生改变。
就拿最前面两批次数据就可以很明显的看到:
- 取前两行分析
table.length: 2
槽位:0
Key: 张三1, Value: 1
槽位:1
================插入1个元素================
table.length: 4
槽位:0
槽位:1
槽位:2
Key: 张三1, Value: 1
槽位:3
Key: 张三2, Value: 2
================插入2个元素================
在table.length=2的时候张三1
是存储在槽位0
上面。随着再往原来的HashMap中put数据就会触发扩容了张三1
就重新存储到新的数组槽位2
上去了,这个过程就很好的让大家体验到了HashMap的扩容
和数据迁移
的感受了。
大家还记得什么时候会触发扩容吗?上面已经说过喽小伙伴们,不过我也还是很乐意和大家在这里复习下什么时候会触发扩容。
前面我门说了,判断是否会扩容只要看table.length0.75的值。如果当插入的历史元素数据总量n>table.length0.75的时候就会触发扩容,然后将老数据迁移到新数组重新hash(rehash)落到新的槽位。
拿上面的例子:2*0.75 = 1.5。所以当你插入张三2
的时候原来已经插入了张三1
元素的个数将会上升到2,那么2>1.5就触发了扩容操作。
由原来的:
table.length: 2
槽位:0
Key: 张三1, Value: 1
槽位:1
变成了现在的新数组:
table.length: 4
槽位:0
槽位:1
槽位:2
Key: 张三1, Value: 1
槽位:3
Key: 张三2, Value: 2
继续观察table.length增加的规律:2->4->8->16。我们会发现每次扩容的table.length
都是扩容前
table.length
的2倍
。
当然源码也给我们说明了每次扩容是原来2倍的理由:
java.util.HashMap#resize
/**
* 重新调整哈希表的大小,当表中的元素数量超过阈值时执行。如果当前表大小不足,则将表大小翻倍。
* 在调整大小过程中,元素会被重新分布到新的表中。
*
* @return 新的哈希表数组
*/
final Node<K,V>[] resize() {
// 获取当前的哈希表和其容量
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 如果当前容量大于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; // 当容量小于最大容量时,将阈值翻倍
}
else if (oldThr > 0) // 如果初始化容量被设置
newCap = oldThr;
else { // 如果使用默认的初始化容量
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 { // 保持链表顺序的重新分布
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; // 返回新的哈希表
}
从上面的源码newThr = oldThr << 1; // 当容量小于最大容量时,将阈值翻倍。这句话可以很明显看出2倍的操作。
oldThr << 1 = oldThr *2。这里依然还是采用的位运算,而没有使用普通的乘法运算。提高计算性能。所以总结HashMap里面用到了很多位运算,而没有采用传统的乘法运算。这里我们也可以后续优化借鉴一下。