多线程之间的数据共享与可见性
如你所知,任何程序都在一个特殊的内存区域中处理数据。每个进程都有自己的内存,但如果我们在该进程中使用多线程,线程就可以访问这片特定区域,从而彼此进行交互。本节将介绍线程如何通过共享数据进行通信,以及开发者在此过程中可能遇到的问题。
线程之间的数据共享
属于同一进程的线程共享公共内存(称为堆内存)。它们可以通过内存中的共享数据进行通信。为了让多个线程访问同一数据,每个线程都必须持有对该数据的引用(例如一个对象)。下图展示了这一思想:
示意图:多个线程共享堆内存中的对象引用。
让我们来看一个示例。以下是一个名为 Counter
的类:
class Counter {
var value = 0
fun increment() {
value++
}
}
这个类有一个函数 increment()
,每次调用会让 value
字段加 1。
接下来是一个继承自 Thread
的类:
class MyThread(val counter: Counter) : Thread() {
override fun run() {
counter.increment()
}
}
MyThread
构造函数接收一个 Counter
实例,并存储在字段中。run
方法调用 counter.increment()
。
现在我们创建一个 Counter
实例和两个 MyThread
实例:
val counter = Counter()
val thread1 = MyThread(counter)
val thread2 = MyThread(counter)
启动这两个线程并打印 counter.value
:
thread1.start() // 启动第一个线程
thread1.join() // 等待第一个线程完成
thread2.start() // 启动第二个线程
thread2.join() // 等待第二个线程完成
println(counter.value) // 输出结果是 2
结果是 2,因为两个线程操作的是同一个 counter
实例,值被增加了两次。
注意:这个例子中两个线程并不是“同时”操作数据的 —— 第二个线程在第一个线程结束后才开始执行。
线程干扰(Thread Interference)
非原子操作是由多个步骤组成的操作。如果一个线程在另一个线程执行的非原子操作中途介入,就可能产生线程干扰问题 —— 多个线程的操作步骤交错执行,产生意料之外的结果。
以 Counter
类为例,value++
实际上由三个步骤组成:
-
读取当前值;
-
将值加一;
-
将结果写回字段。
由于这个操作是非原子的,若两个线程几乎同时调用 increment()
,可能会出现如下情况:
-
线程 A:读取
value
(值为 0) -
线程 A:加 1
-
线程 B:读取
value
(值仍为 0) -
线程 A:写入结果(值变为 1)
-
线程 B:加 1
-
线程 B:写入结果(值仍为 1)
最终结果是 1 而不是 2,线程 A 的结果被线程 B 覆盖了。
类似地,value--
也存在这个问题。
long 与 double 的原子性问题
你可能会惊讶:即便是读取和写入 long
(长整型)或 double
(双精度浮点)变量,在某些平台上也不是原子操作。
class MyClass {
var longVal: Long = 0 // 非原子性
var doubleVal: Double = 0.0 // 非原子性
}
这意味着一个线程在写入时,另一个线程可能看到的是“写了一半”的中间值(比如只写了前 32 位)。
为了解决这个问题,可以使用 @Volatile
注解:
class MyClass {
@Volatile var longVal: Long = 0
@Volatile var doubleVal: Double = 0.0
}
这样就保证了对这些变量的读取与写入是原子的。
注意:Boolean
、Byte
、Short
、Int
、Char
和 Float
类型的读取与写入在 JVM 上是原子的,无需加 @Volatile
。
线程之间的可见性(Visibility)
在并发程序中,一个线程对共享变量的更改,另一个线程可能看不到这些变化,或者看到的是错乱顺序的值,这叫做可见性问题。
原因在于:
编译器、运行时和 CPU 为了优化性能,可能进行缓存或指令重排序,这些优化会在并发上下文中造成问题。
来看一个示例:
var number = 0
var ready = false
class Reader : Thread() {
override fun run() {
while (!ready) {
yield() // 暂时释放资源
}
println(number)
}
}
fun main() {
Reader().start()
number = 42
ready = true
}
按理说输出应为 42,但实际上也可能输出 0。这是因为:
-
主线程对
number
和ready
的修改可能被缓存在寄存器或写缓冲区中; -
Reader
线程可能读取到旧值(0)或乱序执行后的值。
为了解决这个问题,我们可以使用 @Volatile
:
@Volatile var number = 0
@Volatile var ready = false
@Volatile
保证对该字段的更改对所有线程都是立即可见的,并避免了重排序带来的问题。
可见性无需 @Volatile 的两种情况
某些情况下,不使用 @Volatile
也可以保证可见性:
-
在线程启动前,对变量的修改对新线程是可见的。
-
在
join()
返回之后,该线程内部对变量的修改对其他线程是可见的。
小结
-
属于同一进程的线程通过堆内存共享数据。
-
非原子操作的步骤可能交错执行,造成 线程干扰(Thread Interference)。
-
@Volatile
注解保证对变量的修改对所有线程立即可见,并使读取和写入操作具备原子性(不包括递增或递减操作)。 -
@Volatile
不保证复合操作(如value++
)的原子性。 -
可见性问题在多线程程序中是常见而又隐蔽的 bug 来源。