5.线程间协作

一.等待与通知

1-1 wait/notify的作用与用法

使用Object.wait()实现等待的伪代码:

// 在调用wait方法前获得相应对象的内部锁
synchronized(someObject){
    while(保护条件不成立){
        // 调用Object.wait()暂停当前线程
        someObject.wait();
    }  
    // 代码执行到这里说明保护条件已经满足
    // 执行目标动作
    doAction();
}
  • 保护条件是一个包含共享变量的布尔表达式(保护条件可理解为是为了保护目标动作,如果保护条件成立,目标动作才执行)。

 一个线程只有在持有一个对象的内部锁的情况下才能调用该对象的wait方法,因此Object.wait()调用总是放在相应对象所引导的临界区之中。

 由于同一个对象的同一个方法(someObject.wait())可以被多个线程执行,因此一个对象可能存在多个等待线程。

  • someObject.wait()被调用后,someObject的内部锁会被等待线程释放,其他的线程可继续申请该内部锁并调用wait方法。

 线程被暂停的时候对someObject.wait()的调用并未返回。被唤醒的等待线程在占用处理器继续运行的时候,需要再次申请someObject对应的内部锁。在持有内部锁后继续执行someObject.wait()中剩余的指令,直到wait方法返回。

 等待线程只有在保护条件不成立的情况下才执行Object.wait()进行等待。等待线程在其被唤醒、继续运行到再次持有相应对象内部锁的这段时间内,由于其他线程可能抢先获得了相应的内部锁并更新了相关共享变量而导致该线程所需的保护条件再次不成立,因此Object.wait()调用返回之后我们需要再次判断此时保护条件是否成立。所以,对保护条件的判断以及Object.wait()调用应该放在循环语句之中,以确保目标动作只有在保护条件成立的情况下才能够执行。

等待线程对保护条件的判断以及目标动作的执行必须是个原子操作,否则可能产生竞态——目标动作被执行前的那一刻其他线程对共享变量的更新又使得保护条件重新不成立。因此目标动作的执行必须和保护条件的判断以及Object.wait()调用放在同一个对象引导的临界区中。

 Object.wait()暂停当前线程时释放的锁只是该wait方法所属对象的内部锁。当前线程持有的其他内部锁,显示锁并不会因此而被释放。

synchronized(someObject){
    // 先更新等待线程保护条件涉及的共享变量
    updateSharedState();
    // 最后唤醒等待线程
    someObject.notify();
}

 一个线程只有在持有一个对象内部锁的情况下,才能够执行该对象的notify方法,因此Object.notify()调用总是放在相应对象内部锁所引导的临界区之中。也正是如此,Object.wait()在暂停其执行线程的同时必须释放相应的内部锁。

 Object.notify()的执行线程持有的相应对象的内部锁只有在Object.notify()调用所在的临界区执行结束后才会被释放,而Object.notify()本身并不会将这个内部锁释放。

  • 因此为了使等待线程在其被唤醒后能够尽快获得相应的内部锁,需要尽可能地将Object.notify()调用放在靠近临界区结束的地方。
  • 因为如果等待线程被唤醒后,通知线程还没有及时释放内部锁,那么等待线程可能又会再次被暂停,以等待再次获得相应内部锁的机会,而这会导致上下文切换。

Object.wait()/notify()的内部实现

 Java虚拟机会为每个对象维护一个入口集(Entry Set)用于存储申请该对象内部锁的线程。此外,Java虚拟机还会为每个对象维护一个被称为等待集(Wait Set)的队列,该对列用于存储该对象上的等待线程。Object.wait()/notify()实现等待/通知中的几个关键动作,都是在Object.wait()中实现的。Object.wait()的部分内部实现伪代码:

