对象的共享
要编写正确的并发程序,关键问题在于:在访问共享的可变状态时需要进行正确的管理。这次介绍如何共享和发布对象,从而使他们能够安全地由多个线程同时访问。
可见性
先看一段代码
public class Test1 {
private static boolean flag;
private static int number;
private static class ReaderThread extends Thread{
public void run(){
while (!flag)
Thread.yield(); //让线程状态变为就绪
System.out.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
number = 51;
flag = true;
}
}
在代码中,主线程和读线程都将访问共享变量flag和number。看起来可能会输出51,但是也可能输出0,或者无法终止,因为代码中没有使用足够的同步机制,也无法保证主线程写入的flag和number的值对于线程来说是可见的。
在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存造作的执行顺序进行判断,几乎无法得到正确的结论。
所以,只要有数据在多个线程之间共享,就使用正确的同步。
线程安全与非线程安全的可变整数类,代码如下
public class Test2 {
private int value;
//非线程安全
// public int getValue() {
// return value;
// }
// public void setValue(int value) {
// this.value = value;
// }
//线程安全
public synchronized int getValue() {
return value;
}
public synchronized void setValue(int value) {
this.value = value;
}
}
加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。
Volatile
Java提供了一种稍弱的同步机制,volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会讲该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的支付,因此在读取volatile类型的变量时总会返回最新写入的值。
如下代码,是一种典型的volatile用法,主线程和测试线程同时执行,主线程会改变flag的值,让测试线程来停止循环。
public class Test3 {
private volatile static boolean flag = false;
public static class TestThread extends Thread{
public void run(){
while (!flag){
System.out.println(flag);
}
}
}
public static void main(String[] args) {
new TestThread().start();
for (int i=0;i<10000;i++){
for (int j=0;j<10000;j++){
if (i==9999&&j==9999){
flag = true;
}
}
}
}
}
在当前大多数处理器架构上,读取volatile变量的开销只比读取非volatile变量的开销高一些。
仅当volatile能简化代码的实现以及同步策略的验证时,才使用它们,如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量。
加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性
什么时候应该使用volatile变量:
- 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
- 该变量不会与其他状态变量一起纳入不变性条件中。
- 在访问变量时不需要加锁。
发布与逸出
发布一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。
当某个不应该发布的对象被发布时,就被称为逸出。
发布对象的最简单方法是将对象的引用保存到一个公有的静态变量中,以便任何类和线程都能看见该对象,如下代码
public class Test4 {
public static Set Test;
public void initialize(){
Test = new HashSet();
}
}
在initialize方法中实例化一个新的HashSet对象,并将对象引用保存到Test中以发布该对象。
线程封闭
当访问共享的可变数据时,通常需要使用同步。一种避免使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步。 这种技术就被成为线程封闭,它是实现线程安全性的最简单方式之一。
线程封闭技术的另一种常见应用是JDBC(Java Database Connectivity)的Connection对象。在典型的服务器应用程序中,线程从连接池中获得一个Connection对象,并且用该对象来处理请求,使用完后再将对象返还给连接池。
ThreadLocal类
维持线程封闭性的一种更规范方法是使用ThreadLocal,这个类能使线程中的某个值与保存值得对象关联起来。
通过JDBC的链接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接,代码如下
public class Test5 {
private static ThreadLocal<Connection> connectionThreadLocal = new ThreadLocal<Connection>(){
public Connection initialValue(){
return DriverManager.getConnection(DB_URL);//数据库路径
}
};
public static Connection getConnection(){
return connectionThreadLocal.get();
}
}
当某个线程初次调用ThreadLocal.get()方法时,就会调用initialValue来获取初始值。
不可变
满足同步需求的另一种方法是使用不可变对象。
如果某个对象在被创建后其状态就不能被修改,那么这个对象就被称为不可变对象。
不可变对象一定是线程安全的。
当满足以下条件时,对象才是不可变的:
- 对象创建以后其状态就不能修改。
- 对象的所有域都是final类型。
- 对象时正确创建的(在对象的创建期间,this引用没有逸出)。
在可变对象基础上构建不可变类
public final class Test6 {
private static final Set<String> stooges = new HashSet<String>();
public Test6(){
stooges.add("A");
stooges.add("B");
stooges.add("C");
}
public boolean isStooge(String name){
return stooges.contains(name);
}
}
安全发布
要安全的发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。可以通过以下方式来安全发布
- 在静态初始化函数中初始化一个对象引用。
- 将对象的引用保存到volatile类型的域或者AtomicReferance对象中。
- 将对象的引用保存到某个正确构造对象的final类型域中。
- 将对象的引用保存到一个由锁保护的域中。
在并发程序中使用和共享数据时,可以使用一些实用的策略,包括:
线程封闭: 线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
只读共享: 在没有额外同步的请胯下,共享的只读对象可以由多个线程并发访问,任何线程都不可更改它。共享的只读对象包括不可变对象和事实不可变对象。
线程安全共享: 线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进一步的同步。
保护对象: 被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。