首页 > 代码库 > Java 并发编程(二)对象的可见性
Java 并发编程(二)对象的可见性
要编写正确的并发程序,关键问题在于:在访问共享的可变状态时需要进行正确的管理。
在第一部分,我们介绍了如果通过同步来避免多个线程在同一时刻访问相同的数据,而这节,我们将介绍如何共享和发布对象,从而使他们能够安全的由多个线程同时访问。这两部分形成了构建线程安全类以及通过 java.util.concurrent 类库来构建并发应用程序的重要基础。
我们已经知道了同步代码块和同步方法可以确保以原子的方式执行操作,但一种常见的误解是,认为关键字 synchronized 只能用于实现原子性或者确定”临界区“。其实同步还有另一个重要的方面:内存可见性(Memory Visibility)。我们不仅希望防止某个线程正在使用对象而另一个对象正在修改对象状态, 而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的变化。如果没有同步,那么这种情况就无法实现。你可以通过显示的同步或者类库中内置的同步来确保对象被安全的发布。总结来说,就是同步关键字可以保证原子访问+内存可见。
可见性
在单线程环境中,如果向某个变量写入值,在没有其他写入操作的情况下读取这个变量,那么总能得到相同的值。这听起来很容易能理解。然而,当读写操作在不同的线程中执行时,情况却并非如此。通常,我们无法确保执行读操作的线程能适时的看到其他线程写入的值,有时甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
我们通过一个小例子来说明多个线程在没有同步的情况下共享数据时出现的错误。首先看下面这段代码。
public class NoVisibility { private static boolean ready; private static int number; private static class ReaderThread extends Thread{ @Override public void run() { while(!ready) Thread.yield(); System.out.print(number); } } public static void main(String[] args) throws InterruptedException{ new ReaderThread().start(); number = 42; ready = true; } }
在代码中,主线程和读线程都将访问共享变量 ready 和 number。主线程启动读线程,然后将number 设为 42,并将 ready 设为 true 。读线程一直循环直到发现 ready 的值 变为 true,然后输出 number 的值。虽然 NoVisibility 看起来会输出 42,但事实上很可能输出0,或者根本无法终止。这是因为在代码中没有同步机制,无法保证主线程写入的 ready 值和 number 值对于读线程来说是可见的。 另一种更奇怪的现象是,NoVisibility 可能会输出 0 ,因为读线程可能看到了写入 ready 的值,却没有看到之后写入 number 的值,这种现象称为”重排序(Reordering)”。只要在某个线程中无法检测到重排序情况,那么就无法确保线程中的操作将按程序中指定的顺序执行。当主线程首先写入 number ,然后再没有同步的情况下写入 ready, 那么读线程看到的顺序可能与写入的顺序完全相反。
注:在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整(重排序)。在缺乏足够同步的多线程程序中,要相对内存操作的执行顺序进行判断,几乎无法得到正确的结论。重排序看上去似乎是一种失败的设计,但却能使 JVM 充分的利用现代多核处理器的强大性能。例如,在缺少同步的情况下,Java 内存模型允许编译器对操作顺序进行重排序,并将数值缓存在寄存器中。此外,它还允许 CPU 对操作顺序进行重排序,并将数值缓存在处理器特定的缓存中。
失效数据
Novisibility 展示了缺乏同步可能得到一个已经失效的值:失效数据。更糟糕的是,失效值可能不会同时出现:一个线程可能获取到某个变量的最新值,却获得另一个变量的失效值。有时候要确保可见性,仅仅对 set 方法进行同步是不够的,需要对 get 和 set 方法都需要进行同步。
非原子的64位操作
当线程在没有同步的情况下读取变量的时候,可能会得到一个失效值,而不是一个随机值。这种安全性保证称为最低安全性(out-of-thin-air-safety)。
最低安全性适用于绝大多数变量,但是存在一个例外:非 volatile 类型的64位数值变量(double 和 long)。Java 内存模型要求,变量的读取操作和写入操作都必须是原子操作,但对于非 volatile 类型的 long 和 double 变量,JVM 允许将64为的读操作或写操作分解为两个32位的操作。当读取一个非 volatile 类型的 long 变量时,那么很可能会读取到某个值的高32位和另一个值的低32位。因此即使不考虑失效数据的问题,在多线程程序中使用共享且可变的long 和 double 等类型的变量也是不安全的。除非用关键字 volatile 来声明它们,或者用锁保护起来。
注:虽然 JVM 规范并没有要求64位变量的读写为原子操作,但是现在基本上所有的商业虚拟机都将其实现为原子操作。
Volatile 变量
Java 语言提供了一种稍弱的同步机制,即 volatile 变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为 volatile 之后,虚拟机在运行当前指令的时候,会建立一个内存屏障(Memory Barrier 或 Memory Fence),阻止重排序时将后面的指令重排序到内存屏障之前的位置。volatile 变量是一种比 synchronized 关键字更轻量级的同步机制。
注:只有一个 CPU 访问内存时,并不需要内存屏障;但如果有两个或更多的 CPU 访问同一块内存,且其中一个在观测另一个,就需要内存屏障来保证一致性了。
虽然 volatile 变量使用十分方便,但也存在着一定的局限性。它通常用来做某个操作完成、发生中断或者状态的标志。 虽然 volatile 变量也可以用于表示其他的状态信息,但使用时要非常小心。例如, volatile 的语义不足以保证递增(count++)操作的原子性。
Java 并发编程(二)对象的可见性