设计线程安全的类
在设计线程安全类的过程中,需要包涵以下三个基本要素:
- 找出构成对象状态的所有变量
- 找出约束状态变量的不变性条件
- 建立对象状态的并发访问管理策略
同步策略(Synchronization Policy)
定义了如何在不违背对象不变条件或后验条件的情况下对其状态的访问操作进行协同。
收集同步需求
要确保类的线程安全性,就需要保证它的不变性条件不会在并发访问的情况下被破坏。
对象和变量都有一个状态空间,即所有可能的取值。状态空间越小,就越容易判断线程的状态。final类型的域使用的越多,就越能简化对象可能状态的分析过程。
如果不了解对象的不变性条件与后验条件,那么就不能确保线程安全性。要满足在状态变量的有效值状态转化上的各种约束条件,就需要借助原子性与封装性。
依赖状态的操作
类的不变性条件与后验条件约束了对象上有哪些状态和状态转换是有效的。在某些情况,还包涵一些基于状态的先验条件(Precondition)
,例如不能获得null的引用等。
在单线程程序里,如果某个操作无法满足先验条件,就只能失败。而在并发程序中,先验条件可能会由于其他线程执行的操作而变成真,在并发程序中,要一直等到先验条件为真,然后再执行该操作。
状态的所有权
在定义哪些变量将构成对象的状态时,只考虑对象拥有的数据。如果分配了一个HashMap对象,等于创建了多个对象:
- HashMap对象
- 在HashMap对象中包涵的多个对象
- 在Map.Entry中可能包涵的内部对象
垃圾回收机制让我们避免了如何处理所有权的问题。在许多情况下,所有权和封装性是互相关联的:
- 对象封装它拥有的状态
- 对它封装的状态拥有所有权
如果发布了某个可变对象的引用,那么就不再拥有独占控制权,最多是共享控制权。对于从构造函数或者从方法中传递进来的对象,类通常并不拥有这些对象,除非这些方法是被专门设计为转移传递进来的对象的所有权(例如同步容器封装器的工厂方法)。
实例封闭
实例封闭指的是:某个对象只能由单个线程访问。
将数据封装在对象内部,可以将访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁
被封闭的对象一定不能超过它们既定的作用域。对象可以封闭在类的一个实例上(例如作为类的私有成员),或者封闭在某个作用域内(例如作为一个局部变量),再或者封闭在线程内(例如在同一线程内,将某个对象从一个方法传递到另一个方法)。
public class PersonSet {
private final Set mySet = new HashSet<>();
public synchronized void addPerson(Person person){
mySet.add(person);
}
public synchronized boolean containsPerson(Person person){
return mySet.contains(person);
}
}
- mySet是个hashset,并不是线程安全的,但是由于mySet被封闭在PersonSet对象中,且能访问mySet的方法都被同步保护起来
- 并且mySet由final修饰,所以不需要关心逸出问题。
- 如果Person类是可变的,那么在从PersonSet中获得Person时,还需要做额外的同步,要想安全地使用Person对象,最可靠的方法是将Person成为线程安全的类,当然也可以使用锁来保护Person对象,并确保所有客户代码在访问person的时候都获得了正确的锁。
封闭机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时,就无需检查整个程序。
Java监视器模式
遵循java监视器模式的对象会把所有可变状态都封装起来,并由对象自己的内置锁来保护。
在许多类中都使用了java监视器模式,例如Vector和Hashtable。java监视器模式的主要优势在于其的简单性。
使用私有的锁对象而不是对象的内置锁(或任何其他可以通过公有方式访问的锁),有许多优点:
- 私有的锁对象可以将锁封装起来,使客户代码无法获得到锁,但是客户代码可以通过公有方法来访问锁,以便参与到它的同步策略中。
- 但是如果客户代码错误的获得了另一个对象的锁,那么可能产生活跃性问题。
基于监视器模式的车辆追踪
public class MonitorVehicleTracker {
private final Map locations;
public MonitorVehicleTracker(Map locations) {
this.locations = deepCopy(locations);
}
public synchronized Map getLocations() {
return deepCopy(locations);
}
public synchronized MutablePoint getLocation(String id) {
MutablePoint loc = locations.get(id);
return loc == null ? null : new MutablePoint(loc);
}
public synchronized void setLocation(String id, int x, int y) {
MutablePoint loc = locations.get(id);
if(loc == null){
throw new IllegalArgumentException("no such id:"+id);
}
loc.x = x;
loc.y = y;
}
private static Map deepCopy(Map m) {
Map result = new HashMap<>();
for (String id : m.keySet()) {
result.put(id, new MutablePoint(m.get(id)));
}
return Collections.unmodifiableMap(result);
}
- 虽然MutablePoint对象不是线程安全的,但是这个追踪器类是线程安全的,不论是构造,还是访问,都利用了深拷贝来复制正确的值,从而生成了新的对象。
- 这种方式通过复制可变数据来维护线程安全,在通常情况下不存在什么性能问题,但是在车辆容器Map非常大的情况下,将极大的降低性能。
- 通过拷贝的方式有个错误情况,就是车辆的位置实际上已经发生了变化,但是返回的信息确实不变的。这种情况是好是坏,就要取决于用户的需求。
将多个非线程安全的类组合成为一个类时,java监视器模式是非常有用的。
线程安全性的委托
基于委托的车辆追踪器
首先,MutablePoint需要改为线程安全的Point
/**
* 通过不变性保证线程安全
*/
public class Point {
public final int x, y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
由于Point是不可变的,所以是线程安全的,final类型的值可以被自由的共享和发布。
public class DelegationVehicleTracker {
private final ConcurrentMap locations;
private final Map unmodifiableMap;
public DelegationVehicleTracker(Map points) {
locations = new ConcurrentHashMap(points);
unmodifiableMap = Collections.unmodifiableMap(locations);
}
public Map getLocations() {
return unmodifiableMap;
}
public Point getLocation(String id) {
return locations.get(id);
}
public void setLocation(String id, int x, int y) {
if(locations.replace(id,new Point(x,y)) == null){
throw new IllegalArgumentException("invalid vehicle name :"+id);
}
}
}
- getLocations返回的是一个不可修改的unmodifiableMap映射,getLocation返回的是一个线程安全的不变对象Point,location和unmodifiableMap 是在构造中通过final域发布的,所以这些操作不存在并发安全性问题。
- 但是值得一提的是,setLocation 是可以线程覆盖更新的,返回给线程的是当且最新的值,这个和之前的监视器模式呈现相反的效果:即便不再继续请求getlocation,仅是观察所保存的对象,依然可以获得最新的状态。(获得的是location的实时只读拷贝)
独立的状态变量
如果我们将线程安全性委托给多个状态变量,只要这些状态变量是各自独立的,即组合成的类并不会在其保护的多个状态变量上增加任何不变性条件。例如鼠标事件监听器和键盘事件监听器之间不存在任何关系,二者相互独立,所以可以将线程安全性委托给这两个线程安全的监听器列表。
当委托失效时
当然,大多数组合不会完全的各自独立,不存在任何关系:在它们的状态变量之间存在着某些不变性条件。
如果一个类是由多个独立且线程安全的状态变量组成,并且在所有的操作中都不包含无效状态转换,那么可以将线程安全性委托给底层的状态变量。
即使类中的各个状态组成部分都是线程安全的,也不能保证这个类一定是线程安全的。
这一观点很类似于volatile的变量规则:仅当一个变量参与到包含其他变量的不变性条件时,才可以声明为volatile
发布底层的状态变量
当把线程安全性委托给某个对象的底层状态变量时,在什么条件下才可以发布这些变量从而使其他类能修改它们?答案仍然取决于在类中对这些变量施加了哪些不变性条件。
如果一个状态变量是线程安全的,并且没有任何不变性条件来约束它的值,在变量的操作上也不存在任何不允许的状态转换,那么就可以安全的发布这个变量。
发布状态的车辆追踪器
public class SafePoint {
private int x, y;
private SafePoint(int[] a) {
this(a[0], a[1]);
}
private SafePoint(SafePoint p) {
this(p.get());
}
public SafePoint(int x, int y) {
this.x = x;
this.y = y;
}
public synchronized int[] get() {
return new int[]{x, y};
}
public synchronized void set(int x, int y) {
this.x = x;
this.y = y;
}
}
- SafePoint类提供的get方法同时获得x,y的值,并将二者放在一个组中返回。
- 如果为x和y单独提供get方法,可能会存在在获得两个不同的坐标之间,x,y值发生变化,从而导致调用者获得一个safepoint不曾到过的值。
public class PublishingVehicleTracker {
private final Map locations;
private final Map unmodifiableMap;
public PublishingVehicleTracker(Map locations){
this.locations = new ConcurrentHashMap<>(locations);
this.unmodifiableMap = Collections.unmodifiableMap(locations);
}
public Map getLocations(){
return unmodifiableMap;
}
public SafePoint getLocation(String id){
return locations.get(id);
}
public void setLocation(String id,int x,int y){
if(!locations.containsKey(id)){
throw new IllegalArgumentException("invaild vehicle name :"+id);
}
locations.get(id).set(x,y);
}
}
- PublishingVehicleTracker将线程安全性委托给底层的ConcurrentHashMap,map中的对象是线程安全的SafePoint,以此来达到线程安全的目的。
- PublishingVehicleTracker中通过getLocations获得底层只读副本,再通过setLocation修改对象状态,但是却无法增加或者删除车辆。
- 如果需要对车辆位置进行判断或者当位置变化时执行一些操作,那么PublishingVehicleTracker就不再是线程安全的了。
在现有的线程安全类中增加功能
假如,我们需要一个链表,它需要能提供一个原子的“若没有就添加的功能(Put-if-Absent)”的操作。同步的List类已经实现了大部分功能,我们可以根据它提供的contains和add方法来构造一个“若没有则添加的操作”。
要想添加一个新的原子操作,最安全的方法就是修改原始的类,但这个通常无法做到,因为无法访问或修改类的源代码。要想修改原始类,需要了解类的同步策略。这样增加的代码才能和原有的设计保持一致。
还有一种方法是拓展这个类,假定这个类在设计的时候考虑了可拓展性,通过继承该类,添加一个新方法putIfAbsent。
public class BetterVector extends Vector{
public synchronized boolean putIfAbsent(E x){
boolean absent = !contains(x);
if(absent){
add(x);
}
return absent;
}
}
拓展的代码更加脆弱,如果底层的类改变了同步策略并选择了不同的锁保护它的状态,那么子类会被破坏,因为在同步策改变后它无法再使用正确的锁来控制对基类状态的并发访问(Vector的规范中定义了它的同步策略,所以BetterVector不存在这个问题)。
客户端加锁机制
对于由Collections.synchronizedList封装的ArrayList,这两种方法在原始类或者对类进行拓展都行不通,因为客户代码并不知道在同步封装器工厂方法中返回的List对象类型。
第三种策略是拓展类的功能,但并不是拓展类本身,而是将拓展代码放入一个辅助类
中。
public class ListHelper {
public List list = Collections.synchronizedList(new ArrayList());
public boolean putIfAbsent(E x){
synchronized (list){
boolean absent = !list.contains(x);
if(absent){
list.add(x);
}
return absent;
}
}
}
将list对象本身作为锁,可以保证与list对象的其他操作都是原子的。
通过一个原子操作来拓展类是很脆弱的,因为它将类的加锁代码分布到多个类中。
组合
为现有的类添加一个原子操作,更好的方法就是:组合(Composition)
public class ImprovedList implements List {
private final List list;
public ImproveList(List list){
this.list = list;
}
public synchronized boolean putIfAbsent(T x){
boolean absent = !list.contains(x);
if(absent){
list.add(x);
}
return absent;
}
}
- ImprovedList假设把某个链表对象传给构造函数以后,客户端不会再直接使用这个对象,而是通过ImprovedList访问。
- ImprovedList通过自身的内置锁增加了一层额外的加锁,它并不关心底层的List是否是线程安全的,即使List不是线程安全的,或List修改了它的同步策略,ImprovedList也会提供一套自有的加锁机制来实现线程安全性。虽然额外的同步层会造成性能的损失,但是代码更加的健壮。
- 事实上,我们使用了java监视器模式来封装现有的list,并且只要在类中拥有指向底层List的唯一外部引用,就能保证线程安全性。