java并发:线程协作

线程协作

线程间进行协作时,则一般有着一些合作的条件,有一些与状态相关的依赖。

构建自定义的同步工具

类库中包含许多存在状态依赖性的类,例如FutureTaskSemaphoreBlockingQueue等,这些类的一些操作中有着基于状态的前提条件。

创建状态依赖类的最简单方法通常是在类库中现有状态依赖类的基础上进行构造。如果没有所需要的功能,则可以使用Java与类库提供的底层机制来构造自己的同步机制。包括内置的条件队列,显式的Condition对象以及AbstractQueueSynchronizer框架。

状态依赖性的管理

在单线程程序中调用一个方法时,如果某个基于状态的前提条件未得到满足,那么这个条件将永远无法成真,因此要使得这些类操作失败。

但是在并发中,基于状态的条件可能会由于其他线程的操作而改变:一个资源池可能在前几条指令前还是空的,现在变为非空的。对于并发对象上依赖状态的方法,虽然有时候在前提条件不满足的情况下会失败,但通常更好的选择时等待前提条件变为真。

依赖状态的操作可以一直阻塞直到可以继续执行,这比使它们先失败再实现起来要更为方便且不易出错。内置的条件队列可以使线程一直阻塞,直到对象进入某个进程可以继续执行的状态,并且当被阻塞的线程可以继续执行时在唤醒它们。

可阻塞的状态依赖性操作形式如下:即锁是在操作的执行过程中被释放与重新获取的,构成前提条件的状态变量必须由对象的锁来保护,从而使得它们在测试前提条件的同时保持不变。如果前提条件未满足就必须释放锁,以便其他线程可以修改对象的状态,否则前提条件永远无法变成真。

1
2
3
4
5
6
7
8
9
scquire lock on object state
while(precodition does not hold){
release lock
wait until precodition might hold
optionally fail if interrupted or timeout expies
reacquire lock
}
perform action
release lock

有界缓存

在生产者与消费者的设计中,经常使用类似ArrayBlockingQueue这样的有界缓存,当执行put时,不能放入元素已满的缓存中,当条件未满足时

  • 依赖状态的操作可以抛出一个异常或返回一个错误,使其成为调用者的一个问题
  • 也可以保持阻塞直到对象进入正确的状态

有界缓存的几种实现

采用不同的方式处理前提条件失败的问题。其中的缓存状态变量buf、head、tail等均由缓存的内置锁保护,并提供同步的doPut,子类中通过这些方法实现put,底层的状态将对子类隐藏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@ThreadSafe
public abstarct class BaseBoundedBuffer<V>{
@GuardedBy("this") private final V[] buf;
@GuardedBy("this") private int tail;
@GuardedBy("this") private int head;
@GuardedBy("this") private int count;
protected BaseBoundedBuffer(int cap){
this.buf = (V[]) new Object[cap];
}

protected synchronized final void doPut(V v){
buf[tail] = b;
if(++tail == buf.length){
tail = 0;
}
++count;
}

protected synchronized final V doTake(){
V v = buf[head];
buf[head] = null;
if(++head == buf.length){
head = 0;
}
--count;
return v;
}

public synchronized final boolean ifFull(){
return count == buf.length;
}

public synchronized final boolean isEmpty(){
return count == 0;
}
}

简单实现

1
2
3
4
5
6
7
8
9
10
11
@ThreadSafe
public class GrumpyBoundedBuffer<V> extends BaseBoundedBuffer<V>{
public GrumpyBoundedBuffer(int size){super(size);}

public synchronized void put(V v) throws BufferFullException{
if(isFull()){
throw new BufferFullException();
}
doPut(v);
}
}

对于有界队列来说,缓存已满并不是一个异常条件,只是一个信息。类似于红灯并不是交通异常一样,这样的实现带来了复杂性。

1
2
3
4
5
6
7
8
while(true){
try{
V item = buffer.take();
break;
}catch(BufferEmptyException){
Thread.sleep(1000);
}
}

修改方式为,当缓存处于某种错误的状态时返回一个错误值,但这种实现没有解决根本问题,即调用者必须自行处理前提条件失败的问题

可选的实现方式:

  • sleep
  • 轮询
  • Thread.yield()

条件队列

条件队列类似于一个任务完成的提示音,如果你注意听铃声,则当任务完成后会立刻得到停止,并放下或做完手头的事情,开始做提示的任务。

如果忽略了铃声,则会错过停止信息,但依然可以去观察任务的状态,如果任务完成则下一步,否则再次留意铃声。

条件队列使得一组线程能够通过某种方式来等待特定的条件变为真,传统队列当中的元素是一个个元素,而条件队列当中是一个个正在等待相关条件的线程。

每个对象同样可以作为一个条件队列,Object.notifyObject.waitObject.notifyAll方法构成了内部条件队列的API。

对象的内置锁与其内部条件队列是相互关联的,要调用条件队列中任何一个方法,都必须持有对象X上的锁。这是因为“等待由状态构成的条件”和“维护状态的一致性”两种机制必须被紧密地绑定在一起:只有能对状态进行检查时,才能在某个条件上等待,并且只有能够修改状态时,才能从条件等待中释放另一个线程。

  • 条件队列没有改变原来的语义,但是在CPU效率、上下文切换开销和响应性等进行了优化
  • 如果某个功能无法通过轮询和休眠来实现,那么条件队列也无法实现。但是条件队列使得在表达和管理状态依赖性时更加简单高效
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@ThreadSafe
public class BoundedBuffer<V> extends BaseBoundedBuffer<V>{
public synchronized void put(V v) throws InterruptedException{
while(isFull()){
wait();
}
doPut();
notifyAll();
}

public synchronized V take() throws InterruptedException{
while(isEmpty()){
wait();
}
V v = doTake();
notifyAll();
return v;
}
}

