侧边栏壁纸
  • 累计撰写 57 篇文章
  • 累计创建 10 个标签
  • 累计收到 2 条评论

目 录CONTENT

文章目录

3、活跃性、性能与测试

yilee
2023-04-04 / 0 评论 / 0 点赞 / 51 阅读 / 0 字
温馨提示:
本文最后更新于2024-05-31,若内容或图片失效,请留言反馈。 部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

第三部分 活跃性、性能与测试

第十章 避免活跃性危险

10.1 死锁

​ 每个人都拥有其他人需要的资源,同时又等待其他人已经拥有的资源,并且每个人在获得所有需要的资源之前都不会放弃已经拥有的资源。

​ 当一个线程永远地持有一个锁,并且其他线程都尝试获得这个锁时,那么它们将永远被阻塞。在线程A 持有锁L 并想获得锁M 的同时,线程B 持有锁M 并尝试获得锁L, 那么这两个线程将永远地等待下去。这种情况就是最简单的死锁形式(或者称为“抱死[DeadlyEmbrace]”) ,其中多个线程由于存在环路的锁依赖关系而永远地等待下去。(把每个线程假想为有向图中的一个节点,图中每条边表示的关系是:“线程A 等待线程B 所占有的资源”。如果在图中形成了一条环路,那么就存在一个死锁。)

​ 在数据库系统的设计中考虑了监测死锁以及从死锁中恢复。在执行一个事务(Transaction)时可能需要获取多个锁,并一直持有这些锁直到事务提交。当它检测到一组事务发生了死锁时(通过在表示等待关系的有向图中搜索循环),将选择一个牺牲者并放弃这个事务。作为牺牲者的事务会释放它所持有的资源,从而使其他事务继续进行。应用程序可以重新执行被强行中止的事务,而这个事务现在可以成功完成,因为所有跟它竞争资源的事务都已经完成了。

​ JVM 在解决死锁问题方面并没有数据库服务那样强大。当一组Java 线程发生死锁时,“游戏”将到此结束-这些线程永远不能再使用了。根据线程完成工作的不同,可能造成应用程序完全停止,或者某个特定的子系统停止,或者是性能降低。恢复应用程序的唯一方式就是中止并重启它,并希望不要再发生同样的事情。

​ 与许多其他的并发危险一样,死锁造成的影响很少会立即显现出来。如果一个类可能发生死锁,那么并不意味着每次都会发生死锁,而只是表示有可能。当死锁出现时,往往是在最糟糕的时候一在高负载情况下。

10.1.1 锁顺序死锁

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

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

​ 在LeftRightDeadlock 中发生死锁的原因是:两个线程试图以不同的顺序来获得相同的锁。如果按照相同的顺序来请求锁,那么就不会出现循环的加锁依赖性,因此也就不会产生死锁。如果每个需要锁L 和锁M 的线程都以相同的顺序来获取L 和M, 那么就不会发生死锁了。

image-20221120225824973

如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。

10.1.2 动态的锁顺序死锁

​ 在制定锁的顺序时,可以使用System.identityHashCode 方法,该方法将返回由Object.hashCode 返回的值。

// 通过锁顺序来避免动态的锁顺序死锁
// 程序将资金从一个账户转入另一个账户
public class InduceLockOrder {
    private static final Object tieLock = new Object();

    public void transferMoney(final Account fromAcct,
                              final Account toAcct,
                              final DollarAmount amount)
            throws InsufficientFundsException {
        class Helper {
            public void transfer() throws InsufficientFundsException {
                if (fromAcct.getBalance().compareTo(amount) < 0)
                    throw new InsufficientFundsException();
                else {
                    fromAcct.debit(amount);
                    toAcct.credit(amount);
                }
            }
        }
        int fromHash = System.identityHashCode(fromAcct);
        int toHash = System.identityHashCode(toAcct);

        if (fromHash < toHash) {
            synchronized (fromAcct) {
                synchronized (toAcct) {
                    new Helper().transfer();
                }
            }
        } else if (fromHash > toHash) {
            synchronized (toAcct) {
                synchronized (fromAcct) {
                    new Helper().transfer();
                }
            }
        } else {
            synchronized (tieLock) {
                synchronized (fromAcct) {
                    synchronized (toAcct) {
                        new Helper().transfer();
                    }
                }
            }
        }
    }

    interface DollarAmount extends Comparable<DollarAmount> {
    }

    interface Account {
        void debit(DollarAmount d);

        void credit(DollarAmount d);

        DollarAmount getBalance();

        int getAcctNo();
    }

    class InsufficientFundsException extends Exception {
    }
}

​ 在极少数情况下,两个对象可能拥有相同的散列值,此时必须通过某种任意的方法来决定锁的顺序,而这可能又会重新引入死锁。为了避免这种情况,可以使用“加时赛(TieBreaking)"锁。在获得两个Account 锁之前,首先获得这个“加时赛”锁,从而保证每次只有一个线程以未知的顺序获得这两个锁,从而消除了死锁发生的可能性(只要一致地使用这种机制)。如果经常会出现散列冲突的情况,那么这种技术可能会成为并发性的一个瓶颈(这类似于在整个程序中只有一个锁的情况),但由于System.identityHashCode 中出现散列冲突的频率非常低,因此这项技术以最小的代价,换来了最大的安全性。

​ 如果在Account 中包含一个唯一的、不可变的,并且具备可比性的键值,例如账号,那么要制定锁的顺序就更加容易了:通过键值对对象进行排序,因而不需要使用“加时赛”锁。

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

​ 某些获取多个锁的操作并不像在LeftRightDeadlock 或transferMoney 中那么明显,这两个锁并不一定必须在同一个方法中被获取。

// Taxi 代表一个出租车对象,包含位置和目的地两个属性, Dispatcher 代表一个出租车车队。
public class CooperatingDeadlock {
    // Warning: deadlock-prone!
    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);
        }

        public synchronized Point getDestination() {
            return destination;
        }

        public synchronized void setDestination(Point destination) {
            this.destination = destination;
        }
    }

    class Dispatcher {
        @GuardedBy("this") private final Set<Taxi> taxis;
        @GuardedBy("this") private final Set<Taxi> availableTaxis;

        public Dispatcher() {
            taxis = new HashSet<Taxi>();
            availableTaxis = new HashSet<Taxi>();
        }

        public synchronized void notifyAvailable(Taxi taxi) {
            availableTaxis.add(taxi);
        }

        public synchronized Image getImage() {
            Image image = new Image();
            for (Taxi t : taxis)
                image.drawMarker(t.getLocation());
            return image;
        }
    }

    class Image {
        public void drawMarker(Point p) {
        }
    }
}

​ 因为setLocation 和notifyAvailable 都是同步方法,因此调用setLocation的线程将首先获取Taxi 的锁,然后获取Dispatcher 的锁。同样,调用getlmage 的线程将首先获取Dispatcher 锁,然后再获取每一个Taxi 的锁(每次获取一个)。这与LeftRightDeadlock 中的情况相同,两个线程按照不同的顺序来获取两个锁,因此就可能产生死锁。

​ 在LeftRightDeadlock 或transferMoney 中,要查找死锁是比较简单的,只需要找出那些需要获取两个锁的方法。然而要在Taxi 和Dispatcher 中查找死锁则比较困难:如果在持有锁的情况下调用某个外部方法,那么就需要警惕死锁。

​ 如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能会获取其他锁(这可能会产生死锁),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。

10.1.4 开放调用

​ 如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用。依赖于开放调用的类通常能表现出更好的行为,井且与那些在调用方法时需要持有锁的类相比,也更易于编写。这种通过开放调用来避免死锁的方法,类似于采用封装机制来提供线程安全的方法:虽然在没有封装的情况下也能确保构建线程安全的程序,但对一个使用了封装的程序进行线程安全分析,要比分析没有使用封装的程序容易得多。

​ 同理,分析一个完全依赖于开放调用的程序的活跃性,要比分析那些不依赖开放调用的程序的活跃性简单。通过尽可能地使用开放调用,将更易于找出那些需要获取多个锁的代码路径,因此也就更容易确保采用一致的顺序来获锁。

​ 将程序中的Taxi 和Dispatcher 修改为使用开放调用,从而消除发生死锁的风险。这需要使同步代码块仅被用于保护那些涉及共享状态的操作。

