浅谈Java并发编程中断的哲学

引言

我们通过并发编程提升了系统的吞吐量,同时我们也希望并发运行的线程能够及时停止并做好资源归纳,所以笔者就借此文来谈谈Java并发编程中线程中断的艺术。

我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili ,也欢迎您了解我的开源项目 mini-redis:github.com/shark-ctrl/...

为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。

详解Java中断的哲学

何时触发中断阻塞

按照操作系统对于线程的任务调度管理来说,当触发以下几种情况时线程就会阻塞而处于BLOCKEDWAITINGTIMED_WAITING几种状态: 第一种是线程执行IO请求,在等待IO资源返回,触发阻塞,此时线程就处于BLOCKED状态,例如服务端server执行serverSocket.accept()等待就绪的客户端接入:

第二种则是等待条件为真期间,线程因此挂起等待notify通知或者通过sleep休眠,进而处于WAITING或者TIMED_WAITING

scss 复制代码
new Thread(() -> ThreadUtil.sleep(3600), "t-0").start();

因为并发互斥原因,线程需要等待其它线程释放监视锁而进入BLOCKED阻塞态:

Java是如何响应中断的

在操作系统中,线程的中断方式一般分为以下两种:

  1. 抢占式:当线程需要中断时,直接强制让线程立刻停止手里的任务
  2. 协作式:当线程需要中断时,通过标识告知线程需要被中断,线程轮询检查时看到这个标识就会直接中断

而Java线程则是采用协作式中断,即调用interrupt时其底层仅仅是将线程设置为可中断状态,等到线程主动检查到线程标识被设置为中断时,则触发InterruptedException

对应的我们以Linux为例给出JDK底层关于线程中断函数interrupt的实现,即位于os_linux.cppinterrupt方法,可以看到其底层本质上就是定位到java线程对应的os线程并将其中断标识设置为true

scss 复制代码
void os::interrupt(Thread* thread) {
  assert(Thread::current() == thread || Threads_lock->owned_by_self(),
    "possibility of dangling Thread pointer");

  OSThread* osthread = thread->osthread();
  
  if (!osthread->interrupted()) {
    //设置os线程中断标识为true
    osthread->set_interrupted(true);
    //......
  }

 //......

}

同时我们也给出处于sleep休眠状态的线程响应中断的源码,同样是以Linux为例的线程封装类os_linux.cpp下的sleep函数,可以看到进行休眠时其底层在明确知晓线程可被中断的时候,就会在for循环中知晓休眠并定期检查可中断状态:

arduino 复制代码
int os::sleep(Thread* thread, jlong millis, bool interruptible) {
   //......

  if (interruptible) {
    jlong prevtime = javaTimeNanos();

    for (;;) {
      //循环中感知到中断直接返回OS_INTRPT标识
      if (os::is_interrupted(thread, true)) {
        return OS_INTRPT;
      }

  
    }
  } else {
    //......
  }
}

线程中断的守则

通常来说我们对线程中断响应度越高,就越容易处理并优雅的完成兜底动作,一般来说,在处理线程中断时一般会出现如下两种情况:

  1. 当前代码层面对象实例不具备处理该中断异常
  2. 处于线程内部的run方法感知到中断无法向上抛出

针对情况1,本质上就是权责上的转移,如果当前业务层面不具备处理此类异常的能力,那么就将异常向上层抛出传递给上层使用者:

java 复制代码
public void sleep(int seconds) throws InterruptedException {
        TimeUnit.SECONDS.sleep(seconds);
    }

而情况2则相对麻烦一些,如果类似于Runnable 这种无法向上抛出的内置接口类的实现,我们则可以主动去捕获中断中断异常,并将在完成必要的资源清理工作后,将当前线程打断从而让高层栈帧感知到这个异常中断:

typescript 复制代码
class Task implements Runnable {

