我们并不希望每一次内存访问都进行分析以确保程序时线程安全的,而是希望将一些现有的线程安全组件组合为更大规模的组件或程序。本章将介绍一些组合模式,这些模式能够将一个类更容易成为线程安全的,并且在维护这些类时不会无意中破坏类的安全性保证。
通过使用封装技术,可以使得在不对整个程序进行分析的情况下就可以判断一个类是否时线程安全的。
在设计线程安全类的过程中,需要包含以下三个基本要素:
* 找出构成对象状态的所有变量。
* 找出约束状态变量的不变性条件。
* 建立对象状态的并发访问管理策略。
要分析对象的状态,首先从对象的域开始。如果对中所有的域都是基本类型的变量,那么这些域将构成对象的全部状态。如果在对象的域中引用了其他对象,那么该对象的状态就包含被引用对象的域。
同步策略定义了如何在不违背对象不变性条件或后验条件的情况下对其状态的访问操作进行协同。同步策略规定了如何将不可变性、线程封闭与加锁机制等结合起来以维护线程的安全性,并且还规定了哪些变量由哪些锁来保护。要确保开发人员可以对这个类进行分析与维护,就必须将同步策略写为正式文档。
要确保类的线程安全性,就需要确保它的不变性条件不会在并发访问的情况下被破坏,这就需要对其状态进行推断。对象与变量都有一个状态空间,即所有可能的取值。状态空间越小,就越容易判断线程的状态。final类型的域使用的越多,就能简化对象可能状态的分析过程。(不可变对象只有唯一的状态)
许多类中定义了一些不可变条件,拥有判断状态是有效的还是无效的。long类型的变量,其状态空间为从Long.MIN_VALUE到Long.MAX_VALUE,但Counter中value取值范围存在限制,即不能是负值。
在操作中还会包含一些后验条件来判断状态迁移是否是有效的。如果Counter的当前状态是17,那么下一个有效状态只能是18。当下一个状态需要依赖当前状态时,这个操作就必须是一个复合操作。并非所有的操作都会在状态转换上施加限制,例如,当更新一个保存当前温度的变量时,该变量之前的状态并不会影响计算结果。
由于不变性条件以及后验条件在状态及状态转换上施加了各种约束,因此就需要额外的同步与封装。如果某些状态是无效的,那么必须对底层的状态变量进行封装,否则客户端代码可能会使对象处于无效状态。如果在某个操作中存在无效的状态转换,那么该操作必须是原子的。如果没有施加这种约束,那么就可以放宽封装性或序列化需求,以便获得更高的灵活性或性能。
在类中也可以包含同时约束多个状态变量的不变性条件。在一个表示数值范围的类中可以包含两个状态变量,分别表示范围的上界和下界。这些变量必须遵循的约束是,下界值应该小于或等于上界值。类似于这种包含多个变量的不变性条件将带来原子性需求:这些相关的变量必须在单个原子操作中进行读取或更新。不能首先更新一个变量,然后释放锁并再次获得锁,然后再更新其他的变量。因为在释放锁后,可能会使对象处于无效状态。如果在一个不变性条件中包含多个变量,那么在执行任何访问相关变量的操作时,都必须持有保护这些变量的锁。
如果不了解对象的不变性条件后后验条件,那么就不能确保线程安全性。要满足在状态变量的有效值或状态转换上的各种约束条件,就需要借助于原子性与封装性。
类的不变性条件与后验条件月份数了在对象上有哪些状态和转换是有效的。在某些对象的方法中还包含一些基于状态的先验条件。例如,不恩能够从空队列中移除一个元素,在输出元素前,队列必须处于“非空的”状态。如果在某个操作中包含有基于状态的先验条件,那么这个操作就称为依赖状态的操作。
在单线程程序中,如果某个操作无法满足先验条件,那么就只能失败。但在并发程序中,先验条件可能会由于其他线程执行的操作而变为真。在并发程序中要一直等到先验条件为真,然后再执行该操作。
在Java中,等待某个条件为真得各种内置机制,(包括等待和通知等机制)都与内置加锁机制紧密关联,要想正确地使用他们并不容易。要想实现某个等待先验条件为真时才执行的操作,一种更简单的方法是通过现有库中的类(例如阻塞队列【BlockingQueue】或信号量【Semaphore】)来实现依赖状态的行为。
如果以某个对象为根节点构造一张对象图,那么该对象的状态将是对象图中所有对象包含的域的一个子集。
在定义哪些变量将构成对象的状态时,只考虑对象拥有的数据。如果分配并填充了一个HashMap对象,那么就相当于创建多个对象:HashMap对象,在HashMap对象中包含的多个对象,以及在Map.Entry中可能包含的内部对象。HashMap对象的逻辑状态包括所有的Map.Entry对象以及内部对象,及时这些对象都是一些独立的对象。
所有权与封装性总是相互关联的:对象封装它拥有的状态,反之也成立,对它封装的状态拥有所有权。状态变量的所有者将决定采用何种加锁协议来维持变量状态的完整性。所有权意味着控制权。然而,如果发布了某个可变对象的引用,那么就不再拥有独占的控制权,最多是“共享控制权”。对于从构造函数或者从方法中传递进来的对象,类通常并不拥有这些对象,除非这些方法是被专门设计为转移传递进来的对象的所有权(例如,同步容器封装器的工厂方法)。
容器类通常变现出一种“所有权分离”的形式,其中容器类拥有其自身的状态,而客户代码则拥有容器中各个对象的状态。(容器自身的状态归容器对象控制,put进容器的对象则由客户端代码控制[这些对象要么时线程安全的对象,要么是事实不可变的对象,或者由锁来保护的对象。])
封装简化了线程安全类的实现过程,它提供了实例封闭机制。当一个对象被封闭到另一个对象中时,能够访问被封闭对象的所有代码路径都是已知。与对象可以由整个程序访问的情况相比,更易于对代码进行分析。通过封闭机制与合适的加锁策略结合起来,可以确保以线程安全的方式来使用非线程安全的对象。
将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁。
被封闭对象一定不能超出既定的作用域。对象可以封闭在类的一个实例(例如作为类的一个私有成员)中,或者封闭在某个作用域内(例如作为一个局部变量),再或者封闭在线程内(例如在某个线程中将对象从一个方法传递到另一个方法,而不是在多个线程之间共享该对象)。
通过封闭机制来确保线程安全,通过封闭与加锁等机制使一个类成为线程安全的(即使这个类的状态变量并不是线程安全的)。
public class PersonSet
{private final Set
}
Person类是可变的,那么在访问从PerSet中获得的Person对象时,还需要额外的同步。可以使Person对象成为一个线程安全类。也可以使用锁来保护Person对象。
实例封闭时构建线程安全类的一个最简单方式,它使得在锁策略的选择上拥有了更多地灵活性。在PersonSet中可以使用内置锁来保护它的状态,对于其他形式的锁只要自始至终都使用同一个锁,就可以保护状态。实例封闭还使得不同的状态可以由不同的锁来保护。
如果将一个本该被封闭的对象发布出去,那么也能破坏封闭性。如果一个对象本应该封闭在特定的作用域内,那么让该对象逸出作用域就是一个错误。当发布其他对象时,例如迭代器或内部的类实例,可能会间接地发布被封闭对象,同样会使被封闭对象逸出。
封闭机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时就无须检查整个程序。
进入和退出同步代码的字节指令也称为monitorenter和monitorexit,而Java的内置锁也称为监视器锁或监视器。
遵循Java监视器模式的对象会把对象的所有可变状态都封装起来,并由对象自己的内置锁来保护。
Java监视器模式的主要优势就在于它的简单性,11章介绍通过更细粒度的加锁策略来提高可伸缩性。
Java监视器模式仅仅是编写代码的约定,对于任何一种锁对象,只要自始至终都使用该锁对象,都可以用来保护对象的状态。如下程序给出了如何使用私有锁来保护状态。
public class PrivateLock
{private final Object myLock = new Object();void someMethod(){synchronized(myLock){// 访问或修改Widget的状态}}
}
私有的锁对象可以将锁封装起来,使客户代码无法得到锁,但客户代码可以通过公有方法来访问锁,以便(正确或者不正确)参与到它的同步策略中。
大多数对象都是组合对象。当从头开始构建一个类,或者将多个非线程安全的类组合为一个类时,Java监视器模式是非常有用的。如果类中的各个组件都已经是线程安全的,会是什么情况?是否需要再增加一个额外的线程安全层?答案是“视情况而定”。在某些情况下,通过多个线程安全类组合而成的类时线程安全的,而在某些情况下,仅仅是好的开端。
4.3.1 示例:基于委托的车辆追踪器
@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
}
可以将线程安全性委托给多个状态变量,只要这些变量是彼此独立的,即组合而成的类并不会在其包含的多个状态变量上增加任何不变性条件。
public class VisualComponent {private final List
}
VisualComponent使用CopyOnWriteArrayList来保存各个监听器列表。它是一个线程安全的链表,特别适用于管理监听器列表。每个列表都是线程安全的,由于各个状态之间不存在耦合关系,因此VisualComponent可以将它的线程安全性委托给mouseListeners和keyListeners等对象。
大多数组合对象都不会像VisualComponent这样简单:在它们的状态变量之间存在着某些不变性条件。NumberRange使用了两个AtomicInteger来管理状态,并且含有一个约束条件,即第一个数值要小于或等于第二个数值。
public class NumberRange {// INVARIANT: lower <&#61; upperprivate final AtomicInteger lower &#61; new AtomicInteger(0);private final AtomicInteger upper &#61; new AtomicInteger(0);public void setLower(int i) {// Warning -- unsafe check-then-actif (i > upper.get())throw new IllegalArgumentException("can&#39;t set lower to " &#43; i &#43; " > upper");lower.set(i);}public void setUpper(int i) {// Warning -- unsafe check-then-actif (i
}
setLower和setUpper都是“先检查后执行”的操作&#xff0c;但它们没有使用足够的加锁机制来保证这些操作的原子性。
如果某个类含有复合操作&#xff0c;例如NumberRange&#xff0c;那么仅靠委托并不足以实现线程安全性。在这种情况下&#xff0c;这个类必须提供自己的加锁机制以保证这些复合操作都是原子操作&#xff0c;除非整个复合操作都可以委托给状态变量。
如果一个类是由多个独立且线程安全的状态变量组成&#xff0c;并且在所有的操作中都不包含无效状态转换&#xff0c;那么可以将线程安全性委托给底层的状态变量。
当把线程安全性委托给某个对象的底层状态变量时&#xff0c;在什么条件下才可以发布这些变量从而使其他类能修改它们&#xff1f;答案仍然是取决于在类中对这些变量施加了哪些不变性条件。虽然Counter中的value域可以为任何整数值&#xff0c;但Counter施加的约束条件是只能取正整数&#xff0c;此外递增操作同样约束了下一个状态的有效取值范围。如果将value声明为一个公有域&#xff0c;那么客户代码可以将它修改为一个无效值&#xff0c;因此发布value会导致这个类出错。另一方面&#xff0c;如果某个变量表示的时当前温度或者最近登录用户的ID&#xff0c;那么即使另一个类在某个时刻修改了这个值&#xff0c;也不会破坏任何不变性条件&#xff0c;因此发布这个变量也是可以接受的。
如果一个状态变量时线程安全的&#xff0c;并且没有任何不变性条件来约束它的值&#xff0c;在变量的操作上也不存在任何不允许的状态转换&#xff0c;那么就可以安全地发布这个变量。
例如&#xff0c;发布VisualComponent中的mouseListeners或keyListeners等变量就是安全的。由于VisutalComponent并没有在其监听器链表的合法状态上施加任何约束&#xff0c;因此这些域可以声明为共有域或者发布&#xff0c;而不会破坏线程安全性。
&#64;ThreadSafe
public class SafePoint {&#64;GuardedBy("this") private int x, y;private SafePoint(int[] a) {this(a[0], a[1]);}public SafePoint(SafePoint p) {this(p.get());}public SafePoint(int x, int y) {this.set(x, y);}public synchronized int[] get() {return new int[]{x, y};}public synchronized void set(int x, int y) {this.x &#61; x;this.y &#61; y;}
}
&#64;ThreadSafe
public class PublishingVehicleTracker {private final Map
}
Java类库中包含许多有用的“基础模块”类。通常&#xff0c;我们应该优先选择重用这些现有的类而不是创建新的类&#xff0c;重用能降低开发工作量、开发风险以及维护成本。有的时候&#xff0c;现有的类只能支持大部分的操作&#xff0c;此时就需要在不破坏线程安全性的情况下添加一个新的操作。
要添加一个新的原子操作&#xff0c;最安全的做法时修改原始的类&#xff0c;但这通常无法做到&#xff0c;因为你可能无法访问或修改类的源代码。要想修改原始的类&#xff0c;就需要理解代码中的同步策略。这样增加的功能才能与原有的设计保持一致。如果直接将新方法添加到类中&#xff0c;那么意味着实现同步策略的所有代码仍然处于一个源代码文件中&#xff0c;从而更容易理解与维护。
另一种方法是扩展这个类&#xff0c;假定在设计这个类时考虑了可扩展性。但是并非所有的类都像Vector那样将状态向子类公开&#xff0c;因此也就不适合采用这种方法。
&#64;ThreadSafe
public class BetterVector <E> extends Vector<E> {// When extending a serializable class, you should redefine serialVersionUIDstatic final long serialVersionUID &#61; -3963416950630760754L;public synchronized boolean putIfAbsent(E x) {boolean absent &#61; !contains(x);if (absent)add(x);return absent;}
}
“扩展”方法比直接将代码添加到类中更加脆弱&#xff0c;因为现在的同步策略实现被分布到多个单独维护的源代码文件中。如果底层的类改变了同步策略并选择了不同的锁来保护它的状态变量&#xff0c;那么子类会被破坏&#xff0c;因为在同步策略改变后它无法再使用正确的锁来控制对基类状态的并发访问。&#xff08;在Vector的规范中定义了它的同步策略&#xff0c;因此BetterVector不存在这个问题。&#xff09;
对于Collections.synchronizedList封装的ArrayList&#xff0c;这两种方法在原始类中添加一个方法或者对类进行扩展都行不通&#xff0c;因为客户代码并不知道在同步封装器工厂方法中返回的List对象的类型。第三种策略是扩展类的功能&#xff0c;但并不是扩展类本身&#xff0c;而是将扩展代码放入一个“辅助类”中。
必须使List在实现客户端加锁或外部加锁时使用同一个锁。客户端加锁是指&#xff0c;对于使用某个对象X的客户端代码&#xff0c;使用X本身用于保护保护其状态的锁来保护这段客户代码。要使用客户端加锁&#xff0c;你必须知道对象X使用的是哪一个锁。
&#64;NotThreadSafe
class BadListHelper
}&#64;ThreadSafe
class GoodListHelper
}
通过添加一个原子操作来扩展类是脆弱的&#xff0c;因为它将类的加锁代码分布到多个类中。然而&#xff0c;客户端加锁却更加脆弱&#xff0c;因为它将类C的加锁代码放到与C完全无关的其他类中。当在那些并不承诺遵循加锁策略的类上使用客户端加锁时&#xff0c;要特别小心。
客户端加锁机制与扩展类机制有许多共同点&#xff0c;二者都是将派生类的行为与基类的实现耦合在一起。正如扩展会破坏实现的封装性&#xff0c;客户端加锁同样会破坏同步策略的封装性。
当为现有的类添加原子操作时&#xff0c;有一种更好的方法&#xff1a;组合。ImproveList通过将List对象的操作委托给底层的List实例来实现List的操作&#xff0c;同时还添加了一个原子的putIfAbsent方法。&#xff08;与Collections.synchronizedList和其他容器封装器一样&#xff0c;ImproveList假设把某个链表对象传递给构造函数以后&#xff0c;客户代码不会再直接使用这个对象&#xff0c;而只能通过ImproveList来访问它。&#xff09;
&#64;ThreadSafe
public class ImprovedList<T> implements List<T> {private final List
}
ImproveList通过自身的内置锁增加了一层额外的加锁。它并不关心底层的List是否是线程安全的&#xff0c;即使List不是线程安全的或者修改了它的加锁实现&#xff0c;ImproveList也会提供一致的加锁机制来实现线程安全性。虽然额外的同步层可能导致轻微的性能损失&#xff0c;但与模拟另一个对象的加锁策略相比&#xff0c;ImproveList更为健壮。事实上&#xff0c;我们使用了Java监视器模式来封装现有的List&#xff0c;并且只要在类中拥有指向底层List的唯一外部引用&#xff0c;就能确保线程安全性。
在文档中说明客户代码需要了解的线程安全性保证&#xff0c;以及代码维护人员需要了解的同步策略。
synchronized&#xff0c;volatile或者任何一个线程安全类都对应于某种同步策略&#xff0c;用于在并发访问时确保数据的完整性。这种策略的程序设计的要素之一&#xff0c;因此应该将其文档化。当然&#xff0c;在设计阶段时编写设计决策文档的最佳时间。这之后的几周或几个月后&#xff0c;一些设计细节会最逐渐变得模糊&#xff0c;因此一定要在忘记之前将它们记录下来。
在设计同步策略时需要考虑多个方面&#xff0c;例如&#xff0c;将哪些变量声明为volatile类型&#xff0c;哪些变量用锁来保护&#xff0c;哪些锁保护哪些变量&#xff0c;哪些变量必须是不可变的或者被封闭在线程中的&#xff0c;哪些操作必须是原子操作等。其中某些方面时严格的实现细节&#xff0c;应该将它们文档化以便于日后的维护。还有一些方面会影响类中加锁行为的外在表现&#xff0c;也应该将其视为规范的一部分写入文档。
我们认为“可能是线程安全”的类通常并不是线程安全的。
如果某个类没有明确地声明是线程安全的&#xff0c;那么就不要假设它是线程安全的&#xff0c;从而有效地避免类似于SimpleDateFormat的问题。
许多Java技术规范都没有说明接口的线程安全性&#xff0c;例如ServletContext&#xff0c;HttpSession或DataSource。
你只能去猜测。一个提高猜测准确性的方法是&#xff0c;从实现者&#xff08;例如容器或数据库的供应商&#xff09;的角度去解释规范&#xff0c;而不是从使用者的角度去解释。
“如果不这么做是不可思议的”。
参考&#xff1a;向已有的线程安全类添加功能
有时一个线程安全类支持我们需要的全部操作&#xff0c;但是更多时候&#xff0c;一个类只支持我们需要的大部分操作&#xff0c;这时我们需要在不破坏其线程安全性的前提下&#xff0c;向它添加一个新的操作。
现在假设我们需要一个线程安全的List&#xff0c;它需要提供给我们一个原子的“缺少即加入&#xff08;put-if-absent&#xff09;”操作&#xff0c;该如何做&#xff1f;
第一种方式&#xff1a;扩展Vector
&#64;ThreadSafe
public class BetterVector<E> extends Vector<E> {public synchronized boolean putIfAbsent(E x) {boolean absent &#61; !contains(x);if (absent)add(x);return absent;}
}
第二种方式&#xff1a;客户端自己加锁
&#64;ThreadSafe
public class ListHelper
}
第三种方式&#xff0c;也是最好的方式&#xff0c;组合加实现&#xff1a;
ImprovedList通过将操作委托给底层的List实例&#xff0c;并实现了List接口&#xff0c;同时还添加了一个原子操作putIfAbsent。&#xff08;这种方式就像Collections.synchronizedList和其他容器封装那样&#xff09;
&#64;ThreadSafe
public class ImprovedList<T> implements List<T> {//实现private final List
}