对象的组合 Java并发编程实战总结

2018-05-31  本文已影响14人  好好学习Sun

        到目前为止, 我们已经介绍了关于线程安全与同步的一些基础知识。然而, 我们并不希望对每一次内存访问都进行分析以确保程序是线程安全的, 而是希望将一些现有的线程安全组件组合为更大规模的组件或程序。本章将介绍一些组合模式, 这些模式能够使一个类更容易成为线程安全的, 并且在维护这些类时不会无意中破坏类的安全性保证。

设计线程安全的类

        在线程安全的程序中,虽然可以将程序的所有状态都保存在公有的静态域中, 但与那些将状态封装起来的程序相比,这些程序的线程安全性更难以得到验证, 并且在修改时也更难以始终确保其线程安全性。通过使用封装技术,可以使得在不对整个程序进行分析的情况下就可以判断一个类是否是线程安全的。

在设计线程安全类的过程中·,需要包含以下三个基本要素:

a.找出构成对象状态的所有变量。

b.找出约束状态变量的不变性条件。

c.建立对象状态的并发访问管理策略。

        要分析对象的状态, 首先从对象的域开始。如果对象中所有的域都是基本类型的变量, 那么这些域将构成对象的全部状态。程序清单4-1 中的Counter 只有一个域value, 因此这个域就是Counter 的全部状态。对于含有n个基本类型域的对象, 其状态就是这些域构成的n元组。例如,二维点的状态就是它的坐标值(x, y)。如果在对象的域中引用了其他对象, 那么该对象的状态将包含被引用对象的域。例如,LinkedList 的状态就包括该链表中所有节点对象的状态。

        同步策略 (Synchronization Policy) 定义了如何在不违背对象不变条件或后验条件的情况下对其状态的访问操作进行协同。同步策略规定了如何将不可变性、 线程封闭与加锁机制等结合起来以维护线程的安全性, 并且还规定了哪些变量由哪些锁来保护。要确保开发人员可以对 这个类进行分析与维护, 就必须将同步策略写为正式文档。

收集同步需求

        要确保类的线程安全性,就需要确保它的不变性条件不会在并发访问的情况下被破坏, 这就需要对其状态进行推断。对象与变量都有一个状态空间, 即所有可能的取值。状态空间越小, 就越容易判断线程的状态。final 类型的域使用得越多, 就越能简化对象可能状态的分析过程。(在极端的情况中, 不可变对象只有唯一的状态。)

        在许多类中都定义了一些不可变条件, 用于判断状态是有效的还是无效的。Counter 中的value 域是long 类型的变量, 其状态空间为从Long.MIN_VALUE 到Long.MAX_VALUE, 但Counter 中value 在取值范围上存在着一个限制, 即不能是负值。

        同样, 在操作中还会包含一些后验条件来判断状态迁移是否是有效的。如果Counter 的当前状态为17, 那么下一个有效状态只能是18。当下一个状态需要依赖当前状态时, 这个操作就必须是一个复合操作。并非所有的操作都会在状态转换上施加限制, 例如, 当更新一个保存当前温度的变量时, 该变量之前的状态并不会影响计算结果。

        由于不变性条件以及后验条件在状态及状态转换上施加了各种约束,因此就需要额外的同步与封装。如果某些状态是无效的, 那么必须对底层的状态变量进行封装,否则客户代码可能会使对象处于无效状态。如果在某个操作中存在无效的状态转换, 那么该操作必须是原子的。另外, 如果在类中没有施加这种约束, 那么就可以放宽封装性或序列化等需求, 以便获得更高的灵活性或性能。

        在类中也可以包含同时约束多个状态变量的不变性条件。在一个表示数值范围的类中可以包含两个状态变量, 分别表示范围的上界和下界。这些变量必须遵循的约束是, 下界值应该小于或等于上界值。类似于这种包含多个变量的不变性条件将带来原子性需求:这些相关的变量必须在单个原子操作中进行读取或更新。不能首先更新一个变量,然后释放锁并再次获得锁, 然后再更新其他的变量。因为释放锁后,可能会使对象处于无效状态。如果在一个不变性条件中包含多个变量, 那么在执行任何访问相关变量的操作时, 都必须持有保护这些变量的锁。

        如果不了解对象的不变性条件与后验条件,那么就不能确保线程安全性。要满足在状态变量的有效值或状态转换上的各种约束条件,就需要借助于原子性与封装性。

