HashMap源码分析
HashMap源码分析
HashMap集合简介
什么是HashMap
- HashMap 是 Map 接口的实现类,基于哈希表结构实现的。其主要特点是以 Key-Value 形式存储数据,HashMap 的操作是不同步的,这就意味着它是线程不安全的。
- 特点:
- 无序性:存入和取出元素顺序不一致
- 唯一性:Key是唯一的
- 可存null:键和值都可以为 null,但是键为null的位置只有一个,首地址
- 数据结构:控制的是 Key,而不是 Value
- JDK 1.8 之前的数据结构是:数组 + 链表
- JDK 1.8 之后的数据结构是:数组 + 链表 + 红黑树
- 单链表阈值(边界值) 大于 8,并且数组长度大于 64,才将链表转为红黑树——目的是高效查询数据
红黑树(Red Black Tree):是一种自平衡二叉查找树,是在计算机科学中用到的一种数据结构,典型的用途是实现关联数组。红黑树是在1972年由Rudolf Bayer发明的,当时被称为平衡二叉B树(symmetric binary B-trees)
HashMap类的继承关系
- Cloneable:空接口,表示可以克隆,创建并返回HashMap对象的一个副本。
- Serializable:序列化接口,属于标记性接口,HashMap 对象可以被序列化和反序列化。
- AbstractMap:父类提供了 Map 实现接口,以最大限度地减少实现此接口所需的工作。
补充:通过上述继承关系我们发现一个很奇怪的现象, 就是HashMap已经继承了AbstractMap,而 AbstractMap 类实现了Map接口,那为什么HashMap还要再实现Map接口呢?同样在ArrayList中LinkedList中都是这种结构。
据 java 集合框架的创始人Josh Bloch描述,这样的写法是一个失误。在java集合框架中,类似这样的写法很多,最开始写java集合框架的时候,他认为这样写,在某些地方可能是有价值的,直到他意识到错了。显然的,JDK的维护者,后来不认为这个小小的失误值得去修改,所以就这样存在下来了。
HashMap原理分析
哈希表简介
什么是哈希表?
- 哈希表(Hash table,也叫散列表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
- 哈希表本质上是一个数组,这个数组中存储的元素是哈希函数算出来的值。目的是为了加快数据查找的速度。
HashMap中哈希表的数组的大小?
我们说,HashMap中的底层数据结构是哈希表,又说是数组+链表+红黑树?那么到底是怎么一回事呢?我接下来看HashMap的数据结构
- 结论:哈希表的本质是一个数组,每个元素的索引位存储时链表。
- 当创建HashMap集合对象时:
- JDK8前,构造方法创建一个长度是16的数组Entry[] table 来存储键值对的对象;
- JDK8后,不是在构造方法中创建对象数组,而是在第一调用put方法时创建长度是16的Node[] table数组,存储Node对象。
- 如果节点长度即链表长度大于阈值8,并且数组长度大于64则进行将链表变为红黑树。
HashMap存储数据过程
存储过程中相关属性
- 加载因子:默认值是 0.75,决定了扩容条件
// 加载因子
final float loadFactor;
- 扩容的临界值:计算方式为(容量 乘以 加载因子)
int threshold;
- 容量capacity:初始化为 16
- 扩容 resize:达到临界值就扩容,扩容后的 HashMap 容量是之前容量的两倍
- 集合元素个数size:表示 HashMap 中键值对实时数量,不等于数组长度
存储过程图解
存储过程的源码分析
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1. 判断哈希表是否为空
if ((tab = table) == null || (n = tab.length) == 0)
// 2. 如果为空,初始化容量为 16
n = (tab = resize()).length;
// 3. 如果不为空,则判断当前 key 的 hash 值对应的索引位置是否有元素
if ((p = tab[i = (n - 1) & hash]) == null)
// 4. 如果没有元素,往当前索引位置放入一个新的节点
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 5. 如果有元素,判断当前索引位置的节点 hash 值和 equals 与 新 key 是否相等
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
// 如果相等,则覆盖 value
e = p;
// 6. 如果不相等,则判断是否是红黑树
else if (p instanceof TreeNode)
// 如果是红黑树节点,则将元素存入红黑树节点
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 7. 如果不相等,也不是红黑树,则遍历所有链表节点
for (int binCount = 0; ; ++binCount) {
// 如果到了最后一个节点还没有找到相等的节点
if ((e = p.next) == null) {
// 则在尾部新增一个节点
p.next = newNode(hash, key, value, null);
// 8. 判断链表的长度是否大于 8
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 如果大于 8,直接将链表转为红黑树
treeifyBin(tab, hash);
break;
}
// 如果遍历节点的hash值和equals值 与 新 key 值相同,则跳出循环
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 如果 key 存在,则直接覆盖 value 值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 判断 HashMap 中节点数是否大于临界值,如果大于则扩容到之前的两倍
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
HashMap底层数据结构
什么是数据结构
- 数据结构是计算机存储、组织数据的方式。数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。通常情况下,精心选择的数据结构可以带来更高的运行或者存储效率。数据结构往往同高效的检索算法和索引技术有关。
- 数据结构:就是存储数据的一种方式。比如:ArrayList、LinkedList。
HashMap的数据结构
- 在 JDK1.8 之前,HashMap 是由数组 + 链表的数据结构组成。
- 在 JDK1.8 之后,HashMap 是由**数组 + 链表 + 红黑树【哈希表】**的数据结构组成。
- 数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)
什么是哈希冲突?
两个对象调用的 hashCode 方法计算得到的哈希码值相同,导致计算出来的数组索引值相同。
- 在 JDK1.8 以后,解决哈希冲突有了较大的变化,当链表的长度大于阈值(或者红黑树的边界值,默认为 8),并且当前数组的长度大于 64,此时此索引位置上的所有数据改为使用红黑树进行存储。
- JDK1.8引入红黑树大程度优化了HashMap的性能,那么对于我们来讲保证 HashMap 集合元素的唯一,其实就是根据对象的hashCode和equals方法来决定的。如果我们往集合中存放自定义的对象,那么保证其唯一,就必须复写hashCode和equals方法建立属于当前对象的比较方式。
- JDK1.8在哈希表中引入红黑树的原因只是为了查找效率更高。
- 简单的来说,哈希表是由数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的。如下图所示。
HashMap中数据结构的源码
- table 用来初始化(必须是2的n次幂)【重点】
// 存储元素的数组
transient Node<K,V>[] table;
- 缓存数组的对象:
// 存放具体元素的集合
transient Set<Map.Entry<K,V>> entrySet;
- HashMap中存放元素的个数【重点】
// 存放元素的个数,并不等于数组的长度
transient int size;
- 红黑树
TreeNode<K, V>
- 链表
Node<K, V>
HashMap源码分析
HashMap的默认初始化容量
初始化容量
// 默认初始化容量 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
为什么初始化容量必须是2的次幂
- 向 HashMap 中添加元素时,要根据 Key 的 hash 值去确定其在数组中的具体位置。HashMap 为了存取高效,要尽量减少碰撞,就要尽量把数据分配均匀,使每个链表的长度大致相同。那么怎么让元素均匀分配呢?这里用到的算法是 hash & (length - 1),即 hash 值与数组长度减一的位运算,该算法的本质作用就是取模 hash % length,在计算机中求余效率远不如位运算。
- hash%length 取模效果操作等于hash&(length-1)的前提,length是2的n次幂!
- 为什么这样能均匀分布减少碰撞呢?2的n次幂实际就是1后面n个0,2的n次幂-1 实际就是n个1。
- 举例:位运算规则说明,按&位运算,相同的二进制数位上,都是1的时候,结果为1,否则为0。
例如 : 数组长度8时候,均匀分布在数组中,哈希碰撞的几率比较小
求余运算的结果:314924944 & (8-1) = 0
00010010110001010101111110010000
& 00000000000000000000000000000111
--------------------------------------------------
00000000000000000000000000000000 --> 结果为0
程序员计算器求解
314924944 & (8-1) = 0
314924945 & (8-1) = 1
314924946 & (8-1) = 2
314924947 & (8-1) = 3
314924948 & (8-1) = 4
314924949 & (8-1) = 6
314924950 & (8-1) = 7
314924951 & (8-1) = 8
314924952 & (8-1) = 0
结论是:数组索引存储的数据均匀分布了,减少哈希碰撞的几率
例如 : 数组长度10时候,没有均匀分布,碰撞几率比较大;
程序员计算器求解 :
314924944 & (10-1) = 0
314924945 & (10-1) = 1
314924946 & (10-1) = 0
314924947 & (10-1) = 1
314924948 & (10-1) = 0
314924949 & (10-1) = 1
314924950 & (10-1) = 0
314924951 & (10-1) = 1
314924952 & (10-1) = 0
结论是:数据全部分布在第一个和第二个索引位置上,大大增加了哈希碰撞的几率。效率低下
手动设置初始化容量
- HashMap 构造方法还可以指定集合的初始化容量大小:
HashMap(int initialCapacity) // 构造一个带指定初始容量和默认加载因子 (0.75) 的空HashMap
- 注意:如果不考虑效率问题,求余即可,就不需要长度必须是 2 的次幂了。但如果采用位运算,必须是 2 的次幂。
- 那么来了,如果有那个蠢蛋不知道,瞎搞。HashMap也自带纠错能力。具备防蠢货功能。如果创建HashMap对象时,输入的数组长度不是2的n次幂,HashMap通过一通位移运算和或运算得到的肯定是2的幂次数,并且是离那个数最近的数字。
// 例如:创建HashMap集合的对象,指定数组长度是10,不是2的幂
// HashMap hashmap = new HashMap(10);
public HashMap(int initialCapacity) { // initialCapacity = 10
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) { // initialCapacity = 10
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);
}
// 自动调整初始化容量,让其符合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
小结
- 根据 key 和 hash 确定存储位置时,数组长度是 2 的 n 次幂,可以保证数据的均匀插入,如果不是,会浪费数组的空间,降低集合性能。
- 一般情况下我们通过求余 % 来均匀分散数据,只不过其性能不如位运算&。
- length的值为2的n次幂,hash & (length - 1) 作用完全等同于hash % length。
- HashMap 中初始化容量为 2 的次幂,原因是为了数组数据均匀分布,尽可能减少哈希冲突,提升集合性能。
- 即便可以手动设置 HashMap 的初始化容量,但是最终还是会被重置为 2 的 n 次幂。
HashMap的加载因子和最大容量
加载因子相关属性
- 哈希表的加载因子(重点)
// 加载因子
final float loadFactor;
- 加载因子的默认值是 0.75,决定了扩容的条件
static final float DEFAULT_LOAD_FACTOR = 0.75f;
- 集合的最大容量:10 7374 1824【10亿】
// 集合最大容量的上限是:2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;
- 扩容的临界值:计算方式是【容量 乘以 加载因子】
// 临界值 当实际大小超过临界值时,会进行扩容
int threshold;
为什么加载因子设置为 0.75,初始化临界值是 12 ?
- loadFactor 太大导致查找元素效率低,小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 0.75 是官方给出的一个比较好的临界值。
- 加载因子越大趋近于1,数组中的存放数据(Node)也就越多,也就越稠密,也就是会让链表的长度增加。
- 加载因子越小趋近于0,数组中的存放数据(Node)也就越少,也就越稀疏,也就是会让链表的长度不会太长。
- 如果希望链表尽可能少些,性能更好。就要提前扩容,但导致的问题是的数组空间浪费,有些桶没有存储数据!典型的鱼与熊掌不可兼得!
- 举个例子:
例如:加载因子是 0.4,那么 16 * 0.4 = 6,也就是数组中满 6 个空间就扩容,会导致数组利用率太低。
加载因子是 0.9,那么 16 * 0.9 = 14,也就是数组中满 14 个空间就扩容,会导致链表有点多了,导致查找效率低。
- 所以既兼顾数组利用率又考虑链表不要太多,经过大量测试0.75是最佳方案。
- threshold 计算公式:
threshold = capacity(数组长度默认16) * loadFactor(加载因子默认0.75)
- 这个值是当前已占用数组长度的最大值。当size>=threshold的时候,那么就要考虑对数组的resize(扩容)。 扩容后的 新HashMap 容量是之前容量的两倍。
修改加载因子
- 同时在 HashMap 的构造器中可以定制 loadFactor,但是最好不要修改
HashMap(int initialCapacity, float loadFactor)
HashMap的红黑树转换边界值详解
边界转换相关属性
- 转换边界值1:当链表长度超过转换边界值 8,就会转红黑树(JDK1.8),还有一个前提条件是数组长度大于等于64
// 当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
- 转换边界值2:当 Map 里面的数量超过这个值时,表中的桶才能形成树形化,这个值不能小于
4 * TREEIFY_THRESHOLD (8)
// 桶中结构转化为红黑树对应的数组长度最小的值
static final int MIN_TREEIFY_CAPACITY = 64;
- 桶(bucket):所谓的桶,指的是一个数组索引位中的所有元素。
- 降级转换边界值:当链表的长度值小于 6,则会从红黑树转为链表
static final int UNTREEIFY_THRESHOLD = 6;
为什么Map桶中节点个数超过8才转为红黑树
HashMap的红黑树数据结构几乎不会被用到,本质上还是一个数组+链表!
- 阈值8定义在HashMap中,针对这个成员变量,在源码的注释中只说明了8是bin(bin就是bucket(桶))从链表转成树的阈值,但是并没有说明为什么是8,在HashMap官方注释说明中:
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 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
- 翻译一下就是:
因为树节点的大小大约是普通节点的两倍,所以我们只在桶包含足够的节点时才使用树节点(参见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分布(泊松分布),是一种统计与概率学里常见到的离散[概率分布]。泊松分布的概率函数为:
P(X=k) = (λk/k!)*e-λ,k = 0, 1, …
泊松分布的参数λ是单位时间(或单位面积)内随机事件的平均发生次数。 泊松分布适合于描述单位时间内随机事件发生的次数。
HashMap的treeifyBin()方法详解-链表转红黑树
转换相关属性
- 转换边界值1:当链表长度超过转换边界值 8,就会转红黑树(JDK1.8),还有一个前提条件是数组长度大于等于64
// 当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
- 转换边界值2:当 Map 里面的数量超过这个值时,表中的桶才能形成树形化,这个值不能小于
4 * TREEIFY_THRESHOLD (8)
// 桶中结构转化为红黑树对应的数组长度最小的值
static final int MIN_TREEIFY_CAPACITY = 64;
- 桶(bucket):所谓的桶,指的是一个数组索引位中的所有元素。
- 降级转换边界值:当链表的长度值小于 6,则会从红黑树转为链表
static final int UNTREEIFY_THRESHOLD = 6;
转换treeifyBin()方法源码分析
- 节点添加完成之后,判断此时节点个数是否大于 TREEIFY_THRESHOLD 临界值 8,如果大于则将链表转换为红黑树,转换红黑树的方法是 treeifyBin,整体代码如下:
// 对 HashMap 集合中桶的链表转为红黑树,如果集合中数组太短,会对数组扩容
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
/*
如果当前数组为空或者数组的长度小于进行树形化的阈值(MIN_TREEIFY_CAPACITY = 64)就去扩容,而不是将节点变为红黑树。
目的: 如果数组很小,那么转换为红黑树,在进行遍历时效率要低一些。这时进行扩容,会重新计算哈希值,链表长度可能就会变短了,数据就会放到数组中,这样相对来说效率要高一些。
*/
// 判断数组是否满足转红黑树最小长度限制(原因:数组太短,转为红黑树不仅没有提高效率,反而降低了。)
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
// 如果不满足最小长度64,则对数组进行扩容
resize();
// 判断数组中桶内非空,并且获取桶中第一个节点
else if ((e = tab[index = (n - 1) & hash]) != null) {
// 下面开始链表转红黑树,hd:红黑树的头节点,tl:红黑树的尾节点
TreeNode<K,V> hd = null, tl = null;
do {
// 新创建一个树的节点,内容和当前链表节点 e 一致。
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
// 将新创建的 p 节点赋值给红黑树的头节点
hd = p;
else {
p.prev = tl; // 将上一个节点p赋值给现在的p的前一个节点
tl.next = p; // 将现在节点p作为树的尾节点的下一个节点
}
tl = p;
// e = e.next 将当前节点的下一个节点赋值给 e,如果下一个节点不等于 null,则回到上面继续取出链表中节点转换为红黑树
} while ((e = e.next) != null);
// 将桶中的第一个元素,替换为红黑树节点
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
- 小结:HashMap集合中,链表节点红黑树节点的临界值是 8,前提是集合中数组的最大容量是 64 以上,否则会对数组进行扩容。
HashMap的扩容机制
- 在不断的添加数据的过程中,会涉及到扩容问题,当超出临界值且要存放的的位置为空时进行扩容。默认扩容方式:扩容为原理容量的 2 倍,并将原有的数据复制过来。
扩容相关属性
- 扩容计数器:用来记录 HashMap 的修改次数
// 每次扩容和更改map结构的计数器
transient int modCount;
- 转换边界值1:当链表长度超过转换边界值 8,就会转红黑树(JDK1.8),还有一个前提条件是数组长度大于等于64
// 当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
- 转换边界值2:当 Map 里面的数量超过这个值时,表中的桶才能形成树形化,这个值不能小于
4 * TREEIFY_THRESHOLD (8)
// 桶中结构转化为红黑树对应的数组长度最小的值
static final int MIN_TREEIFY_CAPACITY = 64;
- 桶(bucket):所谓的桶,指的是一个数组索引位中的所有元素。
- 哈希表的加载因子(重点):默认值是 0.75,决定了扩容的条件
final float loadFactor;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
- 集合最大容量:10 7374 1824【10亿】
static final int MAXIMUM_CAPACITY = 1 << 30;
- 扩容的临界值:计算方式为 容量 乘以 加载因子
int threshold;
扩容机制
- 了解 HashMap 的扩容机制,需要搞懂下面的两个问题:
什么时候需要扩容?
- 主要在两种情况下进行扩容:
- 当 HashMap 集合中,实际存储元素个数超过临界值(threshold)时,会进行扩容,默认初始化为临界值是12。
- 当 HashMap 集合中,单个桶的链表长度达到了 8,并且数组长度还没有到达 64,会进行扩容。
HashMap 的扩容做了哪些事?
- 将原数组中桶内的节点,均匀分散在了新的数组的桶中。
- HashMap 扩容时,分散使用的 rehash 方式非常巧妙,并没有进行 hash 函数调用。由于每次扩容都是翻倍,与原来计算的
(n - 1) & hash
的结果相比,只是多了一个 bit 位。所以节点要么就在原来的位置,要么就被分配到“原位置+旧容量”的位置。 - 怎么理解?下面举个例子,例如从 16 扩容为 32,具体变化如下:
"娜扎"的哈希值740274,然后根据公式 : (n-1) & hash
740274 & (16-1) = 2
String key = "天乐"; // hashCode值 727623
String key = "德华"; // hashCode值 780919
计算hash
727623 & (16-1) = 7
780919 & (16-1) = 7
扩容之后,巧妙的计算 rehash【更高效】
727623 & (32-1) = 7
780919 & (32-1) = 7 + 16 = 23
结论:原位置+旧容量
- 画图说明:下面是 16 扩充为 32 的resize示意图,将原数组中桶内的节点,均匀分散在了新的数组的桶中。
- 注意 : 扩容必定伴随rehash操作,遍历hash表中所有元素。这种操作比较耗时!在编程中,超大HashMap要尽量避免resize, 避免的方法之一就是初始化固定HashMap大小!
扩容方法resize()源码解读
final Node<K,V>[] resize() {
// 得到当前数组
Node<K,V>[] oldTab = table;
// 若当前数组等于null,则长度返回0;否则返回当前数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length; // 原始数组容量
// 当前阈值点 默认是 12 (16*0.75)
int oldThr = threshold; // 初始阈值
// 新的数组容量newCap, 新的阈值点 newThr
int newCap, newThr = 0;
// 如果老的数组长度大于 0
if (oldCap > 0) {
// 超过最大值就不再扩容了,就只好碰撞了
if (oldCap >= MAXIMUM_CAPACITY) {
// 修改阈值为 int 的最大值
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 没超过最大值,就扩充为原来的 2 倍
// (newCap = oldCap << 1) < MAXIMUM_CAPACITY: 扩大到 2 倍之后容量要小于最大容量
// oldCap >= DEFAULT_INITIAL_CAPACITY: 原来数组长度大于数组初始化长度 16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 阈值扩大一倍
newThr = oldThr << 1; // double threshold
}
// 如果原始数组为空,且原始的扩容阈值大于 0,原始阈值 赋值给 新的数组容量
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
// 如果原始数组为空,扩容阈值为0,则设置为默认值
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果新的扩容阈值为0,则重新计算新的 resize 最大上限
if (newThr == 0) {
// 计算公式:新数组容量 * 加载因子
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 赋值新的扩容阈值
threshold = newThr;
// 创建新的哈希表,创建新的数组,容量是之前的2倍。newCap是新的数组长度
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 过滤,排除旧数组为空的情况
if (oldTab != null) {
// 遍历旧的哈希表的每个桶,重新计算桶里元素的新位置,把每个bucket都移动到新的buckets中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
// 将原来的数据赋值为null,便于 GC 回收
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 { // preserve order
// 采用链表处理冲突
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 通过上述讲解的原理来计算节点的新位置
do {
// 原索引
next = e.next;
// 这里来判断如果等于 true,e这个节点在 resize 之后不需要移动位置
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引 + oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到 bucket 里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
HashMap容量初始化最佳策略
HashMap的初始化问题描述
- 《阿里巴巴Java开发手册》中建议初始化HashMap的容量。
为什么要建议初始化 HashMap 容量?
- 防止自动扩容,影响效率。
- 当然阿里的建议是有理论支撑的。我们上面介绍过HashMap的扩容机制,就是当达到扩容条件时会进行扩容。HashMap的扩容条件就是当HashMap中的元素个数(size)超过临界值(threshold)时就会自动扩容。在HashMap中,threshold = loadFactor * capacity。所以,如果我们没有设置初始容量大小,随着元素的不断增加,HashMap会有可能发生多次扩容,而HashMap中的扩容机制决定了每次扩容都需要重建hash表,是非常影响性能的。
- 设置初始化容量,数值不同性能也不一样!当已知HashMap中,将存放的键值对个数时,容量设置成多少合适呢?是直接设置键值个数吗?并不是,我接下来看
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面试题
HashMap中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
- 还可以采用:取余法、平方取中法、伪随机数法。
- 取余方式比较常见,但是与位运算相比,效率较低。
当两个对象的hashCode相等时会怎么样
- 会产生哈希碰撞
- 若 key 值相同,则替换掉旧的 value;否则连接到链表的后面,链表长度超过阈值 8,数组长度大于 64 则自动转为红黑树存储。
何时发生哈希碰撞和什么是哈希碰撞,如何解决哈希碰撞
- 只要两个元素的 key 计算的哈希码值相同就会发生哈希碰撞
- JDK 8 之前使用链表来解决哈希碰撞
- JDK 8 及以后使用链表+红黑树解决哈希碰撞
hashCode和equals方法有何重要性
非常重要
- HashMap 使用 key 对象的 hashCode() 和 equals(Object obj) 方法去决定键值对的存储位置。当从 HashMap 中获取值时,也会被用到。
- 如果两个方法没有被正确的实现,两个不同的 key 也许会产生相同的 hashCode 和 equals 的结果,这就会导致元素存储位置的混乱,降低 HashMap 的性能。
- **hashCode() 和 equals(Object obj) 保证了集合元素的唯一性。**所有不允许存储重复数据的集合类,都使用这两个方法,所以正确实现它们非常重要。
- 如果 o1.equals(o2),那么 o1.hashCode() == o2.hashCode() 总是 true。
- 如果 o1.hashCode() == o2.hashCode(),并不意味着 o1.equals(o2) 会为true。
HashMap默认容量是多少
- 默认容量都是 16,加载因子都是 0.75。
- 当 HashMap 填充了 75% 就会扩容,最小扩容阈值是:16 * 0.75 = 12。
- 扩容一般为原容量的 2 倍。
HashMap的长度为什么是2的n次幂
- 为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀分散在数组中,这个问题的关键在于存储数组索引位的计算【hash & (length - 1)】。
- 这个算法可以如何设计呢?
- 一般我们可能会想到取余(%),但是效率太低。
- 在计算机运算中,位运算效率高于取余操作,而位运算能够做到与取余相同的效果的前提是,数组长度是 2 的 n 次幂。这就解释了 HashMap 的长度为什么是 2 的次幂。
加载因子值的大小对HashMap有什么影响
- 加载因子的大小决定了 HashMap 的数据密度。
- 加载因子越大,数据密度越大,发生碰撞的概率越高,数组桶中链表越长,查询或插入时的比较次数增多,从而导致性能下降。
- 加载因子越小,数据密度越小,发生碰撞的概率越低,数组桶中链表越短,查询或插入时的比较次数减少,从而性能更高。
- 加载因子越小,越容易触发扩容,会影响性能;
- 加载因子越小,存储数据量越少,会浪费内存空间。
- 总之,鱼和熊掌不可兼得!按照其他语言的参考及研究经验,会考虑将加载因子设置为0.7到0.75