class CooperatingNoDeadlock {
    @ThreadSafe
    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) {
            boolean reachedDestination;
            synchronized (this) {
                this.location = location;
                reachedDestination = location.equals(destination);
            }
            if (reachedDestination)
                dispatcher.notifyAvailable(this);
        }

        public synchronized Point getDestination() {
            return destination;
        }

        public synchronized void setDestination(Point destination) {
            this.destination = destination;
        }
    }

    @ThreadSafe
    class Dispatcher {
        @GuardedBy("this") private final Set<Taxi> taxis;
        @GuardedBy("this") private final Set<Taxi> availableTaxis;

        public Dispatcher() {
            taxis = new HashSet<Taxi>();
            availableTaxis = new HashSet<Taxi>();
        }

        public synchronized void notifyAvailable(Taxi taxi) {
            availableTaxis.add(taxi);
        }

        public Image getImage() {
            Set<Taxi> copy;
            synchronized (this) {
                copy = new HashSet<Taxi>(taxis);
            }
            Image image = new Image();
            for (Taxi t : copy)
                image.drawMarker(t.getLocation());
            return image;
        }
    }

    class Image {
        public void drawMarker(Point p) {
        }
    }

}

​ 在程序中应尽量使用开放调用。与那些在持有锁时调用外部方法的程序相比,更易于对依赖于开放调用的程序进行死锁分析。

​ 有时候,在重新编写同步代码块以使用开放调用时会产生意想不到的结果,因为这会使得某个原子操作变为非原子操作。在许多情况下,使某个操作失去原子性是可以接受的。

​ 然而,在某些情况下,丢失原子性会引发错误,此时需要通过另一种技术来实现原子性。例如,在构造一个并发对象时,使得每次只有单个线程执行使用了开放调用的代码路径。

10.1.5 资源死锁

​ 正如当多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁,当它们在相同的资源集合上等待时,也会发生死锁。

​ 假设有两个资源池,例如两个不同数据库的连接池。资源池通常采用信号最来实现当资源池为空时的阻塞行为。如果一个任务需要连接两个数据库,并且在请求这两个资源时不会始终遵循相同的顺序,那么线程A 可能持有与数据库Dl 的连接,并等待与数据库见的连接,而线程B 则持有与队的连接并等待与D] 的连接。(资源池越大,出现这种情况的可能性就越小。)

​ 另一种基于资源的死锁形式就是线程饥饿死锁。一个任务提交另一个任务,并等待被提交任务在单线程的Executor 中执行完成。这种情况下,第一个任务将永远等待下去,并使得另一个任务以及在这个Executor中执行的所有其他任务都停止执行。如果某些任务需要等待其他任务的结果,那么这些任务往是产生线程饥饿死锁的主要来源,有界线程池I 资源池与相互依赖的任务不能一起使用。

10.2 死锁的避免与诊断

​ 如果一个程序每次至多只能获得一个锁,那么就不会产生锁顺序死锁当然,这种情况通常并不现实,但如果能够避免这种情况,那么就能省去很多工作。如果必须获取多个锁,那么在设计时必须考虑锁的顺序:尽量减少潜在的加锁交互数最,将获取锁时需要遵循的协议写入正式文档并始终遵循这些协议。

​ 在使用细粒度锁的程序中,可以通过使用一种两阶段策略(Two-Part Strategy) 来检查代码中的死锁:首先,找出在什么地方将获取多个锁(使这个集合尽量小),然后对所有这些实例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。尽可能地使用开放调用,这能极大地简化分析过程。如果所有的调用都是开放调用,那么要发现获取多个锁的实例是非常简单的,可以通过代码审查,或者借助自动化的源代码分析工具。

10.2.1 支持定时的锁

​ 还有一项技术可以检测死锁和从死锁中恢复过来,即显式使用Lock 类中的定时 tryLock 功能来代替内置锁机制。当使用内置锁时,只要没有获得锁,就会永远等待下去,而显式锁则可以指定一个超时时限(Timeout) ,在等待超过该时间后 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 只与获得它的线程相关联。

​ 当诊断死锁时, JVM 可以帮我们做许多工作一—哪些锁导致了这个问题,涉及哪些线程,它们持有哪些其他的锁,以及是否间接地给其他线程带来了不利影响。其中一个线程持有MumbleDBConnection 上的锁,并等待获得MumbleDBCallableStatement 上的锁,而另一个线程则持有MumbleDBCallableStatement 上的锁,并等待MumbleDBConnection 上的锁。

10.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, 这是因为该程序试图克服优先级调整问题或响应性问题,并试图让低优先级的线程执行更多的时间。

​ 要避免使用线程优先级 , 因为这会增加平台依赖性 , 并可能导致活跃问题。在大多数并发应用程序中,都可以使用默认的线裎优先级。

10.3.2 糟糕的响应性

​ 除饥饿以外的另一个问题是糟糕的响应性,如果在GUI 应用程序中使用了后台线程,那么这种问题是很常见的。在GUI中开发了一个框架,井把运行时间较长的任务放到后台线程中运行,从而不会使用户界面失去响应。但CPU 密集型的后台任务仍然可能对响应性造成影响,因为它们会与事件线程共同竞争CPU 的时钟周期。在这种情况下就可以发挥线程优先级的作用,此时计算密集型的后台任务将对响应性造成影响。如果由其他线程完成的工作都是后台任务,那么应该降低它们的优先级,从而提高前台程序的响应性。

​ 不良的锁管理也可能导致糟糕的响应性。如果某个线程长时间占有一个锁(或许正在对一个大容器进行迭代,并且对每个元素进行计算密集的处理),而其他想要访问这个容器的线程就必须等待很长时间。

10.3.3 活锁

​ 活锁(Livelock) 是另一种形式的活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,而且总会失败。活锁通常发生在处理事务消息的应用程序中:如果不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头。如果消息处理器在处理某种特定类型的消息时存在错误并导致它失败,那么每当这个消息从队列中取出井传递到存在错误的处理器时,都会发生事务回滚。由于这条消息又被放回到队列开头,因此处理器将被反复调用,并返回相同的结果。(有时候也被称为毒药消息, Poison Message 。)虽然处理消息的线程并没有阳塞,但也无法继续执行下去。这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误作为可修复的错误。

​ 当多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁。要解决这种活锁问题,需要在重试机制中引人随机性。在并发应用程序中,通过等待随机长度的时间和回退可以有效地避免活锁的发生。

小结

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

第十一章 性能与可伸缩性

​ 线程的最主要目的是提高程序的运行性能。线程可以使程序更加充分地发挥系统的可用处理能力,从而提高系统的资源利用率。此外,线程还可以使程序在运行现有任务的情况下立即开始处理新的任务,从而提高系统的响应性。

11.1 对性能的思考

​ 提升性能意味着用更少的资源做更多的事情。“资源”的含义很广。对于一个给定的操作,通常会缺乏某种特定的资源,例如CPU 时钟周期、内存、网络带宽、I/O 带宽、数据库请求、磁盘空间以及其他资源。当操作性能由于某种特定的资源而受到限制时,我们通常将该操作称为资源密集型的操作,例如, CPU 密集型、数据库密集型等。

​ 尽管使用多个线程的目标是提升整体性能,但与单线程的方法相比,使用多个线程总会引人一些额外的性能开销。造成这些开销的操作包括:线程之间的协调(例如加锁、触发信号以及内存同步等),增加的上下文切换,线程的创建和销毁,以及线程的调度等。如果过度地使用线程,那么这些开销甚至会超过由于提高吞吐量、响应性或者计算能力所带来的性能提升。另一方面,一个并发设计很糟糕的应用程序,其性能甚至比实现相同功能的串行程序的性能还要差。

​ 要想通过并发来获得更好的性能,需要努力做好两件事情:更有效地利用现有处理资源,以及在出现新的处理资源时使程序尽可能地利用这些新资源。从性能监视的视角来看, CPU要尽可能保持忙碌状态。(当然,这并不意味着将CPU 时钟周期浪费在一些无用的计算上,而是执行一些有用的工作。)如果程序是计算密集型的,那么可以通过增加处理器来提高性能。因为如果程序无法使现有的处理器保持忙碌状态,那么增加再多的处理器也无济于事。通过将应用程序分解到多个线程上执行,使得每个处理器都执行一些工作,从而使所有CPU 都保持忙碌状态。

11.1.1 性能与可伸缩性

​ 应用程序的性能可以采用多个指标来衡昼,例如服务时间、延迟时间、吞吐率、效率、可伸缩性以及容量等。其中一些指标(服务时间、等待时间)用于衡量程序的 “运行速度”,即某个指定的任务单元需要“多快”才能处理完成。另一些指标(生产量、吞吐量)用于程序的 “处理能力”,即在计算资源一定的情况下,能完成“多少“工作。

可伸缩性指的是:当增加计算资源时(列如CPU、内存、存储容量或I/O带宽),程序的吞吐量或者处理能力能相应的增加。