依赖状态的操作

        类的不变性条件与后验条件约束了在对象上有哪些状态和状态转换是有效的。在某些对象 的方法中还包含一些基于状态的先验条件(Precondition)。例如,不能从空队列中移除一个元素,在删除元素前,队列必须处于 “非空的” 状态。 如果在某个操作中包含有基千状态的先验条件,那么这个操作就称为依赖状态的操作。

        在单线程程序中,如果某个操作无法满足先验条件,那么就只能失败。 但在并发程序中, 先验条件可能会由于其他线程执行的操作而变成真在并发程序中要一直等到先验条件为真,然后再执行该操作。

        在Java中,等待某个条件为真的各种内置机制(包括等待和通知等机制)都与内置加锁机制紧密关联, 要想正确地使用它们并不容易。 要想实现某个等待先验条件为真时才执行的操作,一种更简单的方法是通过现有库中的类(例如阻塞队列[Blocking Queue]或信号 量[Semaphore])来实现依赖状态的行为。 第5章将介绍一些阻塞类,例如BlockingQueue、 Semaphore以及其他的同步工具类。 第14章将介绍如何使用在平台与类库中提供的各种底层机制来创建依赖状态的类。

状态的所有权

        如果以某个对象为根节点构造一张对象图, 那么该对象的状态将是对象图中所有对象包含的域的一个子集。 为什么是一个 “子集” ?在从对象可以达到的所有域中,需要满足哪些条件才不属于对象状态的一部分?

        在定义哪些变量将构成对象的状态时,只考虑对象拥有的数据。所有权(Ownership)在Java中并没有得到充分的体现,而是属于类设计中的一个要素。如果分配并填充了一个HashMap对象, 那么就相当于创建了多个对象: HashMap对象,在HashMap对象中包含的多个对象,以及在Map.Entry中可能包含的内部对象。HashMap对象的逻辑状态包括所有的Map.Entry对象以及内部对象,即使这些对象都是一些独立的对象。

        无论如何,垃圾回收机制使我们避免了如何处理所有权的问题。在C++ 中,当把一个对象传递给某个方法时,必须认真考虑这种操作是否传递对象的所有权,是短期的所有权还是长期的所有权。在Java中同样存在这些所有权模型,只不过垃圾回收器为我们减少了许多在引用共享方面常见的错误,因此降低了在所有权处理上的开销。

        许多情况下,所有权与封装性总是相互关联的:对象封装它拥有的状态,反之也成立,即对它封装的状态拥有所有权。状态变量的所有者将决定采用何种加锁协议来维持变量状态的完整性。所有权意味着控制权。然而,如果发布了某个可变对象的引用,那么就不再拥有独占的控制权,最多是“共享控制权”。对于从构造函数或者从方法中传递进来的对象,类通常并不拥有这些对象,除非这些方法是被专门设计为转移传递进来的对象的所有权(例如,同步容器封装器的工厂方法)。

        容器类通常表现出一种“ 所有权分离” 的形式,其中容器类拥有其自身的状态,而客户代码则拥有容器中各个对象的状态。Servlet框架中的ServletContext就是其中一个示例。ServletContext为Servlet提供了类似于Map形式的对象容器服务,在ServletContext中可以通过名称来注册(setAttribute)或获取(getAttribute)应用程序对象。由Servlet容器实现的ServletContext 对象必须是线程安全的,因为它肯定会被多个线程同时访问。 当调用 setAttribute和getAttribute时,Servlet不需要使用同步,但当使用保存在ServletContext中的对象时 ,则可能需要使用同步。这些对象由应用程序拥有,Servlet容器只是替应用程序保管它们。与所有共享对象 一样, 它们必须安全地被共享。为了防止多个线程在并发访问同一个对象时产生的相互于扰, 这些对象应该要么是线程安全的对象, 要么是事实不可变的对象, 或者由锁来保护的对象。

