阅读本文章, 最好有基础的多线程知识,确保你能看懂文章所表达的意思
问题描述
- 我们创建一个类,在类中维护一个HashMap(非线程安全,不要扛为什么不用ConcurrentHashMap,那不在本文的导论范围内)
- 然后我们开启多个线程对这个Map的2个Key,分别为A、B,我们从Map中取出value进行+1的操作在放回去。
- 在线程不安全的情况下,这个Map里两个Key对应的Value大概率是跟我们操作的次数不相同
- 我们先来代码操作
java
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
java
public void some(String key) {
synchronized (countMap){
int integer = countMap.getOrDefault(key, 0);
countMap.put(key, integer + 1);
}
}
- 这时我们会发现,运行的结果始终如一的跟我们预想的一样
- 接下来我们来尝试第二种方式。ReentrantLock
- 首先我们尝试在对这个Map每次操作时都使用同一把锁
java
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分别使用两把不同的锁
java
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在这里进行了初始化,也就是第一次赋值的时候完成。那么接下来我们使用代码来验证一下
java
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
- 才疏学浅,如果错误 还望指教
- 另外求职:www.yuxuan66.com/job