使用条件队列

条件队列使构建高效以及高可响应性的状态依赖性变得更容易,但同时也很容易被不正确地使用,虽然许多规则都能够确保正确地使用条件队列,但在编译器或系统平台上却并没有强制要求遵循这些规则。

这也是为什么要尽量基于LinkedBlockingQueueLatchSemaphoreFutureTask等构造程序的原因之一,如果能够避免使用条件队列,那么实现起来将容易很多。

Object.wait

自动释放锁,并请求OS挂起当前线程,从而使其他线程能够获取这个锁并修改对象的状态,当被挂起的线程醒来时,它将在返回之前重新获得锁。

wait意味着线程休息,但当发生特定的事情时唤醒我。而调用通知方法就意味着特定的事件发生了。

Object.notify

调用时必须持有与条件队列对象相关的锁。JVM会从这个条件队列上等待的多个线程中选择一个来唤醒。

由于这些线程需要获得锁,因此发出通知的线程应该尽快释放锁,确保正在等待的线程尽快解除阻塞。

由于多个线程可以基于不同的条件谓词在同一个条件队列上等待,因此如果使用notify而不是notifyAll将是一种危险的操作。很容易会产生信号丢失的问题。

只有同时满足以下两个条件时,才能使用单一的notify而不是notifyAll

  • 所有等待线程的类型相同。只有一个条件谓词与条件队列相关,并且每个线程在从wait返回后将执行相同的操作
  • 单进单出。在条件队列上的每次通知,最多只能唤醒一个线程来执行

Object.notifyAll

调用时必须持有与条件队列对象相关的锁。JVM会唤醒所有在这个条件队列上等待的线程。

notifyAll可能存在性能问题

条件谓词

要想正确地使用条件队列,关键是找出对象在哪个条件谓词上等待。在Java与JVM当中没有任何信息可以确保正确地使用条件谓词,但如果没有条件谓词,条件等待机制将无法发挥作用。

条件等待当中存在三元关系:加锁、wait、一个条件谓词。在条件谓词当中包含多个状态变量,而状态变量由一个锁保护,因此在测试条件谓词前必须先持有这个锁。锁对象与条件队列对象(调用wait和notify所在的对象)必须是同一个对象

每一次wait调用都会隐式地与特定的条件谓词关联起来,当调用某个特定条件谓词的wait时,调用者必须以及持有与条件队列相关的锁,并且这个锁必须保持着构成条件谓词的状态变量。

当使用条件等待时

  • 通常都有一个条件谓词,包括一些对象状态的测试,线程在执行前必须首先通过这些测试
  • 在调用wait之前测试条件谓词,并且从wait中返回时再次进行测试
  • 在一个循环中调用wait
  • 确保使用与条件队列相关的锁来保护构成条件谓词的各个状态变量
  • 当调用waitnotifynotifyAll等方法时,一定要持有与条件队列相关的锁
  • 在检查条件谓词之后以及开始执行相应的操作之前,不要释放锁

过早唤醒

wait方法的返回并不一定意味着线程正在等待的条件谓词已经为真。内置条件队列可以与多个条件队列一起使用,即存在多个条件。wait方法也还可以假装返回,而不是由于某个线程调用了notify

当执行控制重新进入到wait的代码,已经重新获取了与条件队列相关联的锁。现在条件谓词可能为真,也可能为假(在notifyAll时为真,但是在重新获取锁的时候可能再次为假了),因为并不清楚另一个线程为什么调用notify或notifyAll,也可能你需要电话的铃声,但其实是电脑发出了铃声。

丢失信号

丢失的信号是指:线程必须等待一个已经为真的条件,但在开始等待之前没有检查条件谓词。即线程将等待一个已经发过的事件。

例如在手机响铃之后没有听到,依然在等待接起电话,但是由于电话已经响过了(它只会响一下,但是一直存在),而你没有去接,因此你可能会等待很长的事件。

通知

条件等待的另一半内容是通知。在条件队列API中有两个发出通知的方法:notifynotifyAll

每当在等待一个条件时,一定要确保在条件谓词变为真时通过某种方式发出通知。

子类的安全问题

在使用条件通知或单次通知时,一些约束条件使得子类化的过程变得更加复杂,要想支持子类化,那么在设计类时需要保证:如果在实施子类化时违背了条件通知或单次通知的某个需求,那么在子类中可以增加合适的通知机制来代表基类。

当设计一个可被继承的状态依赖类时,至少需要公开条件队列与锁,并且将条件谓词和同步策略都写入文档,此外还可能需要公开一些底层的状态变量。

另一种选择是完全禁止子类化,例如final,或将条件队列、锁和状态变量隐藏起来,使子类看不见它们,否则子类破坏了在基类中使用notify的方法,那么基类需要修复这种破坏。

封装条件队列

通常我们应该将条件队列封装起来,因而除了使用条件队列的类,就不能在其他地方访问它。否则调用者会自以为理解了在等待和通知上使用的协议,并采用一种违背设计的方式使用条件队列。

入口协议与出口协议

对于每个依赖状态的操作,以及每个修改其他操作依赖状态的操作,都应该定义应该入口协议和出口协议。

入口协议:即wait与条件谓词

出口协议:包括检查该操作修改的所有状态变量,并确认它们释放使某个其他条件谓词为真,如果是,则notifynotifyAll

Java类库

详情参见Java并发:JUC

  • Condition
    • 广义的内置条件队列
  • Synchronized
  • AbstarctQueuedSynchronizer
  • ReentrantLock]
  • Semaphore
  • CountDownLatch
  • FutureTask
  • ReentrantReadWriteLock

参考