​ 在并发应用程序中针对可伸缩性进行设计和调整时所采用的方法与传统的性能调优方法截然不同。当进行性能调优时,其目的通常是用更小的代价完成相同的工作,例如通过缓存来重用之前计算的结果,或者采用时间复杂度为0(n2) 算法来代替复杂度为O(n log n) 的算法。在进行可伸缩性调优时,其目的是设法将问题的计算并行化,从而能利用更多的计算资源来完成更多的工作。

​ 性能的这两个方面——“多快” 和 “多少",是完全独立的,有时候甚至是相互矛盾的。要实现更高的可伸缩性或硬件利用率,通常会增加各个任务所要处理的工作量,例如把任务分解为多个“流水线”子任务时。具有讽刺意味的是,大多数提高单线程程序性能的技术,往往都会破坏可伸缩性。

​ 我们熟悉的三层程序模型,即在模型中的表现层、业务逻辑层和持久化层是彼此独立的,并且可能由不同的系统来处理,这很好地说明了提高可伸缩性通常会造成性能损失的原因。

​ 对于服务器应用程序来说,“多少”这个方面一—可伸缩性、吞吐量和生产最,往往比“多快”这个方面更受重视。(在交互式应用程序中,延迟或许更加重要,这样用户就不用等待进度条的指定,并奇怪程序究竟在执行哪些操作。)本章将重点介绍可伸缩性而不是单线程程序的性能。

11.1.2 评估各种性能权衡因素

​ “快速排序”算法在大规模数据集上的执行效率非常高,但对于小规模的数据集来说,“冒泡排序“实际上更高效。如果要实现一个高效的排序算法,那么需要知道被处理数据集的大小,还有衡最优化的指标,包括:平均计算时间、最差时间、可预知性。然而,编写某个库中排序算法的开发人员通常无法知道这些需求信息。这就是为什么大多数优化措施都不成熟的原因之一:它们通常无法获得一组明确的需求。

避免不成熟的化 。首先使程序正确 , 然后再提高运行速度——如果它还运行得不够快 。

​ 当进行决策时,有时候会通过增加某种形式的成本来降低另一种形式的开销(例如,增加内存使用益以降低服务时间),也会通过增加开销来换取安全性。

​ 在大多数性能决策中都包含有多个变釐,并且非常依赖于运行环境。

​ 在对性能的调优时,一定要有明确的性能需求(这样才能知道什么时候需要调优,以及什么时候应该停止),此外还需要一个测试程序以及真实的配置和负载等环境。在对性能调优后,你需要再次测量以验证是否到达了预期的性能提升目标。

以测试为基准,不要猜测。

11.2 Amdahl 定律

​ 在有些问题中,如果可用资源越多,那么问题的解决速度就越快。而有些任务本质上是串行的。如果使用线程主要是为了发挥多个处理器的处理能力,那么就必须对问题进行合理的井行分解,井使得程序能有效地使用这种潜在的并行能力。

​ Amdahl 定律描述的是:在增加计算资源的情况下,程序在理论上能够实现最高加速比,这个值取决于程序中可并行组件与串行组件所占的比重。

​ 应用程序WorkerThread 中 N 个线程正在执行程序中的工作, 这些线程从一个共享的工作队列中取出任务进行处理,而且这里的任务都不依赖于其他任务的执行结果或影响。

public class WorkerThread extends Thread {
    private final BlockingQueue<Runnable> queue;

    public WorkerThread(BlockingQueue<Runnable> queue) {
        this.queue = queue;
    }

    public void run() {
        while (true) {
            try {
                Runnable task = queue.take();
                task.run();
            } catch (InterruptedException e) {
                break; /* Allow thread to exit */
            }
        }
    }
}

​ 在这个过程中包含了一个串行部分一份队列中获取任务。所有工作者线程都共享同一个工作队列,因此在对该队列进行并发访问时需要采用某种同步机制来维持队列的完整性。如果通过加锁来保护队列的状态,那么当一个线程从队列中取出任务时,其他需要获取下一个任务的线程就必须等待,这就是任务处理过程中的串行部分。

​ 日志文件和结果容器都会由多个工作者线程共享,并且这也是一个串行部分。如果所有线程都将各自的计算结果保存到自行维护数据结构中,并且在所有任务都执行完成后再合并所有的结果,那么这种合并操作也是一个串行部分。

在所有并发程序中都包含一些串行部分。 如果你认为在你的程中不存在串行部分,那么可以再仔细检查一遍。

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

​ 要想知道串行部分是如何隐藏在应用程序的架构中,可以比较当增加线程时吞吐量的变化,并根据观察到的可伸缩性变化来推断串行部分中的差异。

​ 同步的LinkedList 采用单个锁来保护整个队列的状态,井且在offer 和remove 等方法的调用期间都将持有这个锁。ConcurrentLinkedQueue 使用了一种更复杂的非阻塞队列算法,该算法使用原子引用来更新各个链接指针。在第一个队列中,整个的插入或删除操作都将串行执行,而在第二个队列中,只有对指针的更新操作需要串行执行。

11.2.2 Amdahl 定律的应用

​ 如果能准确估计出执行过程中串行部分所占的比例,那么Amdahl 定律就能量化当有更多计算资源可用时的加速比。虽然要直接测量串行部分的比例非常困难,但即使在不进行测试的情况下Amdahl 定律仍然是有用的。

​ 因为我们的思维通常会受到周围环境的影响,因此很多人都会习惯性地认为在多处理器系统中会包含2 个或4 个处理器,甚至更多(如果得到足够大的预算批准),因为这种技术在近年来被广泛使用。但随着多核CPU 逐渐成为主流,系统可能拥有数百个甚至数于个处理器。一些在4 路系统中看似具有可伸缩性的算法,却可能含有一些隐藏的可伸缩性瓶颈,只是还没有遇到而巳。

​ 在评估一个算法时,要考虑算法在数百个或数于个处理器的情况下的性能表现,从而对可能出现的可伸缩性局限有一定程度的认识。

11.3 线程引入的开销

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

11.3.1 上下文切换

​ 如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可运行的线程数大于CPU 的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU 。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。

​ 切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和 JVM 共享的数据结构。但上下文切换的开销并不只是包含JVM 和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。

​ 当线程由于等待某个发生竞争的锁而被阻塞时, JVM 通常会将这个线程挂起,并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞),与CPU 密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。

11.3.2 内存同步

​ 同步操作的性能开销包括多个方面。在synchronized 和volatile 提供的可见性保证中可能会使用一些特殊指令,即内存栅栏(Memory Barrier) 。内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数操作都是不能被重排序的。

​ synchronized 机制针对无竞争的同步进行了优化(volatile 通常是非竞争的)。虽然无竞争同步的开销不为零,但它对应用程序整体性能的影响微乎其微,而另一种方法不仅会破坏安全性,而且还会使你(或者后续开发人员)经历非常痛苦的除错过程。

​ 现代的JVM 能通过优化来去掉一些不会发生竞争的锁,从而减少不必要的同步开销。如果一个锁对象只能由当前线程访问,那么JVM 就可以通过优化来去掉这个锁获取操作,因为另一个线程无法与当前线程在这个锁上发生同步。

​ 一些更完备的JVM 能通过逸出分析(Escape Analysis) 来找出不会发布到堆的本地对象引用(因此这个引用是线程本地的)。即使不进行逸出分析,编译器也可以执行锁粒度粗化(Lock Coarsening) 操作,即将邻近的同步代码块用同一个锁合并起来。

​ 不要过度妲心非竞争同步带来的开 。 这个基本的机制已经非常快了 , 并且 JVM 还能进行额外的优化以进一步降低或消除开销 。 因我们应该将优化重点放在那些发生锁争的地方 。

​ 某个线程中的同步可能会影响其他线程的性能。同步会增加共享内存总线上的通信最,总线的带宽是有限的,并且所有的处理器都将共享这条总线。如果有多个线程竞争同步带宽,那么所有使用了同步的线程都会受到影响。

11.3.3 阻塞

​ 非竞争的同步可以完全在JVM 中进行处理 ,而竞争的同步可能需要操作系统的介入,从而增加开销。当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM 在实现阻塞行为时,可以采用自旋等待(Spin-Waiting, 指通过循环不断地尝试获取锁,直到成功)或者通过操作系统挂起被阻塞的线程。

  • 如果等待时间较短,则适合采用自旋等待方式
  • 如果等待时间较长,则适合采用线程挂起方式。

