引:我们不希望每一次访问内存都进行分析以确保程序是线程安全的,而是希望将一些现有的线程安全组件组合为更大规模的组件或程序。
设计线程安全的类
在设计线程安全类的过程中,需要包含以下三个基本要素:
- 找出构成对象状态的所有变量
- 找出约束状态变量的不变性条件
- 建立对象的并发访问管理策略
同步策略规定了如何将不变性、线程封闭、加锁机制等结合起来以维护线程的安全性,并且规定了哪些变量由哪些锁来保护。要确保开发人员可以对这个类进行分析与维护,就必须将同步策略写成正式文档。
收集同步需求
要确保类的线程安全性,就需要确保它的不变性条件不会再并发访问时被破坏,这就需要对其状态进行推断。在许多类中都定义了一些不可变条件,用于判断状态是有效的还是无效的。同样,在操作中还包含一些后验条件来判断状态转换是否有效。当下一个状态需要依赖当前状态时,这个操作就必须是一个复合操作。如果不了解对象的不变性与后验条件,那么就不能确保线程安全性,要满足状态变量的有效值或状态转换上的各种约束条件,就需要借助于原子性与封装性。
依赖状态的操作
类的不变性条件和后验条件限制了对象的有效状态已经状态转换的有效性。有些对象包含一些基于状态的先验条件,例如,不能从空队列中移除一个元素。如果在操作中包含基于状态的先验条件,那么这个操作就叫做依赖状态操作。
在并发程序中要一直等到先验条件为真然后再进行操作,可以使用现有库的类(阻塞队列
[Blocking Queue]或信号量[Semaphore])来实现依赖状态的行为。
状态的所有权
多数情况下,所有权与封装性是相互关联的:对象封装它拥有的状态,也对它封装的状态拥有所有权。状态变量的所有者将决定采用何种加锁协议来维持变量状态的完整性。所有权意味着控制权。如果发布了某个可变对象的引用,那么原来的所有者就不再独占控制权了,就变成共享控制权了。
实例封闭
封装简化了线程安全类的实现过程,它提供了一种实例封装机制,也简称为封闭。将数据封装在对象内部,就可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁。
可以看下面的例子:1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class PersonSet {
"this") (
private final Set<Person> mySet = new HashSet<>();
public synchronized void addPerson(Person p) {
mySet.add(p);
}
public synchronized boolean containsPerson(Person p) {
return mySet.contains(p);
}
}
PersonSet类说明了如何通过将mySet封闭在一个类属性中以及使用加锁机制使一个类成为线程安全的。PersonSet的状态由HashSet来管理,而HashSet不是线程安全的,但由于mySet是私有的并且不会逸出,因此HashSet被封闭在PersonSet中。唯一能访问mySet的代码路径是addPerson和containsPerson两个方法,在执行它们时都要获得PersonSet的内置锁,所以PersonSet的状态完全由它的内置锁保护,因而PersonSet是一个线程安全的类。
线程封闭的作用可以将非线程安全的类转化为线程安全的类。可以利用到装饰器模式。封闭机制更易于构造线程安全的类,因为在分析线程安全性时可以只分析该类而不用检查整个程序。
Java监视器模式
Java的内置锁也称为监视器锁或监视器。所以使用内置锁来保证线程安全性的模式就叫做Java监视器模式。遵循Java监视器模式的对象会把对象的所有可变状态都封装起来,并由对象自己的内置锁来保护。Java监视器模式仅仅是一种编写代码的乐队,对于任何一种锁对象,只要自始至终都使用该锁对象,都可以用来保护对象的状态。(不知道是不是只要读和写方法只要都保持同步就好了?)
线程安全的委托(需要好好理解的,日后加深)
在某些情况下,通过多个线程类组合而成的类是线程安全的,而在某些情况下,这仅仅是一个好的开端,但却是线程不安全的(由于没有维持不变性约束)。
如果一个类是由多个独立且线程安全的状态变量组成,并且在所有的操作中都不包含无效状态转换,那么可以将线程安全性委托给这些状态变量。
如果一个状态变量是线程安全的,也不参与任何不变性条件,也没有操作上的状态变换,那这个变量就可以发布出去。
在现有的线程安全类中添加功能
Java类库包中包含许多有用的“基础模块”类,通常,我们应该优先选择重用这些现有的类而不是创建新的类:重用能降低开发工作量、开发风险(因为现有的类都已经通过测试)以及维护成本。我们需要在不破坏线程安全性的情况下添加一个新的操作。
要添加一个新的原子操作,最安全的方法是修改原始类,但这通常无法做到,因为可能无法访问或修改类的源代码。如果直接将新方法添加到类中,那么意味着实现同步策略的所有代码仍然处于一个源文件中,从而更容易维护。另一种方法是用子类扩展这个类,但这样的话同步策略的实现就分布在了多个需要单独维护的源文件中,如果父类修改了同步策略选择不同的锁来保护它的状态变量,那子类也需要跟着变。如下面的代码:1
2
3
4
5
6
7
8
9
10
11// 扩展Vector并增加一个“若没有则添加”方法
public class BetterVector<E> extends Vector<E> {
public synchronized boolean putIfAbsent(E x) {
boolean absent = !contains(x);
if (absent) {
add(x);
}
return absent
}
}
客户端加锁机制
第三种策略是扩展类的功能,但并不是扩展类本身,而是将扩展方法放在一个辅助类(Helper class)中。代码如下:1
2
3
4
5
6
7
8
9
10
11public class ListHelper<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;
}
}
putIfAbsent用的是ListHelper的内置锁,但list用的肯定不是ListHelper的锁,尽管所有的list操作都被声明为synchronized,但却是不一样的锁,这就无法确保当putIfAbsent执行时另一个线程不会修改这个list。
要想使这个方法正确执行,必须使list在实现客户端加锁或外部加锁时使用同一个锁。客户端加锁是指,对于使用某个对象X的客户端代码,使用X本身用于保护其状态的锁来保护这段客户端代码。要使用客户端加锁,就必须知道对象X使用的是哪一个锁。在Vector和同步封装器类的文档中指出,它们通过使用Vector或封装器容器的内置锁来支持客户端加锁。所以修改后的putIfAbsent如下:1
2
3
4
5
6
7
8
9
10
11
12
13public class ListHelper<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;
}
}
}
组合
更好地为现有类添加原子操作的方法是:组合。示例如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17public 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;
}
public synchronized void clear() {
list.clear();
}
}
ImprovedList通过自身的内置锁增加了一层额外的加锁。它并不关心List是否是线程安全的,即使List不是线程安全的或者修改了它的加锁实现,ImprovedList也会提供一致的加锁机制来实现线程安全性。事实上,我们使用了Java监视器模式来封装现有的List,并且只要在类中拥有指向底层List的唯一外部引用,就能确保线程安全性。
总结
设计一个线程安全的类方法有很多种,这里提到了三点:实例封闭、线程委托、复用现有基础类。但是具体实现线程安全都不一样,需要按实际情况来确定。
参考
- 《Java并发编程实战》