Java并发编程实践学习笔记(三)——共享对象之可见性
目录
1 过期数据
2 非原子的64位操作
3 锁和可见性
4 Volatile变量(Volatile Variables)
在单线程环境中,如果向某个变量写入值,在没有其他写入操作的情况下读取这个变量,那么总能得到相同的值。然而,当读写操作在不同的线程中执行时,情况却并非如此。通常,我们无法确保执行读操作的线程能适时的看到其他线程写入的值,有时甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
下面是一个可见性的例子,在没有同步的情况下共享变量(不要这么做):
public class NoVisibility {
private static boolean ready = false;
private static int number;
private static class ReaderThread extends Thread {
public void run() {
while (!ready) {
// 线程让步, 暂停当前正在执行的线程对象,并执行自己或其他线程。
Thread.yield();
System.out.println(Thread.currentThread().getName() + ": " + number);
}
}
}
public static void main(String[] args) throws InterruptedException {
new ReaderThread().start();
number = 42;
ready = true;
System.out.println(Thread.currentThread().getName() + ": " + number);
}
}
运行是没问题的,正常输出42,但不代表这个程序是OK的,在代码中,主线程和读线程都将访问共享变量 ready 和 number。虽然 NoVisibility 看起来会输出 42,但事实上,可能会发生以下两种情况:
(1)Novisibility可能会持续循环,因为ReaderThread可能会看不到写入ready的值。
(2)NoVisibility可能会输出0,因为ReaderThread可能会看到写入ready的值,却没有看到写入number的值。这种现象称为”重排序(Reordering)",在没有同步的情况下,编译器、 处理器、运行时都可能对操作的执行顺序进行调整)..
只要在某个线程中无法检测到重排序情况,那么就无法确保线程中的操作将按程序中指定的顺序执行。当主线程首先写入 number ,然后再没有同步的情况下写入 ready, 那么读线程看到的顺序可能与写入的顺序完全相反。
换一个容易复现的例子:
public class VisibilityTest {
boolean isStop = false;
public void test(){
Thread t1 = new Thread(){
public void run() {
isStop=true;
}
};
Thread t2 = new Thread(){
public void run() {
while (!isStop);
}
};
t2.start();
t1.start();
}
public static void main(String args[]) throws InterruptedException {
// 为了方便复现,设置了多次循环
for (int i = 0; i <30; i++){
new VisibilityTest().test();
}
}
}
这段代码可能永远不会结束,因为线程t1对isStop的赋值,线程t2可能对此并不可见。解决办法就是把共享变量添加volatile 关键字。这个例子中,这样改动就可以正常退出了:
volatile boolean isStop = false;
1 过期数据
Novisibility 展示了缺乏同步可能得到一个已经失效的值:失效数据。当读线程查看ready变量时,可能会得到一个已经失效的值。除非在每次访问变量时都是用同步,否则很可能获得该变量的一个失效值。更糟糕的是,失效值可能不会同时出现:一个线程可能获取到某个变量的最新值,却获得另一个变量的失效值。有时候要确保可见性,仅仅对 set 方法进行同步是不够的,需要对 get 和 set 方法都需要进行同步。
下面的例子MutableInteger不是线程安全的,因为get和set都是在没有同步的情况下访问value的。失效值问题容易出现:如果某个线程调用了set,那么里一个正在调用get的线程可能会看到更新后的value,也可能看不到。
public class MutableInteger {
private int value;
public int get() {
return value;
}
public void set(int value) {
this.value = value;
}
}
要解决这个问题,需要对set和get用synchronized关键字修饰进行同步。仅对set方法进行同步是不够的,调用get的线程仍然会看到失效值。
public class SynchronizedInteger {
private int value;
public synchronized int get() {
return value;
}
public synchronized void set(int value) {
this.value = value;
}
}
2 非原子的64位操作
当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被成为最低安全性(out-of-thin-air satety)。
最低安全性适用于绝大多数变量,但存在例外:非volatile(不稳定的,易变的)类型的64位数值变量(double和long),java内存模型要求,变量的读取曹组和写入操作都必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。当读取一个非volatile的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么可能会读取到某个值的高32位和另一个值的低32位。因此,即使不考虑失效数据的问题,在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非用关键字volatile来声明它们,或者用锁保护起来。
虽然 JVM 规范并没有要求64位变量的读写为原子操作,但是现在基本上所有的商业虚拟机都将其实现为原子操作。
3 锁和可见性
内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。如下图,当线程A执行某个同步代码块时,线程B随后进入由同一个锁保护的同步代码块,在这种情况下可以保证,在锁释放之前,A看到的变量值在B获得锁后同样可以由B看到。即当B执行由锁保护的同步代码块时,可以看到A之前在同一个同步代码块中的所有操作。如果没有同步,那么将无法实现上述保证。
加锁的含义不仅仅局限于同步与互斥,还包括内存可见性。为了保证所有线程都能看到共享变量的最新值,读取和写入线程都必须在同一个锁上进行同步。
4 Volatile变量(Volatile Variables)
Java 提供了一种弱同步机制 volatile ,可以用它来保证状态的可见性和有序性。当把变量声明为 volatile 之后,虚拟机在运行当前指令的时候,会建立一个内存屏障(Memory Barrier 或 Memory Fence),阻止重排序时将后面的指令重排序到内存屏障之前的位置。所以,读一个volatile 类型的变量时,总会返回由某一线程所写的最新值。volatile 变量是一种比 synchronized 关键字更轻量级的同步机制。
虽然 volatile 变量使用十分方便,但也存在着一定的局限性。它通常用来做某个操作完成、发生中断或者状态的标志。 虽然 volatile 变量也可以用于表示其他的状态信息,但使用时要非常小心。例如, volatile 的语义不足以保证递增(count++)操作的原子性。
下面例子给出了volatile变量的一种典型用法:检查某个状态标记以判断是否退出循环。这个例子中线程通过类似数绵羊的方法进入休眠状态。asleep必须用volatile修饰。否则,当asleep被另一个线程修改时,执行判断的线程却发现不了。这里也可以用锁来确保asleep更新操作的可见性,但这将使代码复杂。
// 数绵羊
volatile boolean asleep;
...
while (!asleep)
countSomeSheep();
加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。volatile无法保证对变量的任何操作都是原子性的.
使用volatile的情况:
(1)对变量的写入操作不依赖变量的当前值,或确保只有单个线程更新变量的值(如果多个线程都依赖于原值,那么当变量发生非原子操作时,多个线程读取到的变量就不能保证一致了);
(2)该变量不会与其他状态变量一起纳入不变性条件中;
(2)在访问变量时不需要加锁。