多核系统上的 Java 并发缺
转摘至:http://www.ibm.com/developerworks/cn/java/j-concurrencybugpatterns/index.html
?
?
对于多线程编程经验较少的程序员而言,开发多核系统软件将面临两个方面的问题:首先,并发会给 Java 程序引入新的缺陷,如数据速度和死锁,它们是非常难以复现和发现的。其次,许多程序员并不知道特定多线程编程方法的微妙细节,而这可能会导致代码错误。
为了避免给并发程序引入缺陷,Java 程序员必须了解如何识别缺陷在多线程代码中很可能出现的关键位置,然后才能够编写出没有缺陷的软件。在本文中,我们将帮助 Java 开发人员在理解并发编程早期和中期会遇到的问题。我们并不会关注于常见的 Java 并发缺陷模式,如双重检查锁、循环等待和等待不在循环内项目,我们将介绍 6 个鲜为人知的模式,但是却经常出现在真实的 Java 应用程序中。事实上,我们的前两个例子就是在两个流行的 Web 服务器上发现的缺陷。
?
// Jetty 7.1.0,// org.eclipse.jetty.io.nio,// SelectorManager.java, line 105private volatile int _set;......public void register(SocketChannel channel, Object att){ int s=_set++; ......}......public void addChange(Object point){ synchronized (_changes) { ...... }}?
清单 1 中的错误有以下几个部分:
首先,_set 被声明为 volatile,这表示这个域可以由多个线程访问。但是, _set++ 并不是原子操作,这意味着它不会以单个不可分割操作执行。相反,它只是包含三个具体操作序列的简写方法:read-modify-write。最后, _set++ 并没有锁保护。如果方法 register 同时由多个线程调用,那么它会产生一个竞争状态,导致出现错误的 _set 值。您的代码也可能和 Jetty 一样出现这种类型的错误,所以让我更详细地分析一下它是如何发生的。
i++--ii += 1i -= 1i *= 2?
等,另外就是非原子操作(即 read-modify-write)。如果您知道 volatile 关键字在 Java 语言中仅仅保证变量的可见性,而不保证原子性,那么这应该会引起您的注意。一个易变域上的不受锁保护的非原子操作可能 会产生一个竞争状况 — 但是只有在多个线程并发访问非原子操作时才可能出现。
在一个线程安全的程序中,只有一个写线程能够修改这个变量;而其他的线程则可以读取 volatile 声明变量的最新值。
所以,代码是否有问题取决于有多少线程能够并发地访问这个操作。如果这个非原子操作仅仅由一个线程调用,由于是有一个开始联合关系或者外部锁,那么这样的编码方法也是线程安全的。
一定要谨记 volatile 关键字在 Java 代码中仅仅保证这个变量是可见的:它不保证原子性。在那些非原子且可由多个线程访问的易变操作中,一定不能够依赖于 volatile 的同步机制。相反,要使用 java.util.concurrent 包的同步语句、锁类和原子类。它们在设计上能够保证程序是线程安全的。
96: public void addInstanceListener(InstanceListener listener) {97:98: synchronized (listeners) {99: InstanceListener results[] =100: new InstanceListener[listeners.length + 1];101: for (int i = 0; i < listeners.length; i++)102: results[i] = listeners[i];103: results[listeners.length] = listener;104: listeners = results;105: }106:107:}?
假设 listeners 引用的是数组 A,而线程 T1 首先获取数组 A 的锁,然后开始创建数组 B。同时,T2 开始执行,并且由于数据 A 的锁而被阻挡。当 T1 完成数组 B 的 listeners 设置后,退出这个语句,T2 会锁住数组 A,然后开始复制数组 B。然后 T3 开始执行,并锁住数组 B。因为它们获得了不同的锁,T2 和 T3 现在可以同时复制数组 B。
图 1 更进一步地说明了这个执行顺序:
图 1. 由于易变域的同步而失去互斥锁