​ 有些JVM 将根据对历史等待时间的分析数据在这两者之间进行选择,但是大多数JVM 在等待锁时都只是将线程挂起。
​ 当线程无法获取某个锁或者由于在某个条件等待或在 I/O 操作上阻塞时,需要被挂起,这个过程中将包含两次额外的上下文切换,以及所有必要的操作系统操作和缓存操作:被阻塞的线程在其执行时间片还未用完之前就被交换出去,而在随后当要获取的锁或者其他资源可用时,又再次被切换回来。(由于锁竞争而导致阻塞时,线程在持有锁时将存在一定的开销:当它释放锁时,必须告诉操作系统恢复运行阻塞的线程。)

11.4 减少锁的竞争

​ 串行操作会降低可伸缩性,并且上下文切换也会降低性能。在锁上发生竞争时将同时导致这两种问题,因此减少锁的竞争能够提高性能和可伸缩性。

在并发程序中,对可仲缩性的最主要威胁就是独占方式的资源锁。

​ 有两个因素将影响在锁上发生竞争的可能性:锁的请求频率,以及每次持有该锁的时间©。如果二者的乘积很小,那么大多数获取锁的操作都不会发生竞争,因此在该锁上的竞争不会对可伸缩性造成严重影响。然而,如果在锁上的请求量很高,那么需要获取该锁的线程将被阻塞并等待。在极端情况下,即使仍有大量工作等待完成,处理器也会被闲置。

有三种方式可以降低锁的竞争程序:

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

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

​ 降低发生竞争可能性的一种有效方式就是尽可能缩短锁的持有时间。例如,可以将一些与锁无关的代码移出同步代码块,尤其是那些开销较大的操作,以及可能被阻塞的操作,例如 I/O 操作。

​ 尽管缩小同步代码块能提高可伸缩性,但同步代码块也不能过小一些需要采用原子方式执行的操作(例如对某个不变性条件中的多个变盐进行更新)必须包含在一个同步块中。此外,同步需要一定的开销,当把一个同步代码块分解为多个同步代码块时(在确保正确性的情况下),反而会对性能提升产生负面影响。e在分解同步代码块时,理想的平衡点将与平台相关,但在实际情况中,仅当可以将一些“大最”的计算或阻塞操作从同步代码块中移出时,才应该考虑同步代码块的大小。

11.4.2 减小锁的粒度

​ 另一种减小锁的持有时间的方式是降低线程请求锁的频率(从而减小发生竞争的可能性)。这可以通过锁分解和锁分段等技术来实现,在这些技术中将采用多个相互独立的锁来保护独立的状态变量,从而改变这些变篮在之前由单个锁来保护的情况。这些技术能减小锁操作的粒度,并能实现更高的可伸缩性,然而,使用的锁越多,那么发生死锁的风险也就越高。

​ 如果一个锁需要保护多个相互独立的状态变量,那么可以将这个锁分解为多个锁,并且每个锁只保护一个变量,从而提高可伸缩性,并最终降低每个锁被请求的频率。

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

11.4.3 锁分段

​ 把一个竞争激烈的锁分解为两个锁时,这两个锁可能都存在激烈的竞争。虽然采用两个线程并发执行能提高一部分可伸缩性,但在一个拥有多个处理器的系统中,仍然无法给可伸缩性带来极大的提高。

​ 在某些情况下,可以将锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况被称为锁分段。例如,在ConcurrentHashMap 的实现中使用了一个包含16 个锁的数组,每个锁保护所有散列桶的 1/16 ,其中第N 个散列桶由第(N mod 16) 个锁来保护。假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来的l/16 。正是这项技术使得ConcurrentHashMap 能够支持多达16 个并发的写入器。(要使得拥有大最处理器的系统在高访问量的情况下实现更高的并发性,还可以进一步增加锁的数量,但仅当你能证明并发写入线程的竞争足够激烈并需要突破这个限制时,才能将锁分段的数量超过默认的16 个。)

​ 锁分段的一个劣势在于:与采用单个锁来实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。通常,在执行一个操作时最多只需获取一个锁,但在某些情况下需要加锁整个容器,例如当ConcurrentHashMap 需要扩展映射范围,以及重新计算键值的散列值要分布到更大的桶集合中时,就需要获取分段所集合中所有的锁。

11.4.4 避免热点域

​ 锁分解和锁分段技术都能提高可伸缩性,因为它们都能使不同的线程在不同的数据(或者同一个数据的不同部分)上操作,而不会相互干扰。如果程序采用锁分段技术,那么一定要表现出在锁上的竞争频率高于在锁保护的数据上发生竞争的频率。如果一个锁保护两个独立变簸X 和Y, 并且线程A 想要访问X, 而线程B 想要访问Y,那么这两个线程不会在任何数据上发生竞争,即使它们会在同一个锁上发生竞争。

​ 当每个操作都请求多个变世时,锁的粒度将很难降低。这是在性能与可伸缩性之间相互制衡的另一个方面,一些常见的优化措施,例如将一些反复计算的结果缓存起来,都会引入一些“热点域(Hot Field)", 而这些热点域往往会限制可伸缩性。

​ 当实现HashMap 时,你需要考虑如何在size 方法中计算Map 中的元素数量。最简单的方法就是,在每次调用时都统计一次元素的数量。一种常见的优化措施是,在插入和移除元素时更新一个计数器,虽然这在put 和remove 等方法中略微增加了一些开销,以确保计数器是最新的值,但这将把size 方法的开销从O(n) 降低到0(1)。

​ 在单线程或者采用完全同步的实现中,使用一个独立的计数能很好地提高类似size 和isEmpty 这些方法的执行速度,但却导致更难以提升实现的可伸缩性,因为每个修改map 的操作都需要更新这个共享的计数器。即使使用锁分段技术来实现散列链,那么在对计数器的访问进行同步时,也会重新导致在使用独占锁时存在的可伸缩性问题。一个看似性能优化的措施-缓存size 操作的结果, 已经变成了一个可伸缩性问题。在这种情况下,计数器也被称为热点域,因为每个导致元素数量发生变化的操作都需要访问它。

​ 为了避免这个问题, ConcurrentHashMap 中的size 将对每个分段进行枚举并将每个分段中的元素数量相加,而不是维护一个全局计数。为了避免枚举每个元素, ConcurrentHashMap 为每个分段都维护了一个独立的计数,并通过每个分段的锁来维护这个值。

11.4.5 一些替代独占锁的方法

​ 第三种降低竞争锁的影响的技术就是放弃使用独占锁,从而有助于使用一种友好并发的方式来管理共享状态。例如,使用并发容器、读-写锁、不可变对象以及原子变量。

ReadWriteLock 实现了一种在多个读取操作以及单个写入操作情况下的加锁规则:如果多个读取操作都不会修改共享资掠,那么这些读取操作可以同时访问该共享资源,但在执行写入操作时必须以独占方式来获取锁。对于读取操作占多数的数据结构,ReadWriteLock 能提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不变性可以完全不需要加锁操作。

原子变量 提供了一种方式来降低更新”热点域”时的开销,例如静态计数器、序列发生器、或者对链表数据结构中头节点的引用。原子变量类提供了在整数或者对象引用上的细粒度原子操作(因此可伸缩性更高),并使用了现代处理器中提供的底层并发原语(例如比较并交换[compare-and-swap] )。如果在类中只包含少量的热点域,并且这些域不会与其他变参与到不变性条件中,那么用原子变量来替代它们能提高可伸缩性。(通过减少算法中的热点域,可以提高可伸缩性一虽然原子变量能降低热点域的更新开销,但并不能完全消除。)

11.4.6 监测CPU 的利用率

​ 当测试可伸缩性时,通常要确保处理器得到充分利用。

​ 如果所有CPU 的利用率并不均匀(有些CPU 在忙碌地运行,而其他CPU 却并非如此),那么你的首要目标就是进一步找出程序中的并行性。不均匀的利用率表明大多数计算都是由小组线程完成的,并且应用程序没有利用其他的处理器。

​ 如果CPU 没有得到充分利用,那么需要找出其中的原因。通常有以下几种原因:

负载不充足。测试的程序中可能没有足够多的负载,因而可以在桐试时增加负载,并检查利用率、响应时间和服务时间等指标的变化。如果产生足够多的负载使应用程序达到饱和,那么可能需要大量的计算机能耗,并且问题可能在于客户端系统是否具有足够的能力,而不是被测试系统。

I/O 密集。可以通过iostat 或perfmon 来判断某个应用程序是否是磁盘I/O 密集型的,或者通过监测网络的通信流量级别来判断它是否需要高带宽。外部限制。如果应用程序依赖于外部服务,例如数据库或Web 服务,那么性能瓶颈可能并不在你自己的代码中。可以使用某个分析工具或数据库管理工具来判断在等待外部服务的结果时需要多少时间。