public void wait(){
    // 执行线程必须持有当前对象对应的内部锁
    if(!Thread.holdsLock(this)){
        throw new IllegalMonitorStateException();
    }

    if(当前线程不在等待集中){
        // 将当前线程加入当前对象的等待集中
        addToWaitSet(Thread.currentThread());
    }
    
    atomic{ // 原子操作开始
        // 释放当前对象的内部锁
        releaseLock(this);
        // 暂停当前线程
        block(Thread.currentThread()); // 语句①
    } // 原子操作结束

    // 再次申请当前对象的内部锁
    acquireLock(this);  // 语句②
    // 将当前线程从当前对象的等待集中移除
    removeFromWaitSet(Thread.currentThread());
    return;
}

 等待线程在语句①被执行之后就被暂停了。被唤醒的线程在其占用处理器继续运行的时候会继续执行其暂停前调用的Object.wait()中的其他指令,即从语句②开始继续执行。

 Object.wait(long timeout)允许指定一个超时时间(单位为毫秒)。如果被暂停的等待线程在这个时间内没有被其他线程唤醒,那么Java虚拟机会自动唤醒该线程。

  • Object.wait(long timeout)也可能会因Object.notifyAll()而被提前唤醒,所以也要将其放在while(保护条件不成立)的循环块中。
  • Object.wait(long timeout)既无返回值也不会抛出特定的异常,以便区分其返回是由于其他线程通知了当前线程还是由于等待超时,所以需要进行一些额外的处理。
public static void waiter(final long timeout) throws InterruptedException {
    if (timeout < 0) {
        throw new IllegalArgumentException();
    }
    long start = System.currentTimeMillis();
    long waitTime;
    long now;
    synchronized (lock) {
        while (!ready) {
            now = System.currentTimeMillis();
            // 计算剩余等待时间
            waitTime = timeout - (now - start);
            Debug.info("Remaining time to wait: %s ms", waitTime);
            if (waitTime < 0) {
                // 等待超时,退出
                break;
            }
            lock.wait(waitTime);
        }
        // 执行到这里的两种可能①notify ②timeout,所以需要通过ready进行判断
        if (ready) {
            // 执行目标动作
            guardedAction();
        } else {
            Debug.error("Wait timed out, unable to execution target action!");
        }
    }
}

wait/notify与Thread.join()

 Thread.join()可以使当前线程等待目标线程结束之后才继续运行。另一个版本join(long timeout)允许指定一个超时时间。如果目标线程没有在指定时间内终止,那么当前线程就结束等待继续运行。join()等价于join(0),join(long timeout)实际上是使用wait/notify来实现的。

public final synchronized void join(long millis) throws InterruptedException{
    long base = System.currentTimeMillis();
    long now = 0;
    
    if(millis < 0){
        throw new IllegalArgumentException("timeout value is negative");
    }

    if(millis == 0){
        while(isAlive()){
            wait(0);
        }
    } else{
        while(isAlive()){
            // 记录还需等待的时间
            long delay = millis - now;
            if(delay <= 0){
                 break;
            }
           wait(delay);
           // 记录已经等待的时间
           now = System.currentTimeMillis() - base;
        }
    }
}

Java虚拟机会在目标线程的run方法运行结束后执行该线程(也是个对象)的notifyAll方法来通知所有等待线程。

二.Java条件变量

 Condition接口可作为wait/notify的替代品来实现等待/通知,它为解决过早唤醒问题提供了支持。Condition接口定义的await方法、signal方法和signalAll方法分别相当于Object.wait(), Object.notify()和Object.notifyAll()。

 Lock.newCondition()的返回值就是一个Condition实例。Condition.await()/signal()也要求其执行线程持有创建该Condition实例的显示锁(Lock)。每个Condition实例内部维护了一个类似于等待集(Wait Set)的等待队列。

 condtion1.signal()只会唤醒condition1(Condition实例)对应等待队列里的任意一个线程,创建condition1的显示锁创建的其他Condition实例(例如condition2)的等待队列中的线程不受condition1.signal()的影响。

Condition接口的使用方法:

class ConditionUsage {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();

    public void awaitMethod() throws InterruptedException {
        lock.lock();
        try {
            while (保护条件不成立) {
                condition.await();
            }

            // 执行目标动作
            doAction();
        } finally {
            lock.unlock();
        }
    }

    private void doAction() {
        // ...
    }

    public void signalMethod() throws InterruptedException {
        lock.lock();
        try {
            // 更新保护条件
            updateProtectedVariable();
            condition.signal();
        } finally {
            lock.unlock();
        }
    }

    private void updateProtectedVariable() {
        // ...
    }
}
  • 由同一个Lock创建的多个不同的Condition,在await()/signal()时持有的是同一个Lock(创建Condition的那一个)。
  • Condition.await()和Object.wait()类似,它使当前线程暂停的同时也使当前线程释放其持有的显式锁,并且这时Condition.await()的调用也同样未返回。被唤醒的线程需要再次获得相应的显式锁后,Condition.await()调用才返回。