无数的意外行为可能会导致这种情况出现。至少,其中一个新的监听器可能会丢失,或者其中一个线程可能会发生 ArrayIndexOutOfBoundsException 异常(由于 listeners 引用及其长度可能在方法的任意时刻发生变化)。
好的做法是总是将同步域声明为 private final,这能够保证锁对象保持不变,并且保证了互斥(mutex)。
private final Lock lock = new ReentrantLock();public void lockLeak() { lock.lock(); try { // access the shared resource accessResource(); lock.unlock(); } catch (Exception e) {}public void accessResource() throws InterruptedException {...}?
要保证锁得到释放,我们只需要在每一个 lock 之后对应执行一个 unlock 方法,而且它们应该置于 try-finally 复杂语句中。清单 4 说明了这种方法:
清单 4. 总是将 unlock 调用置于 finally 语句中
private final Lock lock = new ReentrantLock();public void lockLeak() { lock.lock(); try { // access the shared resource accessResource(); } catch (Exception e) {} finally { lock.unlock(); }public void accessResource() throws InterruptedException {...}?public class Operator { private int generation = 0; //shared variable private float totalAmount = 0; //shared variable private final Object lock = new Object(); public void workOn(List<Operand> operands) { synchronized (lock) { int curGeneration = generation; //requires synch float amountForThisWork = 0; for (Operand o : operands) { o.setGeneration(curGeneration); amountForThisWork += o.amount; } totalAmount += amountForThisWork; //requires synch generation++; //requires synch } }}?
清单 5 代码中两个共享变量的访问是同步且正确的,但是如果仔细检查,您会注意到 synchronized 语句所需要进行的计算过多。我们可以通过调整代码顺序来解决这个问题,如清单 6 所示:
清单 6. 没有不变代码的同步语句
public void workOn(List<Operand> operands) { int curGeneration; float amountForThisWork = 0; synchronized (lock) { int curGeneration = generation++; } for (Operand o : operands) { o.setGeneration(curGeneration); amountForThisWork += o.amount; } synchronized (lock) totalAmount += amountForThisWork; }}?第二个版本代码在多核机器上执行效果会更好。其原因是清单 5 的同步代码阻止了并行执行。这个方法循环可能会消耗大量的计算时间。在清单 6 中,循环被移出同步语句,所以它可能由多个线程并行执行。一般而言,在保证线程安全的前提下要尽可能地简化同步语句。
public class Employees { private final ConcurrentHashMap<String,Integer> nameToNumber; private final ConcurrentHashMap<Integer,Salary> numberToSalary; ... various methods for adding, removing, getting, etc... public int geBonusFor(String name) { Integer serialNum = nameToNumber.get(name); Salary salary = numberToSalary.get(serialNum); return salary.getBonus(); }}?
这种方法看起来是线程安全的,但是事实上不是这样的。它的问题是 getBonusFor 方法并不是线程安全的。在获取这个序列号和使用它获取薪水之间,另一个线程可能从两个表删除员工信息。在这种情况下,第二个映射访问可能会返回 null,并抛出一个异常。
保证每一个 Map 本身的线程安全是不够的。它们之间存在一个依赖关系,而且访问这两个 Map 的一些操作必须是原子操作。在这里,您可以使用非线程安全的容器(如 java.util.HashMap),然后使用显式的同步语句来保护每一个访问,从而实现线程安全。然后这个同步语句可以在需要时包含这两个访问。
public <E> class ConcurrentHeap { private E[] elements; private final Object lock = new Object(); //protects elements public void add (E newElement) { synchronized(lock) { ... //manipulate elements } } public E removeTop() { synchronized(lock) { E top = elements[0]; ... //manipulate elements return top; } }}?
现在让我添加一个方法,使用另一个实例,并将它的所有元素添加到当前的实例中。这个方法需要访问这两个实例的 elements 成员,如清单 9 所示:
清单 9. 下面代码会产生一个死锁
public void addAll(ConcurrentHeap other) { synchronized(other.lock) { synchronized(this.lock) { ... //manipulate other.elements and this.elements } }}?您认识到了死锁的可能性吗?假设一个程序只有两个实例 heap1 和 heap2。如果其中一个线程调用了 heap1.addAll(heap2),而另一个线程同时调用 heap2.addAll(heap1),那么这两个线程就可能遇到死锁。换言之,假设第一个线程获得了 heap2 的锁,但是它开始执行之前,第二个线程就开始执行方法,同时获取了 heap1 锁。结果,每一个线程都会等待另一个线程所保持的锁。