转载

《Effective Java》Second Edition 第10章 并发

第10章 并发

第66条:同步访问共享的可变数据

  1. 如果没有同步,一个线程的变化就不能被其他线程看到。同步不仅可以阻止一个线程看到对象处于不一致的状态之中,它还可以保证进入同步方法或者同步代码块的每个线程,都看到由同一个锁保护的之前所有的修改效果。
  2. 你可能听说过,为了提高性能,在读或写原子数据的时候,应该避免使用同步。这个建议是非常危险而错误的。虽然语言规范保证了线程在读取原子数据的时候,不会看到任意的数值,但是它并不保证一个线程写入的值对于另一个线程将是可见的。为了在线程之间进行可靠的通信,也为了互斥访问,同步是必要的。这归因于Java语言规范中的内存模型(memory model),它规定了一个线程所做的变化何时以及如何变成对其他线程可见。
// Broken! - How long would you expect this program to run?
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的值所做的改变。没有同步,虚拟机将这个代码:

while(!done)
    i++;

转变成这样:

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

这是可以接受的。这种优化称作提升(hoisting),正是HotSpot Server VM的工作。结果是个活性失败(liveness failure):这个程序无法前进。修正这个问题的一种方式是同步访问stopRequested域。这个程序会如预期般在大约一秒钟之内终止:

// Properly synchronized cooperative thread termination
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();
    }
}

StopThread中被同步方法的动作即使没有同步也是原子的。换句话说,这些方法的同步只是为了它的通信效果。

如果stopRequested被声明为volatile,第二种版本的StopThread中的锁就可以省略。虽然volatile修饰符不执行互斥访问,但它可以保证任何一个线程在读取该域的时候都将看到最近刚刚被写入的值:

// Cooperative thread termination with a volatile field
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;
    }
}

最好还是遵循第47条中的建议,使用类AtomicLong,它是java.util.concurrent.atomic的一部分。它所做的工作正是你想要的,并且有可能比同步版的generateSerialNumber执行得更好:

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

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

如果只需要线程之间的交互通信,而不需要互斥,volatile修饰符就是一种可以接受的同步形式,但要正确地使用它可能需要一些技巧。

第67条:避免过度同步

  1. 换句话说,在一个被同步的区域内部,不要调用设计成要被覆盖的方法,或者是由客户端以函数对象的形式提供的方法。从包含该同步区域的类的角度来看,这样的方法是外来的(alien)。这个类不知道该方法会做什么事情,也无法控制它。根据外来方法的作用,从同步区域中调用它会导致异常、死锁或者数据损坏。
  2. notifyElementAdded方法中的迭代是在一个同步的块中,可以防止并发的修改,但是无法防止迭代线程本身回调到可观察的集合中,也无法防止修改它的observers列表。
  3. 从同步区域中调用外来方法,在真实的系统中已经造成了许多死锁,例如GUI工具箱。
  4. 幸运的是,通过将外来方法的调用移出同步的代码块来解决这个问题通常并不太困难。对于notifyElementAdded方法,这还涉及给observers列表拍张“快照”,然后没有锁也可以安全地遍历这个列表了。经过这一修改,前两个例子运行起来便再也不会出现异常或者死锁了:
// Alien method moved outside of synchronized block - open calls
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);
}

事实上,要将外来方法的调用移出同步的代码块,还有一种更好的方法。自从Java 1.5发行版本以来,Java类库就提供了一个并发集合(concurrent collection),称作CopyOnWriteArrayList,这是专门为此定制的。这是ArrayList的一种变体,通过重新拷贝整个底层数组,在这里实现所有的写操作。由于内部数组永远不改变,因此迭代不需要锁定,速度也非常快。如果大量使用,CopyOnWriteArrayList的性能将大受影响,但是对于观察者列表来说却是很好的,因为它们几乎不改动,并且经常被遍历。

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

在这个多核的时代,过度同步的实际成本并不是指获取锁所花费的CPU时间,而是指失去了并行的机会,以及因为需要确保每个核都有一个一致的内存视图而导致的延迟。过度同步的另一项潜在开销在于,它会限制VM优化代码执行的能力。