Condition接口规避过早唤醒的问题

 在应用代码这一层次上建立保护条件与条件变量的对应关系。让使用不同保护条件的等待线程调用不同的条件变量的await方法来实现其等待。并且让通知线程在更新了共享变量之后,仅调用涉及了这些共享变量的保护条件所对应条件变量的signal/signalAll方法来实现通知。

  • 即,使用同一个Lock创建的不同的Condition对线程进行分组,不同组之间等待和唤醒不会相互影响。

Condition接口解决Object.wait(long)无法区分其返回是由于等待超时还是被通知的问题

 Condition.awaitUntil(Date deadline)返回true表示方法是因被通知而返回的,返回false表示是因超时而返回的。

 等待线程因执行Condition.awaitUntil(Date)而被暂停的同时,其持有的显式锁也会被释放,等待线程被唤醒之后得以继续运行时需要再次申请相应的显式锁,申请到后等待线程对Condition.awaitUntil(Date)的调用才会返回。在等待线程被唤醒到其再次申请到相应的显式锁的这段时间内,其他线程(或者通知线程本身)可能已经抢先获得了相应的显式锁并在临界区中更新了相关的共享变量而使得等待线程所需的保护条件重新不成立。因此,Condition.awaitUntil(Date)返回true(被通知)的情况下,可以选择判断保护条件而继续等待(放在while中)。同时,也能避免应用层代码使用Condition对线程进行分组的不合理导致的提前唤醒。

  • Object.wait()/wait(long), Condition.await(), Condition.awaitUntil(Date)都建议放在while循环中
  • 带超时时间的Object.wait(long),Condition.awaitUntil(Date)在while循环中都需要更新超时时间。

三.倒计时协调器:CountDownLatch

 CountDownLatch可以用来实现一个(或者多个)线程等待其他线程完成一组特定的操作之后(特定操作之后调用CountDownLatch.countDown())才继续运行。这组操作被称为先决操作。

 CountDownLatch内部会维护一个用于表示未完成的先决操作数量的计数器。CountDownLatch.countDown()每被执行一次就会使相应实例的计数器值减1。

 CountDownLatch.await()内部实现伪代码:

atomic{
    while(preOperationCount>0){
        wait();
    }
    
    // 目标操作为空操作,到这里后继续向下执行
}

 CountDownLatch.countDown()内部实现伪代码:

atomic{
    // 当计数器的值达到0之后,计数器的值就不再变化
    if(preOperationCount==0){
        return;
    }

    if(--preOperationCount==0){
        // 如果是最后一个先决操作,唤醒全部等待的线程
        notifyAll();
    }
}
  • 在使用CountDownLatch时,最好将其声明为volatile,保证其可见性。
  • CountDownLatch.countDown()最好放在try-finally的finally块中,避免因为异常导致countDown未被执行而使等待线程一直等下去。
  • CountDownLatch.await(Long timeout, TimeUnit unit)允许指定一个超时时间,在该超时时间内,如果相应CountDownLatch实例的计数器值仍未达到0,那么所有执行该实例的await方法的线程都会被唤醒。该方法的返回值可用于区分其返回是否是由于等待超时。
  • 对于同一个CountDownLatch实例latch,latch.countDown()的执行线程在该方法执行前所执行的任何内存操作对等待线程在latch.await()调用返回之后都是可见且有序的。
  • CountDownLatch的构造器中的参数既可以表示多个先决操作的数量,也可以表示单个先决操作需要被执行的次数。
  • 一个CountDownLatch实例是一次性的,只能够实现一次(一组)等待和一次唤醒。当计数器的值达到0之后,该计数器的值就不再会发生变化,此时调用countDown()不会抛出异常,调用await()的线程不会被暂停。

