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 {
Node<K,V> e; K k;
// 如果当前节点就是我们要插入的节点,则直接使用
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果当前节点是一个红黑树节点,则将其转换为红黑树节点并调用putTreeVal方法
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);
// 如果链表长度超过阈值,则将其转换为红黑树
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;
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; // 返回null,表示插入成功且没有返回旧值
}
在 HashMap
的 putVal()
方法中,可以看出里面进行了很多操作,那么在这里,我们把目光聚焦到++modCount
这一行代码中,它不是一个原子操作,可以理解为典型的i++
操作。
我门可以根据箭头依次看,假设线程1
首先拿到 i=1
的结果,然后进行i+1
操作,但此时i+1
的结果并没有保存下来,线程1
就被切换走了,于是 CPU
开始执行线程2
,它所做的事情和线程1
是一样的i++
操作,线程1
拿到的i
的结果一样都是1
,为什么呢?因为线程1
虽然对i
进行了+1
操作,但结果没有保存,所以线程2
看不到修改后的结果。
然后假设等线程2
对i
进行 +1
操作后,又切换到线程1
,让线程1
完成未完成的操作,即将i+1
的结果 2
保存下来,然后又切换到线程2
完成 i=2
的保存操作,虽然两个线程都执行了对 i
进行 +1
的操
作,但结果却最终保存了i=2
的结果,而不是我们期望的i=3
,这样就发生了线程安全问题,导致了
数据结果错误,这也是最典型的线程安全问题。
如此也可以证明HashMap
是线程不安全的。(上述代码是java8的版本)
如下面的例子:
package com.example;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.IntStream;
public class HashMapNotSafe {
public static void main(String[] args) {
final Map<Integer, String> map = new HashMap<>();
final Integer targetKey = 0b1111_1111_1111_1111;
final String targetValue = "v";
map.put(targetKey, targetValue);
new Thread(() -> {
IntStream.range(0, targetKey).forEach(key -> map.put(key, "someValue"));
}).start();
while (true) {
if (null == map.get(targetKey)) {
throw new RuntimeException("HashMap is not thread safe.");
}
}
}
}
代码中首先建立了一个 HashMap
,并且定义了 key
和 value
, key
的值是一个二进1111_1111_1111_1111
,对应的十进制是 65535
。之所以选取这样的值,就是为了让它在扩容往回填充数据的时候,尽量不要填充得太快,比便于我们能捕捉到错误的发生。而对应的 value
是无所谓的,我们随意选取了一个非 null
的 "v"
来表示它,并且把这个值放到了 map
中。
我们就用一个新的线程不停地往我们的 map
中去填入新的数据,我们先来看是怎么填入的。首先它用了一个 IntStream
,这个 range
是从 0 到之前所讲过的 65535
,这个 range
是一个左闭右开的区间,所以会从 0、1、2、3……
一直往上加,并且每一次加的时候,这个 0、1、2、3、4
都会作为 key 被放到 map
中去。而它的 value
是统一的,都是 "someValue"
,因为 value
不是我们所关心的。
然后,我们就会把这个线程启动起来,随后就进入一个 while
循环,这个 while
循环是关键,在 while
循环中我们会不停地检测之前放入的 key
所对应的 value
还是不是我们所期望的字符串 “v”。我们在 while
循环中会不停地从 map
中取 key
对应的值。如果 HashMap
是线程安全的,那么无论怎样它所 取到的值都应该是我们最开始放入的字符串 "v"
,可是如果取出来是一个 null
,就会满足这个 if
条件并且随即抛出一个异常,因为如果取出 null
就证明它所取出来的值和我们一开始放入的值是不一致的,也就证明了它是线程不安全的,所以在此我们要抛出一个 RuntimeException
提示我们。
运行结果如下:
Exception in thread "main" java.lang.RuntimeException: HashMap is not thread safe.
at com.example.rest.HashMapNotSafe.main(HashMapNotSafe.java:18)
很明显,很快这个程序就抛出了我们所希望看到的 RuntimeException,并且我们把它描述为:HashMap is not thread safe,一旦它能进入到这个 if 语句,就已经证明它所取出来的值是 null,而不是我们期望的字符串 “v”。
比如,有多个线程同时使用 put
来添加元素,而且恰好两个 put
的 key
是一样的,它们发生了碰撞,也就是根据 hash
值计算出来的 bucket
位置一样,并且两个线程又同时判断该位置是空的,可以写入,所以这两个线程的两个不同的 value
便会添加到数组的同一个位置,这样最终就只会保留一个数据,丢失一个数据。
可见性也是线程安全的一部分,如果某一个数据结构声称自己是线程安全的,那么它同样需要保证可见性,也就是说,当一个线程操作这个容器的时候,该操作需要对另外的线程都可见,也就是其他线程都能感知到本次操作。可是 HashMap
对此是做不到的,如果线程1
给某个 key
放入了一个新值,那么线程2
在获取对应的 key
的值的时候,它的可见性是无法保证的,也就是说线程2
可能可以看到这一次的更改,但也有可能看不到。所以从可见性的角度出发,HashMap
同样是线程非安全的。
HashMap
有可能会发生死循环并且造成 CPU 100%
,这种情况发生最主要的原因就是在扩容的时候,也就是内部新建新的 HashMap
的时候,扩容的逻辑会反转散列桶中的节点顺序,当有多个线程同时进行扩容的时候,由于 HashMap
并非线程安全的,所以如果两个线程同时反转的话,便可能形成一个循环,并且这种循环是链表的循环,相当于 A 节点
指向B 节点
,B 节点
又指回到 A 节点
,这样一来,在下一次想要获取该 key
所对应的 value
的时候,便会在遍历链表的时候发生永远无法遍历结束的情况,也就发生 CPU 100%
的情况。