[Java并发编程实战] 共享对象之可见性
「 盛年不重来,一日难再晨,及时当勉励,岁月不待人。」 陶渊明
我们已经知道同步代码块和同步方法可以保证以原子的方式执行,其实,同步还有另外一个重要概念:内存可见性。换句话说,我们不仅希望防止某个线程正在使用对象状态而另一个线程同时在修改状态,而且希望确保当一个线程修改了对象的状态后,其他线程能够看到修改后的状态。
可见性
一个线程对共享变量值的修改,能够及时的被其他线程看到。可见性微妙的,这是因为可能发生错误的事情总是与直觉大相径庭。来看下面这个例子和他的执行结果:
1public class NoVisibility {
2 private static boolean ready;
3 private static int number;
4 private static class ReaderThread extends Thread {
5 public void run() {
6 while(!ready)
7 Thread.yield();
8 System.out.println(number);
9 }
10 }
11 public static void main(String[] args) {
12 // TODO Auto-generated method stub
13 new ReaderThread().start();
14 number = 88;
15 ready = true;
16 }
17}
上面的代码清单,亲测执行的结果是88。
然而,书本上的解释是可能出现错误的结果。错误的结果有下面两种情况(我重现不到下面的结果):
- NoVisibility 可能会一直保持循环,因为对读线程来说,主线程写给 ready 的值可能永远对读线程不可见。
- NoVisibility 可能会打印0,因为早在对 number 赋值之前,主线程就已经写入 ready 并使之对读线程可见,这是一种重排序。
即可亲测没有发生,但是可能会发生。为了防止这种现象的发生,只能通过对共享变量进行恰当的同步。
Java 内存模型(JMM,Java Memory Model)
描述了 java 程序中各种变量(线程共享变量)的访问规则,以及在 JVM 中将变量存储到内存和从内存中读取出变量的底层细节。
这里写图片描述
所有变量都存储在主内存中,每个线程都有自己独立的工作内存,里面保存该线程使用到的变量副本,即主内存中该变量的一份拷贝。
线程对共享变量的所有操作必须在自己的工作内存,线程间变量值的传递需要通过主内存来完成。
加锁与可见性
加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作都必须在同一个锁上同步。
这里写图片描述
当线程 B 执行有锁保护的代码块时,可以看到线程 A 之前在同一个同步代码块中所有的操作结果。这就是为啥要求所有线程在同一个锁上同步,为了确保某个线程写入该变量的值对于其他线程来说是可见的。
非原子的64位操作
JVM 允许将64位的读操作或写操作分解为两个32位的操作。Java 中的 long 类型和 double 类型是64位的,所以当读取一个非 volatile 类型的 long 变量时,如果该变量的读操作和写操作在不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位。因此,在多线程中使用共享的可变的 long 和 double 类型变量时不安全的,除非用关键字 volatile 来声明他们,或者用锁保护起来。
volatile变量
Java 提供了一种稍弱的同步机制,即 volatile 变量,用来确保将变量的更新操作通知到其他线程。volatile 变量具有 synchronized 的可见性,但是不具备原子特性。要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:
- 对变量的写操作不依赖于自身当前值
- 该变量没有包含在具有其他变量的不变式中
volatile 通常被当做标识完成、中断、状态的标记使用。典型应用如下代码,检查状态标记,以确定是否退出一个循环。
1volatile boolean asleep;
2 while(!asleep)
3 countSomeSheep();
当然,上面也可以用锁,但是会让代码变得复杂。volatile 变量不会加锁,也就不会引起线程的阻塞,相比 sychronized, 这只是轻量级的同步机制。尽管 volatile 也可以用来标识其他类型的状态信息,但是要格外小心。比如, volatile 的语义不足以使自增操作(count++)原子化。