四.栅栏(CyclicBarrier)

 有时候多个线程需要互相等待对方执行到代码中的某个地方(集合点),这时集合点的所有线程才能继续执行,关注的是所有线程。而CountDownLatch是一个(或者是多个)等待线程等待其他线程都完成各自的某个操作(对等待线程来说是先决操作)后,等待线程才能继续执行,关注的是等待线程

 CyclicBarrier中的Cyclic表示CyclicBarrier实例是可以重复使用的。

 使用CyclicBarrier实现等待的线程被称为参与方(Party)。参与方只需要执行CyclicBarrier.await()就可以实现等待。参与方可能是并发执行CyclicBarrier.await()的,但是,CyclicBarrier内部维护了一个显式锁,这使其总是可以在所有参与方中分出最后一个执行CyclicBarrier.await()的线程,这个线程被称为最后一个线程。除最后一个线程之外的任何参与方执行CyclicBarrier.await()都会导致该线程被暂停(线程生命周期状态变为WAITING)。最后一个线程执行CyclicBarrier.await()会使得使用相应CyclicBarrier实例的其他所有参与方被唤醒,而最后一个线程自身不会被暂停

 与CountDownLatch不同的是,CyclicBarrier实例是可以被重复使用的:所有参与方被唤醒的时候,任何再次执行CyclicBarrier.await()的线程又会被暂停,直到最后一个线程出现并执行了CyclicBarrier.await()。

  CyclicBarrier的其中一个构造器允许指定一个被称为barrierAction的任务(Runnable接口实例)。barrierAction会被最后一个线程执行CyclicBarrier.await方法时执行,该barrierAction执行结束后其他等待线程才会被唤醒

CyclicBarrier的内部实现

 CyclicBarrier内部使用了一个条件变量trip来实现等待/通知。CyclicBarrier内部使用了分代(Generation)的概念用于表示CyclicBarrier实例是可以重复使用的。除最后一个线程外的任何一个参与方都相当于一个等待线程,这些线程所使用的保护条件是“当前分代内,尚未执行await方法的参与方个数(parties)为0”。当前分代的初始状态是parties等于参与方总数(通过构造器中的parties参数指定)。CyclicBarrier.await()每被执行一次会使相应实例的parties值减少1。最后一个线程相当于通知线程,它执行CyclicBarrier.await()会使相应实例的parties值变为0,此时该线程会先执行barrierAction.run()然后再执行trip.singalAll()来唤醒所有等待线程。接着,开始下一个分代,即使得CyclicBarrier的parties值又重新恢复其初始值。

 任意一个参与方在执行cyclicBarrier.await()前所执行的任何操作对barrierAction.run()而言是可见的、有序的。barrierAction.run()中所执行的任何操作对所有参与方在cyclicBarrier.await()调用成功返回之后的代码而言是可见的、有序的。如下图所示(图中实线表示其一端连接的操作对箭头指向的代码是可见的、有序的)。

  • 可认为barrierAction.run()嵌入到了cyclicBarrier.await()的位置,顺序执行。

五.生产者-消费者模式

5-1 阻塞队列

 JDK1.5中引入的接口java.util.concurrent.BlockingQueue定义了一种线程安全的队列——阻塞队列。BlokingQueue的常用实现类包括ArrayBlockingQueue, LinkedBlockingQueue和SynchronousQueue等。

 阻塞队列按照其存储空间的容量是否受限制可分为有界队列和无界队列(最大存储容量为Integer.MAX_VALUE(231-1)个元素)。有界队列满时,生产者线程会被暂停;有界队列空时,消费者线程会被暂停。

 往队列中存入一个元素(对象)的操作被称为put操作,从队列中取出一个元素(对象)的操作被称为take操作。put操作相当于生产者线程将对象(产品)安全发布到消费者线程。生产者线程执行put操作前所执行的任何内存操作,对后续执行take操作的消费者线程而言是可见的、有序的。

 有界队列可以使用ArrayBlockingQueue或者LinkedBlockingQueue来实现。

ArrayBlockingQueue

  1. 内部使用一个数组作为其存储空间,数组的存储空间是预先分配的,其put、take操作本身不会增加垃圾回收的负担;
  2. 内部在实现put、take操作的时候使用的是同一个锁(显式锁),从而可能导致锁的高争用,进而导致较多的上下文切换;
  3. 既支持非公平调度也支持公平调度;
  4. 适合在生产者和消费者线程之间并发度较低的情况下使用。因为put、take操作使用的是同一个锁,并发度太高会导致较多的上下切换。

LinkedBlockingQueue

既能实现无界队列,也能实现有界队列。LinkedBlockingQueue的其中一个构造函数允许创建队列的时候指定队列容量。

  1. 内部存储空间是一个链表,而链表节点(对象)所需的存储空间是动态分配的,put操作、take操作都会导致链表节点的动态创建和移除,这可能增加垃圾回收的负担;
  2. 内部在实现put、take操作时分别使用了两个显式锁(putLock和takeLock),这降低了锁争用的可能性;
  3. 由于put、take操作使用的是两个锁,因此维护其队列的当前长度(size)时无法使用一个普通的int型变量而是使用了一个原子变量(AtomicInteger)。这个原子变量可能会被生产者线程和消费者线程争用,因此它可能导致额外的开销;
  4. 仅支持非公平调度
  5. 适合在生产者和消费者线程之间并发度比较大的情况下使用。因为put、take操作使用的是不同的锁且默认支持非公平调度策略。

