《Java并发编程实战》-14 第14章 构建自定义的同步工具

第14章 构建自定义的同步工具


14.1 状态依赖性的管理

依赖状态的操作可以一直阻塞直到可以继续执行,这比使它们先失败再实现起来要更为方便且更不容易出错。内置的条件队列可以使线程一直阻塞,直到对象进入某个进程可以继续执行的状态,并且当被阻塞的线程可以执行时再唤醒它们。

14.1.1 实例:将前提条件的失败传递给调用者

14.1.2 实列:通过轮询与休眠来实现简单的阻塞

14.1.3 条件队列

条件队列使得一组线程(称之为**等待线程集合)能够通过某种方式来等待特定得条件变成真。

正如每个Java对象对可以作为一个锁,每个对象同样可以作为一个条件队列,并且Object中的wait、notify和notifyAll方法就构成了内部条件队列的API。对象的内置锁与其内部条件队列是互相关联的。

Object.wait会自动释放锁,并请求操作系统挂起当前线程,从而使其他线程能够获得这个锁并修改对象的状态。

14.2 使用条件队列

14.2.1 条件谓词

将与条件队列相关联的条件谓词以及在这些谓词上等待的操作都写入文档。

每一次wait调用都会隐式地与特定的条件谓词关联起来。当调用某个特定条件谓词的wait时,调用者必须已经持有与条件队列相关的锁,并且这个锁必须保护着构成条件谓词的状态变量。

14.2.2 过早唤醒

当使用条件等待时(例如Object.wait或Condition.await)

  • 通常都有一个条件谓词–包括一些对象状态的测试,线程在执行前必须首先通过这些测试。
  • 在调用wait之前测试条件谓词,并且从wait中返回时再次进行测试。
  • 在一个循环中调用wait
  • 确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量。
  • 当调用wait,notify或notifyAll等方法时,一定要持有与条件队列相关的锁。
  • 在检查条件谓语之后以及开始执行相应的操作之前,不要释放锁。

14.2.4 通知

每当在等待一个条件时,一定要确保在条件谓词变为真时通过某种方式发出通知。

只有同时满足以下两个条件时,才能用单一的notify而不是notifyAll:

  • 所有等待线程的类型都相同。 只有一个条件谓语与条件队列相关,并且每个线程在从wait返回后将执行相同的操作。
  • 单进单出。 在条件变量上的每次通知,最多只能唤醒一个线程来执行。

14.2.5 示例:阀门类

14.2.6 子类的安全性问题

封装条件队列

14.2.8 入口协议与出口协议

14.3 显式的Condition对象

特别注意:在Condition对象中,与wait、notify和notifyAll方法对应的分别时await、signal和aignalAll。但是,Condition对Object进行了扩展,因而它也包含wait和notify方法,一定要确保使用正确的版本–await和signal。

14.4 Synchronizer剖析

AQS是一个用于构建锁和同步器的框架,许多同步器都可以通过AQS很容易并且高效地构造出来。不仅ReentrantLockSemaphore是基于AQS构建的,还包括CountDownLatchReentrantReadWriteLockSynchronousQueueFutureTask

14.5 AbstractQueuedSynchronizer

在基于AQS构建的同步器类中,最基本的操作包括各种形式的获取操作和释放操作。

一个简单的闭锁 使用AbstractQueueSynchronizer实现的二元闭锁

@ThreadSafe
public class OneShotLatch {
    private final Sync sync = new Sync();
    public void signal() {  sync.releaseShared(0) }
    public void await() throws InterruptedException {
        sync.acquireSharedInterruptibly(0);
    }
    private class Sync extends AbstractQueueSynchronizer {
        protected int tryAcquireShared(int ignored) {
            // 如果闭锁式开的(state == 1),那么这个操作操作将成功,否则将失败
            return (getState() == 1) ? 1 : -1;
        }

        protected boolean tryReleaseShared(int ignored) {
            setState(1);    // 现在打开闭锁
            retrun true;    // 现在其他的线程可以获取该闭锁
        }
    }
}

在OneShotLatch中,AQS状态用来表示闭锁状态–关闭(0)或者打开(1)。
acquireSharedInterruptibly方法在处理失败的方式,是把这个线程放入等待线程队列中。

14.6 java.util.concurrent同步器类中的AQS

14.6.1 ReentrantLock

Lock.newCondition将返回一个新的ConditionObject实例,这是AQS的一个内部类。

14.6.2 Semaphore与CountDownLatch

Semaphore将AQS的同步状态用于保存当前可用许可的数量。
CountdownLatch使用AQS的方式与Semaphore很相似:在同步状态中保存的是当前的计数值。

14.6.3 FutureTask

