何为原子性
原子性在编程里通常指的是一个操作不可被中断,要么全部完成,要么完全不执行。就像银行业务不能出现付款方支付成功,收款方余额没有增加的情况,那么原子性的操作需要满足一下特点:
- 不可分割:操作不能被中途打断,要么执行成功,要么执行失败
- 不受并发影响:多个线程同时执行该操作时,结果是确定的,不会因为并发而导致数据不一致。
- 一致性:原子操作在执行时,系统始终保持一致状态,不会出现中间状态。
举一个例子来说明:当前篮子里有8个橘子,需要凑够10个橘子封箱,此时线程A查询到需要向篮子里放2个橘子,恰好线程B也查询到需要向篮子里放2个橘子,那么它们各自向篮子里放了2个橘子,此时篮子里有12个橘子。
分析一下往篮子里放橘子的步骤:
- 读取到当前篮子里有8个橘子
- 对篮子里的橘子进行加2操作,以放满篮子
- 写入篮子最新的橘子个数
以上是多个独立的步骤组成的,每个步骤都可能被其他线程打断,即:线程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。
所以非原子性操作会造成以下一系列的问题:
特点 | 描述 | 可能导致的问题 |
---|---|---|
可分割性 | 流程由多个步骤组成,可以被打断 | 竞态条件 |
竞态条件 | 多个线程同时访问数据,可能导致错误 | 数据不一致 |
非线程安全 | 没有同步机制,容易被多个线程修改 | 并发冲突 |
丢失更新 | 一个线程的修改被另一个线程覆盖 | 数据修改丢失 |
超量修改 | 由于多个线程同时修改,导致最终修改超量 | 多次写入错误 |
数据不一致 | 操作未完成前,数据可能被其他线程修改 | 事务不完整 |
线程安全,最重要的是保证操作的原子性,在中间过程的减少竟态条件。
总结
综上所述,为了保证线程安全,我们可以采取以下措施:首先,通过加锁(如 synchronized
、 ReentrantLock
或Redis
)将非原子性代码段合并为一个整体操作,防止并发干扰。其次,合理使用 AtomicInteger
、ConcurrentHashMap
等线程安全工具提供的原子性方法,以减少锁的开销,提高并发性能。最后,充分发挥并发工具的特性,选择合适的同步机制,使程序既安全又高效。