4.2 实例封闭
如果某对象不是线程安全的,那么可以通过多种技术使其在多线程程序中安全地使用。你可以确保该对象只能由单个线程访问(线程封闭),或者通过一个锁来保护对该对象的所有访问。
封装简化了线程安全类的实现过程,它提供了一种实例封闭机制(Instance Confinement),通常也简称为“封闭”[CPJ 2.3.3]。当一个对象被封装到另一个对象中时,能够访问被封装对象的所有代码路径都是已知的。与对象可以由整个程序访问的情况相比,更易于对代码进行分析。通过将封闭机制与合适的加锁策略结合起来,可以确保以线程安全的方式来使用非线程安全的对象。
将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁。
被封闭对象一定不能超出它们既定的作用域。对象可以封闭在类的一个实例(例如作为类的一个私有成员)中,或者封闭在某个作用域内(例如作为一个局部变量),再或者封闭在线程内(例如在某个线程中将对象从一个方法传递到另一个方法,而不是在多个线程之间共享该对象)。当然,对象本身不会逸出——出现逸出情况的原因通常是由于开发人员在发布对象时超出了对象既定的作用域。
程序清单4-2 中的PersonSet说明了如何通过封闭与加锁等机制使一个类成为线程安全的(即使这个类的状态变量并不是线程安全的)。PersonSet的状态由HashSet来管理的,而HashSet并非线程安全的。但由于mySet是私有的并且不会逸出,因此HashSet被封闭在PersonSet中。唯一能访问mySet的代码路径是addPerson与containsPerson,在执行它们时都要获得PersonSet上的锁。PersonSet的状态完全由它的内置锁保护,因而PersonSet是一个线
需要注意的是,虽然HttpSession对象在功能上类似于Servlet框架,但可能有着更严格的要求。由于Servlet 容器可能需要访问HttpSession中的对象,以便在复制操作或者钝化操作(Passivation,指的是将状态保存到持久性存储)中对它们序列化,因此这些对象必须是线程安全的,因为容器可能与Web Application 程序同时访问它们。(之所以说“可能”,是因为在Servlet的规范中并没有明确定义复制与钝化等操作,这只是大多数Servlet容器的一个常见功能。)
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);
}
}
这个示例并未对Person的线程安全性做任何假设,但如果Person类是可变的,那么在访问从PersonSet 中获得的Person对象时,还需要额外的同步。要想安全地使用Person对象,最可靠的方法就是使Person成为一个线程安全的类。另外,也可以使用锁来保护Person对象,并确保所有客户代码在访问Person对象之前都已经获得正确的锁。
实例封闭是构建线程安全类的一个最简单方式,它还使得在锁策略的选择上拥有了更多的灵活性。在PersonSet中使用了它的内置锁来保护它的状态,但对于其他形式的锁来说,只要自始至终都使用同一个锁,就可以保护状态。实例封闭还使得不同的状态变量可以由不同的锁来保护。(后面章节的ServerStatus中就使用了多个锁来保护类的状态。)
在Java 平台的类库中还有很多线程封闭的示例,其中有些类的唯一用途就是将非线程安全的类转化为线程安全的类。一些基本的容器类并非线程安全的,例如ArrayList和HashMap,但类库提供了包装器工厂方法(例如Collections. synchronizedList 及其类似方法),使得这些非线程安全的类可以在多线程环境中安全地使用。这些工厂方法通过“装饰器(Decorator)”模式(Gamma et al.,1995)将容器类封装在一个同步的包装器对象中,而包装器能将接口中的每个方法都实现为同步方法,并将调用请求转发到底层的容器对象上。只要包装器对象拥有对底层容器对象的唯一引用(即把底层容器对象封闭在包装器中),那么它就是线程安全的。在这些方法的Javadoc中指出,对底层容器对象的所有访问必须通过包装器来进行。
当然,如果将一个本该被封闭的对象发布出去,那么也能破坏封闭性。如果一个对象本应该封闭在特定的作用域内,那么让该对象逸出作用域就是一个错误。当发布其他对象时,例如迭代器或内部的类实例,可能会间接地发布被封闭对象,同样会使被封闭对象逸出。
封闭机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时就无须检查整个程序。
4.2.1 Java 监视器模式
从线程封闭原则及其逻辑推论可以得出Java监视器模式。遵循Java 监视器模式的对象会把对象的所有可变状态都封装起来,并由对象自己的内置锁来保护。
在程序清单4-1的Counter中给出了这种模式的一个典型示例。在Counter中封装了一个状态变量value,对该变量的所有访问都需要通过Counter的方法来执行,并且这些方法都是同步的。
在许多类中都使用了Java 监视器模式,例如Vector和Hashtable。在某些情况下,程序需要一种更复杂的同步策略。第11章将介绍如何通过细粒度的加锁策略来提高可伸缩性。Java监视器模式的主要优势就在于它的简单性。
Java 监视器模式仅仅是一种编写代码的约定,对于任何一种锁对象,只要自始至终都使用该锁对象,都可以用来保护对象的状态。程序清单4-3 给出了如何使用私有锁来保护状态。
public class PrivateLock {
private final Object myLock=new Object();
@GuardedBy("myLock") Widget widget;
void someMethod(){
synchronized(myLock){
//访问或修改Widget的状态
}
}
}
使用私有的锁对象而不是对象的内置锁(或任何其他可通过公有方式访问的锁),有许多优点。私有的锁对象可以将锁封装起来,使客户代码无法得到锁,但客户代码可以通过公有方法来访问锁,以便(正确或者不正确地)参与到它的同步策略中。如果客户代码错误地获得了另一个对象的锁,那么可能会产生活跃性问题。此外,要想验证某个公有访问的锁在程序中是否被正确地使用,则需要检查整个程序,而不是单个的类。
4.2.2 示例:车辆追踪
程序清单4-1 中的Counter是一个简单但用处不大的Java监视器模式示例。我们来看一个更有用处的示例:一个用于调度车辆的“车辆追踪器”,例如出租车、警车、货车等。首先使用监视器模式来构建车辆追踪器,然后再尝试放宽某些封装性需求同时又保持线程安全性。
每台车都由一个String 对象来标识,并且拥有一个相应的位置坐标(x,y)。在VehicleTracker 类中封装了车辆的标识和位置,因而它非常适合作为基于MVC (Model-View-Controller,模型-视图-控制器)模式的GUI应用程序中的数据模型,并且该模型将由一个视图线程和多——
⊖ 虽然Java 监视器模式来自于Hoare 对监视器机制的研究工作(Hoare,1974),但这种模式与真正的监视器类之间存在一些重要的差异。进入和退出同步代码块的字节指令也称为monitorenter 和monitorexit,而Java 的内置锁也称为监视器锁或监视器。
个执行更新操作的线程共享。视图线程会读取车辆的名字和位置,并将它们显示在界面上:
Map<String, Point>locations =vehicles. getLocations();
for (String key :locations. keySet())
renderVehicle(key, locations. get(key));
类似地,执行更新操作的线程通过从GPS 设备上获取的数据或者调度员从GUI界面上输入的数据来修改车辆的位置。
void vehicleMoved(VehicleMovedEvent evt){
Point loc =evt. getNewLocation();
vehicles. setLocation(evt. getVehicleId(), loc. x, loc. y);
}
视图线程与执行更新操作的线程将并发地访问数据模型,因此该模型必须是线程安全的。程序清单4-4 给出了一个基于Java 监视器模式实现的“车辆追踪器”,其中使用了程序清单4-5中的MutablePoint来表示车辆的位置。
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);
}
}
public class MutablePoint{/*程序清单4-5*/}
虽然类MutablePoint不是线程安全的,但追踪器类是线程安全的。它所包含的Map对象和可变的Point 对象都未曾发布。当需要返回车辆的位置时,通过MutablePoint拷贝构造函数或者deepCopy 方法来复制正确的值,从而生成一个新的Map 对象,并且该对象中的值与原有Map对象中的key 值和value 值都相同。
this. x = p. x;public MutablePoint(MutablePoint p){
this. y = p. y;.
}
}
在某种程度上,这种实现方式是通过在返回客户代码之前复制可变的数据来维持线程安全性的。通常情况下,这并不存在性能问题,但在车辆容器非常大的情况下将极大地降低性能⑤。此外,由于每次调用getLocation就要复制数据,因此将出现一种错误情况——虽然车辆的实际位置发生了变化,但返回的信息却保持不变。这种情况是好还是坏,要取决于你的需求。如果在location集合上存在内部的一致性需求,那么这就是优点,在这种情况下返回一致的快照就非常重要。然而,如果调用者需要每辆车的最新信息,那么这就是缺点,因为这需要非常频繁地刷新快照。