CPJ译
Chapter 2. 独占性(Exclusion)
在一个安全的系统中,每个对象都必须确保自身免受integrity violations。有时,这需要和其他对象以及这些对象的方法合作来完成。
独占技术可以保护对象不变式(object invariants)同时也可以防止源自短暂的状态不一致带来的影响。编程技术和设计模式通过防止多个线程并发修改某个对象来实现独占性。所有的实现方法都基于以下列出的某个或某几个策略:
-
通过确保方法不会修改某个对象,因此对象不会出现不一致的状态,从而消除某一部分或所有的独占控制。
-
通过锁或者其他相关的结构,动态的确保某一时刻只有一个线程可以访问某个对象的状态。
-
通过隐藏或者限制对象的访问权,以结构化的形式确保只有一个线程(或者某个时刻只有一个线程)可以使用这个对象。
译者注:
三大原则总结起来就是:S-C-I。Synchronization,Confinement和Immutability。
本章的前三节围绕着上述方法,描述了它们的核心特性和使用模式 — 不变性(2.1),同步(2.2)及约束(2.3)。2.4节讨论了如何把这些方法组合起来,以提升安全性,活跃性,性能,和(或者)提供语义学保证。2.5节演示了如何使用工具类来获得那些仅通过内建结构难以实现的功能。需要额外说明的是,很多在第三章讨论的类,技术及工具,都可以被用来保证独占性(尤其见3.3.2)。
这些技术的强制使用展示了顺序(sequential)和并发编程实践中重要的差别。为了保证并发系统的安全性,必须保证所有多线程访问的对象,要么是不可变的,要么经过了适当的同步,同时还需要保证没有其他类因为自身泄露而导致并发访问。尽管维护这些保证的技术多是其他OO开发实践的拓展,但是,并发程序通常对于错误的容忍度更低。
如1.3.1节所述,上述这些,绝大部分不可能通过编译器和运行时系统来强制保障。分析和测试工具可能在某些失败方面很有用,但是,如何保证每个类、组件、子系统、应用和系统的安全性的主要责任落在它们的开发者身上。另外,独占性相关的策略和设计原则必须是明确并且周知的。
当使用不是给多线程环境编写的代码时,需要多加小心。java.*包中的大多数类都被设计为在预期的使用环境中应用时,是线程安全的(本书中出现了一些例外情况,其他限制出现在类的API文档中)。但是,在构建多线程应用程序时,通常需要对最初设计仅用于单线程上下文的类和包进行修正(rework)或包装(请参阅第2.3.3.1节)。
2.1 不变性(Immutability)
如果一个对象不可能改变状态,那么,当多个activity试图以不兼容的方式改变其状态时,它就永远不会遇到冲突或不一致。
如果现有的对象永远不会改变,取而代之的是,在计算过程中不断创建新的对象,那么程序要容易理解得多。不幸的是,这些程序通常不能处理用户界面,协作线程等交互行为。但是,选择性的使用不变性是并发OO编程的一种基本手段。
最简单的不可变对象,没有任何内部字段。他们的方法是无状态的 — 不依赖于任何对象的任何可赋值(assignable)字段。例如,以下StatelessAdder类及其add方法所有可能的用法显然都是线程安全的,活跃的:
class StatelessAdder {
public int add(int a, int b) { return a + b; }
}
只包含final字段的类,同样具备安全性和活跃性。因为字段永远不会被写入,所以不可变类的实例,不会发生低级别的读-写或者写-写冲突(见1.3.1节)。只要他们的初始值是以一致的(consistent)、合法的方式建立的,那么这些对象就不会发生高级别的invariant failures。例如:
class ImmutableAdder {
private final int offset;
public ImmutableAdder(int a) { offset = a; }
public int addOffset(int b) { return offset + b; }
}
2.1.1 应用(Applications)
当然可以创建其他类型的不可变对象,这些对象所包含的结构和功能比在ImmutableAdder中看到的更加有趣,包括:抽象数据类型,值容器和共享状态表示(shared state representations)。
2.1.1.1 抽象数据类型(ADTs)
不可变对象可以充当抽象数据类型的实例用于展示数值。一些通用的抽象数据类型已经定义在java.*包下了。这包括了java.awt.Color,java.lang.Integer,java.math.BigDecimal,java.lang.String等等。很容易自定义ADT类,比如Fraction,Interval,ComplexFloat等。这些类的实例从不会改变构造好的值,但是会提供相应的方法创建新的对象,来表示新的值。例如:
class Fraction { // Fragments
protected final long numerator;
protected final long denominator;
public Fraction(long num, long den) {
// normalize:
boolean sameSign = (num >= 0) == (den >= 0);
long n = (num >= 0)? num : -num;
long d = (den >= 0)? den : -den;
long g = gcd(n, d);
numerator = (sameSign)? n / g : -n / g;
denominator = d / g;
}
static long gcd(long a, long b) {
// ...compute greatest common divisor ...
}
public Fraction plus(Fraction f) {
return new Fraction(numerator * f.denominator +
f.numerator * denominator,
denominator * f.denominator);
}
public boolean equals(Object other) { // override default
if (! (other instanceof Fraction) ) return false;
Fraction f = (Fraction)(other);
return numerator * f.denominator ==
denominator * f.numerator;
}
public int hashCode() { // override default
return (int) (numerator ^ denominator);
}
}
不可变数据类的实例,仅仅是为了展示其封装的数值,因此它们的标识并不重要。举例来说,所有表示黑色(RGB值为0)的java.awt.Color实例,应该被认为都是一样的。这是为什么ADT风格的类,应该重写Object.equals和Object.hashCode方法来体现数值上的相等性。这一点在Fraction类中已经说明了。这些方法的默认实现,依赖于实例的标识。通过重写equals方法,可以掩盖(mask)多个展示同一个数值以及(或者)提供相同功能的ADT实例的标识,使得客户端不需要关心任意时刻,究竟使用的是哪个ADT对象。
你并不需要在整个程序中致力于使用不可变的ADTs版本。为了不可变的版本,以及某些概念上可变的版本,有时候定义不同的类来支持不同的用法更加有用。例如,java.lang.String类是不可变的,但java.lang.StringBuffer是可更新的。更新时依赖于它的同步方法。
2.1.1.2 值容器(Value containers)
当需要或者方便建立一些一致性状态,且后续依赖它们的时候,可以使用不可变对象。比如,一个不可变的ProgramConfiguration对象反映了某个程序在运行时所有的设置。
当可以通过部分复制旧对象来创建新对象,以一种比较廉价的方式建立不同的变体,版本或对象状态的时候,通常可以使用不可变的值容器。在这种场景下,无需进行状态同步带来的收益超过了拷贝的开销(见2.4.4节)。不可变对象的状态改变是通过某种方式创建一个新的,不同于旧对象的不可变对象来模拟的。
2.1.1.3 共享(Sharing)
当你想要共享对象,在节省空间的同时保持对这些对象的高效访问,不变性是一项很有用的技术。一个不可变对象可以被任意数量的对象引用,而不需要关心同步或者访问限制,例如很多独立的字符(或者象形文字)对象统统可以在不可变的字体对象间共享。这也是设计模式一书中描述的享元模式(Flyweight)的一种应用。绝大多数享元设计的足够简单,通过确保共享表示的不可变性来建立。
很多在并发环境中使用的工具类本质上就是不可变的,同时也在其他对象间共享,例如:
class Relay {
protected final Server server;
Relay(Server s) { server = s; }
void doIt() { server.doIt(); }
}
尽管纯粹的不可变对象是简单的,方便的,普遍的并且是有用的,很多并发的OO程序也依赖于部分不变性 — 只有某些字段是不可变的,或者只有执行了某些特殊方法之后的,或者只是某段时间内的(only over some period of interest)。当使用可变对象来实现某些设计很困难时,利用不可变性就是一个有用的策略。本书中有大量这样的设计,尤其是在2.4节中。
2.1.2 构建(Construction)
所有依赖于不可变性的设计决定必须强制的,恰当的使用final关键字,才是有效的。另外,当初始化不可变对象时(见2.2.7),还需要特别的注意。特别的,如果在一个不可变对象完全初始化完成前,就暴露给其他对象,往往会使结果适得其反。一个适用于所有类的通用原则是:
- 在构造完成前,绝不要允许任何字段对外部可见(Do not allow fields to be accessed until construction is complete)。
在一些情况下,某些不可变的字段还没有在构造器中完全初始化完成,例如,当它们根据不同的文件逐一初始化,或者它们在互相依赖于同一时刻多个对象的构造。还需注意的是,要确保这些对象对于外部不可用,直到字段都稳定(stable)。这一点几乎总是需要使用同步(见2.2.4和3.4.2中的例子)。
这在并发环境中比在顺序程序中更难保证。构造器应该只执行跟字段初始化直接相关的动作。而不应该调用可能依赖于对象被完全构建的方法。构造器不应该把正在被构造的自身的引用记录在可以被其他对象访问的字段中,不应该调用其他方法把this作为参数传入,更通用的说,不应该允许指向this的引用“逸出”(见2.3)。如果没有这些预防措施,在其他线程中运行的方法和类可能会访问到JVM在构造器调用之前,为每个Object 对象默认初始化的值,标量字段是0,引用字段是null。
2.1小结:
不可变类一定是线程安全的,不可变类包括2种类型:没有字段的类;有字段但是所有字段都有final修饰的类。
一个类如果没有字段,就意味着是无状态的,无状态的类一定是线程安全的,比如StatelessAdder工具类。
一个类如果所有字段都被final修饰,意味着该对象的实例一旦创建完成,所有字段就不可写,即类的状态不会被修改,因此既不存在低级别的读-写或写-写冲突,也不存在高级别的因违反不变性导致的问题。
还有一点需要补充的是,对于第二种类型的不可变类,需要 确保 对象的引用(this)不能在构造函数中逸出。否则另一个线程很可能读到一个初始化完全的final字段。
2.2 同步(Synchronization)
锁可防止低级别的存储冲突和相应的高级别的invariant failures。 例如,请考虑以下类:
class Even { // Do not use
private int n = 0;
public int next() { // POST?: next is always even
++n;
++n;
return n;
}
}
当2个或更多个线程执行同一个Even对象的next方法时,如果没有锁,那么预期的后置条件可能会因为存储冲突而失败。下面展示了一种可能的执行轨迹,它只包含了对变量n的读(getfields)和写(putfields)操作。
Thread A | Thread B |
---|---|
read 0 | |
write 1 | |
read 1 | |
write 2 | |
read 2 | read 2 |
write 3 | |
write 3 | return 3 |
return 3 |
作为并发编程中比较典型的例子,大多数两个线程并发执行Even.next的轨迹不会显示出违反安全性。使用这个版本的Even的程序,可能可以通过某些测试,但是最终肯定会在某些测试时失败。这种类型的违法安全性的情况罕见且难以被测试,但却会带来灾难性的影响。这促使了在可靠的并发程序中谨慎而保守的设计实践。
将next方法声明为synchronized,可以防止这种冲突。锁使得synchronized修饰的方法串行化执行。因此在这里,线程A的next方法会在线程B的next方法开始前执行,反之亦然。
2.2.1 结构(Mechanics)
作为后续基于锁的设计策略的预备阶段,以下是关于mechanics的综述,同时也是围绕着synchronized关键字的一些使用说明。
2.2.1.1 对象和锁(Objects and locks)
每个Object或者继承自Object的子类的实例,都拥有一个锁。标量数据类型int,float等不是对象,只能通过对应的包装类来锁。字段不能被标记为synchronized,只有方法可以。但是字段可以被声明为volatile的,以此来提供部分原子性,可见性和有序性。
类似的,持有标量数据类型的数组拥有锁,但是数组元素不拥有锁(更进一步的说,无法把数组元素声明为volatile的)。锁住一个数组,并不意味着锁住了它的所有元素。没有结构可以在一次原子的锁操作中,同时锁住多个对象。
译者注:
这句话基本就是上一句“Locking an array of Objects does not automatically lock all its elements.”另一种形式的说法。
类的实例也是对象。如下所述,类的锁用于给静态方法提供同步。
2.2.1.2 Synchronized methods and blocks
有两种基于synchronized关键字的语法:同步块和同步方法。同步块需要一个参数来确定锁定哪个对象。这允许任何方法锁定任何对象。同步块最通用的参数是this。
同步块比同步方法更为基础。
synchronized void f() { /* body */ }
和下面的写法等价。
void f() { synchronized(this) { /* body */ } }
synchronized并不是方法签名的一部分,因此无法在子类重写父类方法时被自动继承,接口中的方法也无法被声明为synchronized的。同样的,构造器无法被声明为synchronized的(尽管在构造器中可以是使用同步块)。
子类中的实例方法和父类的实例方法共享一把锁。但是内部类的方法的锁独立于其外部类。但是,内部类的非静态方法可以通过以下方式锁住它的外部类:
synchronized(OuterClass.this) { /* body */ }
译者注:
实例方法指的是非static修饰的方法。因为实例方法的锁是基于实例的,无论子类的方法是来自于子类还是其父类,都对应于同一个实例。具体可以参考:https://stackoverflow.com/questions/625726
2.2.1.3 获得和释放锁(Acquiring and releasing locks)
锁遵循一个内建的,由synchronized关键字控制的获取-释放协议。所有的锁都是块结构的。通过进入一个同步的方法或者块获得锁,在退出时候释放锁,及时是异常情况下的退出。因此不可能忘记释放锁。
锁操作基于每个线程,而不是每个调用。当一个线程达到同步块时,如果锁空闲或者该线程已经拥有了这个锁,则通过,否则线程会阻塞(这个重入或者递归的锁策略和POSIX线程中默认的策略不同)。撇开其他效果之外,这允许一个同步方法调用同一对象上的另一个同步方法(self-call)而无需冻结(freezing up)。
同步方法或同步块只遵守相对于同一目标对象上的其他同步方法和块的获取-释放协议。没有同步的方法依然可以在任意时间执行,及时同步方法正在执行的过程中。换句话说,同步不等同于原子性,但是可以用同步实现原子性。
当一个线程释放锁时,另一个线程可能获取它(可能是同一个线程,如果它碰到另一个同步方法)。但是不能保证被阻塞的线程中哪一个会获得锁,或者说它们何时会获得锁(特别的,无法保证尝试获取锁时的公平性 — 见3.4.15 )。没有机制可以知道某个锁是否被某个线程持有。
如共2.2.7种讨论的,除了控制锁之外,synchronized还具有同步底层内存系统的副作用。
2.2.1.4 静态(Statics)
锁定某个对象,不能自动避免对该类或者其父类静态字段的方法。访问静态字段,通过同步静态方法和块来完成。静态同步使用的锁是Class对象持有的锁,而这个Class同样也是静态方法声明的地方。C.class的静态锁也可以通过实例方法中被访问:
synchronized(C.class) { /* body */ }
每个类的静态锁和其他类,包括其父类,都无关。试图在子类中添加新的静态同步方法来保护父类中的静态字段是无效的。应该使用显式的同步块替代。
译者注:
这里指的是用synchronized关键字修饰static方法时,子类和父类默认是使用的各自的Class实例的锁,因此锁是不生效的。
以下结构是一个不好的实践:
synchronized(getClass()) { /* body */ } // Do not use
这锁的是实际的类,可能与定义需要保护的静态字段的类不同。
JVM内部在类加载和初始化期间获取和释放对类对象的锁。除非在写一个特殊的类加载器(ClassLoader)或则在静态初始化期间持有多个锁,这些内部机制不会影响Class对象上普通同步方法和同步块的使用。不会有其他JVM的内部动作独立获取你创建和使用的任何锁。但是当你继承java.*类时,你需要知道在这些类中使用的锁策略。
2.2.1小结:
只有对象才能持有锁,包括:Object类及所有继承自Object类的实例;Class类实例。
非静态方法的同步是基于实例的,这意味着:子类中定义的方法和父类中定义的方法共享子类实例的一个锁。但是,静态方法的同步是基于类的,这意味着:子类中定义的方法持有的是子类的锁;父类中定义的方法持有的是父类的锁。内部类和外部类也不共享一个锁,但是非静态内部类可以通过OuterClass.this持有外部类的锁。
锁操作针对的是线程而不是调用。多个线程在获取锁时,不能保证公平性。
同步可以用来实现原子性,但是同步不等于原子性。
2.2.2 Fully Synchronized Objects
2.2.3 Traversal
2.2.3.1 Synchronized aggregate operations
2.2.3.2 Indexed traversal and client-side locking
2.2.3.3 Versioned iterators
2.2.3.4 Visitors
2.2.4 Statics and Singletons
2.2.5 Deadlock
2.2.6 Resource Ordering
2.2.7 Java内存模型(The Java Memory Model)
考虑下面这个简单的类,它没有使用任何同步:
final class SetCheck {
private int a = 0;
private long b = 0;
void set() {
a = 1;
b = -1;
}
boolean check() {
return ((b == 0) || (b == -1 && a == 1));
}
}
在纯粹的顺序语言中,check方法永远不会返回false。虽然编译器,运行时系统和硬件可能会以一种反直觉的方式处理这段代码,但是,上述结论仍然是成立的。举例来说,以下任意策略都可能应用在set方法的执行上:
- 编译器可能会对指令重排序,因此b可能在a之前赋值。如果方法是内联(inline)的,编译器可能进一步会重排和其他语句的顺序。
- 处理器可能会重排代码最终相关的机器指令,甚至,同时执行它们。
- 内存系统(由缓存控制单元管理)可能会重排变量回写到相应内存单元的顺序。这些写操作可能会和其他计算及内存行为重叠。
- 编译器,处理器,内存系统可能带来机器级的影响,例如在32位的机器上,可能先写入b高位(high-order)字,再写入a, 最后写入b的低位字。
- 编译器,处理器,内存系统可能导致变量相应的内存单元直到随后的检查被调用之后才更新。但是,会维护相应的值(例如在CPU寄存器中)使得代码仍然具有预期的结果。
在顺序语言中,以上这些都不会产生任何影响,只要程序执行时遵循as-if-serial语义[2]。顺序程序不依赖于语句内部的执行细节,因此上述这些优化可以自由的使用。这为编译器和机器提供了必要的灵活性。过去十几年来计算速度的显著提升,得益于大量采用这些技术(pipelined superscalar CPUs,多级缓存,读/写均衡,interprocedural register allocation等)。as-if-serial语义可以让顺序环境的开发人员不需要关注是否存在这些优化,或者这些优化是如何进行的。如果不自己创建线程,几乎不可能会被这些优化所影响。
[2] Somewhat more precisely, as-if-serial (also known as program order) semantics can be defined as any execution traversal of the graph formed by ordering only those operations that have value or control dependencies with respect to each other under a language's base expression and statement semantics.
在并发编程中,情况变得不一样。完全可能存在一个线程执行check方法,另一个线程执行set方法的场景,此时,check方法可能看到优化后的set方法的执行结果。如果上述任意优化发生,那么check就可能会返回false。例如下面将要详述的,check方法可能读取到的long型变量b的值,既不是0也不是-1,而是一个写入了一半的中间值(in-between value)。同样的,非顺序的执行可能导致check方法读取到变量b是-1的时候,变量a的值仍旧是0。
换句话说,指令不仅仅可能交替执行,它们还可能被重排序或者以一种和源代码编写顺序不同的优化形式运行。随着编译器和运行时技术的成熟,以及多处理器的流行,这一现象会变得更加常见。它们会对拥有顺序编程背景(即所有的开发人员)但是从来没有关心过顺序代码底层如何实现的开发人员产生令人费解的结果。这是很多微妙的并发编程错误的根源。
几乎在所有情况下,都有一个最明显也是最简单的方法来防止由于优化机制带来的的复杂性:使用同步。例如,如果所有在SetCheck类中的方法都声明为synchronized,那么就可以确定没有内部处理细节可以影响代码预期的执行结果。
但是,有时候你不能或者不愿意使用同步。或者你必须考虑其他人的代码,而这些代码中没有使用同步。在这些情况下,你必须只能依赖Java内存模型所能提供的最小保证(minimal guarantees)。这个模型允许上述所列的优化,但是限制了它们在语义上潜在的影响,另外还指出了一些开发人员可以用来控制这些语义的技术。
Java内存模型是Java语言规范的一部分,主要在JLS的第17章有描述。在这里,只讨论模型的基础动机,特性和编程结果(programming consequences)。这里阐述的涉及到一小部分第一版JLS[3]遗漏的澄清(clarifications)及更新。
[3] As of this writing, the memory model and other relevant sections of JLS are still being updated to cover the Java 2 Platform. Please check the online supplement for any changes that impact the material in this section.
这些在模型基础上的假设可以被认为是1.2.4中描述的标准SMP机的理想化模型。
在这个模型中,每一个线程可以被认为运行在不同的CPU上。即使在多核处理器上,这也是不切合实际的,但是把每一个线程映射到一个单独的CPU,是为了模型最初的某些特殊的特性,实现线程的一种合法的途径。例如每一个CPU都拥有其他其他CPU无法访问的寄存器,模型必须允许这样的场景,还该场景下,一个线程不知道另一个线程维护的值。但是,模型并不是针对多核处理器的。即使在单核系统中,编译器和处理器的行为,也会导致相同的担忧。
这个模型不是专门针对上述讨论的指令执行技术是否被编译器,CPU,缓存控制以及其他机制执行。它甚至没有讨论类,对象,方法等开发人员熟悉的方面。取而代之的,模型定义了一个线程和主内存之间的抽象关系。每一个线程被定义为拥有一个用来存储值的工作内存(缓存和寄存器的抽象)。模型提供了一些关于方法相应的指令以及字段相应的内存单元的特性的保证。绝大多数规则根据一个值何时必须在主存个每个线程的工作内存间传递来进行阐述。这些规则涉及三个互相交织的方面:
原子性 指令的执行必须拥有和其他指令互相独立的结果。规则只针对内存单元相关的字段的读和写 — 实例变量,静态变量,也包括数组元素,但是不包括方法中的本地变量。
可见性 在何种条件下一个线程的结果对于其他线程是可见的。这里感兴趣的是,一个字段的写入和对于这个字段的读取。
有序性 在某个线程中,何种条件下执行的结果会呈现出无序性。主要的顺序是围绕着顺序赋值语句的读和写。
译者注:
三个方面总结起来就是:V-O-A。Visibility,Ordering和Atomicity。
当一致的(consistently)使用同步时,上述这些特性都有一个简单的表现:所有一个同步方法或者同步块中的改变对于另一个基于同一个锁的同步方法或者同步块都是原子的,并且可见的。同时任意线程看到的结果都像是以代码定义的顺序执行的。尽管在同步方法或者同步块内的语句执行可能不是有序的,但是这并不影响其他使用了同步的线程看到的执行结果。
当没有使用同步或者没有一致的使用同步时,问题开始变得复杂。模型提供的保证比大多数开发人员预期的要弱,同时,也比任意给定的JVM实现所提供的弱。这强制使得开发人员有义务保证对象的一致性关系是独占性实践最重要的部分:对于所有依赖这个对象的线程来说,对象都必须维护不变性,而不是特定于某个执行修改的线程。
模型相关的最重要的规则个特性,将在下面讨论。
2.2.7.1 原子性(Atomicity)
访问和更新除了long和double类型之外的其他的字段对应的内存单元是原子的。这也包括了指向其他类的引用。另外,原子性可以拓展到volatile long和volatile double类型(尽管非volatile的long和double不保证是原子的,但是,当然允许把它们实现为原子的)。
原子性保证了当表达式中使用了非long和double类型的字段时,你读取到的值或者是它的初始值,或者是某些线程写入的值。但不会是两个或者多个线程同时尝试更新时产生的杂乱的bit。但是,如下文所述,原子性不保证读取到的是某个线程写入的最新值。因为这个原因,原子性保证本质上在并发编程设计中影响不大。
译者注:
猜测 这里的原子性指的是模型提供的,跟是否使用同步无关,而是,约定了一个针对32位的变量(排除了long和double)的存取操作,在底层是通过一条CPU指令完成的。
2.2.7.2 可见性(Visibility)
在以下条件下,某个线程对于某个字段的修改,可以保证是对其他线程可见的:
-
写线程释放同步锁,读线程随后获取同一个锁。
本质上来说,释放锁时,会强制把所有写操作从工作内存刷回到主存;获取锁时,会强制重新读取字段的值。虽然锁提供的独占性只针对在同步方法或者同步块内的操作,但是内存影响涵盖执行操作的线程使用的所有字段。
注意synchronized的双重含义:第一层含义是:它既处理提供高层次的同步协议的锁,同时也处理内存系统(有时通过低层次的机器指令内存栅栏)以保持值在跨线程时的同步展示。这反应了,并发编程比顺序编程更接近分布式编程。第二层含义是:同步被认为是一种机制,通过这个机制,某个线程的某个方法表明它愿意发送/接收其他线程中其他方法的变量。从这个角度来说,使用锁并且发送消息可能仅仅被看作是彼此的语法变体。 -
如果某个字段被声明是volatile的,在写线程执行其他内存操作前,任何对该字段的写都会被强制回写到主存并且可见(即,立刻刷新)。读线程必须在每次需要时,重新读取volatile字段。
-
某个线程首次访问一个对象的某个字段时,要么看到的是它的初始值,要么看到的是上次某个线程写入的值。
[4] As of this writing, the JLS does not yet clearly state that the visible initial value read for an initialized final field is the value assigned in its initializer or constructor. However, this anticipated clarification is assumed throughout this book. The visible initial default values of non- final fields are zero for scalars and null for references.
除了其他影响的影响之外,把未完全构建完成的对象赋值某个引用,给是一个不好的实践。在构造器中启动一个线程同样也是有风险的,尤其是这个类可能被继承。Thread.start和锁释放操作有相同的内存语义,启动的线程随后获得锁。如果一个实现了Runnable接口的超类,在子类构造器执行前,调用了new Thread(this).start()方法,这个对象可能还没有被完全初始化。类似的,如果先创建并且启动了一个新线程T,然后,创建了一个被线程T使用的对象X,不能确保X所有的字段对于T都是可见的,除非你在所有X的引用上都使用同步。或者,当可以适用时(when applicable),先创建X,然后再启动T。 -
当一个线程终止,所有变量的写操作都被强制回写到主存。
举例来说,如果一个线程使用Thread.join同步另一个线程的终止,那么它将保证看到该线程执行的结果(参见第4.3.2节)。
可见性问题在同一个线程内不同方法调用时,不会出现。
内存模型保证了,如果上述操作最终发生了,则由一个线程创建的特定字段的特定更新最终将被另一个线程看到。但是,最终可能是一个任意长的时间。长到在不使用同步的线程中,字段的值可能无法与其他线程同步。特别的,除非某个字段是volatile的或者经过同步的,否则,依赖某个字段循环等待的做法是错误的(见3.2.6)。
这个模型同样允许在缺乏同步时的可见性是不一致的。例如,有可能存在这样的情况,对于某个对象,读取到其中某一个字段的的值是新的,但是其他字段的值是旧的。类似的,有可能读到某个引用的值是新的,但是这个引用指向的对象的某个字段的值是旧的。
但是,这些规则并没有规定线程间的可见性失败,只是允许失败发生。这也是,如果在多线程环境下不使用同步,不会保证违反安全性,只是允许违法安全性。在当代的JVM实现版本和平台上,及时是多核处理器,可检测的可见性失败也是很罕见的。在共享CPU的线程之间使用通用高速缓存,缺乏激进的(aggressive)基于编译器的优化,以及存在强大的高速缓存一致性硬件,通常会使得值的改变,在线程之间会立即可见。这使得对基于可见性错误的自由进行测试变得不切实际,因为这样的错误可能极少发生,或者只发生在无法访问的平台上,或者尚未构建的平台上。这使得多线程的安全性失败更加常见。未经同步的并发程序很因为很多原因出现异常,其中就包括内存一致性问题。
2.2.7.3 有序性(Ordering)
有序性体现在两方面:线程内的和线程间的。
-
以线程内部某个方法的视角来看,指令像是在顺序编程语言中的那样遵从as-if-serial语义。
-
以某个未经同步的,并行运行的,关注这个线程的,其他线程的方法的视角来看,任何情况都有可能发生。唯一有用的约束是,同步方法和同步块间,以及volatile修饰的字段上的操作的相对顺序,是有保证的。
再次强调,只有最小的保证。你可能在某个程序或者特定平台上找到更严格的有序性。但是,不应该依赖它们,代码的测试在其他哪些遵守规范但是拥有不同特性的平台上将变得困难。
注意,线程内的视角隐式的涵盖在JLS的语法中。例如,在执行指令的线程内,可以看到数学表达式的执行顺序是从左往右的(JLS 15.6节),但是在其他线程内这是不必要的。
只有因为同步,结构化的独占性,或者纯粹因为偶然,使得当某个时刻只有一个线程维护变量时,线程内的as-if-serial特性才是有用的。当多个线程都在运行未同步的代码,在读写同样的字段时,那么任意的交叉存取,原子性失败,竞态条件以及可见性失败会导致as-if-serial的概念对于其他线程是毫无意义的。
尽管JLS规定了哪些特殊的重排序是合法的,哪些是不合法的,interactions with these other issues reduce practical guarantees to saying that the results may reflect just about any possible interleaving of just about any possible reordering. So there is no point in trying to reason about the ordering properties of such code.
2.2.7.4 Volatile
就原子性、可见性和有序性三方面来说,把一个字段声明为volatile“近似”于在其get/set方法上都加上了synchronized修饰符:
final class VFloat {
private float value;
final synchronized void set(float f) { value = f; }
final synchronized float get() { return value; }
}
之所以说是“近似”,是因为在访问volatile修饰的字段时,并不会涉及到获取锁/释放锁的操作。需要特别在意的是:对于volatile修饰的字段的复合操作(组合读/写操作,比如“++”),不能保证原子性。
同样,有序性和可见性也只限定于访问和修改单个volatile字段本身。把一个引用字段定义为volatile,无法保证该引用指向的对象中,非volatile字段本身的可见性。类似的,把一个数组定义为volatile不能保证数组中的元素的可见性。volatile相关的特性不具有传播能力,因为数组中的元素本身无法被声明为volatile的。
因为是无锁的,所以把定义一个字段为volatile可能比使用synchronized更加轻量,或者说,起码开销不会更大。然而,如果volatile修饰的字段在方法中被频繁的访问到,可能会比直接同步整个方法产生的开销还要大。
当因为某些原因不需要使用锁机制并且又需要在不同的线程中精确的访问到这个变量的时候,把一个字段定义为volatile就变得非常有用了。在以下4种场景时可以这么做:
- The field need not obey any invariants with respect to others.
- 对于该字段的写操作,不依赖于该字段的当前值。
- No thread ever writes an illegal value with respect to intended semantics.
- 读线程的动作不依赖于其他非volatile字段的值。
对于某个字段,当只能有一个线程执行写操作,其他线程都只允许对其执行读操作时,使用volatile修饰是有意义的。举例来说,Thermometer类可能会把temperature字段声明为volatile的;volatile字段也可以被用作任务完成时的标识,就像3.4.2中所讨论的;其他的例子如4.4中所述:使用轻量级的可执行框架可以自动维护某些方面的同步,但是使用volatile仍然是必须的,它可以保证结果变量在不同的任务间的可见性。
2.2.7小结:
2.3 约束
约束是指通过封装技术从结构上保证:某个时间点只有一个activity可以访问某个对象。这保证了某个对象的可访问性对于某个线程来说是唯一的,而不需要依赖每次访问时的动态锁定。主要策略是通过定义某些方法或者类来保证一次只有一个线程或始终只有一个线程能够访问某个受限制的对象。这些方法或者类需要确保对象的所有权不会泄露。
在实践上,约束通过确保没有任何敏感信息能从域中泄露来实现,这一点类似与其他的安全措施。然而,有趣的是,信息泄露几乎总是对某个对象的不正确引用导致的。并且这个问题所带来的挑战和安全问题的其他方面一样:有时候很难证明泄露一定不会发生;然而,除非能证明某个约束设计确实是防泄露的,否则不可以依赖这种约束设计。所幸,存在一些备选策略,使这一形势相对于安全性的其他方面来说,显得不是那么严峻。 因此,当你不能确保约束成立时,还可以使用本章描述的其他排他技术。
一方面,约束依赖语言本身的作用域(scoping),访问控制及安全特性以支持数据的隐藏和封装。另一方面,使用约束来保证唯一性的意义就是不能完全交由语言本身的机制来控制。以下四类场景可以用来检验,指向某个对象x的引用r,是否可以从在某些activity中执行的方法m中逸出:
- m把r作为方法或构造函数的参数。
- m把r作为方法调用的返回值。
- m把r记录在可以被其他activity访问到的字段中(特别是保存在static修饰的字段中,这意味着该字段可以在任意地方被访问到)。
- m以上述某种方式发布了另一个引用,通过这个引用,可以反过来访问r。
如果能保证泄露的发生仅限于出现在那些不能改变字段状态的方法中,那么这些泄露或许是可以接受的。
在一些封闭的类或子系统中(见1.3.4),可以对上述这些进行彻底的检查。但在开放的系统中,大多数约束条件只能作为一种设计规则存在,并借助工具和review来维持。
本节讨论4种约束类型。第一种也是最简单的一种,方法约束,涉及围绕局部变量的普通编程实践。第二种,线程约束,引入了把对字段的访问限制在线程内部的技术。第三种,对象约束,通过面向对象的封装技术提供更加强有力的保证,使得访问对象方法具有唯一性。第四种,组群约束(group confinement),将这些技术拓展到跨线程操作的对象集合中。
2.3.1 方法间约束
如果调用某个方法创建了一个对象,但是该方法没有让这个对象逸出,那么可以确定没有其他线程会干扰这个对象的使用(其他线程甚至不知道它的存在)。把访问限制于方法内的本地变量是一种在所有编程语言中都通用的封装技术。
只需要再谨慎一点,这一技术就可以扩展到方法的序列化调用中。举例来说,考虑下面这个使用java.awt.Point的类。 Point类被定义为一个简单的、记录风格的类,它包含被public修饰的x和y字段,因此跨线程的共享其实例是不明智的。
class Plotter {
// ...
public void showNextPoint() {
Point p = new Point();
p.x = computeX();
p.y = computeY();
display(p);
}
protected void display(Point p) {
// somehow arrange to show p.
}
}
showNextPoint在方法内部创建了一个Point的本地实例。它允许该实例在尾调用(tail call)时逸出到display(p)方法中去,这是因为,哪怕该实例在后续的处理中会在其他线程中被访问到(可能发生在以下场景中:几乎所有基于图形界面的程序都在某种程度上依赖于AWT事件线程—参阅1.1.1.3和4.1.4—虽然其他线程不太可能去修改Point对象),但是showNextPoint方法再也不会访问到这个实例了。
这是一个传递协议(hand-off protocol)的例子,可以保证在任意时间,至多只会有一个活跃的执行方法可以访问某个对象,这个版本也是尾调用最好并且最简单的形式。
类似的用法在工厂方法中也可以见到:构建、初始化一个对象并且在方法最后返回这个对象,如1.1.1.3中的ParticleApplet.makeThread方法演示的那样。
2.3.1.1 会话(Sessions)
多数传递序列被构造成会话的形式。会话中public的入口方法会创建对象,这些对象会被约束在序列化调用的方法所组成的服务中。这个入口方法同样也需要负责序列化调用完成后相关的清理工作,比如:
class SessionBasedService { // Fragments
// ...
public void service() {
OutputStream output = null;
try {
output = new FileOutputStream("...");
doService(output);
}
catch (IOException e) {
handleIOFailure();
} finally {
try { if (output != null) output.close(); }
catch (IOException ignore) {} // ignore exception in close
}
}
void doService(OutputStream s) throws IOException {
s.write(...);
// ... possibly more handoffs ...
}
}
当你面临在哪里进行清理工作的选择时,毫无疑问应该使用finally,而不是依赖于终结方法(比如重写Object.finalize方法)。finally提供了更强有力的手段来保证清理工作会发生,这有助于保护诸如文件这样系统稀缺的资源;相比之下,终结方法通常是在垃圾回收时才被异步触发的。
2.3.1.2 备选协议(Alternative protocols)
如果某个方法在调用另一个或多个方法之后,必须再次访问某个对象,那么尾调用并不合适于在这种场景下使用。因此需要一些额外的设计规则来适配:
public void showNextPointV2() {
Point p = new Point();
p.x = computeX();
p.y = computeY();
display(p);
recordDistance(p); // added
}
方案包括:
调用方拷贝 当有意义的仅是被传递的对象的字段的值而不是对象本身时,调用方可以创建一个该对象的副本用来传递给接收方。例如:
display(p);
应该被替换为:
display(new Point(p.x, p.y));
接收方拷贝 如果一个方法对于入参对象相关的限制一无所知(同样,再一次强调,重要的并不是对象本身),那么可以谨慎的在方法本地保存一个入参的副本。display方法的第一行应该是这样:
Point localPoint = new Point(p.x, p.y);
使用标量(scalar)参数 当不确定是调用方还是接收方的责任时,可以选择不传引用,转而传标量类型的数据,只要这些数据足够接收方构建出一个对象来。我们可以这样重新设计display方法的入参:
protected void display(int xcoord, int ycoord) { ... }
调用时:
display(p.x, p.y);
信任 接收方可能承诺不会对传入的对象进行修改,也不会把对象的引用传递给其他方法。在所有自上而下的调用中。必须确保不会发生泄露。
如果以上的策略都不适用,那么就无法保证纯粹的约束。这时,应该使用本章讨论的其他方案。举例来说,如果这里不需要使用java.awt.Point类,那么可以使用ImmutablePoint类作为代替,这样可以确保类的实例不会被修改(见2.4.4)。
2.3.2 线程内约束
基于线程的约束技术拓展了方法的序列化调用。事实上,最简单,也是最好的设计方案是使用每个线程对应一个会话(thread-per-session)的设计模式(见4.1),这种设计与基于会话的约束相同。举例来说,可以在run方法中初始化要传递的对象:
class ThreadPerSessionBasedService { // Fragments
// ...
public void service() {
Runnable r = new Runnable() {
public void run() {
OutputStream output = null;
try {
output = new FileOutputStream("...");
doService(output);
}
catch (IOException e) {
handleIOFailure();
} finally {
try { if (output != null) output.close(); }
catch (IOException ignore) {}
}
}
};
new Thread(r).start();
}
void doService(OutputStream s) throws IOException {
s.write(...);
// ... possibly more hand-offs ...
}
}
一些并发软件设计(如CSP — 见4.5.1)要求线程内可访问的所有字段严格限制在该线程中。这是对进程中对于寻址空间的强制隔离策略的一种模仿(见1.2.2)。
但需要注意的是,通常在一个线程中,约束对所有对象的访问是不可能的。一个JVM中所有的线程,最终必须共享一些底层资源的访问权,例如通过java.lang.System类中的方法控制的资源。
2.3.2.1 Thread-specific fields
在某个线程中所执行的方法调用,除了可以接收在调用链中受约束的引用之外,还可以通过Thread对象来获取正在运行的线程本身的信息,以及其他更多基于Thread对象的信息。在任何方法中都可以调用Thread.currentThread()这个静态方法,它会返回调用者的Thread对象。
可以利用这一特性,你通过给Thread的子类增加字段并提供相应方法的方式来达到只允许在当前线程内访问它们的效果,例如:
class ThreadWithOutputStream extends Thread {
private OutputStream output;
ThreadWithOutputStream(Runnable r, OutputStream s) {
super(r);
output = s;
}
static ThreadWithOutputStream current() throws ClassCastException {
return (ThreadWithOutputStream) (currentThread());
}
static OutputStream getOutput() { return current().output; }
static void setOutput(OutputStream s) { current().output = s; }
}
这个类可以这样用:
class ServiceUsingThreadWithOutputStream { // Fragments
// ...
public void service() throws IOException {
OutputStream output = new FileOutputStream("...");
Runnable r = new Runnable() {
public void run() {
try { doService(); } catch (IOException e) { ... }
}
};
new ThreadWithOutputStream(r, output).start();
}
void doService() throws IOException {
ThreadWithOutputStream.current().getOutput().write(...);
// ...
}
}
译者注:
- ThreadPerSessionBasedService的service()方法通过把SessionBasedService.service()方法中的逻辑放在Runnable中,从而实现不同的Session在不同线程中的互相隔离。
- ServiceUsingThreadWithOutputStream则换了一种实现的方式,通过定义Thread的子类ThreadWithOutputStream,把OutputStream作为一个private字段维护。在service()方法内部,为每个Runnable创建了OutputStream实例,通过ThreadWithOutputStream的构造参数传入,并通过ThreadWithOutputStream.current().getOutput()取出,这样可以实现每个某个线程维护各自的OutputStream实例(这里有一点需要注意的是,从代码片段很容易误解为不同的Runnable共享一个OutputStream实例,但实际上作者指的应该是不同Service会起不同的Runnable,所以不同线程间可以互相隔离)。
2.3.2.2 ThreadLocal
上述技术的实现依赖特定的Thread子类,java.lang.ThreadLocal工具类消除了这个障碍。它允许thread-specific变量以特别的方式添加到任何代码中。
ThreadLocal类在内部维护了一个将数据(即对象引用)与线程实例关联的表。ThreadLocal支持set和get方法来访问当前线程所持有的数据。java.lang.InheritableThreadLocal类扩展了ThreadLocal,以自动将每一个线程变量传播给由当前线程创建的任何线程。
使用ThreadLocal的大多数设计,都可以看作单例模式的扩展(见2.2.4)。大多数ThreadLocals应用程序,为每个线程构造一个实例,而不是为每个程序一个实例。ThreadLocal 变量通常被声明为静态的,且具有包级别的可见性,因此可以通过给某个定线程中运行的任何一组方法来访问它们。
ThreadLocal可以在我们运行的示例中这样使用:
class ServiceUsingThreadLocal { // Fragments
static ThreadLocal output = new ThreadLocal();
public void service() {
try {
final OutputStream s = new FileOutputStream("...");
Runnable r = new Runnable() {
public void run() {
output.set(s);
try { doService(); }
catch (IOException e) { ... }
finally {
try { s.close(); }
catch (IOException ignore) {}
}
}
};
new Thread(r).start();
}
catch (IOException e) { ... }
}
void doService() throws IOException {
((OutputStream)(output.get())).write(...);
// ...
}
}
2.3.2.3 应用及后果
只有当没有其他更好的选择时,才应该使用ThreadLocals或者上述定义的Thread类的特殊子类。它们和诸如基于会话的其他设计方案相比而言,优缺点如下:
- 在Thread对象中(或与之关联)的引用允许在同一线程中运行的方法自由地共享它们,而不需要将它们明确地作为参数传递。这对于维护当前线程的上下文信息(例如java.security包中的AccessControlContext)或者维护用于打开一组相关文件的当前工作目录来说是一个很好的选择。当需要为每个线程构造资源池的时候,ThreadLocal非常有用(见3.4.1.2)。
- 使用thread-specific类型的变量倾向于隐藏那些会影响方法行为的参数,会导致排查错误和泄露变得更加艰难。从这个角度来说,thread-specific表现出了和全局静态变量一样的问题,只是没有那么极端和不可追溯。
- thread-specific变量状态的改变会影响到所有有关联的代码(比如关闭一个文件,然后打开另一个)。另一方面,所有的这些变化是否都能得到适当的协调就难以保证了。
- 虽然在线程内读写thread-specific字段不需要同步,但是,访问currentThread或者ThreadLocaL表产生的开销并不会比无竞争的方法调用更少。因此通常只有当对象需要共享并且跨线程竞争严重时,使用thread-specific技术才有性能上的提升。
• 使用thread-specific变量会增加代码间的依赖程度,因此降低代码的复用性。这是通过特殊的Thread子类实现线程约束时一个更为严重的问题。例如,doService方法在ThreadWithOutputStream之外无法使用。 任何尝试在此上下文之外调用current方法的行为都会导致报ClassCastException的错误。
• 有时通过ThreadLocal增加上下文信息是能让组件和现有代码一起运行而不需要在调用链路传递这个信息的唯一方法(见3.6.2)。
• 当使用间接基于Thread类的轻量级可执行框架,特别是工作线程池时,很难把执行上下文和数据关联上(见4.1.4)。
2.3.3 对象间约束
即使不能在特定的方法或线程中限制对某个对象的访问,因而必须使用动态锁定,你也可以把所有访问限制在该对象内部,使得一旦线程进入这个对象的某个方法后,就不再需要额外的锁定。这样一来,外部Host容器对象的锁控制权就自动传播到其内部的Parts对象中。为了达到这个目的,指向Parts的引用一定不能泄露(当泄露不可避免时,可以参考2.4.5中的策略)。
在OO程序中随处可见各式各样的对象约束。在涉及并发编程时,唯一需要做的,就是保证在所有Host对象的入口方法处都有同步。这与构造持有原始标量类型(例如double)的完全同步的对象(2.2.2)使用了相同的技术。但是在这里,它被应用在持有对其他对象的引用上。
在基于约束的设计中,Host对象被认为拥有内部的Parts对象。相反,Part对象被认为在“物理上”属于Host对象:
-
Host对象在构造器中为每个Part类构造新实例,给实例的非public字段赋值。这保证了指向Part对象的引用不会被共享。构造器也可以作为“传递”链路的一个起始点。
-
和其他约束技术一样,Host必须保证不会泄露任何指向Part的引用:它绝不会把引用作为任何方法的参数或者返回值,同时确保存有引用的字段是不可被访问的。Part对象也必须保证不能泄露其自身,比如不能把this作为某个外部方法的回调参数(见4.3.1)。这是为了保证想要从外部访问Part对象,只能通过Host对象。
-
在最保守的变体,固定容器(fixed containment)中,Host对象不会对指向内部Part对象的引用重新赋值。这避免了在Host对象中,更新字段时的同步需求。Fixed containment implements the main sense of aggregation discussed in the Design Patterns book and denoted by UML diamond symbols.
-
除非Host对象反过来被其他对象所约束,否则host对象所有适当的方法都应该是同步的(2.3.3.2节给出了一种对某个类定义同步版本和非同步版本的方法)。这可以保证所有对Parts(以及所有由他们递归构造的对象)的访问都维持独占性。在一个受约束的域中持有的Parts可以互相调用另一个的方法而不需要同步;只有通过外部访问时才需要同步。
尽管他们必须满足苛刻的(有时甚至是难以检查的)约束条件,但是对象的约束技术非常普遍,部分原因在于它们在适配模式和其他基于代理的设计模式中具有的实用性。
2.3.3.1 适配器
适配器可以被用在完全同步的host对象中包装原始的(bare)非同步的对象。这就形成了最简单的委托风格的设计模式:适配器只是将所有消息转发给被代理的类。可以用同步适配器来封装当初为顺序环境所编写的“遗留”代码,也可以用它来封装动态加载的代码。这些代码通常被认为在多线程环境中是不安全的。
适配器也可以用来提供一个单一的、安全的入口。这个入口指向经过一个重度优化的,计算密集型的方法集,为了效率,在内部甚至不会执行并发控制。但是,请注意,没有一种包装技术可以处理通过native代码在内部跨线程访问字段的情况。
对于一个或者更多基类对象(ground classes)来说,可以定义一个同步的适配器,这个适配器包含一个字段,称之为被代理对象,被代理对象持有一个指向基类对象的引用,适配器将请求转发给基类对象,并将基类对象的返回值再转发给调用方(请注意任何基类对象的方法中如果包含了返回this引用的形式,都需要转化为适配器的this引用)。代理的引用字段不需要被定义为final的,但是当引用被重新赋值时,需要确保适配器拥有独占访问权。例如,适配器可能会偶尔在内部构造新的被代理对象,并将引用执行它。
如1.4节提到的,当需要保证适配器被认为和内部的基类对象相同时,可以相应的重写equals和hashCode方法。但是在基于约束的设计中没有必要这么做,因为内部对象不会被泄露,因此永远不会出现需要比较内部对象的情况。
作为一个简单的应用,同步适配器可以被用来为那些包含public字段的类提供同步的访问和更新方法:
class BarePoint {
public double x;
public double y;
}
class SynchedPoint {
protected final BarePoint delegate = new BarePoint();
public synchronized double getX() { return delegate.x;}
public synchronized double getY() { return delegate.y; }
public synchronized void setX(double v) { delegate.x = v; }
public synchronized void setY(double v) { delegate.y = v; }
}
java.util.Collection框架使用基于Adapter的方案来实现集合类的分层同步。除Vector和Hashtable之外,基本集合类(例如java.util.ArrayList)是不同步的。可以通过匿名同步代理类来包装原始集合类:
List l = Collections.synchronizedList(new ArrayList());
2.3.3.2 子类化(subclassing)
当某个类的实例总是被限制在其他类中,就没有必要同步这个类的方法。但是当有些实例是这样而另一些不是的时候,最安全的做法是是适当的同步他们,即使并不是在所有环境中都需要锁(See, however, § 2.4.5 and § 3.3.4 for situations in which other tactics may apply)。
伴随着编译器、工具及运行时系统的持续改进,他们越来越能够优化或者减小冗余的锁带来的开销。但是,当必要或者合理时,你可以手动为一个类定义多个版本,然后为某些场景实例化适当的版本。其中最简单的是子类化(见1.4.3):创建一个基类,在子类中通过调用super.m将基类中的各个方法m重写为同步方法:
class Address { // Fragments
protected String street;
protected String city;
public String getStreet() { return street; }
public void setStreet(String s) { street = s; }
// ...
public void printLabel(OutputStream s) { ... }
}
class SynchronizedAddress extends Address {
// ...
public synchronized String getStreet() {
return super.getStreet();
}
public synchronized void setStreet(String s) {
super.setStreet(s);
}
public synchronized void printLabel(OutputStream s) {
super.printLabel(s);
}
}
2.3.4 组群约束
在多线程环境下,可以通过一组对象共同保证在某一时刻,它们之中只有一个可以访问某个资源。资源总是被某一个对象所持有,但是所有权随着时间在对象间不停的变化。维护独占所有权的协议类似于在第2.3.1节中讨论的跨方法调用时引用传递的模式,但是要求更加结构化的管理跨对象和线程组(groups of objects and threads)的一致性。
某种意义上来说,独占资源和物理上的含义是一致的:
- 如果你拥有一个资源,那么你可以利用它做一些事;否则的话,不可以这么做。
- 如果你拥有了这个资源,那么其他人不可以拥有这个资源。
- 如果你把这个资源传递给某人,那么你将不再拥有该资源。
- 如果你把该资源销毁了,那么其他人将不能再拥有它。
如果以这种方式来看,任何类型的对象都可以被视为资源。一种更加具体的描绘该策略方式是:在某一时刻,某个对象至多有一个字段指向任意的独占资源。这一事实可以被用来确保任何给定的activity内的约束行为,从而减少了对资源对象的动态同步。
在一些语境中,涉及独占资源的协议被称为tokens,batons,linear objects, capabilities,有时候也简单的称之为资源。很多并发算法或是分布式算法强依赖于某一时刻只有一个对象可以处理token。一个硬件的例子是令牌环网络(token-ring network),它维护了一个在节点间循环流转的令牌,每个节点只有在拥有该令牌的时候才可以发送消息。
很多传递协议很简单,但是其实现却很容易出错:当涉及到“拥有”的概念时软件和硬件的并表现不太相似(fields containing references to objects just don't act much like physical objects)。例如语句“x.r = y.s”,并不会在操作完成后导致y.s丧失对原先资源的所有权,而是使得r和s同时与资源绑定(这种情况类似于处理现实世界中知识产权和其他形式的许可问题,而这些许可本质上不涉及物理转移操作)。这个问题产生了大量的解决方案,从非正式的惯例,到arbitrarily heavy legal apparatus。
针对不同的Resource对象r和s以及相应的Owner对象x和y,为了提高可靠性,可以将协议封装在执行下列操作的方法中。需要强调的是,获取锁的操作需要同步:
Acquire 所有者x建立对于r的初始所有权。这通常是构造或以其他方式初始化r并且设置r的结果:
synchronized(this) { ref = r; }
Forget 所有者x使得资源r不被任何所有者所有。这通常由当前所有者执行以下操作:
synchronized(this) { ref = null; }
Put (give) 所有者y发送给所有者x一条消息,这条消息包含一个指向资源r的引用作为参数,此后y不再拥有r,但是x拥有r。
// x
void put(Resource s) {
synchronized(this) {
ref = s;
}
}
// y
void anAction(Owner x) {
Resource s;
synchronized(this) {
s = ref;
ref = null;
}
x.put(s);
}
Take. 所有者y向所有者x发送一个请求,x把r作为返回值,y重新拥有资源。
// x
Resource take() {
synchronized(this) {
Resource r = ref;
ref = null;
return r;
}
}
// y
void anAction(Owner x) {
Resource r = x.take();
synchronized(this) {
ref = r;
}
}
Exchange. 所有者y把自己的资源和所有者x的资源交换。这一操作可以通过一次take实现,如:“s = exchange(null)”;或者通过一次不需要返回值的put实现,如“exchange(r) ”。
// x
Resource exchange(Resource s){
synchronized(this) {
Resource r = ref;
ref = s;
return r;
}
}
// y
void anAction(Owner x) {
synchronized(this) {
ref = x.exchange(ref);
}
}
One application of such protocols arises when one object, say an OutputStream, is almost completely confined within its host object, but must be used occasionally by other clients. In these cases, you can allow clients to take the internal object, operate on it, then put it back. In the meantime the host object will be temporarily crippled, but at least you are sure not to encounter integrity violations.