文章目录

  • 第10章 避免活跃性危险
    • 10.1 死锁
      • 10.1.1 锁顺序死锁
      • 10.1.2 动态的锁顺序死锁
      • 10.1.3 在协作对象之间发生的死锁
      • 10.1.4 开放调用
      • 10.1.5 资源死锁
    • 10.2 死锁的避免与诊断
      • 10.2.1 支持定时的锁
      • 10.2.2 通过线程转储信息来分析死锁
    • 10.3 其他活跃危险
      • 10.3.1 饥饿
      • 10.3.2 糟糕的响应性
      • 10.3.3 活锁
    • 小结
  • 第11章 性能与可伸缩性
    • 11.1 对性能的思考
    • 11.2 Amdahl定律
      • 11.2.1 示例:在各种框架中隐藏的串行部分
      • 11.2.2 Amdahl定律的应用
    • 11.3 线程引入的开销
      • 11.3.1 上下文切换
      • 11.3.2 内存同步
      • 11.3.3 阻塞
    • 11.4 减少锁的竞争
      • 11.4.1 缩小锁的范围(”快进快出“)
      • 11.4.2 减小锁的粒度
      • 11.4.3 锁分段
      • 11.4.4 避免热点域
      • 11.4.5 一些替代独占锁的方法
      • 11.4.6 检测CPU的利用率
      • 11.4.7 向线程池说“不”
    • 11.5 示例 比较Map的性能
    • 11.6 减少上下文切换的开销
    • 小结
  • 第12章 并发程序的测试
    • 12.1 正确性测试
      • 12.1.1 基本的单元测试
      • 12.1.2 对阻塞操作的测试
      • 12.1.3 安全性测试
      • 12.1.4 资源管理的测试
      • 12.1.5 使用回调
      • 12.1.6 产生更多的交替操作
    • 12.2 性能测试
      • 12.2.1 在PutTakeTest中增加计时功能
      • 12.2.2 多种算法的比较
      • 12.2.3 响应性衡量
    • 12.3 避免性能测试的陷阱
      • 12.3.1 垃圾回收
      • 12.3.2 动态编译
      • 12.3.3 对代码路径的不真实采样
      • 12.3.4 不真实的竞争程度
      • 12.3.5 不用代码的消除
    • 12.4 其他的测试方法
      • 12.4.1 代码审查
      • 12.4.2 静态分析工具
      • 12.4.3 面向方面的测试技术
      • 12.4.4 分析与检测工具

第10章 避免活跃性危险

在安全性与活跃性之间通常存在着某种制衡。我们使用加锁机制来确保线程安全,但如果过度地使用加锁,则可能导致锁顺序死锁(Lock-Ordering Deadlock)。同样,我们使用线程池和信号量来限制对资源的使用,但这些被限制的行为可能会导致资源死锁(Resource Deadlock)。Java应用程序无法从死锁中恢复过来,因此在设计时一定要排除那些可能导致死锁出现的条件。本章将介绍一些导致活跃性故障的原因,以及如何避免它们。

10.1 死锁

经典的“哲学家进餐”问题很好地描述了死锁状况。5个哲学家去吃中餐,坐在一张圆桌旁。他们有5根筷子(而不是5双),并且每两个人中间放一根筷子。哲学家们时而思考,时而进餐。每个人都需要一双筷子才能吃到东西,并在吃完后将筷子放回原处继续思考。有些筷子管理算法能够使每个人都能相对及时地吃到东西(例如一个饥饿的哲学家会尝试获得两根邻近的筷子,但如果其中一根正在被另一个哲学家使用,那么他将放弃已经得到的那根筷子,并等待几分钟之后再次尝试),但有些算法却可能导致一些或者所有哲学家都“饿死”(每个人都立即抓住自己左边的筷子,然后等待自己右边的筷子空出来,但同时又不放下已经拿到的筷子)。后一种情况将产生死锁:每个人都拥有其他人需要的资源,同时又等待其他人已经拥有的资源,并且每个人在获得所有需要的资源之前都不会放弃已经拥有的资源。
   当一个线程永远地持有一个锁,并且其他线程都尝试获得这个锁时,那么它们将永远被阻塞。在线程A持有锁L并想获得锁M的同时,线程B持有锁M并尝试获得锁L,那么这两个线程将永远地等待下去。这种情况就是最简单的死锁形式(或者称为“抱死[Deadly Embrace]”),其中多个线程由于存在环路的锁依赖关系而永远地等待下去。(把每个线程假想为有向图中的一个节点,图中每条边表示的关系是:“线程A等待线程B所占有的资源”。如果在图中形成了一条环路,那么就存在一个死锁。)
   在数据库系统的设计中考虑了监测死锁以及从死锁中恢复。在执行一个事务(Transaction)时可能需要获取多个锁,并一直持有这些锁直到事务提交。因此在两个事务之间很可能发生死锁,但事实上这种情况并不多见。如果没有外部干涉,那么这些事务将永远等待下去(在某个事务中持有的锁可能在其他事务中也需要)。但数据库服务器不会让这种情况发生。当它检测到一组事务发生了死锁时(通过在表示等待关系的有向图中搜索循环),将选择一个牺牲者并放弃这个事务。作为牺牲者的事务会释放它所持有的资源,从而使其他事务继续进行。应用程序可以重新执行被强行中止的事务,而这个事务现在可以成功完成,因为所有跟它竞争资源的事务都已经完成了。
   JVM在解决死锁问题方面并没有数据库服务那样强大。当一组Java线程发生死锁时,“游戏”将到此结束——这些线程永远不能再使用了。根据线程完成工作的不同,可能造成应用程序完全停止,或者某个特定的子系统停止,或者是性能降低。恢复应用程序的唯一方式就是中止并重启它,并希望不要再发生同样的事情。
   与许多其他的并发危险一样,死锁造成的影响很少会立即显现出来。如果一个类可能发生死锁,那么并不意味着每次都会发生死锁,而只是表示有可能。当死锁出现时,往往是在最糟糕的时候——在高负载情况下。

10.1.1 锁顺序死锁

程序清单10-1中的LeftRightDeadlock存在死锁风险。leftRight和rightLeft这两个方法分别获得left锁和right锁。如果一个线程调用了leftRight,而另一个线程调用了rightLeft,并且这两个线程的操作是交错执行,如图10-1所示,那么它们会发生死锁。

   在LeftRightDeadlock中发生死锁的原因是:两个线程试图以不同的顺序来获得相同的锁。如果按照相同的顺序来请求锁,那么就不会出现循环的加锁依赖性,因此也就不会产生死锁。如果每个需要锁L和锁M的线程都以相同的顺序来获取L和M,那么就不会发生死锁了。
   如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。
   要想验证锁顺序的一致性,需要对程序中的加锁行为进行全局分析。如果只是单独地分析每条获取多个锁的代码路径,那是不够的:leftRight和rightLeft都采用了“合理的”方式来获得锁,它们只是不能相互兼容。当需要加锁时,它们需要知道彼此正在执行什么操作。
   程序清单10-1 简单的锁顺序死锁(不要这么做)

