对象的共享
在上一篇线程安全的总结中,说到了要想编写正确的并发程序,关键在于:在访问共享的可变状态时需要进行正确的管理。本章的总结将介绍如何共享和发布对象,从而使他们能够安全地由多个线程同时访问
可见性
通常,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时候甚至是根本不可能的事情,为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制
下面我们来看一个例子
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.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
number = 10;
ready = true;
}
}
上面的这个例子中,主线程和度线程都将访问共享变量ready和number
- 主线程启动读线程,然后将number设为10,并将ready设为true
- 读线程一直都循环直到发现ready的值变为true,然后输出number的值
- 虽然看起来上面的代码会输出10,但事实上很可能输出0,或者根本无法终止
这是因为在代码中没有使用足够的同步机制,因此无法保证主线程写入的ready的值和number的值对于度线程来说是可见的
NoVisibility可能会持续循环下去,因为读线程可能永远都看不到ready的值,一种更奇怪的现象是,NoVisibility可能会输出0,因为读线程可能看到写入ready的值,但是却没有看到之后写入的number的值,这种现象叫做 "重排序"
只要在某个线程中无法检测到重排序的情况(及时在其他线程中可以很明显地看到该线程中的重排序),那么就无法确保线程中的操作将按照程序中指定的顺序来执行
当主线程首先写入number,然后在没有同步的情况下写入ready,那么读线程看到的顺序可能与写入的顺序完全相反
指令重排
在没有同步的情况下,编译器、处理器以及运行时都可能对操作的执行顺序进行一些意想不到的调整,在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得到正确的结论
失效数据
NoVisibility展示了在缺乏同步的程序中可能产生错误结果的一种情况:失效数据
在NoVisibility中,当读线程查看ready变量的时候,可能会得到一个已经失效的值。除非每次访问这个变量的时候都进行同步,否则很可能获得该变量的一个是小智。更糟糕的是,失效值可能不会同时出现:一个线程可能获得某个变量的最新值,而获得另一个变量的失效值,再来看一个例子
public class MutableInteger {
private int value;
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
上述程序也是不正确的,因为get和set方法都是在没有同步的情况下访问的value,与其他问题比起来,失效值问题更容易出现:如果当某个线程调用了set方法,那么另一个正在调用get方法的线程可能看到更新后的value,也可能看不到
public class SynchronizedInteger {
private int value;
public synchronized int getValue() {
return value;
}
public synchronized void setValue(int value) {
this.value = value;
}
}
改进后的程序对get/set方法使用synchronized修饰并且对赋值和取操作进行上锁,仅仅对set一个方法上锁都是不够的,调用get的线程仍然可能看见失效值
非原子的64位操作
当线程在没有同步的情况下读取变量时,可能获得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性
最低安全性适用于绝大多数变量,但是存在一个例外:非volatile修饰的64位数值变量(double和long),Java内存模型要求,变量的读取操作和写入操作都必须是原子操作,但是对于非volatile类型的long和double变量,JVM允许将64位的读操作或者写操作分解为两个32位的操作
当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作位于两个不同的线程中执行,那么很可能会读取到某个值的高32位和另一个值的低32位,除非使用关键字volatile来声明它们,或者用锁保护起来
加锁与可见性
内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果,如图
当线程A执行某个同步代码块的时候,线程B随后进入由同一个锁保护的同步代码块,在这种情况下可以保证,在锁被释放之前,A看到的变量值在B获得锁后同样可以由B看到,换句话说:当线程B执行由锁保护的同步代码块时,可以看到线程A之前在同一个同步代码块中的所有操作结果。如果没有同步,那么就无法实现上述保证
现在我们可以进一步理解为什么在访问某个共享且可变的变量时要求所有的线程在同一个锁上进行同步,就是为了确保某个线程写入该变量的值对于其他线程来说都是可见的
volatile变量
Java提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程
- 当把变量声明为volatile类型之后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其它内存操作一起重排序
- volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值
我们可以对比一下上面的SynchronizedInteger类,在访问volatile变量时时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。
volatile变量对可见性的影响比volatile变量本身更为重要
当线程A首先写入一个volatile变量并且线程B随后读取该变量时,在写入volatile变量之前对A可见的所有变量的值,在B读取了volatile变量之后,对B也是可见的。因此,从内存可见性的角度来看,写入volatile变量相当于退出同步代码块,而读取volatile变量就相当于进入同步代码块
volatile变量的正确使用方式包括:
- 确保它们自身状态的可见性
- 确保它们所引用对象的状态的可见性
- 以及标识一些重要的程序生命周期事件的发生(例如初始化或关闭)
下面我们再来看一个检查某个状态标记以判断是否退出循环的例子
volatile boolean asleep;
……
while (!asleep) {
countSomeSheep();
}
在这个示例中,线程试图通过类似于数绵羊的传统方法来进入休眠状态。为了是这个示例能够正确运行,asleep必须设置为volatile变量,否则,当asleep被另一个线程修改的时候,执行判断的线程却发现不了。同样我们也能用锁来确保asleep更新操作的可见性,但是这相对于volatile就显得复杂一些
volatile的弊端
虽然volatile变量很方便,但是也同样存在一些局限性。volatile变量通常用作某个操作完成、发生中断或者状态的标志,就例如上面数绵羊的例子,但是使用的时候要格外小心,例如,volatile并保证不了(count++)的原子性,除非能保证只有一个线程对变量执行写操作
当且仅当满足以下所有条件的时候,才应该使用volatile变量:
- 对变量的写操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值
- 该变量不会与其他状态变量一起纳入不变性条件中
- 在访问变量时不需要加锁
发布与逸出
"发布(Publish)"一个对象的意思是指,是对象能够在当前作用域之外的代码中使用。例如,将一个指向该对象的引用保存在其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中
当某个不应该发布的对象被发布时,这种情况就被称为逸出(Escape)
接下来通过一个例子我们来了解一下一个对象是如何逸出的
public static Set<Secret> knownSecrets;
public void initialize() {
knownSecrects = new HashSet<Secret>();
}
发布对象的最简单方法就是将对象的引用保存在一个公有的静态变量中,以便任何类和线程都能看到这个对象,就如上面的程序清单一样:在initialize方法中实例化一个新的HashSet对象,并且讲对象的引用保存到knownSecrets中以发布该对象
当发布某个对象时,可能会间接地发布其他对象,如果将一个Secret对象添加进集合knownSecrets中,那么同样会发布这个对象,因为任何代码都可以遍历这个集合,并获得对这个新Secret对象的引用
再来看一个例子,该例发布了本应为私有的状态数组
public class UnsafeStates {
private String[] status = new String[] {
"TEST1", "TEST2", ......
};
public String[] getStatus() {
return status;
}
}
如果按照上述方式来发布status,就会出现问题,因为任何调用者都能修改这个数组的内容。在这个示例中,数组status已经逸出了它所在的作用域,因为这个本应是私有的变量已经被发布了
当发布一个对象的时候,在该对象的非私有域中引用的所有对象同样会被发布。一般来说1,如果一个已经发布的对象能够通过非私有的变量的引用和方法的调用到达其他对象,那么这些对象也都会被发布