锁竞争。使用分析工具可以知道在程序中存在何种程度的锁竞争,以及在哪些锁上存在“激烈的竞争”。然而,也可以通过其他一些方式来获得相同的信息,例如随机取样,触发一些线程转储并在其中查找在锁上发生竞争的线程。

11.4.7 向对象池说“不“

​ 在 JVM 的早期版本中,对象分配和垃圾回收等操作的执行速度非常慢,但在后续的版本中,这些操作的性能得到了极大提高。事实上,现在Java 的分配操作已经比C 语言的malloc调用更快。

​ 为了解决“缓慢的“对象生命周期问题,许多开发人员都选择使用对象池技术,在对象池中,对象能被循环使用,而不是由垃圾收集器回收井在需要时重新分配。在单线程程序中(Click, 2005) ,尽管对象池技术能降低垃圾收集操作的开销,但对于高开销对象以外的其他对象来说,仍然存在性能缺失(对于轻量级和中量级的对象来说,这种损失将更为严重)。

​ 在并发应用程序中,对象池的表现更加糟糕。当线程分配新的对象时,基本上不需要在线程之间进行协调,因为对象分配器通常会使用线程本地的内存块,所以不需要在堆数据结构上进行同步。然而,如果这些线程从对象池中请求一个对象,那么就需要通过某种同步来协调对对象池数据结构的访问,从而可能使某个线程被阻塞。如果某个线程由于锁竞争而被阻塞,那么这种阻塞的开销将是内存分配操作开销的数百倍,因此即使对象池带来的竞争很小,也可能形成一个可伸缩性瓶颈。)虽然这看似是一种性能优化技术,但实际上却会导致可伸缩性问题。对象池有其特定的用途,但对于性能优化来说,用途是有限的。

通常,对象分配操作的开销比同步的开销更低。

11.5 示例:比较Map 的性能

​ 在单线程环境下, ConcurrentHashMap 的性能比同步的HashMap 的性能略好一些,但在并发环院中则要好得多。在ConcurrentHashMap 的实现中假设,大多数常用的操作邹是获取某个已经存在的值,因此它对各种get 操作进行了优化从而提供最高的性能和并发性。

​ 在同步Map 的实现中,可伸缩性的最主要阻碍在于整个Map 中只有一个锁,因此每次只有一个线程能够访问这个Map 。不同的是, ConcurrentHashMap 对于大多数读操作并不会加锁,并且在写人操作以及其他一些需要锁的读操作中使用了锁分段技术。因此,多个线程能并发地访问这个Map 而不会发生阻塞。

​ ConcurrentHashMap 和ConcurrentSkipListMap 的数据显示,它们在线程数盐增加时能表现出很好的可伸缩性,并且吞吐摄会随着线程数最的增加而增加。

​ 同步容器的数量并非越多越好。单线程情况下的性能与ConcurrentHashMap 的性能基本相当,但当负载情况由非竞争性转变成竞争性时一这里是两个线程,同步容器的性能将变得糟糕。在伸缩性受到锁竞争限制的代码中,这是一种常见的行为。只要竞争程度不高,那么每个操作消耗的时间基本上就是实际执行工作的时间,并且吞吐量会因为线程数的增加而增加。当竞争变得激烈时,每个操作消耗的时间大部分都用于上下文切换和调度延迟,而再加入更多的线程也不会提高太多的吞吐量。

11.6 减少上下文切换的开销

​ 在许多任务中都包含一些可能被阻塞的操作。当任务在运行和阻塞这两个状态之间转换时,就相当于一次上下文切换。在服务器应用程序中,发生阻塞的原因之一就是在处理请求时产生各种日志消息。

​ 在大多数日志框架中都是简单地对 println 进行包装,当需要记录某个消息时,只需将其写入日志文件中。在第7 章的LogWriter 中给出了另一种方法:记录日志的工作由一个专门的后台线程完成,而不是由发出请求的线程完成。从开发人员的角度来看,这两种方法基本上是相同的。但二者在性能上可能存在一些差异,这取决于日志操作的工作量,即有多少线程正在记录日志,以及其他一些因素,例如上下文切换的开销等。

​ 日志操作的服务时间包括与I/O 流类相关的计算时间,如果I/O 操作被阻塞,那么还会包括线程被阻塞的时间。操作系统将这个被阻塞的线程从调度队列中移走井直到I/O 操作结束,这将比实际阻塞的时间更长。当I/O 操作结束时,可能有其他线程正在执行它们的调度时间片,并且在调度队列中有些线程位于被阻塞线程之前,从而进一步增加服务时间。如果有多个线程在同时记录日志,那么还可能在输出流的锁上发生竞争,这种情况的结果与阻塞I/O 的情况一样——线程被阻塞并等待锁,然后被线程调度器交换出去。在这种日志操作中包含了I/O 操作和加锁操作,从而导致上下文切换次数的增多,以及服务时间的增加。

​ 通过将 I/O 操作从处理请求的线程中分离出来,可以缩短处理请求的平均服务时间。调用 log 方法的线程将不会再因为等待输出流的锁或者I/O 完成而被阻塞,它们只需将消息放人队列,然后就返回到各自的任务中。另一方面,虽然在消息队列上可能会发生竞争,但put 操作相对于记录日志的小心操作(可能需要执行系统调用)是一种更为轻量级的操作,因此在实际使用中发生阻塞的概率更小(只要队列没有填满)。由于发出日志请求的线程现在被阻塞的概率降低,因此该线程在处理请求时被交换出去的概率也会降低。我们所做的工作就是把一条包I/O 操作和锁竞争的复杂且不确定的代码路径变成一条简单的代码路径。

​ 从某种意义上讲,我们只是将工作分散开来,并将I/O 操作移到了另一个用户感知不到开销的线程上(这本身已经获得了成功)。通过把所有记录日志的I/O 转移到一个线程,还消除了输出流上的竞争,因此又去掉了一个竞争来源。这将提升整体的吞吐量,因为在调度中消耗的资源更少,上下文切换次数更少,并且锁的管理也更简单。

小结

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

第十二章 并发程序的测试

​ 并发测试大致分为两类,即安全性测试与活跃性测试。在进行安全性测试时,通常会采用测试不变性条件的形式,即判断某个类的行为是否与其规范保持一致。测试活跃性本身也存在问题。活跃性测试包括进展测试和无进展测试两方面,这些都是很难量化的。
​ 与活跃性测试相关的是性能测试。性能可以通过多个方面来衡批,包括:

  • 吞吐量:指一组并发任务中已完成任务所占的比例e
  • 响应性:指请求从发出到完成之间的时间(也称为延迟)。
  • 可伸缩性:指在增加更多资源的情况下(通常指CPU) ,吞吐址(或者缓解短缺)的提升情况。

12.1 正确性测试

​ 在为某个并发类设计单元测试时,首先需要执行与测试串行类时相同的分析—找出需要检查的不变性条件和后验条件。幸运的话,在类的规范中将给出其中大部分的条件,而在剩下的时间里,当编写滇试时将不断地发现新的规范。

// 基于信号量的有界缓存
@ThreadSafe
public class SemaphoreBoundedBuffer <E> {
    private final Semaphore availableItems, availableSpaces;
    @GuardedBy("this") private final E[] items;
    @GuardedBy("this") private int putPosition = 0, takePosition = 0;

    public SemaphoreBoundedBuffer(int capacity) {
        if (capacity <= 0)
            throw new IllegalArgumentException();
        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;
    }

    private synchronized void doInsert(E x) {
        int i = putPosition;
        items[i] = x;
        putPosition = (++i == items.length) ? 0 : i;
    }

    private synchronized E doExtract() {
        int i = takePosition;
        E x = items[i];
        items[i] = null;
        takePosition = (++i == items.length) ? 0 : i;
        return x;
    }
}

​ BoundedBuffer 实现了一个固定长度的队列,其中定义了可阻塞的put 和take 方法,并通过两个计数信号量进行控制。信号量availableltems 表示可以从缓存中删除的元素个数,它的初始值为零(因为缓存的初始状态为空)。同样,信号量availableSpaces 表示可以插入到缓存的元素个数,它的初始值等于缓存的大小。

​ take 操作首先请求从 availableltems 中获得一个许可(Permit) 。如果缓存不为空,那么这个请求会立即成功,否则请求将被阻塞直到缓存不为空。在获得了一个许可后, take 方法将删除缓存中的下一个元素,并返回一个许可到availableSpaces 信号量包put 方法的执行顺序刚好相反,因此无论是从put 方法还是从take 方法中退出,这两个信号量计数值的加和都会等于缓存的大小。(在实际情况中,如果需要一个有界缓存,应该直接使用ArrayBlockingQueue 或LinkedBlockingQueue, 而不是自己编写,但这里用于说明如何对添加和删除等方法进行控制的技术,在其他数据结构中同样可以使用。)