实例封闭

        如果某对象不是线程安全的, 那么可以通过多种技术使其在多线程程序中安全地使用。你可以确保该对象只能由单个线程访问(线程封闭),或者通过一个锁来保护对该对象的所有访问

        封装简化了线程安全类的实现过程,它提供了一种实例封闭机制(Instance Confinement), 通常也简称为 “封闭" 。当一 个对象被封装到另一 个对象中时,能够访问被封装对象的所有代码路径都是已知的。与对象可以由整个程序访问的情况相比,更易于对代码进行分析。通过将封闭机制与合适的加锁策略结合起来, 可以确保以线程安全的方式来使用非线程安全的对象。

        将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程访问数据时总能持有正确的锁。

        被封闭对象一定不能超出它们既定的作用域。对象可以封闭在类的一 个实例(例如作为类 的一个私有成员)中, 或者封闭在某个作用域内(例如作为一个局部变量),再或者封闭在线程内(例如在某个线程中 将对象从一 个方法传递到另一 个方法, 而不是在多个线程之间共享该对象 )。 当然, 对象本身不会逸出一一出现逸出情况的原因通常是由于开发人员在发布对象时超出了对象既定的作用域。

        程序清单4-2中的PersonSet说明了如何通过封闭与加锁等机制使一个类成为线程安全 的(即使这个类的状态变量并不是线程安全的)。PersonSet的状态由HashSet来管理的, 而HashSet 并非线程安全的。 但由于mySet是私有的并且不会逸出, 因此HashSet被封闭在PersonSet中。唯一能访问mySet的代码路径是addPerson 与containsPerson, 在执行它们时都要获得PersonSet上的锁。PersonSet的状态完全由它的内置锁 保护, 因而 PersonSet是一个线程安全的类。


        这个示例并未对 Person 的线程安全性做任何假设, 但如果 Person 类是可变的, 那么在访问从 PersonSet 中获得的 Person 对象时, 还需要额外的同步。 要想安全地使用 Person 对象, 最可靠的方法就是使 Person 成为一个线程安全的类。 另外, 也可以使用锁来保护 Person 对象, 并确保所有客户代码在访问 Person 对象之前都已经获得正确的锁。

        实例封闭是构建线程安全类的一个最简单方式, 它还使得在锁策略的选择上拥有了更多的灵活性。 在 PersonSet 中使用了它的内置锁来保护它的状态, 但对于其他形式的锁来说, 只要自始至终都使用同一个锁, 就可以保护状态。 实例封闭还使得不同的状态变量可以由不同的锁 来保护。(后面章节的 ServerStatus 中就使用了多个锁来保护类的状态。)

        Java 平台的类库中还有很多线程封闭的示例, 其中有些类的唯一用途就是将非线程安全的类转化为线程安全的类。 一些基本的容器类井非线程安全的,例如 ArrayList 和 HashMap,但类库提供了包装器工厂方法(例如 Collections.synhronizedList 及其类似方法),使得这些非线程安全的类可以在多线程环境中安全地使用。 这些工厂方法通过 “装饰器 (Decorator) "模式(Gamma et al., 1995)将容器类封装在一个同步的包装器对象中, 而包装器能将接口中的每个方法都实现为同步方法, 并将调用请求转发到底层的容器对象上。 只要包装器对象拥有对底 容器对象的唯一引用(即把底层容器对象封闭在包装器中),那么它就是线程安全的。 在这些方法的 Javadoc 中指出, 对底层容器对象的所有访问必须通过包装器来进行。

        当然,如果将一个本该被封闭的对象发布出去, 那么也能破坏封闭性。 如果一个对象本应该封闭在特定的作用域内, 那么让该对象逸出作用域就是一个错误。 当发布其他对象时, 例如迭代器或内部的类实例, 可能会间接地发布被封闭对象, 同样会使被封闭对象逸出。

        封闭机制更易于构造线程安全的类, 因为当封闭类的状态时, 在分析类的线程安全性时就无须检查整个程序。

