线程的组合

了解线程安全和同步后,就可尝试构造线程安全的类,并将线程安全的类组合为更大规模的组件或程序。常见的组合模式有:实例封闭、线程安全性的委托、在现有的安全类中添加功能等。这些组合模式能够使一个类更容易成为线程安全的,并在维护这些类时不会无意中破坏类的安全性保证。另外,在维护线程安全性,可以使用文档说明客户代码需要了解的线程安全保证,以及代码维护人员需要了解的同步策略。

设计线程安全的类

在设计线程安全的类的过程中,需要包含以下三个基本步骤:
(1) 找出构成对象状态的所有变量;
(2) 找出约束状态变量的不变性条件;
(3) 建立对象状态的并发访问管理策略;
要分析对象的状态,首先从对象的域开始。如果对象中所有的域都是基本类型的变量,那么这些域将构成对象的全部状态。对于含有n个基本类型域的对象,其状态就是这些域构成的n元组。如果在对象的域中引用了其他对象,那么这个对象的状态将包含被引用对象的域,并以此类推。
同步策略(Synchronization Policy)定义了如何在不违背对象不变条件或后验条件的情况下,对其状态的访问进行协同。
不可变条件:判断状态是有效的还是无效的。如果对long类型的变量,其状态空间为Long.MIN_VALUE到Long.MAX_VALUE,但取值范围存在一个限制:不能为负值。
先验条件:是否执行当前的操作,需要依赖对象的上一个状态。先验条件就是“先检查后执行”,这种情况下要避免“竞态条件”问题。
后验条件:约束哪些状态迁移是有效的。以+1计数器为例,如果当前状态是17,那么下一个有效状态只能是18。当下一个状态需要依赖当前状态时,这个操作就必须是一个复合操作。

收集同步需求

要确保类的线程安全性,就需要确保它的不变性条件不会在并发访问的情况下被破坏。
由于不变性条件及后验条件在状态及状态转换上施加了各种约束,因此就需要额外的同步与封装。如果某些状态是无效的,那么必须对底层的状态变量进行封装,否则客户端代码可能会使对象处于无效状态。如果某个操作中存在无效的状态,那么该操作必须是原子的。此外,如果在类中没有施加这种约束,那么就可以放宽封装性或序列化等需求,以便获得更高的灵活性或性能。
在类中也可以包含同时约束多个状态变量的不变性条件。如果一个表示数值范围的类可以包含两个状态变量,分别表示范围的上界和下界。这些变量必须遵循的约束是下界值应该小于或等于上界的值。这些相关变量必须在单个原子操作中进行读取或更新。不能先更新一个变量,然后释放锁,再获取锁并更新另一个变量。因为释放锁后,可能会使对象处于无效状态。如果一个不变性条件中包含多个变量,那么执行任何访问相关变量的操作时,都必须持有保护这些变量的锁。

依赖状态的操作

类的不变性条件与后验条件约束了在对象上有哪些状态及状态转换是有效的。如果在某个操作中包含有基于状态的先验条件,那么这个操作就称为依赖状态的操作。如在删除元素前,队列必须处于“非空”的状态。
单线程程序中调用一个方法时,如果基于某个状态的前提条件未得到满足,那么这个条件就无法成真。但在多线程程序中,基于状态的条件可能会由于其他线程的操作而改变。因此在编写并发程序中的类时,虽然有时在前提条件不满足的情况下不会失败,但通常一个更好的选择是:等待前提条件变为真。也即多线程场景下“状态依赖性管理”。
当前提条件未满足时,依赖状态的操作可以抛出一个异常返回一个错误状态(让调用者处理这个问题),也可以保持阻塞直到对象进入正确的状态。在实现阻塞时,可以通过轮询或休眠实现简单的阻塞,也可使用条件队列实现优雅的阻塞。

状态的所有权

垃圾回收机制使我们避免了如何处理所有权的问题。
容器类通常表现出一种“所有权分离”的形式,其中容器类拥有其自身的状态,而客户代码则拥有容器中各个对象的状态。

实例封闭(Instance Confinement)–面向对象的封装

封装简化了线程安全类的实现过程,它提供了一种实例封闭的机制(简称为“封闭”)。将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁。
被封装对象一定不能超出它们既定的作用域。对象可以封闭在类的一个实例(如作为类的一个私有成员)中,或者封闭在某个作用域内(如作为一个局部变量)在或者封闭在线程内(如在某个线程中将对象从一个方法传递给另一个方法),而不是在多个线程间共享该对象。
实例封闭是构建线程安全类的一种最简单方式,它还使得在锁策略的选择上拥有了更多的灵活性。
在Java平台的类库中有不少线程封闭的示例,其中一些类的唯一用途就是将非线程安全的类转化成线程安全的类。一些基本的容器类并非线程安全的,如ArrayList和HashMap,但类库提供了包装器工厂方法(如Collections.synchronizedList方法)使得非线程安全的类可以在多线程环境中安全地使用。
封闭机制更易于构建线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时就无需检查整个程序。

Java监视器模式–线程封闭的实际应用