12.1.1 基本的单元测试

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

public class TestBoundedBuffer extends TestCase {
    void testIsEmptyWhenConstructed() {
        SemaphoreBoundedBuffer<Integer> bb = new SemaphoreBoundedBuffer<Integer>(10);
        assertTrue(bb.isEmpty());
        assertFalse(bb.isFull());
    }

    void testIsFullAfterPuts() throws InterruptedException {
        SemaphoreBoundedBuffer<Integer> bb = new SemaphoreBoundedBuffer<Integer>(10);
        for (int i = 0; i < 10; i++)
            bb.put(i);
        assertTrue(bb.isFull());
        assertFalse(bb.isEmpty());
    }
}

12.1.2 对阻塞操作的测试

​ 在测试并发的基本属性时,需要引入多个线程。大多数测试框架并不能很好地支持并发性测试:它们很少会包含相应的工具来创建线程或监视线程,以确保它们不会意外结束。如果在某个测试用例创建的辅助线程中发现了一个错误,那么框架通常无法得知与这个线程相关的是哪一个测试,所以需要通过一些工作将成功或失败信息传递回主测试线程,从而才能将相应的信息技告出来。

​ 在java.util.concurrent 的一致性测试中,一定要将各种故障与特定的测试明确地关联起来。

​ 如果某方法需要在某些特定条件下阻塞,那么当测试这种行为时,只有当线程不再继续执行时,测试才是成功的。要测试一个方法的阻塞行为,类似于测试一个抛出异常的方法:如果这个方法可以正常返回,那么就意味着测试失败。

​ 在测试方法的阻塞行为时,将引入额外的复杂性:当方法被成功地阻塞后,还必须使方法解除阻塞。实现这个功能的一种简单方式就是使用中断一在一个单独的线程中启动一个阻寒操作,等到线程阻塞后再中断它,然后宣告阻塞操作成功。当然,这要求阻塞方法通过提前返回或者抛出 InterruptedException 来响应中断。

void testTakeBlocksWhenEmpty() {
    final SemaphoreBoundedBuffer<Integer> bb = new SemaphoreBoundedBuffer<Integer>(10);
    Thread taker = new Thread() {
        public void run() {
            try {
                int unused = bb.take();
                fail(); // if we get here, it's an error
            } catch (InterruptedException success) {
            }
        }
    };
    try {
        taker.start();
        Thread.sleep(LOCKUP_DETECT_TIMEOUT);
        taker.interrupt();
        taker.join(LOCKUP_DETECT_TIMEOUT);
        assertFalse(taker.isAlive());
    } catch (Exception unexpected) {
        fail();
    }
}

​ 程序创建一个“获取“线程,该线程将尝试从空缓存中获取一个元素。如果take 方法成功,那么表示测试失败。执行测试的线程启动”获取“线程,等待一段时间,然后中断该线程。如果”获取“线程正确地在take 方法中阻塞,那么将抛出InterruptedException, 而捕获到这个异常的catch 块将把这个异常视为测试成功,并让线程退出。然后,主测试线程会尝试与”获取“线程合并,通过调用Thread.isAlive来验证join 方法是否成功返回,如果”获取“线程可以响应中断,那么join 能很快地完成。

12.1.3 安全性测试

​ 要想测试一个并发类在不可预测的并发访问情况下能否正确执行,需要创建多个线程来分别执行put 和take 操作,井在执行一段时间后判断在测试中是否会出现问题。

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

​ 要测试在生产者-消费者模式中使用的类,一种有效的方法就是检查被放入队列中和从队列中取出的各个元素。这种方法的一种简单实现是,当元素被插入到队列时,同时将其插入到一个“影子”列表,当从队列中删除该元素时,同时也从“影子”列表中删除,然后在测试程序运行完以后判断“影子”列表是否为空。然而,这种方法可能会干扰测试线程的调度,因为在修改“影子”列表时需要同步,并可能会阻塞。

​ 一种更好的方法是,通过一个对顺序敏感的校验和计算函数来计算所有入列元素以及出列元素的校验和,并进行比较。如果二者相等,那么测试就是成功的。如果只有一个生产者将元素放入缓存,同时也只有一个消费者从中取出元素,那么这种方法能发挥最大的作用,因为它不仅能测试出是否取出了正确的元素,而且还能测试出元素被取出的顺序是否正确。

​ 如果要将这种方法扩展到多生产者-多消费者的情况,就需要一个对元素入列/出列顺序不敏感的校验和函数,从而在测试程序运行完以后,可以将多个校验和以不同的顺序组合起来。如果不是这样,多个线程就需要访间同一个共享的校验和变量,因此就需要同步,这将成为一个并发的瓶颈或者破坏测试的执行时序。(任何具备可交换性的操作,例如加法或XOR,都符合这些需求。)

​ 要确保测试程序能正确地测试所有要点,就一定不能让编译器可以预先猜测到校验和的。使用连续的整数作为测试数据并不是一种好办法,因为得到的结果总是相同的,而一个智能的编译器通常可以预先计算出这个结果。

​ 要避免这个问题,应该采用随机方式生成的测试数据,但如果选择了一种不合适的随机数生成器(RNG, Random Number Generator) ,那么会对许多其他的测试造成影响。由于大多数随机数生成器类都是线程安全的,并且会带来额外的同步开销鱼因此在随机数生成过程中,可能会在这些类与执行时序之间产生耦合关系。如果每个线程都拥有各自的RNG, 那么这些RNG 就可以不是线程安全的。

​ 与其使用一个通用的RNG, 还不如使用一些简单的伪随机函数。你并不需要某种高质的随机性,而只需要确保在不同的测试运行中都有不同的数字。程序中的xorShift 函数(Marsaglia, 2003) 是最符合这个需求的随机数函数之一。该函数基于hashCode 和nanoTime 来生成随机数,所得的结果既是不可预测的,而且基本上每次运行都不同。

public class XorShift {
    static final AtomicInteger seq = new AtomicInteger(8862213);
    int x = -1831433054;

    public XorShift(int seed) {
        x = seed;
    }

    public XorShift() {
        this((int) System.nanoTime() + seq.getAndAdd(129));
    }

    public int next() {
        x ^= x << 6;
        x ^= x >>> 21;
        x ^= (x << 7);
        return x;
    }
}

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

public class PutTakeTest extends TestCase {
    protected static final ExecutorService pool = Executors.newCachedThreadPool();
    protected CyclicBarrier barrier;
    protected final SemaphoreBoundedBuffer<Integer> bb;
    protected final int nTrials, nPairs;
    protected final AtomicInteger putSum = new AtomicInteger(0);
    protected final AtomicInteger takeSum = new AtomicInteger(0);

    public static void main(String[] args) throws Exception {
        new PutTakeTest(10, 10, 100000).test(); // sample parameters
        pool.shutdown();
    }

    public PutTakeTest(int capacity, int npairs, int ntrials) {
        this.bb = new SemaphoreBoundedBuffer<Integer>(capacity);
        this.nTrials = ntrials;
        this.nPairs = npairs;
        this.barrier = new CyclicBarrier(npairs * 2 + 1);
    }

