HashMap
作者:CloudMissing
HashMap
写在前面
HashMap 是 java.util 包下的基于 <K,V> 键值对存储的数据结构。HashMap 是集合框架中的重要角色之一,实现了 Map 接口。
HashMap 对大数据量的元素插入有最优的解决方案,由 bins 数组 -> 链表 <=> 平衡树
但由于 LinkedHashMap 子类的存在,HashMap在插入、移除或者连接时,都会提供对应的钩子函数给 LinkedHashMap。
HashMap 通常被当作 分桶哈希表,使用数组来存放这些桶。在桶过大时,桶的内部对象会由 Node 演变为 TreeNode 排序树,类似于 TreeMap 的结构。TreeNodes 节点也会跟 Nodes 节点一样遍历,同时在数量过多时,提供了更快的便利。当桶变小时,还会由 TreeNode 变为 Node。
因为 HashMap 是线程不安全的,所以迭代器返回的都是快速-失败的:如果在创建迭代器后的任何时候对映射进行了结构修改,除了通过迭代器自己的 remove 方法之外的任何方式,迭代器都会抛出 ConcurrentModificationException。因此,面对并发修改,迭代器快速而干净地失败,而不是在未来不确定的时间冒出任意的、不确定的行为。
与 HashTable 的比较
HashMap 大致相当于 HashTable,但是二者也是有一些区别的。
是否允许出现 NULL 值。HashMap 是允许 key 和 value 出现 NULL 值,HashTable 是不允许 key 和 value 出现 NULL 值
tip :因为 HashTable 实现了 Dictionary 接口,字典的 key 和 value 必须不为 null
是否线程安全。HashMap 是线程不安全的,非同步方法,当然可以使用 Collections.synchronizedMap(new HashMap(…)) 去实现同步。HashTable 是线程安全的,在 PUT 方法时,添加了 Synchonized 关键字
扩容大小不同。HashMap 每次 resize 方法执行之后,旧的容量 左移一位 (newThr = oldThr << 1)就是新的容量。HashTable 每次 rehash 之后,旧的容量 左移一位加一(newCapacity = (oldCapacity << 1) + 1)为新的容量
初始化容量不同。HashMap 的初始化大小为 16,HashTable 的初始化大小为 11。二者的初始化负载因子均为 0.75
静态属性
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 用于调用含容量的构造参数时,判断入参是否大于最大容量
* 入参必须是小于等于 2^30 的 2 的次幂
* 为什么不是 1 << 31 呢?因为最开始的一位是代表符号位,不参与计算
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 用于对树节点的阈值,而非数组。当一个桶内链表的长度达到 8 时,再次插入一个相同散列的对象就会将桶结构演变为树结构。
* 如果树的值小于等于 6 ,那么就会由树结构变换为桶结构(在每次的 remove 方法时),如果太小就执行非树化( TreeNode.untreeify() )方法
* 为什么是 8 呢?
* 首先一个 treeNode 的大小就普通 Node 节点的2倍,在 resize 的过程中,会重新散列结果。
* 通过 p.hash & oldCap == 0 来确定扩容两倍之后,是在高位还是地位。因为扩容 2 倍,相当于左移一位
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 只会在 resize 的时候去使用,在 split 方法中,会去判断,如果当前的树节点小于 6,就会执行去树化方法,而非拆分为上树结构和下树结构
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 哈希表的最小树化容量。
* 如果一个桶的最大长度达到了 TREEIFY_THRESHOLD,但是数组的长度小于 64,那么就会去扩容,然后重新散列,而非树化。
*/
static final int MIN_TREEIFY_CAPACITY = 64;
为什么最大化树阈值为8?
首先一个 treeNode 的大小就普通 Node 节点的2倍,在 resize 的过程中,会重新散列结果。
这个 key 值不能过大,链表的长度过大,也会影响查询速度。这个 key 也不能过小,过小的话,树化所带来的内存消耗也是不小的。假设 m 最大化树阈值为 k,也就是同一个 key 哈希冲突的最大值。问题就可以转换为简单的泊松分布,经过计算,是符合参数位 0.5 的泊松分布。
经过推算,当 k > 8 时,概率就无限接近于 0 ,可以说是忽略不计。
当选择 8 时,既满足了最大化的链表长度,又找到了最小化的扩容或树化长度。(冲突导致地链表长度为 9 的概率几乎为0,如果有了就进行扩容,这样的代价是最小的)。
为什么由树转为链表的阈值为6?
/**
* If the current tree appears to have too few nodes, the bin is converted back to a plain bin. (The test triggers somewhere between * 2 and 6 nodes, depending on tree structure).
**/
测试阶段的最好效果是在 2 和 6 之间的某处,所以小于6时,就可以直接转为链表
为什么最小化树桶容量为64?
为了更好地解决扩容和树化的冲突,该值必须大于 4 * TREEIFY_THRESHOLD = 32, HashMap 的整个容量计算必须是二的倍数,所以最近的值为 64
构造方法
// 如果
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(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
HashMap 在初始化的时候一定要给初始化容量,这样可以减少扩容带来的影响,因为每次扩容就要重新散列,如果数据太多的话,那么重新散列所带来的时间耗费也是巨大的。之所以在强调一下只是想介绍下构造方法中的 tableSizeFor 方法。
// 返回给定容量的最近的 2 的次幂数
static final int tableSizeFor(int cap) {
int n = cap - 1;
/**
* 将 n 与它的右移 1 位、2 位、 4 位、8 位、16 位后的结果取余并加 1 返回
* 举个例子:加入初始化容量为 5(16 进制数据为 100 )
* 1、n | n >>> 1 = 100 | 010 = 110 = 6
* 2、n | n >>> 2 = 110 | 001 = 111 = 7
* 3、n | n >>> 4 = 111 | 000 = 111 = 7
* 4、n | n >>> 8 = 111 | 000 = 111 = 7
* 5、结果同上
*/
n |= n >>> 1; // n = n | n >>> 1; 将 n 与 n >>> 1 (n 右移 1 位)
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
// 那么 n 的结果最后就为 7 + 1 = 8。也就是离 4 最近的 2 次幂
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
所以在初始化容量的时候,相信也有一定的策略,如果数据不会超过 8 ,那么初始化给 4 < n <= 8 即可。其他结果依此类推。
Node 节点
// 实现于 Map 的 Entry 接口
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() {
return key; }
public final V getValue() {
return value; }
public final String toString() {
return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals