通过ConcurrentHashMap带你了解并发中的原子性与竟态条件

请添加图片描述

何为原子性

原子性在编程里通常指的是一个操作不可被中断,要么全部完成,要么完全不执行。就像银行业务不能出现付款方支付成功,收款方余额没有增加的情况,那么原子性的操作需要满足一下特点:

  1. 不可分割:操作不能被中途打断,要么执行成功,要么执行失败
  2. 不受并发影响:多个线程同时执行该操作时,结果是确定的,不会因为并发而导致数据不一致。
  3. 一致性:原子操作在执行时,系统始终保持一致状态,不会出现中间状态。

举一个例子来说明:当前篮子里有8个橘子,需要凑够10个橘子封箱,此时线程A查询到需要向篮子里放2个橘子,恰好线程B也查询到需要向篮子里放2个橘子,那么它们各自向篮子里放了2个橘子,此时篮子里有12个橘子。

分析一下往篮子里放橘子的步骤:

  1. 读取到当前篮子里有8个橘子
  2. 对篮子里的橘子进行加2操作,以放满篮子
  3. 写入篮子最新的橘子个数

以上是多个独立的步骤组成的,每个步骤都可能被其他线程打断,即:线程A在对篮子的橘子进行加2时,线程B可能读取到篮子里有8个橘子,所以以上步骤是非原子性的。测试代码如下:

//线程个数
private static int THREAD_COUNT = 2;

//总元素数量
private static int ITEM_COUNT = 10;

@GetMapping("/current/wrong")
public String wrong() throws InterruptedException {
    ConcurrentHashMap concurrentHashMap = getData(ITEM_COUNT - 2);
    // 初始化8个元素
    log.info("init size:{}", concurrentHashMap.size());

    ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
    // 使用线程池开启2个线程并发处理
    forkJoinPool.execute(() -> IntStream.rangeClosed(1, 2).parallel().forEach(i -> {
    // 查询还需要补充多少元素,此时反应的是ConCurrentHashMap的中间状态
    int gap = ITEM_COUNT - concurrentHashMap.size();
    log.info("gap size:{}", gap);
    // 补充元素
    concurrentHashMap.putAll(getData(gap));
}));

    // 等待所有任务完成
    forkJoinPool.shutdown();
    forkJoinPool.awaitTermination(1, TimeUnit.HOURS);
    log.info("finish size:{}", concurrentHashMap.size());
    return "OK";
}

    // 向Map中构造制定个数元素
    private ConcurrentHashMap getData(int count) {
        return LongStream.rangeClosed(1, count).boxed()
                .collect(Collectors.toConcurrentMap(i -> UUID.randomUUID().toString(),
                        Function.identity(), (o1, o2) -> o1, ConcurrentHashMap::new));
    }

测试结果:

ConcurrentHashMap明明是线程安全的工具类,为什么会出现了线程安全问题?

以上代码中,读取-> 计算填入多个橘子-> 写入数据不是原子操作,因为实现该功能需要先后完成三个步骤,获取当前篮子中橘子个数时反应的是一个中间状态,且多线程情况下,写入操作的结果可能会被其他线程覆盖。ConcurrentHashMap 只能保证提供的原子性读写操作是线程安全的。

// 获取篮子中当前橘子的个数
ConcurrentHashMap concurrentHashMap = getData(ITEM_COUNT - 2);
// 计算还需几个橘子填满
int gap = ITEM_COUNT - concurrentHashMap.size();
// 写入补充元素
concurrentHashMap.putAll(getData(gap));

如何解决这个问题呢?对这三个步骤进行加锁,将这三个步骤组合为一个步骤即可。实现方案形如JDK提供的synchronized关键字、Redis锁等,为了方便演示代码使用syncronized关键字实现。

ForkJoinPool forkJoinPool = new ForkJoinPool(THREAD_COUNT);
// 使用线程池并发处理逻辑
forkJoinPool.execute(() -> IntStream.rangeClosed(1, 2).parallel().forEach(i -> {
    // 查询还需要补充多少元素,此时反应的是ConCurrentHashMap的中间状态
    synchronized (concurrentHashMap) {
        int gap = ITEM_COUNT - concurrentHashMap.size();
        log.info("gap size:{}", gap);
        // 补充元素
        concurrentHashMap.putAll(getData(gap));
    }
}));

请添加图片描述请添加图片描述

使用synchronized并不是ConcurrentHashMap的最佳实践,它提供了原子性方法computeIfAbsent来做符合逻辑操作。下文中的代码包含两个例子,可以使用ConcurrentHashMap提供的原子性方法避免多线程问题,同时提高其性能。

import java.util.concurrent.ConcurrentHashMap;

public class CompositeOperationsDemo {
    private static final ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();

    // 错误示例:非原子的检查后插入
    public static void unsafePut(String key, String value) {
        // 检查
        if (!map.containsKey(key)) {
            // 插入(可能被其他线程覆盖)
            map.put(key, value);               
        }
    }

    // 正确示例
    public static void safeCompute(String key) {
        map.computeIfAbsent(key, k -> value);
    }
}

什么是竟态条件

上文中我们讲到原子性是同时成功,要么同时失败,非原子性操作时,会出现多个线程同时修改共享数据的情况,正如上文举例中往篮子里放橘子一样,篮子里的橘子就是共享数据,线程A和线程B同时会向篮子里放橘子,线程A(小明)和线程B(小红)同时往篮子里的放橘子,两人放完橘子后发现篮子里无法容纳这么多橘子,形成了数据竞争。

所以竟态条件就是:多个线程 同时修改 共享数据时,如果不加控制,会出现 数据竞争 ,导致意外结果。

Thread A 读取 oranges = 8
Thread B 读取 oranges = 8
Thread A 计算并写入 oranges = 10
Thread B 计算并写入 oranges = 10
A 和 B 读取到相同值,分别计算 needed,最终写入的值可能错误。
结果可能会是 12 而不是 10。

所以非原子性操作会造成以下一系列的问题:

特点描述可能导致的问题
可分割性流程由多个步骤组成,可以被打断竞态条件
竞态条件多个线程同时访问数据,可能导致错误数据不一致
非线程安全没有同步机制,容易被多个线程修改并发冲突
丢失更新一个线程的修改被另一个线程覆盖数据修改丢失
超量修改由于多个线程同时修改,导致最终修改超量多次写入错误
数据不一致操作未完成前,数据可能被其他线程修改事务不完整

线程安全,最重要的是保证操作的原子性,在中间过程的减少竟态条件。

总结

综上所述,为了保证线程安全,我们可以采取以下措施:首先,通过加锁(如 synchronizedReentrantLockRedis)将非原子性代码段合并为一个整体操作,防止并发干扰。其次,合理使用 AtomicIntegerConcurrentHashMap 等线程安全工具提供的原子性方法,以减少锁的开销,提高并发性能。最后,充分发挥并发工具的特性,选择合适的同步机制,使程序既安全又高效。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值