常见线程安全问题之复合操作

ops/2024/11/27 11:59:42/

创作内容丰富的干货文章很费心力,感谢点过此文章的读者,点一个关注鼓励一下作者,激励他分享更多的精彩好文,谢谢大家!


复合操作的问题本质上和 TOCTOU 是一样的,如果有多个操作(如同一变量的读写)就有可能出现线程安全问题。

不过在本节我们要强调的是,即使每个操作本身是原子的,复合操作也不是原子的,这种情形有时候比较难一眼就认出来。

示例

这里以《Java 并发编程实战》第二章的“因式分解”代码为例:

 
java">@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();public void service(ServletRequest req, ServletResponse res) {BigInteger i = extractFromRequest(req);if (i.equals(lastNumber.get())) {               // ①encodeIntoResponse(resp, lastFactors.get());  // ②} else {BigInteger[] factors = factorOf(i);lastNumber.set(i);                            // ③lastFactors.set(factors);                     // ④encodeIntoResponse(resp, factors);}}
}

这个例子中 lastNumber 用来记录上一次做过“因式分解”的数,lastFactors 存放上次因式分解的结果。service 中先判断 lastNumber 是否与请求的数相等,如果相等则使用存储的 lastFactors;反之不相等则需要重新计算因式分解,并把结果存入 lastNumber 与 lastFactors 中。

这里 lastNumber 与 lastFactors 都用了 AtomicReference,它们是 JUC 中的类,可以理解为已经达到了原子性、可见性与顺序性。所以代码中的 ①②③④ 处的 get set 都是原子的,只不过复合操作的问题是,即使每个操作都是原子的,操作整体也不是原子的。

这个示例比较精妙的地方在于它很符合我们的编码习惯,如果不仔细思考甚至都发现不了它存在线程安全问题。

问题时序

考虑线程 A 与线程 B 同时进入 else 语句,且分别需要求得 2 和 3 的因式分解,考虑下面的时序:

----------- Thread A ---------------+--------- Thread B -----------------
lastNumber.set(i);         (=2)     || lastNumber.set(i);         (=3)| lastFactors.set(factors);  (=[1,3])
lastFactors.set(factors);  (=[1,2]) |

则最终结束后 lastNumber = 3lastFactors = [1,2],则下次请求如果是分解 3 ,则会使用 lastFactors 的值,得到结果 [1,2],是错误的结果。

另一方面,也有可能是这样的时序:

----------- Thread A ---------------+--------- Thread B -----------------
lastNumber.set(i);         (=2)     || if (i.equals(lastNumber.get()))  (= 2)|   encodeIntoResponse(resp, lastFactors.get());
lastFactors.set(factors);  (=[1,2]) |

这个时序里,一个线程计算了 2 的结果,正在写回缓存,过程中另一个线程请求因式分解 2,此时 lastNumber = 2,因此返回了 lastFactors 的内容,但线程 A 关于 2 的结果还未写回 lastFactors,线程 B 返回了一个错误的结果。

当然,也有可能是这样的时序:

----------- Thread A ---------------+--------- Thread B -----------------
Initial Value of lastNumber: 2      || if (i.equals(lastNumber.get()))  (= 2)
lastNumber.set(i);         (=3)     |
lastFactors.set(factors);  (=[1,3]) ||   encodeIntoResponse(resp, lastFactors.get()); (=[1,3])

不成熟的解法:同步方法

从 TOCTOU 一节中我们知道,要解决这种竞争问题,需要把对状态的检查与使用都变成原子的,最简单的方式就是在方法上用 synchronized

 
java">@ThreadSafe
public class UnsafeCachingFactorizer implements Servlet {// .. 省略代码public synchronized void service(ServletRequest req, ServletResponse res) {// .. 省略代码}
}

但是这个方法太极端了,所有的请求线程调用 service 方法都需要同步,同一时间只能有一个线程执行该方法,完全失去了多线程的优势。

解法:减小粒度

给整个方法加锁十分简单,但是由于锁的粒度很粗,并发性差。而我们的真实需求其实有两个:

  1. 对 lastNumber 和 lastFactors 的赋值操作需要是原子的
  2. 对 lastNumber 和 lastFactors 的读取也需要是原子的(至少读取过程中不允许赋值)

因此我们可以用 synchronized 代码块,实现如下:

 
java">public class UnsafeCachingFactorizer implements Servlet {private BigInteger lastNumber;private BigInteger[] lastFactors;public void service(ServletRequest req, ServletResponse res) {BigInteger i = extractFromRequest(req);BigInteger[] factors = null;synchronized (this) {                   // ①if (i.equals(lastNumber.get())) {factors = lastFactors.clone();}}if (factors == null) {factors = factor(i);synchronized (this) {                 // ②lastNumber = i;lastFactors = factors;}}encodeIntoResponse(resp, factors);}
}

