这是本公众号的第 23 篇原创文章
荒腔走板
也学学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加锁比较好,可以自由选择抛不抛中断异常
所以,你会用线程中断了吗?
关于作者
我是Yasin,一个有颜有料又有趣的程序员
微信公众号:编了个程(blgcheng)
个人网站:https://yasinshaw.com
这里很多技术干货,关注肯定不后悔
加个星标可以第一时间看到最新文章
听说,转发和在看的人都升职加薪了
推荐阅读
- 什么是ThreadLocal?
- 你也被Spring的这个“线程池”坑过吗?
- 一篇短文带你走进Java线程池
- 一次性把Java的四种引用说清楚!