目 录CONTENT

文章目录

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

Sir丶雨轩
2022-12-29 / 1 评论 / 2 点赞 / 968 阅读 / 0 字 / 正在检测是否收录...

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

问题描述

  1. 我们创建一个类,在类中维护一个HashMap(非线程安全,不要扛为什么不用ConcurrentHashMap,那不在本文的导论范围内)
  2. 然后我们开启多个线程对这个Map的2个Key,分别为A、B,我们从Map中取出value进行+1的操作在放回去。
  3. 在线程不安全的情况下,这个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 中的不同键(AB)时,结果大概率会出现错误,即操作次数和预期的结果不一致。

代码分析:

  1. 基本场景:

    • 有一个 DoSomething 类,其中包含一个 HashMap,用于存储键值对。
    • 我们使用多个线程分别对两个键(AB)进行并发操作,每个线程会对 Map 中的对应键的值进行加 1 操作。
  2. 线程不安全:

    • 代码运行时,线程并发地修改 HashMap,并且没有任何同步机制,所以线程操作会互相干扰,导致操作结果错误(即 AB 的值不正确)。
  3. 使用 synchronized

    • 通过使用 synchronizedcountMap 锁定,保证了每个线程访问 Map 时是串行的,避免了并发问题,结果是正确的。
  4. 使用 ReentrantLock

    • 使用 ReentrantLock 来加锁,每次操作都用同一把锁,结果是正确的,跟 synchronized 的效果一样。
  5. 关键问题——不同键使用不同锁:

    • 文章重点描述了一个场景,即分别使用不同的锁来对 Map 的两个不同键(AB)进行并发操作:
      Lock lock = "A".equals(key) ? lockA : lockB;
      
    • 在这个情况下,尽管每个键的操作有独立的锁,但程序运行结果却出现了错误。代码中的每个线程会为 AB 分配不同的锁,但这并没有解决并发问题,反而引发了错误。

错误的根本原因——HashMap 的实现:

  • 文章通过对 HashMap 源码的分析,发现问题根源在于 HashMap 在初始化时使用的 table 数组是懒加载的(即第一次使用时才初始化)。

  • 当两个线程同时访问 Map 时,可能会触发 resize 操作(即扩容)。HashMap 扩容时会重新分配 table 数组,并可能会导致前一个线程操作的数据被覆盖。

    • 例如:
      1. 线程 0 执行 put 操作并初始化 table
      2. 线程 1 也执行了 put 操作,且由于线程 1 执行时触发了 resize,此时它覆盖了线程 0 初始化的数据,导致线程 0 的数据丢失。

    这个问题实际上是由于 HashMapresize 操作是线程不安全的,多个线程在同时触发 resize 时会发生数据覆盖,从而导致数据丢失。

结论:

  1. 多线程并发问题:文章通过实际代码演示了在多线程环境下,对 HashMap 进行并发操作时,如何由于没有适当的同步,导致结果不一致。
  2. 线程安全:即使为 AB 使用了不同的锁,问题依然存在,因为锁的粒度太小(锁只是保护了单个键的操作,而不是整个 Map 的操作)。 HashMap 在执行 resize 时没有同步,导致并发修改时,数据可能会丢失或被覆盖。
  3. 解决方法:可以使用 ConcurrentHashMap,它是线程安全的,或者为整个 Map 操作加锁,确保整个操作过程的同步性。

这篇文章的核心是通过简单的代码示例,演示了并发操作时常见的错误,强调了 HashMap 在多线程下的线程安全问题,并提出了使用不同锁可能带来的问题。

2
广告 广告

评论区