[Effective Java]第十章 并发

来源:互联网 发布:内锥形螺纹编程实例 编辑:程序博客网 时间:2024/06/14 22:32

第十章 并发

66、 同步访问共享的可变数据

许多程序员把同步的概念仅仅理解为一个种互斥的方式,即,当一个对象被一个线程修改的时候,可以阻止另一个线程观察到对象的内部不一致的状态。正确地使用同步可以保证其他任何方法都不会看到对象处于不一致的状态中。这种观点是正确的,但是它并没有说明同步的全部意义。如果没有同步,一个线程的变化就不能被其他线程看到。同步不仅可以阻止一个线程看到对象处于不一致的状态中(即原子性),它还可以保证进入同步方法或者同步代码块的每个线程,都看到由同一个锁保护的之前所有的修改结果(即可见性)。

我的理解,同步 = 原子性 + 可见性

synchronized就是同步的代名词,它具有原子性与可见性。而volatile只具有可见性,但不具有原子性。可见性其实说的就是在读之前与写之后都与主内同步,除了可见性外,volatile还严禁语义重排:“禁止reorder任意两个volatile字段或者volatile变量,并且同时严格限制(尽管没有禁止)reorder volatile字段(或变量)周围的非volatile字段(或变量)。”

Java语言规范保证或写是一个变量是原子的(即数据的读写是不可分割的。注,不可分割的操作并不意味“多线程安全”),除非这个变量的类型为long或double[JLS 17.4.7]。换句话说,读取一个非long或double类型的变量,可以保证返回值是某个线程完整保存在该变量中的值(即要么读取还没有修改的值,要么读取到某线程修改完后的值,但决不会读到另一线程对变量的一半或一部分修改后的值,如一个int型变量,某线修改该变量的前16位后,被另一线程读到,这是不可能的;而long或double类型的变量就完全有可能这样,读到的是另一线程写入的高32位,而低32位还是原来值),即使用多个线程在没有同步的情况下并发地修改这个变量也是如此。

你可能听说过,为了提高性能,在读或写原子数据的时候,应该避免使用同步。这个建议是非常危险而错误的。虽然语言规范保证了线程在读取原子数据的时候,不会看到任意的数值(严格的说是完整的值,即不会读取还未修改完成的值),但是它并不保证一个线程写入的值对于另一个线程将是可见的(即另一线程修改完后,其他线程有可能将永远读不到这个修改后的值)。为了在线程之间进行可靠的通信(需要靠可见性来保证),也为了互斥访问(需要原子性来保证),同步(需要可见性和原子性来保证)是必要的。这归因于Java语言规范中内存模型,它规定了一个线程所做的变化何时以及如何让其他线程可见[JLS 17]。

如果对共享的可变数据的访问不能同步,其后果将非常可怕,即使这个变量是原子可读写的。考虑下面这个阻止一个线程妨碍另一个线程的任务。由于boolean域的读和写操作都是原子的,程序员在访问这个域的时候不再使用同步,这是错误的做法:

