Java线程机制学习
前面的文章中总结过Java中用来解决共享资源竞争导致线程不安全的几种常用方式:
- synchronized;
- ReentrantLock;
- ThreadLocal;
这些都是在简单介绍了基本用法的基础上再侧重于对底层原理的探讨,由于这些知识点涉及到方方面面,短时间之内完全弄懂并非易事。而写博客的初衷其实是驱动自己在学习的过程中及时总结,用自己的语言再将所学复述一遍以强化对知识的理解程度。所以在这篇文章里,我会从Java中最基本的一些并发概念开始,到Java的基本线程机制,梳理一个相对完整的基础知识脉络,尽量让知识形成体系。所谓勿以浮沙筑高台,如是说。
本文会从如下几个方面来阐述:
1. 关于并发
虽然编程问题中相当大的一部分都可以通过使用顺序编程来解决,但是由于cpu的运算速度比计算机系统中存储及通信等子系统的速度要快几个量级,相对而言在计算过程中,大部分时间会花费在磁盘I/O、网络通信上面,这样处理器在大部分时间里面就都需要等待其他资源,为了不浪费处理器的强大计算能力,让计算机“同时”处理几项任务则是简单而有效的一个“压榨”手段。
除了充分利用cpu的计算能力,在后端开发中,服务端往往也需要同时对多个客户端提供服务,这是一个更具体的并发应用场景。衡量一个服务性能的好坏,每秒事物处理数(Transactions Per Second,TPS)是一个重要指标,代表着一秒内服务端平均能响应的请求总数,而TPS值与程序的并发能力又有非常密切的关系,程序并发协调得越有条不紊,效率自然越高;反之,线程之间频繁阻塞甚至死锁,则会大大降低程序的并发能力。
Java支持多线程编程,而且服务端是其最擅长的领域之一,不过对于如何写好并发应用程序却又是服务端开发的难点之一。学习并发编程就像进入了一个全新的领域,如果你花点儿工夫,就能明白其基本机制,但要想真正地掌握它的实质,就需要深入的学习和理解。
说到并发,需要和并行进行区别:
- 所谓并发,其实是按顺序执行的,cpu在任一时间只执行一个线程,通过给不同线程分配时间段的形式来进行调度,只是看起来好像多个任务是同时执行的;
- 并行,就是多个任务同时在进行着的;
2. 基本线程机制
并发编程使我们可以将程序划分为多个分离的、独立运行的任务。通过使用多线程机制,这些独立任务(也被称为子任务)中的每一个都将通过执行线程来驱动。一个线程就是在进程中的一个单一的顺序控制流,单个进程可以拥有多个并发执行的任务。
线程模型为编程带来了便利,它简化了在单一程序中同时交织在一起的多个操作的处理。在使用线程时,CPU将轮流给每个任务分配其占用的时间。每个任务都觉得自己在一直占用CPU,但事实上CPU时间是划分成片段分配给了所有的任务(例外情况是程序确实运行在多个CPU之上)。线程的一大好处是可以使你从这个层次抽身出来,即代码不必知道它是运行在具有一个还是多个CPU的机器上,所以,使用线程机制是一种建立透明的、可扩展的程序的方法。多任务和多线程往往是使用多处理器系统的最合理方式。
在JDK1.2之后,Java中的线程模型是基于操作系统原生线程模型来实现,但这和Java程序的编码来说是没有影响的。因为Java语言提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个已经执行start()且还未结束的java.lang.Thread类的实例就代表了一个线程。
我们可以通过三种传统的方式来通过线程驱动任务:
- new一个Thread类,并重写run方法(也可以通过匿名类的方式);
- 实现Runnable接口,传入Thread的构造器中;
- 直接在main函数中new一个实现了Runnable接口的类,实例化,直接调用其run方法,其实是由main线程来驱动的;
通过一个例子来体会一下:
public class DefineRunnable {
// 获取一个线程唯一标识 public static AtomicInteger a = new AtomicInteger(); public static int getThreadId() { return a.getAndIncrement(); } public static void main(String[] args) { // 驱动任务,方式1,通过重写Thread中run方法,直接由Thread类驱动 new Thread() { @Override public void run() { System.out.println("lightsOff ! doing-Thread: " + DefineRunnable.getThreadId()); } }.start(); // 驱动任务,方式2,通过将实现了Runnable的类作为构造参数传入Thread的构造器中,通过Thread类来驱动 new Thread(new LightsOff()).start(); // 驱动任务,方式3,通过主线程直接驱动Runnable任务 LightsOff lightsOff = new LightsOff(); lightsOff.run(); } } class LightsOff implements Runnable{ @Override public void run() { System.out.println("lightsOff ! doing-Thread: " + DefineRunnable.getThreadId()); } } /** * 输出 **/ lightsOff ! doing-Thread: 0 lightsOff ! doing-Thread: 1 lightsOff ! doing-Thread: 2
如上是一些基本的驱动任务的方式,当然还有更好的方式,通过交给线程池处理,这在后面会专门撰文详述。
调用Thread对象的start()方法为线程执行必需的初始化操作,然后会自动去调用Runnable的run()方法。调用start()方法之后会迅速返回,即使run()方法没有执行完,这是因为run()是由不同的线程执行的,你仍旧可以执行main中的其他后续操作,程序会同时执行多个方法,main()和多个Runnable中的run()方法。这一点可能会让初次接触线程Thread这一概念的同学觉得莫名困惑,至少我当年就困惑过。
当我们将任务交给线程来驱动之后,任务是否被执行则要取决于线程调度器的调度了。虽然Java的线程调度是由系统自动完成的,但我们还是可以“建议”系统给某些线程多分配一点执行时间或少一点,这项操作可以通过设置线程优先级来完成。Java中一共设置了10个线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY),在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。
但是,线程优先级并不是很靠谱,前面也说到过,Java的线程是通过映射到操作系统的原生线程上来实现的,所以线程调度最终取决于操作系统,不同操作系统的优先级概念是不同的。所以,我们不能在程序中通过优先级来完全准确地判断一组状态都为Ready的线程将会先执行哪一个。
3. 线程状态
Java语言定义了5种线程状态,在任一时间点,一个线程只能有且只有其中的一种状态,分别是新建、运行、等待、阻塞、结束。
3.1 新建(New)
创建后尚未启动的线程就处于这种状态。
3.2 运行(Runable)
Runable包括了操作系统线程状态中的 Running和 Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着CPU为它分配执行时间。
3.3 无限期等待(Waiting)
处于这种状态的线程不会被分配CPU执行时间,它们要等待被其他线程显式地唤醒。以下方法会让线程陷入无限期的等待状态:
- 没有设置 Timeout参数的Object.wait()方法;
- 没有设置 Timeout参数的Thread.join()方法;
- LockSupport park()方法;
3.4 有限期等待(Timed Waiting)
处于这种状态的线程也不会被分配CPU执行时间,不过无须等待被其他线程显式地唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:
- Thread.sleep()方法;
- 设置了Timeout参数的Object.wait()方法;
- 设置了Timeout参数的Thread.join()方法;
- LockSupport.parkNanos()方法;
- LockSupport.parkUntil()方法;
3.5 阻塞(Blocked)
线程被阻塞了,“阻塞状态”在等待着获取到一个排他锁(synchronized中获取的monitor),这个事件将在另外一个线程放弃这个锁的时候发生,在程序等待进入同步区域的时候,线程将进入这种状态。
3.6 结束(Terminated)
已终止线程的线程状态,线程已经结束执行。
上述5种状态在遇到特定事件发生的时候会互相转换,他们的转换关系如下图:
4. 线程常用方法
在线程运行的过程中,我们需要通过各种方式来操纵线程(比如暂停,中断线程)或者协调多个线程(比如通知别的线程)。常用的方式有sleep、join、yield、wait、notify/notifyAll。
4.1 休眠(sleep)
调用某个线程的sleep()方法可以使其休眠给定的时间。
sleep()方法不会释放“锁标志”,也就是说如果有synchronized同步块,其他线程仍然不能访问共享数据。而join()方法会释放"锁标志"。
4.2 加入一个线程(join)
一个线程可以在其他线程之上调用join()方法,其效果是等待一段时间直到第另一个线程结束才继续执行。如果线程A在另一个线程B上调用B.join(),则线程A将被挂起,直到目标线程B结束才恢复(即B.isAlive()返回为假)。
也可以在调用join()时带上一个超时参数(单位可以是毫秒,或者毫秒和纳秒),这样如果目标线程在这段时间到期时还没有结束的话, join方法总能返回。对join()方法的调用可以被中断,做法是在调用线程上调用interrupt方法,这时需要用到try- -catch子句,与sleep类似。
class Sleeper extends Thread{ private int duration; public Sleeper(String name,int sleepTime){ super(name); duration = sleepTime; start(); } public void run(){ try{ sleep(duration); }catch(InterruptedException e){ System.out.println(getName() + " was interrupted. " + "isInterrupted(): " + isInterrupted()); } System.out.println(getName() + " has awakened"); } } class Joiner extends Thread{ private Sleeper sleeper; public Joiner(String name,Sleeper sleeper){ super(name); this.sleeper = sleeper; start(); } public void run(){ try{ sleeper.join(); }catch(InterruptedException e){ System.out.println("Interrupted"); } System.out.println(getName() + " join completed"); } } public class Joining{ public static void main(String[] args){ Sleeper sleepy = new Sleeper("Sleepy",1500); Sleeper grumpy = new Sleeper("Grumpy",1500); Joiner dopey = new Joiner("Dopey",sleepy); Joiner doc = new Joiner("Doc",grumpy); grumpy.interrupt(); } } /** * 输出结果 **/ Grumpywas interrupted. isInterrupted(): false Grumpy has awakened Doc join completed Sleepy has awakened Dopey join completed
在上面的demo中,主线程会启动4个子线程,分别是sleepy、grumpy、doc、dopey。
- sleepy和grumpy启动之后会进入休眠状态,doc和dopey启动之后会调用相应sleep和grumpy的join方法,意味着要等sleepy执行完才会再执行dopey,doc也一样;
- 这时主线程调用grumpy的interrupt()方法,因为grumpy处于休眠状态所以抛出InterruptedException异常;
4.3 让步(yield)
这是Thread类的一个静态方法,当在线程中调用这个方法之后,当前线程将放弃cpu使用,进入ready状态,等待系统重新调度,有可能会重新进入running状态也有可能不会,相当于给其他线程一个机会了。
如果知道已经完成了在run()方法的循环的一次迭代过程中所需的工作,就可以给线程调度机制一个暗示:你的工作已经做得差不多了,可以让别的线程使用CPU了。这个暗示将通过调用 yield方法来作出(不过这只是一个暗示,没有任何机制保证它将会被采纳)。当调用yield()时,你也是在建议具有相同优先级的其他线程可以运行。所以,对于任何重要的控制或在调整应用时,都不能依赖于yield()。
4.4 wait、notify/notifyAll
这三个方法比较特殊,它们不属于Thread类,而是定义在Object中的,虽然不在Thread中,但是又和线程相关。这三个方法的调用方式是通过同步对象锁来调用的,而且必须在同步块中调用。
- wait表示阻塞,调用此方法时当前线程会阻塞,同时释放对象锁;
- notify、notifyAll表示通知,调用该方法之后会释放一个或多个因等待同步锁而阻塞的线程,被释放的线程会去竞争同步锁(synchronized),获取锁了才会继续执行,否则还是处于阻塞状态;
public class ThreadDemo{ static String content; static String LOCK = "lock"; public static void main(String[] args){ new Thread(){ @Override public void run(){ synchronized(LOCK){ content = "hello world"; LOCK.notifyAll(); } } }.start(); synchronized(LOCK){ while(content == null){ try{ LOCK.wait(); }catch(InterruptedException e){ e.printStackTrace(); } System.out.println(content.toUpperCase()); } } } } // 输出 HELLO WORLD
如上面的例子中所示,主线程会启动一个子线程,主线程会判断成员变量content为null时则调用LOCK的wait进入无限等待,然后释放同步锁,子线程获取到锁之后,给content赋值,然后通过调用LOCK的notifyAll()来通知主线程,使得主线程可以解除等待状态,进入到阻塞状态,当子线程执行完毕之后会释放锁,这时主线会获取锁然后继续执行,输出大写的hello world。
5. 线程中断
线程中断仅仅是置线程的中断状态位,并不会停止线程(至于如何停止,本文后面会详述)。支持线程中断的方法(也就是线程中断后会抛出interruptedException的方法)就是在监视线程的中断状态,比如sleep、join等,一旦线程的中断状态被置为“中断状态”,就会抛出中断异常,并且将中断标志重新置为false。所以在Java中设置线程的中断状态位并不会产生对线程运行的实际影响,而是通过监视线程的状态位并做相应处理,或者通过抛出中断异常(InterruptedException)来通知用户进行处理。
和线程中断状态位有直接关系的方法主要有:interrupt()、interrupted()、isInterrupted(),其使用介绍如下:
5.1 interrupt()
interrupt()是Thread的实例方法,用于中断线程。调用该方法的线程的状态为将被置为"中断"状态。
5.2 interrupted()
interrupted()方法为Thread的静态方法,该方法就是直接调用当前线程的isInterrupted(true)的方法,是作用于当前线程,并且会重置当前线程的中断状态。
public static boolean interrupted(){ return currentThread().isInterrupted(true); }
5.3 isInterrupted()
isInterrupted()方法是Thread的实例方法,是作用于调用该方法的线程对象所对应的线程,是直接调用对应线程的isInterrupted(false)的方法,不会重置对应线程的中断状态。
public boolean isInterrupted () { return isInterrupted( false); }
为了更清楚其中的区别,我自己写了一个例子:
public class InterruptTest { public static void main(String[] args) throws InterruptedException { Thread threadA = new Thread(new LightsOff()); threadA.start(); System.out.println("ThreadA isInterruptd --> " + threadA.isInterrupted()); Thread.sleep(500); threadA.interrupt(); System.out.println("ThreadA isInterruptd --> " + threadA.isInterrupted()); Thread.sleep(100); System.out.println("ThreadA isInterruptd --> " + threadA.isInterrupted()); } static class LightsOff implements Runnable{ @Override public void run() { System.out.println("ThreadA start"); while(!Thread.currentThread().isInterrupted()) { } System.out.println("ThreadA continue"); System.out.println("threadA is interrupted? --> " + Thread.interrupted()); } } }
输出结果为:
ThreadA isInterruptd --> false ThreadA start ThreadA continue ThreadA isInterruptd --> true threadA is interrupted? --> true ThreadA isInterruptd --> false
我们看一下整个过程:
- 首先主线程启动线程A;
- 主线程这时候通过实例对象threadA的isInterrupted()获取线程A的中断状态标志位,此时为默认的false;
- 主线程休眠500ms;
- 线程A启动后输出ThreadA start,然后进入while循环,只要线程的中断标志位为false,则一直循环;
- 主线程休眠结束后,调用threadA的interrupted方法,设置线程A的中断状态标志位为true,此时主线程获取线程A的中断标志位为true;
- 线程A跳出循环,输出Thread continue,然后调用线程的静态方法interrrupted,返回true,并且将线程A的中断标志复原为false;
- 主线程休眠100ms,确保线程A已经调用了interrupted方法,此时获取到线程A的中断标志位为false;
6. 终止线程
当调用线程的start方法之后,线程会开始驱动任务,当任务执行完毕之后(也就是run方法执行结束)线程将终止,但是如果因为线程阻塞或者线程长时间执行而不能结束,所以我们希望能够通过某种途径可以终止线程以达到想要的效果,常用的方式有两种:中断、检查中断。
6.1 中断
Thread类包含interrupt()方法,因此你可以终止被阻塞的任务,这个方法将设置线程的中断状态。如果一个线程已经被阻塞,或者试图执行一个阻塞操作,那么设置这个线程的中断状态将导致线程抛出InterruptedException。当抛出该异常或者该任务调用Thread.interrupted()时,中断状态将被复位。
因为这种方式是在任务的run()方法中间打断,更像是抛出的异常,所以在Java线程的中断中用到了异常。而为了在以这种方式终止任务时,返回众所周知的良好状态,必须仔细考虑代码的执行路径,并仔细编写catch子句以正确清除所有事物。
如何调用interrupt?
-
为了调用interrupt(),你必须持有Thread对象。
-
如果你在Executor上调用shutdownNow(),那么它将发送一个interrupt()调用给它启动的所有线程。
-
如果希望只中断某个单一任务,那么可以通过调用submit()而不是executor()来启动任务,就可以持有该任务的上下文。submit()将返回一个泛型Future<?>,持有这种Future的关键在于你可以在其上调用cancel(),并因此可以使用它来中断某个特定任务。如果你将true传递给cancel(),那么它就会拥有在该线程上调用interrupt()以停止这个线程的权限,因此,cancel()是一种中断由Executor启动的单个线程的方式。
对于互斥导致阻塞的中断:
- 在ReentrantLock上阻塞的任务具备可以被中断的能力(即interrupt()可以打断被ReentrantLock互斥所阻塞的调用),而在synchronized方法或临界区上阻塞的任务则不能被中断;
- 不能中断正在试图获取synchronized锁或者试图执行I/O操作的线程;
6.2 检查中断
当你在线程上调用interrupt()时,中断发生的唯一时刻是在任务要进入到阻塞操作中,或者已经在阻塞操作内部时。但是如果根据程序运行的环境,你已经编写了可能会产生这种阻塞调用的代码,那又该怎么办呢?如果你只能通过在阻塞调用上抛出异常来退出,那么你就无法总是可以离开run()循环。因为如果你调用interrupt()以停止某个任务,那么在run循环碰巧没有产生任何阻塞调用的情况下这种方式就不起作用了,需要另一种方式来退出。Thread.interrupted()提供了离开run()循环而不抛出异常的第二种方式。
这种机会是由中断状态来表示的,其状态可以通过调用interrupt()来设置。你可以通过调用interrupted()来检查中断状态,这不仅可以告诉你interrupt()是否被调用过,而且还可以清除中断状态。清除中断状态可以确保并发结构不会就某个任务被中断这个问题通知你两次,你可以经由单一的InterruptedException或单一的成功的Thread.interrupted()测试来得到这种通知。如果想要再次检查以了解是否被中断,则可以在调用Thread.interrupted()时将结果存储起来。
下面的示例展示了典型的惯用法,你应该在run()方法中使用它来处理在中断状态被设置时,被阻塞和不被阻塞的各种可能:
class NeedsCleanup{ private final int id; public NeedsCleanup(int ident){ id = ident; System.out.println("NeedsCleanup " + id); } public void cleanup(){ System.out.println("Cleaning up " + id); } } class Blocked implements Runnable{ private volatile double d = 0.0; public void run(){ try{
// 第2中方式,检查中断的方式 while(!Thread.interrupted()){ // point1 NeedsCleanup n1 = new NeedsCleanup(1); try{ System.out.println("Sleeping"); TimeUnit.SECONDS.sleep(1); // point2 NeedsCleanup n2 = new NeedsCleanup(2); try{ System.out.println("Calculating"); // 复杂浮点运算,耗时但是不会导致阻塞 for(int i = 1; i<2500000; i++) d = d + (Math.PI + Math.E)/d; System.out.println("Finished time-consuming operation"); }finally{
// 保证即使被中断结束了, 依然能够完成n2清理工作 n2.cleanup(); } }finally{
// 保证即使被中断结束了,依然能够完成n1的清理工作 n1.cleanup(); } } System.out.println("Exiting via while() test"); }catch(InterruptedException e){ System.out.println("Exiting via InterruptedException"); } } } public class InterruptingIdiom{ public static void main(String[] args)throws Exception{ if(args.length != 1){ System.out.println("usage: java InterruptingIdiom delay-in-mS"); System.exit(1); } Thread t = new Thread(new Blocked()); t.start(); TimeUnit.MILLISECONDS.sleep(new Integer(args[0]));
// 第1中方式,直接中断 t.interrupt(); } } /** 输出 NeedsCleanup 1 Sleeping NeedsCleanup 2 Calculating Finished time-consuming operation Cleaning up 2 Cleaning up 1 NeedsCleanup 1 Sleeping Cleaning up 1 Exiting via InterruptedException */
如上演示了两种中断线程的方法:
- 在主线程中,经过一段时间的休眠之后,调用线程t的interrupt()方法将其中断,此为中断;
- 在线程t的run()方法中,将所有逻辑都放在一个while循环中,判断条间就是Thread.isInterrupted()的返回值,即使线程t没有进入阻塞状态,但是每一次循环都会检查中断状态,一旦发现中断状态被设置则会退出循环,此为检查中断;
7. 总结
- 关于并发,是为了充分利用cpu的计算能力而产生的;
- Java中的多线程机制是将程序划分为多个分离的、独立的运行任务,每个任务靠单独线程来驱动;
- Java中对线程定义了5种状态:新建、运行、等待、阻塞、结束;
- 线程常用到的方法:sleep、join、yield、wait、notify/notifyAll;
- 线程中断:interrupt()、interrupted()、isInterrupted();
- 终止线程有2种常用的方式:中断、检查中断;
本文重点在最基础的Java线程机制,虽然这部分比较基础,也正是因为如此,往往容易被忽视。但是基础不代表不重要,本文的很多概念还是费了一点时间来搞懂的,如果有不对的地方还请指正,如果你觉得对你有帮助的话,请点个赞吧 ^_^ !