SynchronousQueue

  1. 一种特殊的有界队列。SynchronousQueue内部并不维护用于存储队列元素的存储空间;
  2. 设synchronousQueue为任意一个SynchronousQueue实例,生产者线程执行synchronousQueue.put(E)时,如果没有消费者执行synchronousQueue.take(),那么该生产者线程会被暂停,直到有消费者线程执行了synchronousQueue.take();类似地,消费者执行synchronousQueue.take()时,如果之前没有生产者执行了synchronousQueue.put(E),那么消费者线程会被暂停,直到有生产者线程执行了synchronousQueue.put(E);
  3. 既支持非公平调度也支持公平调度
  4. 适合在消费者处理能力和生产者处理能力相差不大的情况下使用。

 可以将其看作是长度为1的ArrayBlockingQueue

 

 阻塞队列也支持非阻塞式操作(即不会因为队列空或者满了导致执行线程被暂停)。比如,BlockingQueue接口定义的offer(E)和poll()分别相当于put(E)和take的非阻塞版。非阻塞方法常用特殊的返回值表示操作结果:offer(E)的返回值为false表示入对列失败(队列已满),poll()返回null表示队列为空。

5-2 限购:流量控制与信号量

  使用无界队列作为传输通道的一个好处是put操作并不会因队列满而导致生产者线程被阻塞。但如果生产者生产速率过快,会导致无界队列中的元素越来越多,资源占用也会越来越多。因此,一般在使用无界队列作为传输通道时会同时限制生产者的生产速率,即进行流量控制以避免传输通道中积压过多产品。

 JDK1.5中引入标准库类java.util.concurrent.Semaphore可以用来实现流量控制。我们把代码所访问的特定资源或者执行特定的操作的机会看作一种虚拟资源(Virtual Resource)。Semaphore相当于虚拟资源配额管理器,它可以用来控制同一时间对虚拟资源的访问次数。为了对虚拟资源的访问进行流量控制,必须使相应的代码只有在获得配额的情况下才能访问虚拟资源。为此,相应的代码在访问虚拟资源前必须先申请相应的配额,并在资源访问结束后返还相应的配额。

 Semaphore.acquire()/release()分别用于申请配额和返还配额。Semaphore.acquire()在成功获得一个配额后会立即返回。如果当前配额不足,那么Semaphore.acquire()会使其执行线程暂停。Semaphore内部会维护一个等待队列用于存储这些被暂停的线程。Semaphore.acquire()在其返回之前总是会将当前的可用配额减少1。Semaphore.release()会使当前可用配额增加1,并唤醒相应Semaphore实例的等待队列中的任意一个等待线程。

 基于Semaphore和BlockingQueue实现的带流量控制功能的传输通道:

/**
 * 带流量控制功能的传输通道
 *
 * @author Hutao
 * @date 2022/11/9 17:00
 * @since 1.0
 */
public class SemaPhoreBasedChannel<P> implements Channel<P> {

    private final BlockingDeque<P> queue;

    private final Semaphore semaphore;

    /**
     * 构造函数
     * @param queue 阻塞队列,一般是一个无界阻塞队列
     * @param flowLimit 流量限制数(某一时刻可访问虚拟资源的线程数)
     *
     * @author Hutao
     * @date 2022/11/9 17:02 
     * @since 1.0
     */
    public SemaPhoreBasedChannel(BlockingDeque<P> queue, int flowLimit) {
        this(queue, flowLimit, false);
    }

    public SemaPhoreBasedChannel(BlockingDeque<P> queue, int flowLimit, boolean isFair) {
        this.queue = queue;
        this.semaphore = new Semaphore(flowLimit, isFair);
    }

    @Override
    public P take() throws InterruptedException {
        return queue.take();
    }

