关于ReentrantLock和HashMap配合使用产生的问题

阅读本文章, 最好有基础的多线程知识,确保你能看懂文章所表达的意思

问题描述

  1. 我们创建一个类,在类中维护一个HashMap(非线程安全,不要扛为什么不用ConcurrentHashMap,那不在本文的导论范围内)
  2. 然后我们开启多个线程对这个Map的2个Key,分别为A、B,我们从Map中取出value进行+1的操作在放回去。
  3. 在线程不安全的情况下,这个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
相关推荐
sivdead4 小时前
智能体记忆机制详解
人工智能·后端·agent
拉不动的猪5 小时前
图文引用打包时的常见情景解析
前端·javascript·后端
该用户已不存在5 小时前
程序员的噩梦,祖传代码该怎么下手?
前端·后端
间彧5 小时前
Redis缓存穿透、缓存雪崩、缓存击穿详解与代码实现
后端
摸鱼的春哥5 小时前
【编程】是什么编程思想,让老板对小伙怒飙英文?Are you OK?
前端·javascript·后端
Max8126 小时前
Agno Agent 服务端文件上传处理机制
后端
调试人生的显微镜6 小时前
苹果 App 怎么上架?从开发到发布的完整流程与使用 开心上架 跨平台上传
后端
顾漂亮6 小时前
Spring AOP 实战案例+避坑指南
java·后端·spring
间彧6 小时前
Redis Stream相比阻塞列表和发布订阅有哪些优势?适合什么场景?
后端
间彧6 小时前
Redis阻塞弹出和发布订阅模式有什么区别?各自适合什么场景?
后端