设计并发算法的提示和技巧

正确识别独立任务

只能执行那些相互独立的并发任务。

在尽可能高的层面上实施并发处理

对于 Java 来说,你可以使用 Thread 类或 Lock 类来控制线程的创建和同步。
不过 Java 也提供了高层次的并发处理对象,例如执行器或 Fork/Join 框架,它们都可以支持你执行并发任务。

高层机制有下述好处:

考虑伸缩性

若是要实现一个并发算法,主要目标之一就是要利用计算机的全部资源,尤其是要充分利用处理器或者核的数目。

当你使用数据分解来设计并发算法时 ,不要预先假定应用程序要在多少个核或者处理器上执行。
要动态获取系统的有关信息,并且让你的算法使用这些信息来计算它要执行的任务数。
这个过程会给算法执行时间带来额外开销,但是你的算法将有更好的伸缩性。

Runtime.getRuntime().availableProcessors();  // 在 Java 中可以使用该方法来获取信息

如果你使用任务分解来设计并发算法,情况就会更加复杂。
你要根据算法中独立任务的数目来设计,而且强制执行较多的任务将会增加由同步机制引入的开销,而且应用程序的整体性能甚至会更糟糕。
要详细分析算法来判断是否要采用动态的任务数。

使用线程安全 API

如果需要在并发应用程序中使用某个 Java 库不是线程安全的,那么你有如下两个选择:

例如,如果你在并发应用程序中需要用到一个 List ,且需要在多个线程中对其更新,那么就不应该使用 ArrayList 类,因为它不是线程安全的。
在这种情况下,你可以使用一个线程安全的类,例如 ConcurrentLinkedDeque 、 CopyOnWriteArrayList 或者 LinkedBlockingDeque 。

绝不要假定执行顺序

如果你不采用任何同步机制,那么在并发应用程序中任务的执行顺序是不确定的。
任务执行的顺序以及每个任务执行的时间,是由操作系统的调度器所决定的。
在多次执行时,调度器并不关心执行顺序是否相同。下一次执行时顺序可能就不同了。

假定某一执行顺序的结果通常会导致数据竞争问题。
算法的最终结果取决于任务执行的顺序。有时,结果可能是正确的,但在其他时候可能是错误的。
检测导致数据竞争条件的原因非常困难,因此你必须小心谨慎,不要忘记所有必须进行同步的元素。

在静态和共享场合尽可能使用局部线程变量

线程局部变量是一种特殊的变量。
每个任务针对该变量都有一个独立的值,这样你就不需要任何同步机制来保护对该变量的访问。

寻找更易于并行处理的算法版本

使用串行版算法作为实现并发算法的起点。这种方式主要有两个优点。

尽可能使用不可变对象

在并发应用程序中使用不可变对象有如下两个非常重要的好处。

不可变对象存在一个缺点:
如果你创建了太多的对象,可能会影响应用程序的吞吐量和内存使用。

通过对锁排序来避免死锁

在并发应用程序中避免死锁的最佳机制之一是强制要求任务总是以相同顺序获取资源。

实现这种机制的一种简单方式是为每个资源都分配一个编号。
当一个任务需要多个资源时,它需要按照顺序来请求。

使用原子变量代替同步

当你要在两个或者多个任务之间共享数据时,必须使用同步机制来保护对该数据的访问,并且避免任何数据不一致问题。

该解决方案是免锁的,也就是说不需要使用锁或者任何同步机制,因此它的性能比任何采用同步机制的解决方案要好。

在 Java 中可用的最重要的原子变量有如下几种:

占有锁的时间尽可能短

锁允许你定义一个临界段,一次只有一个任务可以执行。
当一个任务执行该临界段时,其他要执行临界段的任务都将被阻塞并且要等待该临界段被释放。
这样,该应用程序其实是以串行方式来工作的。

要特别注意临界段中的指令,因为如果不了解它的话会降低应用程序的性能。
必须将临界段定制得尽可能小,而且它必须仅包含处理与其他任务共享的数据的指令,这样应用程序花费在串行处理上的时间就会最少。

谨慎使用延迟初始化

延迟初始化就是将对象的创建延迟到该对象在应用程序中首次使用时的一种机制。
它的主要优点是可以使内存使用最小化,因为你只需要创建实际需要的对象。

但是在并发应用程序中它也可能引发问题。
如果你使用某个方法初始化某一对象,并且该方法同时被两个不同的任务调用,那么你可以初始化两个不同的对象。
但是这可能会带来问题(例如对单例模式的类来说),因为你只想为这些类创建一个对象。

避免在临界段中使用阻塞操作

阻塞操作是指阻塞任务对其进行调用,直到某一事件发生后再调用的操作。

如果临界段中包含了这样的操作,应用程序的性能就会降低,因为需要执行该临界段的任务都无法执行临界段了。
位于临界段中的操作等待某个 I/O 操作结束,而其他任务则一直在等待临界段。