HashMap
在了解HashMap
之前, 首先谈一谈经常和它一起出现的HashTable
.Hashtable
是早期 Java 类库提供的一个哈希表实现,本身是同步的,不支持 null 键和值,由于同步导致的性能开销,所以已经很少被推荐使用. TreeMap
是基于红黑树实现的Map类, 其put
,get
操作的时间复杂度在O(logN), 但是它的键值存储是有序的, 顺序与compareTo
或Comparator
有关.
下面是Map家族的结构概览
在以上的类中, 如果需要常数时间复杂度的增删改查, 并且对顺序没有要求, 最常用的就是HashMap
类. 哈希是通过哈希函数, 将任意长度的输入变成固定长度的输出, HashMap
正是利用这一性质, 将键值对的特征(hash)转化为固定长度的整型输出, 将输出作为数组下标, 将键值对存储到数组中, 由于数组的随机访问的性能, 能够保证其常数时间的读写.
用到hash的地方, 必须要考虑的问题就是哈希冲突的解决, 哈希冲突的解决思路有以下几种:
- 开放定址法: 当发生冲突时, 采用线性探测或二次探测等方法, 在初始位置的周围寻找一个空的位置放置. 封闭哈希, 元素数量不能超过桶数量, 高负载下性能下降严重
- 再哈希法: 当第一个hash函数产生的值发生碰撞, 用第二个hash函数再产生一个值
- 链地址法: 也就是拉链法, 每个地址相当于一个桶, 当发生冲突时, 还是将键值对放到桶中, 同一个桶的键值对构成链表. 适用频繁插入删除的情况
- 溢出区法: 专门建立一个溢出区, 将发生碰撞的元素用链表存储起来. 和拉链法的思想相似, 但是溢出集中时, 性能不如拉链法.
HashMap
采用的方法是拉链法, 当同时当元素数/桶数达到一定比例(负载因子)时, 进行扩容. 接下来分析源码. 以下分析基于JDK1.8
HashMap#常量
首先是HashMap中有几个重要的常量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16 HashMap的默认初始容量
static final int MAXIMUM_CAPACITY = 1 << 30; // HashMap的最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 推荐的负载因子, 通常情况下, 不用修改
static final int TREEIFY_THRESHOLD = 8; // 单个桶中元素数量, 达到树化门限则会从链表调整为红黑树, 防止哈希碰撞拒绝服务攻击
static final int UNTREEIFY_THRESHOLD = 6; // 单个桶中数量小于门限, 则会退化成链表
static final int MIN_TREEIFY_CAPACITY = 64; // 最小的树化容量, 当Map的容量小于该值时, 如果一个桶的元素过多, 会首先采用扩容方法尝试缓解. 超过该值时, 如果达到TREEIFY-THRESH则会树化
HashMap#构造器
从构造器开始看, HashMap
提供了以下几种构造器
public HashMap(int initialCapacity, float loadFactor)
public HashMap(int initialCapacity)
public HashMap()
public HashMap(Map<? extends K, ? extends V> m)
后面的几种构造函数, 无非就是将一些参数设为了默认值, 我们直接看第一个构造器
public HashMap(int initialCapacity, float loadFactor) {
// 判断initialCapacity和loadFactor的合法性, 大于0, 不为NaN等
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
这个构造器总共就做了两个参数的赋值, 没有其他实际的创建结构, 所以这里其实采用了懒惰加载的方法, 在真正调用的时候,才去初始化内部的结构.
HashMap#tableSize()
然后这里的tableSizeFor()
方法, 其目的是将输入的容量, 转换为大于等于输入容量的2的幂.为什么HashMap
的容量总要保持2的幂, 我们之后再讨论, 但是这里好像还是不对.
返回值为什么是赋值给的threshold
, 这个参数不是扩容的门限值么?
其实这个参数的解释是: 如果table数组还没有被创建, 这个参数等于数组的初始大小, 如果为0则采用默认初始大小.
接着我们看一看tableSizeFor
是怎么转成2的幂的
// 1.8的实现
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;
}
// 早期版本的实现
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
从早期版本实现, 我们很容易看懂, 就是找一个最小的2的幂, 大于等于initalCapacity
嘛, 但是新版的好像就有点懵了, 这是在干什么, 我们不妨举个例子.
- 假设我们的输入是65, 二进制0100 0001, 减1得到0100 0000
- 注意观察最高位的1, 当第一次右移, 然后做或运算, (0100 0000 |0010 0000) = 0110 0000
- 这样最高位的1就变成了两个1, 接着做右移 2位, 然后或运算, (0110 0000 | 0001 1000) = (0111 1000)
- 现在从最高位往右就有至少4个1了, 再用它们往右移4位覆盖低位, 最终得到的从最高位开始往右都是1的结果, 0111 1111
- 最后加1, 得到了1000 0000, 128
这样相比原来循环1位一位往上移, 又快了一些, 如果还没有看懂, 可以看下这个参考资料, 里面带了一些示意图.
HashMap#put
接下来看下具体的初始化工作. 初始化发生在第一次put
, 这个函数只有一行, 但是有个细节, 这里用hash
函数处理了key, 然后再进行实际的putVal
操作.
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
HashMap#hash
我一开始以为, hash函数只是返回了key.hashCode()
的值, 然后发现并不是.
// 1.8
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 以往版本
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
在 JDK1.8 的实现中,将 hashCode 的高 16 位与 hashCode 进行异或运算,主要是为了在 table 的 length 较小的时候,让高位也参与运算,并且不会有太大的开销。为什么比以往版本的hash有所简化, 我所看到的资料主张是因为加入了树化后, 碰撞情况的查找成本小了, 所以hash的计算可以简化.
HashMap#putVal
这个函数是整个类中, 逻辑最密集的之一, 所以相当精彩, 函数中要做的包括懒惰加载-初始化, 检查是否key已经存在, 检查是否要树化等.
/**
* Implements Map.put and related methods
*
* @param hash 经过hash函数得到的hash值
* @param key the key
* @param value the value to put
* @param onlyIfAbsent 为true时, 如果已经存在就不修改
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0) // 如果首次调用, 进行初始化
n = (tab = resize()).length; // resize函数进行初始化
if ((p = tab[i = (n - 1) & hash]) == null) // (n - 1) & hash相当于hash对n取模, 详细看后面的解释.
// 如果找到的桶为空, 则key肯定不存在
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// p是桶的头节点 e是p的后一个节点, 两个指针一前一后遍历链表
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))) // 如果头结点就是要找的key的node
e = p;
else if (p instanceof TreeNode) // 如果头节点是个树节点, 则调用树插入
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 如果有key, e=node 否则e = null
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) { // 如果整个链没有找到, e= null
p.next = newNode(hash, key, value, null); // 创建一个新节点
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash); // 如果达到树化条件, 则树化或扩容
break;
}
if (e.hash == hash && // 找到了key
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e; // 和e = p.next 构成链表往下走
}
}
if (e != null) { // existing mapping for key
// 找到key所在节点, 修改value并返回旧值
}
}
++modCount;
if (++size > threshold) // 超过门限, 扩容
resize();
afterNodeInsertion(evict);
return null;
}
这里留个悬念, 为什么在创建新节点的时候, 调用的是newNode()
方法而不是直接new Node()
产生一个对象? 具体原因请看JDK1.8 Collection知识点与代码分析–LinkedHashMap(LinkedHashMap需要将新节点连接到链表上)
HashMap#resize
resize函数是另一个重要的方法, 其功能包括初始化和扩容
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
// 不进行扩容
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else
// 初始化时, thresh的值不为0, 则按照thresh初始化, 否则按照默认的初始化容量初始化
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab; // 创建新的table
if (oldTab != null) { // 非初始化, 则需要从旧数组移动到新数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
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 // 尾插法保持顺序
// 如果(e.hash & oldCap) == 0 则留在原桶中, 否则进入新桶
}
}
}
}
return newTab;
}
HashMap#Treeify
树化是JDK1.8中HashMap实现的一大亮点, 代码量也很多, 如果一起分析的话, 篇幅过长了, 但是这个放一个关于树化部分的详细分析的文章, 供大家参考. 本文中, 仅对几个和树化相关的非常有意思的核心方法进行分析.
树化的整体流程如下: treeifyBin
方法把一个Bin
链表上的节点全部包装成TreeNode
, 然后由treeify
对链表进行建树, log(N)的时间复杂度下将bin中的元素插入到新建的树中, 每插入一个元素需要通过balanceInsertion
对树进行再平衡.
我们直接来看treeify
的方法源码:
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
if (root == null) {
x.parent = null;
x.red = false;
root = x;
}
else {
// 当不是根节点时,
K k = x.key;
int h = x.hash;
Class<?> kc = null;
for (TreeNode<K,V> p = root;;) {
int dir, ph; // dir表示方向 -1 左, 1 右
K pk = p.key;
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
// hash 相等的情况
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) || // 如果class实现了Comparable 返回class 否则返回null
(dir = compareComparables(kc, k, pk)) == 0) // 如果pk的getClass是kc, 返回k.compareTo(pk), 否则返回0
// 如果两者不可比较
// 如果k, pk的class名不同, 比较两个string
// 如果class相同或者两个之间有至少一个null, 比较Object.hashCode返回的地址
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p; // 记住当前p, 尝试左右儿子
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
// 通过log(n)的复杂度找到插入位置, 插入后, 重新做balance
root = balanceInsertion(root, x);
break;
}
}
}
}
moveRootToFront(tab, root);
}
这个方法中有一步调用了tieBreakOrder
方法, 这个方法的具体功能我在注释中进行了解释, 但是这个方法非常有意思, 一个二叉搜索树需要是严格有序的, 在map中又允许null作为key(只有一个) 因此这个方法的作用就是在两个key不可比的时候, 通过稳定的比较方法计算两者谁更大, 保证之后查找这个key的时候, 可以通过该tieBreakOrder
方法, 再次找到保存在树中的key.
在上述方法的最后, 调用了balanceInsertion
方法进行再平衡, 该方法也就是最关键的判断是否左右旋, flipColor逻辑的代码, 我这里直接贴上我自己的对balanceInsertion
方法的注释贴上来, 作为资料的补充
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
TreeNode<K,V> x) {
x.red = true; // 2-3树尝试同一层插入
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) { // xp x-parent, xpp x-parent-parent, xppl xpp-left, xppr xpp-right
// 找到root节点了
if ((xp = x.parent) == null) {
x.red = false;
return x;
}
// 如果父节点就是root节点,
// 如果父节点非红, 不需要提高层数, 注意这里没有要求只有左儿子能是红边
else if (!xp.red || (xpp = xp.parent) == null)
return root;
// 1. x的父节点是红边, 遇到了两个红边相连的状态
// 2. x的祖父xpp不为空
// 父节点是祖先节点的左儿子
if (xp == (xppl = xpp.left)) {
// 祖先节点的左儿子是红边
// 如果祖先节点的右儿子也是红边, flip
// 这是由于将xpp变成红色以后可能与xpp的父节点发生两个相连红色节点的冲突,这就又构成了第二种旋转变换,所以必须从底向上的进行变换,直到根。
// 所以令x = xpp,然后进行下下一层循环,接着往上走。
if ((xppr = xpp.right) != null && xppr.red) {
xppr.red = false;
xp.red = false;
xpp.red = true;
x = xpp; // 转到这个flip后持有红边的祖先节点
}
else {
// 进入else说明 祖先节点xpp的右儿子不是红边
if (x == xp.right) {
// x是右儿子,
/*
xpp
/
xp红
\
x红
需要先左旋, 再右旋
*/
root = rotateLeft(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
/*
xpp
/
xp红
/
x红
需要xp右旋, flip
*/
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateRight(root, xpp);
}
}
}
}
// 父节点是祖先节点的右儿子, 且父节点为红节点
else {
// flip
if (xppl != null && xppl.red) {
xppl.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
else {
if (x == xp.left) {
/*
xpp
\
xp红
/
x红
需要先x右旋, 再xp左旋
*/
root = rotateRight(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
/*
xpp
\
xp红
\
x红
需要xp左旋, 再flip
*/
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateLeft(root, xpp);
}
}
}
}
}
}
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,
TreeNode<K,V> p) {
/*
pp pp
/ /
p r
\ /
r p
/ \
rl rl
或
pp pp
\ \
p r
\ /
r p
/ \
rl rl
*/
TreeNode<K,V> r, pp, rl;
if (p != null && (r = p.right) != null) {
if ((rl = p.right = r.left) != null)
rl.parent = p;
if ((pp = r.parent = p.parent) == null) // 说明p就是root
(root = r).red = false;
else if (pp.left == p)
pp.left = r;
else
pp.right = r;
r.left = p;
p.parent = r;
}
return root;
}
HashMap常考知识点总结
-
为什么要保证容量是2的幂?
对于任意给定的对象,只要它的 hashCode() 返回值相同,那么计算得到的 hash 值总是相同的。我们首先想到的就是把 hash 值对 table 长度取模运算,这样一来,元素的分布相对来说是比较均匀的。
但是模运算消耗还是比较大的,我们知道计算机比较快的运算为位运算,因此 JDK 团队对取模运算进行了优化,使用上面代码2的位与运算来代替模运算。这个方法非常巧妙,它通过 “(table.length -1) & h” 来得到该对象的索引位置,这个优化是基于以下公式: x m o d 2 n = x & ( 2 n − 1 ) x mod 2^n = x \& (2^n - 1) xmod2n=x&(2n−1)。我们知道 HashMap 底层数组的长度总是 2 的 n 次方,并且取模运算为 “h mod table.length”,对应上面的公式,可以得到该运算等同于“h & (table.length - 1)”。这是 HashMap 在速度上的优化,因为 & 比 % 具有更高的效率。 -
为什么要链表要从头插法变成尾插法?
头插法还是尾插法指的都是resize
函数, 在将节点移动到新的桶中时, 节点如何插入的方法, 头插法的考量是后插入的数据可能是热点数据, 头插更容易访问, 但是存在的问题是头插法在每次resize的时候, 节点之间的前后关系都是倒置, 所以这种优化并不成立. 然而, 在下面的竞态条件下,头插法可能引起链表成环, 而尾插法不会, 因此当被错误用在并发情形下, 尾插法只有可能丢失一部分数据, 而头插法会导致死循环, 彻底不可用. -
如果把HashMap用在并发情形下, 会导致的问题?
出现很典型的竞态条件, 在resize中将链表变成环导致resize无限循环, cpu占用, HashMap无法正常继续.具体情形这篇博客讲的很清楚, 还带有配图.
其他参考资料
Java集合:HashMap详解(JDK 1.8)