Java监视器模式

        从线程封闭原则及其逻辑推论可以得出Java监视器模式。遵循Java监视器模式的对象会把对象的所有可变状态都封装起来,并由对象自己的内詈锁来保护。

        在程序消单4-1的Counter中给出了这种模式的一个典型示例。在Counter中封装了一个状态变量value, 对该变量的所有访问都需要通过Counter的方法来执行, 并且这些方法都是同步的。

        在许多类中都使用了Java监视器模式, 例如Vector和Hashtable. 在某些情况下, 程序需要一种更复杂的同步策略。第11章将介绍如何通过细粒度的加锁策略来提高可伸缩性。Java监视器模式的主要优势就在于它的简单性。

        Java监视器模式仅仅是一种编写代码的约定,对于任何一种锁对象, 只要自始至终都使用该锁对象, 都可以用来保护对象的状态。程序清单4-3给出了如何使用私有锁来保护状态。


        使用私有的锁对象而不是对象的内置锁(或任何其他可通过公有方式访问的锁), 有许多优点。私有的锁对象可以将锁封装起来,使客户代码无法得到锁, 但客户代码可以通过公有方法来访问锁, 以便(正确或者不正确地)参与到它的同步策略中。如果客户代码错误地获得了另一个对象的锁, 那么可能会产生活跃性问题。此外, 要想验证某个公有访问的锁在程序中否被正确地使用, 则需要检查整个程序, 而不是单个的类。

线程安全性的委托

         大多数对象都是组合对象。 当从头开始构建一个类, 或者将多个非线程安全的类组合为一 个类时, Java 监视器模式是非常有用的。 但是, 如果类中的各个组件都已经是线程安全的, 是什么情况呢?我们是否需要再增加一个额外的线程安全层?答案是 “视情况而定”.在某些 情况下, 通过多个线程安全类组合而成的类是线程安全的, 而在某些情况下, 这仅仅是一个好的开端。

        如果某个类含有复合操作, 那么仅靠委托并不足以实现线程安全性。在这种情况下, 这个类必须提供自己的加锁机制以保证这些复合操作都是原子操作, 除非整个复合操作都可以委托给状态变量。如果一个类是由多个独立且线程安全的状态变量组成,并且在所有的操作中都不包含无效状态转换,那么可以将线程安全性委托给底层的状态变量。

        如果一个状态变量是线程安全的,并且没有任何不变性条件来约束它的值,在变量的操作上也不存在任何不允许的状态转换,那么就可以安全地发布这个变量。


在现有的线程安全类中添加功能

        Java类库包含许多有用的 “ 基础模块 ” 类。 通常, 我们应该优先选择重用这些现有的类而不是创建新的类: 重用能降低开发工作量、 开发风险(因为现有的类都已经通过测试) 以及维护成本。 有时候, 某个现有的线程安全类能支持我们需要的所有操作, 但更多时候, 现有的类只能支持大部分的操作, 此时就需要在不破坏线程安全性的情况下添加一个新的操作。

        例如, 假设需要一个线程安全的链表, 它需要提供一个原子的“ 若没有则添加(Put-IfAbsent)"的操作。同步的List 类巳经实现了大部分的功能, 我们可以根据它提供的contains方法和ad d 方法来构造一个“若没有则添加” 的操作。

        “若没有则添加” 的概念很简单, 在向容器中添加元素前, 首先检查该元素是否已经存在如果存在就不再添加。(回想“ 先检查再执行” 的注意事项。)由于这个类必须是线程安全的,因此就隐含地增加了另一个需求, 即“ 若没有则添加” 这个操作必须是原子操作。这意味着如果在链表中没有包含对象X, 那么在执行两次“ 若没有则添加"X后, 在容器中只能包含一个X对象。然而, 如果“ 若没有则添加” 操作不是原子操作, 那么在某些执行情况下, 有两个X 不在容器中, 并且都执行了添加X 的操作, 从而使容器中包含两个相同的X对象。

        要添加一个新的原子操作, 最安全的方法是修改原始的类, 但这通常无法做到, 因为你可能无法访问或修改类的源代码。要想修改原始的类, 就需要理解代码中的同步策略, 这样增加的功能才能与原有的设计保持一致。如果直接将新方法添加到类中, 那么意味着实现同步策略的所有代码仍然处于一个源代码文件中, 从而更容易理解与维护。

        另一种方法是扩展这个类, 假定在设计这个类时考虑了可扩展性。扩展Vector很简单, 但并非所有的类都像Vector那样将状态向子类公开, 因此也就不适合采用这种方法。

        “扩展” 方法比直接将代码添加到类中更加脆弱, 因为现在的同步策略实现被分布到多个单独维护的源代码文件中。如果底层的类改变了同步策略并选择了不同的锁来保护它的状态变量, 那么子类会被破坏, 因为在同步策略改变后它无法再使用正确的锁来控制对基类状态的并发访问。(在Vector的规范中定义了它的同步策略, 因此BetterVector不存在这个问题。)

