HashMap

Map简介

Map中可以说是Java面试中最热门的考点了,提起Map能想到什么呢?HashMap,LinkedHashMap,ConcurrentHashMap,ConcurrentSkipListMap,ConcurrentNavigableMap,ThreadLocalMap,这是JDK为我们提供的。Spring中还有getBean("xxx")…现在先专注于HashMap吧,以后陆续把这些欠下的知识给补上。

image-20200708194743983

HashMap基础

构造函数(4种)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public HashMap() {
// 默认容量 16
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

public HashMap(int initialCapacity) {
// 自定义最大容量
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

/** 自定义最大容量与负载因子 **/
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(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}

存储无序

1
2
3
4
5
6
7
8
9
10
public static void main(String[] args) {
Map<String, String> map = new HashMap<>();
map.put("Banana", "香蕉");
map.put("Orange", "橘子");
map.put("Apple", "苹果");
map.entrySet().forEach(System.out::println);
}
// Apple=苹果
// Orange=橘子
// Banana=香蕉

从输出结果可以看出,HashMap存储与打印的顺序不是一致的,也就是HashMap存储数据不是按序存储的,如果需要按序存储可以选择LinkedHashMap,TreeMap,其中LinkedHashMap是用双向链表来保持有序的,而TreeMap则是用红黑树保持有序的。

重要变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/** 默认初始容量 = 16, 比如为 2 的幂 **/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
/** 最大容量 如果没有被构造方法指定的话将会使用这个数值, 必须是 2 的幂并且 <= 1 << 30 **/
static final int MAXIMUM_CAPACITY = 1 << 30;
/** 默认负载因子, 也就是HashMap中存储的实际Node数量超过总容量的这个百分比之后将会扩容
* 可以等于 1, 也可以大于 1, 只是冲突太多, 降低效率。
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/** 将链表转化为红黑树的阈值. 下面的 bin 指的是 bucket, 如下图中的2开始的一串, 而不是从 0-7 的bucket数组 **/
/**
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8;
/** 红黑树转化为链表的阈值 **/
static final int UNTREEIFY_THRESHOLD = 6;
/** 树化时的最小容量 **/
/**
* The smallest table capacity for which bins may be treeified.
* (Otherwise the table is resized if too many nodes in a bin.)
* Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
* between resizing and treeification thresholds.
*/
static final int MIN_TREEIFY_CAPACITY = 64;

关于为什么要把树化阈值设置为 $8$. 可以参考 阿里面试题:为什么Map桶中个数超过8才转为红黑树,但是个人更推荐看HsahMap源码中的最前面的注释 Implementation notes.,刚才读了一下,感觉很有必要专门写几篇读前言注释的文章了,先大致记载一下刚才获取到的对我而言的新知识。

  • TreeNode所占空间近乎Node两倍,在此就考虑到了空间与时间。
  • 转化成红黑树时,默认按照keyhashCode()结果进行排序,也可以自定义比较器。
  • random hashCodes情况下,bucket中的nodes服从 泊松分布,平均情况下为

$$
p=\frac{0.5^k}{k!}e^{-0.5}, k=0,1,2,\cdots,n
$$

$k$ 的意思是在忽略方差的情况下,链表的长度( $Node$ 的个数),p代表链表长度为 $k$ 时的概率。下面为链表长度出现 $k$ 从 $1$ 到 $8$ 时的概率

1
2
3
4
5
6
7
8
9
10
* 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
* more: less than 1 in ten million

也就是说,在同一个bucket发生 $8$ 次以上碰撞的概率已经小于千万分之一,所以不应该轻易认为链表转化为红黑树是很常见的事情。在数据量达到千万或者亿级以上的时候(还记得哈希表的MAXIMUM_CAPACITY = 1 << 30吗?)可能才会偶尔出现,而这个时候哈希表的效率就显得尤为重要。

网上有很多博客都在强调 $O(n)$ 与 $O(\lg n)$ 的区别,当结点数量为 $8$ 时,

  • 平均情况下:链表查找长度为 $n/2=4$ ,而红黑树的查找长度为 $\log_2 n = 3$,差别好像并不是很大。
  • 最坏情况下:链表查找长度为 $8$,红黑树为 $3$, 好像已经隐隐接近 $3$ 倍,省下了 $5$ 个查找单位时间,但是这种情况出现的概率呢?千万分之六.

存储结构及冲突解决方案

  • Java8 之前:数组加链表
  • Java8 及之后:数组+链表+红黑树 (当链表的长度大于 $8$ 时转化为红黑树,当红黑树的结点个数小于 $6$ 时再转化为链表, 这样做是为了保证在最坏查找情况下 HashMap的查找时间复杂度为 $O(\lg n)$,而不是 $O(n)$ )。
  • 红黑树复习
  • 其他解决冲突的方法:开放定址法,线性探测再散列、平方探测法、再哈希法、双散列函数法…

HashMap的数据结构(一) - shileishmily - 博客园

内部类

从源码可知,HashMap类中有一个实现了 Map.Entry<> 接口的静态类 static class Node<K,V> implements Map.Entry<K,V>,有一个非常重要的字段定义为 transient Node<K,V>[] table; 即哈希桶数组,也就是上图中的Table数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 V setValue(V newValue) { ... }

/** 重写 hashCode 一定要重写 equals */
public final int hashCode() { ... }
public final boolean equals(Object o) { ... }
}

put() 方法

1
2
3
4
map.put("Banana", "香蕉");
- 系统将调用"Banana"这个 key 的 hashCode() 方法得到其 hashCode 值
- (每个Java对象都有这个方法, Object 类为所有类的父类, 所以 Object 内的方法和变量子类也拥有.)
- 高位运算和取模运算, 来定位该键值对的存储位置, 有时两个 key 会定位到相同的位置,表示发生了 Hash 碰撞。当然Hash算法计算结果越分散均匀, Hash 碰撞的概率就越小, map 的存取效率就会越高.
  • put() 方法的逻辑
    • 如果 HashMap 未被初始化过,则初始化
    • keyHash 值,然后计算下标
    • 如果没有碰撞,则直接放入桶中
    • 如果碰撞了,以链表的方式链接到后边
    • 如果链表长度 >= TREEIFY_THRESHOLD - 1 ,并且 table >= MIN_TREEIFY_CAPACITY 将链表转化为红黑树,否则可能只是扩容。
    • 如果红黑树结点小于 $6$ ,将红黑树转化为链表
    • 如果结点已经存在就替换旧值
    • 如果初始桶装满了(容量 $16 \times 0.75 = 12$,就需要扩容 resize()(扩容 $2$ 倍)
  • 如何有效减少碰撞?
    • 扰动函数:促使元素位置分布均匀,减少碰撞几率
    • 使用 final 对象,并采用合适的 equals()hashCode() 方法

HashMap的put方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}

/**
* Implements Map.put and related methods.
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @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;
// 判断数组是否为空或者为 null 若是则进行扩容并将扩容后的长度赋值给 n
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 根据 key 值计算得到的 hash 值寻找数组索引 i
// 如果 tab[i] 为空 则创建并添加新的结点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 如果 tab[i] 不空 说明当前位置有元素
Node<K,V> e; K k;
// 判断 tab[i] 的首个元素是否和key一样,如果相同直接覆盖value
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果 p 是红黑树的结点 如果是红黑树 则在红黑树中添加键值对
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 转换为红黑树进行处理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 树化
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// key 已经存在直接覆盖value
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount; // 扩容次数
// 超过最大容量 就扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}

确定索引位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static final int hash(Object key) {   //jdk1.8 & jdk1.7
int h;
// h = key.hashCode() 为第一步 取hashCode值
// h ^ (h >>> 16) 为第二步 无符号右移 16 位 高位参与运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

static int indexFor(int h, int length) { //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
return h & (length-1); //第三步 取模运算
// 这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位
// 而HashMap底层数组的长度总是 2 的n次方(见下方),这是HashMap在速度上的优化
// 当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length
// 但是&比%具有更高的效率
}

HashMap怎么保证底层数组的长度总是 $2^n$ ?试一试就知道了。

1
2
3
4
5
6
7
8
9
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;
}

这里的Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算

JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高 $16$ 位异或低 $16$ 位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组 table.length 比较小的时候,也能保证考虑高低位都参与到Hash的计算中,同时不会有太大的开销。

异或操作

扩容 resize()

在理解Hash和扩容流程之前,我们得先了解下 HashMap 的几个字段。从 HashMap 的默认构造函数源码可知,构造函数就是对下面几个字段进行初始化,源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {

int threshold; // 所能容纳的 key-value 阈值
// 如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;
// 相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。
final float loadFactor; // 负载因子
int modCount; // 用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。
int size; // 实际存在的键值对数量

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 默认初始容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认负载因子
static final int TREEIFY_THRESHOLD = 8; // 树化门槛
static final int UNTREEIFY_THRESHOLD = 6; // 反树化 即转化为链表门槛
}

扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,将小容量数组中的元素复制到大容量数组里,并释放小容量数组。

分析下 resize 的源码,鉴于JDK1.8融入了红黑树,较复杂,为了便于理解仍然使用JDK1.7的代码,好理解一些,本质上区别不大,具体区别后文再说。

此部分内容大量参考- 美团技术团队知乎文章

1
2
3
4
5
6
7
8
9
10
11
12
13
 1 void resize(int newCapacity) {   //传入新的容量
2 Entry[] oldTable = table; //引用扩容前的Entry数组
3 int oldCapacity = oldTable.length;
4 if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
5 threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
6 return;
7 }
8
9 Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
10 transfer(newTable); //!!将数据转移到新的Entry数组里
11 table = newTable; //HashMap的table属性引用新的Entry数组
12 threshold = (int)(newCapacity * loadFactor);//修改阈值
13 }

这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer() 方法将原有Entry数组的元素拷贝到新的Entry数组里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 1 void transfer(Entry[] newTable) {
2 Entry[] src = table; //src引用了旧的Entry数组
3 int newCapacity = newTable.length;
4 for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
5 Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
6 if (e != null) {
7 src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
8 do {
9 Entry<K,V> next = e.next;
10 int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
11 e.next = newTable[i]; //标记[1]
12 newTable[i] = e; //将元素放在数组上
13 e = next; //访问下一个Entry链上的元素
14 } while (e != null);
15 }
16 }
17 }

newTable[i] 的引用赋给了 e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话),这一点和JDK1.8有区别,下文详解。在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。

下面举个例子说明下扩容过程。假设了我们的hash算法就是简单的用 key mod table.size()。其中的哈希桶数组table的size = 2, 所以 key = 3、7、5put顺序依次为 $ 5、7、3$。在 mod 2 以后都冲突在 table[1] 这里了。这里假设负载因子 loadFactor=1,即当键值对的实际大小 size 大于table 的实际大小时进行扩容。接下来的三个步骤是哈希桶数组 resize 成 $4$,然后所有的 $Node$ 重新 rehash 的过程。

扩容过程

下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是 $2$ 次幂的扩展(指长度扩为原来 $2$ 倍),所以,元素的位置要么是在原位置,要么是在原位置再移动 $2$ 次幂的位置。看下图可以明白这句话的意思,$n$ 为 $table$ 的长度,

  • 图$(a)$表示扩容前的 key1key2 两种 key 确定索引位置的示例
  • 图$(b)$表示扩容后 key1key2 两种 key 确定索引位置的示例,其中 hash1key1 对应的哈希与高位运算结果。

JDK8 rehash

元素在重新计算hash之后,因为 $n$ 变为 $2$ 倍,那么 $n-1$ 的 $mask$ 范围在高位多 $1bit$ (红色),因此新的 $index$ 就会发生这样的变化:

resize

因此,我们在扩充 $HashMap$ 的时候,不需要像 $JDK1.7$ 的实现那样重新计算 $hash$,只需要看看原来的 $hash$ 值新增的那个 $bit$ 是 $1$ 还是 $0$ 就好了,是 $0$ 的话索引没变,是 $1$ 的话索引变成 原索引+oldCap,可以看看下图为 $16$ 扩充为 $32$ 的 $resize$ 示意图:

resize

这个设计确实非常的巧妙,既省去了重新计算 $hash$ 值的时间,而且同时,由于新增的 $1bit$ 是 $0$ 还是 $1$ 可以认为是随机的,因此 $resize$ 的过程,均匀的把之前的冲突的节点分散到新的 $bucket$ 了。这一块就是 $JDK1.8$ 新增的优化点。有一点注意区别,$JDK1.7$中 $rehash$ 的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,$JDK1.8$ 不会倒置。有兴趣的同学可以研究下$JDK1.8$的 $resize$ 源码,写的很赞,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
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;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
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) {
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
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;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}

线程安全性

首先,说HashMap是线程不安全的,并不仅仅是指循环链表的问题,而是在设计与实现上就没有考虑并发环境

HashMap的设计目标是非并发环境下的简洁高效,并没有采取任何措施来保证put,remove以及其他操作的线程安全性,get操作的死循环问题只是线程不安全中最突出的代表。

1
2
3
4
JDK 1.7
在 resize() 操作时, 可能有多个线程同时检测到需要扩容, 然后分别进行扩容, 而扩容时将结点元素从 oldTable 迁移到 newTable 时采用的是头插法, 头插法的结果是与原顺序相反, 就比如 1->2->3的逆序3->2->1, 在使用 next 指针把两个线程分别扩容后的结果连接起来时, 是有可能发生 1->2->3->2->1 的, 这样首尾相连就会形成环形, 从而无限循环。

1.8 修复了链表倒置的问题, 但是在 put 的手仍然会出现数据不一致的问题.
1
2
3
4
5
6
上面说到,HashMap会进行resize操作,在resize操作的时候会造成线程不安全。下面将举两个可能出现线程不安全的地方。

- put的时候导致的多线程数据不一致
这个问题比较容易想象,比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。

- get操作可能因为resize而引起死循环(CPU 100%), 扩容的核心代码为上面代码块中的transfer()方法.

具体分析见:美团文章 - 线程安全性部分

  • 解决办法:

    • Collections.synchronizedMap() : 加入了 synchronized 代码块
    • java.util.concurrent.ConcurrentHashMap() : CAS + synchronized 使锁更细化
  • ConcurrentHashMap : put() 方法的逻辑

    1. 判断 Node[] 数组是否初始化,没有则进行初始化操作

    2. 通过 hash 定位数组的索引下标,是否有 Node 结点,如果没有则使用 CAS 进行添加(链表的头结点) ,添加失败则进入下次循环.

    3. 检查到内部正在扩容,就帮助它一起扩容

    4. 如果 f != null ,则使用 synchronized 锁住 f 元素(链表/红黑树的头元素)

      4.1 如果是 Node (链表结构) 则执行链表的添加操作

      4.2 如果是 TreeNode (树形结构) 则执行树添加操作

    5. 判断链表长度已经达到临界值 $8$(默认值,可修改),当结点数超过这个值就需要把链表转换为树结构。

  • ConcurrentHashMap : 需要注意的点

    • size() 方法和 mappingCount() 方法的异同,两者计算是否准确?
    • 多线程环境下如何进行扩容?

HashMap、Hashtable、ConcurrentHashMap区别

比较方面 HashMap Hashtable ConcurrentHashMap
是否线程安全
线程安全采用的方式 采用synchronized类锁,效率低 CAS + synchronized,锁住的只有当前操作的bucket,不影响其他线程对其他bucket的操作,效率高
数据结构 数组+链表+红黑树(链表长度超过8则转红黑树) 数组+链表 数组+链表+红黑树(链表长度超过8则转红黑树)
是否允许null键值
哈希地址算法 (h=key.hashCode())^(h>>>16 key.hashCode() (h=key.hashCode())^(h>>>16)&0x7fffffff
定位算法 (n-1)&hash (hash&0x7fffffff)%n (n-1)&hash
扩容算法 当键值对数量大于阈值,则容量扩容到原来的2倍 当键值对数量大于等于阈值,则容量扩容到原来的2倍+1 当键值对数量大于等于sizeCtl,单线程创建新哈希表,多线程复制bucket到新哈希表,容量扩容到原来的2倍
链表插入 将新节点插入到链表尾部 将新节点插入到链表头部 将新节点插入到链表尾部
继承的类 继承abstractMap抽象类 继承Dictionary抽象类 继承abstractMap抽象类
实现的接口 实现Map接口 实现Map接口 实现ConcurrentMap接口
默认容量大小 16 11 16
默认负载因子 0.75 0.75 0.75
统计size方式 直接返回成员变量size 直接返回成员变量count 遍历CounterCell数组的值进行累加,最后加上baseCount的值即为size

评论