    @Override
    public void put(P product) throws InterruptedException {
        // 申请一个配额
        semaphore.acquire();
        try {
            // 访问虚拟资源
            queue.put(product);
        } finally {
            // 返回一个配额
            semaphore.release();
        }
    }
}

 Semaphore使用时应注意:

  1. Semaphore.acquire()和Semaphore.release()总是总是配对使用的,这点需要由应用代码自身来保证;
  2. Semaphore.release()调用总是应该放在一个finally块中,以避免虚拟资源访问出现异常的情况下当前线程所获得的配额无法返还;
  3. 创建Semaphore实例时,如果构造器中的参数permits值为1,那么所创建的Semaphore实例相当于一个互斥锁。与其他互斥锁不同的是,由于一个线程可以在未执行过Semaphore.acquire()的情况下执行相应的Semaphore.release(),因此这种互斥锁允许一个线程释放另一个线程持有的锁

5-3 双缓冲与Exchanger

 双缓冲技术:当消费者线程消费一个已填充的缓冲区时,另一个缓冲区可以由生产者线程进行填充,从而实现了数据生成与消费的并发。

  • 适用于:需要一个线程在获取到一个产品在消费时,另一个线程同时生产一个产品。待一个产品生产好且上一个产品被消费完了时,继续消费这个产品和生产下个产品。
  • 这样的需求也可用阻塞队列实现(SynchronousQueue)。

 JDK1.5中引入了标准库java.util.concurrent.Exchanger可以用来实现双缓冲。Exchanger相当于一个只有两个参与方的CyclicBarrier。Exchanger.exchange(V)相当于CyclicBarrier.await()。Exchanger.exchange(V)的声明如下:

 public V exchange(V x) throws InterruptedException

  • V用于指定缓冲区类型;
  • 参数x和返回值相当于缓冲区,参数x是要给出去的缓冲区,返回值是会获得的缓冲区。

 使用:

  • 初始状态下生产者和消费者各自创建一个空的缓冲区。
  • 消费者线程执行Exchanger.exchange(V)时将参数x指定为一个空的或者已经使用过的缓冲区,生产者线程执行Exchanger.exchange(V)时将参数x指定为一个已经填充完毕的缓冲区。

 比照CyclicBarrier来说,生产者线程和消费者线程都执行到Exchanger.exchange(V)相当于这两个线程都达到了集合点,此时生产者和消费者线程各自对Exchanger.exchange(V)的调用就会返回。Exchanger.exchange(V)的返回值就是对方线程(只能由两个线程)执行该方法时所指定的参数x的值

 Exchanger从逻辑上可以被看做SynchronousQueue,另外,其内部也不用维护用于存储产品的存储空间。

 在生产者-单消费者模式中,可以考虑使用Exchanger作为传输通道。

六.线程中断机制

 中断可被看作由一个线程(发起线程Originator)发送给另外一个线程(目标线程Target)的一种指示,该指示表示发起线程希望目标线程停止其正在执行的操作。中断仅仅代表发起线程的一个诉求,目标线程可能不会理会。

 Java会为每个线程维护一个中断标记,用于表示相应的线程是否接收到了中断。中断标记为true时表示相应的线程收到了中断。

  • 目标线程可以通过Thread.currentThread().isInterrupted()调用来获取该线程的中断标记;
  • 也可通过Thread.interrupted()来获取并重置(也称清空)中断标记值,即Thread.interrupted()会返回当前线程的中断标记值并将当前线程中断标记重置为false
  • 调用一个线程的interrupt()相当于将该线程(目标线程)的中断标记置为true。

6-1 InterruptedException异常处理及中断响应

 Java标准库中许多阻塞方法对中断的响应方式都是抛出InterruptedException等异常。Java标准库中也有些阻塞方法/操作无法响应中断,例如InputStream.read()、Lock.lock以及内部锁的申请。