如果一个可变的类要并发使用,应该使这个类变成是线程安全的,通过内部同步,你还可以获得明显比从外部锁定整个对象更高的并发性。否则,就不要在内部同步。让客户在必要的时候从外部同步。在Java平台出现的早期,许多类都违背了这些指导方针。例如,StringBuffer实例几乎总是被用于单个线程之中,而它们执行的却是内部同步。为此,StringBuffer基本上都由StringBuilder代替,它在Java 1.5发行版本中是个非同步的StringBuffer。当你不确定的时候,就不要同步你的类,而是应该建立文档,注明它不是线程安全的。

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

第68条:executor和task优先于线程

  1. 在Java 1.5发行版本中,Java平台中增加了java.util.concurrent。这个包中包含了一个Executor Framework,这是一个很灵活的基于接口的任务执行工具。它创建了一个在各方面都比本书第一版更好的工作队列,却只需要这一行代码:
ExecutorService executor = Executors.newSingleThreadExecutor();

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

executor.execute(runnable);

下面是告诉executor如何优雅地终止(如果做不到这一点,虚拟机可能将不会退出):

executor.shutdown();

例如,可以等待完成一项特殊的任务,你可以等待一个任务集合中的任何任务或者所有任务完成(利用invokeAny或者invokeAll方法),你可以等待executor service优雅地完成终止(利用awaitTermination方法),你可以在任务完成时逐个地获取这些任务的结果(利用ExecutorCompletionService),等等。

如果编写的是小程序,或者是轻载的服务器,使用Executors.newCachedThreadPool通常是个不错的选择,因为它不需要配置,并且一般情况下能够正确地完成工作。

因此,在大负载的产品服务器中,最好使用Executors.newFixedThreadPool,它为你提供了一个包含固定线程数目的线程池,或者为了最大限度地控制它,就直接使用ThreadPoolExecutor类。

现在关键的抽象不再是Thread了,它以前可是既充当工作单元,又是执行机制。现在工作单元和执行机制是分开的。现在关键的抽象是工作单元,称作任务(task)。任务有两种:Runnable及其近亲Callable。执行任务的通用机制是executor service。

从本质上讲,Executor Framework所做的工作是执行,犹如Collections Framework所做的工作室聚集(aggregation)一样。

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

第69条:并发工具优先于wait和notify

  1. 这是因为几乎没有理由再使用wait和notify了。自从Java 1.5发行版本开始,Java平台就提供了更高级的并发工具,它们可以完成以前必须在wait和notify上手写代码来完成的各项工作。既然正确地使用wait和notify比较困难,就应该用更高级的并发工具来代替。
  2. java.util.concurrent中更高级的工具分成三类:Executor Framework、并发集合(Concurrent Collection)以及同步器(Synchronizer)。
  3. 因此有些集合接口已经通过依赖状态的修改操作(state-dependent modify operation)进行了扩展,它将几个基本操作合并到了单个原子操作中。例如,ConcurrentMap扩展了Map接口,并添加了几个方法,包括putIfAbsent(key, value),当键没有映射时会替它插入一个映射,并返回与键关联的前一个值,如果没有这样的值,则返回null。例如,下面这个方法模拟了String.intern的行为(ConcurrentHashMap对获取操作(如get)进行了优化。因此,只有当get表明有必要的时候,才值得先调用get,再调用putIfAbsent):
// Concurrent canonicalizing map atop ConcurrentMap - faster!
private static final ConcurrentMap<String, String> map = 
    new ConcurrentHashMap<String, String>();
public static String intern(String s) {
    String result = map.get(s);
    if(result==null) {
        result = map.putIfAbsent(s,s);
        if(result==null)
            result=s;
    }
    return result;
}

除非不得已,否则应该优先使用ConcurrentHashMap,而不是使用Collections.synchronizedMap或者Hashtable。

有些集合接口已经通过阻塞操作(blocking operation)进行了扩展,它们会一直等待(或者阻塞)到可以成功执行为止。例如,BlockingQueue扩展了Queue接口,并添加了包括take在内的方法,它从队列中删除并返回了头元素,如果队列为空,就等待。这样就允许将阻塞队列用于工作队列(work queue),也称作生产者-消费者队列(producer-consumer queue),一个或者多个生产者线程(producer thread)在工作队列中添加工作项目,并且当工作项目可用时,一个或者多个消费者线程(consumer thread)则从工作队列中取出并处理工作项目。不出所料,大多数ExecutorService实现(包括ThreadPoolExecutor)都使用BlockingQueue。

