目录
一、任务取消
1、线程状态
2、线程进入阻塞状态
3、中断
5、处理不可中断的阻塞
6、ExecuotrService
二、线程间的通信
1、任务间的协作
2、条件队列
3、生产者——消费者模式
4、显示条件队列——Condition对象
5、阻塞队列
三、死锁
1、哲学家就餐问题
2、死锁
四、同步容器
1、传统的同步容器
2、传统同步容器的问题
3、并发容器
五、高级工具
1、栅栏器
一个线程可以处于以下的五种状态之一:
1)新建:当线程被创建时,它只会短暂的处于这种状态。此时它已经分配了必要的系统资源,并执行了初始化。此刻线程已经有资格获取CPU时间,之后调度器将把这个线程转变为可运行状态或阻塞状态。
2)就绪:在这种状态下,调度器把时间片分给线程,线程就可以运行。
3)运行:调度器把时间片分给线程,线程开始执行任务。
4)阻塞:线程能够运行,但有某个条件阻止它的运行。当线程处于阻塞状态时,调度器将忽略线程,不会分配给它CPU。直到线程重新进入就绪状态。
5)死亡:处于死亡或终止的线程将不再是可调度的,并且再也不会得到CPU。线程死亡通常方式是从run()方法返回或者线程产生中断(好的线程死亡机制)。
线程进入阻塞状态,有四个原因:
1)通过调用sleep()使任务进入休眠状态。
2)通过调用wait()使线程挂起。直到线程得到notify()或notifyAll()消息,线程才会进入就绪状态。
3)线程等待某个输入/输出的完成。
4)线程在等待其他线程释放锁。
①、取消任务
如果外部代码能在某个操作正常完成之前将其置入“完成”状态,那么这个操作就可以称为可取消的。一个任务的取消可以有以下的原因:
1)、用户请求取消:用户点击图形界面程序中的“取消”按钮,或者通过管理接口来发出取消请求。
2)、有时间限制的操作:例如,某个应用程序需要在有限时间内搜索问题空间,并在这个时间内选择最佳的解决方案。当计时器超时时,需要取消所有正在搜索的任务。
3)、应用程序事件:应用程序对某个问题空间进行分解并搜索,从而使不同的任务可以搜索问题空间中的不同区域。当其中一个任务找到了解决方案时,所有其他仍在搜索的任务都将被取消
4)、错误:网页爬虫程序搜索相关的页面,并将页面或摘要的数据保存到磁盘。当一个爬虫任务发生错误时(比如说:磁盘已满),那么所有的任务都会去取消。
5)、关闭:当一个程序或服务关闭时必须对正在处理和等待处理的工作执行某种操作。在平缓的关闭过程中,当前正在执行的任务将继续执行直到完成,而在立即关闭的过程中,当前的任务则可能取消。
在java中没有一种安全的抢占式方法来停止线程,因此也就没有安全的抢占式方法来停止任务(尽管有Thread.stop()和suspend()方法提供终止线程的机制,他们是不安全的,可能发生死锁)。只有一些协作式的机制,使请求取消的任务和代码都遵循一种协商好的协议。
使用volatile变量取消任务。下面示例中,一个线程做获取素数的任务(通过不断的轮询取消标志),在main线程中等待一秒后把取消标志置为true。
class PrimeGenerator implements Runnable {
private final List primes = new ArrayList();
private volatile boolean cancelled;
@Override
public void run() {
BigInteger p = BigInteger.ONE;
while(!cancelled) {
p = p.nextProbablePrime();
synchronized(this) {
primes.add(p);
}
}
}
public void canced() {
cancelled = true;
}
public synchronized List get() {
return new ArrayList(primes);
}
}
public class OneSecondPrimeGenerator {
public static void main(String[] args) {
PrimeGenerator generator = new PrimeGenerator();
new Thread(generator).start();
try {
TimeUnit.SECONDS.sleep(1);
} catch(InterruptedException e) {
System.out.println("Interrupted");
} finally {
generator.canced();
}
System.out.println(generator.get());
}
}
②、中断一种好的取消任务的友好协商
线程中断是一种协作机制,线程通过这种机制来通知另外一个线程,告诉它在何时的或者可能的情况下停止当前的工作,并转而执行其它的工作。
Thread类中有三个关于中断和查询中断状态的方法分别是:
1)public void interurpt() {...} 调用该方法能中断目标线程,但不会立刻停止目标线程正在进行的工作,它只是传递中断请求,把中断标志设置为true。
2)public boolean isInterrupted() {...} isInterrupted()方法返回目标线程的中断状态
3)public static boolean interrupted() {...} 静态的interurpted()方法将清除当前线程的中断状态,并返回清除之前线程的中断状态,这是清除中断状态的唯一方法。
中断并不会真正的中断一个正在运行的线程,而只是发出中断请求,然后由线程在下一个合适的时刻中断自己。(这些时刻也称作取消点)
*当线程在阻塞状态下遇到中断请求时:对于调用Thread.sleep(),Object.wait(),Thread.join(),或者使用BlockingQueue.put()等阻塞方法时有中断请求或者执行时发现已被设置好的中断状态,它们将清除中断状态,并抛出InterruptedException异常,表示阻塞操作由于中断而提前结束。
*当线程在非阻塞状态下遇到中断请求时:它的中断状态将被设置,然后根据将被取消的操作来检查中断以判断发生了中断。如果不触发InteruptedException异常,那么中断状态将一直保持,直到明确地清除中断状态(也就是说线程将会继续执行任务,直到有InterruptedException异常抛出,或者清除中断标志)。
在前面的素数生产者的程序中,通过设置“取消标志”进行任务的取消,下面是请求中断取消任务的使用
class PrimeProducer extends Thread {
private final BlockingQueue primeQueue;
public PrimeProducer(BlockingQueue primeQueue) {
this.primeQueue = primeQueue;
}
@Override
public void run() {
try {
BigInteger p = BigInteger.ONE;
while(!Thread.currentThread().isInterrupted()) {
primeQueue.put(p = p.nextProbablePrime());
}
} catch(InterruptedException e) {
System.out.println(Thread.currentThread().getName() + " Interupted");
}
}
public void cancel() {
interrupt();
}
}
public class InterruptCancelTask {
public static void main(String[] args) throws InterruptedException {
PrimeProducer p = new PrimeProducer(new ArrayBlockingQueue(10));
p.start();
TimeUnit.SECONDS.sleep(1);
p.cancel();
}
}
③、中断策略
中断策略规定线程如何解释某个中断请求——当发现中断请求时,应该做哪些工作,哪些工作单元对于中断来说是原子操作,以及以多块的速度来响应中断。
最合理的中断策略是某种形式的线程级取消操作或服务级取消操作:尽快退出,在必要时进行清理,通知某个所有者该线程已经退出。比如说:通过线程池的shutdownNow()方法进行任务取消操作,把一个中断请求分发给多个接受者,这同时也意味着“取消当前任务”和“关闭工作者线程”。这种方式下任务就不会在某个自己拥有的线程中运行,而是在某个服务(线程池)拥有的线程中执行。这是合理的中断策略也是线程与任务对于中断响应的不同响应。
对于中断策略不应做两点假设:
1)任务不应该对执行该任务的线程的中断策略做出任何假设,除非该任务被专门设计为在服务中运行,并且在这些服务中包含特定的中断策略。
2)执行取消操作的代码也不应该对线程的中断策略做出假设。线程应该只能由其所有者中断,所有者可以将线程的中断策略信息封装到某个合适的取消机制中,例如shutdown方法
④、更灵活的中断策略——响应中断
对于大多数可阻塞的方法中(如Thread.sleep())会抛出InterruptedException异常作为中断响应。有两种实用策略可用于处理InterruptedException:
*传递异常(可能在执行某个特定于任务的清除操作之后),使你的方法也称为可中断的阻塞方法。如下所示:
BlockingQueue taskQueue ;
public Task getNextTask() throws InterruptedException {
return taskQueue.take();
}
*恢复中断状态:通过再次调用interrupt()方法恢复中断,从而使调用栈中的上层代码能够对其进行处理。对于那些不支持取消但仍可以调用可中断阻塞方法的操作,它们必须在循环中调用这些方法,并且发现中断后重试。在这种情况下,他们应该保存本地中断状态,并且在返回前恢复中断状态而不是在捕获InterruptedException时恢复中断状态。如下所示:
public Task getNextTask(BlockingQueue queue) {
boolean interrupted = false;
try {
while(true) {
try {
return queue.take();
} catch(InterruptedException e) {
interrupted = true;
}
}
} finally {
if(interrupted)
Thread.currentThread().interrupt();
}
}
⑤、通过Future取消任务
ExecutorService.submit将返回一个Future来描述任务。Future拥有一个cancel()方法,该方法带有一个boolean类型的参数mayInterrupteIfRunning,表示取消操作是否成功(这只是表示任务是否能够接收中断,而不是表示任务是否能检测并处理中断)
如果mayInterruptIfRunning为true并且任务正在某个线程中运行,那么这个线程能够被中断。如果mayInterruptIfRunning为false,那么意味着“任务没有启动,就不要运行它”,这种方式应该用于那些不处理中断的任务中。
每个线程都有自己的中断策略,随意使用Future.cancel()方法是不提倡的,因为你不知道中断发生时线程会发生什么。最好是通过Executor框架创建执行任务的线程,它实现了一种中断策略使得任务可以通过中断被取消。此外,在尝试取消某个任务时,不宜直接中断线程池(调用ExecutorService.shutdown()等方法),因为你不知道中断请求到达时正在运行什么任务,最好通过任务的Future.cancel()取消任务。
public class FutureInteruptTask {
static final ExecutorService taskExec = Executors.newCachedThreadPool();
public static void timedRun(Runnable r, long timeout, TimeUnit unit) throws InterruptedException {
Future> task = taskExec.submit(r);
try {
task.get(timeout, unit);
} catch (ExecutionException e) {
// 接下任务将被取消
} catch (TimeoutException e) {
// 任务如果抛出异常,那么重新抛出异常
} finally {
// 任务结束,取消操作不会造成什么影响
// 任务未结束,那么直接中断线程
task.cancel(true);
}
}
}
如果一个线程由于执行同步的Socket I/O或者等待获得内置锁而阻塞,那么中断请求只能设置线程的中断状态,除此之外没有其他任何作用。不可中断的阻塞总共有以下几种形式以及解决办法:
1)Java.io包中的同步Socket I/O:在服务器应用程序中,最常见的阻塞I/O形式就是对套接字进行读取和写入。虽然InputStream和OutputStream中的read和write等方法都不会响应中断,但通过关闭底层套接字,可以使得执行read或write等方法而被阻塞的线程抛出一个SocketExceptione
2)Java.io包中的同步I/O:当中断一个正在InteruptibleChannel上等待的线程时,将抛出ClosedByInterruptException并关闭链路(这还会使得其他在这条链路上阻塞的线程同样抛出ClosedByInterruptException)。当关闭一个InteruptibleChannel时,将导致所有在链路操作上阻塞的线程都抛出AsynchronousCloseException。
3)Selector的异步I/O:如果一个线程在调用Selector.select方法(在java.nio.channels中)时阻塞了。那么调用close或wakeup方法会使线程抛出ClosedSelectorException并提前返回。
4)获取某个锁:如果一个线程由于等待某个内置锁而阻塞,那么将无法响应中断,因为线程认为它肯定会获得锁,所以将不会理会中断请求。在Lock类中提供LockInteruptibly()方法,用于可响应中断等待一把锁。
①、通过改写interrupt()方法解决I/O阻塞问题。
下面程序中改写了interrupt()方法,使其既能处理标准的中断,也能关闭底层的套接字。因此无论线程在read()或write()方法中阻塞还是某个可中断的阻塞方法中阻塞,都可以被中断并停止执行当前的工作。
public class ReaderThread extends Thread {
private final Socket socket;
private final InputStream in;
private static final int SIZE = 1024;
public ReaderThread(Socket socket) throws IOException {
this.socket = socket;
this.in = socket.getInputStream();
}
public void interupt() {
try {
socket.close();
} catch(IOException e) {
e.printStackTrace();
} finally {
super.interrupt();
}
}
protected void processBuffer(byte[] buffer, int count) {
// 保存数据
}
@Override
public void run() {
try {
byte[] buffer = new byte[SIZE];
while(true) {
int count = in.read(buffer);
if(count < 0)
break;
else if(count > 0)
processBuffer(buffer, count);
}
} catch(IOException e) {
// 线程退出 (当套接字关闭时,抛出SocketException异常(继承自IOException))
}
}
}
①、线程池的生命周期
Executor框架作为线程池的实现,它提供了灵活且强大的异步任务执行基础,支持多种不同类型的任务执行策略,并提供了一种标准的方法将任务的提交过程与执行过程进行解耦,并用Runnable来表示任务。ExecutorService接口扩展了Executor接口,添加了用于生命周期管理的方法。
ExecutorService中生命周期管理方法:
public interface ExecutorService extends Executor {
void shutdown();
List
shutdownNow(); boolean isShutdown();
boolean isTerminated();
boolean awaitTermination(long timeout, TimeUnit unit) thorows InterruptedException;
}
ExecutorService管理的线程池的生命周期有3中状态:运行、关闭、终止。
1)运行:ExecutorService在初始创建时处理运行状态,此时工作队列中任务可能处于完成、正在运行、等待工作线程执行任务。
2)关闭:ExecutorService的关闭可以由shutdown()或shutdownNow()方法进行关闭,前者将执行平缓的关闭,后者是强制性关闭。可以通过调用isShutdown()来轮询ExecutorService是否已经关闭。
3)终止:当ExecutorService关闭后提交的任务将由“拒绝执行处理器”进行处理,它会抛弃任务,或者使得executor方法抛出一个未受检查的RejectedExecutionException。当所有任务完成后,ExecutorService将转入终止状态。可以通过调用awaitTermination()来等待ExecutorService到达终止状态,或者通过调用isTerminated来轮询ExecutorService是否已经终止。
②、再谈ExecutorService的关闭
关闭ExecutorService(生产者——消费者服务模式)有两种方式,调用shutdown()或shutdownNow()。
1)调用shutdown():使用shutdown()是平缓的关闭,它更安全,因为ExectorService会一直等到队列中的所有任务都执行完后才关闭。但它的关闭速度慢,响应性差。
2)调用shutdownNow():使用shutdownNow()是强制关闭(通过向所有线程池中的线程发送interrupt()中断请求),shutdownNow首先关闭当前正在执行的任务,然后返回所有尚未启动的任务清单(List
通过封装,把关闭线程池的方法放在更高级别的服务中。
public void stop() throws InterruptedException {
try {
exec.shutdown();
exec.awaitTermination(TIMEOUT, TimeUnit.MILLISECONDS);
} finally {
writer.colse();
}
}
在解决线程安全性我们使用了同步机制限制多个线程对于共享资源的访问,但在另外方面我们希望多个线程可以进行协作以解决某个特定的问题。这些问题解决必须要依赖于某一部分的完成,才能继续下一部分的工作。这就好比于项目的规划:必须先挖房子的地基,然后才能进行混凝土浇注,而管道的铺垫必须要水泥板浇注之前完成,等等。这样,某些任务的执行必须要某一项相关任务完成之前才能开始。
解决任务协作关键是任务间的“握手”,完成任务“握手”必须依赖于同步机制。这样,才能确保只有一个任务可以响应某个信号,就能根除任何可能的竞争条件。“握手”可以通过Object的wait()和notify()方法来安全地实现。
条件队列使得一组线程(称之为等待线程集合)能够通过某种方法来等待特定的条件变成真。
每个Java对象都可作为一把锁,每个对象同样可以作为一个条件队列(比如说这个对象是一个任务对象),Object中wait()、notify()、notifyAll()方法就构成内部条件队列API。对象的内置锁与其内部条件队列是相互关联的,即要调用内部条件队列的任何一个方法,必须持有对象的锁。这是由于“等待由状态构成的条件”与“维护状态一致性”这两种机制必须紧密地捆绑在一起:“只有能对状态进行检查时,才能在某个条件上等待。只有能修改状态时,才能使条件等待中释放另一个线程。”
①、状态依赖性
状态依赖性使得某些操作有着基于状态的前提条件。比如:不能从一个空队列中删除元素,或者不能获取一个尚未结束的任务的计算结果。这些操作可以执行前,必须等待队列进入“非空”,或者任务进入“已完成”的状态。
*在单线程中:调用一个方法时,如果某个基于状态的前提条件未得到满足(例如“连接池必须非空”),那么这个条件将永远无法成真。因此,在编写单线程方法时要使得这些类在它们的前提条件未被满足前就失败。
*在多线程中:基于状态的条件可能会由于其他线程的操作而改变。编写多线程依赖状态方法时,最好的选择是,即等待“前提条件”变为真。
多线程条件下,构建有界缓存队列,实现状态依赖性。
// 有界缓存
public abstract class BaseBoundedBuffer {
private final V[] buf;
// 指向缓存数组的数据末尾(放数据)
private int tail;
// 指向缓存数组的数组头部(取数据)
private int head;
// 记录缓存数组中数据的个数
private int count;
protected BaseBoundedBuffer(int capacity) {
this.buf = (V[]) new Object[capacity];
}
protected synchronized final void doPut(V v) {
buf[tail] = v;
if(++tail == buf.length)
tail = 0;
++count;
}
protected synchronized final V doTake() {
V v = buf[head];
// 取出数据后把当前位置置为null
buf[head] = null;
if(++head == buf.length)
head = 0;
--count;
return v;
}
public synchronized final boolean isFull() {
return count == buf.length;
}
public synchronized final boolean isEmpty() {
return count == 0;
}
}
// 状态依赖性
public class GrumpyBoundedBuffer extends BaseBoundedBuffer {
protected GrumpyBoundedBuffer(int capacity) {
super(capacity);
}
public synchronized void put(V v) throws BufferFullException {
if(isFull())
throw new BufferFullException();
doPut(v);
}
public synchronized V take() throws BufferEmptyException {
if(isEmpty())
throw new BufferEmptyException();
return doTake();
}
}
②、条件谓词
条件谓词是使某个操作称为状态依赖操作的前提条件,如果没条件谓词,条件等待机制就无法完成(多线程下状态依赖性无法成立)。在条件缓存中,只有当缓存不为空时,take方法才能执行,否则必须等待。对于take方法来说,它的条件谓词就是“缓存不为空”。同样,put方法的条件谓词是“缓存不满”。
在条件等待中存在一种重要的三元关系,包括加锁、wait方法和一个条件谓词。在条件谓词中包含多个状态变量,而状态变量由一个锁来保护,因此在测试条件谓词前必须先持有这个锁。锁对象与条件队列对象必须是同一个对象。
③、wait()
由于线程间存在状态依赖性(一个线程任务依赖于另外一个线程任务)。便有了条件队列,条件队列使得线程必须通过条件等待来实现线程间的协作。所以线程协作的核心概念是条件等待,它的前提是获取对象锁,核心是条件谓词,实现是wait()。
使用wait的形式如下:
syncrhonized(lock) { // 前提,获取对象锁
while(!conditionPredicate()) // 核心,条件谓词
lock.wait(); // 实现,等待。
}
使用wait()方法时将发生以下几件事:
1)释放当前线程获取的对象锁。
2)线程进入条件队列,进入阻塞状态。
3)等待另一个线程修改状态,使条件谓词为真,并调用notify()唤醒它。当唤醒时它会自动的获取对象锁并从wait()调用中返回。
每次wait()调用都会隐式地与特定的条件谓词关联起来。当调用某个特定条件谓词的wait时,调用者必须已经持有与条件队列相关的锁,并且这个锁必须保护构成条件谓词的状态变量。
—— 摘自《java并发编程实战》
④、notify()与notifyAll()
我们可以通过通知方式唤醒在条件队列等待的线程。通知有两种方式notify()以notifyAll()方法。
1)notify():在调用notify时,JVM会从这个条件队列上等待的多个线程中选择一个进行唤醒。
2)notifyAll():而使用notifyAll时,JVM则直接唤醒所有在条件队列上等待的线程。
使用通知应注意下面两个问题:
1)在使用notify与notifyAll时必须持有条件队列对象的锁,这样才能唤醒在条件队列上等待的线程。
2)调用完通知方法后,应快速释放条件队列对象锁,因为唤醒的线程不能重新获得锁,那么将无法从wait中返回。
每当在等待一个条件时,一定要确保在条件谓词变为真时,通过某种方式发出通知。
使用条件队列实现有界缓存
public class BoundedBuffer extends BaseBoundedBuffer {
public BoundedBuffer(int capacity) {
super(capacity);
}
public synchronized void put(V v) throws InterruptedException {
// isFull()--条件谓词
while(isFull())
// 阻塞并直到:非满
wait();
doPut(v);
notifyAll();
}
public synchronized V take() throws InterruptedException {
// isEmpty()--条件谓词
while(isEmpty())
// 阻塞并直到:非空
wait();
V v = doTake();
notifyAll();
return v;
}
}
⑤、丢失的信号
当线程必须等待一个已经为真的条件,但在开始等待之前没有检查条件谓词。此时,线程将等待一个已经为真的事件。这就好比,设置闹钟,在某个时候做某件事,但因为一些原因没有听到这个铃,使得我们一直在苦苦等待铃声的到来。
*notify的危险性与notifyAll的性能:
当多个线程基于不同的条件谓词在同一个条件队列上等待,如果使用notify而不是notifyAll,那么将是一种危险的操作,因为单一的通知很容易造成类似信号丢失的问题。比如说:有三个线程,线程A在条件谓词PA等待,线程B在条件谓词PB等待。因为线程C修改了状态,使条件谓词PB称为真,通过调用notify想唤醒线程B,但JVM通过选择线程A进行唤醒,但PA条件谓词为假,所以线程A继续进入等待。此时,线程B本可以继续执行,但因为丢失的信号却没有被唤醒。所以使用notify应遵循两个条件:
1)所有等待线程的类型都相同(执行同一个任务):只有一个条件谓词与条件队列相关,并且每个线程在从wait返回后将执行相同的操作。
2)单进单出:在条件变量上的每次通知,最多只能唤醒一个线程来执行。
所以,最普遍的做法是优先使用notifyAll,它的性能比notify低效,但更容易保证正确性。在使用notifyAll时,将唤醒所以在条件队列等待的线程,并使它们在锁上进行竞争。最终,大多数将又回到休眠状态。因而,将会出现大量的上下文切换操作以及竞争的锁获取操作(最坏情况下,将导致进行O(n^2)唤醒操作)。是考虑安全性还是性能,应该由具体情况具体分析。
我们可以使用条件队列构建安全的生产者——消费者模式。下面是使用这种模式的餐馆模型。其中厨师代表生产者,生产食物。服务员代表消费者。两个任务必须在生产和消费时进行状态依赖。生产者等待食物是否被消费(条件谓词)。消费者必须等待食物是否被生产(条件谓词)。以此产生协作。
class Meal {
private final int orderNum;
public Meal(int orderNum) {
this.orderNum = orderNum;
}
public String toString() {
return "Meal " + orderNum;
}
}
// 服务员(消费者)任务
class WaitPerson implements Runnable {
private Restaurant restaurant;
public WaitPerson(Restaurant restaurant) {
this.restaurant = restaurant;
}
@Override
public void run() {
try {
while(!Thread.interrupted()) {
synchronized(this) {
// 条件谓词
while(restaurant.meal == null) // 采用while()条件有两个原因:
// 其一:避免唤醒信号丢失,导致死锁,
// 其二,避免在信号丢失之后,多个任务将会锁在此处
// 释放waitPerson对象锁,等待waitPerson对象调用notify()方法停止其等待
wait(); // wait()/notify()方法必须在同步代码块或方法中使用
}
System.out.println("Waitperson got " + restaurant.meal + Thread.currentThread().getName());
synchronized(restaurant.chef) {
restaurant.meal = null;
// 在生产者等待的条件队列对象调用notifyAll()通知生产者
restaurant.chef.notifyAll();
}
}
} catch(InterruptedException e) {
System.out.println("WaitPerson interrupted");
}
}
}
// 厨师(生产者)任务
class Chef implements Runnable {
private Restaurant restaurant;
private int count = 0;
public Chef(Restaurant restaurant) {
this.restaurant = restaurant;
}
@Override
public void run() {
try {
while(!Thread.interrupted()) {
synchronized(this) {
// 当厨师已经生产事务后,条件谓词满足。
while(restaurant.meal != null)
// 等待食物被消费后消费者通知生产者
wait();
}
if(++count == 10) {
System.out.println("Out of food closing");
restaurant.exec.shutdownNow();
}
System.out.println("Order up!");
// 由于服务员任务被wait(),所以在厨师任务此处,将会获取waitPerson对象锁
synchronized(restaurant.waitPerson) {
restaurant.meal = new Meal(count);
// 唤醒等待waitPerson锁的任务(在本程序,其实调用notify()即可,但处于安全性的考虑所以调用notifyAll()更安全点)
// 调用消费者条件队列对象的notifyAll()唤醒消费者
restaurant.waitPerson.notifyAll();
}
TimeUnit.MILLISECONDS.sleep(100);
}
} catch(InterruptedException e) {
System.out.println("Chef interrupted");
}
}
}
public class Restaurant {
Meal meal;
ExecutorService exec = Executors.newCachedThreadPool();
WaitPerson waitPerson = new WaitPerson(this);
WaitPerson waitPerson2 = new WaitPerson(this);
Chef chef = new Chef(this);
public Restaurant() {
exec.execute(chef);
exec.execute(waitPerson);
exec.execute(waitPerson2);
}
public static void main(String[] args) {
new Restaurant();
}
}
每个内置锁只能有一个相关联的条件队列,多个线程可能在同一个条件队列上等待不同的条件谓词,并且在最常见的加锁模式下公开条件队列对象。这些因素都使得无法满足在使用notifyAll时所有等待线程为同一类型的需求。
我们可以使用Lock和Condition对象构建显示锁与显示条件队列以满足对于不同条件下的需求(比如满足通知同一类型的线程)。一个Lock可以与多个Condition关联一起,而一个Condition只能与一个Lock关联。可以通过Lock.newCondition创建Condition对象。在每个锁上可以存在多个等待、条件等待可以是可中断或不可中断的、基于时限的等待、以及公平或非公平的队列操作。Conditon对象还继承了Lock对象的公平性,对于公平的Lock,线程将按照FIFO的顺序依次从Condition.await()释放锁。
在Condition对象中,与wait、notify、notifyAll方法对应的是await、signal、signalAll。
使用显示锁与显示条件队列构建有界缓存,这个程序与使用内置锁的条件队列构建的有界缓存行为是一致的。不同的在于,可以使用两个条件队列构建两个条件谓词并分发到两个等待的线程中,这更易于管理。
public class ConditionBoundedBuffer {
protected final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private static final int SIZE = 1024;
private final V[] items = (V[]) new Object[SIZE];
private int tail;
private int head;
private int count;
public void put(V v) throws InterruptedException {
lock.lock();
try {
// 条件谓词
while(count == items.length)
// 阻塞并直到:非满
notFull.await();
items[tail] = v;
if(++tail == items.length)
tail = 0;
++count;
notEmpty.signal();
} finally {
lock.unlock();
}
}
public V take() throws InterruptedException {
lock.lock();
try {
// 条件谓词
while(count == 0)
// 阻塞并直到:非空
notEmpty.await();
V v = items[head];
items[head] = null;
if(++head == items.length)
head = 0;
--count;
notFull.signal();
return v;
} finally {
lock.unlock();
}
}
}
阻塞队列提供了可阻塞的put和take方法,调用这两个方法将会抛出受检查异常InterruptedException。以及支持定时的offer和poll方法。如果队列已经满了,那么put方法将会阻塞直到有空间可用;如果队列为空,那么take方法将会阻塞直到有元素可用。阻塞队列通过条件队列实现,其中使用了显示的Lock与Condition对象构建条件等待与唤醒。
阻塞队列分为下列四种:
ArrayBlockingQueue | 基于数组实现,它具有固定尺寸的数组,用于缓存数据。它是一个FIFO队列,与ArrayList类似,但比它同步的List具有更好的并发性。最常用的阻塞队列 |
LinkedBlockingQueue | 基于链表实现,是一个无界的缓存队列。它也是一个FIFO队列,与LinkedList类似。 |
PriorityBlockingQueue | PriorityBlockingQueue是一个按优先级排序的队列,不同于FIFO它的排序按照你希望的排序算法进行,其中元素需要实现Comparable接口。 |
SynchronousQueue | SynchronousQueue实际上并不是一个正在的队列,因为它不会为队列中的元素维护存储空间。它维护了一组线程,这些线程在等待这把元素加入或移出队列。 |
使用阻塞队列完成线程协作最常见的应用是通过生产者——消费者模式进行实现。在基于阻塞队列构建的生产者——消费者设计中,当数据生成时,生产者把数据放入队列,而当消费者准备处理数据时,将从队列中获取数据。生产者不需要知道消费者的标识或数量,或者它们是否是唯一的生产者,而只需将数据放入队列即可。同样,消费者也不要知道生产者是谁,或者工作来自何处。
由Edsger Dijkstra提出的哲学家就餐问题是一个经典的死锁例证。问题描述了:5个哲学家去就餐,坐在一张圆桌旁。他们有5根筷子,并且每两个人中间放一根筷子。哲学家们时而思考,时而就餐。每个人都需要一双筷子才能吃到东西,并且吃完后将筷子放回原处继续进行思考。每个哲学家在就餐时都会抓住自己左边的筷子,然后等待右边的筷子空出来,但同时又不放下已经拿到的筷子。在这种情况下每个哲学家都可能“饿死”。
这个问题说明:每个人都拥有其他人需要的资源,同时又等待其他人已经拥有的资源,并且每个人在获得锁需要的资源之前都不会放弃以有的资源。
下面是模拟哲学家就餐问题的程序
public class Chopstick {
// 标识筷子是否使用的状态(条件谓语)
private boolean taken = false;
public synchronized void take() throws InterruptedException {
while(taken)
wait();
// 筷子已被使用
taken = true;
}
public synchronized void drop() {
taken = false;
notifyAll();
}
}
public class Philosopher implements Runnable {
private Chopstick left;
private Chopstick right;
private final int id;
private final int ponderFactor;
private Random random = new Random(47);
// 哲学家思考
private void pause() throws InterruptedException {
if(ponderFactor == 0) return;
TimeUnit.MILLISECONDS.sleep(random.nextInt(ponderFactor * 250));
}
public Philosopher(Chopstick left, Chopstick rigth, int id, int ponderFactory) {
this.left = left;
this.right = rigth;
this.id = id;
this.ponderFactor = ponderFactory;
}
// 在哲学家任务中,会先进行思考,然后先拿着左边的筷子,在拿右边的筷子
@Override
public void run() {
try {
while(!Thread.interrupted()) {
System.out.println(this + " " + "thinking");
pause();
System.out.println(this + " " + "grabbing left");
left.take();
System.out.println(this + " " + "grabbing right");
right.take();
System.out.println(this + " " + "eating");
pause();
// 进餐完成,放下筷子
right.drop();
left.drop();
}
} catch(InterruptedException e) {
System.out.println(this + " " + "exiting via interrupt");
}
}
public String toString() {
return "Philosopher " + id;
}
}
死锁的规范定义如下:如果一个进程/线程集合中的每个进程/线程都在等待只能由该进程/线程集合中的其他进程/线程才能引发的事件,那么,该进程/线程就是死锁的。
——摘自《现代操作系统》
①、资源死锁
大多数的死锁都和资源有关。比如说,哲学家就餐问题中筷子就作为资源给予哲学家就餐,但也因为资源发生死锁。这种死锁称为资源死锁。正如当多个线程相互持有彼此正在等待的资源而又不释放自己已持有的资源时将会发生死锁。
资源死锁还有以下例子:
*假设有两个资源池,如果一个任务是需要连接两个资源池,并且在请求这两个资源不会始终遵循相同的顺序,那么线程A可能持有与资源池D1的连接,并等待与资源池D2的连接,而线程B则持有与D2的连接并等待与D1的连接。
*当一个任务提交另一个任务,并等待被提交任务在单线程的Executor中执行完成。在这种情况下,第一个任务将永远等待下去,并使得另一个任务以及在这个Executor中执行的所有其他任务都停止执行。
②、发生死锁的必要条件
1)互斥条件:每个资源要么已经分配给一个任务,要么就是可用的。
2)占有和等待条件:任务已经得到了某个资源的进程可以再请求新的资源。
3)不可抢占条件:已经分配给一个任务的资源不能强制性的被抢占,它只能被占有它的任务作为普通事件释放。
4)环路等待条件:死锁发生时,系统中一定有两个或两个以上的进程组成的一条环路,该环路中的每个任务都在等待着下一个任务所占有的资源。
死锁发生时,以上四个条件一定是同时满足的。如果其中任何一个条件不成立,死锁就不会发生。
③、简单锁顺序死锁分析
当线程T1使用R1锁并想得到R2锁的同时,线程T2持有R2锁并尝试获得锁R1,那么这两个线程将永远的等待下去。这种情况就是简单的锁顺序死锁。它满足死锁发生的四个必要条件
1)互斥条件:锁作为资源分配给T1、T2线程。
2)占有和等待条件:T1获取锁R1并想得到锁R2,同样T2获取锁R2想得到锁R1。
3)不可抢占条件:锁只能由获得锁的线程释放,而不能由其他线程抢占获取。
4)环路等待条件:当以上三个条件满足时,系统中存在互相等待对方持有锁的线程,那么这两个线程将永远的等待下去。
下面是实现锁顺序死锁的代码,其中作为锁对象left与right分别被线程A与线程B获取,然而这两个线程又想获取对方持有的锁。
public class LeftRightDeadLock {
private final Object left = new Object();
private final Object right = new Object();
public void leftRigth() {
synchronized(left) {
synchronized(right) {
System.out.println("leftRight");
}
}
}
public void rightLeft() {
synchronized(right) {
synchronized(left) {
System.out.println("rightleft");
}
}
}
}
④、死锁避免
如果一个类可能发生死锁,那么并不意味这每次都会发生死锁,而只是表示可能。当死锁出现时,往往是在最糟糕的情况下。比如:服务器高负载情况。JVM并没有完善的监测死锁以及从死锁中恢复的机制。如果一组线程发生死锁,那么这组线程将永远不能使用,这样造成的后果就是或应用程序停止、或性能降低、或某个子系统不能使用。所以,当你发现某个类可能会造成死锁,最好是避免它们发生死锁。避免死锁可以从下面三个方面入手。
*通过使用显示Lock类中定时tryLock功能代替内置锁机制。当使用内置锁时,只要没有获得锁,就会永远等待下去,而显示锁则可以指定一个超时时限(Timeout),在等待超过该时限后tryLock会返回一个失败信息。当定时锁失败时,我们并不需要知道失败原因,或许是发生了死锁。但是,我们可以记录所发生的失败,并且可以通过一种平缓的方式重新对锁进行定时轮询,而不是直接关闭任务。
*JVM通过线程转储帮助识别死锁的发生。线程转储包括各个运行中线程的栈追踪信息,这类似于异常时栈追踪信息。线程存储包括了加锁信息。例如每个线程持有了那些锁,在那些栈帧获得这些所,以及被阻塞的线程正在等待获取哪一个锁。在生成线程转储之前,JVM将在等待关系图中通过搜索循环来找出死锁。如果发现一个死锁,则获取相应的死锁信息,例如死锁中设计那些锁和线程,以及这个锁的获取操作位于程序的那些位置。
*破坏死锁的四个必要条件之一可以避免死锁,比如说,哲学家就餐问题中,我们可以通过破坏占有并等待的条件解决死锁。比如说,哲学家持有左边筷子时,准备拿右边筷子,却发现右边筷子被其他哲学家拿着。那么,放弃当前持有的筷子,并通过设置等待时限,当时限到达时,再次询问筷子是否可用。
传统的同步容器可以分为两类:
1)Verctor与Hashtable
2)Collections.synchronizedXxx等工厂方法提供的封装容器类
这些同步容器都是线程安全的,因为他们将状态封装,并对每个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。
①、性能问题
由于在同步容器中存在着大量的同步方法,因此无论在多线程还是单线程条件下都会造成很大的系统开销,这些开销都来自synchronized关键字,由于不断进行加锁,释放锁,线程在不断的唤醒等待过程中上下文切换频繁因此造成性能下降。
②、安全性问题
同步容器都是线程安全的,但在某些情况下可能需要额外客户端加锁保护复合操作。容器中,最常见的复合操作就是条件运算——若没有则添加以及迭代操作。这些操作在单线程条件任然是线程安全的,但是在多线程并发条件下可能就不是那么安全。比如说:拓展Vector,获取最后一个元素,与删除最后一个元素的操作。
public class CompositeOperation {
private Vector v = new Vector() ;
public T getLast() {
int lastIndex = v.size() - 1;
return v.get(lastIndex);
}
public T deleteLast() {
int lastIndex = v.size() - 1;
T t = v.get(lastIndex);
v.remove(lastIndex);
return t;
}
public void addSameValue(T value, int capacity) {
for(int i = 0; i < capacity; i++)
v.add(value);
}
public static void main(String[] args) throws InterruptedException {
CompositeOperation operation = new CompositeOperation();
operation.addSameValue(1, 10);
ExecutorService exec = Executors.newCachedThreadPool();
exec.execute(new Runnable() {
@Override
public void run() {
try {
while(!Thread.currentThread().isInterrupted()) {
System.out.println("get Vector last element: " + operation.getLast());
TimeUnit.MILLISECONDS.sleep(500);
}
} catch(InterruptedException e) {
System.out.println(Thread.currentThread().getName() + " interrutpted");
}
}
});
exec.execute(new Runnable() {
@Override
public void run() {
try {
while(!Thread.currentThread().isInterrupted()) {
System.out.println("remove Vector last element: " + operation.deleteLast());
TimeUnit.MILLISECONDS.sleep(500);
}
} catch(InterruptedException e) {
System.out.println(Thread.currentThread().getName() + " interrupted");
}
}
});
TimeUnit.SECONDS.sleep(2);
exec.shutdownNow();
}
}
getLast()与deleteLast()方法看似没有问题,但在多线程的情况下,线程A在包含10个元素的Vector上调用getLast,同时线程B在同一个Vector上调用deleteLast()。此时,getLast()将抛出ArrayIndexOutOfBoundsException异常。为什么?因为在调用deleteLast()时使得Vector.size变小,而getLast()查询一个大于Vector.size的值或者请求一个不存在的元素,那么将抛出一个异常。尽管Vector是线程安全的。但在某些复合操作下将变得不安全。
当在Vector上进行循环查询元素操作也会产生安全性问题。比如说,在查询时,由于其它的线程移除了Vector中某一个元素,那么也会抛出异常。
③、迭代器问题
容器类中进行迭代的标准方法都是Iterator。然而,如果有其它线程并发地修改容器,那么即使是使用迭代器也无法避免在迭代器件对容器加锁。当迭代时,发现容器被其他线程修改就会抛出一个ConcurrentModificationException异常。容器表现的行为是“及时失败”的。这种“及时失败”是一种不完善的处理机制,只是“善意地”捕获并发错误。
这种问题解决办法之一是通过客户端在迭代期间进行加锁,但这又可能造成死锁(线程先获取容器对象锁,而容器操作又需要容器对象锁)。如果不存在死锁,长时间对容器加锁势必降低程序的可伸缩性,不断加锁释放锁又加大对系统的开销。
另外一种解决办法是“克隆”容器,在副本上进行迭代,这样避免在进行迭代时其他线程修改容器而导致抛出ConcurrentModificationException异常。
传统的同步容器将所有对容器状态的访问都串行化,以实现它们的线程安全性。这种方式代价是严重降低并发性,当多个线程竞争容器的锁时,吞吐量将严重减少。另一方面,当进行容器的复合操作时容器表现的行为并不具有很好的安全性。
所以,java5.0后添加了专门用于多线程并发访问的并发容器。其中,ConcurrentHashMap就替代同步的散列Map。CopyOnWriteArrayList替代同步的ArrayList(同样CopyOnWriteArraySet替代同步的Set,其原理和CopyOnWriteArrayList一样)。在新的实现的并发容器中,添加了对容器复合操作的同步支持。例如:“若没有则添加”、“替换”、“有条件的删除”等和迭代器安全性的保证。
①、分段锁
锁分解:如果在某把锁上线程竞争激烈,那么我们可以将一把锁分解成两把锁,使竞争变得不那么激烈,从而最大限度地提升性能。
把锁分解技术进一步扩展为对一组独立对象上的锁进行分解,这种情况下称为锁分段。比若说,ConcurrentHashMap的实现使用了一个包含16个锁(这个锁称为Segment它继承自ReetrantLock)的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由(N mod 16)个锁保护,在每个桶中维护了HashEntry,它是一个指向链表结构的数据元素,其中链表结构存放了键值对。通过这种方式能把对于锁的请求减少到原来的1/16,这项技术使其能够支持多达16个并发的写入器。
锁分段技术的最大劣势是:与采取单个锁实现独占访问相比,要获取多个锁来实现独占访问将更加困难并且开销更高。
下面是采取锁分段技术实现ConcurrentHashMap获取元素和独占访问的过程
public class StripedMap {
private static final int N_LOCKS = 16;
private final Node[] buckets;
private final Object[] locks;
private static class Node {
private Object key;
private Object value;
public Node next() {
return new Node();
}
}
public StripedMap(int numBuckets) {
buckets = new Node[numBuckets];
locks = new Object[N_LOCKS];
for (int i = 0; i < locks.length; i++)
locks[i] = new Object();
}
private final int hash(Object key) {
return Math.abs(key.hashCode() % buckets.length);
}
public Object get(Object key) {
// 获取键的hash值
int hash = hash(key);
// 判断这个值在哪个桶为上,并进行加锁(分段加锁实现)
synchronized(locks[hash % N_LOCKS]) {
// 不断轮询桶位上存储的HashEntry的链表结构,看其是否存放了相应的key值
for (Node n = buckets[hash]; n != null; n = n.next())
if (n.key.equals(key))
return n.value;
}
return null;
}
// 对于独占的访问清除容器中的元素,那么将要获取每一个桶为上的锁,这将造成锁获取困难和开销更高
public void clear() {
for (int i = 0; i < buckets.length; i++)
synchronized(locks[i % N_LOCKS]) {
buckets[i] = null;
}
}
}
②、ConcurrentHashMap
与HashMap一样,ConcurrentHashMap也是一个基于散列的Map。它提供了与同步容器完全不同的加锁策略来提高并发性和伸缩性。ConcurrentHashMap并不是将每个方法都在同一个锁上同步并使得每次只能有一个线程访问容器,而是使用一种粒度更细的加锁机制来实现更大程度的共享,即使用了锁分段技术。这种机制下,任意数量的读线程与写线程可以并发地访问Map,并且一定数量的写线程可以并发地修改Map(上以介绍其工作机制)。
ConcurrentHashMap提供的迭代器不会抛出ConcurrentModificationException,因此不需要在迭代过程中对容器加锁。这种迭代器具有弱一致性,而并非善意的提醒:“及时失败”。弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有的元素,并可以(不是保证)在迭代器被构造后将修改操作反映给容器。这种弱化体现在getSize与isEmpty操作上,当并发修改容器时,getSize返回的是一个近似值而不是一个精确值。
在JDK1.8之后采用了CAS与synchronized技术的get、put操作。
③、CopyOnWriteArrayList
“写入时复制(Copy-On-Write)”容器的安全性在于,只要正确发布一个事实不可变的对象,那么在访问该对象就不再需要进一步同步。
1)读写策略:在每次进行修改时,都会创建整个底层数组的副本,而原数组保留在原地使得读操作只能看到这个未被改变的容器。当修改完成时,通过一个原子操作将把新的数组替换原数组,使得新的读操作能够看到这个修改。
2)迭代器策略:“写入时复制”容器的迭代器保留一个指向底层基础数组的引用,这个数组位于迭代器的起始位置,由于它不会被修改,因此在对其进行同步时只需确保数组内容的可见性。由于读写策略的保证,我们获取的迭代器元素与容器创建时的元素完全一致。
①、CountDownLatch
CountDownLatch由于它的特性也称做“倒计时器”,通过向CountDownLatch对象设置一个初始计数值,任何在这个对象上调用await()的方法都将被阻塞,而调用该对象的countDown()方法可以减少这个计数值,直到计数值为0时,调用await()方法的线程将会被唤醒。CountDownLatch被设计只触发一次,计数值不能被重置。
由于CountDownLatch的设计:我们可以创建线程之间协作的另外一种方式。等待某个任务完成的线程调用await()方法阻塞直到需要的任务完成。而当那些完成的任务调用countDown()减少这个计数器的值。
创建10个等待倒计时器的计数为0的线程,另外10个线程完成任务后减少这个计数值。
class TaskPortion implements Runnable {
private static int counter = 0;
private final int id = counter++;
private static Random random = new Random(47);
private final CountDownLatch latch;
TaskPortion(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void run() {
try {
doWord();
latch.countDown();
} catch(InterruptedException e) {
}
}
public void doWord() throws InterruptedException {
TimeUnit.MILLISECONDS.sleep(random.nextInt(2000));
System.out.println(this + "completed");
}
public String toString() {
return String.format("%1$-3d ", id);
}
}
class WaitingTask implements Runnable {
private static int counter = 0;
private final int id = counter++;
private final CountDownLatch latch;
WaitingTask(CountDownLatch latch) {
this.latch = latch;
}
@Override
public void run() {
try {
latch.await();
System.out.println("Latch barrier passed for " + this);
} catch(InterruptedException e) {
System.out.println(this + " interrupted");
}
}
public String toString() {
return String.format("WaitingTask %1$-3d", id);
}
}
public class CountDownLatchDemo {
static final int SIZE = 10;
public static void main(String[] args) {
ExecutorService exec = Executors.newCachedThreadPool();
CountDownLatch latch = new CountDownLatch(SIZE);
for(int i = 0; i < 10; i++)
exec.execute(new WaitingTask(latch));
for(int i = 0; i < 10; i++)
exec.execute(new TaskPortion(latch));
System.out.println("Launched all tasks");
exec.shutdown();
}
}
②、CyclicBarrier
CyclicBarrier实现了一组多个任务能够并行执行任务的机制。只有当其中某个任务未完成那么,其它任务在执行下一个步骤之前都要等待,直至那个任务完成(这就有点像共存亡)。其中等待点称为栅栏,它的概念有点像CountDownLatch,只不过前者是计数减少等待任务,并且只能触发一次。而后者是到某一点上等待直至所以任务完成才继续执行,并且它是可循环使用。
下面实现了一个赛马比赛,在每一回合每匹马只能走0-3步。每一匹马到达栅栏处才能继续下一回合。
class Horse implements Runnable {
private static int counter = 0;
private final int id = counter++;
private int strides = 0;
private static Random random = new Random(47);
private static CyclicBarrier barrier;
public Horse(CyclicBarrier barrier) {
this.barrier = barrier;
}
public synchronized int getStrides() {
return strides;
}
@Override
public void run() {
try {
while(!Thread.interrupted()) {
synchronized(this) {
strides += random.nextInt(3);
}
// 栅栏处等待
barrier.await();
}
} catch(InterruptedException e) {
} catch(BrokenBarrierException e) {
throw new RuntimeException(e);
}
}
public String toString() {
return "Horse " + id + " ";
}
public String tracks() {
StringBuilder s = new StringBuilder();
for(int i = 0; i < getStrides(); i++)
s.append("*");
s.append(id);
return s.toString();
}
}
public class HorseRace {
static final int FINISH_LINE = 75;
private List horses = new ArrayList();
private ExecutorService exec = Executors.newCachedThreadPool();
private CyclicBarrier barrier;
public HorseRace(int nHorses, final int pause) {
// 向CyclicBarrier添加"栅栏动作"即new Runnable 当任务在栅栏处等待时,将会执行该动作
barrier = new CyclicBarrier(nHorses, new Runnable() {
@Override
public void run() {
StringBuilder s = new StringBuilder();
for(int i = 0; i < FINISH_LINE; i++)
s.append("=");
System.out.println(s);
for(Horse horse : horses)
System.out.println(horse.tracks());
for(Horse horse: horses)
if(horse.getStrides() >= FINISH_LINE) {
System.out.println(horse + "won!");
exec.shutdownNow();
return;
}
try {
TimeUnit.MILLISECONDS.sleep(pause);
} catch(InterruptedException e) {
System.out.println("barrier-action sleep interrupted");
}
}
});
for(int i = 0; i < nHorses; i++) {
Horse horse = new Horse(barrier);
horses.add(horse);
exec.execute(horse);
}
}
public static void main(String[] args) {
int nHorses = 7;
int pause = 100;
new HorseRace(nHorses, pause);
}
}