在FutureTask中,AQS同步状态被用来保存任务的状态,例如,正在运行、已经完成或已取消。

14.6.4 ReentrantReadWriteLock

ReentrantReadwriteLock使用了一个16位的状态来表示写入锁的计数,并且使用了另一个16位的状态来表示读取锁的技术。在读取锁的操作将使用共享的获取方法与释放方法,在写入锁上的操作将使用独占的获取方法与释放方法。

AQS在内部维护一个等待线程队列,其中记录了某个线程请求的是独占访问还是共享访问。

总结:

要实现一个依赖状态的类 —- 如果没有满足依赖状态的前提,那么这个类的方法必须阻塞,那么最好的方式是基于现有的库类来构建,例如Semaphore.BlockingQueue或CountDownLatch。然而,有时候现有的库类不能提供足够的功能,在这种情况下,可以使用内置的条件队列、显示的Condition对象或者AbstractQueueSynchronizer来构建自己的同步器。

《Java并发编程实战》-12 第13章 显式锁

第13章 显式锁


13.1 Lock 与 ReentrantLock

使用ReentrantLock来保护对象状态

Lock lock = new ReentrantLock();
...
lcok.lock()
try{
    // 更新对象状态
    // 捕捉异常,并在必要时恢复不变性条件
} finally {
    lock.unlock();
}

13.1.1 轮询锁与定时锁


if (!lock.tryLock(nanosToLock, NANOSECONDS)) 
    return false;
try {
    return sendOnShareLine(message)
} finally {
    lock.unlock()
}

13.1.2 可中断的锁获取操作

正如定时的锁获取操作能在带有事件限制的操作中使用独占锁,可中断的锁获取操作同样能在可取消的操作中使用加锁。

13.1.3 非块结构的加锁

锁分段技术在基于散列的容器中实现了不同的散列链,以便使用不同的锁。

13.2 性能考虑因素

性能时一个不断变化的指标,内置锁和ReentrantLock在各个JDK版本都不一样。

13.3 公平锁

在公平锁上,线程将按照它们发出请求的顺序来获得锁,但在非公平锁上,则允许“插队”:当一个线程请求非公平锁的时,如果在发出请求的同时该锁的状态变为可用,那么这个线程将跳过队列中所有的等待线程并获得这个锁。(在Semaphore中同样可以选择采用公平或非公平的获取顺序。)

在大多数情况下,非公平锁的性能要高于公平锁的性能。

13.4 在synchronied和ReentrantLock之间进行选择

在一些内置锁无法满足需求的情况下,ReentrantLock可以作为一种高级工具。当需要一些高级功能时才应该使用ReentrantLock,这些功能包括:可定时的、可轮询的与可中断的锁获取操作,公平队列,以及非块结构的锁。否则,还是应该优先使用synchronized

读-写锁

ReadWriteLock中的一些可选实现包括:

  • 释放优先。当一个写入操作释放写入锁时,并且队伍中同时存在读线程和写线程,那么应该优先选择读线程,写线程,还是最先发出请求的线程?
  • 读线程插队。如果锁是由读线程持有,但有写线程正在等待,那么新到达的读线程能否立即获得访问权,还是应该在写线程后面等待?如果允许线程插队到写线程之前,那么将提高并发性,但却可能造成写线程饥饿问题。
  • 重入性。读取锁和写入锁是否是可重入的。
  • 降级。如果一个线程持有写入锁,那么它能否在不释放该锁的情况下获得读取锁?这可能会使得写入锁被“降级”为读取锁,同时不允许其他写线程修改被保护的资源。
  • 升级。读取锁能否优于其他正在等待的读线程和写线程而升级为一个写入锁?在大多数的读-写锁实现中并不支持升级,因为如果没有显式的升级操作,那么很容易造成死锁。(如果两个读线程试图同时升级为写入锁,那么二者都不会释放读取锁。)

总结

与内置锁相比,显式的Lock提供了一些宽展功能,在处理锁的不可用性方面有着更高的灵活性,并且对队列行有着更好的控制。但ReentrantLock不能完全替代synchronized,只有在synchronized无法满足需求时,才应该使用它。
读-写允许多个读线程并发地访问被保护的对象,当访问以读取操作为主的数据结构时,他能提高程序的可伸缩性。

《Java并发编程实战》-11 第12章 并发程序的测试

第12章 并发程序的测试


吞吐量:指一组并发任务中已完成任务所栈的比例。
响应性:指请求从发出到完成之间的时间(也成为延迟)。

12.1 正确性测试

在为某个并发类设计单元测试时,首先需要执行与测试串行类时相同的分析—找出需要检查的不变性条件和后验条件。

12.1.1 基本的单元测试

在测试集中包含一组串行测试通常是有帮助的,因为它们有助于在开始分析数据竞争之前找出与并发无关性的问题。

