深入解析线程安全问题:并发开发中的关键挑战与解决方案

线程安全问题在并发开发中极为重要,它是一个重点中的重点。接下来这篇文章就来讲讲与线程安全相关的一些问题。那么,线程安全究竟是什么概念呢?这个概念有简单的一面,也有复杂的一面。“安全”的概念究竟是什么呢?用我个人浅显的话来说,就是在类/对象处于多线程并发执行的场景时,能够确保程序的逻辑是可以被接受的,而不会被扰乱,能够保证业务处理不会出现问题。这个定义并不标准,但线程安全的实际概念确实很难用简单而又准确的方式表达出来。我们从下面的故事开始说起。

深入解析线程安全问题:并发开发中的关键挑战与解决方案

我们之前提到了一些“主线程”和“新线程”,并且也会运用线程池里的线程去完成任务。倘若这些任务都能“各自承担起自己的职责”“彼此之间不相互干扰”,那么自然就会一切顺利。这个条件似乎很苛刻。在很多场景中,为了更好地完成许多任务,各个线程需要相互合作并共享一些数据。例如,在一个 Java 程序运行时,堆内存中有一块对象 O,当前线程 P 创建了这个对象并持有其引用。这时,我们需要充分利用 CPU 资源开启几个额外的线程来处理任务,并将对象 O 传递给它们,比如有线程 A 和 B。现在在线程 P 中,线程 A 和 B 都持有堆中唯一对象 O 的引用,所以它们都能够对 O 进行操作。这样是否会有问题呢?不一定。如果对象 O 的引用仅在这 3 个线程中持有,并且这 3 个线程对对象 O 的操作都是只读的,即 O 从创建开始就没有发生过变化,那么可以想象不会有任何问题存在。但如果有线程会改变对象O,问题就不这么简单了。

继续讲上面的故事以更具体化。在对象 O 中,有一个 int 变量 a,A 和 B 线程都能看到它,当前初始值为 0。此时 A 和 B 同时尝试对 O.a 进行加 1 操作,A 线程读取得到 0,然后加 1 变为 1,但还没来得及写回去,B 也进行了同样操作,以为应该把 1 写回去。那么 A 和 B 线程将 0 写成 1 这个过程是否正确呢?如果 P 线程创建了 A 和 B 线程,其目的是最终让 0 变为 1,这样是没问题的。然而,如果我们的目的是让 A 和 B 分别帮助对象 O“成长”,并且让 a 成为 A 线程和 B 线程某操作次数的记录,那么显然,在上述过程中,如果 A 和 B 都把 1 写回去,a 就会漏掉一次。这是一个被称作“read-and-update”的过程,同时也有的被叫做“read-modify-write”。

刚才那个例子是先读取然后进行更新并写回。现在来看另一种情况。前面所描述的共享对象 O ,除了在上述情况下用到的属性 a 之外,现在还有一个属性 b ,其类型为 java.lang.Object ,在初始情况下,b 的引用为 null 。现在的需求是针对 b 进行 Lazy Init(延迟初始化)。由第一个访问到 b 的线程来负责创建和初始化它,并且后续不允许对其进行更改。这与单例模式对象的实现有相似之处。简单而言,就是如下的代码:

if ( O.b == null )

O.b = new Object();

现在 A 和 B 共享对象 O,并且同时使用到 b 属性,这样做会不会有问题呢?按照刚才描述的需求,必然会有问题,因为这破坏了第二个要求,即引用“不允许在发生变化”。当 A 首先察觉到属性 b 为空时,会创建一个对象 Object1 赋予 b。然而,创建这个对象需要一系列操作,在还未完成创建且 b 引用仍为 null 的时候,B 线程到达了,接着也进入了 if 代码块,并创建了一个对象 Object2。这样一来,b 会先被赋予 Object1,之后才是 Object2。在这个例子里,Object 对象的创建只是一个代表。实际上,实际的操作可以更为复杂,而这种操作的结果会导致 if 的条件不成立。这是另一类情况,被称作“check then act”。

以上两个例子是我在写这篇整理时临时编造的,不知是否完全合适。然而,read and update 以及 check then act 涵盖了线程安全问题中的许多竞争情况。

解决简单问题有一个方案,那就是保证原子性。从上述情况能够看出,出现问题的原因在于 read 和 update 之间以及 check 和 act 的操作持续了足够长的时间,并且在这期间允许其他线程插入并进行操作,也就是说,我们进行了一系列操作,而这些操作之间给其他线程留出了余地。如果我们能保证这一系列操作“足够快”且“足够小”,就不会给别的线程可乘之机。其中一个方案是实现操作的原子化。在 JavaSE5 之后,java.util.concurrent.atomic 包中提供了丰富的原子数据类以及具有原子保证的操作方法。在 Sun JDK 里,这些是通过 sun.misc.Unsafe 类来实现的。需要留意的是,不存在与 Float 和 Double 相对应的原子类。

对于较为复杂的情形,原子类无法解决问题,此时通常需要借助锁来处理。“复杂”的情形有诸多,较为常见的一种是,倘若一个操作需要牵涉到两个变量,并且这两个变量不能同时被一个原子类所保障,那么我们所能采取的办法或许就只有考虑加锁进行操作了。加锁能够保证一系列操作具有原子性,同时也能保证被锁对象具备“不可见性”和顺序性。对于这三个特性的更多讨论,我们将放在后面。

到锁这里,上面的一个故事大致讲完了。但在实际情况中,所考虑的问题或许不只是如此简单。现在我们回过头来看看线程安全的更准确描述以及设计中需要考虑的方面。《Java Concurrency in Practice》书中给出了一个定义,这个定义更为准确且比较好理解,即“一个类在多线程执行的情况下,如果不考虑运行环境的调度干预,并且不需要调用代码进行协调同步,依然能够保证正确地运行,那么这个类就是线程安全的”。按照这个定义,这本书的作者是 Brian Goetz,他在其文章中进一步将线程安全划分成了 5 个级别。

具体而言,需依据原文进行描述。IBM developerworks 上被翻译成中文的文章。

其它关于线程安全方面在设计实践中需要的思考我简要整理了下:

……

你可能想看:
« 上一篇
下一篇 »

相关推荐

java中什么是冒泡,冒泡算法

2026年09月06日

27阅读

java中冒号,java双冒号

2026年09月06日

25阅读

java中的->,java中的是什么意思

2026年09月06日

24阅读

java中的super用法,Java中super的说明

2026年09月06日

27阅读

java中反射的应用,java中反射的应用场景

2026年09月06日

23阅读

java中开关的定义,Java关键字有哪些

2026年09月06日

25阅读