java并发编程实战系列-对象的共享

要编写正确的并发程序,关键问题在于:在访问共享的变量时需要进行正确的管理。之前介绍了使用同步来避免多个线程在同一时间访问相同的数据,接下来将介绍如何共享和发布对象,从而使他们能够安全地由多个线程同时访问。这些内容,形成了构建线程安全类以及通过java.util.concurrent类库来构建并发应用程序的重要基础。

1 可见性

在没有同步的情况下,编译器、处理器在运行时都可能对操作的执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,无法对内存操作的执行顺序进行判断。

有一种简单的方法能避免这些复杂的问题:只要有数据在多个线程之间共享,就使用正确的同步。

1.1 失效数据
public void MutableInteger {

	private int value;
	
	public int get() {
	
		return value;
	
	}

	public void set(int value) {
	
		this.value = value;
	
	}

}

以上的代码不是线程安全的,get和set方法没有在同步的情况下访问value。这时候value可能出现失效数据的情况,也就是当某个线程调用了set,另外一个正在调用get的线程可能会看到更新后的value值,也可能看不到。如果改成同步的方式,将不会出现失效数据的情况。如下所示:

public void SynchronizedInteger {

	private int value;
	
	public synchronized int get() {
	
		return value;
	
	}

	public synchronized void set(int value) {
	
		this.value = value;
	
	}

}
1.2 非原子的64位操作

当线程没有同步的情况下读取变量,可能会得到一个失效值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性(out-of-thin-airsafety)。

最低安全性适用于绝大多数变量,但存在一个例外:非volatile类型的64位数值变量(double和long)。java内存模型要求,变量的读取和写入操作必须是原子操作,但对于非volatile类型的long和double变量,JVM允许将64位的读操作或写操作分解为两个32位的操作。当读取一个非volatile类型的long变量时,如果对该变量的读操作和写操作在不同的线程中执行,那么可能会读取到某个值的高32位和另一个值的低32位。因此,即使不考虑失效数据问题,在多线程程序中使用共享且可变的long和double等类型的变量也是不安全的,除非用关键词volatile来声明它们,或者用锁保护起来。

1.3 加锁与可见性

加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所以执行读操作或者写操作的线程都必须在同一个锁上同步。

1.4 volatile 变量

java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。volatile变量是一种比sychronized关键词更轻量级的同步机制,不会执行加锁操作,读取volatile的开销只比读取非volatile的开销略高一点。当将变量声明为volatile类型后,编译器在运行时会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型变量时总会返回最新写入的值。

仅当volatile变量能简化代码的实现及对同步策略的验证时,才应该使用它们。如果在验证正确时需要对可见性进行复杂的判断,那么就不需要使用volatile变量。volatile变量的正确使用方式包括:确保它们自身状态的可见性,确保它们所引用对象的状态的可见性,以及标识一些重要的程序生命周期事件的发生(例如,初始化或关闭)。以下示例是volatile的用法举例:

	volatile boolean asleep;

	while(!asleep)

	countSheep();

加锁机制即可确保可见性又可确保原子性,而volatile变量只能确保可见性。当且仅当满足以下所有条件时,才应该使用volatile变量:

  • 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
  • 该变量不会与其他状态变量一起纳入不变性条件中。
  • 在访问变量时不需要加锁。

2 发布与逸出

2.1 概念

"发布(Publish)"一个对象的意思是指使对象能够在当前作用域之外的代码中使用。例如,将一个指向该对象的引用保存在其他代码可以访问的地方,或者在某一个非私有的方法中返回该引用,或者将引用传递到其他类的方法中。

当某个不应该发布的对象被发布时,这种情况称为逸出(Escape)。以下是常见的对象逸出方式:

  1. 当发布某个对象时,间接地发布了其他对象。下面的例子就是不小心把Secret对象发布了:
	public static Set<Secret> knownSecrets;
	
	public void initialize() {
	
		knownSecrets = new HashSet<Secret>();
	
	}
  1. 通过非私有的方法返回了私有属性的引用,使得私有属性逸出了它所在的作用域。下面实例就是私有的数组States被公有方法发布了:
