Java并发 基础篇
进程与线程的基本概念
进程产生的背景
最初的计算机只能接受一些特定的指令,用户每输入一个指令,计算机就做出一个操作。当用户在思考或者输入时,计算机就在等待。这样效率非常低下,在很多时候,计算机都处在等待状态。
批处理操作系统
后来有了批处理操作系统,把一系列需要操作的指令写下来,形成一个清单,一次性交给计算机。用户将多个需要执行的程序写在磁带上,然后交由计算机去读取并逐个执行这些程序,并将输出结果写在另一个磁带上。
批处理操作系统在一定程度上提高了计算机的效率,但是由于批处理操作系统的指令运行方式仍然是串行的,内存中始终只有一个程序在运行,后面的程序需要等待前面的程序执行完成后才能开始执行,而前面的程序有时会由于I/O操作、网络等原因阻塞,所以批处理操作效率也不高。
进程的提出
人们对于计算机的性能要求越来越高,现有的批处理操作系统并不能满足人们的需求,而批处理操作系统的瓶颈在于内存中只存在一个程序,那么内存中能不能存在多个程序呢?这是人们亟待解决的问题。
于是,科学家们提出了进程的概念。
进程就是应用程序在内存中分配的空间,也就是正在运行的程序,各个进程之间互不干扰。同时进程保存着程序每一个时刻运行的状态。
程序:用某种编程语言(java、python等)编写,能够完成一定任务或者功能的代码集合,是指令和数据的有序集合,是一段静态代码。
此时,CPU采用时间片轮转的方式运行进程:CPU为每个进程分配一个时间段,称作它的时间片。如果在时间片结束时进程还在运行,则暂停这个进程的运行,并且CPU分配给另一个进程(这个过程叫做上下文切换)。如果进程在时间片结束前阻塞或结束,则CPU立即进行切换,不用等待时间片用完。
当进程暂停时,它会保存当前进程的状态(进程标识,进程使用的资源等),在下一次切换回来时根据之前保存的状态进行恢复,接着继续执行。
使用进程+CPU时间片轮转方式的操作系统,在宏观上看起来同一时间段执行多个任务,换句话说,进程让操作系统的并发成为了可能。虽然并发从宏观上看有多个任务在执行,但在事实上,对于单核CPU来说,任意具体时刻都只有一个任务在占用CPU资源。
对操作系统的要求进一步提高
虽然进程的出现,使得操作系统的性能大大提升,但是随着时间的推移,人们并不满足一个进程在一段时间只能做一件事情,如果一个进程有多个子任务时,只能逐个得执行这些子任务,很影响效率。
比如杀毒软件在检测用户电脑时,如果在某一项检测中卡住了,那么后面的检测项也会受到影响。或者说当你使用杀毒软件中的扫描病毒功能时,在扫描病毒结束之前,无法使用杀毒软件中清理垃圾的功能,这显然无法满足人们的要求。
线程的提出
那么能不能让这些子任务同时执行呢?于是人们又提出了线程的概念,让一个线程执行一个子任务,这样一个进程就包含了多个线程,每个线程负责一个单独的子任务。
使用线程之后,事情就变得简单多了。当用户使用扫描病毒功能时,就让扫描病毒这个线程去执行。同时,如果用户又使用清理垃圾功能,那么可以先暂停扫描病毒线程,先响应用户的清理垃圾的操作,让清理垃圾这个线程去执行。响应完后再切换回来,接着执行扫描病毒线程。
注意:操作系统是如何分配时间片给每一个线程的,涉及到线程的调度策略,有兴趣的同学可以看一下《操作系统》,本文不做深入详解。
总之,进程和线程的提出极大的提高了操作系统的性能。进程让操作系统的并发性成为了可能,而线程让进程的内部并发成为了可能。
多进程的方式也可以实现并发,为什么我们要使用多线程?
多进程方式确实可以实现并发,但使用多线程,有以下几个好处:
- 进程间的通信比较复杂,而线程间的通信比较简单,通常情况下,我们需要使用共享资源,这些资源在线程间的通信比较容易。
- 进程是重量级的,而线程是轻量级的,故多线程方式的系统开销更小。
进程和线程的区别
进程是一个独立的运行环境,而线程是在进程中执行的一个任务。他们两个本质的区别是是否单独占有内存地址空间及其它系统资源(比如I/O):
- 进程单独占有一定的内存地址空间,所以进程间存在内存隔离,数据是分开的,数据共享复杂但是同步简单,各个进程之间互不干扰;而线程共享所属进程占有的内存地址空间和资源,数据共享简单,但是同步复杂。
- 进程单独占有一定的内存地址空间,一个进程出现问题不会影响其他进程,不影响主程序的稳定性,可靠性高;一个线程崩溃可能影响整个程序的稳定性,可靠性较低。
- 进程单独占有一定的内存地址空间,进程的创建和销毁不仅需要保存寄存器和栈信息,还需要资源的分配回收以及页调度,开销较大;线程只需要保存寄存器和栈信息,开销较小。
另外一个重要区别是,进程是操作系统进行资源分配的基本单位,而线程是操作系统进行调度的基本单位,即CPU分配时间的单位 。
上下文切换
上下文切换(有时也称做进程切换或任务切换)是指 CPU 从一个进程(或线程)切换到另一个进程(或线程)。上下文是指某一时间点 CPU 寄存器和程序计数器的内容。
寄存器是cpu内部的少量的速度很快的闪存,通常存储和访问计算过程的中间值提高计算机程序的运行速度。
程序计数器是一个专用的寄存器,用于表明指令序列中 CPU 正在执行的位置,存的值为正在执行的指令的位置或者下一个将要被执行的指令的位置,具体实现依赖于特定的系统。
举例说明 线程A - B
1.先挂起线程A,将其在cpu中的状态保存在内存中。
2.在内存中检索下一个线程B的上下文并将其在 CPU 的寄存器中恢复,执行B线程。
3.当B执行完,根据程序计数器中指向的位置恢复线程A。
CPU通过为每个线程分配CPU时间片来实现多线程机制。CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。
但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
上下文切换通常是计算密集型的,意味着此操作会消耗大量的 CPU 时间,故线程也不是越多越好。如何减少系统中上下文切换次数,是提升多线程性能的一个重点课题。
Java多线程入门类和接口
Thread类和Runnable接口
上一章我们了解了操作系统中多线程的基本概念。那么在Java中,我们是如何使用多线程的呢?
首先,我们需要有一个“线程”类。JDK提供了Thread
类和Runnable
接口来让我们实现自己的“线程”类。
- 继承
Thread
类,并重写run
方法; - 实现
Runnable
接口的run
方法;
继承Thread类
先学会怎么用,再学原理。首先我们来看看怎么用Thread
和Runnable
来写一个Java多线程程序。
首先是继承Thread
类:
1 | public class Demo { |
注意要调用start()
方法后,该线程才算启动!
我们在程序里面调用了start()方法后,虚拟机会先为我们创建一个线程,然后等到这个线程第一次得到时间片时再调用run()方法。
注意不可多次调用start()方法。在第一次调用start()方法后,再次调用start()方法会抛出IllegalThreadStateException异常。
实现Runnable接口
接着我们来看一下Runnable
接口(JDK 1.8 +):
1 |
|
可以看到Runnable
是一个函数式接口,这意味着我们可以使用Java 8的函数式编程来简化代码。
示例代码:
1 | public class Demo { |
Thread类构造方法
Thread
类是一个Runnable
接口的实现类,我们来看看Thread
类的源码。
查看Thread
类的构造方法,发现其实是简单调用一个私有的init
方法来实现初始化。init
的方法签名:
1 | // Thread类源码 |
我们挨个来解释一下init
方法的这些参数:
g:线程组,指定这个线程是在哪个线程组下;
target:指定要执行的任务;
name:线程的名字,多个线程的名字是可以重复的。如果不指定名字,见片段2;
acc:见片段3,用于初始化私有变量
inheritedAccessControlContext
。这个变量有点神奇。它是一个私有变量,但是在
Thread
类里只有init
方法对它进行初始化,在exit
方法把它设为null
。其它没有任何地方使用它。一般我们是不会使用它的,那什么时候会使用到这个变量呢?可以参考这个stackoverflow的问题:Restrict permissions to threads which execute third party software;inheritThreadLocals:可继承的
ThreadLocal
,见片段4,Thread
类里面有两个私有属性来支持ThreadLocal
,我们会在后面的章节介绍ThreadLocal
的概念。
实际情况下,我们大多是直接调用下面两个构造方法:
1 | Thread(Runnable target) |
Thread类的几个常用方法
这里介绍一下Thread类的几个常用的方法:
- currentThread():静态方法,返回对当前正在执行的线程对象的引用;
- start():开始执行线程的方法,java虚拟机会调用线程内的run()方法;
- yield():yield在英语里有放弃的意思,同样,这里的yield()指的是当前线程愿意让出对当前处理器的占用。这里需要注意的是,就算当前线程调用了yield()方法,程序在调度的时候,也还有可能继续运行这个线程的;
- sleep():静态方法,使当前线程睡眠一段时间;
- join():使当前线程等待另一个线程执行完毕之后再继续执行,内部调用的是Object类的wait方法实现的;
Thread类与Runnable接口的比较:
实现一个自定义的线程类,可以有继承Thread
类或者实现Runnable
接口这两种方式,它们之间有什么优劣呢?
- 由于Java“单继承,多实现”的特性,Runnable接口使用起来比Thread更灵活。
- Runnable接口出现更符合面向对象,将线程单独进行对象的封装。
- Runnable接口出现,降低了线程对象和线程任务的耦合性。
- 如果使用线程时不需要使用Thread类的诸多方法,显然使用Runnable接口更为轻量。
所以,我们通常优先使用“实现Runnable
接口”这种方式来自定义线程类。
Callable、Future与FutureTask
通常来说,我们使用Runnable
和Thread
来创建一个新的线程。但是它们有一个弊端,就是run
方法是没有返回值的。而有时候我们希望开启一个线程去执行一个任务,并且这个任务执行完成后有一个返回值。
JDK提供了Callable
接口与Future
接口为我们解决这个问题,这也是所谓的“异步”模型。
Callable接口
Callable
与Runnable
类似,同样是只有一个抽象方法的函数式接口。不同的是,Callable
提供的方法是有返回值的,而且支持泛型。
1 |
|
那一般是怎么使用Callable
的呢?Callable
一般是配合线程池工具ExecutorService
来使用的。我们会在后续章节解释线程池的使用。这里只介绍ExecutorService
可以使用submit
方法来让一个Callable
接口执行。它会返回一个Future
,我们后续的程序可以通过这个Future
的get
方法得到结果。
这里可以看一个简单的使用demo:
1 | // 自定义Callable |
输出结果:
1 | 2 |
Future接口
Future
接口只有几个比较简单的方法:
1 | public abstract interface Future<V> { |
cancel
方法是试图取消一个线程的执行。
注意是试图取消,并不一定能取消成功。因为任务可能已完成、已取消、或者一些其它因素不能取消,存在取消失败的可能。boolean
类型的返回值是“是否取消成功”的意思。参数paramBoolean
表示是否采用中断的方式取消线程执行。
所以有时候,为了让任务有能够取消的功能,就使用Callable
来代替Runnable
。如果为了可取消性而使用 Future
但又不提供可用的结果,则可以声明 Future<?>
形式类型、并返回 null
作为底层任务的结果。
FutureTask类
上面介绍了Future
接口。这个接口有一个实现类叫FutureTask
。FutureTask
是实现的RunnableFuture
接口的,而RunnableFuture
接口同时继承了Runnable
接口和Future
接口:
1 | public interface RunnableFuture<V> extends Runnable, Future<V> { |
那FutureTask
类有什么用?为什么要有一个FutureTask
类?前面说到了Future
只是一个接口,而它里面的cancel
,get
,isDone
等方法要自己实现起来都是非常复杂的。所以JDK提供了一个FutureTask
类来供我们使用。
示例代码:
1 | // 自定义Callable,与上面一样 |
使用上与第一个Demo有一点小的区别。首先,调用submit
方法是没有返回值的。这里实际上是调用的submit(Runnable task)
方法,而上面的Demo,调用的是submit(Callable<T> task)
方法。
然后,这里是使用FutureTask
直接取get
取值,而上面的Demo是通过submit
方法返回的Future
去取值。
在很多高并发的环境下,有可能Callable和FutureTask会创建多次。FutureTask能够在高并发环境下确保任务只执行一次。这块有兴趣的同学可以参看FutureTask源码。
FutureTask的几个状态
1 | /** |
state表示任务的运行状态,初始状态为NEW。运行状态只会在set、setException、cancel方法中终止。COMPLETING、INTERRUPTING是任务完成后的瞬时状态。
以上就是Java多线程几个基本的类和接口的介绍。可以打开JDK看看源码,体会这几个类的设计思路和用途吧!
线程组和线程优先级
线程组(ThreadGroup)
Java中用ThreadGroup来表示线程组,我们可以使用线程组对线程进行批量控制。
ThreadGroup和Thread的关系就如同他们的字面意思一样简单粗暴,每个Thread必然存在于一个ThreadGroup中,Thread不能独立于ThreadGroup存在。执行main()方法线程的名字是main,如果在new Thread时没有显式指定,那么默认将父线程(当前执行new Thread的线程)线程组设置为自己的线程组。
示例代码:
1 | public class Demo { |
输出结果:
1 | 执行main所在线程的线程组名字: main |
ThreadGroup管理着它下面的Thread,ThreadGroup是一个标准的向下引用的树状结构,这样设计的原因是防止”上级”线程被”下级”线程引用而无法有效地被GC回收。
线程的优先级
Java中线程优先级可以指定,范围是1~10。但是并不是所有的操作系统都支持10级优先级的划分(比如有些操作系统只支持3级划分:低,中,高),Java只是给操作系统一个优先级的参考值,线程最终在操作系统的优先级是多少还是由操作系统决定。
Java默认的线程优先级为5,线程的执行顺序由调度程序来决定,线程的优先级会在线程被调用之前设定。
通常情况下,高优先级的线程将会比低优先级的线程有更高的几率得到执行。我们使用方法Thread
类的setPriority()
实例方法来设定线程的优先级。
1 | public class Demo { |
输出结果:
1 | 我是默认线程优先级:5 |
既然有1-10的级别来设定了线程的优先级,这时候可能有些读者会问,那么我是不是可以在业务实现的时候,采用这种方法来指定一些线程执行的先后顺序?
对于这个问题,我们的答案是:No!
Java中的优先级来说不是特别的可靠,Java程序中对线程所设置的优先级只是给操作系统一个建议,操作系统不一定会采纳。而真正的调用顺序,是由操作系统的线程调度算法决定的。
我们通过代码来验证一下:
1 | public class Demo { |
某次输出:
1 | 当前执行的线程是:Thread-17,优先级:9 |
Java提供一个线程调度器来监视和控制处于RUNNABLE状态的线程。线程的调度策略采用抢占式,优先级高的线程比优先级低的线程会有更大的几率优先执行。在优先级相同的情况下,按照“先到先得”的原则。每个Java程序都有一个默认的主线程,就是通过JVM启动的第一个线程main线程。
还有一种线程称为守护线程(Daemon),守护线程默认的优先级比较低。
如果某线程是守护线程,那如果所有的非守护线程都结束了,这个守护线程也会自动结束。
应用场景是:当所有非守护线程结束时,结束其余的子线程(守护线程)自动关闭,就免去了还要继续关闭子线程的麻烦。
一个线程默认是非守护线程,可以通过Thread类的setDaemon(boolean on)来设置。
在之前,我们有谈到一个线程必然存在于一个线程组中,那么当线程和线程组的优先级不一致的时候将会怎样呢?我们用下面的案例来验证一下:
1 | public static void main(String[] args) { |
输出:
我是线程组的优先级6
我是线程的优先级6
所以,如果某个线程优先级大于线程所在线程组的最大优先级,那么该线程的优先级将会失效,取而代之的是线程组的最大优先级。
线程组的常用方法及数据结构
线程组的常用方法
获取当前的线程组名字
1 | Thread.currentThread().getThreadGroup().getName() |
复制线程组
1 | // 获取当前的线程组 |
线程组统一异常处理
1 | package com.func.axc.threadgroup; |
线程组的数据结构
线程组还可以包含其他的线程组,不仅仅是线程。
首先看看 ThreadGroup
源码中的成员变量
1 | public class ThreadGroup implements Thread.UncaughtExceptionHandler { |
然后看看构造函数:
1 | // 私有构造函数 |
第三个构造函数里调用了checkParentAccess
方法,这里看看这个方法的源码:
1 | // 检查parent ThreadGroup |
这里涉及到
SecurityManager
这个类,它是Java的安全管理器,它允许应用程序在执行一个可能不安全或敏感的操作前确定该操作是什么,以及是否是在允许执行该操作的安全上下文中执行它。应用程序可以允许或不允许该操作。比如引入了第三方类库,但是并不能保证它的安全性。
其实Thread类也有一个checkAccess()方法,不过是用来当前运行的线程是否有权限修改被调用的这个线程实例。(Determines if the currently running thread has permission to modify this thread.)
总结来说,线程组是一个树状的结构,每个线程组下面可以有多个线程或者线程组。线程组可以起到统一控制线程的优先级和检查线程的权限的作用。
Java线程的状态及主要转化方法
操作系统中的线程状态转换
首先我们来看看操作系统中的线程状态转换。
在现在的操作系统中,线程是被视为轻量级进程的,所以操作系统线程的状态其实和操作系统进程的状态是一致的。
操作系统线程主要有以下三个状态:
- 就绪状态(ready):线程正在等待使用CPU,经调度程序调用之后可进入running状态。
- 执行状态(running):线程正在使用CPU。
- 等待状态(waiting): 线程经过等待事件的调用或者正在等待其他资源(如I/O)。
Java线程的6个状态
1 | // Thread.State 源码 |
NEW
处于NEW状态的线程此时尚未启动。这里的尚未启动指的是还没调用Thread实例的start()方法。
1 | private void testStateNew() { |
从上面可以看出,只是创建了线程而并没有调用start()方法,此时线程处于NEW状态。
关于start()的两个引申问题
- 反复调用同一个线程的start()方法是否可行?
- 假如一个线程执行完毕(此时处于TERMINATED状态),再次调用这个线程的start()方法是否可行?
要分析这两个问题,我们先来看看start()的源码:
1 | public synchronized void start() { |
我们可以看到,在start()内部,这里有一个threadStatus的变量。如果它不等于0,调用start()是会直接抛出异常的。
我们接着往下看,有一个native的start0()
方法。这个方法里并没有对threadStatus的处理。到了这里我们仿佛就拿这个threadStatus没辙了,我们通过debug的方式再看一下:
1 |
|
我是在start()方法内部的最开始打的断点,叙述下在我这里打断点看到的结果:
- 第一次调用时threadStatus的值是0。
- 第二次调用时threadStatus的值不为0。
查看当前线程状态的源码:
1 | // Thread.getState方法源码: |
所以,我们结合上面的源码可以得到引申的两个问题的结果:
两个问题的答案都是不可行,在调用一次start()之后,threadStatus的值会改变(threadStatus !=0),此时再次调用start()方法会抛出IllegalThreadStateException异常。
比如,threadStatus为2代表当前线程状态为TERMINATED。
RUNNABLE
表示当前线程正在运行中。处于RUNNABLE状态的线程在Java虚拟机中运行,也有可能在等待CPU分配资源。
Java中线程的RUNNABLE状态
看了操作系统线程的几个状态之后我们来看看Thread源码里对RUNNABLE状态的定义:
1 | /** |
Java线程的RUNNABLE状态其实是包括了传统操作系统线程的ready和running两个状态的。
BLOCKED
阻塞状态。处于BLOCKED状态的线程正等待锁的释放以进入同步区。
我们用BLOCKED状态举个生活中的例子:
假如今天你下班后准备去食堂吃饭。你来到食堂仅有的一个窗口,发现前面已经有个人在窗口前了,此时你必须得等前面的人从窗口离开才行。
假设你是线程t2,你前面的那个人是线程t1。此时t1占有了锁(食堂唯一的窗口),t2正在等待锁的释放,所以此时t2就处于BLOCKED状态。
WAITING
等待状态。处于等待状态的线程变成RUNNABLE状态需要其他线程唤醒。
调用如下3个方法会使线程进入等待状态:
- Object.wait():使当前线程处于等待状态直到另一个线程唤醒它;
- Thread.join():等待线程执行完毕,底层调用的是Object实例的wait方法;
- LockSupport.park():除非获得调用许可,否则禁用当前线程进行线程调度。
我们延续上面的例子继续解释一下WAITING状态:
你等了好几分钟现在终于轮到你了,突然你们有一个“不懂事”的经理突然来了。你看到他你就有一种不祥的预感,果然,他是来找你的。
他把你拉到一旁叫你待会儿再吃饭,说他下午要去作报告,赶紧来找你了解一下项目的情况。你心里虽然有一万个不愿意但是你还是从食堂窗口走开了。
此时,假设你还是线程t2,你的经理是线程t1。虽然你此时都占有锁(窗口)了,“不速之客”来了你还是得释放掉锁。此时你t2的状态就是WAITING。然后经理t1获得锁,进入RUNNABLE状态。
要是经理t1不主动唤醒你t2(notify、notifyAll..),可以说你t2只能一直等待了。
TIMED_WAITING
超时等待状态。线程等待一个具体的时间,时间到后会被自动唤醒。
调用如下方法会使线程进入超时等待状态:
- Thread.sleep(long millis):使当前线程睡眠指定时间;
- Object.wait(long timeout):线程休眠指定时间,等待期间可以通过notify()/notifyAll()唤醒;
- Thread.join(long millis):等待当前线程最多执行millis毫秒,如果millis为0,则会一直执行;
- LockSupport.parkNanos(long nanos): 除非获得调用许可,否则禁用当前线程进行线程调度指定时间;
- LockSupport.parkUntil(long deadline):同上,也是禁止线程进行调度指定时间;
我们继续延续上面的例子来解释一下TIMED_WAITING状态:
到了第二天中午,又到了饭点,你还是到了窗口前。
突然间想起你的同事叫你等他一起,他说让你等他十分钟他改个bug。
好吧,你说那你就等等吧,你就离开了窗口。很快十分钟过去了,你见他还没来,你想都等了这么久了还不来,那你还是先去吃饭好了。
这时你还是线程t1,你改bug的同事是线程t2。t2让t1等待了指定时间,此时t1等待期间就属于TIMED_WATING状态。
t1等待10分钟后,就自动唤醒,拥有了去争夺锁的资格。
TERMINATED
终止状态。此时线程已执行完毕。
线程状态的转换
根据上面关于线程状态的介绍我们可以得到下面的线程状态转换图:
BLOCKED与RUNNABLE状态的转换
我们在上面说到:处于BLOCKED状态的线程是因为在等待锁的释放。假如这里有两个线程a和b,a线程提前获得了锁并且暂未释放锁,此时b就处于BLOCKED状态。我们先来看一个例子:
1 |
|
初看之下,大家可能会觉得线程a会先调用同步方法,同步方法内又调用了Thread.sleep()方法,必然会输出TIMED_WAITING,而线程b因为等待线程a释放锁所以必然会输出BLOCKED。
其实不然,有两点需要值得大家注意,一是在测试方法blockedTest()内还有一个main线程,二是启动线程后执行run方法还是需要消耗一定时间的。
测试方法的main线程只保证了a,b两个线程调用start()方法(转化为RUNNABLE状态),如果CPU执行效率高一点,还没等两个线程真正开始争夺锁,就已经打印此时两个线程的状态(RUNNABLE)了。
当然,如果CPU执行效率低一点,其中某个线程也是可能打印出BLOCKED状态的(此时两个线程已经开始争夺锁了)。
这时你可能又会问了,要是我想要打印出BLOCKED状态我该怎么处理呢?BLOCKED状态的产生需要两个线程争夺锁才行。那我们处理下测试方法里的main线程就可以了,让它“休息一会儿”,调用一下Thread.sleep()
方法。
这里需要注意的是main线程休息的时间,要保证在线程争夺锁的时间内,不要等到前一个线程锁都释放了你再去争夺锁,此时还是得不到BLOCKED状态的。
我们把上面的测试方法blockedTest()改动一下:
1 | public void blockedTest() throws InterruptedException { |
在这个例子中两个线程的状态转换如下
- a的状态转换过程:RUNNABLE(
a.start()
) -> TIMED_WATING(Thread.sleep()
)->RUNABLE(sleep()时间到)->BLOCKED(未抢到锁) -> TERMINATED - b的状态转换过程:RUNNABLE(
b.start()
) -> BLOCKED(未抢到锁) ->TERMINATED
斜体表示可能出现的状态, 大家可以在自己的电脑上多试几次看看输出。同样,这里的输出也可能有多钟结果。
WAITING状态与RUNNABLE状态的转换
根据转换图我们知道有3个方法可以使线程从RUNNABLE状态转为WAITING状态。我们主要介绍下**Object.wait()和Thread.join()**。
Object.wait()
调用wait()方法前线程必须持有对象的锁。
线程调用wait()方法时,会释放当前的锁,直到有其他线程调用notify()/notifyAll()方法唤醒等待锁的线程。
需要注意的是,其他线程调用notify()方法只会唤醒单个等待锁的线程,如有有多个线程都在等待这个锁的话不一定会唤醒到之前调用wait()方法的线程。
同样,调用notifyAll()方法唤醒所有等待锁的线程之后,也不一定会马上把时间片分给刚才放弃锁的那个线程,具体要看系统的调度。
Thread.join()
调用join()方法,会一直等待这个线程执行完毕(转换为TERMINATED状态)。
我们再把上面的例子线程启动那里改变一下:
1 | public void blockedTest() { |
要是没有调用join方法,main线程不管a线程是否执行完毕都会继续往下走。
a线程启动之后马上调用了join方法,这里main线程就会等到a线程执行完毕,所以这里a线程打印的状态固定是TERMINATED。
至于b线程的状态,有可能打印RUNNABLE(尚未进入同步方法),也有可能打印TIMED_WAITING(进入了同步方法)。
TIMED_WAITING与RUNNABLE状态转换
TIMED_WAITING与WAITING状态类似,只是TIMED_WAITING状态等待的时间是指定的。
Thread.sleep(long)
使当前线程睡眠指定时间。需要注意这里的“睡眠”只是暂时使线程停止执行,并不会释放锁。时间到后,线程会重新进入RUNNABLE状态。
Object.wait(long)
wait(long)方法使线程进入TIMED_WAITING状态。这里的wait(long)方法与无参方法wait()相同的地方是,都可以通过其他线程调用notify()或notifyAll()方法来唤醒。
不同的地方是,有参方法wait(long)就算其他线程不来唤醒它,经过指定时间long之后它会自动唤醒,拥有去争夺锁的资格。
Thread.join(long)
join(long)使当前线程执行指定时间,并且使线程进入TIMED_WAITING状态。
我们再来改一改刚才的示例:
1
2
3
4
5
6
7
8 public void blockedTest() {
······
a.start();
a.join(1000L);
b.start();
System.out.println(a.getName() + ":" + a.getState()); // 输出 TIEMD_WAITING
System.out.println(b.getName() + ":" + b.getState());
}这里调用a.join(1000L),因为是指定了具体a线程执行的时间的,并且执行时间是小于a线程sleep的时间,所以a线程状态输出TIMED_WAITING。
b线程状态仍然不固定(RUNNABLE或BLOCKED)。
线程中断
在某些情况下,我们在线程启动后发现并不需要它继续执行下去时,需要中断线程。目前在Java里还没有安全直接的方法来停止线程,但是Java提供了线程中断机制来处理需要中断线程的情况。
线程中断机制是一种协作机制。需要注意,通过中断操作并不能直接终止一个线程,而是通知需要被中断的线程自行处理。
简单介绍下Thread类里提供的关于线程中断的几个方法:
- Thread.interrupt():中断线程。这里的中断线程并不会立即停止线程,而是设置线程的中断状态为true(默认是flase);
- Thread.interrupted():测试当前线程是否被中断。线程的中断状态受这个方法的影响,意思是调用一次使线程中断状态设置为true,连续调用两次会使得这个线程的中断状态重新转为false;
- Thread.isInterrupted():测试当前线程是否被中断。与上面方法不同的是调用这个方法并不会影响线程的中断状态。
在线程中断机制里,当其他线程通知需要被中断的线程后,线程中断的状态被设置为true,但是具体被要求中断的线程要怎么处理,完全由被中断线程自己而定,可以在合适的实际处理中断请求,也可以完全不处理继续执行下去。
Java线程间的通信
合理的使用Java多线程可以更好地利用服务器资源。一般来讲,线程内部有自己私有的线程上下文,互不干扰。但是当我们需要多个线程之间相互协作的时候,就需要我们掌握Java线程的通信方式。本文将介绍Java线程之间的几种通信原理。
锁与同步
在Java中,锁的概念都是基于对象的,所以我们又经常称它为对象锁。线程和锁的关系,我们可以用婚姻关系来理解。一个锁同一时间只能被一个线程持有。也就是说,一个锁如果和一个线程“结婚”(持有),那其他线程如果需要得到这个锁,就得等这个线程和这个锁“离婚”(释放)。
在我们的线程之间,有一个同步的概念。什么是同步呢,假如我们现在有2位正在抄暑假作业答案的同学:线程A和线程B。当他们正在抄的时候,老师突然来修改了一些答案,可能A和B最后写出的暑假作业就不一样。我们为了A,B能写出2本相同的暑假作业,我们就需要让老师先修改答案,然后A,B同学再抄。或者A,B同学先抄完,老师再修改答案。这就是线程A,线程B的线程同步。
可以解释为:线程同步是线程之间按照一定的顺序执行。
为了达到线程同步,我们可以使用锁来实现它。
我们先来看看一个无锁的程序:
1 | public class NoneLock { |
执行这个程序,你会在控制台看到,线程A和线程B各自独立工作,输出自己的打印值。如下是我的电脑上某一次运行的结果。每一次运行结果都会不一样。
1 | .... |
那我现在有一个需求,我想等A先执行完之后,再由B去执行,怎么办呢?最简单的方式就是使用一个“对象锁”:
1 | public class ObjectLock { |
这里声明了一个名字为lock
的对象锁。我们在ThreadA
和ThreadB
内需要同步的代码块里,都是用synchronized
关键字加上了同一个对象锁lock
。
上文我们说到了,根据线程和锁的关系,同一时间只有一个线程持有一个锁,那么线程B就会等线程A执行完成后释放lock
,线程B才能获得锁lock
。
这里在主线程里使用sleep方法睡眠了10毫秒,是为了防止线程B先得到锁。因为如果同时start,线程A和线程B都是出于就绪状态,操作系统可能会先让B运行。这样就会先输出B的内容,然后B执行完成之后自动释放锁,线程A再执行。
等待/通知机制
上面一种基于“锁”的方式,线程需要不断地去尝试获得锁,如果失败了,再继续尝试。这可能会耗费服务器资源。
而等待/通知机制是另一种方式。
Java多线程的等待/通知机制是基于Object
类的wait()
方法和notify()
, notifyAll()
方法来实现的。
notify()方法会随机叫醒一个正在等待的线程,而notifyAll()会叫醒所有正在等待的线程。
前面我们讲到,一个锁同一时刻只能被一个线程持有。而假如线程A现在持有了一个锁lock
并开始执行,它可以使用lock.wait()
让自己进入等待状态。这个时候,lock
这个锁是被释放了的。
这时,线程B获得了lock
这个锁并开始执行,它可以在某一时刻,使用lock.notify()
,通知之前持有lock
锁并进入等待状态的线程A,说“线程A你不用等了,可以往下执行了”。
需要注意的是,这个时候线程B并没有释放锁
lock
,除非线程B这个时候使用lock.wait()
释放锁,或者线程B执行结束自行释放锁,线程A才能得到lock
锁。
我们用代码来实现一下:
1 | public class WaitAndNotify { |
在这个Demo里,线程A和线程B首先打印出自己需要的东西,然后使用notify()
方法叫醒另一个正在等待的线程,然后自己使用wait()
方法陷入等待并释放lock
锁。
需要注意的是等待/通知机制使用的是使用同一个对象锁,如果你两个线程使用的是不同的对象锁,那它们之间是不能用等待/通知机制通信的。
信号量
JDK提供了一个类似于“信号量”功能的类Semaphore
。但本文不是要介绍这个类,而是介绍一种基于volatile
关键字的自己实现的信号量通信。
后面会有专门的章节介绍volatile
关键字,这里只是做一个简单的介绍。
volatile关键字能够保证内存的可见性,如果用volatile关键字声明了一个变量,在一个线程里面改变了这个变量的值,那其它线程是立马可见更改后的值的。
比如我现在有一个需求,我想让线程A输出0,然后线程B输出1,再然后线程A输出2…以此类推。我应该怎样实现呢?
代码:
1 | public class Signal { |
我们可以看到,使用了一个volatile
变量signal
来实现了“信号量”的模型。这里需要注意的是,volatile
变量需要进行原子操作。
需要注意的是,signal++
并不是一个原子操作,所以我们在实际开发中,会根据需要使用synchronized
给它“上锁”,或者是使用AtomicInteger
等原子类。并且上面的程序也并不是线程安全的,因为执行while
语句后,可能当前线程就暂停等待时间片了,等线程醒来,可能signal已经大于等于5了。
这种实现方式并不一定高效,本例只是演示信号量
信号量的应用场景:
假如在一个停车场中,车位是我们的公共资源,线程就如同车辆,而看门的管理员就是起的“信号量”的作用。
因为在这种场景下,多个线程(超过2个)需要相互合作,我们用简单的“锁”和“等待通知机制”就不那么方便了。这个时候就可以用到信号量。
其实JDK中提供的很多多线程通信工具类都是基于信号量模型的。我们会在后面第三篇的文章中介绍一些常用的通信工具类。
管道
管道是基于“管道流”的通信方式。JDK提供了PipedWriter
、 PipedReader
、 PipedOutputStream
、 PipedInputStream
。其中,前面两个是基于字符的,后面两个是基于字节流的。
这里的示例代码使用的是基于字符的:
1 | public class Pipe { |
我们通过线程的构造函数,传入了PipedWrite
和PipedReader
对象。可以简单分析一下这个示例代码的执行流程:
- 线程ReaderThread开始执行,
- 线程ReaderThread使用管道reader.read()进入”阻塞“,
- 线程WriterThread开始执行,
- 线程WriterThread用writer.write(“test”)往管道写入字符串,
- 线程WriterThread使用writer.close()结束管道写入,并执行完毕,
- 线程ReaderThread接受到管道输出的字符串并打印,
- 线程ReaderThread执行完毕。
管道通信的应用场景:
这个很好理解。使用管道多半与I/O流相关。当我们一个线程需要先另一个线程发送一个信息(比如字符串)或者文件等等时,就需要使用管道通信了。
其它通信相关
以上介绍了一些线程间通信的基本原理和方法。除此以外,还有一些与线程通信相关的知识点,这里一并介绍。
join方法
join()方法是Thread类的一个实例方法。它的作用是让当前线程陷入“等待”状态,等join的这个线程执行完成后,再继续执行当前线程。
有时候,主线程创建并启动了子线程,如果子线程中需要进行大量的耗时运算,主线程往往将早于子线程结束之前结束。
如果主线程想等待子线程执行完毕后,获得子线程中的处理完的某个数据,就要用到join方法了。
示例代码:
1 | public class Join { |
注意join()方法有两个重载方法,一个是join(long), 一个是join(long, int)。
实际上,通过源码你会发现,join()方法及其重载方法底层都是利用了wait(long)这个方法。
对于join(long, int),通过查看源码(JDK 1.8)发现,底层并没有精确到纳秒,而是对第二个参数做了简单的判断和处理。
sleep方法
sleep方法是Thread类的一个静态方法。它的作用是让当前线程睡眠一段时间。它有这样两个方法:
- Thread.sleep(long)
- Thread.sleep(long, int)
同样,查看源码(JDK 1.8)发现,第二个方法貌似只对第二个参数做了简单的处理,没有精确到纳秒。实际上还是调用的第一个方法。
这里需要强调一下:sleep方法是不会释放当前的锁的,而wait方法会。这也是最常见的一个多线程面试题。
它们还有这些区别:
- wait可以指定时间,也可以不指定;而sleep必须指定时间。
- wait释放cpu资源,同时释放锁;sleep释放cpu资源,但是不释放锁,所以易死锁。
- wait必须放在同步块或同步方法中,而sleep可以再任意位置
ThreadLocal类
ThreadLocal是一个本地线程副本变量工具类。内部是一个弱引用的Map来维护。这里不详细介绍它的原理,而是只是介绍它的使用,以后有独立章节来介绍ThreadLocal类的原理。
有些朋友称ThreadLocal为线程本地变量或线程本地存储。严格来说,ThreadLocal类并不属于多线程间的通信,而是让每个线程有自己”独立“的变量,线程之间互不影响。它为每个线程都创建一个副本,每个线程可以访问自己内部的副本变量。
ThreadLocal类最常用的就是set方法和get方法。示例代码:
1 | public class ThreadLocalDemo { |
可以看到,虽然两个线程使用的同一个ThreadLocal实例(通过构造方法传入),但是它们各自可以存取自己当前线程的一个值。
那ThreadLocal有什么作用呢?如果只是单纯的想要线程隔离,在每个线程中声明一个私有变量就好了呀,为什么要使用ThreadLocal?
如果开发者希望将类的某个静态变量(user ID或者transaction ID)与线程状态关联,则可以考虑使用ThreadLocal。
最常见的ThreadLocal使用场景为用来解决数据库连接、Session管理等。数据库连接和Session管理涉及多个复杂对象的初始化和关闭。如果在每个线程中声明一些私有变量来进行操作,那这个线程就变得不那么“轻量”了,需要频繁的创建和关闭连接。
InheritableThreadLocal
InheritableThreadLocal类与ThreadLocal类稍有不同,Inheritable是继承的意思。它不仅仅是当前线程可以存取副本值,而且它的子线程也可以存取这个副本值。