        @Override
        public void run() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                //执行当前线程资源清理
                //打断当前线程引发更高层线程响应此中断
                Thread.currentThread().interrupt();
            }
        }
    }

Java线程中断处理的一些实践

基于标识取消任务

我们先来说说基于自定义标识的方式中断线程,即非java内置方法层面的协作式标识来停止线程,通过任务运行时轮询检测,一旦线程轮询检查看到中断标识设置为true,则直接结束运行:

对应的我们给出自定义协作式中断的实现,整体思路为:

  1. 采用cancelled标识中断状态,并用volatile保证可见性
  2. 内置初始化一个执行线程thread
  3. 对外暴露start方法,执行线程启动
  4. 对外暴露cancel方法修改线程中断状态
  5. run方法执行业务逻辑,并定期轮询检查中断标识,一旦标识被设置为true则退出循环,结束线程
csharp 复制代码
public class Task implements Runnable {
    /**
     * 使用volatile修饰保证标识修改可见
     */
    private volatile boolean cancelled = false;

    private final Thread thread = new Thread(this);

    public void start() {
        thread.start();
    }

    /**
     * 停止时,通过cancel请求取消
     */
    public void cancel() {
        cancelled = true;

    }

   
    @Override
    public void run() {
        //取消标识检测,如果取消则直接结束循环
        while (!cancelled) {
            System.out.println("running");
            ThreadUtil.sleep(1000);
        }
        System.out.println("task cancelled");
    }
}

对应的我们也给出这种方式的使用示例,可以看到我们的测试代码会在5s后调用task暴露的任务取消方法完成线程中断:

scss 复制代码
 //线程启动运行5s
        Task task = new Task();
        task.start();
        //休眠5s后将task任务对应线程中断
        new Thread(()->{
            ThreadUtil.sleep(5000);
            task.cancel();
        }).start();

而执行的输出结果如下,是符合我们预期的:

arduino 复制代码
running
running
running
running
running
running
task cancelled

当然这种做法也存在一定的弊端,即带有阻塞性质的操作,任务可能出现永远无法检查取消标志,例如我们的线程在循环往阻塞阻塞队列blockingQueueput添加元素,一旦队列空间达到容器上界,当前线程就会阻塞即无法执行到循环分支上:

对应我们也给出这段错误的样例,即阻塞队列添加操作后阻塞而走不到循环判断:

csharp 复制代码
private final BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(100);

    @Override
    public void run() {
        //取消标识检测,如果取消则直接结束循环
        while (!cancelled) {
            System.out.println("running");
            try {
                queue.put(RandomUtil.randomInt(10));
            } catch (InterruptedException e) {
                //......
            }
        }
        System.out.println("task cancelled");
    }

测试代码还是和上一小节一致,不多赘述,测试输出结果如下,即第二次添加操作时发现队列已满而阻塞从而无法打断:

arduino 复制代码
running
running

如何处理阻塞式的中断

参考java内置方法Thread.sleep(1000);或者wait()方法,其底层都会针对外部中断操作做检测,一旦感知到中断就会提前返回,即执行如下步骤:

  1. 清除中断状态
  2. 抛出InterruptedException让被中断线程感知异常

所以对于阻塞式中断的的正确方式为:

通过合理的时机发出中断请求,让线程在下一个合适时候处理中断

所以对于上述阻塞队列操作来说,可以按照如下方式进行线程优雅中断:

  1. 对外暴露interrupt方法打断当前线程,确保阻塞队列插入阻塞时依然可以利用内置方法完成线程打断
  2. 线程感知中断时不可直接抛出异常,而是利用异常捕获将资源处理清楚,再次执行中断循环监测,正常退出线程逻辑

对应我们给出改造后的代码,可以看到我们将cancel改为调用线程的中断方法将线程中断,同时在感知到中断异常时会将执行中断后的兜底逻辑:

csharp 复制代码
/**
     * 停止时,通过cancel请求取消
     */
    public void cancel() {
        thread.interrupt();
    }


    @Override
    public void run() {

            //取消标识检测,如果取消则直接结束循环
            while (!Thread.currentThread().isInterrupted()) {
                System.out.println("运行中......");
                Integer element = RandomUtil.randomInt(10);
                try {
                    queue.put(element);
                } catch (InterruptedException e) {
                   //处理中断
                }

            }
        

    }

合理的中断策略

笔者在上面的文章中对于抛出的异常给出了一段todo的伏笔,这里我们就来说说线程面对中断异常后响应的哲学。一般来说,线程级或者服务级的中断策略为:

  1. 尽快的退出
  2. 必要时完成手头任务的清理

这也就是为什么java中各种并发包的类库对于中断的任务仅仅是抛出InterruptedException而不是直接处理掉中断 ,例如ArrayBlockingQueueput方法:

csharp 复制代码
//将任务中的中断InterruptedException 丢给调用栈的上层代码执行
public void put(E e) throws InterruptedException {
        checkNotNull(e);
        final ReentrantLock lock = this.lock;
        //上可打断的锁
        lock.lockInterruptibly();
        try {
           //......
        } finally {
        //1. 释放锁
            lock.unlock();
        }
    }

而中断策略的响应,正确的做法应该是让执行该任务的线程进行按照如下原则进行处理:

  1. 如果不具备处理的能力,则将异常向上传递
  2. 如果无法传递异常则显示抛出中断让上层的调用栈感知。

以我们Task生产者代码为例,我们将提交给线程即哪些继承runnable或者lambda表达式统称为任务,一般来说持有这些任务的线程不一定是这些任务的执行者,它们仅拥有对于任务状态管理的一些权限,例如一个主线程main方法调用thread-0异步执行阻塞队列存取操作:

所以从任务的维度来说,执行任务的线程应该小心的保存中断的状态,即在面对中断时,它们不应该对中断进行任何的干预,而是让拥有线程的代码段做出正确的响应,即让thread-0感知到中断异常然后将状态状态还原向上传递:

对应的我们也给出阻塞存储元素的优雅中断处理:

  1. 轮询检测本地中断标识,若未中断执行插入
  2. 感知中断,捕获异常并打印未能处理的元素
  3. 完成资源兜底,主动打断线程将状态向上传递
csharp 复制代码
//外部线程打断的方法
 public void cancel() {
        thread.interrupt();
    }


    @Override
    public void run() {
        try {
            //取消标识检测,如果取消则直接结束循环
            while (!Thread.currentThread().isInterrupted()) {
                System.out.println("运行中......");
                Integer element = RandomUtil.randomInt(10);
                try {
                    queue.put(element);
                } catch (InterruptedException e) {
                    Console.log("线程中断,未处理资源:{}", element);
                    Thread.currentThread().interrupt();
                }

            }
        } finally {
            if (thread.isInterrupted()) {
                System.out.println("任务已取消");
            }
        }
        
    }

对应的我们也给出输出结果,可以看到阻塞的队列在被打断后完成必要的资源兜底,就会将中断状态向上传递:

erlang 复制代码
运行中......
运行中......
线程中断,未处理资源:6
任务已取消

时刻保留中断的状态

需要注意的是,执行者仅仅传递中断还是不行的,更重要的一点是:

在必要时刻,保存中断的状态,并返回前恢复状态,而不是捕获到isInterrupted,避免陷入无限循环的漩涡。

很多情况下当前任务不具备处理中断的能力,例如Runnable收到中断的请求不可抛出异常交由上层调用栈处理,那么就在收到中断请求,按照如下步骤执行:

  1. 基于本地标识保留中断状态
  2. 完成必要的收尾工作
  3. 在返回前打断该线程恢复中断状态

例如:线程0循环获取阻塞队列元素,在因为没有元素而阻塞时,线程1打断该线程,已按照时刻保留中断的状态守则,线程0则应该按照如下步骤执行:

  1. 收到中断,利用本地变量保留中断状态
  2. 继续循环等待元素获取
  3. 获取到元素并返回,在返回前将当前线程打断,让外部感知