public class LeftRightDeadlock {private final Object left = new Object();private final Object right = new Object();public void leftRight() {synchronized (left) {synchronized (right) {// soSomething}}}public void rightLeft() {synchronized (right) {synchronized (left) {// soSomething}}}
}

10.1.2 动态的锁顺序死锁

有时候,并不能清楚地知道是否在锁顺序上有足够的控制权来避免死锁的发生。考虑程序清单10-2中看似无害的代码,它将资金从一个账户转入另一个账户。在开始转账之前,首先要获得这两个Account对象的锁,以确保通过原子方式来更新两个账户中的余额,同时又不破坏一些不变性条件,例如“账户的余额不能为负数”。
   程序清单10-2 动态的锁顺序死锁(不要这么做)

public void tarnsferMoney(Account formAccount,Account toAccunt,DollarAmount amount)
throws InsufficientResourcesException
{synchronized (formAccount) {synchronized (toAccunt) {if (formAccount.getBalance().compareTo(amount)<0) {throw new InsufficientResourcesException();} else {formAccount.debit(amount);toAccunt.credit(amount);}}}
}

在transferMoney中如何发生死锁?所有的线程似乎都是按照相同的顺序来获得锁,但事实上锁的顺序取决于传递给transferMoney的参数顺序,而这些参数顺序又取决于外部输入。如果两个线程同时调用transferMoney,其中一个线程从X向Y转账,另一个线程从Y向X转账,那么就会发生死锁:

// A: transferMonry(myAccount, yourAccount, 10);
// B: transferMonry(yourAccount, myAccount, 20);

如果执行时序不当,那么A可能获得myAccount的锁并等待yourAccount的锁,然而B此时持有yourAccount的锁,并正在等待myAccount的锁。
   这种死锁可以采用程序清单10-1中的方法来检查——查看是否存在嵌套的锁获取操作。由于我们无法控制参数的顺序,因此要解决这个问题,必须定义锁的顺序,并在整个应用程序中都按照这个顺序来获取锁。
   在制定锁的顺序时,可以使用System.identityHashCode方法,该方法将返回由Object.hashCode返回的值。程序清单10-3给出了另一个版本的transferMoney,在该版本中使用了System.identityHashCode来定义锁的顺序。虽然增加了一些新的代码,但却消除了发生死锁的可能性。
   程序清单10-3 通过锁顺序来避免死锁

private static final Object tieLock = new Object();
public void transfermoney(final Account fromAccount,final Account toAccount,final BigDecimal amount)
throws InsufficientResourcesException {class Helper {public void transfer() throws InsufficientResourcesException {if (fromAccount.getBalance().compareTo(amount)<0) {throw new InsufficientResourcesException();} else {fromAccount.debit(amount);toAccount.debit(amount);}}}int fromHash = System.identityHashCode(fromAccount);int toHash = System.identityHashCode(toAccount);if (fromHash<toHash) {synchronized (fromAccount) {synchronized (toAccount) {new Helper().transfer();}}} else if (fromHash>toHash) {synchronized (toAccount) {synchronized (fromAccount) {new Helper().transfer();}}} else {synchronized (tieLock) {synchronized (fromAccount) {synchronized (toAccount) {new Helper().transfer();}}}}
}

在极少数情况下,两个对象可能拥有相同的散列值,此时必须通过某种任意的方法来决定锁的顺序,而这可能又会重新引入死锁。为了避免这种情况,可以使用“加时赛(Tie-Breaking)”锁。在获得两个Account锁之前,首先获得这个“加时赛”锁,从而保证每次只有一个线程以未知的顺序获得这两个锁,从而消除了死锁发生的可能性(只要一致地使用这种机制)。如果经常会出现散列冲突的情况,那么这种技术可能会成为并发性的一个瓶颈(这类似于在整个程序中只有一个锁的情况),但由于System.identityHashCode中出现散列冲突的频率非常低,因此这项技术以最小的代价,换来了最大的安全性。
   如果在Account中包含一个唯一的、不可变的,并且具备可比性的键值,例如账号,那么要制定锁的顺序就更加容易了:通过键值对对象进行排序,因而不需要使用“加时赛”锁。
   你或许认为我有些夸大了死锁的风险,因为锁被持有的时间通常很短暂,然而在真实系统中,死锁往往都是很严重的问题。作为商业产品的应用程序每天可能要执行数十亿次获取锁-释放锁的操作。只要在这数十亿次操作中有一次发生了错误,就可能导致程序发生死锁,并且即使应用程序通过了压力测试也不可能找出所有潜在的死锁【具有讽刺意味的是,之所以短时间地持有锁,是为了降低锁的竞争程度,但却增加了在测试中找出潜在死锁风险的难度。】在程序清单l0-4【为了简便,在DemonstrateDeadlock没有考虑账户余额为负数的问题。】中的DemonstrateDeadlock在大多数系统下都会很快发生死锁。
   程序清单10-4 在典型条件下会发生死锁的循环


10.1.3 在协作对象之间发生的死锁

某些获取多个锁的操作并不像在LeftRightDeadlock或transferMoney中那么明显,这两个锁并不一定必须在同一个方法中被获取。考虑程序清单10-5中两个相互协作的类,在出租车调度系统中可能会用到它们。Taxi代表一个出租车对象,包含位置和目的地两个属性,Dispatcher代表一个出租车车队。
   程序清单10-5 在相互协作对象之间的锁顺序死锁(不要这么做)

class Taxi {@GuardedBy("this") private Point location, destination;private final Dispatcher dispatcher;public Taxi(Dispatcher dispatcher) { this.dispatcher = dispatcher; }public synchronized Point getLocation() { return location; }public synchronized void setLocation(Point location) {this.location = location;if (location.equals(destination)) {dispatcher.notifyAvailable(this);}}
}
class Dispatcher {@GuardedBy("this") private final Set<Taxi> taxis;@GuardedBy("this") private final Set<Taxi> availableTaxi;public Dispatcher() {taxis = new HashSet<Taxi>();availableTaxi = new HashSet<Taxi>();}public synchronized void notifyAvailable(Taxi taxi) {availableTaxi.add(taxi);}public synchronized Image getImage() {Image image = new Image();for (Taxi t : taxis) {image.drawMaker(t.getLocation());}return image;}
}

尽管没有任何方法会显式地获取两个锁,但setLocation和getImage等方法的调用者都会获得两个锁。如果一个线程在收到GPS接收器的更新事件时调用setLocation,那么它将首先更新出租车的位置,然后判断它是否到达了目的地。如果已经到达,它会通知Dispatcher:它需要一个新的目的地。因为setLocation和notifyAvailable都是同步方法,因此调用setLocation的线程将首先获取Taxi的锁,然后获取Dispatcher的锁。同样,调用getImage的线程将首先获取Dispatcher锁,然后再获取每一个Taxi的锁(每次获取一个)。这与LeftRightDeadlock中的情况相同,两个线程按照不同的顺序来获取两个锁,因此就可能产生死锁。
   在LeftRightDeadlock或transferMoney中,要查找死锁是比较简单的,只需要找出那些需要获取两个锁的方法。然而要在Taxi和Dispatcher中查找死锁则比较困难:如果在持有锁的情况下调用某个外部方法,那么就需要警惕死锁。
   如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能会获取其他锁(这可能会产生死锁),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。

10.1.4 开放调用

当然,Taxi和Dispatcher并不知道它们将要陷入死锁,况且它们本来就不应该知道。方法调用相当于一种抽象屏障,因而你无须了解在被调用方法中所执行的操作。但也正是由于不知道在被调用方法中执行的操作,因此在持有锁的时候对调用某个外部方法将难以进行分析,从而可能出现死锁。
   如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用(Open Call)[CPJ 2.4.1.3]。依赖于开放调用的类通常能表现出更好的行为,并且与那些在调用方法时需要持有锁的类相比,也更易于编写。这种通过开放调用来避免死锁的方法,类似于采用封装机制来提供线程安全的方法:虽然在没有封装的情况下也能确保构建线程安全的程序,但对一个使用了封装的程序进行线程安全分析,要比分析没有使用封装的程序容易得多。同理,分析一个完全依赖于开放调用的程序的活跃性,要比分析那些不依赖开放调用的程序的活跃性简单。通过尽可能地使用开放调用,将更易于找出那些需要获取多个锁的代码路径,因此也就更容易确保采用一致的顺序来获得锁。【这些对开放调用以及锁顺序的依赖,反映了在构造同步对象(而不是对已构造好的对象进行同步)过程中存在的复杂性。】
   可以很容易地将程序清单10-5中的Taxi和Dispatcher修改为使用开放调用,从而消除发生死锁的风险。这需要使同步代码块仅被用于保护那些涉及共享状态的操作,如程序清单10-6所示。通常,如果只是为了语法紧凑或简单性(而不是因为整个方法必须通过一个锁来保护)而使用同步方法(而不是同步代码块),那么就会导致程序清单10-5中的问题。(此外,收缩同步代码块的保护范围还可以提高可伸缩性,在11.4.1节中给出了如何确定同步代码块大小的方法。)
   程序清单10-6 通过公开调用来避免在相互协作的对象之间产生死锁

@ThreadSafe
class Taxi {@GuardedBy("this") private Point location, destination;private final Dispatcher dispatcher;public synchronized Point getLocation() { return location; }public void setLocation(Point location) {boolean reachedDestination;synchronized (this) {this.location = location;reachedDestination = location.equals(destination);}if (reachedDestination) {dispatcher.notifyAvaileable(this);}}@ThreadSafeclass Dispather {@GuardedBy("this") private final Set<Taxi> taxis;@GuardedBy("this") private final Set<Taxi> availableTaxis;public synchronized void notifyAvaileable(Taxi taxi) {availableTaxis.add(taxi);}public Image getImage() {Set<Taxi> copy;synchronized (this) {copy = new HashSet<>(taxis);}Image image = new Image();for (Taxi t : copy) {image.drawMarker(t.getLocation());}return image;}}
}

在程序中应尽量使用开放调用。与那些在持有锁时调用外部方法的程序相比,更易于对依赖于开放调用的程序进行死锁分析。
   有时候,在重新编写同步代码块以使用开放调用时会产生意想不到的结果,因为这会使得某个原子操作变为非原子操作。在许多情况下,使某个操作失去原子性是可以接受的。例如,对于两个操作:更新出租车位置以及通知调度程序这辆出租车已准备好出发去一个新的目的地,这两个操作并不需要实现为一个原子操作。在其他情况中,虽然去掉原子性可能会出现一些值得注意的结果,但这种语义变化仍然是可以接受的。在容易产生死锁的版本中,getImage会生成某个时刻下的整个车队位置的完整快照,而在重新改写的版本中,getImage将获得每辆出租车不同时刻的位置。
   然而,在某些情况下,丢失原子性会引发错误,此时需要通过另一种技术来实现原子性。例如,在构造一个并发对象时,使得每次只有单个线程执行使用了开放调用的代码路径。例如,在关闭某个服务时,你可能希望所有正在运行的操作执行完成以后,再释放这些服务占用的资源。如果在等待操作完成的同时持有该服务的锁,那么将很容易导致死锁,但如果在服务关闭之前就释放服务的锁,则可能导致其他线程开始新的操作。这个问题的解决方法是,在将服务的状态更新为“关闭”之前一直持有锁,这样其他想要开始新操作的线程,包括想关闭该服务的其他线程,会发现服务已经不可用,因此也就不会试图开始新的操作。然后,你可以等待关闭操作结束,并且知道当开放调用完成后,只有执行关闭操作的线程才能访问服务的状态。因此,这项技术依赖于构造一些协议(而不是通过加锁)来防止其他线程进入代码的临界区。

10.1.5 资源死锁

正如当多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁,当它们在相同的资源集合上等待时,也会发生死锁。
   假设有两个资源池,例如两个不同数据库的连接池。资源池通常采用信号量来实现(请参见5.5.3节)当资源池为空时的阻塞行为。如果一个任务需要连接两个数据库,并且在请求这两个资源时不会始终遵循相同的顺序,那么线程A可能持有与数据库D1的连接,并等待与数据库D2的连接,而线程B则持有与D2的连接并等待与D1的连接。(资源池越大,出现这种情况的可能性就越小。如果每个资源池都有N个连接,那么在发生死锁时不仅需要N个循环等待的线程,而且还需要大量不恰当的执行时序。)
   另一种基于资源的死锁形式就是线程饥饿死锁(Thread-Starvation Deadlock)。8.1.1节给出了这种危害的一个示例:一个任务提交另一个任务,并等待被提交任务在单线程的Executor中执行完成。这种情况下,第一个任务将永远等待下去,并使得另一个任务以及在这个Executor中执行的所有其他任务都停止执行。如果某些任务需要等待其他任务的结果,那么这些任务往往是产生线程饥饿死锁的主要来源,有界线程池/资源池与相互依赖的任务不能一起使用。

10.2 死锁的避免与诊断

如果一个程序每次至多只能获得一个锁,那么就不会产生锁顺序死锁。当然,这种情况通常并不现实,但如果能够避免这种情况,那么就能省去很多工作。如果必须获取多个锁,那么在设计时必须考虑锁的顺序:尽量减少潜在的加锁交互数量,将获取锁时需要遵循的协议写入正式文档并始终遵循这些协议。
   在使用细粒度锁的程序中,可以通过使用一种两阶段策略(Two-Part Strategy)来检查代码中的死锁:首先,找出在什么地方将获取多个锁(使这个集合尽量小),然后对所有这些实例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。尽可能地使用开放调用,这能极大地简化分析过程。如果所有的调用都是开放调用,那么要发现获取多个锁的实例是非常简单的,可以通过代码审查,或者借助自动化的源代码分析工具。

10.2.1 支持定时的锁

还有一项技术可以检测死锁和从死锁中恢复过来,即显式使用Lock类中的定时tryLock功能(参见第13章)来代替内置锁机制。当使用内置锁时,只要没有获得锁,就会永远等待下去,而显式锁则可以指定一个超时时限(Timeout),在等待超过该时间后tryLock会返回一个失败信息。如果超时时限比获取锁的时间要长很多,那么就可以在发生某个意外情况后重新获得控制权。(在程序清单13-3中给出了transferMoney的另一种实现,其中使用了一种轮询的tryLock消除了死锁发生的可能性。)
   当定时锁失败时,你并不需要知道失败的原因。或许是因为发生了死锁,或许某个线程在持有锁时错误地进入了无限循环,还可能是某个操作的执行时间远远超过了你的预期。然而,至少你能记录所发生的失败,以及关于这次操作的其他有用信息,并通过一种更平缓的方式来重新启动计算,而不是关闭整个进程。
   即使在整个系统中没有始终使用定时锁,使用定时锁来获取多个锁也能有效地应对死锁问题。如果在获取锁时超时,那么可以释放这个锁,然后后退并在一段时间后再次尝试,从而消除了死锁发生的条件,使程序恢复过来。(这项技术只有在同时获取两个锁时才有效,如果在嵌套的方法调用中请求多个锁,那么即使你知道已经持有了外层的锁,也无法释放它。);

10.2.2 通过线程转储信息来分析死锁

虽然防止死锁的主要责任在于你自己,但JVM仍然通过线程转储(Thread Dump)来帮助识别死锁的发生。线程转储包括各个运行中的线程的栈追踪信息,这类似于发生异常时的栈追踪信息。线程转储还包含加锁信息,例如每个线程持有了哪些锁,在哪些栈帧中获得这些锁,以及被阻塞的线程正在等待获取哪一个锁。【即使没有死锁,这些信息对于调试来说也是有用的。通过定期触发线程转储,可以观察程序的加锁行为。】在生成线程转储之前,JVM将在等待关系图中通过搜索循环来找出死锁。如果发现了一个死锁,则获取相应的死锁信息,例如在死锁中涉及哪些锁和线程,以及这个锁的获取操作位于程序的哪些位置。
   要在UNIX平台上触发线程转储操作,可以通过向JVM的进程发送SIGQUIT信号(kill-3),或者在UNIX平台中按下Ctrl-\键,在Windows平台中按下Ctrl-Break键。在许多IDE(集成开发环境)中都可以请求线程转储。
   如果使用显式的Lock类而不是内部锁,那么Java 5.0并不支持与Lock相关的转储信息,在线程转储中不会出现显式的Lock。虽然Java 6中包含对显式Lock的线程转储和死锁检测等的支持,但在这些锁上获得的信息比在内置锁上获得的信息精确度低。内置锁与获得它们所在的线程栈帧是相关联的,而显式的Lock只与获得它的线程相关联。
   程序清单10-7给出了一个J2EE应用程序中获取的部分线程转储信息。在导致死锁的故障中包括3个组件:一个J2EE应用程序,一个J2EE容器,以及一个JDBC驱动程序,分别由不同的生产商提供。这3个组件都是商业产品,并经过了大量的测试,但每一个组件中都存在一个错误,并且这个错误只有当它们进行交互时才会显现出来,并导致服务器出现一个严重的故障。
   程序清单10-7 在发生死锁后的部分线程转储信息
   我们只给出了与查找死锁相关的部分线程转储信息。当诊断死锁时,JVM可以帮我们做许多工作——哪些锁导致了这个问题,涉及哪些线程,它们持有哪些其他的锁,以及是否间接地给其他线程带来了不利影响。其中一个线程持有MumbleDBConnection上的锁,并等待获得MumbleDBCallableStatement上的锁,而另一个线程则持有MumbleDBCallableStatement上的锁,并等待MumbleDBConnection上的锁。
   在这里使用的JDBC驱动程序中明显存在一个锁顺序问题:不同的调用链通过JDBC驱动程序以不同的顺序获取多个锁。如果不是由于另一个错误,这个问题永远不会显现出来:多个线程试图同时使用同一个JDBC连接。这并不是应用程序的设计初衷——开发人员惊讶地发现同一个Connection被两个线程并发使用。在JDBC规范中并没有要求Connection必须是线程安全的,以及Connection通常被封闭在单个线程中使用,而在这里就采用了这种假设。这个生产商试图提供一个线程安全的JDBC驱动,因此在驱动程序代码内部对多个JDBC对象施加了同步机制。然而,生产商却没有考虑锁的顺序,因而驱动程序很容易发生死锁,而正是由于这个存在死锁风险的驱动程序与错误共享Connection的应用程序发生了交互,才使得这个问题暴露出来。因为单个错误并不会产生死锁,只有这两个错误同时发生时才会产生,即使它们分别进行了大量测试。

10.3 其他活跃危险

尽管死锁是最常见的活跃性危险,但在并发程序中还存在一些其他的活跃性危险,包括:饥饿、丢失信号和活锁等。(“丢失信号”这种活跃性危险将在14.2.3节中介绍。)

10.3.1 饥饿

当线程由于无法访问它所需要的资源而不能继续执行时,就发生了“饥饿(Starvation)”。引发饥饿的最常见资源就是CPU时钟周期。如果在Java应用程序中对线程的优先级使用不当,或者在持有锁时执行一些无法结束的结构(例如无限循环,或者无限制地等待某个资源),那么也可能导致饥饿,因为其他需要这个锁的线程将无法得到它。
   在Thread API中定义的线程优先级只是作为线程调度的参考。在Thread API中定义了10个优先级,JVM根据需要将它们映射到操作系统的调度优先级。这种映射是与特定平台相关的,因此在某个操作系统中两个不同的Java优先级可能被映射到同一个优先级,而在另一个操作系统中则可能被映射到另一个不同的优先级。在某些操作系统中,如果优先级的数量少于10个,那么有多个Java优先级会被映射到同一个优先级。
   操作系统的线程调度器会尽力提供公平的、活跃性良好的调度,甚至超出Java语言规范的需求范围。在大多数Java应用程序中,所有线程都具有相同的优先级Thread.NORM_PRIORITY。线程优先级并不是一种直观的机制,而通过修改线程优先级所带来的效果通常也不明显。当提高某个线程的优先级时,可能不会起到任何作用,或者也可能使得某个线程的调度优先级高于其他线程,从而导致饥饿。
   通常,我们尽量不要改变线程的优先级。只要改变了线程的优先级,程序的行为就将与平台相关,并且会导致发生饥饿问题的风险。你经常能发现某个程序会在一些奇怪的地方调用Thread.sleep或Thread.yield,这是因为该程序试图克服优先级调整问题或响应性问题,并试图让低优先级的线程执行更多的时间。【Thread.yield(以及Thread.sleep(O))的语义都是未定义的[JLS 17.9]。JVM既可以将它们实现为空操作,也可以将它们视为线程调度的参考。尤其是,在UNIX系统中并不要求它们拥有sleep(O)的语义——将当前线程放在与该优先级对应的运行队列末尾,并将执行权交给拥有相同优先级的其他线程,尽管有些JVM是按照这种方式来实现yield方法的。】
   要避免使用线程优先级,因为这会增加平台依赖性,并可能导致活跃性问题。在大多数并发应用程序中,都可以使用默认的线程优先级。

10.3.2 糟糕的响应性

除饥饿以外的另一个问题是糟糕的响应性,如果在GUI应用程序中使用了后台线程,那么这种问题是很常见的。在第9章中开发了一个框架,并把运行时间较长的任务放到后台线程中运行,从而不会使用户界面失去响应。但CPU密集型的后台任务仍然可能对响应性造成影响,因为它们会与事件线程共同竞争CPU的时钟周期。在这种情况下就可以发挥线程优先级的作用,此时计算密集型的后台任务将对响应性造成影响。如果由其他线程完成的工作都是后台任务,那么应该降低它们的优先级,从而提高前台程序的响应性。
   不良的锁管理也可能导致糟糕的响应性。如果某个线程长时间占有一个锁(或许正在对一个大容器进行迭代,并且对每个元素进行计算密集的处理),而其他想要访问这个容器的线程就必须等待很长时间。

10.3.3 活锁

活锁(Livelock)是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,而且总会失败。活锁通常发生在处理事务消息的应用程序中:如果不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头。如果消息处理器在处理某种特定类型的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务回滚。由于这条消息又被放回到队列开头,因此处理器将被反复调用,并返回相同的结果。(有时候也被称为毒药消息,Poison Message。)虽然处理消息的线程并没有阻塞,但也无法继续执行下去。这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误作为可修复的错误。
   当多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁。这就像两个过于礼貌的人在半路上面对面地相遇:他们彼此都让出对方的路,然而又在另一条路上相遇了。因此他们就这样反复地避让下去。
   要解决这种活锁问题,需要在重试机制中引入随机性。例如,在网络上,如果两台机器尝试使用相同的载波来发送数据包,那么这些数据包就会发生冲突。这两台机器都检查到了冲突,并都在稍后再次重发。如果二者都选择了在1秒钟后重试,那么它们又会发生冲突,并且不断地冲突下去,因而即使有大量闲置的带宽,也无法使数据包发送出去。为了避免这种情况发生,需要让它们分别等待一段随机的时间。(以太协议定义了在重复发生冲突时采用指数方式回退机制,从而降低在多台存在冲突的机器之间发生拥塞和反复失败的风险。)在并发应用程序中,通过等待随机长度的时间和回退可以有效地避免活锁的发生。

小结

活跃性故障是一个非常严重的问题,因为当出现活跃性故障时,除了中止应用程序之外没有其他任何机制可以帮助从这种故障时恢复过来。最常见的活跃性故障就是锁顺序死锁。在设计时应该避免产生锁顺序死锁:确保线程在获取多个锁时采用一致的顺序。最好的解决方法是在程序中始终使用开放调用。这将大大减少需要同时持有多个锁的地方,也更容易发现这些地方。

第11章 性能与可伸缩性

线程的最主要目的是提高程序的运行性能。线程可以使程序更加充分地发挥系统的可用处理能力,从而提高系统的资源利用率。此外,线程还可以使程序在运行现有任务的情况下立即开始处理新的任务,从而提高系统的响应性。
   本章将介绍各种分析、监测以及提升并发程序性能的技术。然而,许多提升性能的技术同样会增加复杂性,因此也就增加了在安全性和活跃性上发生失败的风险。更糟糕的是,虽然某些技术的初衷是提升性能,但事实上却与最初的目标背道而驰,或者又带来了其他新的性能问题。虽然我们希望获得更好的性能——提升性能总会令人满意,但始终要把安全性放在第一位。首先要保证程序能正确运行,然后仅当程序的性能需求和测试结果要求程序执行得更快时,才应该设法提高它的运行速度。在设计并发的应用程序时,最重要的考虑因素通常并不是将程序的性能提升至极限。

11.1 对性能的思考

在几乎所有的工程决策中都会涉及某些形式的权衡。在建设桥梁时,使用更粗的钢筋可以提高桥的负载能力和安全性,但同时也会提高建造成本。尽管在软件工程的决策中通常不会涉及资金以及人身安全,但在做出正确的权衡时通常会缺少相应的信息。例如,“快速排序”算法在大规模数据集上的执行效率非常高,但对于小规模的数据集来说,“冒泡排序”实际上更高效。如果要实现一个高效的排序算法,那么需要知道被处理数据集的大小,还有衡量优化的指标,包括:平均计算时间、最差时间、可预知性。然而,编写某个库中排序算法的开发人员通常无法知道这些需求信息。这就是为什么大多数优化措施都不成熟的原因之一:它们通常无法获得一组明确的需求。
   避免不成熟的优化。首先使程序正确,然后再提高运行速度——如果它还运行得不够快。
   当进行决策时,有时候会通过增加某种形式的成本来降低另一种形式的开销(例如,增加内存使用量以降低服务时间),也会通过增加开销来换取安全性。安全性并不一定就是指对人身安全的威胁,例如桥梁设计的示例。很多性能优化措施通常都是以牺牲可读性或可维护性为代价——代码越“聪明”或越“晦涩”,就越难以理解和维护。有时候,优化措施会破坏面向对象的设计原则,例如需要打破封装,有时候,它们又会带来更高的错误风险,因为通常越快的算法就越复杂。(如果你无法找出其中的代价或风险,那么或许还没有对这些优化措施进行彻底的思考和分析。)
   在大多数性能决策中都包含有多个变量,并且非常依赖于运行环境。在使某个方案比其他方案“更快”之前,首先问自己一些问题:

  • “更快”的含义是什么?
  • 该方法在什么条件下运行得更快?在低负载还是高负载的情况下?大数据集还是小数据集?能否通过测试结果来验证你的答案?
  • 这些条件在运行环境中的发生频率?能否通过测试结果来验证你的答案?
  • 在其他不同条件的环境中能否使用这里的代码?
  • 在实现这种性能提升时需要付出哪些隐含的代价,例如增加开发风险或维护开销?这种权衡是否合适?
       在进行任何与性能相关的决策时,都应该考虑这些问题,本书只介绍并发性方面的内容。我们为什么要推荐这种保守的优化方法?对性能的提升可能是并发错误的最大来源。有人认为同步机制“太慢”,因而采用一些看似聪明实则危险的方法来减少同步的使用(例如16.2.4节中讨论的双重检查锁),这也通常作为不遵守同步规则的一个常见借口。然而,由于并发错误是最难追踪和消除的错误,因此对于任何可能会引入这类错误的措施,都需要谨慎实施。
       更糟的是,虽然你的初衷可能是用安全性来换取性能,但最终可能什么都得不到。特别是,当提到并发时,许多开发人员对于哪些地方存在性能问题,哪种方法的运行速度更快,以及哪种方法的可伸缩性更高,往往会存在错误的直觉。因此,在对性能的调优时,一定要有明确的性能需求(这样才能知道什么时候需要调优,以及什么时候应该停止),此外还需要一个测试程序以及真实的配置和负载等环境。在对性能调优后,你需要再次测量以验证是否到达了预期的性能提升目标。在许多优化措施中带来的安全性和可维护性等风险非常高。如果不是必须的话,你通常不想付出这样的代价,如果无法从这些措施中获得性能提升,那么你肯定不希望付出这种代价。
       以测试为基准,不要猜测。
       在市场上有一些成熟的分析工具可以用于评估性能以及找出性能瓶颈,但你不需要花太多的资金来找出程序的功能。例如,免费的perfbar应用程序可以给出CPU的忙碌程度信息,而我们通常的目标就是使CPU保持忙碌状态,因此这个功能可以有效地评估是否需要进行性能调优或者已实现的调优效果如何。

11.2 Amdahl定律

在有些问题中,如果可用资源越多,那么问题的解决速度就越快。例如,如果参与收割庄稼的工人越多,那么就能越快地完成收割工作。而有些任务本质上是串行的,例如,即使增加再多的工人也不可能增加作物的生长速度。如果使用线程主要是为了发挥多个处理器的处理能力,那么就必须对问题进行合理的并行分解,并使得程序能有效地使用这种潜在的并行能力。
   大多数并发程序都与农业耕作有着许多相似之处,它们都是由一系列的并行工作和串行工作组成的。Amdahl定律描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。假定F是必须被串行执行的部分,那么根据Amdahl定律,在包含N个处理器的机器中,最高的加速比为:

   当N趋近无穷大时,最大的加速比趋近于1/F。因此,如果程序有50%的计算需要串行执行,那么最高的加速比只能是2(而不管有多少个线程可用);如果在程序中有10%的计算需要串行执行,那么最高的加速比将接近10。Amdahl定律还量化了串行化的效率开销。在拥有10个处理器的系统中,如果程序中有10%的部分需要串行执行,那么最高的加速比为5.3(53%的使用率),在拥有100个处理器的系统中,加速比可以达到9.2(9%的使用率)。即使拥有无限多的CPU,加速比也不可能为10。
   图11-1给出了处理器利用率在不同串行比例以及处理器数量情况下的变化曲线。(利用率的定义为:加速比除以处理器的数量。)随着处理器数量的增加,可以很明显地看到,即使串行部分所占的百分比很小,也会极大地限制当增加计算资源时能够提升的吞吐率。

   第6章介绍了如何识别任务的逻辑边界并将应用程序分解为多个子任务。然而,要预测应用程序在某个多处理器系统中将实现多大的加速比,还需要找出任务中的串行部分。
   假设应用程序中N个线程正在执行程序清单11-1中的doWork,这些线程从一个共享的工作队列中取出任务进行处理,而且这里的任务都不依赖于其他任务的执行结果或影响。暂时先不考虑任务是如何进入这个队列的,如果增加处理器,那么应用程序的性能是否会相应地发生变化?初看上去,这个程序似乎能完全并行化:各个任务之间不会相互等待,因此处理器越多,能够并发处理的任务也就越多。然而,在这个过程中包含了一个串行部分——从队列中获取任务。所有工作者线程都共享同一个工作队列,因此在对该队列进行并发访问时需要采用某种同步机制来维持队列的完整性。如果通过加锁来保护队列的状态,那么当一个线程从队列中取出任务时,其他需要获取下一个任务的线程就必须等待,这就是任务处理过程中的串行部分。
   程序清单11-1 对任务队列的串行访问

public class WorkerThread extends Thread {private final BlockingQueue<Runnable> queue;public WorkerThread(BlockingQueue<Runnable> queue) { this.queue = queue; }@Overridepublic void run() {while (true) {try {Runnable take = queue.take();take.run();} catch (InterruptedException ex) {break; // 允许线程退出}}}
}

单个任务的处理时间不仅包括执行任务Runnable的时间,也包括从共享队列中取出任务的时间。如果使用LinkedBlockingQueue作为工作队列,那么出列操作被阻塞的可能性将小于使用同步LinkedList时发生阻塞的可能性,因为LinkedBlockingQueue使用了一种可伸缩性更高的算法。然而,无论访问何种共享数据结构,基本上都会在程序中引入一个串行部分。
   这个示例还忽略了另一种常见的串行操作:对结果进行处理。所有有用的计算都会生成某种结果或者产生某种效应——如果不会,那么可以将它们作为“死亡代码”删除掉。由于Runnable没有提供明确的结果处理过程,因此这些任务一定会产生某种效果,例如将它们的结果写入到日志或者保存到某个数据结构。通常,日志文件和结果容器都会由多个工作者线程共享,并且这也是一个串行部分。如果所有线程都将各自的计算结果保存到自行维护数据结构中,并且在所有任务都执行完成后再合并所有的结果,那么这种合并操作也是一个串行部分。
   在所有并发程序中都包含一些串行部分。如果你认为在你的程序中不存在串行部分,那么可以再仔细检查一遍。

11.2.1 示例:在各种框架中隐藏的串行部分

要想知道串行部分是如何隐藏在应用程序的架构中,可以比较当增加线程时吞吐量的变化,并根据观察到的可伸缩性变化来推断串行部分中的差异。图11-2给出了一个简单的应用程序,其中多个线程反复地从一个共享Queue中取出元素进行处理,这与程序清单11-1很相似。处理步骤只需执行线程本地的计算。如果某个线程发现队列为空,那么它将把一组新元素放入队列,因而其他线程在下一次访问时不会没有元素可供处理。在访问共享队列的过程中显然存在着一定程度的串行操作,但处理步骤完全可以并行执行,因为它不会访问共享数据。