表6-1.1 能够对中断做出响应的一些标准库类/方法
方法(或者类) 为响应中断而抛出的异常
Object.wait()/wait(long)/wait(long, int) InterruptedException
Thread.sleep(long)/sleep(long, int) InterruptedException
Thread.join()/join(long)/join(long, int) InterruptedException
java.util.concurrent.BlockingQueue.take()/put(E) InterruptedException
java.util.concurrent.locks.Lock.lockInterruptibly() InterruptedException
java.util.concurrent.CountDownLatch.await)( InterruptedException
java.util.concurrent.CyclicBarrier.await() InterruptedException
java.util.concurrent.Exchanger.exchange(V) InterruptedException
java.nio.channels.InterruptibleChannel java.nio.channels.ClosedByInterruptException

 能够响应中断的方法通常是在执行阻塞操作前判断中断标志,若中断标志值为true则抛出InterruptedException。

 依照惯例,凡是抛出InterruptedException异常方法,通常会在其抛出异常之前将当前线程的中断标记重置为false。因此,这些方法在判断中断标记时调用的是Thread.interrupted()而非Thread.currentThread().isInterrupted()。

 ReentrantLock.lockInterruptibly()对中断的响应

public final void acquireInterruptibly(int arg) throws InterruptedException{
    if(Thread.interrupted()){
        throw new InterruptedException();
    }
    if(!tryAcquire(arg)){
        doAcquireInterruptibly(arg);
    }
}

 如果发起线程给目标线程发送中断的那一刻,目标线程已经由于执行了一些阻塞方法/操作而被暂停(生命周期状态为WAITING或者BLOCKED)了,比如上述代码中的方法已经执行到了第2个if语句,那么此时Java虚拟机可能会①设置目标线程中断标记②并将该线程唤醒,从而使目标线程被唤醒后继续执行的代码再次得到响应中断的机会。因此,这种情况下能够响应中断的阻塞方法/操作依然可以抛出InterruptedException,并在此之前将线程的中断标记清空。

  • 可见,给目标线程发送中断还能产生唤醒目标线程的效果。

 Java应用层代码通常可以通过对InterruptedException等异常进行处理的方式来实现中断响应。对InterruptedException异常的处理方式包括以下几种:

  • 不捕获InterruptedException。如果应用代码的某个方法调用了能够对中断进行响应的阻塞方法,那么我们也可以在这个方法的异常声明(throws)中也加一个InterruptedException。这种做法实质上是当前方法不知道如何处理中断比较恰当,因此将“难题”抛给其上层代码(比如这个方法的调用方)。
  • 捕获InterrupedException后重新将该异常抛出。使用这种策略通常是由于应用代码需要捕获InterruptedException并对此做一些中间处理(比如处理部分完成的任务),接着再将“难题”抛给其上层代码。
  • 捕获InterruptedException并在捕获该异常后中断当前线程。这种策略实际上在捕获到InterruptedException后又恢复中断标记,这相当于当前代码告诉其他代码:“我发现了中断,但我并不知道如何处理比较妥当,因此我为你保留了中断标记,你看着办吧!”。
public final class Tools{
    public static void randomPause(int maxPauseTime){
        int sleepTime = random.nextInt(maxPauseTime);
        try{
            Thread.sleep(sleepTime);
        } catch (InterruptedException e){
            Thread.currentThread.interrupt(); // 保留线程中断标记
        }
    }
    // ...
}
  • 比较危险的一种处理方法是“吞没”(Swallow)InterruptedException,即应用代码在捕获InterruptedException之后既不重新抛出也不保留中断标志。这种处理策略只有在线程捕获到InterruptedException就可以终止的情况下才适用,其他情况下使用该策略可能导致目标线程无法被终止

七.线程停止

 Java标准库没有提供可直接停止线程的API。

 主动停止一个线程的实现思路:

  1. 为待停止的线程(目标线程)设置一个线程停止标记(布尔型数据),目标线程检测到该标志值为true时,则设法让其run方法返回,这样就实现了线程的终止。
  2. 仅使用专门的实例变量来作为线程的停止标记仍然不够,这是由于当前线程停止标记置为true(表示目标线程需要被停止)的时候,目标线程可能因为执行了一些阻塞方法(比如CountDownLatch.await())而被暂停,因此,这时线程停止标记不会对线程产生任何影响!
  3. 由2可见,为了使线程停止标记的设置能够起作用,可能还需要给目标线程发送中断以将其唤醒,使之得以判断线程停止标记。
  4. 另外,在生产者—消费者模式中一个线程试图停止目标线程的时候,该线程可能仍然有尚未处理完毕的任务,因此可能需要以“优雅”的方式将该线程停止——目标线程只有在其处理完所有待处理的任务后才能够终止。

 依照这个思路,乍一看似乎线程中断标记可以作为线程停止标记,而目标线程则可以通过响应中断来实现其停止,但是由于线程中断标记可能会被目标线程所执行的某些方法清空,因此从通用性的角度来看线程中断标记不能作为线程停止标记。例如,run方法如下的workerThread看起来似乎是可以通过workerThread.interrupt()调用来停止——因为workerThread.run()对channel.take()(BlockingQueue.take())的调用可能由于其他线程调用workerThread.interrupt()而抛出InterruptedException(响应中断),而workderThread对该异常的处理方式是捕获并在捕获后使其run方法返回。

public void run() {
    Runnable task = null;
    try {
        for (; ; ) {
            // take()阻塞前会检查中断标记,如果为true,则抛出InterruptedException并重置中断标记为false
            task = channel.take(); 
            try {
                task.run();
            } catch (Throwable e) {
                e.printStackTrace();
            }
        } // for循环结束
    } catch (InterruptedException e) {
        // 什么也不做
    }
} // run方法结束

  实际上,由于发起线程在执行workerThread.interrupt()的时候workThread并不一定在执行channel.take()而是在执行task.run()。而task.run()中的代码可能会清除(“吞没”)线程的中断标记,从而使得workerThread执行到channel.take()时,依旧无法终止。

 吞没线程标记的示例:

public class MayNotBeTerminatedDemo {

    public static void main(String[] args) throws InterruptedException {
        TaskRunner taskRunner = new TaskRunner();
        taskRunner.init();

        taskRunner.submit(() -> {
            Debug.info("before doing task");
            try {
                // 其他线程调用workerThread.interrupt()时,假设正执行到sleep
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                // 什么也不做(不重置中断标记):这会导致线程中断标记被清除
                
                // 重置中断标记
                // Thread.currentThread().interrupt();
            }
            Debug.info("after doing task");
        });
        taskRunner.workerThread.interrupt();
    }
}

 上述程序的运行结果为:

 workerThread未终止。如果task的run方法中catach到InterruptedException后重置了中断标记,workerThread就能正常结束。

 

 由此可见,从通用角度来看,我们不能使用线程中断标记作为线程停止标记,而需要一个专门的实例变量来作为线程停止标记

 一个比较通用且能够以优雅的方式实现线程停止的方案为:

public class TerminatableTaskRunner implements TaskRunnerSpec {

    // 待处理任务计数器
    public final AtomicInteger reservations = new AtomicInteger(0);

    // 待处理任务队列
    protected final BlockingQueue<Runnable> channel;

    // 线程停止标记
    protected volatile boolean inUse = true;

    protected volatile Thread workerThread;

    public TerminatableTaskRunner(BlockingQueue<Runnable> channel) {
        this.channel = channel;
        this.workerThread = new WorkerThread();
    }

    public TerminatableTaskRunner() {
        this(new LinkedBlockingDeque<>());
    }

    @Override
    public void init() {
        final Thread thread = workerThread;
        if (null != thread) {
            thread.start();
        }
    }

    @Override
    public void submit(Runnable task) throws InterruptedException {
        channel.put(task);
        reservations.incrementAndGet();
    }

    public void shutdown() {
        Debug.info("Shutting down service...");
        inUse = false; // 语句①
        final Thread thread = workerThread;
        if (null != thread) {
            thread.interrupt(); // 语句②
        }
    }

    public void cancelTask() {
        Debug.info("Canceling in progress task...");
        workerThread.interrupt();
    }

    class WorkerThread extends Thread {

        @Override
        public void run() {
            try {
                for (; ; ) {
                    // 线程不再被需要,且无待处理任务
                    if (!inUse && reservations.get() <= 0) {// 语句③
                        // workerThread结束
                        break;
                    }

                    Runnable task = channel.take();
                    try {
                        task.run();
                    } catch (Throwable e) {
                        e.printStackTrace();
                    }
                    // 待处理任务-1
                    reservations.decrementAndGet(); // 语句④
                } // for循环结束
            } catch (InterruptedException e) {
                workerThread = null;
            }
            Debug.info("worker thread terminated.");
        } // run方法结束
    } // WorkerThread结束
}

 run方法所捕获的异常只可能是channel.take()调用抛出的。由于不仅只对中断异常进行了处理,还在每次取出待处理任务前判断了线程的停止标记,因此,即使客户端代码在调用shutdown方法那一刻,目标线程正在执行task.run()且task.run()中的代码(如sleep(1000) try-catch)清空了线程中断标记,而使得后续执行的channel.take()调用无法抛出InterruptedException(因为线程中断标记被task.run()中的代码清空了)的情况下,目标线程仍能通过判断线程停止标记实现停止。

 

posted @ 2022-11-22 17:06  certainTao  阅读(29)  评论(0编辑  收藏  举报