对应的我们给出消费者循环获取元素并处理状态的代码:

arduino 复制代码
 private static final BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(1);

public static String getNextElement() {
        boolean interrupted = false;

        try {
            while (true) {

                try {
                    //1. 等待结果返回而阻塞
                    return blockingQueue.take();
                } catch (InterruptedException e) {
                    //2.收到异常中断,小心保存中断状态,继续阻塞等待元素返回
                    interrupted = true;
                    Console.log("消费者线程中断,待完成本次资源获取后执行中断");
                }
            }
        } finally {
            //3. 返回前基于中断标识将中断状态向上传递
            if (interrupted) {
                Console.log("消费者线程已中断");
                Thread.currentThread().interrupt();
            }
        }

    }

对应的我们也给出测试代码,即在消费者阻塞后将其打断,并投递元素,让其完成优雅中断:

arduino 复制代码
 public static void main(String[] args) {
        //消费者线程
        Thread thread = new Thread(() -> {
            String nextElement = getNextElement();
            Console.log("消费者线程获取结果:{}", nextElement);
        });
        thread.start();
        Console.log("消费者线程启动");
        //休眠5s后将task任务对应线程中断
        new Thread(() -> {
            //休眠5s后将task任务对应线程中断
            ThreadUtil.sleep(5000);
            thread.interrupt();
            //休眠5s后再投递元素
            ThreadUtil.sleep(5000);
            try {
                String element = RandomUtil.randomString(10);
                Console.log("生产者线程投递:{}", element);
                blockingQueue.put(element);
            } catch (InterruptedException e) {
                //......
            }
        }).start();
    }

输出结果如下,可以看到消费者在收到中断后明确保留中断状态,并完成资源处理的工作后执行中断:

复制代码
消费者线程启动
消费者线程中断,待完成本次资源获取后执行中断
生产者线程投递:7gmnj1rqpj
消费者线程已中断
消费者线程获取结果:7gmnj1rqpj

超时任务取消的最优解

如果我们现在需要实现这样以一个函数,该函数会接受外部传入一个异步任务并提交到我们的线程池异步执行,并具备如下要求:

  1. 要求在给定时间完成执行
  2. 任务执行完成后,要知晓是超时取消,还是正常执行完成返回,即任务正确执行则返回true,反之返回false
  3. 任务执行过程中可被中断

所以对于该需求,要做到如下几点:

  1. 可以感知任务执行完成并返回true
  2. 可以感知任务执行超时,并返回false
  3. 任务可中断,直接抛出让上层代码解决

对应我们给出如下代码,可以看到我们采用submit获取异步任务的Future对象,利用Future实现带有时限的阻塞获取,一旦超时则直接抛出超时异常,并在函数返回前的finally语句块调用cancel取消任务,需要注意的是这个cancel方法并不会一味的取消任务:

  1. 如果任务已完成,这就意味着任务已到达终态,不可取消,cancel就会返回false
  2. 如果任务因为超时等原因调用cancel,那么任务则还是活跃的,调用cancel可以取消并直接返回true

所以基于cancel这个特点,我们直接取反,即可实现正确执行返回true,超时返回false:

vbnet 复制代码
private static final ExecutorService executor = ThreadUtil.newExecutor(10);

    public static boolean get(Runnable r, int timeout,TimeUnit timeUnit) throws InterruptedException {
        Future<?> future = executor.submit(r);
        try {
            future.get(timeout, timeUnit);
        } catch (TimeoutException e) {
            Console.error("任务执行超时");
        } catch (ExecutionException e) {
            throw new RuntimeException(e);
        } finally {
            /*
             1. cancel方法传参为true,即任务正常完成不可取消直接返回false,反之返回true
             2. 如果任务已经完成,cancel则返回false,反之返回true
             */
            return future.cancel(true);

        }

    }

