HashMap
参考:
[HashMap defaultLoadFactor = 0.75和泊松分布没有关系](HashMap defaultLoadFactor = 0.75和泊松分布没有关系)
1. 数据结构
JDK1.8前是由数组 + 链表;
JDK1.8之后是数组 + 链表 + 红黑树;
2. 成员变量
// 哈希表默认的初始化容量
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;
// 链表可能转换为红黑树的基本阈值(链表长度>=8)
static final int TREEIFY_THRESHOLD = 8;
// 哈希表扩容后,如果发现红黑树节点数小于6,则退化为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 链表转换为红黑树的另一个条件,哈希表长度必须大于等于64才会转换,否则会扩容
static final int MIN_TREEIFY_CAPACITY = 64;
// 哈希表
transient Node<K,V>[] table;
// Node<K, V>的Set集合
transient Set<Map.Entry<K,V>> entrySet;
// Node<K, V>节点个数
transient int size;
// map内元素的个数的修改次数
transient int modCount;
// 扩容阈值,当size >= threshold时候,有可能会被扩容
int threshold;
// 自定义负载因子
final float loadFactor;
3. JDK7 HashMap 死循环问题
多线程环境下,两个线程同时扩容,因为操作的是相同的对象,导致指针错乱,使得链表节点的父子节点互指(父节点next指向子节点,子节点next指向父节点)。进而在后续扩容时产生死循环问题。
虽然在JDK8已经解决了该问题(尾插法解决),但是还是建议在多线程环境下使用 ConcurrentHashMap ,因为可能出现元素丢失问题。
4. put 数据时具体流程
- 先获得key对应的hash值,利用hashcode()函数,然后通过扰动函数(即移位和异或操作),减少哈希冲突。使得数据尽量平均分布。
- 插入数据前先查看当前table数组的状态,是否需要resize
- 判断插入的数组位置是否为空,为空就直接插入,不为空的话,判断key是否相等,相等就覆盖。
- 不为空,判断是否是红黑树,是的话调用putTreeVal
- 是链表的话,尾插法,同时考虑当前链表是否需要转换为红黑树。
put 方法
HashMap 只提供了 put 用于添加元素,putVal 方法只是给 put 方法调用的一个方法,并没有提供给用户使用。
对 putVal 方法添加元素的分析如下:
- 如果定位到的数组位置没有元素 就直接插入。
- 如果定位到的数组位置有元素就和要插入的 key 比较,如果 key 相同就直接覆盖,如果 key 不相同,就判断 p 是否是一个树节点,如果是就调用
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value)
将元素添加进入。如果不是就遍历链表插入(插入的是链表尾部)。
public V put(K key, V value) {
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;
// table未初始化或者长度为0,进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 桶中已经存在元素
else {
Node<K,V> e; K k;
// 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 将第一个元素赋值给e,用e来记录
e = p;
// hash值不相等,即key不相等;为红黑树结点
else if (p instanceof TreeNode)
// 放入树中
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 ),执行 treeifyBin 方法
// 这个方法会根据 HashMap 数组来决定是否转换为红黑树。
// 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是对数组扩容。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
// 跳出循环
break;
}
// 判断链表中结点的key值与插入的元素的key值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
// 表示在桶中找到key值、hash值与插入元素相等的结点
if (e != null) {
// 记录e的value
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 结构性修改
++modCount;
// 实际大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
我们再来对比一下 JDK1.7 put 方法的代码
对于 put 方法的分析如下:
- ① 如果定位到的数组位置没有元素 就直接插入。
- ② 如果定位到的数组位置有元素,遍历以这个元素为头结点的链表,依次和插入的 key 比较,如果 key 相同就直接覆盖,不同就采用头插法插入元素。
public V put(K key, V value)
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) { // 先遍历
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i); // 再插入
return null;
}
5. 确定桶下标
计算hash值
JDK 1.8 HashMap 的 hash 方法源码:
JDK 1.8 的 hash 方法 相比于 JDK 1.7 hash 方法更加简化,但是原理不变。
static final int hash(Object key) {
int h;
// key.hashCode():返回散列值也就是hashcode
// ^ :按位异或
// >>>:无符号右移,忽略符号位,空位都以0补齐
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}Copy to clipboardErrorCopied
对比一下 JDK1.7 的 HashMap 的 hash 方法源码.
static int hash(int h) {
// 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);
}Copy to clipboardErrorCopied
相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,因为毕竟扰动了 4 次。
取模
令 x = 1<<4,即 x 为 2 的 4 次方,它具有以下性质:
x : 00010000
x-1 : 00001111
令一个数 y 与 x-1 做与运算,可以去除 y 位级表示的第 4 位以上数:
y : 10110010
x-1 : 00001111
y&(x-1) : 00000010
这个性质和 y 对 x 取模效果是一样的:
y : 10110010
x : 00010000
y%x : 00000010
我们知道,位运算的代价比求模运算小的多,因此在进行这种计算时用位运算的话能带来更高的性能。
确定桶下标的最后一步是将 key 的 hash 值对桶个数取模:hash%capacity,如果能保证 capacity 为 2 的 n 次方,那么就可以将这个操作转换为位运算。
注
:hash值本身是一个32位的int类型,因为是与操作,取的是hash值的后N位。
static int indexFor(int h, int length) {
return h & (length-1);
}
6. 何时扩容
(1)Java 8 在新增数据存入成功后进行扩容
(2)扩容会发生在两种情况下(满足任意一种条件即发生扩容):
a. 当前存入数据大于阈值即发生扩容
b. 存入数据到某一条链表时,此时该链表数据个数大于8,且节点总数量小于64即发生扩容
(3)此外需要注意一点 JDK7 是在存入数据前进行判断是否扩容,而 JDK8 是在存入数据后再进行扩容的判断。
HashMap使用的是懒加载,构造完HashMap对象后,只要不进行put 方法插入元素之前,HashMap并不会去初始化或者扩容。
public V put(K key, V value) {
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;
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 {
...
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
在putVal方法第8、9行我们可以看到,当首次调用put方法时,HashMap会发现table为空然后调用resize方法进行初始化。
在putVal方法第16、17行我们可以看到,当添加完元素后,如果HashMap发现size(元素总数)大于threshold(阈值),则会调用resize方法进行扩容。
在这里值得注意的是,在putVal方法第10行我们可以看到,插入元素的hash值是一个32位的int值,而实际当前元素插入table的索引的值为 :
(table.size - 1)& hash
又由于table的大小一直是2的倍数,2的N次方,因此当前元素插入table的索引的值为其hash值的后N位组成的值。
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) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 没超过最大值,就扩充为原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//若threshold(阈值)不为空,table的首次初始化大小为阈值,否则初始化为缺省值大小16。
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else {
// signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的resize上限
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;
if (oldTab != null) {
// 把每个bucket都移动到新的buckets中
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 {
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 原索引
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到bucket里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到bucket里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
- 当table需要扩容时,从第13~ 14行可以看到,扩容后的table大小变为原来的两倍,接下来就是进行扩容后table的调整:
假设扩容前的table大小为2的N次方,有上述put方法解析可知,元素的table索引为其hash值的后N位确定。那么扩容后的table大小即为2的N+1次方,则其中元素的table索引为其hash值的后N+1位确定,比原来多了一位。
因此,table中的元素只有两种情况:
- 元素hash值第N+1位为0:不需要进行位置调整
- 元素hash值第N+1位为1:调整至原索引的两倍位置
在resize方法中,第45行的判断即用于确定元素hashi值第N+1位是否为0:
- 若为0,则使用loHead与loTail,将元素移至新table的原索引处
- 若不为0,则使用hiHead与hiHead,将元素移至新table的两倍索引处
扩容或初始化完成后,resize方法返回新的table。
注:
进行扩容,会伴随着一次重新 hash 分配,并且会遍历 hash 表中所有的元素,是非常耗时的。在编写程序中,要尽量避免 resize。
7. 为什么java Hashmap 中的加载因子是默认为0.75
- 应该是“哈希冲突”和“空间利用率”矛盾的一个折衷。
As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs.
Higher values decrease the space overhead but increase the lookup cost
(reflected in most of the operations of the <tt>HashMap</tt> class, including get and put).
8. 泊松分布,以及为何TREEIFY_THRESHOLD=8
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
-
这一段注释的内容和目的都是为了解释在java8 HashMap中引入Tree Bin(也就是放入数据的每个数组bin从链表node转换为red-black tree node)的原因
-
原注释如上图划线部分:Because TreeNodes are about twice the size of regular nodes, we use them only when bins contain enough nodes to warrant use(see TREEIFY_THRESHOLD).
-
TreeNode虽然改善了链表增删改查的性能,但是其节点大小是链表节点的两倍
-
虽然引入TreeNode但是不会轻易转变为TreeNode(如果存在大量转换那么资源代价比较大),根据泊松分布来看转变是小概率事件,性价比是值得的
-
泊松分布是二项分布的极限形式,两个重点:事件独立、有且只有两个相互对立的结果
-
泊松分布是指一段时间或空间中发生成功事件的数量的概率
-
对HashMap table[]中任意一个bin来说,存入一个数据,要么放入要么不放入,这个动作满足二项分布的两个重点概念
-
对于HashMap.table[].length的空间来说,放入0.75 length个数据,某一个bin中放入节点数量的概率情况如上图注释中给出的数据(表示数组某一个下标存放数据数量为0~8时的概率情况)
举个例子说明,HashMap默认的table[].length=16,在长度为16的HashMap中放入12(0.75length)个数据,某一个bin中存放了8个节点的概率是0.00000006
扩容一次,162=32,在长度为32的HashMap中放入24个数据,某一个bin中存放了8个节点的概率是0.00000006
再扩容一次,32*2=64,在长度为64的HashMap中放入48个数据,某一个bin中存放了8个节点的概率是0.00000006 -
所以,当某一个bin的节点大于等于8个的时候,就可以从链表node转换为treenode,其性价比是值得的。
9.红黑树与链表
红黑树:
节点必须是红色或者是黑色
根节点是黑色的
所有的叶子节点是黑色的。
每个红色节点的两个子节点是黑色的,也就是不能存在父子两个节点全是红色
从任意每个节点到其每个叶子节点的所有简单路径上黑色节点的数量是相同的。
红黑树退化为链表的情况
基本思想是当红黑树中的元素减少并小于一定数量时,会切换回链表。而元素减少有两种情况:
- 调用map的remove方法删除元素
hashMap的remove方法,会进入到removeNode方法,找到要删除的节点,并判断node类型是否为treeNode,然后进入删除红黑树节点逻辑的removeTreeNode方法中,该方法有关解除红黑树结构的分支如下:
//判断是否要解除红黑树的条件
if (root == null || root.right == null ||(rl = root.left) == null || rl.left == null) {
tab[index] = first.untreeify(map); // too smallreturn;
}
可以看到,此处并没有利用到网上所说的,当节点数小于UNTREEIFY_THRESHOLD时才转换,而是通过红黑树根节点及其子节点是否为空来判断。而满足该条件的最大红黑树结构如下:
节点数为10,大于 UNTREEIFY_THRESHOLD(6),但是根据该方法的逻辑判断,是需要转换为链表的
- resize的时候,对红黑树进行了拆分
resize的时候,判断节点类型,如果是链表,则将链表拆分,如果是TreeNode,则执行TreeNode的split方法分割红黑树,而split方法中将红黑树转换为链表的分支如下:
//在这之前的逻辑是将红黑树每个节点的hash和一个bit进行&运算,
//根据运算结果将树划分为两棵红黑树,lc表示其中一棵树的节点数
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;if (hiHead != null)
// (else is already treeified)loHead.treeify(tab);
}
这里才用到了 UNTREEIFY_THRESHOLD 的判断,当红黑树节点元素小于等于6时,才调用untreeify方法转换回链表
链表转为红黑树
当向HashMap中put Node时,如果当前Node的hash值计算出的索引处已被占并且为单个Node或为长度小于8的链表,则以链表的形式存储Node;
当向HashMap中put Node时,如果当前Node的hash值计算出的索引处已被占并且为长度大于等于8的链表,则先当前Node放入链表的末端,随后将该链表转化为红黑树。(将链表转换成红黑树前会判断,如果当前数组的长度小于64,那么会选择先进行数组扩容,而不是转换为红黑树)
注:如果同一个节点的链表数据节点个数>TREEIFY_THRESHOLD=8且数组长度>=MIN_TREEIFY_CAPACITY=64,则会该链表进化为红黑树,如果红黑树中节点个数小于UNTREEIFY_THRESHOLD=6,会退化为链表。
单向链表转换为红黑树的时候会先变化为双向链表,最终转换为红黑树,双向链表和红黑树是并存的。
HashMap是懒汉式创建的,只有在put数据是才会build。
初始容量(16)和负载因子(0.75)是决定了整个类的性能,是权衡和时间和空间上的性能,中庸之道。
如果是插入链表,JDK1.8是尾插法;JDK1.7是头插法