【Java并发.4】对象的组合
到目前为止,我们已经介绍了关于线程安全与同步的一些基础知识。然而,我们并不希望对每一系内存访问都进行分析以确保程序是线程安全的,而是希望将一些现有的线程安全组件组合为更大规模的组件或程序。
4.1 设计线程安全的类
通过使用封装技术,可以使得在不对整个程序进行分析的情况下就可以判断一个类是否是线程安全的。
在设计线程安全类的过程中,需要包含以下三个基本要素:
- 找出构成对象状态的所有变量。
- 找出约束状态变量的不变性条件。
- 建立对象状态的并发访问管理策略。
要分析对象的状态,首先从对象的域开始。如果对象中所有的域都是基本类型的变量,那么这些域将构成对象的全部状态。如果在对象的域中引用了其他对象,那么该对象的状态将包含被引用对象的域。
看如下清单:使用Java 监视器模式的线程安全计数器
public class Counter { private long value = 0; public synchronized long getValue() { return value; } public synchronized long increment() { if (value == Long.MAX_VALUE) { throw new IllegalArgumentException(""); } return ++value; } }
同步策略(Synchronization Policy)定义了如何在不违背对象不变条件或后验条件的情况下对其状态的访问操作进行协同。同步策略规定了如何将不可变性、线程封闭与加锁机制等结合起来以维护线程的安全性,并且还规定了那些变量由那些锁来保护。
4.1.1 收集同步需求
要确保类的线程安全性,就需要确保它的不变性条件不会在并发访问的情况下被破坏,这就需要对其状态进行推断。同样,在操作中还会包含一些后验条件来判断状态迁移是否有效的。如自增值。
由于不变性条件以及后验条件在状态及状态转换上施加了各种约束,因此就需要额外的同步与封装。如果某些状态是无效的,那么必须对底层的状态变量进行封装,否则客户代码可能会使对象处于无效状态。如果在某个操作中存在无效的状态转换,那么该操作必须是原子的。另外,如果在类中没有施加这种约束,那么就可以放宽封装性或序列化等需求,以便获得更高的灵活性或性能。
如果不了解对象的不变性条件与后验条件,那么就不能确保线程安全性。要满足在状态变量的有效值或状态转换上的各种约束条件,就需要借助于原子性与封装性。
4.1.2 依赖状态的操作
类的不变性条件与后验条件约束了在对象上有哪些状态和状态转换是有效的。如果在某个操作中包含有基于状态的先验条件,那么这个操作就称为依赖的操作。
等待某个条件为真的各种内置机制(包括等待和通知等机制)都与内置加锁机制紧密关联,要想正确地使用它们并不容易。要想实现某个等待先验条件为真时才执行的操作,一种更简单的方法是通过现有库中的类(例如阻塞队列【Blocking Queue】或信号量【Semaphore】)来实现依赖状态的行为。
4.2 实例封装
如果某对象不是线程安全的,那么可以通过多种技术使其在多线程程序中安全地使用。你可以确保该对象只能由单个线程访问(线程封闭),或者通过一个锁来保护对该对象的所有访问。
封装简化了线程安全类的实现过程,它提供了一种实例封闭机制(instance Confienement)。当一个对象被封装到另一个对象中时,能够访问被封装对象的所有代码路径都是已知的。
对数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁。
程序清单:通过封装机制来确保线程安全
public class PersonSet { private final Set<Person> mySet = new HashSet<Person>(); public synchronized void addPerson(Person p) { mySet.add(p); } public synchronized boolean containPerson(Person p) { return mySet.contains(p); } }
实例封装是构建线程安全类的一个最简单方式,它还使得在锁策略的选择上拥有了更多的灵活性。
当然,如果将一个本该本封闭的对象发布出去,那么也会破坏封闭性。如果一个对象本应该封闭在特定的作用域内,那么让该对象逸出作用域就是一个作物。当发布其他对象时,例如迭代器或内部的类实例,可能会间接地发布被封闭的对象,同样会使本封闭的对象逸出。
封闭机制更容易构造线程安全的类,因为当类封闭的状态时,在分析类的线程安全性时就无须检查整个程序。
4.2.1 Java监视器模式
从线程封闭原则及其逻辑推理可以得出Java监视器模式。遵循Java监视器模式的对象会把对象的所有可变状态都封装起来,并由对象自己的内置锁来保护。
程序清单:通过一个私有锁来保护状态
public class PrivateLock { private final Object myLock = new Object(); void someMethod() { synchronized (myLock) { //do something } } }
使用私有的锁对象而不是对象的内置锁(任何其他可通过公有方式访问的锁),有许多优点。私有的锁对象可以将锁封装起来,是客户代码无法得到锁,但客户代码可以通过公有方法来访问,以便参与到它的同步策略中。如果客户代码错误地获得了另一个对象的锁,那么可能会产生活跃性问题。此外,要想验证某个公有访问的锁在程序中是否被正确地使用,则需要检查整个程序,而不是单个的类。
4.2.2 示例:车辆追踪
以下程序清单中,我们看一个示例: 一个用于调度车辆的“车辆追踪器”。首先使用监视器模式来构建车辆追踪器,然后尝试放宽某些封装性需求同时又保持线程安全性。
public class MonitorVehicleTracker { 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 lo = locations.get(id); return lo == null ? null : new MutablePoint(lo); //返回拷贝信息 } public synchronized void setLocations(String id, int x, int y) { MutablePoint lo = locations.get(id); if (lo == null) { throw new IllegalArgumentException(""); } lo.x = x; lo.y = y; } private static Map<String, MutablePoint> deepCopy(Map<String, MutablePoint> locations) { Map<String, MutablePoint> result = new HashMap<String, MutablePoint>(); for (String id : locations.keySet()) { result.put(id, new MutablePoint(locations.get(id))); } return Collections.unmodifiableMap(result); } }
public class MutablePoint { 【不要这么做】 public int x, y; public MutablePoint() { x = 0; y = 0; } public MutablePoint(MutablePoint p) { this.x = p.x; this.y = p.y; } }
虽然类 MutablePoint 不是线程安全的,但追踪器类时线程安全的。它所包含的 Map 对象和可变的 Point 对象都未曾发布。当需要返回车辆的位置时,通过 MutablePoint 拷贝构造函数或者 deepCopy 方法来复制正确的值,从而生成一个新的Map 对象,并且该对象中的值与原有 Map 对象中的 key 值和 value 值都相同。
在某种程度上,这种实现方式是通过再返回客户代码之前复制可变的数据来维持线程安全性的。通常情况下,这并不存在性能问题,但在车辆容器非常大的情况下将极大地降低性能。
4.3 线程安全性的委托
4.3.1 示例:基于委托的车辆追踪器
下面将介绍一个更实际的委托示例,构造一个委托给线程安全类的车辆追踪器。我们将车辆位置保存到一个 实现线程安全的Map 对象中,还可以用一个不可变的 Point 类来代替 MutablePoint 以保存位置。
程序清单: 在DelegatingVehicleTracker 中使用的不可变 Point 类
public class Point { public final int x, y; public Point(int x, int y) { this.x = x; this.y = y; } }
由于Point 类时不可变的,因而它是线程安全的。 将线程安全委托给 ConcurrentHashMap。
public class DelegatingVehicleTrack { private final ConcurrentMap<String, Point> locations; private final Map<String, Point> unmodifiableMap; public DelegatingVehicleTrack(Map<String, Point> pointMap) { locations = new ConcurrentHashMap<String, Point>(pointMap); unmodifiableMap = Collections.unmodifiableMap(locations); } public Map<String, Point> getLocations() { return unmodifiableMap; } public Point getLocation(String id) { return locations.get(id); } public void setLocations(String id, int x, int y) { if (locations.replace(id, new Point(x, y)) == null) { throw new IllegalArgumentException(""); } } }
在使用监视器模式的车辆追踪器中返回的是车辆位置的快照,而在使用委托的车辆追踪器中返回的是一个不可修改但却实时的车辆位置图。
4.3.2 独立的状态变量
到目前为止,这些委托示例都仅仅委托给了单个线程安全的状态变量。我们还可以将线程安全性委托给多个状态变量,只要这些变量时彼此独立的,即组合而成的类并不会再其包含的多个状态变量上增加任何不变性条件。
程序清单:将线程安全性委托给多个状态变量
public class VisualComponent { private final List<KeyListener> keyListeners = new CopyOnWriteArrayList<>(); private final List<MouseListener> mouseListeners = new CopyOnWriteArrayList<>(); public void addKeyListener(KeyListener keyListener) { keyListeners.add(keyListener); } public void addMouseListener(MouseListener mouseListener) { mouseListeners.add(mouseListener); } public void removeKeyListener(KeyListener keyListener) { keyListeners.remove(keyListener); } public void removeMouseListener(MouseListener mouseListener) { mouseListeners.remove(mouseListener); } }
VisualComponent 使用 CopyOnWriteArrayList 来保存各个监听器列表。它是一个线程安全的链表,特别适用于管理监听器列表。
4.3.3 当委托失败时
大多数组合对象都不会像 VisualComponent 这样简单:在它们的状态变量之间存在着某些不变性条件。
程序清单:NumbeRange 类并不足以保护它的不变性条件
public class NumberRange { 【不要这样做】 //不变性条件 : lower <= upper private final AtomicInteger lower = new AtomicInteger(0); private final AtomicInteger upper = new AtomicInteger(0); public void setLower(int i) { if (i > upper.get()) { // 不安全的 先检查后执行 System.out.println("lower > upper"); return; } lower.set(i); } public void setUpper(int i) { if (i < lower.get()) { // 不安全的 先检查后执行 System.out.println("lower > upper"); return; } upper.set(i); } public boolean isInRange(int i) { return (i >= lower.get() && i <= upper.get()); } }
NumberRange 不是线程安全的,没有维持对下界和上界进行约束的不变性条件。假设取值范围在(0, 10),如果一个线程调用 setLower(5),而另一个线程调用 setUpper(4),那么在一些错误的执行时序中,这两个调用都通过了检查,并且都设置成功。因此,虽然 AtomicInteger 是线程安全的,但经过组合得到的类却不是线程安全的。
如果一个类是由多个独立且线程安全的状态变量组成,并且在所有的操作中都不包含无效状态转换,那么可以将线程安全性委托给底层的状态变量。
4.3.4 发布底层的状态变量
当线程安全性委托给某个对象的底层状态变量时,在什么条件下才可以发布这些变量从而使其他类能修改它们? 答案仍然取决于在类中对这些变量施加了那些不变性条件。
如果一个状态变量时线程安全的,并且没有任何不变性条件来约束它的值,在变量的操作上也不存在任何不允许的状态转换,那么久可以安全地发布这个变量。
4.3.5 示例:发布状态的车辆追踪器
我们来构造车辆追踪器的另一个版本,并在这个版本中发布底层的可变状态。我们需要修接口以适应这种变化,即使用可变且线程安全的 Point 类。
程序清单:线程安全且可变的 Point 类
public class SafePoint { private int x, y; public SafePoint(SafePoint sp) { this.x = sp.x; this.y = sp.y; } private SafePoint(int[] a) { this(a[0], a[1]); } 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; } }
程序清单:安全发布底层状态的车辆追踪器
public class PublishingVehicleTracker { private final Map<String, SafePoint> locations; private final Map<String, SafePoint> unmodifiableMap; public PublishingVehicleTracker(Map<String, SafePoint> locations) { this.locations = new ConcurrentHashMap<String, SafePoint>(locations); this.unmodifiableMap = Collections.unmodifiableMap(locations); } public Map<String, SafePoint> getLocations() { return unmodifiableMap; } public SafePoint getLocations(String id) { return locations.get(id); } public void setLocations(String id, int x, int y) { if (!locations.containsKey(id)) { throw new IllegalArgumentException(""); } locations.get(id).set(x, y); } }
4.4 在现有的线程安全类中添加功能
Java 类库包含许多有用的“基础模块”类。通常,我们应该优先选择重用这些现有的类而不是创建新的类:重用能降低开发工作量、开发风险以及维护成本。有时候,某个现有的线程安全类能支持我们需要的所有操作,但更多时候,现有的类智能支持大部分的操作,此时就需要在不破坏线程安全性的情况下添加一个新操作。
程序清单:扩展 Vector 并增加一个“若没有则添加”方法
public class BetterVector<E> extends Vector { public synchronized boolean putIfAbsent(E e) { boolean absent = !contains(e); if (absent) add(e); return absent; } }
“扩展”方法比直接将代码添加到类中更加脆弱,因为现在的同步策略实现被分布到多个单独维护的源代码文件中。如果底层的类改变了同步策略并选择了不同的锁来保护它的状态变量,那么子类会被破坏,因为在同步策略改变后它无法再使用正确的锁来控制对基类状态的并发访问。
4.4.1 客户端加锁机制
看一个错误例子:非线程安全的“若没有则添加”
public class ListHelper<E> { 【不要这样做】 public List<E> list = Collections.synchronizedList(new ArrayList<E>()); public synchronized boolean putIfAbsent(E e) { boolean absent = !list.contains(e); if (absent) list.add(e); return absent; } }
为什么这种方式不能实现线程安全性?毕竟,putIfAbsent 已经声明为 synchronized 类型的变量,对不对?问题在于在错误的锁上进行了同步。无论List 使用哪一个锁来保护它的状态,可以确定的是,这个锁并不是 ListHelper 上的锁。
要想使这个方法能正确执行,必须使List 在实现客户端加锁或外部加锁时使用同一个锁。
程序清单:通过客户端加锁来实现“若没有则添加”
public class ListHelper<E> { public List<E> list = Collections.synchronizedList(new ArrayList<E>()); public boolean putIfAbsent(E e) { synchronized (list) { boolean absent = !list.contains(e); if (absent) list.add(e); return absent; } } }
4.4.2 组合
当为现有的类添加一个原子操作时,有一种更好的方法:组合(Composition)。看如下程序清单:通过组合实现“若没有则添加”
public class ImprovedList<E> { private final List<E> list; public ImprovedList(List<E> list) { this.list = list; } public synchronized boolean putIfAbsent(E e) { boolean absent = !list.contains(e); if (absent) list.add(e); return absent; } public synchronized void clear() { list.clear(); } // 按照类似的方式委托List的其他方法 }
ImprovedList 通过自身的内置锁增加了一层额外的加锁。
4.5 将同步策略文档化
在维护线程安全性时,文档是最强大的(同时也是最未充分利用的)工具之一。
在文档中说明客户代码需要了解的线程安全性保证,以及代码维护人员需要了解的同步策略。