class UnsafeStates {

	private String[] states = new String[] {
	
		"start", "process", "end"
	
	};
	
	public String[] getStates() {
	
		return states;
	
	}

}
  1. 隐式地使this引用逸出。如下所示,当ThisEscape类发布EventListener时,也隐含地发布了ThisEscape实例本身,因为在这个内部类的实例中包含了对ThisEscape实例的隐含引用。
public class ThisEscape {

	public ThisEscape(EventSource source) {
	
		source.registerListener(
	
		new EventListener() {
		
			public void onEvent(Event e) {
			
				doSomething(e);
			
			}
		
		}
	
	}

}
2.2 安全的对象构造过程

不要在构造过程中使this引用逸出。因为从对象的构造函数中发布对象时,只是发布了一个尚未构造完成的对象。

以下就是通过私有的构造函数和公共的工厂方法(Factory Method)来防止this引用逸出:

public class SafeListener {

	private final EventListener listener;
	
	private SafeListener() {
	
		listener = new EventListener() {
		
			public void onEvent(Event e) {
			
				doSomething(e);
			
			}
	
		}
	
	}
	
	public static SafeListener newInstance(EventSource source) {
	
		SafeListener safe = new SafeListener();
		
		source.registerListener(safe.listener);
		
		return safe;
		
	}

}

3 线程封闭

当访问共享的可变数据时,通常需要使用同步。一种不使用同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要使用同步。这种技术被称为线程封闭(Thread Confinement),它是实现线程安全的最简单方式之一。

线程封闭的示例:

  • Swing的可视化组件和数据模型对象都不是线程安全的,Swing通过将它们封闭到Swing的事件分发线程中来实现线程安全性。
  • JDBC的Connection对象,由于大多数请求(Servlet请求或EJB调用等)都是由单个线程采用同步的方式来处理,并且在Connection对象返回前,连接池不会再将它分配给其他线程,因此,这种连接管理模式在处理请求时隐含地讲Connection对象封闭在线程中。
3.1 栈封闭

栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象。局部变量的固有属性之一就是封闭在执行线程中。它们位于执行线程的栈中,其他线程无法访问这个栈。

3.2 ThreadLocal类

维持线程封闭性的一种更规范的方式是使用ThreadLocal,这个类能使线程中的某个值与保存值的对象关联起来。ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因为get总是返回由当前执行线程在调用set时设置的最新值。

ThreadLocal对象通常用于防止对可变的单实例变量或全局变量进行共享。以下的实例就是将JDBC的连接保存到ThreadLocal对象中,每个线程都拥有属于自己的连接。

private static ThreadLocal<Connection> connectionHolder

= new ThreadLocal<Connection>() {

	public Connection initialValue() {
	
		return DriverManager.getConnection(DB_URL);

	}

};

public static Connection getConnection() {

	return connectionHolder.get();

}

4 不变性

不可变对象一定是线程安全的。

当满足以下条件时,对象才是不可变的:

  • 对象创建以后其状态就不能修改。
  • 对象的所有域都是final类型。
  • 对象是正确创建的(在对象创建期间,this引用没有逸出)。

下面的示例就是在可变对象的基础上构建的不可变类:

public final class ThreeStooges {

	private final Set<String> stooges = new HashSet<String>();
	
	public ThreeStooges() {
	
		stooges.add("A");
		
		stooges.add("B");
		
		stooges.add("C");
	
	}
	
	public boolean isStooge(String name) {
	
		return stooges.contains(name);
	
	}

}

5 安全发布的常用模式

要安全发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:

  • 在静态初始化函数中初始化一个对象引用。
  • 将对象的引用保存到volatile类型的域或者AtomicReferance对象中。
  • 将对象的引用保存到某个正确构造对象的final类型域中。
  • 将对象的引用保存到一个由锁保护的域中。

参考:

Java并发编程实战(Java Concurrency In Practice)-机械工业出版社 第三章

猜你喜欢

转载自blog.csdn.net/sunjian1122/article/details/89298697