在 ① 中把读操作用 synchronized 代码块保证原子性,在 ② 中用同样方法保证赋值的原子性。另一个关键点是,两个代码块需要加同一个锁,此处直接用了 this,是最稳妥的选择,当然也可以锁其它的 object,只要两个块加同一个锁即可。

另外此处因为使用了 synchronized,对 lastNumber 和 lastFactors 不再需要使用原子类。通常原子类(如 AtomicReference) 对单个操作的原子性保证很方便,但复合操作本身需要加锁,这里再使用原子类就显得没必要了。

小结

复合操作即使操作本身是原子的,复合操作作为一个整体本身也不具备原子性。所以和 TOCTOU 问题一样,解决方法是需要加锁来保证复合操作整体的原子性。

还有一点比较特殊,是我们看到“读操作”和“写操作”一样,都是必须要加锁的。

示例中我们也看到,并发编程是在简单性与并发性中的权衡。锁的粒度粗了,使用起来简单,但是并发性低,也许就满足不了性能要求;反之锁的粒度细了,并发性提高了,但是复杂度也随之增加,稍有不慎就容易有线程安全问题。


http://www.ppmy.cn/ops/137073.html

相关文章

解决 java -jar 报错:xxx.jar 中没有主清单属性

问题复现 在使用 java -jar xxx.jar 命令运行 Java 应用程序时&#xff0c;遇到了以下错误&#xff1a; xxx.jar 中没有主清单属性这个错误表示 JAR 文件缺少必要的启动信息&#xff0c;Java 虚拟机无法找到应用程序的入口点。本文将介绍该错误的原因以及如何通过修改 pom.xm…

工作坊报名|使用 TEN 与 Azure,探索你的多模态交互新场景

GPT-4o Realtime API 发布&#xff0c;语音 AI 技术正在进入一场新的爆发。语音AI技术的实时语音和视觉互动能力将为我们带来更多全新创意和应用场景。 实时音频交互&#xff1a; 允许应用程序实时接收并响应语音和文本输入。自然语音生成&#xff1a; 减少 AI 技术生成的语音…

多任务基础知识学习

一、单任务与多任务的区别&#xff1a; 学习链接&#xff1a;https://zhuanlan.zhihu.com/p/27421983 多任务学习:单模型解决多个问题_什么是单任务模型-CSDN博客 SingleTask: Train one model for each task, respectively 多任务学习(Multi-Task Leamning,MTL)是机器学习只…

微信小程序中会议列表页面的前后端实现

题外话&#xff1a;想通过集成腾讯IM来解决即时聊天的问题&#xff0c;如果含语音视频&#xff0c;腾讯组件一年5万起步&#xff0c;贵了&#xff01;后面我们改为自己实现这个功能&#xff0c;这里只是个总结而已。 图文会诊需求 首先是个图文列表界面 同个界面可以查看具体…

java框架Netty网络编程——问鼎篇

Netty进阶 01 初识 Netty&#xff1a;为什么 Netty 这么流行&#xff1f; 粘包现象 案例 服务端代码 public static void main(String[] args) {NioEventLoopGroup bossGroupnew NioEventLoopGroup(1);NioEventLoopGroup workerGroupnew NioEventLoopGroup(2);try {ServerBoo…

对比C++,Rust在内存安全上做的努力

简介 近年来&#xff0c;越来越多的组织表示&#xff0c;如果新项目在技术选型时需要使用系统级开发语言&#xff0c;那么不要选择使用C/C这种内存不安全的系统语言&#xff0c;推荐使用内存安全的Rust作为替代。 谷歌也声称&#xff0c;Android 的安全漏洞&#xff0c;从 20…

网络研讨会——如何使用Figma、Canva或Sketch设计Delphi移动应用程序

2024年11月30日星期六 - 北京午夜12点 如何使用Figma、Canva或Sketch设计Delphi移动应用程序 专业设计应用程序Figma、Sketch和Canva有大量优秀的应用程序设计等着你去实现。我们看看有什么可用的&#xff0c;并使用一些最好的设计来创建应用程序。。。 立即报名免费在线研讨会…

Redis(非关系型数据库)的作用 详细解读

edis&#xff08;Remote Dictionary Server&#xff09;是一个开源的、高性能的、基于内存的数据结构存储系统。它具有极高的读写性能&#xff0c;并且能够支持多种数据结构的存储。Redis 最初的设计目标是作为一个缓存解决方案&#xff0c;但随着其功能的不断扩展&#xff0c;…