关联博文
数据结构之Map基础入门与详解
认真学习Java集合之HashMap的实现原理
认真研究HashMap的读取和存放操作步骤
认真研究HashMap的初始化和扩容机制
认真研究JDK1.7下HashMap的循环链表和数据丢失问题
认真研究HashMap中的平衡插入
认真研究HashMap的结点移除
认真研究HashMap中的平衡删除
HashMap 是基于哈希表的Map 接口的非同步实现。此实现提供所有可选的映射操作,并允许使用null 值和null 键(允许一个null键,HashTable不允许entry的键或者值为空)。此类不保证映射的顺序,特别是它不保证该顺序恒久不变。
JDK1.8 之前 HashMap 的数据结构是 数组+链表
。数组是 HashMap 的主体,单向链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突
)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于等于阈值(默认为 8)时,且tab.length>=64时,将链表转化为红黑树
,以减少搜索时间。
注意,HashMap的底层数据结构中链表是单向链表,也就是只有next指针,没有prev指针。而LinkedHashMap维护了before、 after、head以及tail。
【1】底层数据结构分析
JDK1.8 之前 HashMap 底层是 数组和链表
结合在一起使用也就是 链表散列
。HashMap 通过 key 的 hashCode
经过扰动函数
处理过后得到 hash 值
,然后通过 (n - 1) & hash值
判断当前元素存放的位置(这里的 n 指的是数组的长度
)。
如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key
是否相同,如果相同的话,直接覆盖;不相同就通过拉链法解决冲突。
① hash(Object key)
–扰动函数
所谓扰动函数指的就是 HashMap 的 hash
方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法造成的碰撞,换句话说使用扰动函数之后可以减少碰撞。
JDK 1.8 的 hash
方法 相比于 JDK 1.7 hash
方法更加简化,但是原理不变。
static final int hash(Object key) {
int h;
// key.hashCode():返回散列值也就是hashcode
// ^ :按位异或--相同为0 不同为1
// >>>:无符号右移,符号位随着移动,空位都以0补齐
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
//保留h的高16位,低16位变为原高16位与低16位异或结果
}
先获得key的hashCode的值 h,然后 h 和 h右移16位 做异或运算。
实质上是把一个数的低16位与他的高16位做异或运算,因为在 (n - 1) & hash 的计算中,hash变量只有末x位会参与到运算。使高16位也参与到hash的运算能减少冲突。
对比一下 JDK1.7(jdk1.7.0_17)的 HashMap 的 hash
方法源码。
final int hash(Object k) {
int h = 0;
if (useAltHashing) {
if (k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h = hashSeed;
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
hash(int h)
方法根据key 的hashCode
重新计算一次散列。此算法加入了高位计算,防止低位不变,高位变化时,造成的hash 冲突。
相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次。我们可以看到在HashMap 中要找到某个元素,需要根据key 的hash 值
来求得对应数组中的位置。如何计算这个位置就是hash 算法。
② jdk1.7的拉链法
所谓 “拉链法”
就是:将链表和数组
相结合。也就是说创建一个链表数组
,数组中每一格就是一个链表
。若遇到哈希冲突,则将冲突的值加到链表中即可。
jdk1.7 HashMap数据结构:
从上图中可以看出,HashMap 底层就是一个数组结构,数组中的每一项又是一个链表。当新建一个HashMap 的时候,就会初始化一个数组。
③ jdk1.8及以后
相比于之前的版本,jdk1.8在解决哈希冲突时有了较大的变化,当链表长度>=阈值(TREEIFY_THRESHOLD 默认为8)且tab.length>=64时,将链表转化为红黑树
,以减少搜索时间。
【2】类的属性
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 序列号
private static final long serialVersionUID = 362498820763181265L;
// 默认的初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的负载因子0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当桶(bucket)上的结点数大于等于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
// 当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中结构转化为红黑树对应的数组 table的最小length
//即,如果根据TREEIFY_THRESHOLD 判断需要转成红黑树时,还要判断当前容量是否大于MIN_TREEIFY_CAPACITY
//如果小于MIN_TREEIFY_CAPACITY ,则resize;否则转成红黑树
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组,总是2的幂次倍
transient Node<k,v>[] table;
// 存放具体元素的集
transient Set<map.entry<k,v>> entrySet;
// 存放元素的个数,注意这个不等于数组的长度(table.length)
//HashMap中所有键值对的个数
transient int size;
// 每次扩容和更改map结构的计数器--快速失败机制有关
transient int modCount;
//The next size value at which to resize (capacity * load factor).
// 临界值, 当实际大小超过临界值(容量*填充因子)时,会进行扩容
//默认为16*0.75=12
int threshold;
// 填充因子-负载因子
final float loadFactor;
}
① loadFactor负载因子
负载因子loadFactor 定义为:hashmap.size() / tab.length
。
CAPACITY也就是
tab.length
,默认是16。
threshold
默认是CAPACITY*loadFactor =16*0.75=12
。
扩容的前提是++size > threshold
,size是HashMap中存放键值对的个数。
每一次put一个新的key,都会导致size+1
如下图所示,散列表的容量是64(数组没有画全),size是14。这是一种比较极端的情况,数组还有很多空的位置,但是出现了红黑树。
当负载因子设置比较大的时候,扩容的门槛就被提高了,扩容发生的频率比较低,占用的空间会比较小,但此时发生 Hash 冲突的几率就会提升,因此需要更复杂的数据结构来存储元素,这样对元素的操作时间就会增加,运行效率也会因此降低;
而当负载因子值比较小的时候,扩容的门槛会比较低,因此会占用更多的空间,此时元素的存储就比较稀疏,发生哈希冲突的可能性就比较小,因此操作性能会比较高。
loadFactor的默认值为0.75f
是官方给出的一个比较好的临界值。
② threshold
HashMap 的实现中,通过threshold 字段来确定是否需要扩容。
threshold = (int)(capacity * loadFactor);
结合负载因子的定义公式可知,threshold 就是在此loadFactor 和capacity
对应下允许的最大元素数目
,超过这个数目就重新resize,以降低实际的负载因子。
默认的的负载因子0.75是对空间和时间效率的一个平衡选择。当容量超出此最大容量时, resize后的HashMap容量是旧容量的两倍
。
给定的默认容量为 16,负载因子为 0.75
。Map 在使用过程中不断的往里面存放数据,当size(数量)达到了threshold-临界值- 16 * 0.75 = 12 就需要将当前 16 的容量进行扩容
。
③ HashMap 容量为什么总是为 2 的次幂
如果 n 永远是2的次幂,那么 n-1 通过 二进制表示,永远都是尾端以连续1的形式表示(00001111,00000011)
当(n - 1) 和 hash
做与运算时,会保留hash中 后 x 位的 1
例如 00001111 & 10000011 = 00000011
这样做有如下好处:
-
&运算速度快,至少比%取模运算块
-
能保证 索引值 肯定在 capacity 中,不会超出数组长度
-
(n - 1) & hash,当n为2次幂时,会满足一个公式:(n - 1) & hash = hash % n
【3】为什么说(n-1)&hash
是速度上的优化?
初始化时HashMap 的容量总是2 的n
次方,即底层数组的长度总是为2的n 次方。当length 总是 2 的n 次方时,(n - 1) & hash
运算等价于hash对length 取模,也就是hash%n
,但是&比%
具有更高的效率。
这看上去很简单,其实比较有玄机的,我们举个例子来说明。
假设数组长度分别为15 和16,优化后的hash 码分别为8 和9,那么&运算后的结果如下:
从上面的例子中可以看出:当它们和15-1(1110)“与”
的时候,产生了相同的结果,也就是说它们会定位到数组中的同一个位置上去,这就产生了碰撞。8 和9 会被放到数组中的同一个位置上形成链表,那么查询的时候就需要遍历这个链 表,得到8 或者9,这样就降低了查询的效率。
同时,我们也可以发现,当数组长度为15 的时候,hash 值会与15-1(1110)进行“与”
,那么 最后一位永远是0。而0001(第二个位置),0011(第四个位置),0101(第六个位置),0111(第8个位置),1001(第10个位置),1011(第12个位置),1101(第14个位置)
这几个位置永远都不能存放元素了,空间浪费相当大
。更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!
而当数组长度为16 时,即为2 的n 次方时,2^n-1
得到的二进制数的每个位上的值都为1.这使得在低位上&
时,得到的和原hash 的低位
相同。则不存在空间浪费的情况,数组的所有位置都可以被索引到。
//jdk1.8
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
所以说,当数组长度为2 的n 次幂的时候,不同的key 算得得index相同的几率较小,那么数据在数组上分布就比较均匀,也就是说碰撞的几率小。相对的,查询的时候就不用遍历某个位置上的链表,这样查询效率也就较高了。
总结来讲就是:
- 采用&运算比%取模运算效率高
- 当数组长度为2的n次幂时,
(n-1)&hash
==hash%n
【4】链表与树的转化
① treeifyBin
在put方法过程中,如果链表结点个数大于等于TREEIFY_THRESHOLD=8
且tab.length>=64(MIN_TREEIFY_CAPACITY)
,那么就会触发链表转化为红黑树。
treeifyBin方法如下所示(不是一定转化为树哦,可能触发扩容)
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//index = (n - 1) & hash 即索引位置
//判断是否需要扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
//do...将每一个结点转换为TreeNode并维护了两个结点之间的双向指向
do {
// 将 e 转化为一个TreeNode
TreeNode<K,V> p = replacementTreeNode(e, null);
// 拿到头结点
if (tl == null)
hd = p;
else {
// 将p与tl 组成双向链表
p.prev = tl;
tl.next = p;
}
// 临时存放当前结点
tl = p;
} while ((e = e.next) != null);
// 把头结点放到索引位置
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
//返回一个新的TreeNode
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
这里首先进行了判断,是扩容还是链表转换为红黑树。然后获取到索引位置的结点进行遍历,将每一个结点都转换为TreeNode并维护了结点间的prev和next指向关系。最后触发hd.treeify(tab);
尝试进行树化(因为不一定哦,可能会触发扩容)。
② treeify将链表转换为红黑树
这个方法很纯粹,将给定结点位置的链表转化为红黑树。
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
//this表示触发当前方法的结点,即索引位置的结点
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
// 第一次循环将x赋予root,根节点一定是黑色
if (root == null) {
x.parent = null;
x.red = false;//标注黑色
root = x;
}
else {
K k = x.key;
int h = x.hash;
Class<?> kc = null;
//对树结点进行遍历,根据dir 更新p的值
for (TreeNode<K,V> p = root;;) {
int dir, ph;
K pk = p.key;
// 将当前结点X 与root结点进行对比,必须要有大小
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
//p表示当前循环判断节点,最开始是root,依次与X进行对比
// xp指向P表示的结点,下面P结点将会重新指向left或者right
TreeNode<K,V> xp = p;
// 如果p.left or p.right 为null,表示找到了位置放置X
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
// 平衡插入,获取新的root
root = balanceInsertion(root, x);
break;
}
}
}
}
// 将root结点移动到索引位置 (n-1)&hash
moveRootToFront(tab, root);
}
方法逻辑总结如下:
- ① 遍历链表的每个结点,第一次将当前结点作为root,然后获取next;
- ② 第二次及以后则遍历root,找到合适位置放置x,然后进行平衡插入获取新的root
- ③ 如果②中没有中断循环则重复① ② 操作
- ④ 将root结点移动到索引位置
(n-1)&hash
③ 平衡插入获取新的root结点
参数中的root为当前root结点,x 为新插入的结点 。xpp 为xp的parent,xppl 为xpp的left,xppr 为 xpp的right ,xp是x.parent。这个过程会涉及到红黑树的左旋和右旋。
// root为当前root结点,x 为新插入的结点
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
TreeNode<K,V> x) {
//插入结点颜色首先默认是红色
x.red = true;
//无限循环,直到return
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
// 如果parent为null,说明是根节点,直接返回x,颜色为黑色
if ((xp = x.parent) == null) {
//这一步很关键,根节点强制调整为黑色
x.red = false;
return x;
}
//如果xp颜色为黑色,或者xpp为null,则无需处理,返回root
else if (!xp.red || (xpp = xp.parent) == null)
return root;
//左侧分支
if (xp == (xppl = xpp.left)) {
//如果xppr.red 也就是祖父结点的右孩子,即叔叔结点为红色
//这种情况需要调整xp、xppr的颜色
if ((xppr = xpp.right) != null && xppr.red) {
xppr.red = false;//调整为黑色
xp.red = false;//调整为黑色
xpp.red = true;//暂时调整为红色
x = xpp;//X指向XPP,进行下一次循环处理
}
//xppr也就是叔叔结点不存在或者为黑色
else {
//如果X是右孩子
if (x == xp.right) {
//左旋
root = rotateLeft(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
//右旋
root = rotateRight(root, xpp);
}
}
}
}
//if (xp == (xppl = xpp.left)) 的else-右侧分支
else {
if (xppl != null && xppl.red) {
xppl.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
else {
if (x == xp.left) {
root = rotateRight(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
root = rotateLeft(root, xpp);
}
}
}
}
}
}
④ 将树的根结点移动到索引位置
如下则将root结点移动到(n - 1) & root.hash
位置。
static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) {
int n;
if (root != null && tab != null && (n = tab.length) > 0) {
// 获取索引位置
int index = (n - 1) & root.hash;
// 获取索引位置结点,如果first==root则无需处理
TreeNode<K,V> first = (TreeNode<K,V>)tab[index];
if (root != first) {
Node<K,V> rn;
tab[index] = root;//索引位置放上root
TreeNode<K,V> rp = root.prev;//记录root的前结点
if ((rn = root.next) != null)
// 把root.next.prev-->root.prev ,将root从中间摘出来
((TreeNode<K,V>)rn).prev = rp;
if (rp != null)
// 如果rp不为null,则rp.next=rn形成双向指定
rp.next = rn;
if (first != null)
// first与root形成双向指定
first.prev = root;
root.next = first;
//这个很重要,索引位置结点的prev一定是null
root.prev = null;
}
assert checkInvariants(root);
}
}
⑤ 扩容时红黑树的分割
扩容时会涉及到oldTab中结点向newTab中转移的过程。链表可能会被分割为两个,红黑树也可能会被分割为两个。其中在红黑树的分割过程中可能会触发向链表的转换。
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//index就是结点位置,bit 是oldCap
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
// b=tab[index]
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
//位置不变的头和尾结点
TreeNode<K,V> loHead = null, loTail = null;
//位置改变的头和尾结点,tab[index+bit]
TreeNode<K,V> hiHead = null, hiTail = null;
// lc记录位置不变的结点个数,hc记录位置改变的结点个数
int lc = 0, hc = 0;
//从当前结点开始,一路next遍历下去。这个循环过程只维护了结点间的next关系
//所以拆分后,还需要对两个“对象”进行树化和链表化处理
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
// 等同于 e.hash&oldCap==0 ,位置不变
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
//临时存最后一个结点
loTail = e;
//位置不变的结点数量+1
++lc;
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
//对位置不变的“对象”的处理
if (loHead != null) {
//如果结点数量小于等于6 则转换为链表
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
// hiHead 为null说明没有位置改变的,且lc >UNTREEIFY_THRESHOLD
//那么本身就是一棵树,无需多余处理
if (hiHead != null) // (else is already treeified)
loHead.treeify(tab);
}
}
//对位置[index+oldCap]的“对象”的处理
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
// 进行链表化
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
// loHead为null则说明整体迁移到新的位置,无需多余处理了
if (loHead != null)
hiHead.treeify(tab);
}
}
}
这里通过遍历每个结点,对结点进行(e.hash & bit) == 0
判断来拆分为两个“对象”。然后对两个对象尝试进行树化和链表化的处理。这里特别注意的是,仅可以认为循环遍历过程中维护了两个“对象”中结点间的next关系,“对象”是链表还是红黑树需要后续判断、处理。
⑥ untreeify转换为链表
这个过程比较简单,获取到当前结点q然后使用q.next遍历下去,把遍历到的每个结点追加到 hd 后面就形成了单向链表。
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null;
for (Node<K,V> q = this; q != null; q = q.next) {
Node<K,V> p = map.replacementNode(q, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
return new Node<>(p.hash, p.key, p.value, next);
}
【5】Fail-Fast 机制
我们知道java.util.HashMap 不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast 策略。
这一策略在源码中的实现是通过modCount 域,modCount 顾名思义就是结构修改次数,对HashMap 结构的修改都将增加这个值,那么在迭代器初始化过程中会将这个值赋给迭代器的expectedModCount。
在迭代过程中,判断modCount 跟expectedModCount 是否相等,如果不相等就表示已经有其他线程修改了Map。
在HashMap 的API 中指出,由所有HashMap 类的“collection 视图方法”所返回的迭代器都是快速失败的:在迭代器创建之后,如果从结构上对映射进行修改,除非通过迭代器本身的 remove 方法,其他任何时间任何方式的修改,迭代器都将抛出 ConcurrentModificationException。因此,面对并发的修改,迭代器很快就会完全失败,而不冒在将来不确定的时间发生任意不确定行为的风险。
注意,迭代器的快速失败行为不能得到保证,一般来说,存在非同步的并发修改时,不可能作出任何坚决的保证。快速失败迭代器尽最大努力抛出 ConcurrentModificationException。因此,编写依赖于此异常的程序的做法是错误的,正确做法是:迭代器的快速失败行为应该仅用于检测程序错误。
HashMap的并发问题
hashmap在插入新的阶段的时候,多个线程同时插入,会把除了最后的那个线程的其它线程插入的结点丢失。
对于修改的时候,多个线程修改,会只保留最后的一个线程的修改结果。
扩容的时候,会只保留最后一个线程的扩容后的那个数组。
【6】jdk1.8下hash方法详解
源码基于jdk1.8:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
从上面的代码可以看到key的hash值的计算方法。key的hash值高16位不变,低16位与高16位异或作为key的最终hash值。(h >>> 16
,表示无符号右移16位,高位补0,任何数跟0异或都是其本身,因此key的hash值高16位不变。)
为什么要这么干呢? 这个与HashMap中table下标的计算有关。
n = table.length;
index = (n-1) & hash;
因为,table的长度都是2的幂,因此index仅与hash值的低n位有关(此n非table.leng,而是2的幂指数),hash值的高位都被与操作置为0了。
假设table.length=2^4=16和某散列值做“与”操作如下,结果就是截取了最低的四位值。
10100101 11000100 00100101
& 00000000 00000000 00001111
----------------------------------
00000000 00000000 00000101 //高位全部归零,只保留末四位
但这时候问题就来了,这样就算我的散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。更要命的是如果散列本身做得不好,分布上成等差数列的漏洞,恰好使最后几个低位呈现规律性重复,就无比烦人。
时候“扰动函数”的价值就体现出来了,看下面这图:
右位移16位,正好是32bit的一半,自己的高半区和低半区做异或,就是为了混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来。
参考博文: