《java并发编程实践》读书笔记(二)- 对象的共享

        在前面章节中指出,要编写正确的并发程序,关键的问题在于对共享变量的可变状态需要正确的管理,介绍了线程如何通过同步来避免在同一时刻对共享变量进行安全的访问。

 

        我们已经知道同步代码块或者同步方法可以确保以原子的方式执行操作,但是一种常见的错误理解是认为关键字synchronized只能用语实现原子性或者确定临界区。其实同步还有另外一个很重要的一面,就是:内存可见性,我们不仅希望防止某个线程正在使用对象状态而另一个线程在同时修改对象的状态,而且希望确保当一个线程修改对象状态后,其他线程能够马上看到修改过后的状态,如果没有同步这种情况是无法实现的。

 

一、可见性

 

        可见性是一种复杂的属性,因为可见性中的错误总是违背我们的直觉,在读操作和写操作在不同的线程中执行时,不一定总是得到相同的值,通常,我们无法确保执行读操作的线程能适时的看到其他线程写入的值,有时甚至是根本不可能的事情,为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。关于内存可见性可以阅读另一篇详细介绍java内存模型的文章。

   

        下面示例中的NoVisibility说明了当多个线程在没有使用同步的情况下共享数据出现的错误。在代码中主线程和读线程将访问共享变量的ready和number,主线程启动读线程,然后将number设为42,将ready设为true,,读线程一直循环直到发现ready的值变为true,然后输出number的值,虽然看起来NoVisibility会输出42,但事实上很可能输出0,或者根本无法终止。

         

public class NoVisibility{
    private static boolean ready;
    private static int number;
    private static class ReaderThread extends Thread{
        public void run(){
            while(!ready)
                Thread.yield();
            System.out.println(number);
        }
    }

    public static void main(String[] args){
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}

         NoVisibility可能会持续循环下去,因为读线程可能永远看不到ready的值,一种更奇怪的现象是,NoVisibility可能会输出0,因为读线程可能看到了写入ready的值,但却没有看到之后写入number的值,这种现象被称为重排序,只要在某个线程中无法检测到重排序情况,那么就无法确保线程中的操作将按照程序中指定的顺序来执行。

 

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

 

        1、失效数据

 

        NoVisibility展示了在未使用同步的情况下可能产生错误结果的一种情况:失效数据。当读线程查看ready变量时,可能会得到一个已经失效的值,除非在每次访问变量时都使用同步,还有一种更糟糕的情况是,失效值不会同时出现,一个线程可能获取到变量的最新值,另一个线程获取到失效值。

        非线程安全的可变整数类:

@NotThreadSafe
public class MutableInteger{
    private int value;

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

         线程安全的可变整数类:

@ThreadSafe
public class MutableInteger{
    private int value;

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

 

        2、加锁与可见性

        

        内置锁可以用于确保某个线程以一种可以预测的方式查看另一个线程的执行结果:

        
《java并发编程实践》读书笔记(二)- 对象的共享_第1张图片
 

        现在我们可以进一步理解为什么在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,就是为了确保某个线程写入该变量的值对于其他线程来说是可见的,否则,如果一个线程在未持有正确锁的情况下读取某个变量,那么读到的可能是一个失效值。

 

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

 

        3、Volatile变量

 

        Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile后,编译器与运行时都会注意到这个变量时共享的,因此不会将该变量上的操作与其他的内存进行重排序。volatile变量不会被缓存到寄存器或对其他处理器不可见的地方,因此读取到volatile变量的线程总是会返回最新值。

 

        在访问volatile变量时不会执行加锁操作,因此也不会使执行的线程阻塞,所以volatile是比synchronized关键字更轻量级的同步机制。

 

        volatile变量对可见性的影响比volatile本身更为重要,然而,我们并不建议过度的使用volatile变量提供的可见性,如果在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更脆弱,也更难以理解。

 

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

 

        数绵羊:

volatile boolean asleep;

while (!asleep)
    countSomeSleep();

         虽然volatile变量很方便,但是也有一些局限性,volatile变量通常用做某个操作的完成,发生中断或者状态的标志。

 

        加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。

 

        volatile的使用场景:

           对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。