从线程封闭原则及其逻辑推论可以得出Java监视器模式。遵循Java监视器模式的对象会把对象的所有可变状态都封装起来,并由对象自己的内置锁保护。Java监视器模式的主要优势就在于它的简单性。Java监视器模式仅仅是一种编写代码的约定,对于任何一种锁对象,只要自始至终都使用该锁对象,都可以用来保护对象的状态。使用私有锁保护状态的示例代码如下:

public class PrivateLock {
    private final object myLock = new Object();
    @GuardBy("myLock") Widget widget;
    void someMethod() {
        synchronized(myLock) {
            // 访问或修改Widget的状态
        }
    }
}

使用私有的锁对象而不是对象的内置锁,有许多优点:(1)避免客户代码无法得到锁,保证客户代码仅能通过公有方法来访问锁,以便参与到它的同步策略中。(2)对锁正确性使用验证仅需在当前类进行,而不用检查整个程序。

线程安全性的委托

大多数对象都是组合对象。如果类中的各个组件已是线程安全,要根据情况来判断是否需要增加一个额外的线程安全层。
如果当前对象委托给单个线程安全的状态变量,那么这个对象也是线程安全的。
如果当前对象将线程安全性委托给多个状态变量,且这些状态变量是彼此独立的,即组合而成的类并不会再其包含的多个状态变量上增加任何不变性条件,那么这个对象也是线程安全的。
如果当前对象将线程安全性委托给多个状态变量,且这些状态变量之间存在某种不变性条件,那么这个对象可能不是线程安全的。当多个状态变量之间存在某种不变性条件时,在状态变量变更时,为保证不变性条件,需要执行复合操作(如先检查后执行操作)。当某个类中含有复合操作时,仅依赖委托不足以实现线程安全性。在这种情况下,这个类必须提供自己的加锁机制,以保证这些复合操作都是原子操作,除非整个复合操作可以委托给状态变量。

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

Java类库包含许多有用的“基础模块”类。通常,应该优先选择重用这些现有的类而不是创建新的类:重用能降低开发工作量、开发风险(因为现有的类都已经通过测试且已经被大量使用)以及维护成本。
有时,某个现有的线程安全类无法支撑所有的需求,更多的时候,现有的类只能支持大部分的操作。此时,需要在不破坏线程安全性的情况下添加一个新的操作
要添加一个新的原子操作,最安全的方法是修改原始的类,但这通常无法做到,因为可能不具备访问或修改类的源代码的权限。如果能够修改原始类,就需要理解代码中的同步策略,这样增加的功能才能与原有的设计保持一致。
另一种方法是扩展这个类。假定在设计这个类时考虑了可扩展性,并向子类公开的部分状态。那么子类就可基于这些公开的状态,进行相应的扩展。
“扩展”方法比直接将代码添加到类中更脆弱,因为现有的同步策略被分布到多个单独维护的源代码文件中。如果改变了同步策略并更换了同步锁,那么子类就会被破坏。因为在同步策略变更后,它无法再使用正确的锁来控制对基类状态的并发访问。

客户端加锁机制

除了在原始类中添加一个方法或者对类进行扩展外,还有一种策略是扩展类的功能,而不是扩展类本身,也就是将扩展代码放到一个“辅助类”中。也即客户端加锁。
客户端加锁是指,对于使用某个对象X的客户端代码,使用X本身用于保护其状态的锁来保护这段客户端代码。示例代码如下:

@ThreadSafe 
public class ListHelper<E> {
    public List<E> list = Collections.synchronizedList(new ArrayList<E>());
    ...
    public boolean putIfAbsent(E x) {
        synchronized(list) {
            boolean absent = !list.contain(x);
            if(absent) {
                list.add(x);
            }
            return absent;
        }
    } 
}

客户端加锁同样很脆弱,它将类的加锁代码放到与其完全无关的类中。当在那些并不承诺加锁策略的类上使用客户端加锁时,要特别小心。
客户端加锁机制与扩展类机制有许多相似点:二者都是将派生类的行为与基类的实现耦合在一起。正如扩展会破坏实现的封装性,客户端加锁同样会破坏同步策略的封装性

组合(Composition)

当为现有的类添加一个原子操作时,有一种更好的方法:组合。示例代码中将List对象的操作委托给底层的List实例来实现List的操作。示例代码如下:

@ThreadSafe 
public class ImprovedList<T> implements List<T> {
    private final List<T> list;
    public ImprovedList(List<T> list) {
        this.list = list;
    }
    public synchronized boolean putIfAbsent(T x) {
        boolean contains = list.contains(x);
        if(contains) {
            list.add(x);
        }
        return !contians;
    }
    public synchronized void clear() {
        list.clear();
    }
    // 按照类似的方法委托List的其他方法
    ...
}

可见,使用组合后,不需要关心底层的实现是否线程安全,当前类也会提供一致的加锁机制来实现线程安全性。虽然额外的同步层可能会导致性能的损失,但与之前的策略,组合策略更为健壮。

将同步策略文档化

一般提供基础类库的作者会花时间维护文档。业务代码很少维护。缺点:需要在调整代码时,及时同步文档。

原创不易,如果本文对您有帮助,欢迎关注我,谢谢 ~_~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值