Java线程中断的秘密

简介: 也学学why哥,在每篇文章加一段荒腔走板的内容。也就是随便聊聊,可能是自己近期的生活状态或者感悟,一些想法和思考,也可能是写这篇文章的初衷等等,甚至只是随便吹吹牛。

荒腔走板


也学学why哥,在每篇文章加一段荒腔走板的内容。也就是随便聊聊,可能是自己近期的生活状态或者感悟,一些想法和思考,也可能是写这篇文章的初衷等等,甚至只是随便吹吹牛。


最近可能由于工作压力有点大,所以老是感觉不是很开心,前几天还在朋友圈吐槽了一下,看到很多朋友的鼓励,很感动。


其实想一想自己这二十几年来也算挺幸运的,家庭和睦,感情顺利,学业也还马马虎虎过得去,工作虽然跳了几次槽,但也还算顺利。虽名下无车无房无孩子,但也算过得不错,超市自由还是有的。其实没必要抱怨太多,父母那一代人比我们不容易太多了,我们所谓的“吃苦”根本不算什么。


进大厂也是自己的选择,努力加油啦。

今天一上午去西湖走了一圈,晚上回来吃了一顿家人做的大餐,然后去外面朋友教我学滑板,又Get了一项新技能。晚上10点回来继续写文章,差不多写到12点半(远远晚于自己的预期),可能真的要秃了。实在是越写越多,控制不住。。。


生活就是这样,你只有尝试去热爱,才能真正收获快乐。

再说回为什么要写这篇文章。其实周五选题的时候纠结了一下,本来想写Spring Ioc的,它的受众面可能会广一点,但是看了一下,源码还蛮复杂的,感觉也要花比较大的篇幅去理顺,后面在看吧。各位读者朋友也可以尽管留言,你们想看哪方面的文章,给我点灵感。


线程中断这个idea是前段时间我们多线程电子书的读者群里,菠萝问的提一个关于AQS的疑问,然后我们在群里简单讨论了一下,得出了一些初步的答案。

讨论线程中断

但下来后,自己又想彻底理清楚线程中断的机制,之前写多线程书的时候,并没有很注重这一部分。网上关于线程中断的文章其实并不多,希望这篇文章能够对大家有所帮助。


线程中断的使用场景

线程中断,指的是我们希望关闭一个线程。

那什么时候会需要用到线程中断呢?举个例子,我们在打滴滴的时候,通常可以选多个车型。而如果一旦某个车型打到了,就会取消掉其它所有的打车。

再比如我们去请求一个第三方的API,我们希望在限定的时间内得到结果,如果得不到,我们会希望取消该任务。

类似的例子还有很多,凡是我们需要中断另一个线程正在做的事,就可以用线程中断来帮我们实现。

Java的各种线程工具类也广泛使用了线程中断的机制。


抢占式和协作式中断

历史上Java曾经使用stop()方法终止线程的运行,他们属于抢占式中断。但它引来了很多问题,早已被JDK弃用。使用stop()方法可能会带来数据不一致的问题,甚至可能根本不能停止线程。

有兴趣的同学可以看官方的这篇文章:https://docs.oracle.com/javase/6/docs/technotes/guides/concurrency/threadPrimitiveDeprecation.html

经历了很长时间的发展,Java最终选择了用一种协作式的中断机制来实现中断,也就是现在的实现。

所谓协作式,是通过一个中断标识位来实现的。其它线程如果想要中断线程A,就对线程A的中断标识位做一个标记,线程A自己通过轮询去检查标识位,然后自己做处理。

那么问题来了,这个标识位在哪里?如何轮询的呢?线程轮询到中断会做什么呢?这几个问题会在后面具体介绍,这里先做一个简单的解释。

Java中线程中断的标识位是由本地方法维护的,在Java层面仅留了几个API给用户获取和操作。

轮询是如何实现的?不同的场景实现方式不同。线程睡眠、等待等场景,是通过JVM自己的轮询实现的。而在一些Java并发工具里面,轮询是在Java代码层面去实现的。

