Thread专题(3) - 组合对象

时间:2022-10-11 10:57:20

此文被笔者收录在系列文章 ​​​架构师必备(系列)​​ 中

虽然前面介绍了一些线程安全与同步的基础知识,但我们不希望为了获得线程安全而去分析每次内存访问,而希望组合成更大的组件或程序安全的组件对外提供安全性。

一、设计线程安全的类

设计线程安全类的过程应该包括3个基本要素:1、确定对象状态由哪些变量构成;2、确定限制状态变量的不变约束;3、制定一个管理并发访问对象状态的策略。

这里的关键点就是对象内部数据的同步,同步策略定义了对象如何协调对其状态的访问,并且不会违反它的不变约束和后验条件以及先验条件。它规定了如何把不可变性、线程限制和锁结合起来,从而维护线程的安全性,还指明了哪些锁保护哪些变量。

收集同步需求

维护类的线程安全性意味着确保在并发访问下保护它的不变约束,这需要对其状态进行判断。很多类通过不变约束来判定某一种状态是否合法,比如计数,那么17下面必然是18。但是如果是温度,那么就不必遵循上面的17下面是18这样的规则。这就叫后验条件。

不变约束和方法的后验条件加在状态及状态转换上的约束,引入了额外的同步与封装的需要。如果某些状态是非法的,则必须封装该状态下的状态变量,否则客户代码会将对象置于非法状态。如果一个操作的过程中出现非法置换,则这个操作必须是原子的。另一方面,如果类并未强制任何约束,那么可以开放一些类的封装和序列化的条件以获得更佳的灵活性

不理解对象的不变约束和后验条件,就不能保证线程的安全性。要约束状态变量的有效值或状态置换,就需要原子性与封装性

有些时候也要验证先验条件,比如不能从空队列中移除一个元素,所以有时用wait和notify方法,但这些方法并不容易使用,不如用blockingQueue、semaphore及其它同步工具更保险。

容器类通常表现出一种“所有权分离”的形式,即指容器拥有容器框架的状态,而客户代码拥有存储在容器中的对象的状态。比如servlet的设计,存在request中的对象由客户代码持有,而存在ServletContext中的对象必须由容器共享。

实例限制

将数据封装在对象内部,把对数据的访问限制在对象的方法上,更易确保线程在访问数据时总能获得正确的锁。被限制对象一定不能逸出它的期望可用范围之外。

下面的例子中,非线程安全的HashSet管理着PersionSet的状态,不过由于mySet是私有的,不会逸出,唯一可以访问mySet的代码路径是addPerson与containsPerson,执行它们需要获得PersonSet的锁,PersonSet的内部锁保护了它所有的状态,因而确保了PersonSet是线程安全的。

这种方法称为实例限制,比如类库中的ArrayList和HashMap都是非线程安全的,但通过包装器工厂方法(Collections.synchronizedList及其同族的方法),使这些非线程安全的类可以安全地用于多线程的环境中。这些工厂方法利用了Decorator(装饰者)模式。通过这种方式在安全检查时就不必检查完整的程序。

@ThreadSafe
public class PersonSet {
@GuardedBy("this") private final Set<Person> mySet = new HashSet<Person>();
public synchronized void addPerson(Person p) {
mySet.add(p);
}
public synchronized boolean containsPerson(Person p) {
return mySet.contains(p);
}
interface Person {
}
}

Java监视器模式

线程限制原则的直接推论之一是 java监视器模式。遵循java监视器模式的对象封装了所有的可变状态,并由对象自己的内部锁保护。比如Vector和Hashtable都使用了java监视器模式,这种模式是一种习惯约定,优点在于简单。它的原理就是自定义锁,人为的控制方法访问和写入时要用到同一把锁。

public class PrivateLock {
private final Object myLock = new Object();
@GuardedBy("myLock") Widget widget;
void someMethod() {
synchronized (myLock) {
// Access or modify the state of widget
}
}
}

使用私有锁对象,而不是对象的内部锁有很多好处,好处之一就是客户代码无法得到它。然而可公共访问的锁允许客户代码涉足它的同步策略--正确或不正确的,客户不正确的得到另一个对象的锁,会引起活跃度方面的问题。

监视器例子的两个实例还可以有这两种实现复制和委托:1、同步方式,每次都复制一下线程,相对于数据量小的程序还可以,如果数据量大就会影响性能;2、委托线程安全,如果一个类由多个彼此独立的线程安全的状态变量组成,并且类的操作不包含任何无效状态转换时,可以将线程安全委托给这些状态变量。否则必须加锁来保证其完整性。

发布底层的状态变量

如果一个状态变量是线程安全的,没有任何不变约束限制它的值,并且没有任何状态转换限制它的操作,那么它可以被安全发布。

二、向已有的线程安全类添加功能

比如我们向一个线程安全的List中添加一个”缺少即加入“操作,这需要检查再运行,这时就要加锁了,必须保证操作的原子性。以后写程序也要注意检查再运行的线程警告,前提是检查的对象是个共享的对象。

修改原始类 

添加一个新原子操作最安全的方式是:修改原始的类,以支持期望的操作,但有时会受到限制,比如没有源码等等。

扩展原始类

另一个方法是扩展这个类,但各种操作可能会分散到多个源文件中,比上一种方法更脆弱。如果底层的类选择了不同的锁保护它的状态变量,子类从而会破坏它的同步策略,因为它不能用正确的锁控制对基类状态的并发访问。但Vector不会遇到这样的问题。

@ThreadSafe
public class BetterVector <E> extends Vector<E> {
public synchronized boolean putIfAbsent(E x) {
boolean absent = !contains(x);
if (absent)
add(x);
return absent;
}
}//Vector的同步策略已由其规约固定住,所以不会遇到上面提到的问题。

客户端加锁

为了添加另一个原子操作而去扩展一个类很容易出问题,是因为它将加锁的代码分布到对象继承体系中的多个类中,但客户端加锁更加脆弱。客户端加锁与扩展类的方式都会破坏类的封装性不建议使用。

下面这个例子之所以错误是因为同步行为发生在错误的锁上,无论List使用哪个锁保护它的状态,这个锁都和ListHelper无关。这就意味着putIfAbsent对于List的其他操作并不是原子化的。当putIfAbsent操作执行时,不能保证另一个线程不会修改list。

@NotThreadSafe
class BadListHelper <E> {
public List<E> list = Collections.synchronizedList(new ArrayList<E>());

public synchronized boolean putIfAbsent(E x) {
boolean absent = !list.contains(x);
if (absent)
list.add(x);
return absent;
}
}

为了让这个方法正确工作,必须保证方法所使用的锁与list用于客户端加锁与外部加锁时所用的是同一个锁。或把list修改为private类型或是用下面的方式。

@ThreadSafe
class GoodListHelper <E> {
public List<E> list = Collections.synchronizedList(new ArrayList<E>());

public boolean putIfAbsent(E x) {
synchronized (list) {
boolean absent = !list.contains(x);
if (absent)
list.add(x);
return absent;
}
}
}

组合(composition)

向已有的类中添加一个原子操作,最好的方法是组合,采用委托的方式。引入一个新的锁层,这个层不关心底层是否是线程安全的。但这种做法会带来性能上的损失。下面这个例子注意下list的定义。ImprovedList持有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 !contains;
}