           该变量不会与其他状态变量一起纳入不变性的条件中。

           在访问变量时不需要加锁。

 

二、发布与逸出

 

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

 

        在某些情况下,我们有时又是需要发布这个对象的,但如果在发布时要确保线程安全性,则可能需要同步,发布内部状态可能会破坏对象的封装性,例如在对象构造完成之前就发布该对象,就会破坏线程的安全性。当某个不应该发布的对象被发布时,这种情况就被称为逸出(escape)

 

         我们先来看下一个对象是如何逸出的,发布对象的最简单的方法就是将对象的引用保存到公有的静态变量中,以便任何类和线程都能看到该对象引用。

 

         发布一个对象:

public static Set<Secret> knownSecrets;

public void initialize(){
    knownSecrets= new HashSet<Secret>();
}

        当发布一个对象时,有可能间接的发布了另一个对象,如果将一个Secret对象添加到集合knownSecrets中,那么同样会发布这个对象,因为任何代码都可以遍历这个集合,并获得对Secret对象的引用。同样的,如果从非私有方法中返回一个引用,那么也会发布这个返回的对象。

 

     使内部的可变状态逸出:

class UnsafeStatus{
    private String[] states = new String[]{"AK","AL",...};   

    public String[] getStates(){
        return states;
    }
}

     在这个示例中,数组states已经逸出了它所在的作用域,因为这本应是私有变量却被发布了。

 

     当发布一个对象时,在该对象的非私有域中引用的所有对象同样会被发布,一般来说,如果一个已经发布的对象能够通过非私有变量引用和方法调用到达其他的对象,那么这些对象也都会被发布。

 

     当把一个对象传递给某个外部的方法时,就相当于发布了这个对象,你无法知道哪些代码会执行,也不知道在外部的方法中究竟会发布这个对象还是保留对象的引用,并在随后由另一个线程使用。

 

     最后一种发布对象或其内部状态的机制就是发布一个内部类的实例:

public class ThisEscape{
    public ThisEscape(EventSource source){
        source.registerListener(
            new EventListener(){
                public void onEvent(Event e){
                     doSomething(e);//包含了对ThisEscape实例的隐含引用
               }    
            }
        );
    }
}  

 

        安全的对象构造过程:

     

        在ThisEscape中给出了逸出的一个特殊示例,即this引用在构造函数中逸出,当内部的EventListener实例发布时,在外部封装的ThisEscape实例也逸出了。当且仅当对象的构造函数返回时,对象才处于可预测的和一致的状态,因此,当从对象的构造函数中发布对象时,只是发布了一个尚未构造完成的对象,即使发布对象的语句位于构造函数的最后一行也是如此,如果this引用在构造过程中逸出,那么这种对象被认为是不正确的构造。

 

        在构造过程中使this引用逸出的一个常见错误是,在构造函数中启动一个线程。当对象在其构造函数中创建一个线程时,无论是显式的创建(通过将它传递给构造函数)还是隐式的创建(由于Thread或者Runnable是该对象的一个内部类),this引用都会被新创建的线程共享。在对象尚未构造完成之前,新的线程就能看见它。在构造函数中创建线程并没有错误,但最好不要立即启动它,而是通过一个start或者initial方法来启动。

 

        如果想在构造函数中注册一个事件监听器或者启动线程,那么可以使用一个私有的构造函数和一个公共的工厂方法,从而避免不正确的构造。

 

        使用工厂方法来防止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;
    }
}

 

三、线程封闭

 

        当访问共享的可变数据时,通常需要同步。一种避免使用同步方式就是不共享数据,如果仅在单线程内访问数据就不需要同步,这种技术被称为线程封闭,它是实现线程安全性最简单的方式之一。当某个对象封闭在一个线程中时,这种方法将自动实现线程的安全性,即使被封闭的对象本身不是线程安全的。

 

        在Java语言中并没有强制规定某个变量必须由锁来保护,同样在java语言中也无法强制将对象封闭在某个线程中。Java语言及其核心库提供了一些机制来帮助维护线程封闭性,例如局部变量和ThreadLocal类。

 

        1、栈封闭

 

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

 