客户端加锁机制

        对于由Collections.synchronizedList 封装的ArrayList, 这两种方法在原始类中添加一个方法或者对类进行扩展都行不通, 因为客户代码并不知道在同步封装器工厂方法中返回的List对象的类型。第三种策略是扩展类的功能, 但并不是扩展类本身, 而是将扩展代码放人一个“ 辅助类” 中。

        为什么这种方式不能实现线程安全性?毕竟,putlfAbsent已经声明为synchronized类型的 变量,对不对?问题在于在错误的锁上进行了同步。无论List使用哪一个锁来保护它的状态,可以确定的是,这个锁并不是ListHelper上的锁。ListHelper只是带来了同步的假象,尽管所有的链表操作都被声明为synchronized,但却使用了不同的锁,这意味着putlfAbsent相对于List的其他操作来说并不是原子的,因此就无法确保当putifAbsent执行时另一个线程不会修改链表。

        要想使这个方法能正确执行, 必须使 List 在实现客户端加锁或外部加锁时使用同一个锁。 客户端加锁是指, 对千使用某个对象X的客户端代码, 使用X本身用于保护其状态的锁来保护 这段客户代码。 要使用客户端加锁, 你必须知道对象X使用的是哪一个锁。

        Vector 和同步封装器类的文档中指出, 它们通过使用 Vector 或封装器容器的内置锁来支持客户端加锁。 程序清单 4-15 给出了在线程安全的 List 上执行 putlfAbsent 操作, 其中使用了 正确的客户端加锁。

        通过添加一个原子操作来扩展类是脆弱的, 因为它将类的加锁代码分布到多个类中。然而,客户端加锁却更加脆弱, 因为它将类 C的加锁代码放到与 C 完全无关的其他类中。 当在那些并不承诺遵循加锁策略的类上使用客户端加锁时, 要特别小心。

        客户端加锁机制与扩展类机制有许多共同点, 二者都是将派生类的行为与基类的实现耦合在一起。 正如扩展会破坏实现的封装性 [EJItem 14], 客户端加锁同样会破坏同步策略的封装性。

组合

        当为现有的类添加一个原子操作时, 有一种更好的方法:组合(Composition)。程序清单4-16 中的ImprovedList通过将List对象的操作委托给底层的List实例来实现List的操作, 同时还添加了一个原子的putlfAbsent方法。(与Collections.synchronizedList和其他容器封装器一样,ImprovedList假设把某个链表对象传给构造函数以后, 客户代码不会再直接使用这个对象,而只能通过lmprovedList来访问它。)

        lmprovedList通过自身的内置锁增加了一层额外的加锁。它并不关心底层的List是否是线程安全的, 即使List不是线程安全的或者修改了它的加锁实现,ImprovedList也会提供一致的加锁机制来实现线程安全性。虽然额外的同步层可能导致轻微的性能损失, 但与模拟另一个对象的加锁策略相比,ImprovedList更为健壮。事实上, 我们使用了Java监视器模式来封装现有的List, 并且只要在类中拥有指向底层List的唯一外部引用, 就能确保线程安全性

将同步策略文档化

        在文档中说明客户代码需要了解的线程安全性保证,以及代码维护人员需要了解的同步策略。

上一篇下一篇

猜你喜欢

热点阅读