一.HashMap的基本结构
HashMap是基于哈希表(Hash Table)实现的数据结构。它通过哈希函数将键映射到数组中的位置,以实现高效的键值对存储和查找。以下是HashMap的主要组成部分:
主要组成部分
- 数组(Node<K,V>[] table):
- 存储键值对的数组,每个元素是一个链表或红黑树的头节点。
- 数组的每个位置被称为“桶”(bucket),每个桶存储一个或多个键值对。
- 链表(Node<K,V>):
- 当多个键的哈希值相同时,这些键值对会存储在同一个链表中。
- 链表用于解决哈希冲突,即不同的键映射到相同的数组位置。
- 红黑树:
- 自JDK 1.8开始,当链表长度超过一定阈值(默认是8)时,链表会转换为红黑树,以提高查询效率。
- 当链表长度小于一定值(默认是6)时,红黑树会转换回链表。
Hashmap是如何创建这些部分的?
HashMap 属于 Map 接口的一种实现,其基本实现原理是拉链法。
其内部主要包含了两个组成部分:数组table 和 桶(链表)bucket。
当对 HashMap 放入一个<key,value> 键值对时,会先对 key 调用 hashCode() 方法计算出一个哈希值,再通过一种散列函数将哈希值映射到 table 数组中的一个位置 index,随后将<key,value> 添加到 index 处的 bucket 中。
1. 数组(Node<K,V>[] table)
HashMap的主干是一个数组,数组中的每个元素被称为“桶”(bucket),用于存储键值对。数组的每个位置可以存储一个链表或红黑树的头节点。
数组的创建
数组是在HashMap的构造函数中初始化的。
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
// 默认初始容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 存储键值对的数组
transient Node<K,V>[] table;
// HashMap中的键值对数量
transient int size;
// 构造函数
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;
this.threshold = tableSizeFor(initialCapacity);
}
// 默认构造函数
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // 加载因子
}
}
我们对部分源代码进行讲解
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
HashMap
类继承自AbstractMap
并实现了Map
接口,同时也实现了Cloneable
和Serializable
接口。这意味着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;
this.threshold = tableSizeFor(initialCapacity);
}
这个构造函数允许用户指定初始容量和加载因子。
- initialCapacity: 初始容量。如果小于0,会抛出
IllegalArgumentException
。如果大于最大容量,则会被设置为最大容量。 - loadFactor: 加载因子。如果小于等于0或不是一个数字,会抛出
IllegalArgumentException
。
tableSizeFor这个方法用于计算大于等于给定容量的最小2的幂。它通过一系列的位运算将容量调整为接近的2的幂。 这是为了确保哈希表的容量始终是2的幂,从而优化哈希分布和性能。
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;
}
键值对的插入
当创建数组完成后,我们向HashMap
中插入一个键值对时,会触发一系列操作,包括计算哈希值、找到合适的数组索引、处理冲突等。
1. 计算哈希值
首先,HashMap
会计算键的哈希值。HashMap
使用一个改进的哈希函数来减少冲突:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这个哈希函数通过将键的哈希码高位和低位混合,减少了哈希冲突的概率。
这是如何计算的呢?
- 获取原始哈希码:
h = key.hashCode();
- 右移16位:
这个操作将哈希码h >>> 16
h
的高16位移到低16位,使用的是无符号右移操作符>>>
,这意味着高位用0填充。
假设我们有一个键对象,其原始哈希码为h = 0b10101010101010101010101010101010
(一个32位的二进制数),那么计算过程如下:
h = 0b10101010101010101010101010101010
h >>> 16 = 0b00000000000000001010101010101010
h ^ (h >>> 16) = 0b10101010101010100000000000000000
通过这种方式,原始哈希码的高位和低位都参与了最终哈希值的计算。这种混合操作有助于减少哈希冲突,因为它使得哈希值的分布更加均匀,避免了由于哈希码的某些位不变而导致的冲突。
2. 定位数组索引
计算完哈希值后,HashMap
使用哈希值来确定键值对在数组中的位置。具体来说,它通过对数组长度取模来确定索引:
int index = (n - 1) & hash;
这里,n
是数组的长度,hash
是键的哈希值。
3. 处理冲突
如果两个不同的键计算出的数组索引相同,就会发生哈希冲突。HashMap
使用链地址法(即链表)来处理冲突。在Java 8及以后,当链表长度超过一定阈值(默认是8)时,会将链表转换为红黑树,以提高查找效率。
4. 插入节点
找到合适的数组索引后,HashMap
会将键值对插入到数组的对应位置。如果该位置已经有节点(即发生冲突),则会将新节点添加到链表或红黑树中。
5. 扩容和再哈希
当HashMap
中的元素数量超过一定阈值(由加载因子决定)时,会触发扩容操作。扩容时,HashMap
会创建一个更大的数组,并将所有现有的键值对重新哈希到新数组中。这是一个比较耗时的操作,但它保证了哈希表的性能。