HashMap集合源码详解
HashMap集合源码详解
目标
- 掌握HashMap底层数据存储结构,及其变换【链表转红黑树】原理
- 掌握HashMap的初始化容量大小,及加载因子0.75原理
- 理解HashMap链表转红黑树边界值设计思想
- 掌握HashMap扩容机制,及初始化容量最佳实践
一、HashMap集合简介
1.1什么是HashMap?
HashMap是Map接口的实现类。HashMap是基于哈希表数据结构实现的。主要特点:双列集合,每个元素含有key和value。注意:hashMap不是线程安全的。JUC包中的替代集合处理。
特点:
-
无序性:存入和取出元素的顺序不一致
-
唯一性:key是唯一的
-
可存null:键和值都可以为null,但是键是唯一的,键只有一个null
-
数据结构:哈希表结构,控制的是key而非value值。
- 哈希表的实现方案有很多:
- JDK1.8之前:数组 + 链表
- JDK1.8之后: 数组 + 链表 + 红黑树【当单个链表长度超过8,并且数组的长度大于64才会出现红黑树结构】
- 目的:高效率存取数据
扩展:什么是红黑树?是一种自平衡的二叉查找树。红黑树是1972年由 Rudoif Bayer发明的,当时称之为平衡二叉B树。
1.2HashMap类的继承关系
HashMap继承关系如下图所示:
说明:三个接口,一个抽象父类。
- Cloneable空接口,表示可以克隆。创建并返回HashMap对象的一个副本。
- Serializable序列化接口。属于标记性接口。HashMap对象可以被序列化和反序列化。
- AbstractMap父类提供了Map实现接口。以最大限度地减少实现此接口所需的工作。
补充:通过上述继承关系我们发现一个很奇怪的现象,就是HashMap已经继承了AbstractMap而AbstractMap类实现了Map接口,那为什么HashMap还要在实现Map接口呢?同样在ArrayList中LinkedList中都是这种结构。
二、HashMap原理分析
2.1哈希表简介
什么是哈希表
哈希表(Hash table,也叫散列表),是根据建码值直接进行访问的数据结构,也就是说,它通过把键码值映射在数组的一个位置来进行访问。能够大大加快访问元素的速度。这种映射关系,是由一种函数来完成。哈希函数,散列函数。存放记录的位置,是一个数组,散列表。
哈希表的本质,其实是一个数组。这个数组存储值是一个哈希函数算出的值。
目的:为了提升存取数据的速度。
// 哈希表数据结构的案例
public static void main(String[] args) {
//目标:掌握哈希表的数据结构
HashMap<String, Integer> map = new HashMap<>();
map.put("hello", 55);
map.put("world", 56);
map.put("java", 53);
map.put("world", 52);
map.put("通话", 51);
map.put("重地", 77);
System.out.println("map = " + map);// map = {通话=51, 重地=77, world=52, java=53, hello=55}
}
结论:哈希表的本质是一个数组,每个元素的索引位存储的链表。
结论2:
- 初始化哈希表内的数组的大小是16
- 索引位置相同,hash值和equals均不同,则创建新节点连接下去
- 连接列表的长度超过8,并且数组长度大于64,则链表转为红黑树
2.2HashMap存储数据过程详解
1、与数据存储过程相关的属性&概念
1.加载因子:默认值0.75,决定了HashMap的数组的扩容。
final float loadFactor;
static final float DEFAULT_LOAD_FACTOR = 0.75f;//默认值
2.扩容临界值,当前HashMap的集合数组中元素超出了一定范围,进行扩容条件。
int threshold;//(capacity数组容量 * 加载因子)
3.当前集合中数组的容量:capacity,初始化的容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
4.扩容:当前集合中元素存入达到阈值【扩容临界值】,则进行扩容。HashMap扩容特点每次扩2倍。
5、集合元素个数size:表示当前HashMap中存储的键值对的个数。注意,不等于集合的数组的长度。
2、存储过程图解
3、存储过程源码分析
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
// HashMap中的数组
Node<K,V>[] tab; Node<K,V> p; int n, i;
//1.判断数组是否为空,如果为空,则进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
//则进行扩容【添加第一个元素的时候,会进行容量】
n = (tab = resize()).length;
//2.如果数组不为空,求出key的对应数组容量所在索引位置。求解索引位位置,采用取余的方式哈希值%16
//(数组长度 - 1) 位运算 hash 范围也在数组长度以内。前提条件 : 数组的长度只能是2的n次幂
if ((p = tab[i = (n - 1) & hash]) == null)//如果数组对应key指的索引位置,没有元素
//直接新增一个节点
tab[i] = newNode(hash, key, value, null);
else {
//3.如果不为空
Node<K,V> e; K k;
//判断,第一个节点对象的哈希和key是否相同,如果相同,则覆盖
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//4.判断当前的数组索引对应的链表,是否是红黑树
else if (p instanceof TreeNode)
//5.如果是红黑树,则将元素加入红黑树节点
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//6.循环当前数组索引位的所有的链表元素,判断,元素是否哈希值和equals方法结果相同,则覆盖
//如果不相同,忽略
for (int binCount = 0; ; ++binCount) {
//全部不相同,则将key和value创建一个新的节点,连接到链表上
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//判断,当前链表的长度是否大于8,如果大于则链表转红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//链表转红黑树的方法,被使用的概率极低!
treeifyBin(tab, hash);
break;
}
//第一个节点对象的哈希和key是否相同,如果相同,则覆盖
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;
//判断当前容量是否触发扩容机制
//当前数组的长度,大于阈值12
if (++size > threshold)
resize();//扩容
afterNodeInsertion(evict);
return null;
}
2.3HashMap底层数据结构详解
1、什么是数据结构?
数据结构是计算机存储、组织数据的方式。数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。通常情况下,精心选择的数据结构可以带来更高的运行或存储效率。数据结构往往同高效的检索算法和索引技术有关。
数据结构:就是存储数据的一种方式。ArrayList、LinkedList
2、HashMap的数据结构-哈希表
- 在JDK1.8之前,HashMap的数组 + 链表组成
- 在JDK1.8之后,HashMap的数组 + 链表 + 红黑树组成。
数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。
什么是哈希冲突?两个对象调用其自身的hashCode方法,产生相同的哈希值,计算的数组索引位置相同,哈希值也相同产生哈希碰撞。
JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为8)并且当前数组的长度大于64时,此时此索引位置上的所有数据改为使用红黑树存储。
JDK1.8引入红黑树很大程度优化了HashMap的性能,那么对于我们来讲保证HashSet集合元素的唯一,其实就是根据对象的HashCode和equals方法来决定的。如果我们往集合中存放自定义的对象,那么保证其唯一,就必须复写hashCode和equals方法建立属于当前对象的比较方式。
当位于一个链表中的元素较多,即hash值相等但内容不相等的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,哈希表存储采用数组+链表+红黑树实现,当链表长度(阈值)超过8时且当前数组的长度>时,将链表转换为红黑树,这样大大减少了查找时间。JDK1.8在哈希表中引入红黑树的原因只是为了查找效率更高。
简单的来说,哈希表是由数组+链表+红黑树(JDK1.8增加红黑树部分)实现的。如下图所示。
3、数据结构的源码
HashMap底层数据结构的源码
transient Node<K,V>[] table;//存储HashMap的数组,链表就是node对象
缓存数组的对象
transient Set<Map.Entry<K,V>> entrySet;
当前HashMap集合存了多少个元素的
transient int size;
红黑树
TreeNode<K,V>
链表
Node<K,V>
三、HashMap源码分析
3.1HashMap的默认初始化容量
初始化容量是多少?16
//默认16, 1<<4 == 1*2的4次方 ==> 16
//初始化的容量,满足2的n次幂
//HashMap的容量,必须是2的n次幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
初始化容量必须是2的n次幂,为什么?【大厂面试常见题目之一】
向HashMap中添加元素时,要根据key的hash值去确定其在数组中的具体位置。HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同。
怎么让元素均匀分配呢?
这里用到的算法时hash&(length-1)。hash值与数组长度减一的位运算。算法本质作用是类似于取模,hash%length。但是计算机中直接求余效率远不如位运算。这里的位运算的前提:length是2的n次幂!
为什么这样能均匀分布减少碰撞呢?
举例:位运算规则说明:按&位运算,相同的二进制数位上,都是1的时候,结果为1.否则为零。
例如:数组长度8时,均匀分布在数组中,哈希碰撞的几率比较小;
哈希值->314924944
1、方式一:取模运算,在计算机中,取模运算的效率比较低,相对于位运算。
314924944 % 16 = 0
314924945 % 16 = 1
314924946 % 16 = 2
314924947 % 16 = 3
314924948 % 16 = 4
314924949 % 16 = 5
....
2、方式二:位运算,前提length是2的n次幂!
位运算:
0001 0010 1100 0101 0101 1111 1001 0000
& 0000 0000 0000 0000 0000 0000 0000 1111
----------------------------------------------
0000 0000 0000 0000 0000 0000 0000 0000 ==> 0
314924944 & 16 = 0
314924945 & 16 = 1
314924946 & 16 = 2
314924947 & 16 = 3
....
结论是:数组索引存储的数据结构均匀分布了,减少哈希碰撞的几率
反例:
例如:数组长度10时,没有均匀分布,碰撞几率比较大:
程序员计算器求解:
314924944 & (10 - 1) = 0
314924945 & (10 - 1) = 1
314924946 & (10 - 1) = 0
314924947 & (10 - 1) = 1
....交替为0
结论是:数据全部分布在第一个和第二个索引位置上,大大增加了哈希碰撞的几率。效率低下。
手动设置初始化容量
HashMap构造方法还可以指定集合的初始化容量大小:
HashMap(int initialCapacity) 构造一个带指定初始化容量和默认加载因子(0.75)的空HashMap。
注意:当然如果不考虑效率问题,求余即可。就不需要长度必须是2的n次幂了。如果采用位运算,必须是2的n次幂!
那么来了,如果有哪个蠢蛋,瞎搞。HashMap也自带纠错能力。具备防蠢货能力。如果创建HashMap对象时,输入的数组长度不是2的n次幂,HashMap通过一通位移运算和或运算得到的肯定是2的幂次数,并且是离那个数最近的数字。
源码:
//初始化一个指定容量的HashMap集合
public HashMap(int initialCapacity) {
//加入默认的加载因子
this(initialCapacity, DEFAULT_LOAD_FACTOR);//0.75
}
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//当前HashMap的容量不是无限大,最大的是
//static final int MAXIMUM_CAPACITY = 1 << 30; 2的30次方
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);
}
//自动调整初始化的容量,让其符合2的n次幂条件
//如果传入的值是10,会自动校正为最近的一个2的n次幂,16
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;
}
加入给的初始化容量为10,最终容量会变为最近的16!
举例:加入给的初始化容量为10,最终容量会变为最近的16!
小结:
1.根据key的hash确定存储位置时,数组长度是2的n次幂,可以保证数据的均匀插入。如果不是,会浪费数组的空间,降低集合性能!
2.一般情况下,我们通过求余%来均匀分散数据。只不过其性能不如位运算【&】。
3.length的值为2的n次幂,hash&(length - 1) 作用完全等同于hash % length。
4.HashMap中初始化容量为2次幂原因是为了数组数据均匀分布。尽可能减少哈希冲突,提升集合性能。
5.即便可以手动设置HashMap的初始化容量,但是最终还是会被重设为2的n次幂。
3.2HashMap的加载因子0.75和最大容量
1.加载因子相关属性
哈希表的加载因子(重点)默认0.75
final float loadFactor;//加载因子
//默认值
static final float DEFAULT_LOAD_FACTOR = 0.75f;//加载因子决定了何时会扩容
集合最大容量:100个亿【10 7374 1824】
static final int MAXIMUM_CAPACITY = 1 << 30;
扩容的临界值:计算方式为(容量 乘以 加载因子)
int threshold;//扩容临界值
//默认容量,默认的加载因子,扩容临界值16 * 0.75 = 12
2.为什么加载因子设置为0.75,初始化临界值是12?
加载因子可以手动设置!
loadFactor太大导致查找效率低,小导致数组的利用率低,存放的数据会很分散。loadFactor的默认值为0.75f是官方给出的一个比较好的临界值。
-
加载因子越大趋近于1,数组中的存放的数据(Node)也就越多,也就越稠密,也就是会让链表的长度增加。
-
加载因子越小趋近于0,数组中的存放的数据(Node)也就越少,也就越稀疏。也就是会让链表的长度不会太长。
如果希望链表尽可能少些,性能更好。就要提前扩容,但导致的问题是数组空间浪费,有些桶没有存储数据!典型的鱼和熊掌不可兼得!
举例:
例如:加载因子是0.4。 那么16*0.4--->6 如果数组中满6个空间就扩容会造成数组利用率太低了。每次扩容变一倍。
加载因子是0.9。 那么16*0.9--->14 那么这样就会导致链表有点多了,导致查找元素效率低。
所以既兼顾数组利用率又考虑链表不要太多,经过大量测试0.75是最佳方案。
threshold计算公式:capacity(数组长度默认16) * loadFactor(加载因子默认0.75)。
这个值是当前已占用数组长度的最大值。当size>=threshold的时候,那么就要考虑对数组的resize(扩容)。扩容后的 新HashMap容量是之前容量的两倍。
不要改动加载因子,非常不建议!
3.修改加载因子
同时在HashMap的构造器中可以定制loadFactor。但最好别 改~
构造方法:
HashMap(int initialCapacity, float loadFactor) 构造一个带指定初始容量和加载因子的空 HashMap。
小结:
HashMap集合中,默认的加载因子是0.75! 不建议修改
HashMap集合最大容量,10个亿。2的 30次方。
3.3 HashMap的红黑树转换边界值详解
1、边界转换相关属性
1.边界转换值1:当链表的长度超过边界值8,就会启动转换红黑树【JDK8】。前提:数组长度不能低于64
//当桶中节点数,超过边界值,就会转为红黑树
static final int TREEIFY_THRESHOLD = 8;
2.转换边界值2:当Map里面的数量超过这个值时,表中的 桶才能进行树形化,这个值不能小于4 * TREEIFY_THRESHOLD(8)
static final int MIN_TREEIFY_CAPACITY = 64;//就是边界值 * 4 ==> 8*4 ===> 32
2.桶(bucket):所谓的桶,指的是一个数组索引位中的所有元素。
4.降级转换边界值:当链表的 值小于6则会从红黑树转会链表。
//桶中元素的下边界,如果一个链表是红黑树,但是元素比较少,还会将红黑树转换为链表
static final int UNTREEIFY_THRESHOLD = 6;
2、为什么Map桶中节点个数超过8才转为红黑树?
HashMap的红黑树数据结构几乎不会被用到,本质上还是一个链表+数组!!!
8阈值定义在HashMap中,针对这个成员变量,在源码的注释中只说明了8是bin(bin就是Bucket(桶))从链表转成树的阈值,但是并没有说明为什么是8;
在HashMap官方注释说明:
//翻译后内容
因为树节点的大小大约是普通节点的两倍,所以我们只在箱子包含足够的节点时才使用树节点(参见TREEIFY_THRESHOLD)。当它们变得太小(由于删除或调整大小)时,就会被转换会普通的桶。在使用分布良好的用户hashcode时,很少使用红黑树。
情况下,在随即哈希码下,桶中节点的频率服从泊松分布,默认调整阈值为0.75,平均参数约为0.5,尽管由于调整粒度的差异很大,忽略方差,列表大小k的预期出现次数(exp(-0.5)*pow(0.5, k)/factorial(k))。
第一个值是:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million
红黑树节点对象占用空间是普通链表节点的两倍,所以只有当桶中包含足够多的节点时才会转成红黑树。当桶中节点数变少时,又会转成普通链表。并且我们查看源码的时候发现,链表长度达到8就转成红黑树,当长度降到6就转成链表。
这样就解释了为什么不是开始就将其转换为红黑树节点,而是数量数才转。
说白了就是空间和时间的权衡!
官方还说了:当hashCode哈希函数值离散性很好的情况下。红黑树被用到的概率非常小!概率为0.00000006。
理想的情况下,优秀的hash算法,会让所有桶的节点的分布频率会遵循泊松分布。我们可以看到,一个桶中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。因为数据被均匀分布在每个桶中,所以几乎不会有桶中的链表长度会达到阈值!
所以之所以选择8,不是随便决定的,而是根据概率统计决定的。由此可见,发展将近30年的Java每一项改动和优化都是非常严谨和科学的。也就是说:选择8因为符合泊松分布,超过8的时候,概率已经非常小了,所以我们选择8这个数字。
但是哈希函数【hashCode】是有用户控制,用户选择的hash函数,离散性可能会很差。JDK又不能阻止用户实现这种不好的hash算法。因此,就可能导致不均匀的数据分布。所以超过8了,就采用红黑树,来提升效率。
也是防蠢货设计!
扩展:Poisson分布(泊松分布),是一种统计与概率学里常见到的离散[概率分布]。泊松分布的概率函数为:
泊松分布的参数λ是单位时间(或单位面积)内随机事件的平均发生次数。泊松分布适合于描述单位时间内随机事件发生的次数。
小结:
- 当链表的长度超过边界值8,就会启动转换红黑树【JDK1.8】。数组长度不能低于64。
- 开发的时候,使用HashMap的链表,很难转为红黑树操作。
3.5HashMap的treeifyBin()方法详解-链表转红黑树
1、转换相关属性
1.转换边界值1:当链表超过转换边界值8,就会转红黑树(1.8新增),前提条件:数组长度大于64.
//当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
2.转换边界值2:当Map里面的数量超过这个值时,表中的桶才能进行树形化,这个值不能小于4*TREEIFY_THRESHOLD(8)
//桶中结构转化为红黑树对应的数组长度最小的值
static final int MIN_TREEIFY_CAPACITY = 64;
3.桶(bucket):所谓的桶,指的是一个数组索引位中的所有元素。
4.降级转换边界值:当链表的值小于6则会从红黑树转回链表。
//当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
2、转换treeifyBin()方法源码分析
节点添加完成之后判断此时节点个数是否大于TREEIFY_THRESHOLD临界值8.如果大于则将链表转换为红黑树,转换红黑树的方法treeifyBin,整个代码如下:
//判断条件:桶中节点数量大于等于 边界阈值-1。【计算机数数都是从0开始】
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);//执行链表转红黑树的方法
treeifyBin方法如下所示:
//将所有的链表的节点,转换为红黑树节点
//如果哈希表的数组太小【64】了,就不会去转换!
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//1.如果当前数据为空,或者数组的长度小于红黑树的最小阈值。就会去扩容,而非进行转为红黑树
//MIN_TREEIFY_CAPACITY == 64
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();//扩容
//2.转换为红黑树。目的,就是通过空间,换取时间!
//当前哈希在表桶节点不为空
else if ((e = tab[index = (n - 1) & hash]) != null) {
//进行转换:链表转为红黑树
//红黑树头部节点hd
//红黑树尾部节点tl
TreeNode<K,V> hd = null, tl = null;
//遍历所有的链表的节点,将链表节点转为红黑树结构的节点
do {
TreeNode<K,V> p = replacementTreeNode(e, null);//原始节点替换为红黑树节点
if (tl == null)//如果尾部节点为空,将头部节点给尾部节点
hd = p;
else {//如果不是
p.prev = tl;//将尾部节点交给prev节点
tl.next = p;//当前节点p,交给尾部节点的下一个节点
}
tl = p;
} while ((e = e.next) != null);
//判断当前索引,头部节点如果不为空
if ((tab[index] = hd) != null)
hd.treeify(tab);//将桶中的第一个节点,转为红黑树的顶部节点
}
}
小结:
1.HashMap集合中,链表节点红黑树节点的临界值是8,前提是集合中数组的最大容量是64以上。否则会对数组进行扩容!
3.6HashMap的扩容机制
扩容的由来:
- 在不断向Map集合中添加数据的时候,数组并不是一直保持初始容量16.扩容阈值 加载因子*容量 12。
- 数组 + 链表【查询效率变的很慢】
- 红黑树占用空间大【占用空间大】
1、扩容相关属性
1.扩容计数器:用来记录HashMap的修改次数、
//每次扩容和更改map结构的计数器
transient int modCount;
2.转换边界值1:当链表超过转换边界值8,就会转红黑树(1.8新增),前提条件:数组长度大于64。
//当前(bucket)上的节点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
3.转换边界值2:当Map里面的数量超过这个值时,表中的桶才能进行树形化,这个值不能小于4*TREEIFY_THRESHOLD(8)
//桶中结构转化为红黑树对应的数组长度最小的值
static final int MIN_TREEIFY_CAPACITY = 64;
4.桶(bucket):所谓的桶,指的是一个数组索引位中的所有元素。
5.哈希表的加载因子(重点)默认值是0.75,决定了扩容的条件
// 加载因子
final float loadFactor;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
6.集合最大容量:10,7374,824【10亿】
//集合最大容量的上限是:2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;
7.扩容的临界值:计算方式(容量 乘以 加载因子)
//临界值 当实际大小超过临界值时,会进行扩容
int threshold;
2、扩容机制
了解HashMap的扩容机制,你需要搞懂这两个问题:
1.什么时候需要扩容?
2.HashMap的扩容做了哪些事情?
问题1:什么时候需要扩容?
主要在两种情况下进行扩容:
1.当HashMap集合中,实际存储元素个数超过临界值(threshold)时,会进行扩容。默认初始化临界值是12。
2.当HashMap中,单个桶的链表长度达到了8,并且数组长度还没到达64。会进行扩容。
问题2:HashMap的扩容做了哪些事?
将原数组中桶的节点,均匀分散在了新的数组的桶中。
HashMap扩容时分散使用的rehash方式非常巧妙。并没有进行hash函数调用。
由于每次扩容都是翻倍,与原来计算的(n-1)&hash的结果相比,只是多了一个bit位。所以节点要么就在原来的位置,要么就被分配到原位置+旧容量这个位置。
结论:rehash,原始桶中的元素,扩容之后,要么在当前索引位上,要么在原索引位+数组长度!
怎么理解呢?例如我们从16扩展为32时,具体的变化如下所示:
"娜扎"的哈希值740274
公式:(n-1) & hash
740274 & (16 - 1) = 2
//通过哈希值,算出两个字符串在同一个桶中
string key = "天乐";//hashCode值 727623
string key = "德华";//hashCode值 780919
计算hash
727626 & (16 - 1) = 7
780919 & (16 - 1) = 7
扩容之后,巧妙的计算rehash【更高效】
727623 & (32-1) = 7
780919 & (32-1) = 7 + 16 = 23
//结论 :同一个桶内的节点,要在原来位置,要么就是"原位置+旧容量"!
画图说明:
下图为16扩充为32的resize示意图:将原数组中桶内的节点,均匀分散在了新的数组的桶中。
结论:
1.7是两个字符串计算的原始索引,存入同一个桶中。扩容之后,”天乐“在原来位置,”德华“被分配原位置+旧容量位置。
2.因此我们在扩容HashMap时,不需要重新计算hash。只需要看原来的hash值新增bit是1还是0就可以了!
1.是0索引没变
2.是1索引变成”原索引+oldCap(原位置+旧容量)“。
注意:
- 扩容必定伴随rehash操作,遍历hash表中所有元素。这种操作比较耗时!
- 在编程中,超大HashMap要尽量避免resize,避免的方法之一就是初始化固定HashMap大小!
3、扩容方法resize()源码解读
扩容情况之一:
//HashMap的扩容方法
final Node<K,V>[] resize() {
//将一个全局的数组,赋值局部的数组变量oldTable
Node<K,V>[] oldTab = table;
//获取原始数组的容量:16
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//获取原始哈希表的扩容边界阈值
int oldThr = threshold;
//定义新的数组的容量,定义新数组的扩容边界阈值
int newCap, newThr = 0;
//判断,原始容量是否大于0
if (oldCap > 0) {
//判断原始的额容量是否大于最大容量
if (oldCap >= MAXIMUM_CAPACITY) {//10个亿
threshold = Integer.MAX_VALUE;//扩容的边界阈值,将等于int的最大值
//直接结束当前的循环,已经到达最大值了
return oldTab;
}
//如果没有达到最大值,进行扩容oldCap << 1(翻倍)。
//判断翻倍之后小于最大值,并且 原始容量大于最大容量16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//扩容边界阈值也翻倍:12 --> 24
newThr = oldThr << 1; // double threshold
}
//判断边界阈值是否大于0.
else if (oldThr > 0) // initial capacity was placed in threshold
//如果大于0,旧的阈值替换为新的阈值
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);
}
//求出的新的边界阈值,给予全局的HashMap的集合
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//创建新的扩容后的数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;//将新的数组赋值给全局的变量
if (oldTab != null) {//判读新的数组,如果不为null
//将原始的数组,均匀的分散在新的数组中
for (int j = 0; j < oldCap; ++j) {//循环遍历操作
Node<K,V> e;//遍历后的原始数组的节点
if ((e = oldTab[j]) != null) {//遍历的节点,存在元素
oldTab[j] = null;//清空原始数组索引的数据,节省空间,提高GC效率
//判断当前节点下,是否还有元素
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...while循环
do {
next = e.next;
//rehash操作,
//当前节点的key的哈希值,位运算新的,得出权限的位置
if ((e.hash & oldCap) == 0) {//要么是0,要么是1
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);
//当前节点的key的哈希值,位运算新的,得出权限的位置
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
3.7HashMap容量初始化最佳策略
hashMap的扩容是一个比较耗费性能操作:创建新的数组,又是赋值、rehash操作...
1、HashMap的初始化问题描述
《阿里巴巴Java开发手册》中建议初始化HashMap的容量。
为什么要建议初始化HashMap容量?
- 防止自动扩容,影响效率!
当然阿里的建议是有理论支撑的。我们上面介绍过HashMap的扩容机制,就是当达到扩容条件时会进行扩容。HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。在HashMap中,threshold = loadFactor*capacity。
所以,如果我们没有设置初始容量大小,随着元素的不断增加,HashMap会有可能发生多次扩容,而HashMap中的扩容机制决定了每次扩容都需要重建hash表,是非常影响性能的。
设置初始化容量,数值不同性能也不一样!
当已知HashMap中,将存放的键值对个数时,容量设置成多少合适呢?
是直接设置键值个数吗?并不是,我接下来看
2、HashMap中容量初始化多少合适?
假如现在HashMap集合要存入,16个元素。如果你初始化16个。集合总容量是16,扩容阈值是12.最终HashMap集合在存入到12个元素时,会进行一次扩容操作。这样会导致性能损耗。
有没有更好的办法?
《阿里巴巴Java开发手册》有以下建议:初始化的容量就是扩容的阈值!
如果我们通过initialCapacity/0.75F + 1.0F 计算:16/0.75 + 1 = 22。
22经过Jdk处理之后【2的n次幂】,集合的初始化容量会被设置成32。
集合总容量是32,扩容阈值是24。最终HashMap集合在存入到16个元素时,完全不会进行扩容。榨取最后一滴性能!
有利必有弊,这样的做法会增加数组的无效容量,牺牲一小部分内存。出于对性能的极致追求,这部分牺牲是值得的!
四、HashMap面试题精讲
1、HashMap中hash函数是怎么实现的?还有哪些hash函数的实现方式?
底层采用的是key的hashCode方法 加 异或(^) 加 无符号右移(>>>)操作计算出hash值。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
而哈希表中,计算数组索引的方法是位运算,如下代码。保证所有的hash最终落在数组最大索引范围之内。
i = (n - 1) & hash
还可以采用:取余法、平方取中法,伪随机数法。
取余数方式较为常见10%8,但是与位运算相比,效率较低。
2、当两个对象的hashCode相等时会怎么样?
会产生哈希碰撞
如果若key值相同,则替换旧的value。不然连接到链表后面,链表长度超过阈值8,数组长度大于64自动转换为红黑树存储。
3、何时发生哈希碰撞和什么是哈希碰撞,如何解决哈希碰撞?
只要两个元素的key计算的哈希码值相同就会发生哈希碰撞。
jdk8前使用链表解决哈希碰撞。
jdk8及以后使用链表+红黑树解决哈希碰撞。
哈希基本可以确定是唯一性:但是很有可能会出现哈希碰撞!
4、hashCode和equals方法有何重要性?
非常重要!
1.HashMap使用key对象的#hashcode()和#eques(Object obj)方法去决定键值对的存储位置。
当从HashMap中获取值时,也会被用到
- 如果这两个方法没有被正确地实现,两个不同key也会产生相同的#hashcode()和#equals(Object obj)输出。这就会导致元素存储位置的混乱,降低HashMap性能。
2.#hashcode()和#equals(Object obj)保证了集合元素的唯一性
所有不允许存储重复数据的集合类,都使用这两个方法,所以正确实现它们非常重要。
- 如果01.equals(o2),那么o1.hashCode() == 02.hashCode 总是为true的。
- 如果o1.hashCode() == o2.hashCode(),并不意味o1.equals(o2)会为true。
5、HashMap默认容量是多少?
- 默认容量都是16,加载因子是0.75。
- 就是当HashMap填充了75%就会扩容,最小扩容阈值(16 * 0.75 = 12)
- 扩容一般会扩为原内存2倍。
6、HashMap的长度为什么是2的n次幂?
为了能让HashMap存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀分散在数组中。这个问题的关键在于存储数组索引位的算法【hash & (length - 1)】。相当于取余的操作!
这个算法可以如何设计呢?
1.程序员们一般会想到%取余,但是效率太低。
2.在计算机运算中,位运算高于取余操作。而位运算能够做到与取余相同效果的前提是,数组长度是2的n次幂。
这就解释了HashMap的长度为什么是2的幂次幂。
7、加载因子值的大小对HashMap有什么影响?
加载因子的大小决定了HashMap的数据密度
-
加载因子越大,数据密度越大,发生碰撞概率越高,数组桶中链表长度越长,查询或者插入时的比较次数增多。从而导致性能下降。
-
加载因子越小,数据密度越小,发生碰撞概率越小,数组桶中链表越短,查询和插入时比较的次数也就越小。性能会更高。
加载因子越小,越容易触发扩容,会影响性能
加载因子越小,存储数据量越小,会浪费内存空间
鱼与熊掌不可兼得!
按照其他语言的参考及研究经验,会考虑将加载因子设置为0.7到0.75【最佳】!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?