Java多线程
大部分时候,我们都做着单线程的编程,前面所有程序都只有一条顺序执行流——程序从main方法开始执行,依次向下执行每行代码,如果程序执行某行代码时遇到了阻塞,则程序将会停滞在该处。但实际的情况是,单线程的程序往往功能非常有限,例如下载网络资源时如果使用单线程,除了物理带宽的限制,单线程下载会让下载进度变的异常缓慢,这就带来了非常糟糕的体验。
Java语言提供了非常优秀的多线程支持,程序可以通过非常简单的方式来启动多线程。
线程概述
线程和进程
几乎所有的操作系统都支持进程的概念,所有运行中的任务通常对应一个进程(Process)。当一个程序进入内存运行时,即变成一个进程。进程是处于运行过程中的程序,并且具有一定的独立功能,进程是系统进行资源分配和调度的一个独立单位。
线程是进程的组成部分,一个进程可以拥有多个线程,一个线程必须有一个父进程。线程可以拥有自己的堆栈、自己的程序计数器和自己的局部变量,但不拥有系统资源,它与父进程的其他线程共享该进程所拥有的全部资源。
简而言之,一个程序运行后至少有一个进程,一个进程里可以包含多个线程,但至少要包含一个线程。
并行和并发
并发性(concurrency)和并行性(parallel)是两个概念。
并行指在同一时刻,有多条指令在多个处理器上同时执行;
并发指在同一时刻只能有一条指令执行,但多个进程指令被快速轮换执行,使得在宏观上具有多个进程同时执行的效果。
在单CPU的时代多个任务都是并发执行的,这是因为单个CPU同时只能执行一个任务。在单CPU时代多任务是共享一个CPU的,当一个任务占用CPU运行时,其他任务就会被挂起,当占用CPU的任务时间片用完后,会把CPU让给其他任务来使用,所以在单CPU时代多线程编程是没有太大意义的,并且线程间频繁的上下文切换还会带来额外开销。
线程的创建和启动
Java使用Thread类代表线程,所有的线程对象都必须是Thread类或其子类的实例。每个线程的作用是完成一定的任务,实际上就是执行一段程序流(一段顺序执行的代码)
继承Thread类
通过继承Thread类来创建并启动多线程的步骤如下。
- 定义Thread类的子类,并重写该类的run()方法,该run()方法的方法体就代表了线程需要完成的任务。因此把run()方法称为线程执行体。
- 创建Thread子类0实例,即创建了线程对象。
- 调用线程对象的start()方法来启动该线程。
下面通过示例来演示该方式:
1 | package cn.bytecollege; |
在上面的程序中定义了类MyThread1,并集成了Thread类重写了run方法,run方法的方法体即使线程执行的任务,需要注意的是,在代码14行启动线程时,并不是直接调用run()方法,而是调用了start()方法。
除此之外,上面程序还用到了线程的如下两个方法。
- Thread.currentThread():currentThread()是Thread类的静态方法,该方法总是返回当前正在执行的线程对象。
- getName():该方法是Thread类的实例方法,该方法返回调用该方法线程的名字。
实现Runnable接口
由于Java是单继承的,如果使用继承Thread类的方式去创建线程的话,就不能继承其他类,这无疑降低了代码的灵活性,因此Java还为开发者提供了Runnable接口创建多线程。实现Runnable接口来创建并启动多线程的步骤如下:
- 定义Runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。
- 创建Runnable实现类的实例,并以此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。
下面,通过示例演示实现Runnable接口创建线程。
- 调用线程对象的start()方法来启动该线程。
1 | package cn.bytecollege; |
上面的程序,通过继承Runnable接口并重写run()方法,创建了线程,需要注意的是在启动线程时,MyThread并没有start()方法,所以使用了Thread的构造方法,将线程t传入并启动了线程,此处启动的并不是线程thread,而是线程t。
Runnable对象仅仅作为Thread对象的target,Runnable实现类里包含的run()方法仅作为线程执行体。而实际的线程对象依然是Thread实例,只是该Thread线程负责执行其target的run()方法。
使用Callable和Future创建线程
从Java 5开始,Java提供了Callable接口,该接口像是Runnable接口的增强版,Callable接口提供了一个call()方法可以作为线程执行体,但call()方法比run()方法功能更强大。
- call()方法可以有返回值。
- call()方法可以声明抛出异常。
因此我们完全可以提供一个Callable对象作为Thread的target,而该线程的线程执行体就是该Callable对象的call()方法。问题是:Callable接口是Java 5新增的接口,而且它不是Runnable接口的子接口,所以Callable对象不能直接作为Thread的target。而且call()方法还有一个返回值——call()方法并不是直接调用,它是作为线程执行体被调用的。
因此,Java 5提供了Future接口来代表Callable接口里call()方法的返回值,并为Future接口提供了一个FutureTask实现类,该实现类实现了Future接口,并实现了Runnable接口——可以作为Thread类的target。
在Future接口里定义了如下几个公共方法来控制它关联的Callable任务。
- boolean cancel(boolean mayInterruptIfRunning):试图取消该Future里关联的Callable任务。
- V get():返回Callable任务里call()方法的返回值。调用该方法将导致程序阻塞,必须等到子线程结束后才会得到返回值。
- V get(long timeout,TimeUnit unit):返回Callable任务里call()方法的返回值。该方法让程序最多阻塞timeout和unit指定的时间,如果经过指定时间后Callable任务依然没有返回值,将会抛出TimeoutException异常。
- boolean isCancelled():如果在Callable任务正常完成前被取消,则返回true。
- boolean isDone():如果Callable任务已完成,则返回 true。
创建并启动有返回值的线程的步骤如下:
- 创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,且该call()方法有返回值。
- 创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。
- 使用FutureTask对象作为Thread对象的target创建并启动新线程。
- 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。
下面通过示例学习使用Callable创建并启动线程:
1 | package cn.bytecollege; |
上面程序中创建 Callable 实现类与创建 Runnable 实现类并没有太大的差别,只是Callable的call()方法允许声明抛出异常,而且允许带返回值。上面程序中是以 Callable 对象来启动线程的关键代码。程序先创建一个Callable实现类的实例,然后将该实例包装成一个FutureTask对象。
程序启动以FutureTask对象为target的线程。程序最后调用FutureTask对象的get()方法来返回call()方法的返回值——该方法将导致主线程被阻塞,直到call()方法结束并返回为止。
运行结果如下:
通过继承Thread类或实现Runnable、Callable接口都可以实现多线程,不过实现Runnable接口与实现Callable接口的方式基本相同,只是Callable接口里定义的方法有返回值,可以声明抛出异常而已。因此可以将实现Runnable接口和实现Callable接口归为一种方式。这种方式与继承Thread方式之间的主要差别如下。采用实现Runnable、Callable接口的方式创建多线程
- 线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。
- 在这种方式下,多个线程可以共享同一个 target 对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
- 劣势是:编程稍稍复杂,如果需要访问当前线程,则必须使用Thread.currentThread()方法。
采用继承Thread类的方式创建多线程。
- 劣势是:因为线程类已经继承了Thread类,所以不能再继承其他父类。
- 优势是:编写简单,如果需要访问当前线程,则无须使用Thread.currentThread(),直接使用this即可获得当前线程。
线程的生命周期
当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态,在线程的生命周期中,它要经过新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和死亡(Dead)五种状态。
1.NEW
Java源码对NEW状态的说明是:创建成功但是没有调用start()方法启动的Thread线程实例都处于NEW状态。当然,并不是Thread线程实例的start()方法一经调用,其状态就从NEW状态到RUNNABLE状态,此时并不意味着线程立即获取CPU时间片并且立即执行,中间需要一系列操作系统的内部操作。
2.RUNNABLE
当Java线程的Thread实例的start()方法被调用后,操作系统中的对应线程进入的并不是运行状态,而是就绪状态,而Java线程并没有这个就绪状态。Java中的线程管理是通过JNI本地调用的方式委托操作系统的线程管理API完成的。
一个操作系统线程如果处于就绪状态,就表示“万事俱备,只欠东风”,即该线程已经满足执行条件,但是还不能执行。处于就绪状态的线程需要等待系统的调度,一旦就绪状态被系统选中,获得CPU时间片,线程就开始占用CPU,开始执行线程的代码,这时线程的操作系统状态发生了改变,进入了运行状态。在操作系统中,处于运行状态的线程在CPU时间片用完之后,又回到就绪状态,等待CPU的下一次调度。就这样,操作系统线程在就绪状态和执行状态之间被系统反复地调度,这种情况会一直持续,直到线程的代码逻辑执行完成或者异常终止。这时线程的操作系统状态又发生了改变,进入线程的最后状态TERMINATED状态。
就绪状态和运行状态都是操作系统中的线程状态。在Java语言中,并没有细分这两种状态,而是将这两种状态合并成同一种状态——RUNNABLE状态。因此,在Thread.State枚举类中,没有定义线程的就绪状态和运行状态,只是定义了RUNNABLE状态。这就是Java线程状态和操作系统中线程状态不同的地方。
3.WAITING
处于WAITING(无限期等待)状态的线程不会被分配CPU时间片,需要被其他线程显式地唤醒,才会进入就绪状态。线程调用以下3种方法会让自己进入无限等待状态:
- Object.wait()方法,对应的唤醒方式为Object.notify()/Object.notifyAll()。
- Thread.join()方法,对应的唤醒方式为:被合入的线程执行完毕。
- LockSupport.park()方法,对应的唤醒方式LockSupport.unpark(Thread)。
4.TIMED_WAITING
线程处于一种特殊的等待状态,准确地说,线程处于限时等待状态。能让线程处于限时等待状态的操作大致有以下几种:
- Thread.sleep(int n):使得当前线程进入限时等待状态,等待时间为n毫秒。
- Object.wait():带时限的抢占对象的monitor锁。
- Thread.join():带时限的线程合并。
- LockSupport.parkNanos():让线程等待,时间以纳秒为单位。
- LockSupport.parkUntil():让线程等待,时间可以灵活设置。
5.BLOCKED
线程处于一种阻塞状态,该状态并不会占用CPU资源,以下情况会让线程进入阻塞状态:
- 线程等待获取锁,而该锁被其他线程持有,则该线程进入阻塞状态。当其他线程释放了该锁,并且线程调度器允许该线程持有该锁时,该线程退出阻塞状态。
- IO阻塞,线程发起了一个阻塞式IO操作后,如果不具备IO操作的条件,线程就会进入阻塞状态。IO包括磁盘IO、网络IO等。IO阻塞的一个简单例子:线程等待用户输入内容后继续执行。
6.TERMINATED
处于RUNNABLE状态的线程在run()方法执行完成之后就变成终止状态TERMINATED了。当然,如果在run()方法执行过程中发生了运行时异常而没有被捕获,run()方法将被异常终止,线程也会变成TERMINATED状态。
新建和就绪状态
当程序使用 new 关键字创建了一个线程之后,该线程就处于新建状态,此时它和其他的Java对象一样,仅仅由Java虚拟机为其分配内存,并初始化其成员变量的值。此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体。当线程对象调用了start()方法之后,该线程处于就绪状态,Java虚拟机会为其创建方法调用栈和程序计数器,处于这个状态中的线程并没有开始运行,只是表示该线程可以运行了。至于该线程何时开始运行,取决于JVM里线程调度器的调度。
线程的调度目前主要分为两种:分时调度和抢占式调度。
- 分时调度:系统平均分配CPU时间片,所有线程轮流占用CPU,也就是说在时间片调度的分配上所有线程“人人平等”。
- 抢占式调度:系统按照线程优先级分配CPU时间片。优先级高的线程优先分配CPU时间片,如果所有就绪线程的优先级相同,那么会随机选择一个,优先级高的线程获取的CPU时间片相对多一些。
由于目前大部分操作系统都是使用抢占式调度模型进行线程调度,Java的线程管理和调度是委托给操作系统完成的,与之相对应,Java的线程调度也是使用抢占式调度模型。
启动线程使用 start()方法,而不是 run()方法!永远不要调用线程对象的run()方法!调用start()方法来启动线程,系统会把该run()方法当成线程执行体来处理;但如果直接调用线程对象的run()方法,则run()方法立即就会被执行,而且在 run()方法返回之前其他线程无法并发执行——也就是说,系统把线程对象当成一个普通对象,而run()方法也是一个普通方法,而不是线程执行体。
1 | package cn.bytecollege.cycle; |
运行上面的程序可以看出当thread线程创建对象后,此时打印线程为新建状态(NEW),当启动线程,线程已经程开始运行时在代码第6行打印了运行状态(RUNNABLE)
只能对处于新建状态的线程调用 start()方法,否则将引发IllegalThreadStateException异常。也就是说当线程调用过start()方法后再不能重复调用,否则将引发异常。
运行和阻塞状态
如果处于就绪状态的线程获得了CPU,开始执行run()方法的线程执行体,则该线程处于运行状态,如果计算机只有一个 CPU,那么在任何时刻只有一个线程处于运行状态。当然,在一个多处理器的机器上,将会有多个线程并行(注意是并行:parallel)执行;当线程数大于处理器数时,依然会存在多个线程在同一个CPU上轮换的现象。
当一个线程开始运行后,它不可能一直处于运行状态(除非它的线程执行体足够短,瞬间就执行结束了),线程在运行过程中需要被中断,目的是使其他线程获得执行的机会,线程调度的细节取决于底层平台所采用的策略。对于采用抢占式策略的系统而言,系统会给每个可执行的线程一个小时间段来处理任务;当该时间段用完后,系统就会剥夺该线程所占用的资源,让其他线程获得执行的机会。在选择下一个线程时,系统会考虑线程的优先级。
当发生如下情况时,线程将会进入阻塞状态。
- 线程调用sleep()方法主动放弃所占用的处理器资源。
- 线程调用了一个阻塞式IO方法,在该方法返回之前,该线程被阻塞。
- 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有。关于同步监视器的知识、后面将有更深入的介绍。
- 线程在等待某个通知(notify)。
当前正在执行的线程被阻塞之后,其他线程就可以获得执行的机会。被阻塞的线程会在合适的时候重新进入就绪状态,注意是就绪状态而不是运行状态。也就是说,被阻塞线程的阻塞解除后,必须重新等待线程调度器再次调度它。
针对上面几种情况,当发生如下特定的情况时可以解除上面的阻塞,让该线程重新进入就绪状态。
- 调用sleep()方法的线程经过了指定时间。
- 线程调用的阻塞式IO方法已经返回。
- 线程成功地获得了试图取得的同步监视器。
- 线程正在等待某个通知时,其他线程发出了一个通知。
下图显示了线程状态转换图。
下面通过示例来演示程序进入阻塞状态:
1 | package cn.bytecollege.cycle; |
运行上面的程序发现启动线程后,线程一直处于阻塞状态,这是因为在代码第9行程序一直在等待着用户的输入。
线程死亡
线程会以如下3种方式结束,结束后就处于死亡状态。
- run()或call()方法执行完成,线程正常结束。
- 线程抛出一个未捕获的 Exception或Error。
- 直接调用该线程的stop()方法来结束该线程——该方法容易导致死锁,通常不推荐使用。
当主线程结束时,其他线程不受任何影响,并不会随之结束。一旦子线程启动起来后,它就拥有和主线程相同的地位,它不会受主线程的影响。
为了测试某个线程是否已经死亡,可以调用线程对象的isAlive()方法,当线程处于就绪、运行、阻塞3种状态时,该方法将返回true;当线程处于新建、死亡2种状态时,该方法将返回false。
下面通过程序来演示该状态:
1 | package cn.bytecollege.cycle; |
在上面的程序中代码第12行启动了thread线程,启动该线程后让主线程休眠3s,给予thread线程充分的执行时间,当主线程休眠结束后,再次获取thread线程的状态,发现发现打印了TERMINATED。运行结果如下:
不要对处于死亡状态的线程调用start()方法,程序只能对新建状态的线程调用start()方法,对新建状态的线程两次调用start()方法也是错误的。这都会引发IllegalThreadState Exception异常。
控制线程
Java 的线程支持提供了一些便捷的工具方法,通过这些便捷的工具方法可以很好地控制线程的执行。
join线程
Thread提供了让一个线程等待另一个线程执行完成的方法——join(),当某个程序在执行流中调用其他线程的join()方法时,调用线程将被阻塞,直到被join()方法加入的线程执行结束,通俗点说就是在A线程中调用线程B的join()方法,线程A会一直等待直到线程B执行结束再执行,也可以理解为插队。
下面通过示例来学习该方法:
1 | package cn.bytecollege.cycle; |
1 | package cn.bytecollege.cycle; |
1 | package cn.bytecollege.cycle; |
在上面的程序中创建了a、b两个线程,其中在a线程for循环中当i等于20时,启动b线程并调用了b线程的join方法,此时a线程会一直等待b线程执行,直到b线程执行结束,再继续执行。
join()方法有如下3种重载形式。
- join():等待被join的线程执行完成。此方法会把当前线程线程状态变成WAITING,直到被join的线程执行结束。
- join(long millis):等待被join的线程的时间最长为millis毫秒。如果在millis毫秒内被join的线程还没有执行结束,则不再等待。此方法会把当前线程状态变为TIMED_WAITING,直到被join的线程执行结束,或者等待被合并线程执行millis的时间
- join(long millis,int nanos):等待被join的线程的时间最长为millis毫秒加nanos毫微秒。此方法会把当前线程状态变为TIMED_WAITING,直到被join的线程执行结束,或者等待被合并线程执行millis+nanos的时间
守护线程
有一种线程,它是在后台运行的,它的任务是为其他的线程提供服务,这种线程被称为“后台线程(Daemon Thread)”,又称为“守护线程”,JVM的垃圾回收线程就是典型的后台线程。
后台线程有个特征:如果所有的前台线程都死亡,后台线程会自动死亡。
调用Thread对象的setDaemon(true)方法可将指定线程设置成后台线程。下面程序将执行线程设置成后台线程,可以看到当所有的前台线程死亡时,后台线程随之死亡。当整个虚拟机中只剩下后台线程时,程序就没有继续运行的必要了,所以虚拟机也就退出了。
除此以外可以通过isDaemon()方法判断线程是否是守护线程。
1 | package cn.bytecollege.cycle; |
1 | package cn.bytecollege.cycle; |
运行上面的程序发现当前台线程(此时只有两个线程运行,一个main线程,一个守护线程,所以main线程就是前台线程)运行结束时,守护线程不管是否执行完毕都会结束。这是因为当主线程也就是程序中唯一的前台线程运行结束后,JVM 会主动退出,因而后台线程也就被结束了。
只有前台线程全部终止了,相当于没有了被守护者,守护线程也就没有工作可做了,也就可以全部终止了。当然,用户线程全部终止,JVM进程也就没有继续的必要了。
使用守护线程时,有以下几点需要特别注意:
- 守护线程必须在启动前将其守护状态设置为true,启动之后不能再将用户线程设置为守护线程,否则JVM会抛出一个InterruptedException异常。具体来说,如果线程为守护线程,就必须在线程实例的start()方法调用之前调用线程实例的setDaemon(true),设置其daemon实例属性值为true。
- 守护线程存在被JVM强行终止的风险,所以在守护线程中尽量不去访问系统资源,如数据库连接。守护线程被强行终止时,可能会引发系统资源操作不负责任的中断,从而导致资源不可逆的损坏。
- 守护线程创建的线程也是守护线程。在守护线程中创建的线程,新的线程都是守护线程。在创建之后,如果通过调用setDaemon(false)将新的线程显式地设置为用户线程,新的线程可以调整成用户线程。
线程休眠
sleep()
如果需要让当前正在执行的线程暂停一段时间,并进入阻塞状态,则可以通过调用Thread类的静态sleep()方法来实现。
- static void sleep(long millis):让当前正在执行的线程暂停millis毫秒,并进入阻塞状态,该方法受到系统计时器和线程调度器的精度与准确度的影响。
- static void sleep(long millis,int nanos):让当前正在执行的线程暂停millis毫秒加nanos毫微秒,并进入阻塞状态,该方法受到系统计时器和线程调度器的精度与准确度的影响。
当前线程调用 sleep()方法进入阻塞状态后,在其睡眠时间段内,该线程不会获得执行的机会,即使系统中没有其他可执行的线程,处于sleep()中的线程也不会执行,因此sleep()方法常用来暂停程序的执行。
1 | package cn.bytecollege.cycle; |
LockSupport类
LockSupport是JUC提供的一个线程阻塞与唤醒的工具类,该工具类可以让线程在任意位置阻塞和唤醒,其所有的方法都是静态方法。
LockSupport常用方法如下:
1 | //无限期阻塞当前线程 |
LockSupport的方法主要有两类:park和unpark。park的英文意思为停车,如果把Thread看成一辆车的话,park()方法就是让车停下,其作用是将调用park()方法的当前线程阻塞;而unpark()方法是让车启动,然后跑起来,其作用是将指定线程Thread唤醒。
下面通过示例学习LockSupport的使用:
LockSupport.park()和Thread.sleep()的区别:
从功能上说,LockSupport.park()与Thread.sleep()方法类似,都是让线程阻塞,二者的区别如下:
- Thread.sleep()没法从外部唤醒,只能自己醒过来;而被LockSupport.park()方法阻塞的线程可以通过调用LockSupport.unpark()方法去唤醒。
- Thread.sleep()方法声明了InterruptedException中断异常,这是一个受检异常,调用者需要捕获这个异常或者再抛出;而调用LockSupport.park()方法时不需要捕获中断异常。
- 被LockSupport.park()方法、Thread.sleep()方法所阻塞的线程有一个特点,当被阻塞线程的Thread.interrupt()方法被调用时,被阻塞线程的中断标志将被设置,该线程将被唤醒。不同的是,二者对中断信号的响应方式不同:LockSupport.park()方法不会抛出InterruptedException异常,仅仅设置了线程的中断标志;而Thread.sleep()方法会抛出InterruptedException异常。
- 与Thread.sleep()相比,调用LockSupport.park()能更精准、更加灵活地阻塞、唤醒指定线程。
注意:通过LockSupport.park()方法进入阻塞的线程和通过Thread.sleep()进入阻塞的线程一样,都不会释放锁。
LockSupport.part()和Object.wait()的区别:
- Object.wait()方法需要在synchronized块中执行,而LockSupport.park()可以在任意地方执行。
- 当被阻塞线程被中断时,Object.wait()方法抛出了中断异常,调用者需要捕获或者再抛出;当被阻塞线程被中断时,LockSupport.park()不会抛出异常,调用时不需要处理中断异常。
- 如果线程在没有被Object.wait()阻塞之前被Object.notify()唤醒,也就是说在Object.wait()执行之前去执行Object.notify(),就会抛出IllegalMonitorStateException异常,是不被允许的;而线程在没有被LockSupport.park()阻塞之前被LockSupport.unPark()唤醒,也就是说在LockSupport.park()执行之前去执行LockSupport.unPark(),不会抛出任何异常,是被允许的。
线程让步
yield()方法是一个和 sleep()方法有点相似的方法,它也是 Thread 类提供的一个静态方法,它也可以让当前正在执行的线程暂停,但它不会阻塞该线程,它只是将该线程转入就绪状态。yield()只是让当前线程暂停一下,让系统的线程调度器重新调度一次,线程调度器会从线程就绪队列里获取一个线程优先级高的线程,当然完全可能的情况是:当某个线程调用了yield()方法暂停之后,线程调度器又将其调度出来重新执行。
当某个线程调用了yield()方法暂停之后,只有优先级与当前线程相同,或者优先级比当前线程更高的处于就绪状态的线程才会获得执行的机会。下面程序使用yield()方法来让当前正在执行的线程暂停。
1 | package cn.bytecollege.cycle; |
上面程序中的第一行粗体字代码调用yield()静态方法让当前正在执行的线程暂停,让系统线程调度器重新调度。
sleep()方法和yield()方法的区别:
- sleep()方法暂停当前线程后,会给其他线程执行机会,不会理会其他线程的优先级;但yield()方法只会给优先级相同,或优先级更高的线程执行机会。
- sleep()方法会将线程转入阻塞状态,直到经过阻塞时间才会转入就绪状态;而yield()不会将线程转入阻塞状态,它只是强制当前线程进入就绪状态。因此完全有可能某个线程调用yield()方法暂停之后,立即再次获得处理器资源被执行。
- sleep()方法声明抛出了 InterruptedException 异常,所以调用 sleep()方法时要么捕捉该异常,要么显式声明抛出该异常;而yield()方法则没有声明抛出任何异常。
线程优先级
每个线程执行时都具有一定的优先级,优先级高的线程获得较多的执行机会,而优先级低的线程则获得较少的执行机会。每个线程默认的优先级都与创建它的父线程的优先级相同,在默认情况下,main线程具有普通优先级,由main线程创建的子线程也具有普通优先级。
Thread类提供了setPriority(int newPriority)、getPriority()方法来设置和返回指定线程的优先级,其中setPriority()方法的参数可以是一个整数,范围是1~10之间,也可以使用Thread类的如下3个静态常量。
- MAX_PRIORITY:其值是10。
- MIN_PRIORITY:其值是1。
- NORM_PRIORITY:其值是5。
1 | package cn.bytecollege.cycle; |
虽然Java提供了10个优先级级别,但这些优先级级别需要操作系统的支持。不同操作系统上的优先级并不相同,而且也不能很好地和Java的10个优先级对应。在这种情况下,我们应该尽量避免直接为线程指定优先级。
线程的停止
Java语言提供了stop()方法终止正在运行的线程,但是Java将Thread的stop()方法设置为过时,不建议大家使用。为什么呢?在程序中,我们是不能随便中断一个线程的,我们无法知道这个线程正运行在什么状态,它可能持有某把锁,强行中断线程可能导致锁不能释放的问题;或者线程可能在操作数据库,强行中断线程可能导致数据不一致的问题。正是由于调用stop()方法来终止线程可能会产生不可预料的结果,因此不推荐调用stop()方法。
所以,这里介绍一下Thread的interrupt()方法,此方法本质不是用来中断一个线程,而是将线程设置为中断状态。
当我们调用线程的interrupt()方法时,它有两个作用:
如果此线程处于阻塞状态(如调用了Object.wait()方法),就会立马退出阻塞,并抛出InterruptedException异常,线程就可以通过捕获InterruptedException来做一定的处理,然后让线程退出。更确切地说,如果线程被Object.wait()、Thread.join()和Thread.sleep()三种方法之一阻塞,此时调用该线程的interrupt()方法,该线程将抛出一个InterruptedException中断异常(该线程必须事先预备好处理此异常),从而提早终结被阻塞状态。
如果此线程正处于运行之中,线程就不受任何影响,继续运行,仅仅是线程的中断标记被设置为true。所以,程序可以在适当的位置通过调用isInterrupted()方法来查看自己是否被中断,并执行退出操作。
线程中断:
首先,一个线程不应该由其他线程来强制中断或停止,而是应该由线程自己自行停止。所以,Thread.stop, Thread.suspend, Thread.resume 都已经被废弃了。而 Thread.interrupt 的作用其实也不是中断线程, 而是「通知线程应该中断了」,具体到底中断还是继续运行, 应该由被通知的线程自己处理。具体来说,当对一个线程,调用 interrupt() 时,① 如果线程处于被阻塞状态(例如处于sleep, wait, join 等状态),那么线程将立即退出被阻塞状态, 并抛出一个InterruptedException异常。 仅此而已。② 如果线程处于正常活动状态,那么会将该线程的中断标志设置为 true,仅此而已。被设置中断标志的线程将继续正常运行,不受影响。
interrupt() 并不能真正的中断线程, 需要被调用的线程自己进行配合才行。也就是说,一个线程如果有被中断的需求,那么就可以这样做。① 在正常运行任务时,经常检查本线程的中断标志位,如果被设置了中断标志就自行停止线程。② 在调用阻塞方法时正确处理InterruptedException异常。(例如,catch异常后就结束线程。) thread.interrupt();Thread.interrupted()清除标志位是为了下次继续检测标志位。 如果一个线程被设置中断标志后,选择结束线程那么自然不存在下次的问题,而如果一个线程被设置中断标识后,进行了一些处理后选择继续进行任务,而且这个任务也是需要被中断的,那么当然需要清除标志位了。
线程死锁
死锁及死锁产生的条件
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去,如图所示:
在上图中,线程A已经持有了资源2,它同时还想申请资源1,线程B已经持有了资源1,它同时还想申请资源2,所以线程A和线程B就因为相互等待对方已经持有的资源,而进入了死锁状态。
那么为什么会产生死锁呢?死锁的产生必须具备以下四个条件。
- 互斥条件:指线程对已经获取到的资源进行排它性使用,即该资源同时只由一个线程占用。如果此时还有其他线程请求获取该资源,则请求者只能等待,直至占有资源的线程释放该资源。
- 请求并持有条件:指一个线程已经持有了至少一个资源,但又提出了新的资源请求,而新资源已被其他线程占有,所以当前线程会被阻塞,但阻塞的同时并不释放自己已经获取的资源。
- 不可剥夺条件:指线程获取到的资源在自己使用完之前不能被其他线程抢占,只有在自己使用完毕后才由自己释放该资源。
- 环路等待条件:指在发生死锁时,必然存在一个线程—资源的环形链,即线程集合{T0, T1, T2, …, Tn}中的T0正在等待一个T1占用的资源,T1正在等待T2占用的资源,……Tn正在等待已被T0占用的资源。
下面通过示例来演示线程死锁
1 | package cn.bytecollege2; |
输出结果如下:
从结果分析可以得出:
Thread-0是线程A, Thread-1是线程B,代码首先创建了两个资源,并创建了两个线程。从输出结果可以知道,线程调度器先调度了线程A,也就是把CPU资源分配给了线程A,线程A使用synchronized(resourceA)方法获取到了resourceA的监视器锁,然后调用sleep函数休眠1s,休眠1s是为了保证线程A在获取resourceB对应的锁前让线程B抢占到CPU,获取到资源resourceB上的锁。线程A调用sleep方法后线程B会执行synchronized(resourceB)方法,这代表线程B获取到了resourceB对象的监视器锁资源,然后调用sleep方法休眠1s。好了,到了这里线程A获取到了resourceA资源,线程B获取到了resourceB资源。线程A休眠结束后会企图获取resourceB资源,而resourceB资源被线程B所持有,所以线程A会被阻塞而等待。而同时线程B休眠结束后会企图获取resourceA资源,而resourceA资源已经被线程A持有,所以线程A和线程B就陷入了相互等待的状态,也就产生了死锁。下面谈谈本例是如何满足死锁的四个条件的。
首先,resourceA和resourceB都是互斥资源,当线程A调用synchronized(resourceA)方法获取到resourceA上的监视器锁并释放前,线程B再调用synchronized(resourceA)方法尝试获取该资源会被阻塞,只有线程A主动释放该锁,线程B才能获得,这满足了资源互斥条件。
线程A首先通过synchronized(resourceA)方法获取到resourceA上的监视器锁资源,然后通过synchronized(resourceB)方法等待获取resourceB上的监视器锁资源,这就构成了请求并持有条件。
线程A在获取resourceA上的监视器锁资源后,该资源不会被线程B掠夺走,只有线程A自己主动释放resourceA资源时,它才会放弃对该资源的持有权,这构成了资源的不可剥夺条件。
线程A持有objectA资源并等待获取objectB资源,而线程B持有objectB资源并等待objectA资源,这构成了环路等待条件。所以线程A和线程B就进入了死锁状态。
如何避免线程死锁
要想避免死锁,只需要破坏掉至少一个构造死锁的必要条件即可,但是学过操作系统的读者应该都知道,目前只有请求并持有和环路等待条件是可以被破坏的。
造成死锁的原因其实和申请资源的顺序有很大关系,使用资源申请的有序性原则就可以避免死锁,那么什么是资源申请的有序性呢?我们对上面线程B的代码进行如下修改。
1 | Thread threadB = new Thread(()->{ |
输出结果如下:
如上代码让在线程B中获取资源的顺序和在线程A中获取资源的顺序保持一致,其实资源分配有序性就是指,假如线程A和线程B都需要资源1,2,3, …, n时,对资源进行排序,线程A和线程B只有在获取了资源n-1时才能去获取资源n。
可以简单分析一下为何资源的有序分配会避免死锁,比如上面的代码,假如线程A和线程B同时执行到了synchronized(resourceA),只有一个线程可以获取到resourceA上的监视器锁,假如线程A获取到了,那么线程B就会被阻塞而不会再去获取资源B,线程A获取到resourceA的监视器锁后会去申请resourceB的监视器锁资源,这时候线程A是可以获取到的,线程A获取到resourceB资源并使用后会放弃对资源resourceB的持有,然后再释放对resourceA的持有,释放resourceA后线程B才会被从阻塞状态变为激活状态。所以资源的有序性破坏了资源的请求并持有条件和环路等待条件,因此避免了死锁。
JMM模型
JMM(Java Memory Model):Java 内存模型,是 Java 虚拟机规范中所定义的一种内存模型,Java 内存模型是标准化的,屏蔽掉了底层不同计算机的区别。也就是说,JMM 是 JVM 中定义的一种并发编程的底层模型机制。
JMM简介
Java内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存中的变量复制到自己的工作空间或者叫作工作内存,线程读写变量时的操作的是自己工作内存中的变量。
Java线程间修改共享变量的可见性由Java内存模型控制(即JMM),JMM决定以线程对共享变量的写入对另一个线程可见,从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系。
JMM内存模型规定详解如下:
- 所有的共享变量都存储于主内存。这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
- 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
- 线程对变量的所有的操作(读,写)都必须在工作内存中完成,而不能直接读写主内存中的变量。
- 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
JMM是一个抽象的概念,并不真实存在,其抽象模型示意图如下:
从上图可以看出,如果A、B两个线程之间要通信,必须经历以下2个步骤
- 线程A把本次内存中更新过的共享变量刷新到主内存中去。
- 线程B从主内存中读取线程A已经更新过的共享变量。
然而,JMM 这样的规定可能会导致线程对共享变量的修改没有即时更新到主内存,或者线程没能够及时将共享变量的最新值同步到工作内存中,从而使得线程在使用共享变量的值时,该值并不是最新的。
JMM特性
JMM的关键技术点都是围绕着多线程的原子性、可见性和有序性来建立的。因此,我们首先必须了解这些概念。
原子性
原子性是指,一个或者多个操作不可分割,要么全部执行,并且执行过程中不会被任何因素打断,要么就都不执行。Java中可以使用synchronized保证原子性。
可见性
可见性是指当一个线程修改了某一个共享变量的值时,其他线程是否能够立即知道这个修改。显然,对于串行程序来说,可见性问题是不存在的。因为你在任何一个操作步骤中修改了某个变量,在后续的步骤中读取这个变量的值时,读取的一定是修改后的新值。Java中的volatile、synchronized、Lock都能保证可见性。如一个变量被volatile修饰后,表示当一个线程修改共享变量后,其会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。而synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
有序性
对于一个线程的执行代码而言,我们总是习惯性地认为代码是从前往后依次执行的。这么理解也不能说完全错误,因为就一个线程内而言,确实会表现成这样。但是,在并发时,程序的执行可能就会出现乱序。给人的直观感觉就是:写在前面的代码,会在后面执行。听起来有些不可思议,有序性问题的原因是程序在执行时,可能会进行指令重排,重排后的指令与原指令的顺序未必一致。
在单核CPU的场景下,当指令被重排序之后,如何保障运行的正确性呢?其实很简单,编译器和CPU都需要遵守As-if-Serial规则。
As-if-Serial规则的具体内容为:无论如何重排序,都必须保证代码在单线程下运行正确。为了遵守As-if-Serial规则,编译器和CPU不会对存在数据依赖关系的操作进行重排序,因为这种重排序会改变执行结果。但是,如果指令之间不存在数据依赖关系,这些指令可能被编译器和CPU重排序。
下面是一段非常简单的示例代码:
1 | public class ReorderDemo{ |
在示例代码中,③和①之间存在数据依赖关系,同时③和②之间也存在数据依赖关系。因此,在最终执行的指令序列中,③不能被重排序到①和②的前面,因为③排到①和②的前面,程序的结果将会被改变。但①和②之间没有数据依赖关系,编译器和CPU可以重排序①和②之间的执行顺序。
虽然编译器和CPU遵守了As-if-Serial规则,无论如何,也只能在单CPU执行的情况下保证结果正确。在多核CPU并发执行的场景下,由于CPU的一个内核无法清晰分辨其他内核上指令序列中的数据依赖关系,因此可能出现乱序执行,从而导致程序运行结果错误。因此As-if-Serial规则只能保障单内核指令重排序之后的执行结果正确,不能保障多内核以及跨CPU指令重排序之后的执行结果正确。
synchronized关键字
线程安全问题
在多线程编程中经常会出现程序运行结果和预期结果不一致的问题,例如两个线程对初始值为0的同一变量自增,结果不一定是2,也有可能1,这种多个线程对同一个对象中的实例变量进行并发访问时发生,产生的后果就是“脏读”,这就是非线程安全。其根源就是没有保证线程的原子性。
下面通过示例演示该问题:
1 | package cn.bytecollege.lock; |
1 | package cn.bytecollege; |
1 | package cn.bytecollege; |
运行上面的程序发现程序有时候会输出1和2,这是符合预期的,但是如果多运行几次就会发现还会输出2和2,这就出现了不符合预期,也就是线程不同步的情况,为什么会出现这种情况呢,在前面的内容中知道当线程获得资源后会执行run方法中方法体,当分配的资源使用完毕后不管run方法是否执行完都会立即停止然后下一个线程继续执行。
注意:++操作并不是一个原子操作,实际上自增操作中包含了以下3步:
- 读取自增变量的值
- 对变量进行自增
- 将自增的结果保存进变量
在上述代码中可能就会出现如下的执行情况:
- T1时刻线程1读取了变量num值为0,并完成了自增值成为了1
- T2时刻线程1停止运行,由于已经保存自增后的值,线程2读取num的值也为1,并完成了自增操作,num变成了2。
- T3时刻线程2停止运行,线程1打印num值2
- T4时刻线程1停止运行,线程2打印num值2
因此,上述程序有可能打印的并不是1、2,而是2、2。上述过程可以描述如下表:
时刻 | 线程1 | 线程2 |
---|---|---|
T1 | 1.读取num=02.自增 num=13.写入num=1 | |
T2 | 1.读取num=12.自增 num=23.写入num=2 | |
T3 | 1.打印num=2 | |
T4 | 1.打印num=2 |
从执行过程来看,很明显自增操作并没有保证原子性,那么如何解决这种问题呢?在接下来的同步方法和同步代码块中将会深入学习。
同步方法
为了解决多线程环境下访问同一对象的实例变量值不同步的情况,Java的多线程支持引入同步监视器来解决这个问题。一种解决方式就是在方法头中加入synchronized关键字让方法成为同步方法,对于同步方法而言,无须显式指定同步监视器,同步方法的同步监视器是this,也就是该对象本身。
重构上面的程序:
1 | package cn.bytecollege; |
1 | package cn.bytecollege; |
运行上面的程序可以发现无论运行多少次,运行的结果都是符合预期的:打印了1和2,同步监视器可以理解为为synchronized所修饰的方法加锁,当方法被加锁后在多线程环境下一旦线程获得资源(这里的资源是指锁和CPU时间片),那么在此方法执行结束之前,其他线程都不能进入该方法,这也就避免了多个线程访问同一对象的实例变量时出现值不同步的情况。换句话说synchronized可以保证操作的原子性。
同步代码块
除此之外,Java还可以使用同步代码块来解决线程安全问题,同步代码块语法格式如下:
1 | synchronized(obj){ |
使用同步代码块继续重构上面的示例:
1 | package cn.bytecollege; |
1 | package cn.bytecollege; |
运行上面的程序发现运行结果也是符合预期的。需要注意的是,当选取锁时一定要保证该锁在当前运行环境中是唯一的,如果锁失去了唯一性,同步代码块也就不能达到同步的目的。
任何时刻只能有一个线程可以获得对同步监视器的锁定,当同步代码块执行完成后,该线程会释放对该同步监视器的锁定。
任何线程进入同步代码块、同步方法之前,必须先获得对同步监视器的锁定,那么何时会释放对同步监视器的锁定呢?程序无法显式释放对同步监视器的锁定,线程会在如下几种情况下释放对同步监视器的锁定。
- 当前线程的同步方法、同步代码块执行结束,当前线程即释放同步监视器。
- 当前线程在同步代码块、同步方法中遇到break、return终止了该代码块,当前线程将会释放同步监视器。
- 当前线程在同步代码块、同步方法中出现了未处理的Error或Exception,导致了该代码块、该方法异常结束时,当前线程将会释放同步监视器。
- 当前线程执行同步代码块或同步方法时,程序执行了同步监视器对象的wait()方法,则当前线程暂停,并释放同步监视器。
在如下所示的情况下,线程不会释放同步监视器。
- 线程执行同步代码块或同步方法时,程序调用 Thread.sleep()、Thread.yield()方法来暂停当前线程的执行,当前线程不会释放同步监视器。
Synchronized实现原理
当使用了synchronized加锁后,对源代码进行反编译,可以看出源代码中有monitorenter和monitorexit两条指令,每一个对象都有一个monitor,一个monitor只能被一个线程拥有。当一个线程执行到monitorenter指令时会尝试获取相应对象的monitor,获取规则如下:
- 如果monitor的进入数为0,则该线程可以进入monitor,并将monitor进入数设置为1,该线程即为monitor的拥有者。
- 如果当前线程已经拥有该monitor,只是重新进入,则进入monitor的进入数加1,所以synchronized关键字实现的锁是可重入的锁。
- 如果monitor已被其他线程拥有,则当前线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor。
monitorexit
只有拥有相应对象的monitor的线程才能执行monitorexit指令。每执行一次该指令monitor进入数减1,当进入数为0时当前线程释放monitor,此时其他阻塞的线程将可以尝试获取该monitor。
synchronized的内存语义
前面介绍了共享变量内存可见性问题主要是由于线程的工作内存导致的,下面我们来讲解synchronized的内存语义,这个内存语义就可以解决共享变量内存可见性问题。
- 进入synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工作内存中清除,这样在synchronized块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取。
- 退出synchronized块的内存语义是把在synchronized块内对共享变量的修改刷新到主内存。
其实这也是加锁和释放锁的语义,当获取锁后会清空锁块内本地内存中将会被用到的共享变量,在使用这些共享变量时从主内存进行加载,在释放锁时将本地内存中修改的共享变量刷新到主内存。除可以解决共享变量内存可见性问题外,synchronized经常被用来实现原子性操作。另外请注意,synchronized关键字会引起线程上下文切换并带来线程调度开销。
同步锁
从Java 5开始,Java提供了一种功能更强大的线程同步机制——通过显式定义同步锁对象来实现同步,在这种机制下,同步锁使用Lock对象充当。
Lock接口的主要抽象方法如下:
从Lock提供的接口方法可以看出,显式锁至少比Java内置锁多了以下优势:
方法 | 说明 |
---|---|
lock() | 抢占锁,如果抢占成功则向下运行,如果失败则阻塞抢锁线程 |
void lockInterruptibly() throws InterruptedException | 可中断抢锁,当前线程在抢锁的过程中可以响应中断信号。 |
boolean tryLock() | 尝试抢锁,线程为非阻塞模式,在调用tryLock()方法后立即返回。如果抢锁成功则返回true,如果抢锁失败则返回false |
boolean tryLock(long time,TimeUnit unit)throws InterruptedException | 限时抢锁,到达超时时间返回false,并且此限时抢锁方法也可以响应中断信号 |
void unlock() | 释放锁 |
Condition newCondition() | 获取与显示锁绑定的Condition对象,用于“等待—通知”方式的线程间通信。 |
与synchronized关键字不同,显式锁不再作为Java内置特性来实现,而是作为Java语言可编程特性来实现。这就为多种不同功能的锁实现留下了空间,各种锁实现可能有不同的调度算法、性能特性或者锁定语义。
从Lock提供的接口方法可以看出,显式锁至少比Java内置锁多了以下优势:
1.可中断获取锁
使用synchronized关键字获取锁的时候,如果线程没有获取到被阻塞,阻塞期间该线程是不响应中断信号(interrupt)的;而调用Lock.lockInterruptibly()方法获取锁时,如果线程被中断,线程将抛出中断异常。
2.可非阻塞获取锁
使用synchronized关键字获取锁时,如果没有成功获取,线程只有被阻塞;而调用Lock.tryLock()方法获取锁时,如果没有获取成功,线程也不会被阻塞,而是直接返回false。
3.可限时抢锁
调用Lock.tryLock(long time,TimeUnit unit)方法,显式锁可以设置限定抢占锁的超时时间。而在使用synchronized关键字获取锁时,如果不能抢到锁,线程只能无限制阻塞。
因此可以看出Lock提供了比synchronized方法和synchronized代码块更广泛的锁定操作,Lock实现允许更灵活的结构,可以具有差别很大的属性,并且支持多个相关的Condition对象。
Lock是控制多个线程对共享资源进行访问的工具。通常,锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。
可重入锁
ReentrantLock是JUC包提供的显式锁的一个基础实现类,ReentrantLock类实现了Lock接口,它拥有与synchronized相同的并发性和内存语义,但是拥有了限时抢占、可中断抢占等一些高级锁特性。
在实现线程安全的控制中,比较常用的是ReentrantLock(可重入锁)。使用该Lock对象可以显式地加锁、释放锁。
ReentrantLock是一个可重入的独占(或互斥)锁,其中两个修饰词的含义为:
- 可重入的含义:表示该锁能够支持一个线程对资源的重复加锁,也就是说,一个线程可以多次进入同一个锁所同步的临界区代码块。比如,同一线程在外层函数获得锁后,在内层函数能再次获取该锁,甚至多次抢占到同一把锁。
- 独占的含义:在同一时刻只能有一个线程获取到锁,而其他获取锁的线程只能等待,只有拥有锁的线程释放了锁后,其他的线程才能够获取锁。
1 | package cn.bytecollege.lock; |
1 | package cn.bytecollege.lock; |
使用ReentrantLock对象来进行同步,加锁和释放锁出现在不同的作用范围内时,通常建议使用 finally 块来确保在必要时释放锁。
1 | package cn.bytecollege.lock; |
ReentrantLock锁具有可重入性,也就是说,一个线程可以对已被加锁的ReentrantLock锁再次加锁,ReentrantLock对象会维持一个计数器来追踪lock()方法的嵌套调用,线程在每次调用lock()加锁后,必须显式调用unlock()来释放锁,所以一段被锁保护的代码可以调用另一个被相同锁保护的方法。
volatile关键字
在多线程并发编程中synchronized和volatile都扮演着重要的角色,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。
volatile的作用
上面介绍了使用synchronized的方式可以解决共享变量内存可见性问题,但是使用锁太笨重,因为它会带来线程上下文的切换开销。对于解决内存可见性问题,Java还提供了一种弱形式的同步,也就是使用volatile关键字。该关键字可以确保对一个变量的更新对其他线程马上可见。当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。
1 | public class VolitaleDemo { |
在上例VolatileDemo中新建了内部类MyTest,并在其中定义了变量num,此时num没有做内存可见性处理。
在main方法中新建了线程t1,调用了MyTest中的change()方法,修改num的值为60。并在main方法中检测num是否还是0。
运行程序可以发现,程序一直没有停止,也没有输出最后的end,这说明t1修改了共享变量num的值后,对main线程是不可见了。
修改代码,将变量使用volatile修饰,再次运行程序可发现程序检测到num值被修改为了60。并且输出了end。
但是需要注意的是volatile只能保证可见性和有序性。并不能保证原子性。
那么一般在什么时候才使用volatile关键字呢?
- 写入变量值不依赖变量的当前值时。因为如果依赖当前值,将是获取—计算—写入三步操作,这三步操作不是原子性的,而volatile不保证原子性。
- 读写变量值时没有加锁。因为加锁本身已经保证了内存可见性,这时候不需要把变量声明为volatile的。
指令重排序
volatile的另一作用是禁止指令重排序,也就是保证有序性,那么什么是指令重排序呢?
指令重排序是指在执行程序时,编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
重排序分3种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术,来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序。
上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序可能会导致多线程程序出现内存可见性问题。
对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
下面,先通过示例来学习指令重排序:
1 | int a = 0; |
单看上面的程序好像没有问题,最后 i 的值是 1。但是为了提高性能,编译器和处理器常常会在不改变数据依赖的情况下对指令做重排序。假设线程 A 在执行时被重排序成先执行代码 2,再执行代码 1;而线程 B 在线程 A 执行完代码 2 后,读取了 flag 变量。
由于条件判断为真,线程 B 将读取变量 a。此时,变量 a 还根本没有被线程 A 写入,那么 i 最后的值是 0,导致执行结果不正确。那么如何程序执行结果正确呢?这里仍然可以使用 volatile 关键字。
这个例子中, 使用 volatile 不仅保证了变量的内存可见性,还禁止了指令的重排序,即保证了 volatile 修饰的变量编译后的顺序与程序的执行顺序一样。那么使用 volatile 修饰 flag 变量后,在线程 A 中,保证了代码 1 的执行顺序一定在代码 2 之前。
对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
内存屏障
内存屏障又称内存栅栏(Memory Fences),是一系列的CPU指令,它的作用主要是保证特定操作的执行顺序,保障并发执行的有序性。在编译器和CPU都进行指令的重排优化时,可以通过在指令间插入一个内存屏障指令,告诉编译器和CPU,禁止在内存屏障指令前(或后)执行指令重排序。
由于不同CPU硬件实现内存屏障的方式不同,JMM屏蔽了这种底层CPU硬件平台的差异,定义了不对应任何CPU的JMM逻辑层内存屏障,由JVM在不同的硬件平台生成对应的内存屏障机器码。
JMM内存屏障主要有Load和Store两类,具体如下:
- Load Barrier(读屏障):在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主存加载数据。
- Store Barrier(写屏障):在写指令之后插入写屏障,能让写入缓存的最新数据写回主存。
- 在实际使用时,会对以上JMM的Load Barrier和Store Barrier两类屏障进行组合,组合成LoadLoad(LL)、StoreStore(SS)、LoadStore(LS)、StoreLoad(SL)四个屏障,用于禁止特定类型的CPU重排序。
LoadLoad(LL)屏障
在执行预加载(或支持乱序处理)的指令序列中,通常需要显式地声明LoadLoad屏障,因为这些Load指令可能会依赖其他CPU执行的Load指令的结果。
一段使用LoadLoad(LL)屏障的伪代码示例如下:
1 | Load1;LoadLoad;Load2; |
该示例的含义为:在Load2要读取的数据被访问前,使用LoadLoad屏障保证Load1要读取的数据被读取完毕。
StoreStore(SS)屏障
通常情况下,如果CPU不能保证从高速缓冲向主存(或其他CPU)按顺序刷新数据,那么它需要使用StoreStore屏障。
一段使用StoreStore(SS)屏障的伪代码示例如下:
1 | Store1;StoreStore;Store2; |
该示例的含义为:在Store2及后续写入操作执行前,使StoreStore屏障保证Store1的写入结果对其他CPU可见。
LoadStore(LS)屏障
该屏障用于在数据写入操作执行前确保完成数据的读取。一段使用LoadStore(LS)屏障的伪代码示例如下:
1 | Load1;LoadStore;Store2; |
该示例的含义为:在Store2及后续写入操作执行前,使LoadStore屏障保证Load1要读取的数据被读取完毕。
(SL)屏障
该屏障用于在数据读取操作执行前,确保完成数据的写入。使用StoreLoad(SL屏障)屏障的伪代码示例如下:
1 | Store1;StoreLoad;Load2; |
该示例的含义为:在Load2及后续所有读取操作执行前,使StoreLoad屏障保证Store1的写入对所有CPU可见。StoreLoad(SL)屏障的开销是4种屏障中最大的,但是此屏障是一个“全能型”的屏障,兼具其他3个屏障的效果,现代的多核CPU大多支持该屏障。
线程通信
当线程在系统内运行时,线程的调度具有一定的透明性,程序通常无法准确控制线程的轮换执行,但我们可以通过一些机制来保证线程协调运行。例如A、B两个线程,当A线程在工作时B线程等待,当A线程运行结束时通知B线程,B线程继续执行。
等待/通知机制
方法wait()的作用是使当前执行代码的线程进行等待,wait()方法是Object类的方法,该方法用来将当前线程置入“预执行队列”中,并且在wait()所在的代码行处停止执行,直到接到通知或被中断为止。在调用wait()之前,线程必须获得该对象的对象级别锁,即只能在同步方法或同步块中调用wait()方法。在执行wait()方法后,当前线程释放锁。在从wait()返回前,线程与其他线程竞争重新获得锁。如果调用wait()时没有持有适当的锁,则抛出IllegalMonitorStateException,它是RuntimeException的一个子类,因此,不需要try-catch语句进行捕捉异常。
方法notify()也要在同步方法或同步块中调用,即在调用前,线程也必须获得该对象的对象级别锁。如果调用notify()时没有持有适当的锁,也会抛出IllegalMonitorStateException。该方法用来通知那些可能等待该对象的对象锁的其他线程,如果有多个线程等待,则由线程规划器随机挑选出其中一个呈wait状态的线程,对其发出通知notify,并使它等待获取该对象的对象锁。需要说明的是,在执行notify()方法后,当前线程不会马上释放该对象锁,呈wait状态的线程也并不能马上获取该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出synchronized代码块后,当前线程才会释放锁,而呈wait状态所在的线程才可以获取该对象锁。当第一个获得了该对象锁的wait线程运行完毕以后,它会释放掉该对象锁,此时如果该对象没有再次使用notify语句,则即便该对象已经空闲,其他wait状态等待的线程由于没有得到该对象的通知,还会继续阻塞在wait状态,直到这个对象发出一个notify或notifyAll。
notifyAll()方法可以使所有正在等待队列中等待同一共享资源的“全部”线程从等待状态退出,进入可运行状态。此时,优先级最高的那个线程最先执行,但也有可能是随机执行,因为这要取决于JVM虚拟机的实现。
用一句话来总结一下wait和notify:wait使线程停止运行,而notify使停止的线程继续运行。
下面,先来通过示例学习如果不在同步块中使用的情况:
1 | package cn.bytecollege.notify; |
运行结果如下:
假设现在有2个线程,两个线程共享同一个实例变量,一个线程对变量加1,另一个线程对变量减1,并输出结果:
1 | package cn.bytecollege.notify; |
1 | package cn.bytecollege.notify; |
1 | package cn.bytecollege.notify; |
运行结果如下图:
DCL之单例模式(饿汉模式、懒汉模式、双重检查模式)
饿汉模式
谓的“饿汉”是因为程序刚启动时就创建了实例,通俗点说就是刚上菜,大家还没有开始吃的时候就先自己吃一口。
1 | public class Singleton { |
第3行 通过一个私有构造方法限制了创建此类对象的途径(反射忽略)。这种方法很安全,但从某种程度上有点浪费资源,比方说从一开始就创建了Singleton实例,但很少去用它,这就造成了方法区资源的浪费,因此出现了另外一种单例模式,即懒汉单例模式
懒汉模式
之所以叫“懒汉”是因为只有真正叫它的时候,才会出现,不叫它它就不理,跟它没关系。也就是说真正用到它的时候才去创建实例,并不是一开始就创建实例。如下代码所示:
1 | public class Singleton { |
看似很简单的一段代码,但存在一个问题,就是线程不安全的问题。例如,现在有1000个线程,都需要这一个Singleton的实例,验证一下是否拿到同一个实例,代码如下所示:
1 | public class Singleton { |
部分运行结果,乱七八糟:
944436457、1638599176、710946821、67862359
为什么会这样?第一个线程过来了,执行到第7行,睡了1ms,正在睡的同时第二个线程来了,第二个线程执行到第5行时,结果肯定为空,因此接下来将会有两个线程各自创建一个对象,这必然会导致Singleton.getInstance().hashCode()结果不一致。可以通过给整个方法加上一把锁改进如下:
通过给getInstance()方法加上synchronized来解决线程一致性问题,结果分析虽然显示所有实例的hashcode都一致,但是synchronized的粒度太大了,即锁的临界区太大了,有点影响效率,例如如果第4行和第5行之间有业务处理逻辑,不会涉及共享变量,那么每次对这部分业务逻辑加锁必然会导致效率低下。为了解决粗粒度的问题,可以对代码进一步改进:通过分析运行结果发现,虽然锁的粒度变小了,但线程不安全了。为什么会这样呢?因为有种情况,线程1执行完if判断后还没有拿到锁的时候时间片用完了,此时线程2来了,执行if判断时发现对象还是空的,继续往下执行,很顺利的拿到锁了,因此线程2创建了一个对象,当线程2创建完之后释放掉锁,这时线程1激活了,顺利的拿到锁,又创建了一个对象。所以代码还需要再一步的改进。
就是需要考虑指令重排序的问题,因此得加入volatile来禁止指令重排序
1 | public class Singleton { |
通过在第10行又加了一层if判断,也就是所谓的Double Check Lock。也就是说即便拿到锁了,也得去作一步判断,如果这时判断对像不为空,那么就不用再创建对象,直接返回就可以了,很好的解决了“改进2”中的问题。但这里第8行是不是可以去了,我个人觉得都行,保留第8行的话,是为了提升效率,因为如果去了,每个线程过来就直接抢锁,抢锁本身就会影响效率,而if判断就几ns,且大部分线程是不需要抢锁的,所以最好保留。
生产者/消费者问题
在多线程中有一个经典的问题:生产者/消费者问题,当生产者生产好产品,通知消费者消费。当消费者消费产品后再通知生产者生产产品,如果在生产者在生产前发现已经有生产好的产品,则不生产,先等待消费者消费,同样,当消费者消费产品时发现生产者没有生产好产品,则等待生产者先生产。
生产者-消费者问题不仅仅是一个多线程同步问题的经典案例,而且业内已经将解决该问题的方案抽象成了一种设计模式——“生产者-消费者”模式。“生产者-消费者”模式是一个经典的多线程设计模式,它为多线程间的协作提供了良好的解决方案。
这个问题就可以通过线程间通信来解决。
1 | package cn.bytecollege.consumer; |
1 | package cn.bytecollege.consumer; |
1 | package cn.bytecollege.consumer; |
1 | package cn.bytecollege.consumer; |
基于显式锁进行“等待——通知”方式的线程间通信
在前面介绍Java的线程间通信机制时,基于Java内置锁实现一种简单的“等待-通知”方式的线程间通信:通过Object对象的wait、notify两类方法作为开关信号,用来完成通知方线程和等待方线程之间的通信。
“等待-通知”方式的线程间通信机制,具体来说是指一个线程A调用了同步对象的wait()方法进入等待状态,而另一线程B调用了同步对象的notify()或者notifyAll()方法去唤醒等待线程,当线程A收到线程B的唤醒通知后,就可以重新开始执行了。
需要特别注意的是,在通信过程中,线程需要拥有同步对象的监视器,在执行Object对象的wait、notify方法之前,线程必须先通过抢占到内置锁而成为其监视器的持有者。
与Object对象的wait、notify两类方法相类似,基于Lock显式锁,JUC也为大家提供了一个用于线程间进行“等待-通知”方式通信的接口——java.util.concurrent.locks.Condition。
Condition接口主要方法
1 | public interface Condition { |
为了避免与Object中的wait/notify/notifyAll方法在使用时发生混淆,JUC对Condition接口的方法改变了名称,同样的wait/notify/notifyAll方法,在Condition接口中名称被改为await/signal/signalAll方法。
Condition的“等待-通知”方法和Object的“等待-通知”方法的语义等效关系为:
- Condition类的await方法和Object类的wait方法等效。
- Condition类的signal方法和Object类的notify方法等效。
- Condition类的signalAll方法和Object类的notifyAll方法等效。
Condition对象的signal(通知)方法和同一个对象的await(等待)方法是一一配对使用的,也就是说,一个Condition对象的signal(或signalAll)方法不能去唤醒其他Condition对象上的await线程。
Condition对象是基于显式锁的,所以不能独立创建一个Condition对象,而是需要借助于显式锁实例去获取其绑定的Condition对象。不过,每一个Lock显式锁实例都可以有任意数量的Condition对象。不过,每一个Lock显式锁实例都可以有任意数量的Condition对象。具体来说,可以通过lock.newCondition()方法去获取一个与当前显式锁绑定的Condition实例,然后通过该Condition实例进行“等待-通知”方式的线程间通信。
下面通过示例来学习Condition的用法:
1 | public class ConditionDemo { |
线程池
系统启动一个新线程的成本是比较高的,因为它涉及与操作系统交互。在这种情形下,使用线程池可以很好地提高性能,尤其是当程序中需要创建大量生存期很短暂的线程时,更应该考虑使用线程池。
线程池在系统启动时即创建大量空闲的线程,程序将一个Runnable对象或Callable对象传给线程池,线程池就会启动一个线程来执行它们的run()或call()方法,当run()或call()方法执行结束后,该线程并不会死亡,而是再次返回线程池中成为空闲状态,等待执行下一个Runnable对象的run()或call()方法。
除此之外,使用线程池可以有效地控制系统中并发线程的数量,当系统中包含大量并发线程时,会导致系统性能剧烈下降,甚至导致JVM崩溃,而线程池的最大线程数参数可以控制系统中并发线程数不超过此数。
线程池相关类定义在java.util.concurrent包中,也就是常说的JUC包,首先我们了解一下线程池相关的类和接口,其架构图如下:
1.Executor
Executor是Java异步目标任务的“执行者”接口,其目标是执行目标任务。“执行者”Executor提供了execute()接口来执行已提交的Runnable执行目标实例。
2.ExecutorService
ExecutorService继承于Executor。它是Java异步目标任务的“执行者服务接口”,对外提供异步任务的接收服务。ExecutorService提供了“接收异步任务并转交给执行者”的方法,如submit系列方法、invoke系列方法等。
3.AbstractExecutorService
AbstractExecutorService是一个抽象类,它实现了ExecutorService接口。AbstractExecutorService存在的目的是为ExecutorService中的接口提供默认实现。
4.ThreadPoolExecutor
ThreadPoolExecutor是JUC线程池的核心实现类。线程的创建和终止需要很大的开销,线程池中预先提供了指定数量的可重用线程,所以使用线程池会节省系统资源,并且每个线程池都维护了一些基础的数据统计,方便线程的管理和监控。
5.ScheduledExecutorService
ScheduledExecutorService是一个接口,它继承于ExecutorService。它是一个可以完成“延时”和“周期性”任务的调度线程池接口。
6.ScheduledThreadPoolExecutor
ScheduledThreadPoolExecutor继承于ThreadPoolExecutor,它提供了ScheduledExecutorService线程池接口中“延时执行”和“周期执行”等抽象调度方法的具体实现。
7.Executors
Executors是一个静态工厂类,它通过静态工厂方法返回ExecutorService、ScheduledExecutorService等线程池示例对象,这些静态工厂方法可以理解为一些快捷的创建线程池的方法。
线程池参数
Java为开发者提供了ThreadPoolExecutor用于创建线程池,先查看一下该类的构造方法:
1 | public ThreadPoolExecutor(int corePoolSize, |
从源代码可以看出构造方法包含了7个参数,这7个参数的含义如下:
- corePoolSize:线程池中会维护一个最小的线程数量,即使这些线程处理空闲状态,他们也不会被销毁,除非设置了allowCoreThreadTimeOut。这里的最小线程数量即是corePoolSize。
- maximumPoolSize:一个任务被提交到线程池以后,首先会找有没有空闲存活线程,如果有则直接将任务交给这个空闲线程来执行,如果没有则会缓存到工作队列中,如果工作队列满了,才会创建一个新线程,然后从工作队列的头部取出一个任务交由新线程来处理,而将刚提交的任务放入工作队列尾部。线程池不会无限制的去创建新线程,它会有一个最大线程数量的限制,这个数量即由maximunPoolSize指定。
- keepAliveTime:空闲线程存活时间,一个线程如果处于空闲状态,并且当前的线程数量大于corePoolSize,那么在指定时间后,这个空闲线程会被销毁,这里的指定时间由keepAliveTime来设定。
- unit:空闲线程存活时间单位
- workQueue:新任务被提交后,会先进入到此工作队列中,任务调度时再从队列中取出任务,Java中提供了4中工作队列:
- ArrayBlockingQueue:基于数组的有界阻塞队列,按FIFO排序。新任务进来后,会放到该队列的队尾,有界的数组可以防止资源耗尽问题。当线程池中线程数量达到corePoolSize后,再有新任务进来,则会将任务放入该队列的队尾,等待被调度。如果队列已经是满的,则创建一个新线程,如果线程数量已经达到maxPoolSize,则会执行拒绝策略。
- LinkedBlockingQueue:基于链表的无界阻塞队列(其实最大容量为Interger.MAX_VALUE),按照FIFO排序。由于该队列的近似无界性,当线程池中线程数量达到corePoolSize后,再有新任务进来,会一直存入该队列,而不会去创建新线程到maxPoolSize,因此使用该工作队列时,参数maxPoolSize其实是不起作用的。有两个快捷创建线程池的工厂方法Executors.newSingleThreadExecutor和Executors.newFixedThreadPool使用了这个队列,并且都没有设置容量(无界队列)。
- SynchronousQueue:一个不缓存任务的阻塞队列,生产者放入一个任务必须等到消费者取出这个任务。也就是说新任务进来时,不会缓存,而是直接被调度执行该任务,如果没有可用线程,则创建新线程,如果线程数量达到maxPoolSize,则执行拒绝策略。快捷工厂方法Executors.newCachedThreadPool所创建的线程池使用此队列。
- PriorityBlockingQueue:具有优先级的无界阻塞队列,优先级通过参数Comparator实现。
- threadFactory:线程工厂,创建一个新线程时使用的工厂,可以用来设定线程名、是否为daemon线程等等
- handler 拒绝策略:当工作队列中的任务已到达最大限制,并且线程池中的线程数量也达到最大限制,这时如果有新任务提交进来,该如何处理呢。这里的拒绝策略,就是解决这个问题的,java中提供了4中拒绝策略:
- CallerRunsPolicy:该策略下,在调用者线程中直接执行被拒绝任务的run方法,除非线程池已经shutdown,则直接抛弃任务。
- AbortPolicy:该策略下,直接丢弃任务,并抛出RejectedExecutionException异常。该策略是线程池默认的拒绝策略。
- DiscardPolicy:该策略下,直接丢弃任务,并且不会抛出任何异常。
- DiscardOldestPolicy:该策略下,抛弃进入队列最早的那个任务,然后尝试把这次拒绝的任务放入队列。
通过上述参数的描述可以梳理线程池的工作流程如下图所示:
在使用线程池的过程中需要注意一下两点:
- 核心和最大线程数量、BlockingQueue队列等参数如果配置得不合理,可能会造成异步任务得不到预期的并发执行,造成严重的排队等待现象。
- 线程池的调度器创建线程的一条重要的规则是:在corePoolSize已满之后,还需要等阻塞队列已满,才会去创建新的线程。
常用线程池
在Java 5以前,开发者必须手动实现自己的线程池;从Java 5开始,Java内建支持线程池。Java 5新增了一个Executors工厂类来产生线程池,该工厂类包含如下几个静态工厂方法来创建线程池。
newCachedThreadPool()
创建一个具有缓存功能的线程池,系统根据需要创建线程,这些线程将会被缓存在线程池中。“可缓存线程池”的特点大致如下:
- 在接收新的异步任务target执行目标实例时,如果池内所有线程繁忙,此线程池就会添加新线程来处理任务。
- 此线程池不会对线程池大小进行限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小。
- 如果部分线程空闲,也就是存量线程的数量超过了处理任务数量,就会回收空闲(60秒不执行任务)线程。
“可缓存线程池”的适用场景:需要快速处理突发性强、耗时较短的任务场景,如Netty的NIO处理场景、REST API接口的瞬时削峰场景。“可缓存线程池”的线程数量不固定,只要有空闲线程就会被回收;接收到的新异步任务执行目标,查看是否有线程处于空闲状态,如果没有就直接创建新的线程。
newFixedThreadPool(int nThreads)
该方法用于创建一个“固定数量的线程池”,其唯一的参数用于设置池中线程的“固定数量”。
“固定数量的线程池”的特点大致如下:
- 如果线程数没有达到“固定数量”,每次提交一个任务线程池内就创建一个新线程,直到线程达到线程池固定的数量。
- 线程池的大小一旦达到“固定数量”就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
- 在接收异步任务的执行目标实例时,如果池中的所有线程均在繁忙状态,新任务会进入阻塞队列中(无界的阻塞队列)。
“固定数量的线程池”的适用场景:需要任务长期执行的场景。“固定数量的线程池”的线程数能够比较稳定地保证一个数,能够避免频繁回收线程和创建线程,故适用于处理CPU密集型的任务,在CPU被工作线程长时间占用的情况下,能确保尽可能少地分配线程。
“固定数量的线程池”的弊端:内部使用无界队列来存放排队任务,当大量任务超过线程池最大容量需要处理时,队列无限增大,使服务器资源迅速耗尽。
newSingleThreadExecutor()
该方法用于创建一个“单线程化线程池”,也就是只有一个线程的线程池,所创建的线程池用唯一的工作线程来执行任务,使用此方法创建的线程池能保证所有任务按照指定顺序(如FIFO)执行。
从以上输出中可以看出,该线程池有以下特点:
- 单线程化的线程池中的任务是按照提交的次序顺序执行的。
- 池中的唯一线程的存活时间是无限的。
- 当池中的唯一线程正繁忙时,新提交的任务实例会进入内部的阻塞队列中,并且其阻塞队列是无界的。
总体来说,单线程化的线程池所适用的场景是:任务按照提交次序,一个任务一个任务地逐个执行的场景。
newScheduledThreadPool(int corePoolSize)
该方法用于创建一个“可调度线程池”,即一个提供“延时”和“周期性”任务调度功能的ScheduledExecutorService类型的线程池。
上面4个方法中的前3个方法返回一个ExecutorService对象,该对象代表一个线程池,它可以执行 Runnable 对象或 Callable 对象所代表的线程;而后1个方法返回一个ScheduledExecutorService线程池,它是ExecutorService的子类,它可以在指定延迟后执行线程任务。
ExecutorService代表尽快执行线程的线程池(只要线程池中有空闲线程,就立即执行线程任务),程序只要将一个Runnable对象或Callable对象(代表线程任务)提交给该线程池,该线程池就会尽快执行该任务。ExecutorService里提供了如下3个方法。
- Future<?> submit(Runnable task):将一个Runnable对象提交给指定的线程池,线程池将在有空闲线程时执行Runnable对象代表的任务。其中Future对象代表Runnable任务的返回值——但run()方法没有返回值,所以Future对象将在run()方法执行结束后返回null。但可以调用Future的isDone()、isCancelled()方法来获得Runnable对象的执行状态。
- Future submit(Runnable task,T result):将一个Runnable对象提交给指定的线程池,线程池将在有空闲线程时执行Runnable对象代表的任务。其中result显式指定线程执行结束后的返回值,所以Future对象将在run()方法执行结束后返回result。
- <T> Future<T> submit(Callable<T> task):将一个Callable对象提交给指定的线程池,线程池将在有空闲线程时执行Callable对象代表的任务。其中Future代表Callable对象里call()方法的返回值。
ScheduledExecutorService 代表可在指定延迟后或周期性地执行线程任务的线程池,它提供了如下4个方法。
- ScheduledFuture<V> schedule(Callable<V> callable, long delay,TimeUnit unit):指定callable任务将在delay延迟后执行。
- ScheduledFuture<?> schedule(Runnable command, long delay,TimeUnit unit):指定command任务将在delay延迟后执行。
- ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period,TimeUnit unit):指定command任务将在delay延迟后执行,而且以设定频率重复执行。也就是说,在 initialDelay 后开始执行,依次在 initialDelay+period、initialDelay+2*period…处重复执行,依此类推。
- ScheduledFuture<?> scheduleWithFixedDelay(Runnable command,long initialDelay, long delay,TimeUnit unit):创建并执行一个在给定初始延迟后首次启用的定期操作,随后在每一次执行终止和下一次执行开始之间都存在给定的延迟。如果任务在任一次执行时遇到异常,就会取消后续执行;否则,只能通过程序来显式取消或终止该任务。
当用完一个线程池后,应该调用该线程池的shutdown()方法,该方法将启动线程池的关闭序列,调用shutdown()方法后的线程池不再接收新任务,但会将以前所有已提交任务执行完成。当线程池中的所有任务都执行完成后,池中的所有线程都会死亡;另外也可以调用线程池的shutdownNow()方法来关闭线程池,该方法试图停止所有正在执行的活动任务,暂停处理正在等待的任务,并返回等待执行的任务列表。
使用线程池来执行线程任务的步骤如下。
- 调用Executors类的静态工厂方法创建一个ExecutorService对象,该对象代表一个线程池。
- 创建Runnable实现类或Callable实现类的实例,作为线程执行任务。
- 调用ExecutorService对象的submit()方法来提交Runnable实例或Callable实例。
- 当不想提交任何任务时,调用ExecutorService对象的shutdown()方法来关闭线程池。
下面通过示例来学习线程池的简单用法:
1 | package cn.bytecollege.lock; |
1 | package cn.bytecollege.lock; |
在上面的示例中创建了3个线程,以及一个固定线程个数为2的线程池,将3个线程提交给线程池运行,从结果中可以发现thread3并没有执行,这是因为线程池中只有2个线程,任务数多于线程数,线程池执行了拒绝策略。
线程池的状态
一般情况下,线程池启动后建议手动关闭。在介绍线程池的关闭之前,我们先了解一下线程池的状态。线程池总共存在5种状态,定义在ThreadPoolExecutor类中,具体代码如下:
1 | private static final int RUNNING = -1 << COUNT_BITS; |
线程池的5种状态具体如下:
- RUNNING:线程池创建之后的初始状态,这种状态下可以执行任务。
- SHUTDOWN:该状态下线程池不再接受新任务,但是会将工作队列中的任务执行完毕。
- STOP:该状态下线程池不再接受新任务,也不会处理工作队列中的剩余任务,并且将会中断所有工作线程。
- TIDYING:该状态下所有任务都已终止或者处理完成,将会执行terminated()钩子方法。
- TERMINATED:执行完terminated()钩子方法之后的状态。
线程池的状态转换规则为:
- 线程池创建之后状态为RUNNING。
- 执行线程池的shutdown()实例方法,会使线程池状态从RUNNING转变为SHUTDOWN。
- 执行线程池的shutdownNow()实例方法,会使线程池状态从RUNNING转变为STOP。
- 当线程池处于SHUTDOWN状态时,执行其shutdownNow()方法会将其状态转变为STOP。
- 等待线程池的所有工作线程停止,工作队列清空之后,线程池状态会从STOP转变为TIDYING。
- 执行完terminated()钩子方法之后,线程池状态TIDYING转变为TERMINATED。
线程池状态转换规则如下图:
关闭线程池主要涉及以下3个方法:
shutdown:是JUC提供的一个有序关闭线程池的方法,此方法会等待当前工作队列中的剩余任务全部执行完成之后,才会执行关闭,但是此方法被调用之后线程池的状态转为SHUTDOWN,线程池不会再接收新的任务。
shutdownNow:是JUC提供的一个立即关闭线程池的方法,此方法会打断正在执行的工作线程,并且会清空当前工作队列中的剩余任务,返回的是尚未执行的任务。
awaitTermination:等待线程池完成关闭。在调用线程池的shutdown()与shutdownNow()方法时,当前线程会立即返回,不会一直等待直到线程池完成关闭。如果需要等到线程池关闭完成,可以调awaitTermination()方法。
ThreadLocal
在Java的多线程并发执行过程中,为了保证多个线程对变量的安全访问,可以将变量放到ThreadLocal类型的对象中,使变量在每个线程中都有独立值,不会出现一个线程读取变量时被另一个线程修改的现象。ThreadLocal类通常被翻译为“线程本地变量”类或者“线程局部变量”类。
ThreadLocal基本使用
ThreadLocal位于JDK的java.lang核心包中。如果程序创建了一个ThreadLocal实例,那么在访问这个变量的值时,每个线程都会拥有一个独立的、自己的本地值。“线程本地变量”可以看成专属于线程的变量,不受其他线程干扰,保存着线程的专属数据。
ThreadLocal类比较简单主要的方法只有以下4个:
- set(T value):该方法用于设置“线程本地变量”在当前线程的ThreadLocalMap中对应的值。
- get():该方法用于获取“线程本地变量”在当前线程的ThreadLocalMap中对应的值,相当于获取线程本地值。
- remove():该方法用于当前线程的ThreadLocalMap中移除“线程本地变量”所对应的值。
- initialValue():该方法是当“线程本地变量”在当前线程的ThreadLocalMap中尚未绑定值时,initialValue()方法用于获取初始值。如果没有调用set()直接调用get(),就会调用该方法,但是该方法只会被调用一次。默认情况下,initialValue()方法返回null,如果不想返回null,可以继承ThreadLocal以覆盖此方法。
下面先通过两个示例来学习ThreadLocal的基本使用。
1 | public class ThreadLocalDemo { |
上例中创建了5个线程,每个线程从ThreadLocal中获取了一个num的副本分别进行自增,可以看出最后的结果5个线程分别输出了最终值k=6,而num的值还是1,换句话说,每个线程都复制了一份num并对属于自己的num进行自增而不影响num。