线程中断后做什么?不同场景实现方式不同,往往跟轮询的实现配合起来。一般来说,通过JVM轮询的实现,会抛InterruptedException异常,而轮询在Java代码层面实现的,不会抛异常。


线程中断的API

前面提到,Java中线程的中断标识位是由本地方法维护的,但是留了几个API给用户操作。关于线程中断,Thread类定义了这几个方法:

// 中断线程
public void interrupt() {}
// 看线程是否已经设置中断标识
public boolean isInterrupted() {}
// 看当前线程是否已经设置中断标识,这个方法会重置中断标识,有副作用
public static boolean interrupted() {}

大家可以去看一下JDK的源码,这三个方法底层都是调用的native方法来实现的,也就是我们前面提到的,这个中断标识位是由本地方法维护的,没有在Java代码层面维护。

这里有两个方法都可以判断当前线程是否已经设置中断标识,一个是isInterrupted,它比较简单,没有副作用。另一个是interrupted,它会重置中断标识,有副作用。

话不多说,上代码:

Thread.currentThread().interrupt();
System.out.println(Thread.interrupted());
System.out.println(Thread.interrupted());
System.out.println(Thread.interrupted());
// 打印如下:true false false

而且它们两个方法是有区别的,一个是实例方法,查看某个线程是否设置中断标识;一个是静态方法,查看当前线程是否设置中断标识。不同的方法应用场景不同,尤其是interrupted,它存在的意义是什么?我们会在下文挑点源码分析一些它们的应用场景。


线程对中断的处理

线程在不同状态时,对中断的处理是不同的。Java线程有6个状态,定义在Thread.State这个类里。

// Thread.State 源码
public enum State {
    NEW,
    RUNNABLE,
    BLOCKED,
    WAITING,
    TIMED_WAITING,
    TERMINATED;
}

关于Java的6个状态及其转换关系,本文不做细讲,有疑问的读者可以在公众号回复“多线程”,即可获取我们写的多线程电子书,在第4章有详细介绍。

NEW/TERMINATE

如果线程在NEW(尚未启动),或者TERMINATED(已经结束)状态下,调用interrupt()方法对它没有任何效果,中断标识位也不会被设置。

RUNNABLE/BLOCKED

如果线程在RUNNABLE(运行中),或者BLOCKED(等待锁)状态,调用interrupt()方法会设置中断标识位,但是不影响线程的状态。

WAITING/TIMED_WAITING

线程处于WAITING(等待),或者TIMED_WAITING(超时等待)状态,这里为了方便,我们统称为“阻塞”。在阻塞状态下,JVM会主动去轮询中断标识位。

进入阻塞状态一般有两种方式,第一种是使用sleep, wait, join(join底层是使用wait来实现的)等方法。它们的API都会显式地抛出InterruptedException异常:

// 进入WAITING
public final void join() throws InterruptedException;
public final void wait() throws InterruptedException;
// 进入TIMED_WAITING
public final native void wait(long timeout) throws InterruptedException;
public static native void sleep(long millis) throws InterruptedException;
public final synchronized void join(long millis) throws InterruptedException;
复制代码

如果调用了上面这几种方法使线程进入了阻塞状态,这个时候再调用interrupt()方法,就会使得该线程被唤醒,并抛出InterruptedException

需要注意的是,抛出异常后,中断标志位会被清空,因为线程的中断标志位会由true重置为false,因为线程为了处理异常已经重新处于就绪状态。

除了上面几种方法外,还有另外一种方式可以使得线程进入阻塞状态,那就是在Java多线程工具类被广泛使用的LockSupport.park方法。

但是park方法与sleep, wait, join方法不同的是,它在遇到线程中断标识被设置为true后,会立即返回不再阻塞,但不会抛出异常


IO对中断的处理

这个其实在interrupt方法源码上面的注释写得比较详细了。其实只对NIO有所支持。