   图11-2的曲线对两个线程安全的Queue的吞吐率进行了比较:其中一个是采用synchroni-zedList封装的LinkedList;另一个是ConcurrentLinkedQueue。这些测试在8路Sparc V880系统上运行,操作系统为Solaris。尽管每次运行都表示相同的“工作量”,但我们可以看到,只需改变队列的实现方式,就能对可伸缩性产生明显的影响。
   ConcurrentLinkedQueue的吞吐量不断提升,直到到达了处理器数量上限,之后将基本保持不变。另一方面,当线程数量小于3时,同步LinkedList的吞吐量也会有某种程度的提升,但是之后会由于同步开销的增加而下跌。当线程数量达到4个或5个时,竞争将非常激烈,甚至每次访问队列都会在锁上发生竞争,此时的吞吐量主要受到上下文切换的限制。
   吞吐量的差异来源于两个队列中不同比例的串行部分。同步的LinkedList采用单个锁来保护整个队列的状态,并且在offer和remove等方法的调用期间都将持有这个锁。ConcurrentLinkedQueue使用了一种更复杂的非阻塞队列算法(请参见15.4.2节),该算法使用原子引用来更新各个链接指针。在第一个队列中,整个的插入或删除操作都将串行执行,而在第二个队列中,只有对指针的更新操作需要串行执行。

11.2.2 Amdahl定律的应用

如果能准确估计出执行过程中串行部分所占的比例,那么Amdahl定律就能量化当有更多计算资源可用时的加速比。虽然要直接测量串行部分的比例非常困难,但即使在不进行测试的情况下Amdahl定律仍然是有用的。
   因为我们的思维通常会受到周围环境的影响,因此很多人都会习惯性地认为在多处理器系统中会包含2个或4个处理器,甚至更多(如果得到足够大的预算批准),因为这种技术在近年来被广泛使用。但随着多核CPU逐渐成为主流,系统可能拥有数百个甚至数千个处理器。【市场信息:在写作本书时,Sun正在发布基于8核Niagara处理器的低端服务器系统,此外Azul也正在发布基于24核Vega处理器的高端服务器系统(包括96路、192路和384路)。】一些在4路系统中看似具有可伸缩性的算法,却可能含有一些隐藏的可伸缩性瓶颈,只是还没有遇到而已。
   在评估一个算法时,要考虑算法在数百个或数千个处理器的情况下的性能表现,从而对可能出现的可伸缩性局限有一定程度的认识。例如,在11.4.2节和11.4.3节中介绍了两种降低锁粒度的技术:锁分解(将一个锁分解为两个锁)和锁分段(把一个锁分解为多个锁)。当通过Amdahl定律来分析这两项技术时,我们会发现,如果将一个锁分解为两个锁,似乎并不能充分利用多处理器的能力。锁分段技术似乎更有前途,因为分段的数量可随着处理器数量的增加而增加。(当然,性能优化应该考虑实际的性能需求,在某些情况下,将一个锁分解为两个就够了。)

11.3 线程引入的开销

单线程程序既不存在线程调度,也不存在同步开销,而且不需要使用锁来保证数据结构的一致性。在多个线程的调度和协调过程中都需要一定的性能开销:对于为了提升性能而引入的线程来说,并行带来的性能提升必须超过并发导致的开销。

11.3.1 上下文切换

如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。
   切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM共享的数据结构。应用程序、操作系统以及JVM都使用一组相同的CPU。在JVM和操作系统的代码中消耗越多的CPU时钟周期,应用程序的可用CPU时钟周期就越少。但上下文切换的开销并不只是包含JVM和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。这就是为什么调度器会为每个可运行的线程分配一个最小执行时间,即使有许多其他的线程正在等待执行:它将上下文切换的开销分摊到更多不会中断的执行时间上,从而提高整体的吞吐量(以损失响应性为代价)。
   当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞(包括阻塞I/O,等待获取发生竞争的锁,或者在条件变量上等待),与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。(无阻塞算法同样有助于减小上下文切换。请参见第15章。)
   上下文切换的实际开销会随着平台的不同而变化,然而按照经验来看:在大多数通用的处理器中,上下文切换的开销相当于5 000~10000个时钟周期,也就是几微秒。
   UNIX系统的vmstat命令和Windows系统的perfmon工具都能报告上下文切换次数以及在内核中执行时间所占比例等信息。如果内核占用率较高(超过10%),那么通常表示调度活动发生得很频繁,这很可能是由I/O或竞争锁导致的阻塞引起的。

11.3.2 内存同步

同步操作的性能开销包括多个方面。在synchronized和volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(Memory Barrier)。内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。
   在评估同步操作带来的性能影响时,区分有竞争的同步和无竞争的同步非常重要。synchronized机制针对无竞争的同步进行了优化(volatile通常是非竞争的),而在编写本书时,一个“快速通道(Fast-Path)”的非竞争同步将消耗20~250个时钟周期。虽然无竞争同步的开销不为零,但它对应用程序整体性能的影响微乎其微,而另一种方法不仅会破坏安全性,而且还会使你(或者后续开发人员)经历非常痛苦的除错过程。
   现代的JVM能通过优化来去掉一些不会发生竞争的锁,从而减少不必要的同步开销。如果一个锁对象只能由当前线程访问,那么JVM就可以通过优化来去掉这个锁获取操作,因为另一个线程无法与当前线程在这个锁上发生同步。例如,JVM通常都会去掉程序清单11-2中的锁获取操作。
   程序清单11-2 没有作用的同步(不要这么做)

synchronized (new Object()) { /* 执行一些操作 */ }

一些更完备的JVM能通过逸出分析(Escape Analysis)来找出不会发布到堆的本地对象引用(因此这个引用是线程本地的)。在程序清单11-3的getStoogeNames中,对List的唯一引用就是局部变量stooges,并且所有封闭在栈中的变量都会自动成为线程本地变量。在getStoogeNames的执行过程中,至少会将Vector上的锁获取/释放4次,每次调用add或toString时都会执行1次。然而,一个智能的运行时编译器通常会分析这些调用,从而使stooges及其内部状态不会逸出,因此可以去掉这4次对锁获取操作。【这个编译器优化也被称为锁消除优化(Lock Elision),IBM的JVM支持这种优化,并且预期从Java 7开始在HotSpot中支持。】
   程序清单11-3 可通过锁消除优化去掉的锁获取操作

 public String getStoogeNames() {List<String> stooges = new Vector<String>();stooges.add("Moe");stooges.add("Larry");stooges.add("Curly");return stooges.toString();
}

即使不进行逸出分析,编译器也可以执行锁粒度粗化(Lock Coarsening)操作,即将邻近的同步代码块用同一个锁合并起来。在getStoogeNmnes中,如果JVM进行锁粒度粗化,那么可能会把3个add与1个toString调用合并为单个锁获取/释放操作,并采用启发式方法来评估同步代码块中采用同步操作以及指令之间的相对开销。【一个智能的动态编译器会发现该方法总是返回相同的字符串,因此在第一次执行后,把getStoogeNames重新编译为仅返回第一次执行的结果。】这不仅减少了同步的开销,同时还能使优化器处理更大的代码块,从而可能实现进一步的优化。
   不要过度担心非竞争同步带来的开销。这个基本的机制已经非常快了,并且JVM还能进行额外的优化以进一步降低或消除开销。因此,我们应该将优化重点放在那些发生锁竞争的地方。
   某个线程中的同步可能会影响其他线程的性能。同步会增加共享内存总线上的通信量,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响。【有时候,人们会拿这个方面与不包含“避让(Backoff)”机制的非阻塞算法相比较,因为在激烈的竞争下,非阻塞算法能比基于锁的算法产生更多的同步通信量。请参见第15章。】

11.3.3 阻塞

非竞争的同步可以完全在JVM中进行处理(Bacon等,1998),而竞争的同步可能需要操作系统的介入,从而增加开销。当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM在实现阻塞行为时,可以采用自旋等待(Spin-Waiting,指通过循环不断地尝试获取锁,直到成功)或者通过操作系统挂起被阻塞的线程。这两种方式的效率高低,要取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待时间较短,则适合采用自旋等待方式,而如果等待时间较长,则适合采用线程挂起方式。有些JVM将根据对历史等待时间的分析数据在这两者之间进行选择,但是大多数JVM在等待锁时都只是将线程挂起。
   当线程无法获取某个锁或者由于在某个条件等待或在I/O操作上阻塞时,需要被挂起,在这个过程中将包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作:被阻塞的线程在其执行时间片还未用完之前就被交换出去,而在随后当要获取的锁或者其他资源可用时,又再次被切换回来。(由于锁竞争而导致阻塞时,线程在持有锁时将存在一定的开销:当它释放锁时,必须告诉操作系统恢复运行阻塞的线程。)

11.4 减少锁的竞争

我们已经看到,串行操作会降低可伸缩性,并且上下文切换也会降低性能。在锁上发生竞争时将同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。
   在对由某个独占锁保护的资源进行访问时,将采用串行方式——每次只有一个线程能访问它。当然,我们有很好的理由来使用锁,例如避免数据被破坏,但获得这种安全性是需要付出代价的。如果在锁上持续发生竞争,那么将限制代码的可伸缩性。
   在并发程序中,对可伸缩性的最主要威胁就是独占方式的资源锁。
   有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间[1]。如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成严重影响。然而,如果在锁上的请求量很高,那么需要获取该锁的线程将被阻塞并等待。在极端情况下,即使仍有大量工作等待完成,处理器也会被闲置。
   有3种方式可以降低锁的竞争程度:

  • 减少锁的持有时间。
  • 降低锁的请求频率。
  • 使用带有协调机制的独占锁,这些机制允许更高的并发性。

11.4.1 缩小锁的范围(”快进快出“)

降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。例如,可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作,例如I/O操作。
   我们都知道,如果将一个“高度竞争”的锁持有过长的时间,那么会限制可伸缩性,例如在第2章中介绍的SynchronizedFactorizer的示例。如果某个操作持有锁的时间超过2毫秒并且所有操作都需要这个锁,那么无论拥有多少个空闲处理器,吞吐量也不会超过每秒500个操作。如果将这个锁的持有时间降为1毫秒,那么能够将这个锁对应的吞吐量提高到每秒1000个操作。【事实上,这里的计算仅考虑了锁的持有时间过长而导致的开销,而并没有考虑在锁的竞争中导致切换上下文而导致的开销。】
   程序清单11-4给出了一个示例,其中锁被持有过长的时间。userLocationMatches方法在一个Map对象中查找用户的位置,并使用正则表达式进行匹配以判断结果值是否匹配所提供的模式。整个userLocationMatches方法都使用了synchronized来修饰,但只有Map.get这个方法才真正需要锁。
   程序清单11-4 将一个锁不必要地持有过长时间

@ThreadSafe
public class AttributeStore {@GuardedBy("this") private final Map<String, String> attribute = new HashMap<>();public synchronized boolean userLocationMatches(String name, String regexp) {String key = "user."+name+".location";String location = attribute.get(key);if (location == null) {return false;} else {return Pattern.matches(regexp, location);}}
}

在程序清单11-5的BetterAttributeStore中重新编写了AttributeStore,从而大大减少了锁的持有时间。第一个步骤是构建Map中与用户位置相关联的键值,这是一个字符串,形式为users.name.location。这个步骤包括实例化一个StringBuilder对象,向其添加几个字符串,并将结果实例化为一个String类型对象。在获得了位置后,就可以将正则表达式与位置字符串进行匹配。由于在构建键值字符串以及处理正则表达式等过程中都不需要访问共享状态,因此在执行时不需要持有锁。通过在BetterAttributeStore中将这些步骤提取出来并放到同步代码块之外,从而减少了锁被持有的时间。

@ThreadSafe
public class BetterAttributeStore {@GuardedBy("this")private final Map<String, String> attribute = new HashMap<>();public boolean userLocationMatches(String name, String regexp) {String key = "user."+name+".location";String location;synchronized (this) {location = attribute.get(key);}if (location == null) {return false;} else {return Pattern.matches(regexp, location);}}
}

通过缩小userLocationMatches方法中锁的作用范围,能极大地减少在持有锁时需要执行的指令数量。根据Amdahl定律,这样消除了限制可伸缩性的一个因素,因为串行代码的总量减少了。
   由于在AttributeStore中只有一个状态变量attributes,因此可以通过将线程安全性委托给其他的类来进一步提升它的性能(参见4.3节)。通过用线程安全的Map(Hashtable、synchronizedMap或ConcurrentHashMap)来代替attributes, AttributeStore可以将确保线程安全性的任务委托给顶层的线程安全容器来实现。这样就无须在AttributeStore中采用显式的同步,缩小在访问Map期间锁的范围,并降低了将来的代码维护者无意破坏线程安全性的风险(例如在访问attributes之前忘记获得相应的锁)。
   尽管缩小同步代码块能提高可伸缩性,但同步代码块也不能过小——一些需要采用原子方式执行的操作(例如对某个不变性条件中的多个变量进行更新)必须包含在一个同步块中。此外,同步需要一定的开销,当把一个同步代码块分解为多个同步代码块时(在确保正确性的情况下),反而会对性能提升产生负面影响。【如果JVM执行锁粒度粗化操作,那么可能会将分解的同步块又重新合并起来。】在分解同步代码块时,理想的平衡点将与平台相关,但在实际情况中,仅当可以将一些“大量”的计算或阻塞操作从同步代码块中移出时,才应该考虑同步代码块的大小。

11.4.2 减小锁的粒度

另一种减小锁的持有时间的方式是降低线程请求锁的频率(从而减小发生竞争的可能性)。这可以通过锁分解和锁分段等技术来实现,在这些技术中将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变量在之前由单个锁来保护的情况。这些技术能减小锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,那么发生死锁的风险也就越高。
   设想一下,如果在整个应用程序中只有一个锁,而不是为每个对象分配一个独立的锁,那么,所有同步代码块的执行就会变成串行化执行,而不考虑各个同步块中的锁。由于很多线程将竞争同一个全局锁,因此两个线程同时请求这个锁的概率将剧增,从而导致更严重的竞争。所以如果将这些锁请求分布到更多的锁上,那么能有效地降低竞争程度。由于等待锁而被阻塞的线程将更少,因此可伸缩性将提高。
   如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。
   在程序清单11-6的ServerStatus中给出了某个数据库服务器的部分监视接口,该数据库维护了当前已登录的用户以及正在执行的请求。当一个用户登录、注销、开始查询或结束查询时,都会调用相应的add和remove等方法来更新ServerStatus对象。这两种类型的信息是完全独立的,ServerStatus甚至可以被分解为两个类,同时确保不会丢失功能。
   程序清单11-6 对锁进行分解

@ThreadSafe
public class ServerStatus {@GuardedBy("this") public final Set<String> users;@GuardedBy("this") public final Set<String> quries;public synchronized void addUser(String u) { users.add(u); }public synchronized void addQuery(String q) { quries.add(q); }public synchronized void removeUser(String u) { users.remove(u); }public synchronized void removeQuery(String q) { quries.remove(q); }
}

在代码中不是用ServerStatus锁来保护用户状态和查询状态,而是每个状态都通过一个锁来保护,如程序清单11-7所示。在对锁进行分解后,每个新的细粒度锁上的访问量将比最初的访问量少。(通过将用户状态和查询状态委托给一个线程安全的Set,而不是使用显式的同步,能隐含地对锁进行分解,因为每个Set都会使用一个不同的锁来保护其状态。)
   程序清单11-7 将ServerStatus重新改写为使用锁分解技术

@ThreadSafe
public class ServerStatus {@GuardedBy("users") public final Set<String> users;@GuardedBy("queries") public final Set<String> quries;public void addUser(String u) { synchronized (users) {users.add(u);}}public void addQuery(String q) {synchronized (quries) {quries.add(q);}}
}

如果在锁上存在适中而不是激烈的竞争时,通过将一个锁分解为两个锁,能最大限度地提升性能。如果对竞争并不激烈的锁进行分解,那么在性能和吞吐量等方面带来的提升将非常有限,但是也会提高性能随着竞争提高而下降的拐点值。对竞争适中的锁进行分解时,实际上是把这些锁转变为非竞争的锁,从而有效地提高性能和可伸缩性。

11.4.3 锁分段

把一个竞争激烈的锁分解为两个锁时,这两个锁可能都存在激烈的竞争。虽然采用两个线程并发执行能提高一部分可伸缩性,但在一个拥有多个处理器的系统中,仍然无法给可伸缩性带来极大的提高。在ServerStatus类的锁分解示例中,并不能进一步对锁进行分解。
   在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。例如,在ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁来保护。假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来的1/16。正是这项技术使得ConcurrentHashMap能够支持多达16个并发的写入器。(要使得拥有大量处理器的系统在高访问量的情况下实现更高的并发性,还可以进一步增加锁的数量,但仅当你能证明并发写入线程的竞争足够激烈并需要突破这个限制时,才能将锁分段的数量超过默认的16个。)
   锁分段的一个劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需获取一个锁,但在某些情况下需要加锁整个容器,例如当ConcurrentHashMap需要扩展映射范围,以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段所集合中所有的锁。【要获取内置锁的一个集合,能采用的唯一方式是递归。】
   在程序清单11-8的StripedMap中给出了基于散列的Map实现,其中使用了锁分段技术。它拥有N_LOCKS个锁,并且每个锁保护散列桶的一个子集。大多数方法,例如get,都只需要获得一个锁,而有些方法则需要获得所有的锁,但并不要求同时获得,例如clear方法的实现。【这种清除Map的方式并不是原子操作,因此可能当StripedMap为空时其他的线程正并发地向其中添加元素。如果要使该操作成为一个原子操作,那么需要同时获得所有的锁。然而,如果客户代码不加锁并发容器来实现独占访问,那么像size或isEmpty这样的方法的计算结果在返回时可能会变得无效,因此,尽管这种行为有些奇怪,但通常是可以接受的。】

@ThreadSafe
public class StripedMap {// 同步策略,buckets[n]由locks[n%N_LOCKS]来保护private static final int N_LOCKS = 16;private final Node[] buckets;private final Object[] locks;private static class Node<K,V> {final K key;volatile V val;volatile Node<K,V> next;Node(int hash, K key, V val, Node<K,V> next) {this.key = key;this.val = val;this.next = next;}}public StripedMap(int numBuckets) {buckets = new Node[numBuckets];locks = new Object[N_LOCKS];for (int i = 0; i < N_LOCKS; i++) {locks[i] = new Object();}}private final int hash(Object key) {return Math.abs(key.hashCode() % buckets.length);}public Object get(Object key) {int hash = hash(key);synchronized (locks[hash % N_LOCKS]) {for (Node m = buckets[hash]; m != null; m = m.next) {if (m.key.equals(key)) {return m.val;}}return null;}}public void clear() {for (int i = 0; i < buckets.length; i++) {synchronized (locks[i % N_LOCKS]) {buckets[i] = null;}}}
}

11.4.4 避免热点域

锁分解和锁分段技术都能提高可伸缩性,因为它们都能使不同的线程在不同的数据(或者同一个数据的不同部分)上操作,而不会相互干扰。如果程序采用锁分段技术,那么一定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。如果一个锁保护两个独立变量X和Y,并且线程A想要访问X,而线程B想要访问Y(这类似于在ServerStatus中,一个线程调用addUser,而另一个线程调用addQuery),那么这两个线程不会在任何数据上发生竞争,即使它们会在同一个锁上发生竞争。
   当每个操作都请求多个变量时,锁的粒度将很难降低。这是在性能与可伸缩性之间相互制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引入一些“热点域(HotField)”,而这些热点域往往会限制可伸缩性。
   当实现HashMap时,你需要考虑如何在size方法中计算Map中的元素数量。最简单的方法就是,在每次调用时都统计一次元素的数量。一种常见的优化措施是,在插入和移除元素时更新一个计数器,虽然这在put和remove等方法中略微增加了一些开销,以确保计数器是最新的值,但这将把size方法的开销从O(n)降低到O(1)。
   在单线程或者采用完全同步的实现中,使用一个独立的计数能很好地提高类似size和isEmpty这些方法的执行速度,但却导致更难以提升实现的可伸缩性,因为每个修改map的操作都需要更新这个共享的计数器。即使使用锁分段技术来实现散列链,那么在对计数器的访问进行同步时,也会重新导致在使用独占锁时存在的可伸缩性问题。一个看似性能优化的措施——缓存size操作的结果,已经变成了一个可伸缩性问题。在这种情况下,计数器也被称为热点域,因为每个导致元素数量发生变化的操作都需要访问它。
   为了避免这个问题,ConcurrentHashMap中的size将对每个分段进行枚举并将每个分段中的元素数量相加,而不是维护一个全局计数。为了避免枚举每个元素,ConcurrentHashMap为每个分段都维护了一个独立的计数,并通过每个分段的锁来维护这个值。【如果size方法的调用频率与修改Map操作的执行频率大致相当,那么可以采用这种方式来优化所有已分段的数据结构,即每当调用size时,将返回值缓存到一个volatile变量中,并且每当容器被修改时,使这个缓存中的值无效(将其设为-1)。如果发现缓存的值非负,那么表示这个值是正确的,可以直接返回,否则,需要重新计算这个值。】

11.4.5 一些替代独占锁的方法

如果size方法的调用频率与修改Map操作的执行频率大致相当,那么可以采用这种方式来优化所有已分段的数据结构,即每当调用size时,将返回值缓存到一个volatile变量中,并且每当容器被修改时,使这个缓存中的值无效(将其设为-1)。如果发现缓存的值非负,那么表示这个值是正确的,可以直接返回,否则,需要重新计算这个值。
   ReadWriteLock(请参见第13章)实现了一种在多个读取操作以及单个写入操作情况下的加锁规则:如果多个读取操作都不会修改共享资源,那么这些读取操作可以同时访问该共享资源,但在执行写入操作时必须以独占方式来获取锁。对于读取操作占多数的数据结构,ReadWriteLock能提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不变性可以完全不需要加锁操作。
   原子变量(请参见第15章)提供了一种方式来降低更新“热点域”时的开销,例如静态计数器、序列发生器、或者对链表数据结构中头节点的引用。(在第2章的示例中使用了AtomicLong来维护Servlet的计数器。)原子变量类提供了在整数或者对象引用上的细粒度原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语(例如比较并交换[compare-and-swap])。如果在类中只包含少量的热点域,并且这些域不会与其他变量参与到不变性条件中,那么用原子变量来替代它们能提高可伸缩性。(通过减少算法中的热点域,可以提高可伸缩性——虽然原子变量能降低热点域的更新开销,但并不能完全消除。)

11.4.6 检测CPU的利用率

当测试可伸缩性时,通常要确保处理器得到充分利用。一些工具,例如UNIX系统上的vmstat和mpstat,或者Windows系统的perfmon,都能给出处理器的“忙碌”状态。
   如果所有CPU的利用率并不均匀(有些CPU在忙碌地运行,而其他CPU却并非如此),那么你的首要目标就是进一步找出程序中的并行性。不均匀的利用率表明大多数计算都是由一小组线程完成的,并且应用程序没有利用其他的处理器。
   如果CPU没有得到充分利用,那么需要找出其中的原因。通常有以下几种原因:
   负载不充足。 测试的程序中可能没有足够多的负载,因而可以在测试时增加负载,并检查利用率、响应时间和服务时间等指标的变化。如果产生足够多的负载使应用程序达到饱和,那么可能需要大量的计算机能耗,并且问题可能在于客户端系统是否具有足够的能力,而不是被测试系统。
   I/O密集。 可以通过iostat或perfmon来判断某个应用程序是否是磁盘I/O密集型的,或者通过监测网络的通信流量级别来判断它是否需要高带宽。
   外部限制。 如果应用程序依赖于外部服务,例如数据库或Web服务,那么性能瓶颈可能并不在你自己的代码中。可以使用某个分析工具或数据库管理工具来判断在等待外部服务的结果时需要多少时间。
   锁竞争。 使用分析工具可以知道在程序中存在何种程度的锁竞争,以及在哪些锁上存在“激烈的竞争”。然而,也可以通过其他一些方式来获得相同的信息,例如随机取样,触发一些线程转储并在其中查找在锁上发生竞争的线程。如果线程由于等待某个锁而被阻塞,那么在线程转储信息中将存在相应的栈帧,其中包含的信息形如“waiting to lock monitor……”。非竞争的锁很少会出现在线程转储中,而对于竞争激烈的锁,通常至少会有一个线程在等待获取它,因此将在线程转储中频繁出现。
   如果应用程序正在使CPU保持忙碌状态,那么可以使用监视工具来判断是否能通过增加额外的CPU来提升程序的性能。如果一个程序只有4个线程,那么可以充分利用一个4路系统的计算能力,但当移植到8路系统上时,却未必能获得性能提升,因为可能需要更多的线程才会有效利用剩余的处理器。(可以通过重新配置程序将工作负载分配给更多的线程,例如调整线程池的大小。)在vmstat命令的输出中,有一栏信息是当前处于可运行状态但并没有运行(由于没有足够的CPU)的线程数量。如果CPU的利用率很高,并且总会有可运行的线程在等待CPU,那么当增加更多的处理器时,程序的性能可能会得到提升。

11.4.7 向线程池说“不”

在JVM的早期版本中,对象分配和垃圾回收等操作的执行速度非常慢与其他事情一样,例如,同步、图形化、JVM启动以及反射等,都是作为实验性技术的第一个版本。】,但在后续的版本中,这些操作的性能得到了极大提高。事实上,现在Java的分配操作已经比C语言的malloc调用更快:在HotSpot l.4.x和5.0中,“new Object”的代码大约只包含10条机器指令。
   为了解决“缓慢的”对象生命周期问题,许多开发人员都选择使用对象池技术,在对象池中,对象能被循环使用,而不是由垃圾收集器回收并在需要时重新分配。在单线程程序中(Click,2005),尽管对象池技术能降低垃圾收集操作的开销,但对于高开销对象以外的其他对象来说,仍然存在性能缺失【除了损失CPU指令周期外,在对象池技术中还存在一些其他问题,其中最大的问题就是如何正确地设定对象池的大小(如果对象池太小,那么将没有作用,而如果太大,则会对垃圾回收器带来压力,因为过大的对象池将占用其他程序需要的内存资源)。如果在重新使用某个对象时没有将其恢复到正确的状态,那么可能会产生一些“微妙的”错误。此外,还可能出现一个线程在将对象归还给线程池后仍然使用该对象的问题,从而产生一种“从旧对象到新对象”的引用,导致基于代的垃圾回收器需要执行更多的工作。】对于轻量级和中量级的对象来说,这种损失将更为严重)。
   在并发应用程序中,对象池的表现更加糟糕。当线程分配新的对象时,基本上不需要在线程之间进行协调,因为对象分配器通常会使用线程本地的内存块,所以不需要在堆数据结构上进行同步。然而,如果这些线程从对象池中请求一个对象,那么就需要通过某种同步来协调对对象池数据结构的访问,从而可能使某个线程被阻塞。如果某个线程由于锁竞争而被阻塞,那么这种阻塞的开销将是内存分配操作开销的数百倍,因此即使对象池带来的竞争很小,也可能形成一个可伸缩性瓶颈。(即使是一个非竞争的同步,所导致的开销也会比分配一个对象的开销大。)虽然这看似是一种性能优化技术,但实际上却会导致可伸缩性问题。对象池有其特定的用途【在特定的环境中,例如J2ME或RTSJ,需要对象池技术来提高内存管理或响应性管理的效率。】,但对于性能优化来说,用途是有限的。
   通常,对象分配操作的开销比同步的开销更低。

11.5 示例 比较Map的性能

在单线程环境下,ConcurrentHashMap的性能比同步的HashMap的性能略好一些,但在并发环境中则要好得多。在ConcurrentHashMap的实现中假设,大多数常用的操作都是获取某个已经存在的值,因此它对各种get操作进行了优化从而提供最高的性能和并发性。
   在同步Map的实现中,可伸缩性的最主要阻碍在于整个Map中只有一个锁,因此每次只有一个线程能够访问这个Map。不同的是,ConcurrentHashMap对于大多数读操作并不会加锁,并且在写入操作以及其他一些需要锁的读操作中使用了锁分段技术。因此,多个线程能并发地访问这个Map而不会发生阻塞。
   图11-3给出了几种Map实现在可伸缩上的差异:ConcurrentHashMap、ConcurrentSkipListMap,以及通过synchronizedMap来包装的HashMap和TreeMap。前两种Map是线程安全的,而后两个Map则通过同步封装器来确保线程安全性。每次运行时,将有N个线程并发地执行一个紧凑的循环:选择一个随机的键值,并尝试获取与这个键值相对应的值。如果不存在相应的值,那么将这个值增加到Map的概率为p=0.6,如果存在相应的值,那么删除这个值的概率为p=0.02。这个测试在8路SparcV880系统上运行,基于Java 6环境,并且在图中给出了将ConcurrentHashMap归一化为单个线程时的吞吐量。(并发容器与同步容器在可伸缩性上的差异比在Java 5.0中更加明显。)

