读书笔记-Java并发编程设计原则与模式(第二版)
参考文献
- <<Java并发编程 设计原则与模式(第二版)>> Doug Lea
第一章 面向对象的并发编程
- Java编程语言提供的三种常用的并发构件
- 独占(Exclusion). 可以通过阻止多个并发行为间的有害干扰来维护对象状态的一致性.通常使用同步(
synchronized
) - 状态依赖(
State dependence
).是否可以触发,阻止,延迟或是恢复某些行为是由一些对象是否处于这些行为可能成功或是成功的状态上决定的.通常,状态依赖关系使用监视器(monitor
)方法实现,如Object.wait, Object.notify和Object.notifyAll
. - 创建线程(
Create threads
).使用线程(Thread
)对象来创建和管理并发操作.
- 独占(Exclusion). 可以通过阻止多个并发行为间的有害干扰来维护对象状态的一致性.通常使用同步(
- 通过保证同步在同一个对象上的方法或者代码块操作的原子性(
atomicity
),加锁机制可以同时提供多种保护措施,包括对上层和底层冲突的保护.原子操作被作为一个整体来执行,这样它们就不会被插入的其他线程的操作打断.
第二章 独占
不变性
- 如果一个对象的状态不能改变,那么它永远不会遇到由于多个操作以不同的方式改变器状态而导致的冲突和不一致现象.
- 当前对象永远不会被改变,在执行中只是不断地创建新的对象,用这中方法保证不可变性很容易理解.不幸的是,这种方法一般不能控制界面的交互以及线程之间的协作等功能.
- 具有不可变性最简单的对象,是对象中根本没有数据.因此,它们的方法都是没状态的,也就是说这些方法不依赖任何对象的任何数据.
- 同样的安全和活跃性在具有
final
数据的类中也适用.这样的类的实例不会面临底层的读写冲突和写写冲入,这是因为其值不会被改写.并且,只要它们的初始值以一种一致的,合法的方式创建的,那么这些对象在更高的层面上也不会出现不变性方面的错误. - 在构造函数执行结束之前,不能访问对象的数据.
同步
机制
对象和锁
- 每一个
Object
类及其子类的实例都拥有一把锁.而int
及float
等基本类型都不是Object
类.基本类型只能通过包含它们的对象被锁住.每个单独的成员变量都不能标记为synchronized
.锁只能在使用成员变量的方法中应用.但是,可以像$2.2.7.4中讨论的那样,将成员变量声明为volatile
类型,这将影像成员变量的原子性(atomicity
),可见性和顺序性. - 同样,包含基本类型元素的数组对象也是拥有锁的对象,但是它的每个基本元素却没有锁(不能把数组元素声明为
volatile
).锁住Object
类型的数组,不会自动锁住数组中的每一个元素.没有在一个原子操作中同时锁住多个对象的构件.
同步方法和阻塞
-
synchronized
关键字在语法上有两种形式: 作用于程序块或方法.块同步需要一个参数来表明锁住的是哪一个对象.这种方式使得任何一个方法都可以锁住任何一个对象.同步块最常用的参数就是this
.1
2
3synchronized void f() {/* body */}
// <==>
void f() { synchronized(this) {/* body */} } -
synchronized
关键字不属于方法签名的一部分,所以当子类覆盖父类方法时,synchronized
修饰符不会被继承.因此接口中的方法不能被声明为synchronized
.同样地,构造函数不能被声明为synchronized
(尽管构造函数中的程序块可以被声明为synchronized
) -
子类和父类方法使用同一个锁.但是内部类的锁和它外部类无关,然而,一个非静态的内部类可以锁住它的外部类,就像下面这个样子:
1
synchronized(OuterClass.this) {/* body */}
申请和释放锁
- 锁的申请和释放是在使用
synchronized
关键字时根据内部的申请释放协议来使用的.所有的锁都是快结构.当进入synchronized
方法或者块的时候得到锁 ,退出的时候释放锁,即使因异常退出也会释放锁.不会有忘了释放锁的情况发生. - **锁操作是基于"每个线程"而不是"每个调用".**如果锁是空闲的,或者某个线程已经拥有了锁,那么该线程就可以通过获取锁的操作继续处于活动状态,否则该线程将处于阻塞状态.[这里的可以多次进入(reentrant或者recursive)使用的锁和
POSIX
的线程使用锁的原则有所不同.]一般来说,允许一个synchronized
方法在不释放锁的情况下直接调用需要同一个锁的另一个synchronized
方法. synchronized
方法或者块只需要和另一个需要同一个锁的synchronized
方法或者块遵守锁原则就可以.非同步的方法在任何时候都可以执行,即使同步方法正在执行.换句话说,synchronized
和原子操作(atomic
)不是等价的,但是同步可以实现原子操作.
静态
-
锁住一个对象并不代表不可访问这个对象或者其他任何父类的静态数据.可以通过
synchronized static
方法或者块来实现静态数据的保护.静态同步方法使用静态方法所在的类相关的Class
对象拥有的锁.1
synchronized(C.class) {/* body */}
-
和每个类相关的静态锁和任何其他类的锁都没有关系,包括它的父类.如果想在子类中增加一个静态同步方法来达到保护父类的静态数据的目的是不可能的.
完全同步对象
- 锁是最基本的信息接收控制机制.
- 基于锁的最安全的(但不一定是最好的)并发面向对象设计策略是,把注意力限制在完全同步对象(也就是原子对象).因为在完全同步对象中:
- 所有的方法都是同步的;
- 没有公共成员变量,或者其他封装问题;
- 所有的方法都是有限的(没有无限循环,或者无休止的递归),所以最终都会释放锁;
- 所有成员变量在构造函数中已经初始化为稳定一致的状态.
- 在一个方法开始和结束的时候,对象的状态都应该稳定一致(遵守不变性),即使出现了一次情况也应该如此.
遍历
- 完全同步对象策略对另一种集合的通用方法不起作用: 遍历.遍历就是对集合中的每一个元素执行一些相应的操作或逐个使用.因为对集合元素的操作可能无限多,所以把集合中的每个方法都定义为
synchronized
方法是没有意义的. - 对这个设计问题一般有三种解决方法:
- 聚集操作
- 索引化遍历
- 版本化迭代变量
同步聚合操作
-
一种安全使用枚举的方法就是把作用于每个元素的操作抽取出来,这样可以把它作为
synchronized applyToAll
方法的参数.1
2
3
4
5
6
7
8
9
10
11
12
13interface Procedure{
void apply(Object obj);
}
class ExpandableArrayWithApply extends ExpandableArray{
public ExpandableArrayWithApply(int cap){ supper(cap);}
synchronized void applyToAll(Procedure p){
for(int i = 0;i < size; ++i){
p.apply(data[i])
}
}
}-
比如说,这可以用来打印集合v中的所有元素
1
2
3
4
5v.appyToAll(new Procedure(){
public void apply(Object obj){
System.out.println(obj);
}
})
-
-
这个方法消除了在遍历过程中其他线程试图增加或减少元素可能带来的干扰,但是代价是拥有集合的锁的时间太长.这种代价有时是可以接受的,但是这种方法会引发性能和活跃性问题.处理的方法正如$1.1.1.1中所说的默认规则,当调用操作方法(这里指
apply
方法)时释放锁.
索引化遍历和客户端锁
-
对于
ExpandableArray
使用另一种遍历策略是要求客户端使用索引的访问方法来遍历,例如:1
2
3for(int i = 0; i < v.size; ++i){ // Do not use
System.out.println(v.get(i));
}-
这样可以避免对每个元素操作的时候都使用锁,其代价是对每个元素都要进行两个同步操作(size,和get).更重要的是,为了处理由细锁粒度产生的潜在冲突问题,必须要重写循环.像i<v.size()这样的操作可能成功,但在之后,另一个线程可能删除了当前的最后一个元素,如果这时再调用v.get(i)可能就会出错.解决这个问题的一个办法是使用客户端锁来保证大小检查和访问原子性.
1
2
3
4
5
6
7
8
9
10
11for(int i = 0; true; ++i){
Object obj = null;
synchronized(v){
if(i < v.size()){
obj = v.get(i);
}else{
break;
}
}
System.out.println(obj);
} -
即便这样,还可能会有问题.例如: 如果
ExpandableArray
类支持重新设置元素位置的方法,那么在遍历过程中,如果v被这样修改了,那么同样的元素就可能被打印两次. -
作为极端的手段,客户端可以将全部的遍历包含在
synchronized(v)
语句中.同理,这种方法通常是可以接受的,但是会引发讨论使用同步聚合方法时所见的长时间被锁的问题.,如果对元素的操作很费时,则可以先拷贝数组用来遍历:1
2
3
4
5
6
7
8
9
10Object[] snapshot;
synchronized(v){
snapshot = new Object[v.size()];
for(int i = 0; i< snapshot.length; ++i){
snapshot[i] = v.get(i);
}
}
for(int i = 0; i< snapshot.length; ++i){
System.out.println(snapshot[i]);
}
-
版本化迭代变量
- 第三种遍历方法是涉及的集合类支持**失败即放弃(fast-fail)**的迭代变量,如果在遍历过程中集合元素被修改,迭代操作就会抛出一个异常.实现这种策略的最简单的方法就是维护一个迭代操作的版本号,这个版本号在每次更新集合时都会增长.每当迭代变量访问下一个元素时,都会先看一下这个版本号,如果它变了,则会抛出一个异常.这个版本号应该足够大,使得在一次遍历过程中版本号不会循环,一般来讲,整型(int)就足够了.