BIO是不支持中断的,比如InputStream,中断线程虽然也会设置标识位,但IO不会处理这个中断。

如果当前线程被一个IO操作阻塞住了,而这个Channel实现了InterruptibleChannel接口(主流的NIO Channel都实现了这个接口),那当前线程的中断位会被设置为true,Channel会被关掉,然后线程会收到一个ClosedByInterruptException异常(这个异常是在NIO包中定义的)。

如果当前线程被一个Selector阻塞住了,那当前线程的中断位会被设置为true,Selector会立即返回一个非零的数字。


源码分析


那么JDK是如何使用线程中断的?我们挑两个大家熟悉的多线程工具类来聊一下。

AQS

首先是大名鼎鼎的AQS,作为众多Java多线程工具类的基础类,在Java多线程江湖中的处于德高望重的地位。其核心的方法acquire更是重中之重。acquire方法底层调用的是acquireQueued方法,也是因为这里的代码,才让我萌生了写这篇文章的冲动。

关于AQS,本文不做细讲,有疑问的读者可以在公众号回复“多线程”,即可获取我们写的多线程电子书,在第11章有详细介绍。

先来欣赏一下这段看球不懂的源码:

// 入口,获取资源
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}
// 中断自己
static void selfInterrupt() {
    Thread.currentThread().interrupt();
}
// 排队获取资源
final boolean acquireQueued(final Node node, int arg) {
    boolean interrupted = false;
    try {
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                return interrupted;
            }
            if (shouldParkAfterFailedAcquire(p, node))
                interrupted |= parkAndCheckInterrupt();
        }
    } catch (Throwable t) {
        cancelAcquire(node);
        if (interrupted)
            selfInterrupt();
        throw t;
    }
}
// park并且检测是否中断
private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}

PS: 这是Java 11的源码,在Java 8的源码的基础上有一些小改变。 |= 的意思与+=, -=类似,你懂的。

看acquireQueued腰部那一截代码,首先有个判断:如果获取锁失败了,应不应该park一下。如果是,就去park一下,进入阻塞,并且检查中断。如果中断了,就把这里的interrupted设置为true,返回给上面,然后上面的acquire方法再调用selfInterrupt方法,给当前线程设置中断标识位。

我们在这里思考几个问题:

中断后发生了什么?

中断后,代码里面的interrupted被设置为了true,但是仍然继续进行下一次循环

  • 如果这次获取到了资源,就返回给上面,给当前线程设置中断标识位;
  • 如果还是没有获取到资源,就继续park当前线程

为什么中断后不立即返回,而是继续循环?

当前线程是在尝试获取资源失败的时候被别的线程设置中断标识位的,但那并不代表这个线程要立即停止一切工作并返回,所谓“协作式中断”,就是我应该自己选择一个自己觉得安全的地方中断。

对于这个方法来说,继续循环尝试获取资源,直到获取到了资源,当前线程才完成了这个任务,然后交给外面去决定。

简单来说就是,中不中断我不管,我把自己的事情做完就行,要处理中断你自己去处理。

为什么获取到了资源,不把中断标识位设置成true?

因为在parkAndCheckInterrupt里面调用了Thread.interrupted,已经把重点标识位重置为false了,外面丢失了当前线程被中断过的信息,所以这里要补偿这个信息,告诉外面,当前线程曾经被中断过。

为什么acquire方法要selfInterrupt?

仍然是补偿中断信息。这里才是真正的补偿,重新把当前线程的中断位设置为true。因为当前线程确实被中断过,虽然我自己不用管,但是还是要把中断位设置回去,这样外面才知道这个信息。

为什么这里要使用interrupted而不是isInterrupted方法?

因为parkAndCheckInterrupt是在一个循环里面。上面提到了,如果这次获取不到资源,还会继续循环。那如果仍然获取不到怎么办呢?当前线程应该再次被park。而如果这里使用的是isInterrupted方法,它就不会重置中断标识位,那第二次park发现当前线程的中断标识位已经被设置成了true,就不会阻塞当前线程,而是直接往后走了,这是不对的。

