HashMap
AbstractMap
- AbstractMap 是 Map 接口的的实现类之一,也是 HashMap, TreeMap, ConcurrentHashMap 等类的父类。
- Abstract 默认是不支持添加操作的,实现类需要重写 put() 方法
AbstractMap 中的内部类
- SimpleImmutableEntry, 表示一个不可变的键值对
- SimpleEntry, 表示可变的键值对
HashMap 概述
HashMap 是一个采用哈希表实现的键值对集合,继承自 AbstractMap,实现了 Map 接口。
HashMap 的特殊存储结构使得在获取指定元素前需要经过哈希运算,得到目标元素在哈希表中的位置,然后再进行少量比较即可得到元素,这使得 HashMap 的查找效率极高。
当发生 哈希冲突(碰撞) 的时候,HashMap 采用 拉链法 进行解决,因此 HashMap 的底层实现是 数组+链表
HashMap 特点
- 底层实现是 链表数组,JDK 8 后又加了 红黑树
- 实现了 Map 全部的方法
- key 用 Set 存放,所以想做到 key 不允许重复,key 对应的类需要重写 hashCode 和 equals 方法
- 允许空键和空值(但空键只有一个,且放在第一位( Table[0] ),下面会介绍)
- 元素是无序的,而且顺序会不定时改变
- 插入、获取的时间复杂度基本是 O(1)(前提是有适当的哈希函数,让元素分布在均匀的位置)
- 遍历整个 Map 需要的时间与 桶(数组) 的长度成正比(因此初始化时 HashMap 的容量不宜太大)
- 两个关键因子:初始容量、加载因子
除了不允许 null 并且同步,Hashtable 几乎和他一样
HashMap 的 13 个成员变量
- 默认初始容量:16,必须是 2 的整数次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
- 默认加载因子的大小:0.75,可不是随便的,结合时间和空间效率考虑得到的
static final float DEFAULT_LOAD_FACTOR = 0.75f;
- 最大容量: 2^ 30 次方
static final int MAXIMUM_CAPACITY = 1 << 30;
- 哈希表的加载因子
final float loadFactor;
- 当前 HashMap 修改的次数,这个变量用来保证 fail-fast 机制
transient int modCount;
- 阈值,下次需要扩容时的值,等于 容量*加载因子
int threshold;
- 树形阈值:JDK 1.8 新增的,当使用 树 而不是列表来作为桶时使用。必须必 2 大
static final int TREEIFY_THRESHOLD = 8;
- 非树形阈值:也是 1.8 新增的,扩容时分裂一个树形桶的阈值,要比 TREEIFY_THRESHOLD 小
static final int UNTREEIFY_THRESHOLD = 6;
- 树形最小容量:桶可能是树的哈希表的最小容量。至少是 TREEIFY_THRESHOLD 的 4 倍,这样能避免扩容时的冲突
static final int MIN_TREEIFY_CAPACITY = 64;
- 缓存的 键值对集合(另外两个视图:keySet 和 values 是在 AbstractMap 中声明的)
transient Set<Map.Entry<K,V>> entrySet;
- 哈希表中的链表数组
transient Node<K,V>[] table;
- 键值对的数量
final float loadFactor;
HashMap 的初始容量和加载因子
由于 HashMap 扩容开销很大(需要创建新数组、重新哈希、分配等等),因此与扩容相关的两个因素
- 容量:数组的数量
- 加载因子:决定了 HashMap 中的元素占有多少比例时扩容
以上两个因素成为了 HashMap 最重要的部分之一,它们决定了 HashMap 什么时候扩容。
HashMap 的默认加载因子为 0.75,这是在时间、空间两方面均衡考虑下的结果:
- 加载因子太大的话发生冲突的可能就会大,查找的效率反而变低
- 太小的话频繁 rehash,导致性能降低
HashMap 中的链表节点
transient Node<K, V>[] table;
Node 实现
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(value, e.getValue()))
return true;
}
return false;
}
}
HashMap 中的添加方法: put()
//添加指定的键值对到 Map 中,如果已经存在,就替换
public V put(K key, V value) {
//先调用 hash() 方法计算位置
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;
//如果当前 哈希表内容为空,新建,n 指向最后一个桶的位置,tab 为哈希表另一个引用
//resize() 后续介绍
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//如果要插入的位置没有元素,新建个节点并放进去
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//如果要插入的桶已经有元素,替换
// e 指向被替换的元素
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//p 指向要插入的桶第一个 元素的位置,如果 p 的哈希值、键、值和要添加的一样,就停止找,e 指向 p
e = p;
else if (p instanceof TreeNode)
//如果不一样,而且当前采用的还是 JDK 8 以后的树形节点,调用 putTreeVal 插入
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//否则还是从传统的链表数组查找、替换
//遍历这个桶所有的元素
for (int binCount = 0; ; ++binCount) {
//没有更多了,就把要添加的元素插到后面得了
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;
}
//如果找到要替换的节点,就停止,此时 e 已经指向要被替换的节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//存在要替换的节点
if (e != null) {
V oldValue = e.value;
//替换,返回
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//如果超出阈值,就得扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
插入逻辑
- 先调用 hash() 方法计算哈希值
- 然后调用 putVal() 方法中根据哈希值进行相关操作
- 如果当前哈希表内容为空,新建一个哈希表
- 如果要插入的桶中没有元素,新建个节点并放进去
- 否则从桶中第一个元素开始查找哈希值对应位置
- 如果桶中第一个元素的哈希值和要添加的一样,替换,结束查找
- 如果第一个元素不一样,而且当前采用的还是 JDK 8 以后的树形节点,调用 putTreeVal() 进行插入
- 否则还是从传统的链表数组中查找、替换,结束查找
- 当这个桶内链表个数大于等于 8,就要调用 treeifyBin() 方法进行树形化
- 最后检查是否需要扩容
关键方法
- hash(): 计算对应的位置
- resize(): 扩容
- putTreeVal(): 树形节点的插入
- treeifyBin(): 树形化容器
HashMap 中的哈希函数: hash()
HashMap 中通过将传入键的 hashCode 进行无符号右移 16 位,然后进行按位异或,得到这个键的哈希值。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
HashMap 中的初始化/扩容方法: resize()
每次添加时会比较当前元素个数和阈值:
if (++size > threshold)
resize();
Tips
- 新初始化哈希表时,容量为默认容量,阈值为: 容量*加载因子
- 已有哈希表扩容时,容量、阈值均翻倍
- 如果之前这个桶的节点类型是树,需要把新哈希表里当前桶也变成树形结构
- 复制给新哈希表中需要重新索引(rehash),这里采用的计算方法是
- e.hash & (newCap - 1),等价于 e.hash % newCap
HashMap 中的获取方法: get()
- 先计算哈希值
- 然后再用
(n - 1) & hash
计算出桶的位置 - 在桶里的链表进行遍历查找
时间复杂度一般跟链表长度有关,因此哈希算法越好,元素分布越均匀,get() 方法就越快,不然遍历一条长链表,太慢了。
不过在 JDK 1.8 以后 HashMap 新增了红黑树节点,优化这种极端情况下的性能问题。
总结
- HashMap 有个缺点:不是同步的。
当多线程并发访问一个哈希表时,需要在外部进行同步操作,否则会引发数据不同步问题。
你可以选择加锁,也可以考虑用 Collections.synchronizedMap 包一层,变成个线程安全的 Map
// 最好在初始化时就这么做。
Map m = Collections.synchronizedMap(new HashMap(...));
- 红黑树优化
当 HashMap 中有大量的元素都存放到同一个桶中时,这时候哈希表里只有一个桶,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),完全失去了它的优势。
针对这种情况,JDK 1.8 中引用了 红黑树(时间复杂度为 O(logn)) 优化这个问题。
- HashMap 中 equals() 和 hashCode() 有什么作用?
HashMap 的添加、获取时需要通过 key 的 hashCode() 进行 hash(),然后计算下标 ( n-1 & hash),从而获得要找的同的位置。
当发生冲突(碰撞)时,利用 key.equals() 方法去链表或树中去查找对应的节点。
- 你知道 hash 的实现吗?为什么要这样实现?
在 JDK 1.8 的实现中,是通过 hashCode() 的高16位异或低16位实现的:
(h = k.hashCode()) ^ (h >>> 16)
主要是从速度、功效、质量 来考虑的,这么做可以在桶的 n 比较小的时候,保证高低 bit 都参与到 hash 的计算中,同时位运算不会有太大的开销。