文章目录
- 常见集合线程安全性
- HashMap为什么线程不安全?
- 怎么保证HashMap线程安全
- Hashtable
- ConcurrentHashMap 引入细粒度锁
- 代码中分析
- 总结
- 小结
常见集合线程安全性
ArrayList、LinkedList、TreeSet、HashSet、HashMap
、TreeMap等都是线程不安全的。
HashTable
是线程安全的。
HashMap为什么线程不安全?
来看个例子
public static void main(String[] args) {
HashMap<String, Integer> map = new HashMap<>();
// 创建两个线程同时向HashMap中添加1000个元素
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
map.put(String.valueOf(i), i);
}
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 1000; i < 2000; i++) {
map.put(String.valueOf(i), i);
}
}
});
// 启动线程
thread1.start();
thread2.start();
try {
// 等待两个线程执行完成
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 输出 HashMap 的大小
System.out.println("map集合大小: " + map.size());
}
//输出结果:map集合大小:1920(这个数字在变动)
在多线程环境下,如果多个线程同时对 HashMap
进行修改操作(例如添加、删除元素),可能会导致数据结构破坏,进而引发各种问题,比如丢失数据等。
怎么保证HashMap线程安全
第一:
如何保证HashMap的线程安全呢?可能你们想到了synchronized
,确实,你可以通过在添加元素时使用 synchronized 来确保 HashMap 的线程安全性。这样做可以在多线程环境下保证对 HashMap 的操作是互斥的,从而避免了多个线程同时修改 HashMap 导致的线程不安全问题。
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
//synchronized (map) 中的 map 是一个对象锁
//它指定了在执行同步代码块时使用的锁对象
synchronized (map) {
for (int i = 0; i < 1000; i++) {
map.put(String.valueOf(i), i);
}
}
}
});
当一个线程进入同步代码块(即 synchronized (map) 所包围的部分)时,它会尝试获取 map 对象的锁。如果这个锁当前没有被其他线程占用,那么该线程将获得锁,并可以执行同步代码块中的操作;如果该锁已经被其他线程占用,那么该线程将被阻塞,直到锁被释放。被锁住的对象将会在同步代码块执行完毕后自动释放。
第二:
使用Collections.synchronizedMap()
包装:
Map<Integer, String> synchronizedMap = Collections.synchronizedMap(new HashMap<>());
这样可以获得一个线程安全的 HashMap,但性能可能不如 ConcurrentHashMap。
第三:
使用 ConcurrentHashMap:
ConcurrentHashMap
是专门为高并发环境设计的,JDK 1.8它使用了 CAS + synchronized
来保证线程安全性,而且性能表现优秀。
Map<Integer, String> concurrentHashMap = new ConcurrentHashMap<>();
Hashtable
Hashtable
使用的是全表加锁的方式来保证线程安全,也就是说,当一个线程要对 Hashtable 进行读写操作时,它会对整个 Hashtable 加锁,阻塞其他所有线程的访问
HashTable 替代品 ConcurrentHashMap ,ConcurrentHashMap 引入了细粒度锁和无锁读取等技术,大大提高了并发环境下的性能和扩展性
ConcurrentHashMap 引入细粒度锁
ConcurrentHashMap
之所以是线程安全的,主要是因为它在内部实现时采用了特殊的机制来确保多个线程同时访问和修改数据时不会发生冲突。
JDK 1.7
版本中的实现:
ConcurrentHashMap 在 JDK 1.7 中使用了分段锁(Segmentation)的结构,将整个哈希表分成了多个段(Segment),每个段有自己的锁。不同段之间的修改操作可以并发进行(提高了并发性能),只有在同一段内的操作才需要竞争锁。
JDK 1.8
版本中的优化:
JDK 1.8 对 ConcurrentHashMap 进行了重大优化,废弃了分段锁的设计,而是采用了更细粒度的锁分离技术。
在 JDK 1.8 中,ConcurrentHashMap 内部使用了基于 CAS(Compare and Swap 是一种原子操作,用于在多线程环境下实现对共享数据的安全更新。CAS 是一种乐观锁机制,可以避免使用传统的互斥锁,提高了并发性能。)
操作的 synchronized
关键字
同时,ConcurrentHashMap 在 JDK 1.8 中引入了红黑树
作为链表的替代结构,当链表长度达到一定阈值时,会将链表转换为红黑树,以提高查找效率。这种优化又提高了 ConcurrentHashMap 的并发性能和吞吐量。
代码中分析
来看看put方法
public static void main(String[] args) {
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("a",111);
}
在 JDK8 中,ConcurrentHashMap 的实现方式已经改变,不再采用分段锁的方式,而是采用了 CAS+Synchronized 的方式来保证线程安全
我们需要先了解tabAt ,casTabAt(本质是CAS 算法,看下面源码可知)的利用来保障线程安全的操作:
tabAt
方法用于从哈希表数组 tab 中获取指定索引 i 处的节点数组。
casTabAt
方法用于原子性地将哈希表数组 tab 中指定索引 i 处的值从 c 更新为 v。(CAS 比较并交换,可实现原子操作)
put源码分析:
-
遍历桶(Bin)时不加锁:
首先,代码会根据hash
值找到对应的桶(tab[i]
),并且如果该桶是空的(f == null
),会尝试通过CAS(compare-and-swap)
操作将新的节点放入其中,这一部分操作是无锁的。else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; }
-
对非空桶的头节点加锁:
当桶tab[i]
已经存在节点时,会进入synchronized (f)
代码块,其中f
是桶中的头节点。通过对头节点f
加锁,保证对该桶中的修改是线程安全的。synchronized (f) { if (tabAt(tab, i) == f) { // 锁定成功后,进行进一步操作 } }
- 这种加锁方式是“桶级别”的锁,而不是对整个
HashMap
或ConcurrentHashMap
加全局锁,因此不会影响其他线程对其他桶的访问。
- 这种加锁方式是“桶级别”的锁,而不是对整个
-
对链表或树结构的操作:
加锁后,代码会对链表中的节点进行遍历,或者在红黑树中进行查找(如果桶已经转化为树形结构)。在这个过程中,同步块只对桶的头节点加锁,而不是对每个节点加锁。因此,在修改链表或树的过程中,保证了线程安全性,但并不会影响其他线程对其他桶的操作。-
如果该桶是链表结构,会遍历链表并进行元素的插入操作:
for (Node<K,V> e = f;; ++binCount) { K ek; if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { oldVal = e.val; if (!onlyIfAbsent) e.val = value; break; } Node<K,V> pred = e; if ((e = e.next) == null) { pred.next = new Node<K,V>(hash, key, value, null); break; } }
-
如果该桶是树形结构(红黑树),则调用
putTreeVal
方法进行树中的插入操作:if (f instanceof TreeBin) { Node<K,V> p; binCount = 2; if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) { oldVal = p.val; if (!onlyIfAbsent) p.val = value; } }
-
-
加锁粒度:
由于只对每个桶的头节点加锁,因此即使多个线程同时对不同的桶进行操作,也不会相互阻塞。只有在同一个桶中有多个线程试图修改数据时,才会发生锁竞争。这种设计提高了并发性能,相比全局锁的方式有更高的并发吞吐量。 -
树化操作:
当链表中的节点数量超过TREEIFY_THRESHOLD
时,会将链表转化为红黑树以提高查询和修改效率。树化也是在线程安全的环境下进行的,但依然是通过对头节点加锁实现的。if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i);
总结
- 加锁机制:对每个桶(
tab[i]
)的头节点
进行加锁。这种锁定策略使得同一个桶中的修改是线程安全的。 - 并发性能优化:这种设计避免了全局锁,提高了在高并发环境下的性能。当多个线程操作不同的桶时,操作不会相互干扰。
小结
而在 JDK 1.8 中,ConcurrentHashMap 放弃了分段锁,而是采用了更为精细的桶结构。每个桶可以独立加锁
,使得并发修改操作可以更细粒度地进行。此外,当桶中的元素数量达到一定阈值时,链表结构会转变为红黑树,以减少搜索时间。这种锁分离技术提高了并发性能,使得 ConcurrentHashMap 在并发情况下表现更加出色。它是通过 CAS + synchronized
来实现线程安全的,并且它的锁粒度更小
,查询性能也更高。
❤觉得有用的可以留个关注ya❤