对于多线程编程经验较少的程序员而言,开发多核系统软件将面临两个方面的问题:首先,并发会给 Java 程序引入新的缺陷,如数据速度和死锁,它们是很是难以复现和发现的。其次,许多程序员并不知道特定多线程编程方法的微妙细节,而这可能会致使代码错误。java
为了不给并发程序引入缺陷,Java 程序员必须了解如何识别缺陷在多线程代码中极可能出现的关键位置,而后才可以编写出没有缺陷的软件。在本文中,咱们将帮助 Java 开发人员在理解并发编程早期和中期会遇到的问题。咱们并不会关注于常见的 Java 并发缺陷模式,如双重检查锁、循环等待和等待不在循环内项目,咱们将介绍 6 个不为人知的模式,可是却常常出如今真实的 Java 应用程序中。事实上,咱们的前两个例子就是在两个流行的 Web 服务器上发现的缺陷。程序员
咱们要介绍的第一个并发缺陷是在普遍使用的开源 HTTP 服务器 Jetty 上发现的。这是已通过 Jetty 社区确认的一个真实缺陷(见 参考资料 的缺陷报告)。编程
清单 1. 在一个易变(volatile)域上不获取锁的状况下执行非原子操做数组
// Jetty 7.1.0, // org.eclipse.jetty.io.nio, // SelectorManager.java, line 105 private 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 的操做,如bash
i++ --i i += 1 i -= 1 i *= 2
等,另外就是非原子操做(即 read-modify-write
)。若是您知道 volatile
关键字在 Java 语言中仅仅保证变量的可见性,而不保证原子性,那么这应该会引发您的注意。一个易变域上的不受锁保护的非原子操做可能 会产生一个竞争情况 — 可是只有在多个线程并发访问非原子操做时才可能出现。服务器
在一个线程安全的程序中,只有一个写线程可以修改这个变量;而其余的线程则能够读取 volatile
声明变量的最新值。数据结构
因此,代码是否有问题取决于有多少线程可以并发地访问这个操做。若是这个非原子操做仅仅由一个线程调用,因为是有一个开始联合关系或者外部锁,那么这样的编码方法也是线程安全的。多线程
必定要谨记 volatile
关键字在 Java 代码中仅仅保证这个变量是可见的:它不保证原子性。在那些非原子且可由多个线程访问的易变操做中,必定不可以依赖于 volatile 的同步机制。相反,要使用 java.util.concurrent
包的同步语句、锁类和原子类。它们在设计上可以保证程序是线程安全的。
在 Java 语言中,咱们使用了同步语句来获取互斥锁,这能够保护多线程系统的共享资源访问。然而,易变域的同步中会有一个漏洞,它可能破坏互斥。解决的方法是必定要将同步的域声明为 private final
。让咱们先来仔细看看问题是如何产生的。
同步语句是由同步域所引用对象保护的,而不是由域自己保护的。若是一个同步域是易变的(这意味着这个域在初始化以后可能在程序的其余位置赋值),这极可能不是有用的语义,由于不一样的线程可能同时访问不一样的对象。
您能够在清单 2 中看到这个问题,这是节选自开源 Web 应用服务器 Tomcat 的代码片段:
清单 2. Tomcat 的错误
public void addInstanceListener(InstanceListener listener) { synchronized (listeners) { InstanceListener results[] = new InstanceListener[listeners.length + 1]; for (int i = 0; i < listeners.length; i++) results[i] = listeners[i]; results[listeners.length] = listener; listeners = results; } }
假设 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
)。
一个实现 java.util.concurrent.locks.Lock
接口的锁控制着多个线程是如何访问一个共享资源的。这些锁不须要使用语句结构,因此它们比同步方法或语句更灵活。然而,这种灵活性可能致使编码错误,由于不使用语句的锁是不会自动释放的。若是一个 Lock.lock()
调用没有在同一个实例上执行相应的 unlock()
调用,其结果就可能形成一个锁泄漏。
若是忽视关键代码中的方法行为,咱们就很容易形成 java.util.concurrent
锁泄漏,有可能抛出的异常。您能够从清单 3 的代码看到这一点,其中 accessResource
方法在访问共享资源时抛出了一个 InterruptedException
异常。结果,unlock()
是不会被调用的。
清单 3. 分析一个锁泄漏
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 {...}
有一些并发缺陷有时不会使代码出错,可是它们可能会下降应用程序的性能。考虑清单 5 中的 synchronized
语句:
清单 5. 带有不变代码的同步语句
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 中,循环被移出同步语句,因此它可能由多个线程并行执行。通常而言,在保证线程安全的前提下要尽量地简化同步语句。
其余方面……
您可能会有疑问,是否使用
AtomicInteger
和AtomicFloat
来表示清单 5 和 6 中的两个共享变量,而后去掉全部同步代码,是否会更好一些。这取决于其余方法是如何处理这些变量的,以及它们之间有什么样的依赖关系。
假设您的应用程序有两个表:第一个表将员工姓名映射到一个员工号,另外一个将这个员工号映射到一个薪水记录。这些数据须要支持并发访问和更新,而您能够经过线程安全的 ConcurrentHashMap
实现,如清单 7 所示:
清单 7. 两个阶段的访问
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
),而后使用显式的同步语句来保护每个访问,从而实现线程安全。而后这个同步语句能够在须要时包含这两个访问。
能够考虑使用一个线程安全容器类 — 一个保证用户操做线程安全的数据结构。(这与 java.util
中的大多数容器不一样,它不须要用户同步容器的使用。)在清单 8 中,一个可修改的成员变量负责保存数据,而一个锁对象则保护全部对它的访问。
清单 8. 一个线程安全的容器
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
锁。结果,每个线程都会等待另外一个线程所保持的锁。
您能够经过肯定实例顺序来防止对称锁死锁,这样当须要获取两个实例的锁时,其顺序是动态计算获得的,并决定哪个锁先获取到。Brian Goetz 在他撰写的书 Java Concurrency in Practice 中详细讨论这个方法(见 参考资料)。
不只仅发生在容器上
这个对称锁死锁场景是很常见的,由于它出如今 Java 1.4 版本上,其中
Collections.synchronized
方法返回的一些同步容器会发生死锁。可是,不只仅容器容易受到对称锁死锁的影响。若是一个类有一个方法使用同一个类的其余实例做为参数,那么这个类对这两个实例成员的操做也必须是原子的。其中compareTo
和equals
方法就是很好的两个例子。
许多 Java 开发人员都只是刚刚开始了解多核环境并发程序的编写方法。在这个过程当中,咱们要放弃所掌握的单线程编写方法,而使用更复杂的多线程环境方法。研究并发缺陷模式是一个发现多线程编程问题的好方法,也有利于掌握这些方法的微妙之处。
您能够学习从总体上把握缺陷模式的方法,这样在您编写代码或检查代码时就可以发现一些特定的问题线索。您也可使用一些静态分析工具来发现问题。FindBugs 就是一个可以查找代码中可能的缺陷模式的开源静态分析工具。事实上,FindBugs 可用于检查本文讨论的第二和第三个缺陷模式。
静态分析工具的另外一个常见的缺点是它们会产生错误的警告,这样您可能会浪费更多时间去检查原本不是缺陷的问题。可是,如今出现了新的更适合于测试并发程序的动态分析工具。其中两种是 IBM® Multicore Software Development Kit (MSDK) 和 ConcurrentTesting (ConTest),它们均可以从 IBM alphaWorks 上免费下载。
原文地址:IBM 开发者博客