设计并发算法的提示和技巧
正确识别独立任务
只能执行那些相互独立的并发任务。
在尽可能高的层面上实施并发处理
对于 Java 来说,你可以使用 Thread 类或 Lock 类来控制线程的创建和同步。
不过 Java 也提供了高层次的并发处理对象,例如执行器或 Fork/Join 框架,它们都可以支持你执行并发任务。
高层机制有下述好处:
-
不需要担心线程的创建和管理,只需要创建并且发送任务以使其执行。
Java 并发 API 会帮助你控制线程的创建和管理。 - 它们都经过了优化,可以比直接使用线程提供更好的性能。
例如,它们使用了一个线程池,可对线程进行重用,避免了为每个任务都创建线程。 - 它们含有一些高级特性,可以使API更加强大。
例如,有了Java中的执行器,你可以执行以 Future 对象形式返回结果的任务。 - 你的应用程序很容易从一个操作系统被迁移到另一个,而且它将具有更好的伸缩性。
- 你的应用程序在今后的 Java 版本中可能会更加快速。
Java 开发人员一直都在改进内部构件,而且 JVM 优化也会更加适合于 JDK API。
考虑伸缩性
若是要实现一个并发算法,主要目标之一就是要利用计算机的全部资源,尤其是要充分利用处理器或者核的数目。
当你使用数据分解来设计并发算法时 ,不要预先假定应用程序要在多少个核或者处理器上执行。
要动态获取系统的有关信息,并且让你的算法使用这些信息来计算它要执行的任务数。
这个过程会给算法执行时间带来额外开销,但是你的算法将有更好的伸缩性。
Runtime.getRuntime().availableProcessors(); // 在 Java 中可以使用该方法来获取信息
如果你使用任务分解来设计并发算法,情况就会更加复杂。
你要根据算法中独立任务的数目来设计,而且强制执行较多的任务将会增加由同步机制引入的开销,而且应用程序的整体性能甚至会更糟糕。
要详细分析算法来判断是否要采用动态的任务数。
使用线程安全 API
如果需要在并发应用程序中使用某个 Java 库不是线程安全的,那么你有如下两个选择:
-
如果已经存在一个线程安全的替代方案,那么就应该使用该替代方案。
-
如果不存在线程安全的替代方案,就应该添加必要的同步机制来避免所有可能出现问题的情形,尤其是数据竞争条件。
例如,如果你在并发应用程序中需要用到一个 List ,且需要在多个线程中对其更新,那么就不应该使用 ArrayList 类,因为它不是线程安全的。
在这种情况下,你可以使用一个线程安全的类,例如 ConcurrentLinkedDeque 、 CopyOnWriteArrayList 或者 LinkedBlockingDeque 。
绝不要假定执行顺序
如果你不采用任何同步机制,那么在并发应用程序中任务的执行顺序是不确定的。
任务执行的顺序以及每个任务执行的时间,是由操作系统的调度器所决定的。
在多次执行时,调度器并不关心执行顺序是否相同。下一次执行时顺序可能就不同了。
假定某一执行顺序的结果通常会导致数据竞争问题。
算法的最终结果取决于任务执行的顺序。有时,结果可能是正确的,但在其他时候可能是错误的。
检测导致数据竞争条件的原因非常困难,因此你必须小心谨慎,不要忘记所有必须进行同步的元素。
在静态和共享场合尽可能使用局部线程变量
线程局部变量是一种特殊的变量。
每个任务针对该变量都有一个独立的值,这样你就不需要任何同步机制来保护对该变量的访问。
-
使用 ThreadLocal 类
ThreadLocal 类确保了每个线程都将访问自己针对该变量的实例,而不需要使用 Lock 类、 Semaphore 类或者类似的类。 -
使用 ConcurrentHashMap<Thread, MyType> 这样的方式
寻找更易于并行处理的算法版本
使用串行版算法作为实现并发算法的起点。这种方式主要有两个优点。
-
很容易测试并行算法结果的正确性。
-
可以度量采用并发处理后获得的性能提升。
尽可能使用不可变对象
在并发应用程序中使用不可变对象有如下两个非常重要的好处。
-
不需要任何同步机制来保护这些类的方法。
如果两个任务要修改同一对象,它们将创建新的对象,因此绝不会出现两个任务同时修改同一对象的情况。 -
不会有任何数据不一致问题,因为这是第一点的必然结果。
不可变对象存在一个缺点:
如果你创建了太多的对象,可能会影响应用程序的吞吐量和内存使用。
通过对锁排序来避免死锁
在并发应用程序中避免死锁的最佳机制之一是强制要求任务总是以相同顺序获取资源。
实现这种机制的一种简单方式是为每个资源都分配一个编号。
当一个任务需要多个资源时,它需要按照顺序来请求。
使用原子变量代替同步
当你要在两个或者多个任务之间共享数据时,必须使用同步机制来保护对该数据的访问,并且避免任何数据不一致问题。
-
volatile关键字
如果只有一个任务修改数据而其他任务都读取数据,那么你可以使用 volatile 关键字而无须任何同步机制,并且不会出现数据不一致问题。 - 锁、 synchronized关键字或者其他同步方法。
- 原子变量
这些变量都是在单个变量上支持原子操作的类。
它们含有一个名为 compareAndSet(oldValue, newValue) 的方法,该方法具有一种机制,可用于探测某个步骤中将新值赋给变量的操作是否完成。如果变量的值等于 oldValue ,那么该 方法将变量的值更改为 newValue 并且返回 true 。否则,该方法返回 false 。
该解决方案是免锁的,也就是说不需要使用锁或者任何同步机制,因此它的性能比任何采用同步机制的解决方案要好。
在 Java 中可用的最重要的原子变量有如下几种:
- AtomicInteger
- AtomicLong
- AtomicReference
- AtomicBoolean
- LongAdder
- DoubleAdder
占有锁的时间尽可能短
锁允许你定义一个临界段,一次只有一个任务可以执行。
当一个任务执行该临界段时,其他要执行临界段的任务都将被阻塞并且要等待该临界段被释放。
这样,该应用程序其实是以串行方式来工作的。
要特别注意临界段中的指令,因为如果不了解它的话会降低应用程序的性能。
必须将临界段定制得尽可能小,而且它必须仅包含处理与其他任务共享的数据的指令,这样应用程序花费在串行处理上的时间就会最少。
谨慎使用延迟初始化
延迟初始化就是将对象的创建延迟到该对象在应用程序中首次使用时的一种机制。
它的主要优点是可以使内存使用最小化,因为你只需要创建实际需要的对象。
但是在并发应用程序中它也可能引发问题。
如果你使用某个方法初始化某一对象,并且该方法同时被两个不同的任务调用,那么你可以初始化两个不同的对象。
但是这可能会带来问题(例如对单例模式的类来说),因为你只想为这些类创建一个对象。
避免在临界段中使用阻塞操作
阻塞操作是指阻塞任务对其进行调用,直到某一事件发生后再调用的操作。
如果临界段中包含了这样的操作,应用程序的性能就会降低,因为需要执行该临界段的任务都无法执行临界段了。
位于临界段中的操作等待某个 I/O 操作结束,而其他任务则一直在等待临界段。