这里有段简单的测试代码:

Thread thread = new Thread(() -> {
    LockSupport.park();
    System.out.println("first parked");
    Thread.currentThread().isInterrupted();
    LockSupport.park(); // 这里不会被阻塞,因为中断位是true
    System.out.println("second parked");
    Thread.interrupted();
    LockSupport.park(); // 这里会被阻塞,因为中断位是false
    System.out.println("third parked");
});
thread.start();
thread.interrupt(); // 中断,唤醒第一次park
// 打印出:
// first parked
// third parked

Lock

Lock接口有一个方法:

void lockInterruptibly() throws InterruptedException;

其实现基本上都是调用的AQS的acquireSharedInterruptibly方法,也有子类是自己的实现,不过实现方式都差不多。会先用Thread.interrupted()方法检查当前线程是否中断,并重置标识位。如果已经中断,就直接抛出InterruptedException

而大家也知道,Lock接口的lock方法,也基本是用AQS来实现的,只不过每次acquire 1个资源。

所以Lock接口提供了两种处理中断的锁,一种是lock方法,不对中断做任何处理;另一种是lockInterruptibly方法,遇到中断会抛异常。具体使用哪种方式,由用户自己去选择,非常的灵活。

ThreadPoolExecutor

它又来了,线程池!

众所周知,线程池内部维护了一个Worker队列,也就是我们被复用的线程。runWorker方法就是用这个队列不断取任务出来跑。里面有这样一段代码,大神怕我们看不懂还专门贴心地写了一大段注释:

// If pool is stopping, ensure thread is interrupted;
// if not, ensure thread is not interrupted.  This
// requires a recheck in second case to deal with
// shutdownNow race while clearing interrupt
if ((runStateAtLeast(ctl.get(), STOP) ||
     (Thread.interrupted() &&
      runStateAtLeast(ctl.get(), STOP))) &&
    !wt.isInterrupted())
    wt.interrupt();

简单来说,就是如果线程池正在关闭,确保线程是被中断了的;如果线程池没关闭,确保线程是没被中断的。

我们再看看线程池的shutdown方法,里面调用了interruptIdleWorkers方法,用来中断所有空闲的worker。而shutdownNow方法,调用的是interruptWorkers,用来中断所有的worker,不管你空不空闲。

所以如果我们想优雅地关掉线程池,调用shutdown更好。


总结


经过这么一大通分析,我们可以得出一些结论:

  • Java是使用的协作式中断,线程中断只是设置标识位
  • 不同线程状态对中断的处理不一样,其中阻塞状态最为复杂
  • sleep, wait, join等是JVM去轮询标识位,然后抛异常
  • park是JVM轮询标识位,但不抛异常,交给用户自己去处理中断
  • nio能够响应中断
  • 使用Lock加锁比较好,可以自由选择抛不抛中断异常

所以,你会用线程中断了吗?

