阅读本文章, 最好有基础的多线程知识,确保你能看懂文章所表达的意思
问题描述
- 我们创建一个类,在类中维护一个HashMap(非线程安全,不要扛为什么不用ConcurrentHashMap,那不在本文的导论范围内)
- 然后我们开启多个线程对这个Map的2个Key,分别为A、B,我们从Map中取出value进行+1的操作在放回去。
- 在线程不安全的情况下,这个Map里两个Key对应的Value大概率是跟我们操作的次数不相同
- 我们先来代码操作
package com.yuxuan66.demo;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* @author Sir丶雨轩
* @since 2022/12/29
*/
public class LockTest {
public static class DoSomething{
private final Map<String, Integer> countMap = new ConcurrentHashMap<>();
private final Lock lockA = new ReentrantLock();
private final Lock lockB = new ReentrantLock();
public void some(String key) {
int integer = countMap.getOrDefault(key, 0);
countMap.put(key, integer + 1);
}
public synchronized void print() {
System.out.println(countMap);
// TODO 注意 我们在这里判断了理想值是否跟真实的结果一致
if (countMap.get("A") != 5000 || countMap.get("B") != 5000) {
System.out.println("error");
System.exit(0);
}
}
}
public static class Run implements Runnable{
private final DoSomething doSomething;
private final String key;
public Run(DoSomething doSomething, String key) {
this.doSomething = doSomething;
this.key = key;
}
@Override
public void run() {
for (int i = 0; i < 100; i++) {
doSomething.some(key);
}
}
}
public static void main(String[] args) throws InterruptedException {
// 由于要展示的问题不一定稳定复现,所以我们开启多次循环,在重复校验我们的代码
for (int j = 0; j < 10000; j++) {
DoSomething doSomething = new DoSomething();
Thread[] threads = new Thread[100];
for (int i = 0; i < 100; i += 2) {
threads[i] = new Thread(new Run(doSomething, "A"));
threads[i + 1] = new Thread(new Run(doSomething, "B"));
}
for (int i = 0; i < 100; i++) {
threads[i].start();
}
for (int i = 0; i < 100; i++) {
threads[i].join();
}
doSomething.print();
}
}
}
- 在上面代码中,我们展示了在完全不考虑线程安全的下,每一次运行都会直接结束掉
- 下面我们尝试第一种线程安全的方式 synchronized
public void some(String key) {
synchronized (countMap){
int integer = countMap.getOrDefault(key, 0);
countMap.put(key, integer + 1);
}
}
- 这时我们会发现,运行的结果始终如一的跟我们预想的一样
- 接下来我们来尝试第二种方式。ReentrantLock
- 首先我们尝试在对这个Map每次操作时都使用同一把锁
public void some(String key) {
Lock lock = lockA;
lock.lock();
try{
int integer = countMap.getOrDefault(key, 0);
countMap.put(key, integer + 1);
}finally {
lock.unlock();
}
}
- 这是我们发现运行的结果跟synchronized是没有区别的,都是正确的
- 下面到了本文所描述的重点
- 当我们对Map的2两个Key分别使用不同的锁,这个可能在一定的环境下也是有使用场景的,比如对应的锁可能会被其他对象所控制。
- 这里就简单模拟一下,对两个Key分别使用两把不同的锁
public void some(String key) {
Lock lock = "A".equals(key) ? lockA : lockB;
lock.lock();
try {
int integer = countMap.getOrDefault(key, 0);
countMap.put(key, integer + 1);
} finally {
lock.unlock();
}
}
-
这个时候我们在运行就会发现结果错误了,
-
这个时候大家可以想一下为什么会出现这个情况呢?对Map的操作明明都在lock的代码内。我们对于每一个key的操作都是独立的锁,理论来说是不会冲突的
-
其实这个问题还真就跟锁没什么关系,让我们来打开HashMap的源码(1.8环境下)的 397行
-
我们可以发现注释写的很清楚,这个存放着Map数据的table数组会在第一次使用的时候初始化,并非在new HashMap的时候完成。
-
我们也可以打开源码的第630行,这里在table == null的时候去调用了一个方法 resize 我们可以看到,table在这里进行了初始化,也就是第一次赋值的时候完成。那么接下来我们使用代码来验证一下
public void some(String key) {
Lock lock = "A".equals(key) ? lockA : lockB;
lock.lock();
try {
Field field = HashMap.class.getDeclaredField("table");
field.setAccessible(true);
System.out.println("table = " + field.get(countMap));
// 直接结束掉程序,避免日志过多无法看到输出
System.exit(0);
int integer = countMap.getOrDefault(key, 0);
countMap.put(key, integer + 1);
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
- 到此我们就弄明白了这次的问题,由于两个Key的两把锁同时进入了resize方法,thread-0 初始化->putVal,thread-1 初始化(这个时候覆盖掉了thread-0 put的val)->putVal 所以就会导致我们看到的结果总是会少1
- 才疏学浅,如果错误 还望指教
以下为ChatGPT生产的内容总结
问题概述:
文章描述了一个多线程下操作非线程安全的 HashMap
(没有使用 ConcurrentHashMap
)的问题。在多个线程并发操作同一个 Map
中的不同键(A
和 B
)时,结果大概率会出现错误,即操作次数和预期的结果不一致。
代码分析:
-
基本场景:
- 有一个
DoSomething
类,其中包含一个HashMap
,用于存储键值对。 - 我们使用多个线程分别对两个键(
A
和B
)进行并发操作,每个线程会对Map
中的对应键的值进行加 1 操作。
- 有一个
-
线程不安全:
- 代码运行时,线程并发地修改
HashMap
,并且没有任何同步机制,所以线程操作会互相干扰,导致操作结果错误(即A
和B
的值不正确)。
- 代码运行时,线程并发地修改
-
使用
synchronized
:- 通过使用
synchronized
对countMap
锁定,保证了每个线程访问Map
时是串行的,避免了并发问题,结果是正确的。
- 通过使用
-
使用
ReentrantLock
:- 使用
ReentrantLock
来加锁,每次操作都用同一把锁,结果是正确的,跟synchronized
的效果一样。
- 使用
-
关键问题——不同键使用不同锁:
- 文章重点描述了一个场景,即分别使用不同的锁来对
Map
的两个不同键(A
和B
)进行并发操作:Lock lock = "A".equals(key) ? lockA : lockB;
- 在这个情况下,尽管每个键的操作有独立的锁,但程序运行结果却出现了错误。代码中的每个线程会为
A
和B
分配不同的锁,但这并没有解决并发问题,反而引发了错误。
- 文章重点描述了一个场景,即分别使用不同的锁来对
错误的根本原因——HashMap
的实现:
-
文章通过对
HashMap
源码的分析,发现问题根源在于HashMap
在初始化时使用的table
数组是懒加载的(即第一次使用时才初始化)。 -
当两个线程同时访问
Map
时,可能会触发resize
操作(即扩容)。HashMap
扩容时会重新分配table
数组,并可能会导致前一个线程操作的数据被覆盖。- 例如:
- 线程 0 执行
put
操作并初始化table
。 - 线程 1 也执行了
put
操作,且由于线程 1 执行时触发了resize
,此时它覆盖了线程 0 初始化的数据,导致线程 0 的数据丢失。
- 线程 0 执行
这个问题实际上是由于
HashMap
的resize
操作是线程不安全的,多个线程在同时触发resize
时会发生数据覆盖,从而导致数据丢失。 - 例如:
结论:
- 多线程并发问题:文章通过实际代码演示了在多线程环境下,对
HashMap
进行并发操作时,如何由于没有适当的同步,导致结果不一致。 - 线程安全:即使为
A
和B
使用了不同的锁,问题依然存在,因为锁的粒度太小(锁只是保护了单个键的操作,而不是整个Map
的操作)。HashMap
在执行resize
时没有同步,导致并发修改时,数据可能会丢失或被覆盖。 - 解决方法:可以使用
ConcurrentHashMap
,它是线程安全的,或者为整个Map
操作加锁,确保整个操作过程的同步性。
这篇文章的核心是通过简单的代码示例,演示了并发操作时常见的错误,强调了 HashMap
在多线程下的线程安全问题,并提出了使用不同锁可能带来的问题。
评论区