倒计数锁存器(Countdown Latch)是一次性的障碍,允许一个或者多个线程等待一个或者多个其他线程来做某些事情。CountDownLatch的唯一构造器带有一个int类型的参数,这个int参数是指允许所有在等待的线程被处理之前,必须在锁存器上调用countDown方法的次数。

// Simple framework for timing concurrent execution
public static long time(Executor executor, int concurrency,
        final Runnable action) throws InterruptedException {
    final CountDownLatch ready = new CountDownLatch(concurrency);
    final CountDownLatch start = new CountDownLatch(1);
    final CountDownLatch done = new CountDownLatch(concurrency);
    for(int i=0; i<concurrency; i++) {
        executor.execute(new Runnable() {
            public void run() {
                ready.countDown(); // Tell timer we're ready
                try {
                    start.await(); // Wait till peers are ready
                    action.run();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    done.countDown(); // Tell timer we're done
                }
            }
        });
    }
    ready.await(); // Wait for all workers to be ready
    long startNanos = System.nanoTime();
    start.countDown(); // And they're off!
    done.await(); // Wait for all workers to finish
    return System.nanoTime() - startNanos;
}

wait方法被用来使线程等待某个对象。它必须在同步区域内部被调用,这个同步区域将对象锁定在了调用wait方法的对象上。下面是使用wait方法的标准模式:

// The standard idiom for using the wait method
synchronized (obj) {
    while(<condition does not hold>)
        obj.wait(); // (Releases lock, and reacquires on wakeup)
    ... // Perform action appropriate to condition
}

始终应该使用wait循环模式来调用wait方法;永远不要在循环之外调用wait方法。循环会在等待之前和之后测试条件。

一种常见的说法是,你总是应该使用notifyAll。这是合理而保守的建议。它总会产生正确的结果,因为它可以保证你将会唤醒所有需要被唤醒的线程。你可能也会唤醒其他一些线程,但是这不会影响程序的正确性。这些线程醒来之后,会检查它们正在等待的条件,如果发现条件并不满足,就会继续等待。

就好像把wait调用放在一个循环中,以避免在公有可访问对象上的意外或恶意的通知一样,与此类似,使用notifyAll代替notify可以避免来自不相关线程的意外或恶意的等待。否则,这样的等待会“吞掉”一个关键的通知,使真正的接收线程无限地等待下去。

第70条:线程安全性的文档化

  1. 当一个类的实例或者静态方法被并发使用的时候,这个类的行为如何,是该类与其客户端程序建立的约定的重要组成部分。
  2. 无条件的线程安全(unconditionally thread-safe)——这个类的实例是可变的,但是这个类有着足够的内部同步,所以,它的实例可以被并发使用,无需任何外部同步。其例子包括Random和ConcurrentHashMap。
  3. 有条件的线程安全(conditionally thread-safe)——除了有些方法为进行安全的并发使用而需要外部同步之外,这种线程安全级别与无条件的线程安全相同。这样的例子包括Collections.synchronized包装返回的集合,它们的迭代器(iterator)要求外部同步。
  4. 没有必要说明枚举类型的不可变性。除非从返回类型来看已经很明显,否则静态工厂必须在文档中说明被返回对象的线程安全性,如Collections.synchronizedMap所示。
  5. 当一个类承诺了“使用一个公有可访问的锁对象”时,就意味着允许客户端以原子的方式执行一个方法调用序列,但是,这种灵活性是要付出代价的。并发集合(如ConcurrentHashMap和ConcurrentLinkedQueue)使用的那种并发控制,并不能与高性能的内部并发控制相兼容。客户端还可以发起拒绝服务攻击,他只需超时地保持公有可访问锁即可。
  6. 为了避免这种拒绝服务攻击,应该使用一个私有锁对象(private lock object)来代替同步的方法(隐含着一个公有可访问锁):
\\ Private lock object idiom - thwarts denial-of-service attack
private final Object lock = new Object();
public void foo() {
    synchronized(lock) {
        ...
    }
}

因为这个私有锁对象不能被这个类的客户端程序所访问,所以它们不可能妨碍对象的同步。

如果你编写的是无条件的线程安全类,就应该考虑使用私有锁对象来代替同步的方法。这样可以防止客户端程序和子类的不同步干扰,让你能够在后续的版本中灵活地并发控制采用更加复杂的方法。

