1 概述
线程安全类可以分为3个大类:
- 遗留的的线程安全集合Hashtable,Vector
- 使用Collections装饰的线程安全集合,如:
- Collections.syncronizedCollection
- Collections.syncronizedList
- Collections.syncronizedMap
- Collections.syncronizedSet
- …
- java.util.concurrent.*;
- ConcurrentHashMap
- CopyOnWriteArrayList
- …
遗留的线程安全集合,方法全部加了synchronized,并发性能很低,不推荐。
Collections装饰的线程安全集合,以Collections.syncronizedMap为例,我们看下,它是怎么把线程不安全的集合变成线程安全map。
private static class SynchronizedMap<K,V>
implements Map<K,V>, Serializable {
private static final long serialVersionUID = 1978198479659022715L;
private final Map<K,V> m; // Backing Map
final Object mutex; // Object on which to synchronize
SynchronizedMap(Map<K,V> m) {
this.m = Objects.requireNonNull(m);
mutex = this;
}
SynchronizedMap(Map<K,V> m, Object mutex) {
this.m = m;
this.mutex = mutex;
}
public int size() {
synchronized (mutex) {return m.size();}
}
public boolean isEmpty() {
synchronized (mutex) {return m.isEmpty();}
}
public boolean containsKey(Object key) {
synchronized (mutex) {return m.containsKey(key);}
}
public boolean containsValue(Object value) {
synchronized (mutex) {return m.containsValue(value);}
}
public V get(Object key) {
synchronized (mutex) {return m.get(key);}
}
public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
// ...
}
包装后Collections.SyncronizedMap相当于在原因Map方法上加syncronized,并发性能并没有提升。
下面以ConcurrentHashMap为例详解下它与原HashMap相比如果提高并发性能。首先我们先看下原HashMap存在的一些并发问题。
2 HashMap并发问题
2.1 JD7并发死链问题
jd7在新增某个桶下标链表元素时,默认会插入链表头,源代码如下所示:
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];
table[bucketIndex] = new Entry<>(hash, key, value, e);
size++;
}
当jd7扩容时,可能引发死链问题,测试示例如下:
public class TestDeadLink {
public static void main(String[] args) {
HashMap<Integer, Integer> map = new HashMap<>();
// 1, 35, 16, 50 当map大小为16时,它们在同一桶内
// 放12个元素,容量的3/4
map.put(2, null);
map.put(3, null);
map.put(4, null);
map.put(5, null);
map.put(6, null);
map.put(7, null);
map.put(8, null);
map.put(9, null);
map.put(10, null);
map.put(16, null);
map.put(35, null);
map.put(1, null);
System.out.println("扩容前大小[main]:" + map.size());
new Thread() {
@Override
public void run() {
// 放入第13个元素,发生扩容
map.put(50, null);
System.out.println("扩容后大小[thread-0]:" + map.size());
}
}.start();
new Thread() {
@Override
public void run() {
// 放入第13个元素,发生扩容
map.put(50, null);
System.out.println("扩容后大小[thread-1]:" + map.size());
}
}.start();
}
}
说明:
HashMap默认容量16,当大小超过容量的3/4时,会扩容,扩容为原来容量的2倍,同时把HashMap中旧数组元素迁移至新数组中。
死链复现步骤:
调试工具使用的idea
在HashMap 590行加断点:即扩容时,迁移数据的方法中
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length; // 590行代码
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) { // 594行
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
断点条件如下,目的是让HashMap扩容为32时,线程为Thread-0或者Thread-1时停下来
newTable.length==32&&(Thread.currentThread().getName().equals("Thread-0")||Thread.currentThread().getName().equals("Thread-1"))
如图所示:
断点暂停方式选择Thread,开始调试程序。
长度为16时,桶下标为1的key
1
16
35
50
长度为32时,桶下标为1的key
1
35
扩容前大小[main]:12
在HashMap源码594行加断点,(条件Thread.currentThread().getName().equals(“Thread-0”)),这是为了观察e结点和next结点的状态。
Thread-0单步执行到594行,可以在Variables面板观察e和next变量,使用view as -> Object 查看结点状态
e (1)->(35)-(16)->null
next (35)->(16)->null
如图所示:
在Threads面板选择Thread-1恢复运行,控制台输出如下:
扩容后大小[thread-1]:13
此时
newTable[1] (35)->(1)->null
这是Thread-0还停留在594处,Variables面板变量状态发生改变
e (1)->null
next (35)->(1)->null
Thread-1虽然结果正确,但Thread-0还要继续执行。接下来单步调试观察死链产生,下一轮循环到594,将e迁移到newTable链表头
newTable[1] (1)->null
e (35)->(1)->null
next (1)->null
// 下一个循环
newTable[1] (35)->(1)->null
e (1)->null
next null
// 下一个循环
newTable[1] (35)<=>(1)
e null
newTable[1]形成环。如图所示:
进而程序卡死,如图:
总结:
- 究其原因,是因为在多线程环境下视野非线程安全的map集合
- jdk8虽然将扩容算法做了调整,不再将新元素加入链表头(而是保持插入顺序,即练尾),但在多线程环境下会出现其他问题。
2.2常见问题
改变HashMap存在数据丢失可能,以Jdk8新增为例,源代码如下:
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;
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) { // 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;
}
在 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); 下标i新增第一个链表元素时,如果多线程并发新增元素都在桶下标i处,那么后执行的会覆盖掉先执行,引起数据丢失;其他类型的新增同样存在数据丢失的可能。
在删除数据的时候,多线程情况下如果要删除同一key导致可能删除对应key的后继结点元素或者其他情况。
总之线程不安全的HashMap在并发情况下,可能出现各种意外情况,特别当Jdk版本为7时,可能引发并发死链问题。
下面我们将详解ConcurrentHashMap,以jd8中为例。
3 后记
如有问题,欢迎交流讨论。
❓QQ:806797785
⭐️源代码仓库地址:https://gitee.com/gaogzhen/concurrent
参考:
[1]黑马程序员.黑马程序员深入学习Java并发编程,JUC并发编程全套教程[CP/OL].2020-01-18/2022-12-12.p274~p280.