目录
相关文章
|
11天前
|
监控 安全 Java
在 Java 中使用线程池监控以及动态调整线程池时需要注意什么?
【10月更文挑战第22天】在进行线程池的监控和动态调整时,要综合考虑多方面的因素,谨慎操作,以确保线程池能够高效、稳定地运行,满足业务的需求。
88 38
|
8天前
|
安全 Java
java 中 i++ 到底是否线程安全?
本文通过实例探讨了 `i++` 在多线程环境下的线程安全性问题。首先,使用 100 个线程分别执行 10000 次 `i++` 操作,发现最终结果小于预期的 1000000,证明 `i++` 是线程不安全的。接着,介绍了两种解决方法:使用 `synchronized` 关键字加锁和使用 `AtomicInteger` 类。其中,`AtomicInteger` 通过 `CAS` 操作实现了高效的线程安全。最后,通过分析字节码和源码,解释了 `i++` 为何线程不安全以及 `AtomicInteger` 如何保证线程安全。
java 中 i++ 到底是否线程安全?
|
12天前
|
Java 调度
[Java]线程生命周期与线程通信
本文详细探讨了线程生命周期与线程通信。文章首先分析了线程的五个基本状态及其转换过程,结合JDK1.8版本的特点进行了深入讲解。接着,通过多个实例介绍了线程通信的几种实现方式,包括使用`volatile`关键字、`Object`类的`wait()`和`notify()`方法、`CountDownLatch`、`ReentrantLock`结合`Condition`以及`LockSupport`等工具。全文旨在帮助读者理解线程管理的核心概念和技术细节。
30 1
[Java]线程生命周期与线程通信
|
3天前
|
存储 设计模式 分布式计算
Java中的多线程编程:并发与并行的深度解析####
在当今软件开发领域,多线程编程已成为提升应用性能、响应速度及资源利用率的关键手段之一。本文将深入探讨Java平台上的多线程机制,从基础概念到高级应用,全面解析并发与并行编程的核心理念、实现方式及其在实际项目中的应用策略。不同于常规摘要的简洁概述,本文旨在通过详尽的技术剖析,为读者构建一个系统化的多线程知识框架,辅以生动实例,让抽象概念具体化,复杂问题简单化。 ####
|
4天前
|
Java 开发者
在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
在Java多线程编程的世界里,Lock接口正逐渐成为高手们的首选,取代了传统的synchronized关键字
19 4
|
4天前
|
消息中间件 供应链 Java
掌握Java多线程编程的艺术
【10月更文挑战第29天】 在当今软件开发领域,多线程编程已成为提升应用性能和响应速度的关键手段之一。本文旨在深入探讨Java多线程编程的核心技术、常见问题以及最佳实践,通过实际案例分析,帮助读者理解并掌握如何在Java应用中高效地使用多线程。不同于常规的技术总结,本文将结合作者多年的实践经验,以故事化的方式讲述多线程编程的魅力与挑战,旨在为读者提供一种全新的学习视角。
24 3
|
10天前
|
安全 Java
在 Java 中使用实现 Runnable 接口的方式创建线程
【10月更文挑战第22天】通过以上内容的介绍,相信你已经对在 Java 中如何使用实现 Runnable 接口的方式创建线程有了更深入的了解。在实际应用中,需要根据具体的需求和场景,合理选择线程创建方式,并注意线程安全、同步、通信等相关问题,以确保程序的正确性和稳定性。
|
5天前
|
安全 Java 调度
Java中的多线程编程入门
【10月更文挑战第29天】在Java的世界中,多线程就像是一场精心编排的交响乐。每个线程都是乐团中的一个乐手,他们各自演奏着自己的部分,却又和谐地共同完成整场演出。本文将带你走进Java多线程的世界,让你从零基础到能够编写基本的多线程程序。
17 1
|
9天前
|
缓存 Java 调度
Java中的多线程编程:从基础到实践
【10月更文挑战第24天】 本文旨在为读者提供一个关于Java多线程编程的全面指南。我们将从多线程的基本概念开始,逐步深入到Java中实现多线程的方法,包括继承Thread类、实现Runnable接口以及使用Executor框架。此外,我们还将探讨多线程编程中的常见问题和最佳实践,帮助读者在实际项目中更好地应用多线程技术。
17 3
|
11天前
|
监控 安全 Java
Java多线程编程的艺术与实践
【10月更文挑战第22天】 在现代软件开发中,多线程编程是一项不可或缺的技能。本文将深入探讨Java多线程编程的核心概念、常见问题以及最佳实践,帮助开发者掌握这一强大的工具。我们将从基础概念入手,逐步深入到高级主题,包括线程的创建与管理、同步机制、线程池的使用等。通过实际案例分析,本文旨在提供一种系统化的学习方法,使读者能够在实际项目中灵活运用多线程技术。