    void test() {
        try {
            for (int i = 0; i < nPairs; i++) {
                pool.execute(new Producer());
                pool.execute(new Consumer());
            }
            barrier.await(); // wait for all threads to be ready
            barrier.await(); // wait for all threads to finish
            assertEquals(putSum.get(), takeSum.get());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

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

    class Producer implements Runnable {
        public void run() {
            try {
                int seed = (this.hashCode() ^ (int) System.nanoTime());
                int sum = 0;
                barrier.await();
                for (int i = nTrials; i > 0; --i) {
                    bb.put(seed);
                    sum += seed;
                    seed = xorShift(seed);
                }
                putSum.getAndAdd(sum);
                barrier.await();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }

    class Consumer implements Runnable {
        public void run() {
            try {
                barrier.await();
                int sum = 0;
                for (int i = nTrials; i > 0; --i) {
                    sum += bb.take();
                }
                takeSum.getAndAdd(sum);
                barrier.await();
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }
}

​ PutTakeTest 使用了一个确定性的结束条件,从而在判断测试何时完成时就不需要在线程之间执行额外的协调。test 方法将启动相同数址的生产者线程和消费者线程,它们将分别插入(put)和取出(Take) 相同数量的元素,因此添加与删除的总数相同。

​ 像PutTakeTest 这种测试能很好地发现安全性问题。如果在PutTakeTesc 使用的Bounded.Buffer 中忘记将 dolnsert 和doExtract 声明为synchronized, 那么在运行PutTakeTest 时会立即失败。通过多个线程来运行PutTakeTest, 并且使这些线程在不同系统上的不同容昼的缓存上迭代数百万次,使我们能进一步确定在put和take 方法中不存在数据破坏问题.

这些测试应该放在多处理器的系统上运行,从而进一步测试更多形式的交替运行,然而,CPU的数量越多并不一定会使测试越高效,要最大程度的检测出一些对执行时序敏感的数据竞争,那么测试中的线程数量应该多于CPU的数量,这样在任意时刻都会有一 些线程在运行,而另一些被交换出去,从而可以检查线程间交替行为的可预测性。

​ 在一些测试中通常要求执行完一定数量的操作后才能停止运行,如果在携试代码中出现了一个错误并抛出了一个异常,那么这个测试将永远不会结束。最常见的解决方法是:让测试框架放弃那些没有在规定时间内完成的测试,具体要等待多长的时间,则要凭经验来确定,并且要对故障进行分析以确保所出现的问题并不是由于没有等待足够长的时间而造成的。(这个问题并不仅限于对并发类的测试,在串行测试中也必须区分长时间的运行和无限循环。)

12.1.4 资源管理的测试

​ 测试的另一个方面就是要判断类中是否没有做它不应该做的事情,例如资源泄漏。对于任何持有或管理其他对象的对象,都应该在不需要这些对象时销毁对它们的引用。这种存储资源泄漏不仅会妨碍垃圾回收器回收内存(或者线程、文件句柄、套接字、数据库连接或其他有限资源),而且还会导致资源耗尽以及应用程序失败。

​ 对于像BoundBuffer 这样的类来说,资源管理的问题尤为重要。之所以要限制缓存的大小,其原因就是要防止由于资源耗尽而导致应用程序发生故障,例如生产者的速度远远高于消费者的处理速度。通过对缓存进行限制,将使得生产力过剩的生产者被阻塞,因而它们就不会继续创建更多的工作来消耗越来越多的内存以及其他资源。

​ 通过一些测量应用程序中内存使用情况的堆检查工具,可以很容易地测试出对内存的不合理占用,许多商业和开源的堆分析工具中都支持这种功能

12.1.5 使用回调

​ 在构造测试案例时,对客户提供的代码进行回调是非常有帮助的。回调函数的执行通常是在对象生命周期的一些已知位置上,并且在这些位置上非常适合判断不变性条件是否被破坏。例如,在ThreadPoolExecutor 中将调用任务的Runnable 和ThreadF actory 。

​ 在测试线程池时,需要测试执行策略的多个方面:在需要更多的线程时创建新线程,在不需要时不创建,以及当需要回收空闲线程时执行回收操作等。要构造一个全面的测试方案是很困难的,但其中许多方面的测试都可以单独进行。
​ 通过使用自定义的线程工厂,可以对线程的创建过程进行控制。在程序TestingThreadFactory 中将记录己创建线程的数最。这样,在测试过程中,测试方案可以验证己创建线程的数最。我们还可以对TestingThreadFactory 进行扩展,使其返回一个自定义的Thread, 并且该对象可以记录自己在何时结束,从而在测试方案中验证线程在被回收时是否执行策略一致。

// 测试THReadPoolExecutor 的线程工厂类
public class TestThreadPool extends TestCase {

    private final TestingThreadFactory threadFactory = new TestingThreadFactory();

    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() {
                public void run() {
                    try {
                        Thread.sleep(Long.MAX_VALUE);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                }
            });
        for (int i = 0;
             i < 20 && threadFactory.numCreated.get() < MAX_SIZE;
             i++)
            Thread.sleep(100);
        assertEquals(threadFactory.numCreated.get(), MAX_SIZE);
        exec.shutdownNow();
    }
}

class TestingThreadFactory implements ThreadFactory {
    public final AtomicInteger numCreated = new AtomicInteger();
    private final ThreadFactory factory = Executors.defaultThreadFactory();

    public Thread newThread(Runnable r) {
        numCreated.incrementAndGet();
        return factory.newThread(r);
    }
}

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

12.1.6 产生更多的交替操作

​ 由于并发代码中的大多数错误都是一些低概率事件,因此在测试并发错误时需要反复地执行许多次,但有些方法可以提高发现这些错误的概率。在前面提到过,在多处理器系统上,如果处理器的数量少于活动线程的数量,那么与单处理器系统或者包含多个处理器的系统相比,将能产生更多的交替行为。同样,如果在不同的处理器数量、操作系统以及处理器架构的系统上进行测试,就可以发现那些在特定运行环境中才会出现的问题。

12.2 性能测试

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

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

public class BarrierTimer implements Runnable {
    private boolean started;
    private long startTime, endTime;

    public synchronized void run() {
        long t = System.nanoTime();
        if (!started) {
            started = true;
            startTime = t;
        } else
            endTime = t;
    }

    public synchronized void clear() {
        started = false;
    }

    public synchronized long getTime() {
        return endTime - startTime;
    }
}

​ 我们可以将栅栏的初始化过程修改为使用这种栅栏动作,即使用能接受栅栏动作的CyclicBarrier 构造函数。在修改后的test 方法中使用了基于栅栏的计时器。

public class TimedPutTakeTest extends PutTakeTest {
    private BarrierTimer timer = new BarrierTimer();

    public TimedPutTakeTest(int cap, int pairs, int trials) {
        super(cap, pairs, trials);
        barrier = new CyclicBarrier(nPairs * 2 + 1, timer);
    }

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

    public static void main(String[] args) throws Exception {
        int tpt = 100000; // trials per thread
        for (int cap = 1; cap <= 1000; cap *= 10) {
            System.out.println("Capacity: " + cap);
            for (int pairs = 1; pairs <= 128; pairs *= 2) {
                TimedPutTakeTest t = new TimedPutTakeTest(cap, pairs, tpt);
                System.out.print("Pairs: " + pairs + "\t");
                t.test();
                System.out.print("\t");
                Thread.sleep(1000);
                t.test();
                System.out.println();
                Thread.sleep(1000);
            }
        }
        PutTakeTest.pool.shutdown();
    }
}

​ 在真实的生产者-消费者应用程序中,如果工作者线程要通过执行一些复杂的操作来生产和获取各个元素条目(通常就是这种情况),那么之前那种CPU 空闲状态将消失,井且由于线程过多而导致的影响将变得非常明显。这个测试的主要目的是,测量生产者和消费者在通过有界缓存传递数据时,哪些约束条件将对整体吞吐量产生影响。

12.2.2 多种算法的比较

​ 虽然BoundedBuffer 是一种非常合理的实现,井且它的执行性能也不错,,但还是没有ArrayBlockingQueue 或LinkedBlockingQueue 那样好(这也解释了为什么这种缓存算法没有被选入类库中)。java.util.concurrent 中的算法已经通过类似的测试进行了调优,其性能也已经达到我们已知的最佳状态。此外,这些算法还能提供更多的功能芞BoundedBuffer 运行效率不高的主要原因是:在put 和take 方法中都含有多个可能发生竞争的操作,例如,获取一个信号量,获取一个锁,以及释放信号址等。在其他实现方法中,可能发生竞争的位置将少很多。

LinkedBlockingQueue 的可伸缩性要高于ArrayBlockingQueue 。

12.2.3 响应性衡量

​ 到目前为止,我们的重点是吞吐量的测量,这通常是并发程序最重要的性能指标。但有时候,我们还需要知道某个动作经过多长时间才能执行完成,这时就要测益服务时间的变化情况。而且,如果能获得更小的服务时间变动性,那么更长的平均服务时间是有意义的,“可预测性”同样是一个非常有价值的性能特征。通过测量变动性,使我们能回答一些关于服务质量的问题。

​ 除非线程由于密集的同步需求而被持续地阻塞,否则非公平的信号量通常能实现更好的吞吐批,而公平的信号量则实现更低的变动性。因为这些结果之间的差异非常大,所以Semaphore 要求客户选择针对哪一个特性进行优化。

12.3 避免性能测试的陷阱

​ 理论上,开发性能测试程序是很容易的——找出一个典型的使用场景,编写一段程序多次执行这种使用场景,并统计程序的运行时间。但在实际情况中,你必须提防多种编码陷阱,们会使性能慎试变得毫无意义。

12.3.1 垃圾回收

​ 垃圾回收的执行时序是无法预测的,因此在执行测试时,垃圾回收器可能在任何时刻运行。如果测试程序执行了N 次迭代都没有触发垃圾回收操作,但在第N+l 次迭代时铀发了垃圾回收操作,那么即使运行次数相差不大,仍可能在最终测试的每次迭代时间上带来很大的(却虚假的)影响。

​ 有两种策略可以防止垃圾回收操作对测试结果产生偏差。

  • 第一种策略是,确保垃圾回收操作在测试运行的整个期间都不会执行(可以在调用JVM 时指定-verbose: gc 来判断是否执行了垃圾回收操作)。
  • 第二种策略是,确保垃圾回收操作在测试期间执行多次,这样测试程序就能充分反映出运行期间的内存分配与垃圾回收等开销。通常

​ 第二策略更好,它要求更长的测试时间,并且更有可能反映实际环境下的性能。

​ 在大多数采用生产者-消费者设计的应用程序中,都会执行一定数量的内存分配与垃圾回收等操作一生产者分配新对象,然后被消费者使用井丢弃。如果将有界缓存测试运行足够长的时间,那么将引发多次垃圾回收,从而得到更精确的结果。****

12.3.2 动态编译

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

​ 这种编译的执行时机是无法预测的。只有在所有代码都编译完成以后,才应该统计测试的运行时间。测量采用解释执行的代码速度是没有意义的,因为大多数程序在运行足够长的时间后,所有频繁执行的代码路径都会被编译

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

​ 测试程序不仅要大致判断某个典型应用程序的使用模式,还需要尽量覆盖在该应用程序中将执行的代码路径集合。否则,动态编译器可能会针对一个单线程测试程序进行一些专门优化,但只要在真实的应用程序中略微包含一些并行,都会使这些优化不复存在。因此,即便你只是想测试单线程的性能,也应该将单线程的性能测试与多线程的性能测试结合在一起。(在TimedPutTakeTest 中不会出现这个问题,因为即使在最小的测试用例中都使用了两个线程。)

12.3.4 不真实的竞争程度

​ 并发的应用程序可以交替执行两种不同类型的工作:访问共享数据(例如从共享工作队列中取出下一个任务)以及执行线程本地的计算(例如,执行任务,并假设任务本身不会访问共享数据)。根据两种不同类型工作的相关程度,在应用程序中将出现不同程度的竞争,并表现出不同的性能与可伸缩性。

​ 要获得有实际意义的结果,在并发性能测试中应该尽扯模拟典型应用程序中的线程本地计算量以及并发协调开销。如果在真实应用程序的各个任务中执行的工作,与测试程序中执行的工作截然不同,那么测试出的性能瓶颈位置将是不准确的。

12.3.5 无用代码的消除

​ 由于基准测试通常不会执行任何计算,因此它们很容易在编译器的优化过程中被消除。在大多数情况下,编译器从程序中删除无用代码都是一种优化措施,但对于基准测试程序来说却是一个大问题,因为这将使得被渊试的内容变得更少。如果幸运的话,编译器将删除整个程序中的无用代码,从而得到一份明显虚假的测试数据。但如果不幸运的话,编译器在消除无用代码后将提高程序的执行速度,从而使你做出错误的结论。

12.4 其他的测试方法

​ 测试的目标不是更多地发现错误,而是提高代码能桉照预期方式工作的可信度。由于找出所有的错误是不现实的,所以质量保证(Quality Assurance, QA) 的目标应该是在给定的测试资源下实现最高的可信度。并发程序中的错误通常会比串行程序中更多,因此需要更多的测试才能获得相同的可信度。

12.4.1 代码审查

​ 正如单元侧试和压力测试在查找并发错误时是非常高效和重要的手段,多人参与的代码审查通常是不可替代的。

12.4.2 静态分析工具

​ 静态代码分析是指在进行分析时不需要运行代码,而代码核查工具可以分析类中是否存在一些常见的错误模式。在一些静态分析工具(例如,开源的FindBugs 0) 中包含了许多错误模式检查器,能够检测出多种常见的编码错误,其中许多错误都很容易在测试与代码审查中遗漏。

​ 在编写本书时, FindBugs 包含的检查器中可以发现以下与并发相关的错误模式,而且一直在不断地增加新的检查器:

  • 不一致的同步。许多对象遵循的同步策略是,使用对象的内置锁来保护所有变量。如果某个域被频繁地访问,但并不是在每次访问时都持有相同的锁,那么这就可能表示没有一致地遵循这个同步策略。

  • 调用Thread.run 。在Thread 中实现了Runnable, 因此包含了一个run 方法。然而,如果直接调用Thread.run, 那么通常是错误的,而应该调用Thread. start

  • 未被释放的锁。与内置锁不同的是,执行控制流在退出显式锁的作用域时,通常不会自动释放它们。标准的做法是在一个finally 块中释放显式锁,否则,当发生Exception 事件时,锁仍然处于未被释放的状态。

  • 空的同步块。虽然在Java 内存模型中,空同步块具有一定的语义,但它们总是被不正确地使用,无论开发人员尝试通过空同步块来解决何种间题,通常都存在一些更好的解决方案。

  • 双重检查加锁。双重检查加锁是一种错误的习惯用法,其初衷是为了降低延迟初始化过程中的同步开销,该用法在读取一个共享的可变域时缺少正确的同步。

  • 在构造函数中启动一个线程。如果在构造函数中启动一个线程,那么将可能带来子类化问题,同时还会导致this 引用从构造函数中逸出。

  • 通知错误。notify 和notif;•All 方法都表示,某个对象的状态可能以某种方式发生了变化,并且这种方式将在相关条件队列上被阻塞的线程恢复执行。只有在与条件队列相关的状态发生改变后,才应该调用这些方法。如果在一个同步块中调用了notify 或notifyAll, 但没有修改任何状态,那么就可能出错。

  • 条件等待中的错误。当在一个条件队列上等待时, Object.wait 和Condition.await 方法 应该在检查了状态谓词之后(请参见第14 章),在某个循环中调用,同时需要持有正确的 锁。如果在调用Object.wait 和Condition.await 方法时没有持有锁,或者不在某个循环中, 或者没有检查某些状态谓词,那么通常都是一个错误。

  • 对Lock 和Condition 的误用。将Lock 作为同步块来使用通常是一种错误的用法,正 如调用Condition.wait 而不调用await (后者能够通过测试被发现,因此在第一次调用它时将抛出IllegalMonitorStateException).

  • 在休眠或者等待的同时持有一个锁。如果在调用Thread.sleep 时持有一个锁,那么将导致其他线程在很长一段时间内无法执行,因此可能导致严重的活跃性问题。如果在调用 Object.wait 或Condition.await 时持有两个锁,那么也可能导致同样的问题。

  • 自旋循环。如果在代码中除了通过自旋(忙于等待)来检查某个域的值以外不做任何事情,那么将浪费CPU 时钟周期,并且如果这个域不是volatile 类型,那么将无法保证这种自旋过程能结束。当等待某个状态转换发生时,闭锁或条件等待通常是一种更好的技术。

12.4.3 面向方面的测试技术

​ 面向方面编程(AOP) 技术在井发领域的应用是非常有限的,因为大多数主流的AOP 工具还不能支持在同步位置处的“切入点(Pointcut)”。然而, AOP 可以用来确保不变性条件不被破坏,或者与同步策略的某些方面保持一致。

12.4.4 分析与监测工具

​ 大多数商业分析工具都支持线程。这些工具在功能与执行效率上存在着差异,但通常都能给出对程序内部的详细信息(虽然分析工具通常采用侵入式实现,因此可能对程序的执行时序和行为产生极大的影响)。大多数分析工作通常还为每个线程提供了一个时间线显示,并且用颜色来区分不同的线程状态(可运行,由千等待某个锁而阻塞,由千等待I/0 操作而阻塞等等)。从这些显示信息中可以看出程序对可用CPU 资源的利用率,以及当程序表现糟糕时,该从何处查找原因。(许多分析工具还声称能够找出哪些锁导致了竞争,但在实际情况中,这些功能与人们期望的加锁行为分析能力之间存在一定的差距。)

小结

​ 要测试井发程序的正确性可能非常困难,因为井发程序的许多故障模式都是一些低概率事件,它们对千执行时序、负载情况以及其他难以重现的条件都非常敏感。而且,在测试程序中还会引入额外的同步或执行时序限制,这些因素将掩盖被测试代码中的一些井发问题。要测试并发程序的性能同样非常困难,与使用静态编译语言(例如C) 编写的程序相比,用Java 编写的程序在测试起来更加困难,因为动态编译、垃圾回收以及自动优化等操作都会影响与时间相关的测试结果。
​ 要想尽可能地发现潜在的错误以及避免它们在正式产品中暴露出来,我们需要将传统的测试技术(要谨慎地避免在这里讨论的各种陷阱)与代码审查和自动化分析工具结合起来,每项技术都可以找出其他技术忽略的问题。

0
  1. 支付宝打赏

    qrcode alipay
  2. 微信打赏

    qrcode weixin

评论区