12.1.2 对阻塞操作的测试

在测试并发的基本属性时,需要引入多个线程。

Thread.getState的返回结果不能用于并发控制,它能限制测试的有效性—其主要作用还是作为调试信息的来源。

12.1.3 安全性测试

在构建对并发类的安全性测试中,需要解决的关键问题在于,要找出那些容易检查的属性,这些属性在发生错误的情况下极有可能失败,同时又不会使得错误检查代码认为地限制并发性。理性情况是,在测试属性中不需要任何同步机制。

12.1.4 资源管理的测试

12.1.5 使用回调

回调函数的执行通常是在对象生命周期的一些已知位置上,并且在这些位置上非常适合判断不变性是否被破坏。

12.1.6 产生跟多的交替操作

在访问共享状态的操作中,使用Thread.yield将产生更多的上下文切换(这项技术是平台相关的)。

12.2 性能测试

12.2.1 在PutTakeTest中增加计时功能

12.2.2 多种算法的比较

响应性衡量

如果能获取更小的服务时间变动性,那么更长的平均时间是有意义的,“可预测需性”同样是一个非常有价值的性能特征。
除非线程由于密集的同步需求而被持续地阻塞,否则非公平的信号量通常能实现更好的吞吐量。

12.3 避免性能测试的陷阱

12.3.1 垃圾回收

垃圾回收的执行时序是无法预测的,因此在执行测试时,垃圾回收器可能在任何时刻运行。

12.3.2 动态编译

当某个类第一次被加载时,JVM会通过解释字节码的方式执行它。在某个时刻,如果一个方法运行的次数足够多,那么动态编译器会将它编译为机器代码,当编译完成后,代码的执行方式将从解释执行变成直接执行。

12.3.3 对代码路径的不真实采集

运行时编译器根据收集到的信息对已编译的代码进行优化。JVM可以与执行过程特定的信息来生成更优的代码,这个意味着在编译某个程序的方法M时生成的代码,将可能与编译另一个不同程序中的方法M时生成的代码不同。

12.3.4 不真实的竞争程度

并发的应用程序可以交替执行两种不同的类型的工作:访问共享数据(例如从共享工作队列中取出下一个任务)以及执行线程本地的计算(例如,执行任务,并假设任务本身不会访问共享数据)。

12.3.5 无用代码的消除

要编写有效的性能测试程序,那就需要告诉优化器不要将基准测试当作无用代码而优化掉。这就要求在程序中对每个计算结果都要通过某种方式来使用,这种方式不需要同步或者大量的计算。

12.4 其他的测试方法

测试的目标不是更多地发现错误,而是提高代码按照预期方式工作的可信度。

12.4.1 代码审查

多人参与的代码审查通常是不可替代的(另一方面,代码审查也不能取代测试)。

12.4.2 静态分析工具

静态分析工具能生成一个警告列表,其中包含的警告信息必须通过手工方式进行检查,从而确定这些警告是否表示真正的错误。

FindBUgs包含的检测器:

  • 调用Thread.run()
  • 未被释放的锁
    标准的做法是在一个人finally块中释放显式锁,否则,当发生Exception事件时,锁仍然处于未被释放的状态。
  • 空的同步块
  • 双重检查加锁
    双重检查加锁时一种错误的习惯用法,其初衷是为了降低延迟初始化过程中的同步开销,该用法在读取一个共享的可变域时缺少正确的同步。
  • 在构造函数中启动一个线程。
    如果在构造函数中启动一个线程,那么将可能带来子类化问题,同时还会导致this引用从构造函数中逸出。
  • 通知错误
    如果一个同步块中调用了notify或notifyAll,但没有修改任何状态,那么就可能出错。
  • 条件等待中的错误
    如果在调用Object.wait和Condition.await方法时没有持有锁,或者不在某个循环中,或者没有检查某些状态谓词,那么通常是一个错误。
  • 对Lock和Condition的误用
  • 在休眠或者等待的同时持有一个锁。
  • 自旋循环。
    当等待某个状态转换发生时,闭锁或条件等待通常比在代码中自旋是一种更好的技术。

12.4.3 面向方面的测试技术

AOP可以用来确保不变性条件不被破坏,或者与同步策略的某些方面保持一直。

12.4.4 分析与监测工具

分析工具通常为能给出程序内部的详细信息,为每个线程提供一个事件线显示,并且用颜色区分不同的线程状态(可运行,由于等待某个锁为阻塞,由于等待I/O操作而阻塞等等)。

小结

要测试并发程序的正确性可能非常困难,因为并发程序的许多故障模式哦都是一些低概率事件,它们对于执行时序、负载情况以及其他难以出现的条件都非常敏感。