并发编程原则
(二)
内存可见性与共享对象安全访问方式
前 言:
在这个系列的上一篇文章中,我们讨论了线程安全性,以及保护线程安全性的重要工具——“锁”技术。而且我们提到了,编写正确的并发程序的关键在于对共享的、可变的状态进行访问管理,使访问线程持续进行正确的活动。
在这篇文章中我们要重点讨论,并发编程中有关共享对象的另一些重要的问题,那就是“内存可见性问题”以及“共享对象的安全访问方式”。
1、内存可见性:
内存可见性是微妙的,由它所引发的错误往往与我们的直觉大相径庭。在单线程环境中,向一个变量写入一个值,然后在没有写干扰的情况下肚去这个变量,我们会得到我们刚刚写入的相同值。但是当读写发生在不同的线程中时,情况却根本不会是这样。通常不能保证线程及时的读取其他线程写入的值。见如入下1.1.1代码:
public class Novisibility{
private static boolean ready;
private static int number;
private static class ReadThread extends Thread{
public void run(){
while(!ready){
Thread.yield();
System.out.println(number);
}
}
}
public static void main(String[] arg){
new ReadThread().start();
number=42;
ready=true;
}
}
对于这个程序来说,可能会一直保持循环,因为对于读线程来说,ready的值可能永远不可见。甚至更奇怪的是,它可能会打印出0,因为早在对number赋值前,主线程可能早就已经对number和ready写入了默认值。上面的代码展现了内存可见性能够引起的意外效果——“过期数据”,当读线程检查ready变量时,它有可能看到一个过期值。怎么样才能避免这样的问题呢?有一个简单的办法:那就是通过同步,只要数据需要被跨线程共享,就需要进行恰当的同步。见入下代码1.1.2所示:
public class synchronizedInteger{
private int value;
public synchronized int get(){
return value;
}
public synchronized void set(){
this.value=value;
}
}
如上面的代码所示,通过同步get/set方法,可以使用内部锁将get/set方法原子化。通过内部锁不但可以保证原子化进而保证类的线程安全性,另一方面同步还保证了内存可见性,通过使用同步可以保证一个线程对value值的修改,对另一个线程可见。因此锁不仅仅是关于同步与互斥的,也是关于内存可见性的,但是仅仅使用同步还是有可能使线程读取到过期数据。所以为了保证所有线程都能够看到共享的、可变的最新值,读取和写入线程必须使用同一个对象的公共锁进行同步。如上面的代码,同一个synchronizedInteger类实例对象的方法,都使用synchronizedInteger类的内部锁进行同步,因此可以保证相同的synchronizedInteger类实例间的内存可见性。
在Java中没有必要非要通过同步锁来保证内存可见性,Java提供了一种同步的弱形式:volatile变量。访问volatile变量的操作不会加锁,也不会引起执行线程的阻塞,这使得访问volatile变量相对于synchronized而言,只是轻量级同步。当一个线程访问volatile变量时,不会相对一般的变量那样,将变量拷贝一份放入线程的本地栈中,而是会从变量的原始存放地去访问变量值。volatile变量不会缓存在寄存器或者缓存在对其他处理器隐藏的地方,所以volatile变量总是有某个线程写入的最新值。由于每次都要到变量的原始存放地去访问变量值,所以读取volatile变量的开销要略高于一般变量。
volatile变量固然方便,但是它们通常被用于保证对象可见性,或者用于标识重要事件的发生(如:完成、中断、以及状态变化)。但是volatile变量是不能保证操作原子化的,比如如果对变量count声明为volatile,是不能保证count++操作为原子化的,除非你保证只有一个线程访问执行该操作。因此有如下结论:加锁可以保证原子性与可见性;volatile变量只能保证可见性。
2、安全访问共享可变对象的方法:
(1)、线程封闭:
上一篇文章中我们讨论过,要安全的通过多线程访问共享数据、共享对象需要将访问方法变成原子化方法,这需要使用同步锁机制。如果不共享数据或者对象,那么就不需要同步。线程封闭技术就提供了这样一种机制,线程封闭是实现线程安全的最简单方式之一。当对象被封闭在一个线程中时,这种方法会自动成为线程安全的,即使被封闭的对象本身不是线程安全的。比如我们常见的数据库连接池技术,当一个请求从连接池中获得一个Connection对象后,这个Connection对象就被限制在请求线程中,请求线程可以使用Connection对象完成相关操作,而且在请求将Connection对象归还前,池不会将它再分配给别的请求。实现线程封闭有两种方式:第一种是通过栈限制;第二种是使用线程本地池变量(在Java中通过java.lang.ThreadLocal提供了支持)。下面我们分别讨论这两种方式。
.栈限制:
栈限制其实线程限制的一种特例,在栈限制中,只能通过本地变量才可以触及对象。本地变量被限制在访问线程的线程栈中,其他线程无法访问这个栈。如下面2.2.1代码所示:
public int loadTheArk(Collection<Animal> locals){
SortedSet<Animal> animals;
int numbers=0;
Animal local=null;
animals=new TreeSet<Animal>(new SpeciesGenderComparator);
animals.addAll(locals);
for(Animal a:animals){
if(local ==null || ! local.isPotentialMate(a))
local=a;
else{
++numbers;
…….
}
}
}
在上面的代码中,我们实例化了一个TreeSet对象animals,此时只有一个引用指向该集合,而且这个引用是方法的本地变量,因此该集合变量便被限制在本地变量的执行线程中。但是如果我们将animals对象的引用发布(如:传递到其他类对象的方法中),那么就有可能导致animals对象中的对象实例被修改,从而破坏了线程的封闭性。
.线程本地池变量:
使用线程本地池变量是一种维护线程限制的更加规范的方式,在Java中通过ThreadLocal类对线程本地池变量提供了直接支持。线程本能地池变量提供了这样一种机制,它会将每一个使用它的线程与一个持有数据的对象相关联,并且为每个使用它的线程维护一份这个持有数据对象的单独拷贝。线程本地池变量通常用于防治再给予可变单体或全局变量的设计中,出现不正确的共享。比如在一个单线程程序中,维护一个全局数据库连接,这个Connection对象在启动时就已经被初始化,然后由其他方法调用共享。在多线程环境下如果没有额外的协调,那么使用全局变量将是不安全的。通过使用ThreadLocal存储Connection对象,就可以为每个线程维护一个单独的属于自己的Connection对象。如下面2.2.2代码所示:
public class GetConnectionFactory{
private static ConnectionFactory connectionFactory;
private String dburl=DB_URL;
try{
connectionFactory=new ConnectionFactory();
}catch(Exception e){
e.printStackTrace();
}
public final static ThreadLocal connectionpool=new ThreadLocal();
public static Connection getConnection(){
Connection connection=connectionpool.get();
if(connection==null){
connection=connectionFactory.createConnection(dburl);
connectionpool.set(connection);
}
return connection
}
}
(2)、构建不可变对象:
到目前为止,我们所描述的所有原子性与可见性危险,如:过期数据,未及时更新,对象状态不一致等。几乎都是由于在多线程下协调各种难以预测的行为工作,而又天真的认为这些工作能够完美的结束。在多线程环境下,多个线程总是试图同时访问相同的可变状态,但是如果对象的状态不能被修改,那么所有的风险与复杂度自然也就销声匿迹了。
这种创建后状态不能被修改的对象称为对象的不可变性或者称为不变对象。既然不可变对象状态不能被修改,那么理所当然不可变对象天生就是线程安全的。不可变对象的状态都是一些常量域,而且这些域都是在构造函数中创建的。
因此不可变对象是简单的,因为它的所有状态都是由构造函数谨慎的控制着。同时它也是安全的,因为将不可变对象发布到恶意代码,或者充满漏洞的代码中,由于不能被修改状态,所以它是线程安全的。
在Java中语言规范并没有提供关于不可变对象的语言级的定义支持,但是我们可以使用Java提供的基本元素来构造不可变对象,而且在不可变对象内部,同样可以使用通常的可变对象来管理它的状态,只要对象创建后不能从外部修改这些状态。因此我们可以通过满足如下条件,来使一个对象成为不可变对象。
.它的状态不能在创建后被修改,即不能提供修改状态的方法;
.所有的域都应该是final类型的,并且是private访问级别的;
.不可变对象必须要被正确的创建,也就是说在创建过程中要由构造函数统一协调控制创建与域的赋值;
.如果需要更新对象状态需要使用clone机制获得对象状态的一份拷贝;
在Java中final域有着特殊含义,它确保了初始化安全性,初始化安全性保证了final域只有在构造函数完成时可见,因此使不可变对象不需要同步就能自由的被访问和共享。如下面代码2.2.3所示,来构建一个不可变对象类:
class OneValueCache{
private final BigInteger lastnumber;
private final BigInteger[] lastfactory;
public OneValueCache(BigInteger i,BigInteger[] factors){
lastnumber=i;
lastfactory=Arrays.copyOf(factors,factors.length);
}
public BigInteger[] getFactors(BigInteger i){
if(lastnumber==null || !lastnumber.equals(i)){
return null;
}
else
return Arrays.copyOf(lastfactory,lastfactory.length);
}
}
按照不可变对象条件,我们构造了不可变对象,通过使用不可变对象持有所有变量,可以消除在访问和更新这些变量时的竞态条件。若使用可变对象,那么就要使用锁来保证原子性。因此使用不可变对象从某些方面来说是具有性能优势的。如果需要更新不可变对象的状态变量,那么需要采用相应的clone机制,来复制一份一模一样的状态变量给相应的修改线程,采用clone的前提是不可变对象的状态变量不是基本类型或者是其他不可变对象的reference。如下面2.2.4代码所示,该代码展示了不可变对象的使用。
public class Cachefactors implements Servlet{
private volatile OneValueCache
cache=new OneValueCache(new BigInteger(0),
BigInteger[0]{new BigInteger(0)});
public void service(SerletRequest req,ServletResponse res){
BigInteger i=new BigInteger(req.getParameter(“i”));
BigInteger[] factors=cache. getFactors(i);
if(factors==null){
factors=factor(i);
cache=new OneValueCache(i,factors);
}
……
}
}
与cache相关操作不会相互干扰,因为OneValueCache是不可变对象类,而且每次只有一条代码路径访问它。另外OneValueCache类持有与不变约束相关的多个状态变量,并且使用volatile确保及时的内存可见性。基于这些保证即使OneValueCache没有显式的使用同步,却仍然是线程安全的。