关于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
相关推荐
AskHarries44 分钟前
Java字节码增强库ByteBuddy
java·后端
佳佳_1 小时前
Spring Boot 应用启动时打印配置类信息
spring boot·后端
许野平2 小时前
Rust: 利用 chrono 库实现日期和字符串互相转换
开发语言·后端·rust·字符串·转换·日期·chrono
BiteCode_咬一口代码3 小时前
信息泄露!默认密码的危害,记一次网络安全研究
后端
齐 飞4 小时前
MongoDB笔记01-概念与安装
前端·数据库·笔记·后端·mongodb
LunarCod4 小时前
WorkFlow源码剖析——Communicator之TCPServer(中)
后端·workflow·c/c++·网络框架·源码剖析·高性能高并发
码农派大星。5 小时前
Spring Boot 配置文件
java·spring boot·后端
杜杜的man5 小时前
【go从零单排】go中的结构体struct和method
开发语言·后端·golang
幼儿园老大*5 小时前
走进 Go 语言基础语法
开发语言·后端·学习·golang·go
llllinuuu5 小时前
Go语言结构体、方法与接口
开发语言·后端·golang