        由于任何方法都无法获得对基本类型的引用,因此java语言的这种语义就确保了基本类型的局部变量始终封闭在线程内。

 

        基本类型的局部变量与引用变量的线程封闭性:

public int loadTheArk(Collection<Animal> candidates){
    SortedSet<Animal> animals ;
    int numPairs = 0;
    Animal candidate = null;

    //animals被封闭在方法中,不要使它们逸出
    animals = new TreeSet<Animal>(new SpecialsGenderComparator());
    animals.add(candidates);
    for(Animal a : animals){
        if(candidate == null ||  !candidate.isPotentialMate(a))
           candidate = a;
        else{
            ark.load(new AnimalPair(candidate,a));
            ++numPairs;
            candidate = null;
        }
    }
    return numPairs;
}   

         在loadTheArk中实例化一个TreeSet对象,并将指向该对象的一个引用保存到animals中,此时,只有一个引用指向集合animals,这个引用被封闭在局部变量中,因此也被封闭在执行线程中,然而如果发布了对集合animals的引用,那么封装性将被破坏,并导致对象animals的逸出。

 

        2、ThreadLocal类

 

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

 

        ThreadLocal对象通常用于防止对可变的单实例变量或全局变量进行共享。

     

        使用ThreadLocal来维持线程的封闭性:

private static ThreadLocal<Connection> connectionHolder
    = new ThreadLocal<Connection>(){
        public Connection initialValue(){
            return DriverManager.getConnection(DB_URL);
        }
};

public static Connection getConnection(){
    return connectionHolder.get();
}

         由于JDBC的连接对象不一定是线程安全的,因此,当多线程应用程序在没有协同情况下使用全局变量时,就不是线程安全的,通过将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接。

 

        从概念上看,你可以将ThreadLocal<T>视为包含了Map<Thread,T>对象,其中保存了特定于该线程的值,但ThreadLocal的实现并非如此。

 

        在实现应用程序框架时大量使用了ThreadLocal,例如,在EJB调用期间,J2EE容器需要将一个事务的上下文(Transaction Context)与某个执行中的线程关联起来,通过将事务上下文保存在静态的ThreadLocal对象中,可以很容易的实现这个功能,当框架代码需要判断当前运行的是哪个事务时,只需从这个ThreadLocal对象中读取事务的上下文。

 

        但是不能经常滥用ThreadLocal,例如将所有的全局变量都作为ThreadLocal对象,这是不正确的。

 

 四、 不变性

 

        满足同步需求的另一种方法是使用不可变对象,如果某个对象在被创建以后其状态就不能被修改,那么这个对象就称为不可变对象。线程安全性时不可变对象的固有属性之一,不可变对象一定是线程安全的。

 

        不可变对象很简单,它们只有一种状态,并且该状态由构造函数来控制,在程序设计中一个最困难的地方就是判断复杂对象的可能状态,然而,判断不可变对象的状态却很简单。

 

        虽然在Java语言规范和Java内存模型中都没有给出不可变性的正式定义,但不可变性并不等于将对象中所有的域都声明为final类型,即使对象中所有域都是final类型的,这个对象也仍然是可变的,因为在final类型的域中可以保存对可变对象的引用。

 

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

           a.对象创建以后其状态就不能修改。

           b.对象所有域都是final类型。

           c.对象是正确创建的(在对象创建期间,this引用没有逸出)。

 

        在不可变对象的内部仍然可以使用可变对象来管理它们的状态。

 

        在可变对象基础上构建不可变类:

 

@Immutable
public final class ThreeStooges{
    private final Set<String> stooges = new HashSet<String>();

    public ThreeStooges(){
        stooges.add("Moe");
        stooges.add("Larry");
        stooges.add("Curly");
    }

    public boolean isStooge(String name){
        return stooges.contains(name);
    }
}
         尽管保存姓名的Set对象是可变的,但从ThreeStooges的设计中可以看到,在Set对象构造完成后无法对其进行修改,stooges是一个final类型的引用变量,因此所有的对象状态都通过一个final域来访问,最后一个要求是“正确的构造对象”,这个要求很容易满足。

 

 