对应的我们也给出测试代码,可以看到笔者的代码休眠2s,等到超时时间为1s,这也就意味着cancel最终会成功超时取消返回true:

arduino 复制代码
try {
            boolean isTimeOut = get(() -> {
                ThreadUtil.sleep(2000);
                Console.log("任务执行完成");
            }, 1,TimeUnit.SECONDS);

            Console.log("任务是否超时:{}", isTimeOut);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

输出结果如下,可以看到任务执行超时后直接打断休眠,任务执行到已完成的输出,然后执行超时取消,如果取反得到false:

arduino 复制代码
任务执行超时
任务执行完成
任务是否超时:true

处理系统层面阻塞IO

有时候阻塞并非来自阻塞式并发包的调用,而是例如硬件层面文件IO或者网络层面的socket IO,这种API涉及内核态调用,通过interrupt我们也只能修改中断表示,无法直接将其中断。

所以我们只能通过间接的手段干预其资源关闭来做到中断,无论是socket还是文件IO,本质上都是针对系统或者网卡IO数据的阻塞读取,所以我们可以直接通过关闭文件IO流或者socket套接字来间接打断其资源读取。

对应的我们以文件IO为例给出代码示例,可以看到我们通过继承thread重写其中断方法,当我们需要打断系统资源时,直接关闭其流通道让工作线程感知到这一点,然后通过原生interrupt修改中断状态:

java 复制代码
public class IOThread extends Thread {
    private final BufferedReader utf8Reader;

    public IOThread(String path) {
        utf8Reader = FileUtil.getUtf8Reader(path);
    }

    @Override
    public synchronized void start() {
        while (true) {
            try {
                String line = utf8Reader.readLine();
                Console.log(line);
            } catch (IOException e) {
                //保存IO上下文状态
                throw new RuntimeException(e);
            }
        }

    }

    @Override
    public void interrupt() {
        try {
            //强制关闭IO让其感知中断
            utf8Reader.close();
        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            super.interrupt();
        }
    }


    
}

对应的我们也给出使用示例,这段代码会在中断线程调用interrupt关闭流通道直接直接将IOUtil 线程打断:

ini 复制代码
        IOThread ioThread = new IOThread("F:\test.txt");
        Thread thread = new Thread(() -> {
            ThreadUtil.sleep(10_000);
            ioThread.interrupt();
        });

        thread.start();
        ioThread.start();

小结

本文针对Java并发编程的中断的使用技巧、状态保存、合理的中断时机和不同场景的中断方式进行了深入的剖析的讲解,希望对你有帮助。

我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili ,也欢迎您了解我的开源项目 mini-redis:github.com/shark-ctrl/...

为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。

参考

《Java并发编程实战》

本文使用 markdown.com.cn 排版

相关推荐
Billow_lamb1 小时前
Spring Boot2.x.x 全局错误处理
java·spring boot·后端
苏三的开发日记1 小时前
Java后台定时器导致系统奔溃的原因分析
后端
remaindertime1 小时前
基于Ollama和Spring AI:实现本地大模型对话与 RAG 功能
人工智能·后端·ai编程
Lear1 小时前
Spring Boot异步任务实战:优化耗时操作,提升系统性能
后端
望眼欲穿的程序猿1 小时前
Win系统Vscode+CoNan+Cmake实现调试与构建
c语言·c++·后端
bing_1582 小时前
Spring Boot 项目中判断集合(List、Set、Map)不能为空且不为 null的注解使用
spring boot·后端·list
喵个咪2 小时前
Go 接口与代码复用:替代继承的设计哲学
后端·go
喵个咪2 小时前
ASIO 定时器完全指南:类型解析、API 用法与实战示例
c++·后端
IT_陈寒2 小时前
Vite 3.0 重磅升级:5个你必须掌握的优化技巧和实战应用
前端·人工智能·后端