第71条:慎用延迟初始化

  1. 延迟初始化就像一把双刃剑。它降低了初始化类或者创建实例的开销,却增加了访问被延迟初始化的域的开销。根据延迟初始化的域最终需要初始化的比例、初始化这些域要多少开销,以及每个域多久被访问一次,延迟初始化(就像其他的许多优化一样)实际上降低了性能。
  2. 如果出于性能的考虑而需要对静态域使用延迟初始化,就使用lazy initialization holder class模式。这种模式(也称作initialize-on-demand holder class idiom)保证了类要到被用到的时候才会被初始化。
// Lazy initialization holder class idiom for static fields
private static class FieldHolder {
    static final FieldType field = computeFieldValue();
}
static FieldType getField() { return FieldHolder.field; }

当getField方法第一次被调用时,它第一次读取FieldHolder.field,导致FieldHolder类得到初始化。这种模式的魅力在于,getField方法没有被同步,并且只执行一个域访问,因此延迟初始化实际上并没有增加任何访问成本。现代的VM将在初始化该类的时候,同步域的访问。一旦这个类被初始化,VM将修补代码,以便后续对该域的访问不会导致任何测试或者同步。

如果出于性能的考虑需要对实例域使用延迟初始化,就使用双重检查模式(double-check idiom)。这种模式避免了在域被初始化之后访问这个域时的锁定开销。这种模式背后的思想是:两次检查域的值,第一次检查时没有锁定,看看这个域是否被初始化了;第二次检查时有锁定。只有当第二次检查时表明这个域没有被初始化,才会调用computeFieldValue方法对这个域进行初始化。因为如果域已经被初始化就不会有锁定,域被声明为volatile很重要。

// Double-check idiom for lazy initialization of instance fields
private volatile FieldType field;
FieldType getField() {
    FieldType result = field;
    if (result==null) { // First check (no locking)
        synchronized(this) {
            result = field;
            if(result==null) // Second check (with locking)
                field = result = computeFieldValue();
        }
    }
    return result;
}

尤其对于需要用到局部变量result可能有点不解。这个变量的作用是确保field只在已经被初始化的情况下读取一次。(我觉得像是快照的功能。)

有时候,你可能需要延迟初始化一个可以接受重复初始化的实例域。如果处于这种情况,这可以使用双重检查惯用法的一个变形,它省去了第二次检查。没错,它就是单重检查模式(single-check idiom)。

如果你不在意是否每个线程都重新计算域的值,并且域的类型为基本类型,而不是long或者double类型,就可以选择从单重检查模式的域声明中删除volatile修饰符。这种变体称之为racy single-check idiom。它加快了某些架构上的域访问,代价是增加了额外的初始化(直到访问该域的每个线程都进行一次初始化)。这显然是一种特殊的方法,不适合日常的使用。然而,String实例却用它来缓存它们的散列码。

第72条:不要依赖于线程调度器

  1. 要编写健壮的、响应良好的、可移植的多线程应用程序,最好的办法是确保可运行线程的平均数量不明显多于处理器的数量。
  2. 如果某一个程序不能工作,是因为某些线程无法像其他线程那样获得足够的CPU时间,那么,不要企图通过调用Thread.yield来“修正”该程序。Thread.yield没有可测试的语义(testable semantic)。更好的解决办法是重新构造应用程序,以减少可并发运行的线程数量。
  3. 有一种相关的方法是调整线程优先级,同样有类似的警告。线程优先级是Java平台上最不可移植的特征了。
  4. 在本书第一版中说过,对于大多数程序员来说,Thread.yield的唯一用途是在测试期间人为地增加程序的并发性。应该使用Thread.sleep(1)代替Thread.yield来进行并发测试。

第73条:避免使用线程组

  1. 线程组的初衷是作为一种隔离applet(小程序)的机制,当然是出于安全的考虑。但是它们从来没有真正履行这个承诺,它们的安全价值已经差到根本不在Java安全模型的标准工作中提及的地步。
  2. 总而言之,线程组并没有提供太多有用的功能,而且它们提供的许多功能还都是有缺陷的。我们最好把线程组看作是一个不成功的试验,你可以忽略掉它们,就当它们根本不存在一样。如果你正在设计的一个类需要处理线程的逻辑组,或许就应该使用线程池executor。

原文地址:http://www.jsdblog.com/article/63  

正文到此结束
Loading...