        在“不可变的对象”和“不可变的对象引用”之间存在着差异,保存在不可变对象中的程序状态仍然可以更新,即通过将一个保存新状态的实例来替换原来的不可变对象。

 

        1、final域

 

        final类型的域是不能修改的(但如果final域所引用的对象是可变的,那么这些被引用的对象是可以修改的),final域能确保初始化过程的安全性,从而可以不受限制的访问不可变对象,并在共享这些对象时无须同步。

 

        2、示例:使用Volatile类型来发布不可变对象

 

        对数值及其因数分解结果进行缓存的不可变容器类:

 

@Immutable
class OneValueCache{
    private final BigInteger lastNumber;
    private final BigInteger[] lastFactors;

    public OneValueCache(BigInteger  i,BigInteger [] factors){
        lastNumber = i;
        lastFactors = Arrays.copyof(factors,factors.length);
    }

    public BigInteger[] getFactors(BigInteger  i){
        if(lastNumber == null || !lastNumber.equals(i)){
            return null;
        }else
            return Arrays.copyof(factors,factors.length);
    }
}
         使用指向不可变容器对象的volatile类型引用以缓存最新结果

 

 

@ThreadSafe
public class VolatileCachedFactorizer implements Servlet{
    private volatile OneValueCache cache = new OneValueCache(null,null);
    
    public void service(ServletRequest req,ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        BigInteger [] factors = cache.getFactors(i);
        if(factors == null){
            factors = factor(i);
            cache = new OneValueCache(i,factors);
        }       
        encodeIntoResponse(resp,factors);
    }
}
         与cache相关的操作不会互相干扰,因为 OneValueCache是不可变的,并且在每条相应的代码路径中只会访问它一次,通过使用包含多个状态变量的容易对象来维持不变性条件,并使用一个volatile类型的引用来确保可见性,使得 VolatileCachedFactorizer在没有显式使用锁的情况下仍然是线程安全的。
五、安全发布
     在下面的程序中将对象引用保持到公有域中,那么还不足以安全的发布这个对象。
     在没有足够同步的情况下发布对象:
//不安全的发布
public Holder holder;

public void initialize(){
    holder = new Holder(2);
}
     这个示例为什么会运行失败,由于存在可见性的问题,其他线程看到的Holder对象将处于不一致的状态,即便在该对象的构造函数中已经正确的构建了不变性条件。
     1、由于未正确发布,因此这个类可能会出现故障:
public class Holder{
    private int n ;
       
    public Holder(int n){this.n = n;}

    public void assertSanity(){
        if(n != n)
            throw new AssertionError("This statement is false");
    }
}
      由于没有使用同步确保Holder对象对其他线程的可见,因此将Holder称为“未被正确发布”,在未被正确发布的对象中存在两个问题,一是除了发布对象的线程外,其他线程可能看到Holder域是一个失效值,因此将看到一个空引用或之前的旧值,二是更糟糕的情况是,线程看到Holder引用的值是最新的,但Holder状态的值却是失效的。更加不可预测的是,某个线程在第一次读取域时看到是失效值,二再次读取这个域会得到一个更新值,这也是抛出错误的原因。
     问题并不在于Holder类本身,而是在于Holder类未被正确的发布,然而将n声明了final类型,那Holder将是不可变的,从而避免出现不正确发布的问题。
     2、安全的发布常用模式
     要安全的发布一个对象,对象的引用以及对象的状态必须同时对其他线程可见,一个正确构造的对象可以通过以下的方式安全的发布:
     a.在静态初始化函数中初始化一个对象引用。
     b.将对象的引用保存到Volatile类型的域或者AtomicReferance对象中。
     c.将对象的引用保存到某个正确构造对象的final类型域中。
     d.将对象的引用保存到一个由锁保护的域中。
     通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器:
public static Holder holder = new Holder(2);
     
    在线程安全容器内部的同步意味着,在将对象放入到某个容器,如Vector或synchronizedList时,可以安全的发布一个对象。
 
    总结:
     在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:
     线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改。
     只读共享。在没有额外同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都能不修改它。
     线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的公有接口来进行访问而不需要进步的同步。
     保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已经发布的并且由某个特定锁保护的对象。

 

 

 

 

 

 

你可能感兴趣的:(Java并发编程)