设计线程安全的类
设计安全的类的过程,需要包含三个要素:
- 找出构成对象状态的所有变量
- 找出状态变量的约束
- 建立对象状态的并发访问策略
这三条也是本篇文章的核心,以下将会逐一展开对象状态和约束都是什么,以及重点,如何结合三条要素,去设计并发访问策略。有三种常用的模式,封闭实例,委托,拓展。
状态和约束
状态就是对象中的状态变量,约束则是涉及到并发不可变性的种种约束。之所以放在一起讲,是因为本身约束就是作用于变量上。
找到状态和约束的过程,实际上也是收集同步需求的过程。
状态
对象的所有域都可能是状态,只要它们不是不可变的,如果域为引用类型,则引用对象的域也需要进行分析。
所以每个对象都会有一个状态变化空间,空间越小,这种分析越容易。从实践的角度来看,final的类型越多,不可变状态越多,这种分析越容易。
约束
状态之上的约束有三种:
- 前置:先验条件。例如,判断集合为空才可以操作。
- 后置:后验条件。例如,如果当前状态为14那么下一个状态必定是15。
- 不变性条件。例如,NumberRange中low < high。
这部分,我认为简单理解为状态上的约束即可。不管是多个之间的不可变性条件,或者是集合不可变等等,通通都属于状态上的约束。
封闭实例机制
封闭实例机制,就不得不说到Java监视器模式。Java监视器模式是实现封闭实例机制最常见的方式。
所谓的封闭实例机制,就是把对象所有可变状态都封装起来,并由对象自己的内置锁来保护。
它非常好理解。简单来说就是封装状态,提供受限的同步接口进行访问。例如以下代码,只要Person是线程安全的,那么mySet就是线程安全的。
@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);
}
}
我们尝试用开篇提到的三个要素来分析以下:
- 状态和约束
- mySet,添加需要线程安全
- Person,线程安全类(假设)
- 同步策略
- 使用Java监视器模式,对mySet进行封装,提供同步的添加和访问接口
- 用this锁保护状态的访问修改。
再来看一个更加复杂的例子,这个例子将会贯穿后文。
我们需要观察一个车辆位置的集合,跟踪车辆位置,例如绘图线程专门绘制这些点。我们需要获得某个点的位置,设置某个点的位置,获得所有车辆位置的视图。
这样的需求需要线程安全性,一种基于Java监视器的实现如下。
虽然MutablePoint不是线程安全的,但是MonitorVehicleTracker是线程安全的,它包含的map和point都没有发布(使用了保护性拷贝),并不会出现一致性问题(例如if 某个状态
,进入该分支后被中断,切回来发现状态变动,导致一致性问题)。
@ThreadSafe
public class MonitorVehicleTracker {
@GuardedBy("this")
private final Map<String, MutablePoint> locations;
public MonitorVehicleTracker(
Map<String, MutablePoint> locations) {
this.locations = deepCopy(locations);
}
public synchronized Map<String, MutablePoint> 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<String, MutablePoint> deepCopy(
Map<String, MutablePoint> m) {
Map<String, MutablePoint> result = new HashMap<String, MutablePoint>();
for (String id : m.keySet())
result.put(id, new MutablePoint(m.get(id)));
return Collections.unmodifiableMap(result);
}
}
@NotThreadSafe
public class MutablePoint {
public int x, y;
public MutablePoint() {
x = 0;
y = 0;
}
}
我们来分析一下:
- 状态和约束
- locations:集合不得增减,访问修改需要线程安全
- point:没有约束,但是Point的x,y更新获取要一致
- 同步策略,
- 使用Java监视器模式
- 提供locations的深拷贝,没有发布该状态,也不会发布Point
- 使用synchronized,this锁保护状态修改访问的安全性
线程安全性的委托
事实上这个非常好理解,就是我们将这种线程安全性的需要,委托给其他线程安全性的类。
例如委托给AtomicLong,可以保证该变量的自增的线程安全性。
我们考虑如何使用委托设计上述车辆位置跟踪案例。
@Immutable
public class Point {
public final int x, y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
@ThreadSafe
public class DelegatingVehicleTracker {
private final ConcurrentMap<String, Point> locations;
private final Map<String, Point> unmodifiableMap;
public DelegatingVehicleTracker(Map<String, Point> points) {
locations = new ConcurrentHashMap<String, Point>(points);
unmodifiableMap = Collections.unmodifiableMap(locations);
}
public Map<String, Point> 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);
}
}
- 状态和约束
- locations:集合不得增减,集合访问修改需要线程安全
- point:没有约束,但point的x,y两个状态的获取和更新需要一致
- 同步策略
- 使用Java监视器模式
- locations委托给线程安全的ConcurrentMap,保证修改访问的安全性
- 提供不可变视图unmodifiableMap,保证集合不会增减(不可变视图后文会讲解)
- Point改写为不可变类,使得unmodifiableMap获得的Point引用也不可变
状态安全发布
审视上面例子,我们思考一下,Point随着unmodifiableMap被发布了,但是Point是没有约束的(在本例的需求中),是否有必要不可变?
答案是否定的。
如果一个状态变量是线程安全的,并且没有任何不变性条件来约束它的值,在变量的操作上也不存在任何不允许的状态转换,那么就可以安全地发布这个变量。
所以当前例子可以改为以下代码,它获得的位置集合不可变,但是具体的位置却可以自由更新,这因为这Point可以变动。
@ThreadSafe
public class PublishingVehicleTracker {
private final Map<String, MutablePoint> locations;
private final Map<String, MutablePoint> unmodifiableMap;
public PublishingVehicleTracker(
Map<String, MutablePoint> locations) {
this.locations = new ConcurrentHashMap<String, MutablePoint>(locations);
this.unmodifiableMap = Collections.unmodifiableMap(this.locations);
}
public Map<String, MutablePoint> getLocations() {
return unmodifiableMap;
}
public MutablePoint getLocation(String id) {
return locations.get(id);
}
public void setLocation(String id, int x, int y) {
if (!locations.containsKey(id))
throw new IllegalArgumentException(
"invalid vehicle name: " + id);
locations.get(id).set(x, y);
}
}
Point不可变的情况下,它没有任何约束,但当它可变的时候,它有一个隐含的约束,就是Point本身x,y两个状态的更新要一致——不能跟新了一个被中断。所以正确的设计是这样的,你能按照三要素分析一下这个案例的线程安全设计吗?
@ThreadSafe
public class SafePoint {
@GuardedBy("this")
private int x, y;
private SafePoint(int[] a) {
this(a[0], a[1]);
}
public SafePoint(SafePoint p) {
// 如果this(p.x, p.y)那就会产生竞态条件,需要同时获取
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;
}
}
拓展现有线程安全类
编写线程安全类是个技术活,所以一种更建议的方式是复用。复用能降低开发工作量、开发风险和维护成本。
如何拓展线程安全类呢?有四种方法,我们来分析一下。
修改源代码
修改源代码需要理解源代码的同步策略,让拓展和原来的策略保持一致。
这当然很好,容易维护和理解。
但问题在于源代码很多时候都是我们无法修改的。
继承
@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类添加了线程安全的putIfAbsent方法。
为何不好
拓展方法比直接将代码添加到类中更加脆弱,因为同步策略被分布到多个单独维护的类中,如果底层类改变了同步策略,例如选用了不同的锁来维护,那么子类就会被破坏。
客户端加锁
客户端加锁说的是,当我们知道源代码使用什么锁的时候,获取这个锁,利用这个锁编写helper类,来拓展我们的方法。
@ThreadSafe
public 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;
}
}
}
为什么需要获取原来的锁,我们可以理解下面的错误实现来理解这个问题。
下面的实现不是线性安全的,因为它和原来的List使用了不同的锁,所以它根本没办法使得添加和获取操作同步。
@NotThreadSafe
public 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;
}
}
为何不好
同样,客户端加锁比起继承的方式甚至更加脆弱,因为它把List策略放到了一个和List完全无关其他类中,这样一来,所有地方都必须由客户端保证遵守客户端的策略(这可能也是一种Ad-hoc)。
例外,客户端加锁和继承方式有很多共同点,二者都是将派生类的行为与基类的实现耦合在一起,正如继承会破坏封装性,客户端加锁也会破坏封装性。
组合
一种更好的方式是使用组合,具体而言,就是实现相同的接口,并在原来的接口上进行拓展。
@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;
}
public synchronized void clear() {
list.clear();
}
// ... similarly delegate other List methods
}
好在哪里
它并不关心底层的List是否是线程安全的,即使它修改了它的实现或者同步策略,ImprovedList也提供了一致性的加锁机制。事实上,这也是Java监视器模式的一个实例。
不可变视图
使用这种方式,还可以为已有集合提供不可变的视图,例如上文我们经常使用的Collections.synchronizedList
,就是JDK提供的工具类实现。
源码如下:
public static <T> List<T> synchronizedList(List<T> list) {
return (list instanceof RandomAccess ?
new SynchronizedRandomAccessList<>(list) :
new SynchronizedList<>(list));
}
static class SynchronizedList<E>
extends SynchronizedCollection<E>
implements List<E> {
private static final long serialVersionUID = -7754090372962971524L;
final List<E> list;
SynchronizedList(List<E> list) {
super(list);
this.list = list;
}
public E get(int index) {
synchronized (mutex) {
return list.get(index);
}
}
public E set(int index, E element) {
synchronized (mutex) {
return list.set(index, element);
}
}
// ...
}