   ConcurrentHashMap和ConcurrentSkipListMap的数据显示,它们在线程数量增加时能表现出很好的可伸缩性,并且吞吐量会随着线程数量的增加而增加。虽然图11-3中的线程数量并不大,但与普通的应用程序相比,这个测试程序在每个线程上生成了更多的竞争,因为它除了向Map施加压力外几乎没有执行任何其他操作,而实际的应用程序通常会在每次迭代中进行一些线程本地工作。
   同步容器的数量并非越多越好。单线程情况下的性能与ConcurrentHashMap的性能基本相当,但当负载情况由非竞争性转变成竞争性时——这里是两个线程,同步容器的性能将变得糟糕。在伸缩性受到锁竞争限制的代码中,这是一种常见的行为。只要竞争程度不高,那么每个操作消耗的时间基本上就是实际执行工作的时间,并且吞吐量会因为线程数的增加而增加。当竞争变得激烈时,每个操作消耗的时间大部分都用于上下文切换和调度延迟,而再加入更多的线程也不会提高太多的吞吐量。

11.6 减少上下文切换的开销

在许多任务中都包含一些可能被阻塞的操作。当任务在运行和阻塞这两个状态之间转换时,就相当于一次上下文切换。在服务器应用程序中,发生阻塞的原因之一就是在处理请求时产生各种日志消息。为了说明如何通过减少上下文切换的次数来提高吞吐量,我们将对两种日志方法的调度行为进行分析。
   在大多数日志框架中都是简单地对println进行包装,当需要记录某个消息时,只需将其写入日志文件中。在第7章的LogWriter中给出了另一种方法:记录日志的工作由一个专门的后台线程完成,而不是由发出请求的线程完成。从开发人员的角度来看,这两种方法基本上是相同的。但二者在性能上可能存在一些差异,这取决于日志操作的工作量,即有多少线程正在记录日志,以及其他一些因素,例如上下文切换的开销等。【如果日志模块将I/O操作从发出请求的线程转移到另一个线程,那么通常可以提高性能,但也会引入更多的设计复杂性,例如中断(当一个在日志操作中阻塞的线程被中断,将出现什么情况)、服务担保(日志模块能否保证队列中的日志消息都能在服务结束之前记录到日志文件)、饱和策略(当日志消息的产生速度比日志模块的处理速度更快时,将出现什么情况),以及服务生命周期(如何关闭日志模块,以及如何将服务状态通知给生产者)。】
   日志操作的服务时间包括与I/O流类相关的计算时间,如果I/O操作被阻塞,那么还会包括线程被阻塞的时间。操作系统将这个被阻塞的线程从调度队列中移走并直到I/O操作结束,这将比实际阻塞的时间更长。当I/O操作结束时,可能有其他线程正在执行它们的调度时间片,并且在调度队列中有些线程位于被阻塞线程之前,从而进一步增加服务时间。如果有多个线程在同时记录日志,那么还可能在输出流的锁上发生竞争,这种情况的结果与阻塞I/O的情况一样——线程被阻塞并等待锁,然后被线程调度器交换出去。在这种日志操作中包含了I/O操作和加锁操作,从而导致上下文切换次数的增多,以及服务时间的增加。
   请求服务的时间不应该过长,主要有以下原因。首先,服务时间将影响服务质量:服务时间越长,就意味着有程序在获得结果时需要等待更长的时间。但更重要的是,服务时间越长,也就意味着存在越多的锁竞争。11.4.1节中的“快进快出”原则告诉我们,锁被持有的时间应该尽可能地短,因为锁的持有时间越长,那么在这个锁上发生竞争的可能性就越大。如果一个线程由于等待I/O操作完成而被阻塞,同时它还持有一个锁,那么在这期间很可能会有另一个线程想要获得这个锁。如果在大多数的锁获取操作上不存在竞争,那么并发系统就能执行得更好,因为在锁获取操作上发生竞争时将导致更多的上下文切换。在代码中造成的上下文切换次数越多,吞吐量就越低。
   通过将I/O操作从处理请求的线程中分离出来,可以缩短处理请求的平均服务时间。调用log方法的线程将不会再因为等待输出流的锁或者I/O完成而被阻塞,它们只需将消息放入队列,然后就返回到各自的任务中。另一方面,虽然在消息队列上可能会发生竞争,但put操作相对于记录日志的I/O操作(可能需要执行系统调用)是一种更为轻量级的操作,因此在实际使用中发生阻塞的概率更小(只要队列没有填满)。由于发出日志请求的线程现在被阻塞的概率降低,因此该线程在处理请求时被交换出去的概率也会降低。我们所做的工作就是把一条包含I/O操作和锁竞争的复杂且不确定的代码路径变成一条简单的代码路径。
   从某种意义上讲,我们只是将工作分散开来,并将I/O操作移到了另一个用户感知不到开销的线程上(这本身已经获得了成功)。通过把所有记录日志的I/O转移到一个线程,还消除了输出流上的竞争,因此又去掉了一个竞争来源。这将提升整体的吞吐量,因为在调度中消耗的资源更少,上下文切换次数更少,并且锁的管理也更简单。
   通过把I/O操作从处理请求的线程转移到一个专门的线程,类似于两种不同救火方案之间的差异:第一种方案是所有人排成一队,通过传递水桶来救火;第二种方案是每个人都拿着一个水桶去救火。在第二种方案中,每个人都可能在水源和着火点上存在更大的竞争(结果导致了只能将更少的水传递到着火点),此外救火的效率也更低,因为每个人都在不停的切换模式(装水、跑步、倒水、跑步……)。在第一种解决方案中,水不断地从水源传递到燃烧的建筑物,人们付出更少的体力却传递了更多的水,并且每个人从头至尾只需做一项工作。正如中断会干扰人们的工作并降低效率,阻塞和上下文切换同样会干扰线程的正常执行。

小结

由于使用线程常常是为了充分利用多个处理器的计算能力,因此在并发程序性能的讨论中,通常更多地将侧重点放在吞吐量和可伸缩性上,而不是服务时间。Amdahl定律告诉我们,程序的可伸缩性取决于在所有代码中必须被串行执行的代码比例。因为Java程序中串行操作的主要来源是独占方式的资源锁,因此通常可以通过以下方式来提升可伸缩性:减少锁的持有时间,降低锁的粒度,以及采用非独占的锁或非阻塞锁来代替独占锁。

第12章 并发程序的测试

在编写并发程序时,可以采用与编写串行程序时相同的设计原则与设计模式。二者的差异在于,并发程序存在一定程度的不确定性,而在串行程序中不存在这个问题。这种不确定性将增加不同交互模式以及故障模式的数量,因此在设计并发程序时必须对这些模式进行分析。
   同样,在测试并发程序时,将使用并扩展许多在测试串行程序时用到的方法。在测试串行程序正确性与性能等方面所采用的技术,同样可以用于测试并发程序,但对于并发程序而言,可能出错的地方远比串行程序多。在测试并发程序时,所面临的主要挑战在于:潜在错误的发生并不具有确定性,而是随机的。要在测试中将这些故障暴露出来,就需要比普通的串行程序测试覆盖更广的范围并且执行更长的时间。
   并发测试大致分为两类,即安全性测试与活跃性测试。在第1章,我们将安全性定义为“不发生任何错误的行为”,而将活跃性定义为“某个良好的行为终究会发生”。
   在进行安全性测试时,通常会采用测试不变性条件的形式,即判断某个类的行为是否与其规范保持一致。例如,假设有一个链表,在它每次被修改时把其大小缓存下来,那其中一项安全性测试就是比较在缓存中保存的大小值与链表中实际元素的数目是否相等。这种测试在单线程程序中很简单,因为在测试时链表的内容不会发生变化。但在并发程序中,这种测试将可能由于竞争而失败,除非能将访问计数器的操作和统计元素数据的操作合并为单个原子操作。要实现这一点,可以对链表加锁以实现独占访问,然后采用链表中提供的某种“原子快照”功能,或者在某些“测试点(Test Point)”上采用原子方式来判断不变性条件或者执行测试代码。
   在本书中,我们曾通过执行时序图来说明“错误的”交互操作,这些操作将在未被正确构造的类中导致各种故障,而测试程序将努力在足够大的状态空间中查找出错的地方。然而,测试代码同样会对执行时序或同步操作带来影响,这些影响可能会掩盖一些本可以暴露的错误。【这些再增加了调试代码或测试代码后消失的错误称为“海森堡错误”(Heisenbugs,来源于“韩森堡测不准原理”)】
   测试活跃性本身也存在问题。活跃性测试包括进展测试和无进展测试两方面,这些都是很难量化的——如何验证某个方法是被阻塞了,而不只是运行缓慢?同样,如何测试某个算法不会发生死锁?要等待多久才能宣告它发生了故障?
   与活跃性测试相关的是性能测试。性能可以通过多个方面来衡量,包括:
   吞吐量:指一组并发任务中已完成任务所占的比例。
   响应性:指请求从发出到完成之间的时间(也称为延迟)。
   可伸缩性:指在增加更多资源的情况下(通常指CPU),吞吐量(或者缓解短缺)的提升情况。

12.1 正确性测试

在为某个并发类设计单元测试时,首先需要执行与测试串行类时相同的分析——找出需要检查的不变性条件和后验条件。幸运的话,在类的规范中将给出其中大部分的条件,而在剩下的时间里,当编写测试时将不断地发现新的规范。
   为了进一步说明,接下来我们将构建一组测试用例来测试一个有界缓存。程序清单12-1给出了BoundedBuffer的实现,其中使用Semaphore来实现缓存的有界属性和阻塞行为。
   程序清单12-1 基于信号量的有界缓存

@ThreadSafe
public class BoundedBuffer<E> {private final Semaphore availableItems, availableSpaces;@GuardedBy("this") private final E[] items;@GuardedBy("this") private int putPosition = 0, takePosition = 0;public BoundedBuffer(int capacity) {availableItems = new Semaphore(0);availableSpaces = new Semaphore(capacity);items = (E[]) new Object[capacity];}public boolean isEmpty() { return availableItems.availablePermits() == 0; }public boolean isFull() { return availableSpaces.availablePermits() == 0; }public void put(E x) throws InterruptedException {availableSpaces.acquire();doInsert(x);availableItems.release();}public E take() throws InterruptedException {availableItems.acquire();E item = doExtract();availableSpaces.release();return item;}public synchronized void doInsert(E x) {int i = putPosition;items[i] = x;putPosition = (++i == items.length) ? 0 : i;}public synchronized E doExtract() {int i = takePosition;E x = items[i];items[i] = null;takePosition = (++i == items.length) ? 0 : i;return x;}
}

BoundedBuffer实现了一个固定长度的队列,其中定义了可阻塞的put和take方法,并通过两个计数信号量进行控制。信号量availableItems表示可以从缓存中删除的元素个数,它的初始值为零(因为缓存的初始状态为空)。同样,信号量availableSpaces表示可以插入到缓存的元素个数,它的初始值等于缓存的大小。
   take操作首先请求从availableItems中获得一个许可(Permit)。如果缓存不为空,那么这个请求会立即成功,否则请求将被阻塞直到缓存不为空。在获得了一个许可后,take方法将删除缓存中的下一个元素,并返回一个许可到availableSpaces信号量【在计数信号量中,通常不会显式地用一个所有者线程来表示许可,或者将其与所有者线程关联起来。release方法将创建一个许可,而acquire方法将消耗一个许可。】。put方法的执行顺序刚好相反,因此无论是从put方法还是从take方法中退出,这两个信号量计数值的加和都会等于缓存的大小。(在实际情况中,如果需要一个有界缓存,应该直接使用ArrayBlockingQueue或LinkedBlockingQueue,而不是自己编写,但这里用于说明如何对添加和删除等方法进行控制的技术,在其他数据结构中同样可以使用。)

12.1.1 基本的单元测试

BoundedBuffer的最基本单元测试类似于在串行上下文中执行的测试。首先创建一个有界缓存,然后调用它的各个方法,并验证它的后验条件和不变性条件。我们很快会想到一些不变性条件:新建立的缓存应该是空的,而不是满的。另一个略显复杂的安全测试是,将N个元素插入到容量为N的缓存中(这个过程应该可以成功,并且不会阻塞),然后测试缓存是否已经填满(不为空)。程序清单12-2给出了这些属性的JUnit测试方法。
   程序清单12-2 BoundedBuffer的基本单元测试

class BoundedBufferTest extends TestCase {void testIsEmptyWhenConstructed() {BoundedBuffer<Integer> bb = new BoundedBuffer<>(10);assertTrue(bb.isEmpty());assertFalse(bb.isFull());}void testIsFullAfterPuts() throws InterruptedException {BoundedBuffer<Integer> bb = new BoundedBuffer<>(10);for (int i = 0; i < 10; i++) {bb.put(i);  }  assertTrue(bb.isFull());assertFalse(bb.isEmpty());}
}

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

12.1.2 对阻塞操作的测试

在测试并发的基本属性时,需要引入多个线程。大多数测试框架并不能很好地支持并发性测试:它们很少会包含相应的工具来创建线程或监视线程,以确保它们不会意外结束。如果在某个测试用例创建的辅助线程中发现了一个错误,那么框架通常无法得知与这个线程相关的是哪一个测试,所以需要通过一些工作将成功或失败信息传递回主测试线程,从而才能将相应的信息报告出来。
   在java.util.concurrent的一致性测试中,一定要将各种故障与特定的测试明确地关联起来。因此JSR166专家组创建了一个基类【http://gee.cs.oswego.edu/cgi-bin/viewcvs.cgi/jsr166/src/test/tck/JSR166TestCase.java】,其中定义了一些方法可以在tearDown期间传递和报告失败信息,并遵循一个约定:每个测试必须等待它所创建的全部线程结束以后才能完成。你不需要考虑这么深入,关键的需求在于,能否通过这些测试,以及是否在某个地方报告了失败信息以便用于诊断问题。
   如果某方法需要在某些特定条件下阻塞,那么当测试这种行为时,只有当线程不再继续执行时,测试才是成功的。要测试一个方法的阻塞行为,类似于测试一个抛出异常的方法:如果这个方法可以正常返回,那么就意味着测试失败。
   在测试方法的阻塞行为时,将引入额外的复杂性:当方法被成功地阻塞后,还必须使方法解除阻塞。实现这个功能的一种简单方式就是使用中断——在一个单独的线程中启动一个阻塞操作,等到线程阻塞后再中断它,然后宣告阻塞操作成功。当然,这要求阻塞方法通过提前返回或者抛出InterruptedException来响应中断。
   “等待并直到线程阻塞后”这句话说起来简单,做起来难。实际上,你必须估计执行这些指令可能需要多长的时间,并且等待的时间会更长。如果估计的时间不准确(在这种情况下,你会看到伪测试失败),那么应该增大这个值。
   程序清单12-3给出了一种测试阻塞操作的方法。这种方法会创建一个“获取”线程,该线程将尝试从空缓存中获取一个元素。如果take方法成功,那么表示测试失败。执行测试的线程启动“获取”线程,等待一段时间,然后中断该线程。如果“获取”线程正确地在take方法中阻塞,那么将抛出InterruptedException,而捕获到这个异常的catch块将把这个异常视为测试成功,并让线程退出。然后,主测试线程会尝试与“获取”线程合并,通过调用Thread.isAlive来验证join方法是否成功返回,如果“获取”线程可以响应中断,那么join能很快地完成。
   程序清单12-3 测试阻塞行为以及对中断的响应性

void testTakeBlocksWhenEmpty() {final BoundedBuffer<Integer> bb = new BoundedBuffer<>(10);Thread taker = new Thread() {@Override public void run() {try {int unused = bb.take();fail(); // 如果执行到这里,那么表示出现了一个错误} catch (InterruptedException success) { }}};try {taker.start();Thread.sleep(LOCKUP_DETECT_TIMEOUT);taker.interrupt();taker.join(LOCKUP_DETECT_TIMEOUT);assertFalse(taker.isAlive());} catch (Exception ex) {fail();}
}

如果take操作由于某种意料之外的原因停滞了,那么支持限时的join方法能确保测试最终完成。这个测试方法测试了take的多种属性——不仅能阻塞,而且在中断后还能抛出InterruptedException。在这种情况下,最好是对Thread进行子类化而不是使用线程池中的Runnable,即通过join来正确地结束测试。当主线程将一个元素放入队列后,“获取”线程应该解除阻塞状态,要测试这种行为,可以使用相同的方法。
   开发人员会尝试使用Thread.getState来验证线程能否在一个条件等待上阻塞,但这种方法并不可靠。被阻塞线程并不需要进入WAITING或TIMED_WAITING等状态,因此JVM可以选择通过自旋等待来实现阻塞。类似地,由于在Object.wait或Condition.await等方法上存在伪唤醒(SpuriousWakeup,请参见第14章),因此,即使一个线程等待的条件尚未成真,也可能从WAITING或TIMED_WAITING等状态临时性地转换到RUNNABLE状态。即使忽略这些不同实现之间的差异,目标线程在进入阻塞状态时也会消耗一定的时间。Thread.getState的返回结果不能用于并发控制,它将限制测试的有效性——其主要作用还是作为调试信息的来源。

12.1.3 安全性测试

程序清单12-2和程序清单12-3的测试用例测试了有界缓存的一些重要属性,但它们却无法发现由于数据竞争而引发的错误。要想测试一个并发类在不可预测的并发访问情况下能否正确执行,需要创建多个线程来分别执行put和take操作,并在执行一段时间后判断在测试中是否会出现问题。
   如果要构造一些测试来发现并发类中的安全性错误,那么这实际上是一个“先有蛋还是先有鸡”的问题:测试程序自身就是并发程序。要开发一个良好的并发测试程序,或许比开发这些程序要测试的类更加困难。
   在构建对并发类的安全性测试中,需要解决的关键问题在于,要找出那些容易检查的属性,这些属性在发生错误的情况下极有可能失败,同时又不会使得错误检查代码人为地限制并发性。理想情况是,在测试属性中不需要任何同步机制。
   要测试在生产者-消费者模式中使用的类,一种有效的方法就是检查被放入队列中和从队列中取出的各个元素。这种方法的一种简单实现是,当元素被插入到队列时,同时将其插入到一个“影子”列表,当从队列中删除该元素时,同时也从“影子”列表中删除,然后在测试程序运行完以后判断“影子”列表是否为空。然而,这种方法可能会干扰测试线程的调度,因为在修改“影子”列表时需要同步,并可能会阻塞。
   一种更好的方法是,通过一个对顺序敏感的校验和计算函数来计算所有入列元素以及出列元素的校验和,并进行比较。如果二者相等,那么测试就是成功的。如果只有一个生产者将元素放入缓存,同时也只有一个消费者从中取出元素,那么这种方法能发挥最大的作用,因为它不仅能测试出是否取出了正确的元素,而且还能测试出元素被取出的顺序是否正确。
   如果要将这种方法扩展到多生产者-多消费者的情况,就需要一个对元素入列/出列顺序不敏感的校验和函数,从而在测试程序运行完以后,可以将多个校验和以不同的顺序组合起来。如果不是这样,多个线程就需要访问同一个共享的校验和变量,因此就需要同步,这将成为一个并发的瓶颈或者破坏测试的执行时序。(任何具备可交换性的操作,例如加法或XOR,都符合这些需求。)
   要确保测试程序能正确地测试所有要点,就一定不能让编译器可以预先猜测到校验和的值。使用连续的整数作为测试数据并不是一种好办法,因为得到的结果总是相同的,而一个智能的编译器通常可以预先计算出这个结果。
   要避免这个问题,应该采用随机方式生成的测试数据,但如果选择了一种不合适的随机数生成器(RNG, Random Number Generator),那么会对许多其他的测试造成影响。由于大多数随机数生成器类都是线程安全的,并且会带来额外的同步开销【有很多基准测试都是衡量RNG造成的并行瓶颈有多大,而许多开发人员或用户并不知道这些基准的存在。】,因此在随机数生成过程中,可能会在这些类与执行时序之间产生耦合关系。如果每个线程都拥有各自的RNG,那么这些RNG就可以不是线程安全的。
   与其使用一个通用的RNG,还不如使用一些简单的伪随机函数。你并不需要某种高质量的随机性,而只需要确保在不同的测试运行中都有不同的数字。程序清单12-4的xorShift函数(Marsaglia,2003)是最符合这个需求的随机数函数之一。该函数基于hashCode和nanoTime来生成随机数,所得的结果既是不可预测的,而且基本上每次运行都不同。
   程序清单12-4 适合在测试中使用的随机数生成器

static int xorShift(int y) {y^ = (y << 6);y^ = (y >>> 21);y^ = (y << 7);return y;
}

在程序清单12-5和程序清单12-6的PutTakeTest中启动了N个生产者线程来生成元素并把它们插入到队列,同时还启动了N个消费者线程从队列中取出元素。当元素进出队列时,每个线程都会更新对这些元素计算得到的校验和,每个线程都拥有一个校验和,并在测试结束后将它们合并起来,从而在测试缓存时就不会引入过多的同步或竞争。
   程序清单12-5 测试BoundedBuffer的生产者-消费者程序
//todo

12.1.4 资源管理的测试

到目前为止,所有的测试都侧重于类与它的设计规范的一致程度——在类中应该实现规范中定义的功能。测试的另一个方面就是要判断类中是否没有做它不应该做的事情,例如资源泄漏。对于任何持有或管理其他对象的对象,都应该在不需要这些对象时销毁对它们的引用。这种存储资源泄漏不仅会妨碍垃圾回收器回收内存(或者线程、文件句柄、套接字、数据库连接或其他有限资源),而且还会导致资源耗尽以及应用程序失败。
   对于像BoundedBuffer这样的类来说,资源管理的问题尤为重要。之所以要限制缓存的大小,其原因就是要防止由于资源耗尽而导致应用程序发生故障,例如生产者的速度远远高于消费者的处理速度。通过对缓存进行限制,将使得生产力过剩的生产者被阻塞,因而它们就不会继续创建更多的工作来消耗越来越多的内存以及其他资源。
   通过一些测量应用程序中内存使用情况的堆检查工具,可以很容易地测试出对内存的不合理占用,许多商业和开源的堆分析工具中都支持这种功能。在程序清单12-7的testLeak方法中包含了一些堆分析工具用于抓取堆的快照,这将强制执行一次垃圾回收【从技术上来看,我们无法强制执行一次垃圾回收,System.gc只是建议JVM在合适的时刻执行垃圾回收。通过使用-XX:+DisableExplicitGC,可以告诉HotSpot忽略System.gc调用。】,然后记录堆大小和内存用量的信息。
   程序清单12-7 测试资源泄漏

class Big {double[] data = new double[100000];
}
void testLeak() throws InterruptedException {BoundedBuffer<Big> bb = new BoundedBuffer<>(CAPACITY);int heapSize1=;//生成的堆快照for (int i = 0; i < CAPACITY; i++) {bb.put(new Big());}for (int i = 0; i < CAPACITY; i++) {bb.take();}int heapSize1=//生成堆的快照AssertTrue(Math.abs(heapSize1-heapSize2)<THRESHOLD)
}

testLeak方法将多个大型对象插入到一个有界缓存中,然后再将它们移除。第2个堆快照中的内存用量应该与第1个堆快照中的内存用量基本相同。然而,doExtract如果忘记将返回元素的引用置为空(items[i]=null),那么在两次快照中报告的内存用量将明显不同。(这是为数不多几种需要显式地将变量置空的情况之一。在大多数情况下,这种做法不仅不会带来帮助,甚至还会带来负面作用[EJItem 5]。)

12.1.5 使用回调

在构造测试案例时,对客户提供的代码进行回调是非常有帮助的。回调函数的执行通常是在对象生命周期的一些已知位置上,并且在这些位置上非常适合判断不变性条件是否被破坏。例如,在ThreadPoolExecutor中将调用任务的Runnable和ThreadFactory。
   在测试线程池时,需要测试执行策略的多个方面:在需要更多的线程时创建新线程,在不需要时不创建,以及当需要回收空闲线程时执行回收操作等。要构造一个全面的测试方案是很困难的,但其中许多方面的测试都可以单独进行。
   通过使用自定义的线程工厂,可以对线程的创建过程进行控制。在程序清单12-8的TestingThreadFactory中将记录已创建线程的数量。这样,在测试过程中,测试方案可以验证已创建线程的数量。我们还可以对TestingThreadFactory进行扩展,使其返回一个自定义的Thread,并且该对象可以记录自己在何时结束,从而在测试方案中验证线程在被回收时是否与执行策略一致。
   程序清单12-8 测试THReadPoolExecutor的线程工厂类

class TestingThreadFactory implements ThreadFactory {public final AtomicInteger numCreated = new AtomicInteger();private final ThreadFactory factory = Executors.defaultThreadFactory();@Overridepublic Thread newThread(Runnable r) {numCreated.incrementAndGet();return factory.newThread(r);}
}

如果线程池的基本大小小于最大大小,那么线程池会根据执行需求相应增长。当把一些运行时间较长的任务提交给线程池时,线程池中的任务数量在长时间内都不会变化,这就可以进行一些判断,例如测试线程池是否能按照预期的方式扩展,如程序清单12-9所示。
   程序清单12-9 验证线程池扩展能力的测试方法

class TestingThreadFactory implements ThreadFactory {public final AtomicInteger numCreated = new AtomicInteger();private final ThreadFactory factory = Executors.defaultThreadFactory();@Overridepublic Thread newThread(Runnable r) {numCreated.incrementAndGet();return factory.newThread(r);}public void testPoolExpansion() throws InterruptedException {int MAX_SIZE = 10;ExecutorService exec = Executors.newFixedThreadPool(MAX_SIZE);for (int i = 0; i < 10 * MAX_SIZE; i++) {exec.execute(new Runnable() {@Overridepublic void run() {try {Thread.sleep(Long.MAX_VALUE);} catch (InterruptedException ex) {Thread.currentThread().interrupt();}}});}for (int i = 0; i < 20 & threadFactory.numCreated.get() < MAX_SIZE; i++) {Thread.sleep(100);assertEquals(hreadFactory.numCreated.get(),MAX_SIZE);exec.shutdownNow();}}
}

12.1.6 产生更多的交替操作

由于并发代码中的大多数错误都是一些低概率事件,因此在测试并发错误时需要反复地执行许多次,但有些方法可以提高发现这些错误的概率。在前面提到过,在多处理器系统上,如果处理器的数量少于活动线程的数量,那么与单处理器系统或者包含多个处理器的系统相比,将能产生更多的交替行为。同样,如果在不同的处理器数量、操作系统以及处理器架构的系统上进行测试,就可以发现那些在特定运行环境中才会出现的问题。
   有一种有用的方法可以提高交替操作的数量,以便能更有效地搜索程序的状态空间:在访问共享状态的操作中,使用Thread.yield将产生更多的上下文切换。(这项技术的有效性与具体的平台相关,因为JVM可以将Thread.yield作为一个空操作(no-op)[JLS 17.9]。如果使用一个睡眠时间较短的sleep,那么虽然更慢些,但却更可靠。)程序清单12-10中的方法在两个账户之间执行转账操作,在两次更新操作之间,像“所有账户的总和应等于零”这样的一些不变性条件可能会被破坏。当代码在访问状态时没有使用足够的同步,将存在一些对执行时序敏感的错误,通过在某个操作的执行过程中调用yield方法,可以将这些错误暴露出来。这种方法需要在测试中添加一些调用并且在正式产品中删除这些调用,这将给开发人员带来不便,通过使用面向方面编程(Aspect-Oriented Programming, AOP)的工具,可以降低这种不便性。
   程序清单12-10 使用Thread.yield来产生更多的交替操作

public synchronized void transferCredits(Account from,Account to,int amount) {from.setBalance(form.getBalance() - amount);if (random.nextInt(1000) > THRESHOLD) {Thread.yield();}to.setBalance(to.getBalance() + amount);
}

12.2 性能测试

性能测试通常是功能测试的延伸。事实上,在性能测试中应该包含一些基本的功能测试,从而确保不会对错误的代码进行性能测试。
   虽然在性能测试与功能测试之间肯定会存在重叠之处,但它们的目标是不同的。性能测试将衡量典型测试用例中的端到端性能。通常,要获得一组合理的使用场景并不容易,理想情况下,在测试中应该反映出被测试对象在应用程序中的实际用法。
   在某些情况下,也存在某种显而易见的测试场景。在生产者-消费者设计中通常都会用到有界缓存,因此显然需要测试生产者在向消费者提供数据时的吞吐量。对PutTakeTest进行扩展,使其成为针对该使用场景的性能测试。
   性能测试的第二个目标是根据经验值来调整各种不同的限值,例如线程数量、缓存容量等。这些限值可能依赖于具体平台的特性(例如,处理器的类型、处理器的步进级别(Stepping Level)、CPU的数量或内存大小等),因此需要动态地进行配置,而我们通常需要合理地选择这些值,从而使程序能够在更多的系统上良好地运行。

12.2.1 在PutTakeTest中增加计时功能

之前对PutTakeTest的主要扩展就是测量运行一次所需的时间。现在,我们不测量单个操作的时间,而是实现一种更精确的测量方式:记录整个运行过程的时间,然后除以总操作的数量,从而得到每次操作的运行时间。之前使用了CyclicBarrier来启动和结束工作者线程,因此可以对其进行扩展:使用一个栅栏动作来测量启动和结束时间,如程序清单12-11所示。
   程序清单12-11 基于栅栏的定时器

this.timer = new BarrierTimer();this.barrier = new CyclicBarrier(npairs*2+1,timer);public class BarierTiemer implements Runnable {private boolean started;private long startTime, endTime;public synchronized void run() {long t = System.nanoTime();if (!started) {started = true; started = t;} else {endTime = t;}}public synchronized void clear() {started = false;}public synchronized long getTime() {return endTime - startTime;  }}

我们可以将栅栏的初始化过程修改为使用这种栅栏动作,即使用能接受栅栏动作的CyclicBarrier构造函数。
   在修改后的test方法中使用了基于栅栏的计时器,如程序清单12-12所示。
   程序清单12-12 采用基于栅栏的定时器进行测试

public void test() {try {timer.clear();for (int i = 0; i < nPairs; i++) {pool.execute(new Producer());pool.execute(new Consumer());}long nsPerItem = tiemr.getTime() / (nPairs*(long)nTrials);System.out.println("Throughput:"+nsPerItem+"ns/item");assertEquals(putSum.get(), taskSum.get());} catch (Exception ex) {throw new RuntimeException();}    }

我们可以从TimedPutTakeTest的运行中学到一些东西。第一,生产者-消费者模式在不同参数组合下的吞吐率。第二,有界缓存在不同线程数量下的可伸缩性。第三,如何选择缓存的大小。要回答这些问题,需要对不同的参数组合进行测试,因此我们需要一个主测试驱动程序,如程序清单12-13所示。
   程序清单12-13 使用TimedPutTakeTest的程序

 public static void main(String[] args) {int tnp = 100000; // 每个线程中的测试次数for (int cap = 1; cap < 1000; cap*=10) {System.out.println("Capacity:"+cap);for (int pairs = 0; pairs < 128; pairs*=2) {TimedputTaskTest t = new TimedPutTaskTest(cap,pairs,tpt);System.out.println("Pairs+"+pairs+"\t");t.test();System.out.println("\t");Thread.sleep(1000);t.test);System.out.println();Thread.sleep(1000);}}pool.shutdown();}

图12-1给出了在4路机器上的一些测试结果,缓存的容量分别为1、10、100和1000。我们可以看到,当缓存大小为l时,吞吐率是非常糟糕的,这是因为每个线程在阻塞并且等待另一个线程之前,所取得的进展是非常有限的。当把缓存大小提高到10时,吞吐率得到了极大提高:但在超过10之后,所得到的收益又开始降低。

   初看起来会感到有些困惑:当增加更多的线程时,性能却略有下降。其中的原因很难从数据中看出来,但可以在运行测试时使用CPU的性能工具(例如perfbar):虽然有许多的线程,但却没有足够多的计算量,并且大部分时间都消耗在线程的阻塞和解除阻塞等操作上。线程有足够多的CPU空闲时钟周期来做相同的事情,因此不会过多地降低性能。
   然而,要谨慎对待从上面数据中得出的结论,即在使用有界缓存的生产者-消费者程序中总是可以添加更多的线程。这个测试在模拟应用程序时忽略了许多实际的因素,例如生产者几乎不需要任何工作就可以生成一个元素并将它放入队列,同时消费者也无须太多工作就能获取一个元素。在真实的生产者-消费者应用程序中,如果工作者线程要通过执行一些复杂的操作来生产和获取各个元素条目(通常就是这种情况),那么之前那种CPU空闲状态将消失,并且由于线程过多而导致的影响将变得非常明显。这个测试的主要目的是,测量生产者和消费者在通过有界缓存传递数据时,哪些约束条件将对整体吞吐量产生影响。

12.2.2 多种算法的比较

虽然BoundedBuffer是一种非常合理的实现,并且它的执行性能也不错,但还是没有ArrayBlockingQueue或LinkedBlockingQueue那样好(这也解释了为什么这种缓存算法没有被选入类库中)。java.util.concurrent中的算法已经通过类似的测试进行了调优,其性能也已经达到我们已知的最佳状态。此外,这些算法还能提供更多的功能【如果你是一个并发专家,并且放弃一些已提供的功能,那么就可能超过它们的性能。】BoundedBuffer运行效率不高的主要原因是:在put和take方法中都含有多个可能发生竞争的操作,例如,获取一个信号量,获取一个锁,以及释放信号量等。在其他实现方法中,可能发生竞争的位置将少很多。
   图12-2给出了一个在双核超线程机器上对这三个类的吞吐量测试结果,在测试中使用了一个包含256个元素的缓存,以及相应版本的TimedPutTakeTest。测试结果表明,LinkedBlockingQueue的可伸缩性要高于ArrayBlockingQueue。初看起来,这个结果有些奇怪:链表队列在每次插入元素时,都必须分配一个链表节点对象,这似乎比基于数组的队列执行了更多的工作。然而,虽然它拥有更好的内存分配与GC等开销,但与基于数组的队列相比,链表队列的put和take等方法支持并发性更高的访问,因为一些优化后的链接队列算法能将队列头节点的更新操作与尾节点的更新操作分离开来。由于内存分配操作通常是线程本地的,因此如果算法能通过多执行一些内存分配操作来降低竞争程度,那么这种算法通常具有更高的可伸缩性。(这种情况再次证明了,基于传统性能调优的直觉与提升可伸缩性的实际需求是背道而驰的。)

12.2.3 响应性衡量

12.3 避免性能测试的陷阱

12.3.1 垃圾回收

12.3.2 动态编译

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

12.3.4 不真实的竞争程度

12.3.5 不用代码的消除

12.4 其他的测试方法

12.4.1 代码审查

12.4.2 静态分析工具

12.4.3 面向方面的测试技术

12.4.4 分析与检测工具

《Java并发编程实战》【第三部分 活跃性、性能与测试】相关推荐

  1. java并发编程(3)避免活跃性危险

    活跃性危险 一.死锁 发生:每个人都不愿意放弃自己的锁,确想要别人的锁,这就会导致死锁  1.锁顺序死锁:如果每个线程以固定的顺序获取锁,那么至少在程序中不会出现锁顺序导致的死锁: 因为顺序固定如:所 ...

  2. JAVA并发编程实战---第三章:对象的共享(2)

    线程封闭 如果仅仅在单线程内访问数据,就不需要同步,这种技术被称为线程封闭,它是实现线程安全性的最简单的方式之一.当某个对象封闭在一个线程中时,这种方法将自动实现线程安全性,即使被封闭的对象本生不是线 ...

  3. java并发编程实战-第三章-对象的共享

    3.1可见性 首先我们需要知道的是,java的线程都有自己独立的缓存,线程之间进行共享变量的交互是通过自身和缓存和主存的交互实现的. 如果线程的每次更改缓存都刷入主存,主存每次被一个线程的缓存修改,都 ...

  4. JAVA并发编程实战---第三章:对象的共享

    在没有同步的情况下,编译器.处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整.在缺乏足够同步的多线程程序中,要对内存操作的执行顺序进行判断几乎无法得到正确的结果. 非原子的64位操作 当 ...

  5. java并发编程实战wwj----------第三阶段-------------CompletableFuture---------------56-59

    代码: 我找的博客:https://www.cnblogs.com/happyliu/archive/2018/08/12/9462703.html 第二段代码: 这里设置的是守护线程. 代码: pa ...

  6. java并发编程实战wwj----------第三阶段-------------ConcurrentHashMap----------------73

    红黑树的总结:https://www.jianshu.com/p/5dbaa6707017 链表+数组+红黑树. 首先看下node的数据结构: static class Node<K,V> ...

  7. java并发编程实战wwj----------第三阶段-------------ConcurrentHashMap----------------72

    阻塞队列:多个线程操作的队列. JDK7:分成若干哥Segments. 是一个key和value数组. 原理:https://blog.csdn.net/it_dx/article/details/7 ...

  8. Java并发编程实战之互斥锁

    文章目录 Java并发编程实战之互斥锁 如何解决原子性问题? 锁模型 Java synchronized 关键字 Java synchronized 关键字 只能解决原子性问题? 如何正确使用Java ...

  9. Java并发编程实战————Executor框架与任务执行

    引言 本篇博客介绍通过"执行任务"的机制来设计应用程序时需要掌握的一些知识.所有的内容均提炼自<Java并发编程实战>中第六章的内容. 大多数并发应用程序都是围绕&qu ...

  10. 【极客时间】《Java并发编程实战》学习笔记

    目录: 开篇词 | 你为什么需要学习并发编程? 内容来源:开篇词 | 你为什么需要学习并发编程?-极客时间 例如,Java 里 synchronized.wait()/notify() 相关的知识很琐 ...

最新文章

  1. 13张图彻底搞懂分布式系统服务注册与发现原理
  2. 【Memcache】下载与安装
  3. elasticsearch 结构化搜索_在案例中实战基于range filter来进行范围过滤
  4. linux 进程创建 进程启动 监控
  5. Java与Python:哪一种最适合您? [信息图]
  6. Kettle7.1在window启动报错
  7. 图像拼接2 特征匹配
  8. html盒子自动居中,css盒子怎么居中?
  9. canvas绘制竖排的数字_大佬教你用Python Tkinter实现数字猜谜小游戏
  10. Unity飞机大战源码下载
  11. SDN 技术介绍整理 学习网站 - (持续更新)
  12. vue请求接口时报警告Provisional headers are shown
  13. 微信小程序,实现一个简易的新闻网
  14. Redis 客户端哪家强? Lettuce手下见真香!
  15. java rar解压
  16. 微信公众号文字该如何排版?
  17. RocketMQ PERM含义 PERM=7、6、4、2
  18. 【读书笔记】《曾国藩的正面与侧面(三)》
  19. [OPNET]license expired的处理
  20. Learning a Deep Single Image Contrast Enhancer from Multi-Exposure Images阅读札记

热门文章

  1. win10 能联网缺出现地球的原因及解决办法
  2. /sm/wx.php,出生日期查五行属性
  3. 怎么在视频上加字幕?有哪些软件推荐?这一款就足够
  4. 获取下拉框的文本和值,下拉框默认选中
  5. 希捷1T移动硬盘复制文件速度越来越慢
  6. 易经读书笔记09风天小畜
  7. 使用mysql日期函数查询上季度数据
  8. 基于movable-view的微信小程序拖拽排序(含源码)
  9. 算法创作|“石头剪刀布”问题解决方法
  10. shell快捷键总结