import java.util.concurrent.TimeUnit;public class StopThread {    private static boolean stopRequested;    public static void main(String[] args) throws InterruptedException {       Thread backgroundThread = new Thread(new Runnable() {           public void run() {              int i = 0;              while (!stopRequested)                  i++;           }       });       backgroundThread.start();       //睡一秒       TimeUnit.SECONDS.sleep(1);       stopRequested = true;    }}

你可能期待这个程序运行大约一秒钟之后,主线程将stopRequested设置为true,致使后台线程的循环终止。但是在我的机子上,这个程序永远不会终止:因为后台线和永远在循环中!
问题在于,由于没有同步,就不能保证后台线程何时“看到”主线程对stopRequested的值所做的修改。在没有同步的情况下,VM将个这个代码:

while (!stopRequested)    i++;

转变成这样:

if (!stopRequested)    while (true)       i++;

这是完全有可能的,也是可以接受的。这种优化称作提升(hoisting),正是HopSpot Server VM的工作。结果是个“活性失败”:这个程序无法结束。修改这个问题的一种方式是同步访问stopRequested域,修改如下:

public class StopThread {    private static boolean stopRequested;    private static synchronized void requestStop() {       stopRequested = true;    }    private static synchronized boolean stopRequested() {       return stopRequested;    }    public static void main(String[] args) throws InterruptedException {       Thread backgroundThread = new Thread(new Runnable() {           public void run() {              int i = 0;              while (!stopRequested())                  i++;           }       });       backgroundThread.start();       TimeUnit.SECONDS.sleep(1);       requestStop();    }}

注意上面的写方法(requestStop)和读方法(stopRequested)都被同步了,只同步写方法或读方法是不够的!

StopThread程序中被同步方法的动作即使没有同步也是原子的。换句话说,这些方法的同步只是为了它的通信效果(即可见性),而不是为了互斥访问(即原子性)。虽然循环的每个迭代中的同步开销很小,还是有其他更正确的替代方法,它更加简洁,性能也可能更好。这种替代就是将stopRequested声明为volatile,第二版本的StopThread中的锁就可以省略。虽然volatile修饰符不具有互斥访问的特性,但它可以保证任何一个线程在读取该域的时候都将看到最近刚刚被其他线程写入的值,下面是使用volatile修正后的版本:

public class StopThread {    private static volatile boolean stopRequested;    public static void main(String[] args) throws InterruptedException {       Thread backgroundThread = new Thread(new Runnable() {           public void run() {              int i = 0;              while (!stopRequested)                  i++;           }       });       backgroundThread.start();       TimeUnit.SECONDS.sleep(1);       stopRequested = true;    }}

上面就说了,volatile只具有可见性,而不具有原子性,所以使用时要格外小心,请考虑下面的方法,假设它要产生序列号:

private static volatile int nextSerialNumber = 0;public static int generateSerialNumber() {    return nextSerialNumber++;}

这个方法的目的是要确保每次调用都要返回不同的值,而且是递增的(只要不超过2^32次调用)。这个方法的状态只包含一个可原子访问的域:nextSerialNumber,不同步的情况下读到的这个域的所有可能的值都是合法(即不可能读到修改未完成的值),但是,这个方法仍然无法工作。
问题在于,增量操作(++)不是原子的。它在nextSerialNumber域中执行两项操作:首先它读取值,然后写回一个新值,相当于原来的值再加上1。如果第二个线程在第一个线程读取旧值和写回新值期间读取这个域,第二个线程就会与第一个线程一起看到同一个值,并返回相同的序列号。这就是“安全性失败”:这个程序会计算出错误的结果。
修正generateSerialNumber方法的一种方法是是在它的声明中加上synchronized修饰符。这样可能确保多个调用不会交叉存在。一旦这么做,就可以且应该从nextSerialNumber中删除volatile修饰符。为了让这个方法更可靠,要用long代替int。但最好还是遵循第47条中的建议,使用类AtomicLong,它是java.util.concurrent.atomic的一部分,它比同步版本的generateSerialNumber性能上可能要更好,因为atomic包使用了非锁定的线程安全技术来做到同步的,下面是使用AtomicLong修正后的版本:

private static final AtomicLong nextSerialNum = new AtomicLong();public static long generateSerialNumber() {    return nextSerialNum.getAndIncrement();}

避免本条目中所讨论到的问题的最佳办法是不共享可变的数据,要么共享不可变的数据(见第15条),要么压根不共享。

让一个线程在我短时间内修改一个数据对象,然后与其他线程共享,这是可以接受的,只同步共享对象引用的动作。然后其他线程没有进一步的同步也可以读取对象,只要它没有再被修改。这种对象被称作为事实上不可变的。将这种对象引用从一个线程传递到其他的线程被称作安全发布。安全发布对象引用有许多种方法:可以将它保存在静态域中,作为类初始化的一部分;可以将它保存在volatile域、final域或者通过正常锁定访问域中;或者可以将它放到并发集合中。下面是针对安全发布的例子“将 volatile 变量用于一次性安全发布”,来自XXXX:

模式 #2:一次性安全发布(one-time safe publication)

缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原语值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。(这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象)。

实现安全发布对象的一种技术就是将对象引用定义为 volatile 类型。清单 3 展示了一个示例,其中后台线程在启动阶段从数据库加载一些数据。其他代码在能够利用这些数据时,在使用之前将检查这些数据是否曾经发布过。
清单 3. 将 volatile 变量用于一次性安全发布

public class BackgroundFloobleLoader {    public volatile Flooble theFlooble;    public void initInBackground() {        // do lots of stuff        theFlooble = new Flooble();  // this is the only write to theFlooble    }}public class SomeOtherClass {    public void doWork() {        while (true) {            // do some stuff...            // use the Flooble, but only if it is ready            if (floobleLoader.theFlooble != null)                doSomething(floobleLoader.theFlooble);        }    }}

如果 theFlooble 引用不是 volatile 类型,doWork() 中的代码在解除对 theFlooble 的引用时,将会得到一个不完全构造的 Flooble。
该模式的一个必要条件是:被发布的对象必须是线程安全的,或者是有效的不可变对象(有效不可变意味着对象的状态在发布之后永远不会被修改)。volatile 类型的引用可以确保对象的发布形式的可见性,但是如果对象的状态在发布后将发生更改,那么就需要额外的同步。

总之,当多个线程共享可变数据的时候,每个读或写数据的线程都必须执行同步。如果没有同步,就无法保证一个线程所做的修改可以被另一个线程获知。未能同步共享可变数据会造成程序的“活性失败”和“安全性失败”。如果只是需要线程之间的交互通信,而不需要互斥,volatile修饰就是一种可以接受的同步形式。

《Practical Java》线程拾遗

如果synchronized函数抛也异常,则在异常离开这个函数前,锁会被自动释放。

不允许你将构造函数声明为synchronized,否则编译出错。原因是当两个线程并发调用同一个构造函数时,它们各自操控的是同一个class的两个不同实体对象的内存,所以没有必要。但是,如果构造器中要访问竞争共享资源的代码时,需要使用同步块来访问临界资源。

synchronized修饰的非静态函数时,锁对象为this;修饰静态函数时,锁对象为当前对象的Class对象。

需要同步的资源一定要声明成private的,不然外界直接可以访问这个临界资源了。

notifyAll和notify一样,不能指定以何种顺序通知线程。唤醒线程由JVM决定,除了保证所有等待中的线程都被唤醒之外,不做任何其他保证,线程未必以优先权顺序来接获通知。

使用wait和notifyAll线程通信机制替换轮询循环,避免不必要的性能损耗。

不要对locked object(上锁对象)的object reference重新赋值,否则会破坏同步。

不要调用stop或suspend。stop的本意是用来中止一个线程,中止线程的问题根源不在object locks,而在object的状态,当stop中止一个线程时,会释放线程持有的所有locks,但是你并不知道当时代码正在做些什么,所以会造成object处于无效状态;suspend本意是用来“暂时悬挂起一个线程”,但不安全,因为容易引起死锁,与sleep一样的是阻塞时不释放锁,但与sleep不同的是sleep是在等待一段时间后会自动唤醒,而suspend后一定需要另一线程通过调用该线程的resume方法来恢复,但此时如果调用resume方法的线程需要suspend所拥有的锁时,就会产生死锁,而sleep则安全多了,它在阻塞只在指定的时间之内,时间一到它就会恢复运行,不易引起死锁;destroy该方法最初用于破坏该线程,与suspend一样也不会释放锁,不过,该方法决不会被实现,即使要实现,它也极有可能与 suspend 一样产生死锁。

死锁实例:

class TestDeathLock {//死锁例子    static void deathLock(Object lock1, Object lock2) {       try {           synchronized (lock1) {              Thread.sleep(10);              synchronized (lock2) {                  System.out.println(Thread.currentThread());              }           }       } catch (Exception e) {           e.printStackTrace();       }    }    public static void main(String[] args) {       final Object lock1 = new Object();       final Object lock2 = new Object();       new Thread() {           public void run() {              deathLock(lock1, lock2);           }       }.start();       new Thread() {           public void run() {              // 注意,这里在交换了一下位置              deathLock(lock2, lock1);           }       }.start();    }}

67、 避免过多同步

与第66相反。过多同步可能会导致性能降低、死锁,甚至不确定的行为。

为了避免活性失败和安全性失败,在一个被同步的方法或者代码块中,永远不要放弃对客户端的控制。换句话说,在一个被同步的区域内部,不要调用自己类中可被重写的方法,或者是由客户端以函数对象(如策略接口或回调接口)的形式提供的方法(见第21条)。从包含该同步区域的类的角度来看,这样的方法是外来的,这个类不知道这样的方法会做什么事,也无法控制它。根据外来方法的作用,从同步区域中调用它会导致异常、死锁或者数据损坏。

下面是一个可被观察的集合,为了简单起见,在从集合中删除元素时(remove())没有提供通知方法,只提供了在调用添加add()时才通知所有观察者,这个可被观察的集合类ObservableSet是在第16条中可重用的ForwardingSet上实现的:

public class ObservableSet<E> extends ForwardingSet<E> {    public ObservableSet(Set<E> set) {       super(set);    }    private final List<SetObserver<E>> observers = new ArrayList<SetObserver<E>>();    public void addObserver(SetObserver<E> observer) {       synchronized (observers) {           observers.add(observer);       }    }    public boolean removeObserver(SetObserver<E> observer) {       synchronized (observers) {           return observers.remove(observer);       }    }    // This method is the culprit    private void notifyElementAdded(E element) {       synchronized (observers) {           for (SetObserver<E> observer : observers)              //这里就是在调用外来方法,由客户端提供实例,这里只是调用了回调接口而已              observer.added(this, element);       }    }    @Override    public boolean add(E element) {       boolean added = super.add(element);       if (added)           notifyElementAdded(element);       return added;    }    @Override    public boolean addAll(Collection<? extends E> c) {       boolean result = false;       for (E element : c)           result |= add(element); // calls notifyElementAdded       return result;    }}

观察者接口:

// 集合观察者回调接口public interface SetObserver<E> {    // 当一个元素添加到ObservableSet时调用    void added(ObservableSet<E> set, E element);}

第一次使用下面测试类来进行测试:

public class Test1 {    public static void main(String[] args) {       ObservableSet<Integer> set = new ObservableSet<Integer>(              new HashSet<Integer>());       set.addObserver(new SetObserver<Integer>() {// 观察者注册           public void added(ObservableSet<Integer> s, Integer e) {              System.out.println(e);              if (e == 23)                  s.removeObserver(this);//注销           }       });       for (int i = 0; i < 100; i++)           set.add(i);    }}

上面运行时输出到23后,ObservableSet的notifyElementAdded方法的for循环抛出了ConcurrentModificationException异常,因为notifyElementAdded在使用Iterator遍历集合的过程中,另一个方法added删除元素23,改变了observers的结构,所以当它准备遍历第元素时24就抛出了异常,这正是因为违反了在使用代替遍历集合时,不能通过集合本身去修改其结构的约束所致。

第二次使用以下类来进行测试:

public class Test2 {    public static void main(String[] args) {       ObservableSet<Integer> set = new ObservableSet<Integer>(              new HashSet<Integer>());       // Observer that uses a background thread needlessly       set.addObserver(new SetObserver<Integer>() {           public void added(final ObservableSet<Integer> s, Integer e) {              System.out.println(e);              if (e == 23) {                  ExecutorService executor = Executors                         .newSingleThreadExecutor();                  final SetObserver<Integer> observer = this;                  try {                     executor.submit(new Runnable() {                         public void run() {                            s.removeObserver(observer);                         }                     }).get();//等待removeObserver方法调用完成                  } catch (ExecutionException ex) {                      throw new AssertionError(ex.getCause());                  } catch (InterruptedException ex) {                     throw new AssertionError(ex.getCause());                  } finally {                     executor.shutdown();                  }              }           }       });       for (int i = 0; i < 100; i++)           set.add(i);    }}

运行时发生死锁。后台线程调用s.removeObserver,它企图锁定observers,但它无法获得该锁,因为主线程已经先锁定了。在这期间,主线程又一直等待后台线程来完成对观察都的删除,这正是造成死锁的原因。

ObservableSet中的同步根本就没有起到作用,相反还造成了上面的死锁。通过将外来方法的调用移出同步的代码块来解决这个问题通常并不太困难。第一种解决办法是对于notifyElementAdded方法,给observers列表拍张“快照”,然后没有锁也可以安全地遍历这个列表了,经过这样的修改,前两个例子运行起来不会出异常或死锁了:

private void notifyElementAdded(E element) {    List<SetObserver<E>> snapshot = null;    synchronized (observers) {       snapshot = new ArrayList<SetObserver<E>>(observers);    }    for (SetObserver<E> observer : snapshot)       observer.added(this, element);}

第二种解决办法是使用1.5中的并发集合类,见第69条,这里使用CopyOnWriteArrayList来代替ArrayList,每次add与remove、set时都会重新拷贝整个底层数组,由于内部数组永远没有改动,即没有共享,所以不需要锁定:

private final List<SetObserver<E>> observers = new CopyOnWriteArrayList<SetObserver<E>>();public void addObserver(SetObserver<E> observer) {    observers.add(observer);}public boolean removeObserver(SetObserver<E> observer) {    return observers.remove(observer);}private void notifyElementAdded(E element) {    for (SetObserver<E> observer : observers)       observer.added(this, element);}

在同步区外调用外来方法被称作为“开放调用”,除了可以避免死锁之外,开放调用还可以极大地增加并发性。外来方法的运行时间可能是任意长,如果在同步区域内调用外来方法,其他线程对受保护资源的访问就会阻塞。

通常,你应该在同步区域内做尽可能少的工作。获得锁,检查共享数据,根据需要转换数据,然后释放锁。如果你必须要执行某个很耗时的动作,则应该设法将它移到同步区外,但不能违背第66条的指导方针。

上面是讨论正确性,下面讨论一下性能。虽然自从Java平台早期以来,同步的成本已经下降了,但更重要的是,永远不要过多同步。在这个多核时代,过多同步的实际成本并不是指获取锁所花费的CPU时间,而是指失去了并行的机会。另外潜在的开销在于,它会限制VM优化代码执行的能力。

要在一个类的内部进行同步(同步块,非同步整个方法),一个很好的理由是因为它将被大量地并发使用,而且通过执行内部细粒度的同步操作你可以获得很高的并发性。

如果一个可变的类要在并发环境中使用,应该使这个类变成线程安全的(见70)。如果经常用在并发环境中,通过内部同步,你可以获得明显比从外部锁整个对象更高的并发性(在外部同步锁的粒度粗,最细也只能到方法级别,而在内同步可以缩小同步的范围,只在需要的代码行进行同步,而不是整个方法。粗粒度锁时间长,而细粒度锁时间短,所以并发性高)。否则,如果很少在并发环境中,就不要在内部同步,让客户在必要的时候(需要并发的时候)从外部同步。在Java平台出现的早期,许多类都违背了这些指导方针,例如,StringBufer实例几乎总是被用于单个线程之中,而它们执行的却是内部同步。为此,StringBuffer基本上都都StringBuilder代替,它在Java1.5版本中是个非同步的StringBuffer。

如果你在内部同步了类,就可以使用不同的方法来实现高并发性,例如拆分锁、分离锁和非阻塞并发控制。

如果方法修改了静态域,那么你也必须同步对这个域的访问,即使这个方法通常只用于单个线程。客户要在这种方法上执行外部同步是不可能的,因为不可能保证其他不相关的客户也会执行外部同步。第66条中的generateSerialNumber方法就是这样的一个例子。(注,这段一直没有理解)

总之,为了避免死锁和数据破坏,千万不要从同步区域内部调用外来方法。更为一般地讲,要尽量限制同步域内部的工作量。当你在设计一个可变类的时候,要考虑一下它们是否应该自己完成同步操作。

68、 task(工作单元)和executor(执行机制)优先于线程(工作单元 + 执行机制)

本书第1版49条中阐述了简单的工作队列,下面是实例代码:

//工作队列public abstract class WorkQueue {    private final List queue = new LinkedList();//队列    private boolean stopped = false;    protected WorkQueue() {       //启动工作队列后台处理线程       new WorkerThread().start();    }    // 入队    public final void enqueue(Object workItem) {       synchronized (queue) {           queue.add(workItem);           queue.notify();       }    }    //停止工作队列    public final void stop() {       synchronized (queue) {           stopped = true;           queue.notify();       }    }    //工作队列中元素的抽象处理方法    protected abstract void processItem(Object workItem) throws InterruptedException;    // 后台工作线程    private class WorkerThread extends Thread {       public void run() {           while (true) { // Main loop              Object workItem = null;              synchronized (queue) {                  try {                     while (queue.isEmpty() && !stopped)                         queue.wait();                  } catch (InterruptedException e) {                     return;                  }                  if (stopped)                     return;                  workItem = queue.remove(0);              }              try {                  //调用外来方法,一定要入在同步块的外面调用,原因见第67条                  processItem(workItem); // No lock held              } catch (InterruptedException e) {                  return;              }           }       }    }}//工作队列测试class DisplayQueue extends WorkQueue {    //元素处理方法实现 每秒处理一个元素    protected void processItem(Object workItem) throws InterruptedException {       System.out.println(workItem);       Thread.sleep(1000);    }    public static void main(String[] args) throws InterruptedException {       WorkQueue queue = new DisplayQueue();       for (int i = 0; i < 10; i++)           queue.enqueue(new Integer(i));       // 等待所有元素处理完后再停止队列       Thread.sleep(11 * 1000);       queue.stop();//停止工作队列后台处理线程    }}

这个类允许客户将后台线程异步处理的工作项目加入队列。当不再需要这个工作队列时,客户端可以调用一个方法,让后台线程在完成了已经在队列中的所有工作之后,优雅地终止自己。但这个类容易出现安全问题或准确性。幸运的是,你再也不需要编写这样的代码了。

在java.15中,增加了java.util.concurrent。这个包中包含了一个Executor Framework,这是一个很灵活的基于接口的任务执行工具。它创建了一个在各方面都本书第一版更好的工作队列,却只需要这一行代码:

ExecutorService executor = Executors.newSingleThreadExecutor();

下面是为执行提交一个Runnable的方法:

executor.execute(runnable);

下面是告诉executor如果优雅地终止(如果不这样,VM可能不会退出):

executor.shutdown();

你可以利用executor service完成更多的事情,如可以等待一特殊的任务(如第67条中的SetObserver),你也可以等待一个任务集合中的任何任务或者所有任务完成(利用invokeAny或者invokeAll方法),你也可以等待excecutor service优雅地完成终止(利用awaitTermination方法),你还可以在任务完成时逐个地获取这些任务的结果(利用ExecutorCompletionService),等等。

如果想让不止一个线程来处理来自这个队列的请求,只要调用一个同的静态工厂,就可创建不同的executor service,即线程池,池中的数量可以固定也可变化。

当然选择executor service是很的技巧的。如果是小程序,或者是轻载的服务器,使用Executors.newCachedThreadPool通常不错,因为它不需要配置,并且一般也能完成工作。但对于大负载的服务器来说,缓存的线程池就不是好了,因为在缓存的线程池中,被提交的任务没有排队,而是直接交给线程执行,如果服务器负载很重,会导致吞吐率下降,创建更多的线程。因此,在大负载的产品中,最好使用executors.newFixedThreadPool,它为你提供了一个包含固定线程数目的线程池。然而,如果你想更灵活,可以直接使用ThreadPoolExecutor类,这个类允许你控制线程池的几乎每个方面。

你不仅应该尽量不要编写自己的工作队列,而且应该尽量不直接使用线程,现在关键的抽象不再是Thread了,它以前可是即充当工作单元,又是执行机制。现在工作单元和执行机制是分开的。现在关键的抽象是工作单元,称作任务(task)。任务有两种:Runnable及其近亲Callable(与Runnable相似,但它会返回值)。执行任务的通用机制是executor service。如果你从任务的角度来看问题,并让一个executor service替你执行任务,在选择适当的执行策略方面就获得很的灵活性,从本质上讲,Excecutor Famework所做的工作是执行。

Executor Framework也有一个可替代java.util.Timer的东西,即ScheduledThreadPoolExecutor。虽然timer使用起来容易,但被调度的线程池executor更加灵活。timer只用一个线程来执行任务,这在面对长期运行的任务时,会影响到定时的准确性。如果timer唯一的线程抛出未被捕获的异常,timer就会终止。但被调度的线程池executor支持多个线程,并且能从抛出未受检异常的任务中恢复。

69、 并发工具优先于wait和notify

回顾第一版:永远不要在循环的外面调用wait—–

总是使用wait循环模式来调用wait方法,永远不要在循环的外面调用wait。循环被用来在等待的前后测试等待条件。下面是使用wait方法的标准模式:

synchronized(obj){    while(<等待条件>){        obj.wait();    }    … // 条件满足后开始处理}

而不能是这样:

if(<等待条件>){       obj.wait();}… // 条件满足后开始处理

在等待之前测试条件,当条件已经成立时就跳过等待,这对于确保活性是必要的。如果条件已经成立,并且在线程等待之前,notify(或者notifyAll)方法已经被调用,则无法保证该线程将会从等待中苏醒过来。

在等待之后测试条件,如果条件不成立的话继续等待,这对于确保安全性是必要的。当条件不成立的时候,如果线程继续执行,则可能会破坏被保护的约束条件。当条件不成立时,有下面一些理由可使一个线程苏醒过来:

  • 另一个线程可能已经得到了锁,并且从一个线程调用notify那一刻起,到等待线程苏醒过来的这段时间中,得到锁的线程已经改变了受保护的状态。
  • 条件并不成立,但是一个线程可能意外地或恶意地调用了notify。在共有可访问地对象上等待,这些类实际上把自己暴露在了这种危险地境地中。共有可访问对象地同步方法中包含地wait都会出现这样地问题。
  • 通知线程在唤醒等待线程时可能会过度“大方“。例如,即使只有某一些等待线程的条件已经被满足,但是通知线程可能仍然调用notifyAll。
  • 在没有通知的情况下,等待线程也可能(但很少)会苏醒过来。这被称为“伪唤醒“。

为什么这么做,因为当线程醒过来时,等待条件可能还是成立的。通过调用某个对象上的wait后,当线程会进入锁对象的等待池,在被唤醒后不会马上进入就绪状态,而是进入锁对象的锁池,只有再一次获取锁后,才能进入到就绪状态,也有可能就在它再一次获取锁前,等待条件被另一线程改变了,或者是在等待条件还根本还未破坏时另一线程意外或恶意的调用了notify或notifyAll。

notify唤醒一个正在等待的线程(如果这样的线程存在的话),而notifyAll唤醒所有正在等待的线程,通常,你总是应该使用notifyAll。这是合理而保守的建议,它总会产生正确的结果,因为它可以保证你将会唤醒所有需要被唤醒的线程。你可能也会唤醒基本他一些线程,但是这不会影响唾弃的正确性,这些线程醒来之后,会检查它们正在等待的条件,如果发现条件不满足,就会继续等待。

从优化的角度来看,如果处于等待状态的所有线程都在等待同一个条件,而每次只有一个线程可以从这个条件中被唤醒,那么你就应该选择调用nofiy,而不是notifyAll。即使这些条件都是真的,还是有理由使用notifyAll而不是notity,就好像把wait调用放在一个循环中,以避免在公有可访问的对象上意外或恶意的通知。与此类似,使用notifyAll代替notify可能避免来自不相关线程的意外或恶意的等等,否则的话,这样的等待会“吞掉”一个关键的通知,使真正的接收线程无限地等待下去。在第68条中WorkQueue例子中没有使用notifyAll的原因是,因为辅助线程WorkerThread在一个私有的对象queue上等待,所以这里不存在意外或者恶意地等待的危险。

关于使用notifyAll优先于notify的有一个告诫:虽然使用notifyAll不会影响正确性,但会影响性能,特别在是等待线程很多的情况下,因为所有被唤醒(唤醒后只是进行锁池状态)后的线程有可能因为等待条件被破坏而再次进入阻塞状态(等待池)(中间经过了从等待池状态中唤醒—>进入锁池状态—>获取锁—>进行等待池—>释放锁几个动作,所以很消耗性能,另外,即使没有获取到锁的,也会因引起大量线程竞争锁而影响性能),这会导致大量的上下文切换。但如果唤醒后等待条件不再被破坏的情况下是没有问题。所以本人认为在线程数量小或只有一个线程的情况下,可以使用notifyAll,因为这样即带来了可靠性,但又不太引响性能;或者是在线程数量大的情况,而一旦唤醒后等待条件不再被其他线程“很快”破坏或者根本就不可能被破坏时,也还是可以使用notifyAll的,因为此时的唤醒工作不会白作。

end—–

上面的这些仍然有效,但这些建议现在远远没有之前那么重要了,因为几乎没有理由再使用wait和notify,1.5中有更高级的并发工具来实现这些。

java.util.concurrent中更高级的工具分成三类:Executor Framework、并发集合以及同步器, Executor Framework已在第68条中简单的提到过。

并发集合为的集合接口(如List、Queue和Map)提供了高性能的并发实现。为了提供高并发性,这些实现在内部自己管理同步,困此,并发集合中不可能排除并发活动;将它锁定没有什么作用,只会使程序速度变慢。

上面提到“并发集合中不可能排除并发活动”,是说客户无法原子地对并发集合进行方法调用(如先调用它的get方法判断一个元素是否存在,如果不存在,再通地put放入不存在的元素就会有并发问题,因为可以在get后,切换到另一线程,然后再切换回来调用put),并发集合中的方法只是单个方法是原子性的,如果调用多个方法(如get与put)要求是原子的则也需要额外的同步才行,不过这些集合已经将些多个方法的调用扩展成了另一个接口,这样我们也就不需要自己同步了。例如ConcurrentMap扩展了Map接口,并添加了几个方法,如putIfAbsent(K key, V value)。

ConcurrentHahsMap除了提供卓越的并发性之外,速度也非常快。除非不得已,否则应该优先使用ConcurrentHahsMap,而不是使用Collections.synchronizedMap或者Hashtable。并发Map比老式的同步Map性能高,所以应该优先使用并发集合,而不是使用外部同步的集合。

同步器是一些使线程能够等待另一个线程的对象,允许它们协调动作,最常用的同步器是CountDownLatch和Semaphore。较不常用的是CyclicBarrier和Exchanger。

总这,直接使用wait和notify就像用“并发汇编语言”进行编程一样,而java.util.concurrent则提供了更高级语言。没有理由在新的代码中使用wait或notify,即使有,也是极少。如果你在维护使用wait和notify的代码时,务必确保始终是利用标准模式从while循环内部调用wait。一般情况下,应该使用notifyAll,而不是notify。如果使用notify,请小心,确保程序的活性。

70、 线程安全性的文档化

如果你没有在一个类的文档里描述并发的情况,使用这个类的程序员将可能缺少同步和过多同步。

一个类为了可被多个线程安全可用,必须在文档中清楚地说明它所支持的线程安全性级别,下面是些常见的安全级别:
1、 不可变类——这个类的实例是不可变的,所以,不需要外部的同步,如String、Long和BigInteger。
2、 无条件的线程安全——这个类的实例是可变的,但是这个类有着足够的内部同步,所以,它的实例可以被并发使用,无需任何外部同步。如,Random和ConcurrentHashMap。
3、 有条件的线程安全——除了有些方法为进行安全的并发使用而需要外部同步外,这种线程安全级别与无条件的线程安全相同。如Collections.synchronized包装返回的集合,它们的迭代器(iterator)要求同步,否则在迭代期间被其他线程所修改。下面是源码:

 public Iterator<E> iterator() {        return c.iterator(); // Must be manually synched by user!   }

4、 非线程安全——这个类的实例是可变的。为了并发地使用它们,客户必须利用自己选择的外部同步包围每个方法调用(或者调用序列)。如能用的集合实现ArrayList、HashMap。
5、 线程对立的——这个类不能安全地被多个线程并发使用,即使所有的方法调用都被外部同步包围。线程对立的根源通常在于,没有同步地修改静态数据。没有人会有意编写一个线程对立的类;这种类是因为没有考虑到并发性而产生后果。幸运的是,在Java平台类库中,这样的类很少,System.runFinalizersOnExit()是这样的,但已废除了。

上面这里只是粗略的分类,详细参见《Java Concurrency Practice》一书中的线程安全注解。

在文档中描述一个有条件的线程安全类要特别小心。你必须指明哪个调用序列需要外部同步,还要指明为了执行这些序列,必须获得哪一把锁(极少的情况下是指哪几把锁)。通常情况下,这是指作用在实力身上的那把锁,但也有例外。如果一个对象代表了另一个对象的一个视图,客户通常就必须在后台对象上同步,以防止其他线程直接修改后台对象。例如,Collection.synchronizedMap的文档应该有这样的说明:

It is imperative that the user manually synchronize on the returned map when iterating over any of its collection views:(当遍历任何被返回Map的集合视图时,用户必须手工对它们进行同步:)
Map<K, V> m = Collections.synchronizedMap(new HashMap<K, V>());...Set<K> s = m.keySet();// Needn't be in synchronized blocksynchronized(m){    for(K key : s)        key.f();}

类的线程安全说明通常放在它的文档注释中,但是带有特殊线程安全属性的方法则应该在它们自己的文档注释中说明它们的属性。没有必要说明枚举类型的不可变性。除非从返回类型来看已经很明显了,否则静态工厂必须在文档中说明被返回对象的线程安全性。

尽量别使用公有对象来作为锁对象,因为这样外界可能意外或者故意的霸占锁,造成拒绝服务,所以这该使用私有锁对象来代替同步方法(非静态的同步方法的锁就是this,但这个对象在外面可以访问到,所以避免使用):

private final Object lock = new Object();//注意最好声明成final的    public void foo(){           synchronized(lock){           …    }}

这样外界就不能访问到这个锁对象,所以它们不可能妨碍对象的同步。但是,需要重早一下的是,私有锁对象模式只能用在无条件的线程安全类上。有条件的线程安全类不能使用这种模式,因数它们必须在文档中说明:在执行某些方法调用序列时,它们客户端程序必须获取哪把锁。

注意lock被声明伪final的。这样可以防止不小心改变它的内容,而导致不同步访问包含对象。

私有锁对象模式特别适合用于那些专门为继承而设计的类。如果这种类使用它的实例作为锁对象,子类可能很容易在无意中妨碍基类的操作,反之亦然。出于不同的目的而使用相同的锁,子类和基类可能会“相互绊住对方的脚”。

总之,每个类都需要说明线程安全说明,synchronized修饰符并不能说明这个类就是线程安全的。有条件的线程安全类必须在文档中指明“哪个方法调用序列需要外部同步,以及在执行这些序列的时候要获得哪把锁”。如果你编写的是无条件的线程安全类,就应考虑私有锁对象代替同步方法。这样可以防止客户端程序和子类的不同步干扰。

71、 慎用延迟初始化

延迟初始化时延迟到需要域的值时才将它初始化的这种行为。如果永远不需要整个值,整个域就永远不会被初始化。这种方法既适用于静态域,也适用于实例域。虽然延迟初始化主要是一种优化,但它也可以用来打破类和实例初始化中的有害循环。

就像大多数优化一样,对于延迟初始化,最好建议“除非绝对必要,否则就不要这么做“,延迟初始化降低了初始化类或者创建实例的开销,却增加了访问被延迟初始化的域的开销。根据延迟初始化的域最终需要初始化的比例、初始化这些域要多少开销,以及每个域多久被访问一次,延迟初始化世纪上可能降低了性能。

如果域只在类的实例部分被访问,并且初始化这个域的开锁很高,可能就值得进行延迟初始化。要确定这一点,唯一的办法就是测量类在用和不用延迟初始化时的性能差别。

大多情况下,正常的初始化要优先于延迟初始化,下面是下正常初始化的一个典型声明,注意,如果域是不可变的,一定要加上final或者在可变情况下加上volatile,因为这样才能保证实例初始化的完整性:

// Normal initialization of an instance field - Page 282private final FieldType field1 = computeFieldValue();

如果利用延迟初始化来破坏初始化的循坏,就要使用同步访问方法,因为它是最简单,最清楚的替代方法:

private FieldType field;synchronized FieldType getField(){    if(field == null){        filed = computeFieldvalue();    }    return field;}

上面这两种模式也可用在静态域上,只需在前域与方法前添加static,锁对象从this变为.class对象即可。

如果出于性能的考虑需要对静态域使用延迟初始化,可借助于一个Holder类来延迟加载(叫做lazy initialization holder class模式),保证了类要到被用到的时候才会被初始化:

prvate static class FieldHolder{       static final FieldType field = computeFieldValue();}static FieldType getField(){return FieldHolder.field;}//这里没有使用同步,性能高

现代的VM将在初始化类的时候,将会同步域的访问,也就是说如果类还没有初始化完成,是不能访问这些域的,一旦这个类被初始化,VM将修补代码,以便后续对该域的访问不会导致任何同步。

如果出于性能的考虑而需要对实例域使用延迟初始化,就使用双重检查模式。这种模式避免了在域被初始化之后访问这个域时的锁定开销(因为不必要的同步):

private volatile FieldType field;FieldType getField() {       FieldType result = field;//第一次从主存中读field       if (result == null) { // First check (no locking) 从工作内中读result              synchronized (this) {                     /*                      * 这里个人认为result没起多大作用,直接对field进行判断即可,像这样:                      * if (field == null)                      *          field = result = computeFieldValue();                      * 因为在这里最多只执行两次,一次就是在初始化时,第二次是初始化完成后                      * 第一次访问,但第二次有可能不会发生在这里,所以直接使用上面与下面没                      * 有什么很大的区别,result优化效果最多起二次作用,而不上外面的多次                      * 访问那么有用                      */                     result = field;                     if (result == null) // Second check (with locking)                            field = result = computeFieldValue();              }       }       return result;//从工作内中读result}

这种模式背后的思想是:两次检查域的值[因此名字叫双重检查],第一次检查时没有锁定,看看这个域是否被初始化了,第二次检查时有锁定。只有当第二次检查时表明这个域没有被初始化,才会调用初始化方法对这个域进行初始化。因为如果域已经被初始化就不会有锁定,域被声明为volatile很重要。

注意这里的局部变量result,这个变量的作用是确保field在已经被初始化的情况下从主存中只读取一次,而不使用这个局部变量时需要至少两次从主内存中读取,而从工作内存中读取比从主内存中直接读取要快。虽然这不是严格的要求,但是可以提升性能。在我的机器上,上述方法比没用局部变量的方法快了大约25%,对比一下不使用局部变量时双重检测模式:

private volatile FieldType field;FieldType getField() {       if (field== null) { // 第一次从主存中读field              synchronized (this) {                     if (field == null) // 第二次从主存中读field,但只有在初始化完成后第一                                                        // 访问时才有可能执行到这里                            field = computeFieldValue();              }       }       return field;//第三次从主存中读field}

在1.5以前,双重检查模式的功能很不稳定,因为volatile修饰符的语义不够强,难以支持它。但1.5版本中的内存模型解决了这个问题。所以如今可以使用双重检查对实例域进行延迟初始化,但对静态域也可使用双重检查模式,但没有理由这么做,因为使用类的延迟加载方式是更好的选择了。

双重检查模式对于只能产生一个实例是很重要的,但有时候,你可能需要延迟初始化一个可以接受重复初始化的实例域。如果是这情况,可以省去第二次检查,这就是所谓的“单重检查模式”,下面就是这样的例子,注意fileld仍然是volatile:

private volatile FieldType field;private FieldType getField() {       FieldType result = field;       if (result == null)              field = result = computeFieldValue();       return result;}

本条目中讨论的所有初始化方法都适用于基本类型的域,以及对象引用域。当双重检查模式或者单重检查模式应用到数值型的基本类型域时,就会用0来检查这个域(数值类型默认值),而不是null。

如果你不在意是否每个线程都新计算域的值,并且域的类型为基本类型,而不是long或者double类型,就可以选择从单重检查模式的域声明中删除volatile修饰符。这种变体叫 racy single-check idiom。它加快了某些架构上的域访问,代价是增加了额外的初始化(知道访问该域的每个线程都进行一次初始化)。

总之,大多数的域应该正常地进行初始化,而不是延迟初始化。如果为了达到性能目标,或者为了破坏有害的初始化循环,而必须延迟初始化一个域,就可以使用相应的延迟初始化方法。对于实例域,使用双重检查模式;对于静态域,则使用类延迟加载。对于可以接受重复初始化的实例域,也可考虑使用单重检查模式。下面是上面完全实例代码:

class FieldType {}// 各种初始化模式public class Initialization {    // 正常初始化    private final FieldType field1 = computeFieldValue();    private static FieldType computeFieldValue() {       return new FieldType();    }    // 延迟初始化模式 - 同步访问    private FieldType field2;    synchronized FieldType getField2() {       if (field2 == null)           field2 = computeFieldValue();       return field2;    }    // 对静态域使用类延迟初始化    private static class FieldHolder {       static final FieldType field = computeFieldValue();    }    static FieldType getField3() {       return FieldHolder.field;    }    // 对实例域进行双重检测延迟初始化    private volatile FieldType field4;    FieldType getField4() {       FieldType result = field4;       if (result == null) { // First check (no locking)           synchronized (this) {              result = field4;              if (result == null) // Second check (with locking)                  field4 = result = computeFieldValue();           }       }       return result;    }    // 单重检查 - 会引起重复初始化    private volatile FieldType field5;    private FieldType getField5() {       FieldType result = field5;       if (result == null)           field5 = result = computeFieldValue();       return result;    }}

72、 不依赖于线程调度器

任何依赖于线程调度器来达到正确性或者性能要求的程序,很有可能是不可移植的。

要编写健壮的、响应良好的、可移植的多线程程序应用程序,最好的办法确保可运行线程的平均数量不明显多于处理器的数量。注意可运行的数量不等于线程的总数量,在等待的线程不是可运行的。

保持可运行线程数量尽可能少的主要方法是,让每个线程做些有意义的工作,然后等待更多有意义工作,如果线程没有在做有意义的工作,就不应该运行。规定线程池的大小,并且使任务保持恰当地小,彼此独立,任务不应该太小,否则分配地开销也会影响性能。

线程不应该一直处于忙等的状态,即反复地检查一个共享对象,以等待某些事情的发生。忙等会极大地增加处理器的负担,降低了同一机器上其他进行可以完成的工作量。如下面这个例子:

public void await() {//忙等,等到为零止    while (true) {       synchronized (this) {           if (count == 0)              return;       }    }}

Thread.yield的唯一用途是在测试期间人为地增加程序的并发性,可以发现一些隐藏的Bug,这种方法曾经十分奏效,但从来不能保证一定可行。Java语言规范中,Thread.yield根本不做实质性的工作,只是将控制权返回给调用者。所应该使用Thread.sleep(1)代替Thread.yield来进行并发测试,但千万不要使用Thread.sleep(0),它会立即返回。

另外,不要人为地调整线程的优先级,线程优先级是Java平台上最不可移值的特征了。通过调整线程优先级来解决严重地活性问题是不合理的。在你找到并修正底层真正原因之前,这个问题可能会再次出现。

总之,不要让应用程序的正确性依赖于线程调度器。否则,结果得到的应用程序将既不健壮,也不具有可移植性。作为推论,不要依赖Thread.yield或者线程优先级。这些措施仅仅对调度器作些暗示。线程优先级可以用来提高一个已经正常运行的程序的服务质量,但永远不要用来“修正“一个原来并不能工作的程序。

73、 避免使用线程组

线程组初衷是作为一种安全隔离一些小程序的机制,但是它们从来没有真正履行这个承诺,它们的安全价值已经差到根本不能在Java安全模型的标准工作中提及的地步了。

除了安全性外,它们允许你同时把Thread的某些基本功能应用到一组线程上,但有时已被废弃,剩下的也很少使用了,因为它们有时并不准确。

总之,线程级并没有提供太多有用的功能,而且它们提供的许多的功能都有缺陷的。我们最好把线程组看作是一个不成功的试验,你可以忽略他们,就当不存在一样。如果你正在设计一个类需要处理线程的逻辑组,或许应该使用线程池executor。

转载自:[Effective Java]第十章 并发

0 0
原创粉丝点击