文章目录
声明:
本博客是本人在学习《实战 Java 高并发程序设计》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。
本博客已标明出处,如有侵权请告知,马上删除。
我们已经探讨为什么必须面对并行程序这样复杂的程序设计方法,那么下面就需要静下心来,认真研究如何才能构建一个正确、健壮并且高效的并行系统。本章将详细介绍有关 Java 并行程序的设计基础,以及一些常见的问题,希望对读者有所帮助。
2.1 有关线程你必须知道的事
在介绍线程前,我们还是先了解一下线程的 “母亲” ——进程。如果你有读过操作系统的课程,那你对进程一定不会陌生。在这种专业级的书籍中,应该会给出一些 “官方” 的解释,比如像下面这样描述:
进程(Process) 是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
不过我不想把这种严谨且抽象的描述介绍给大家。用一句简单的话来说,你在 windows 中,看到的后缀为 .exe 的文件,都是一个程序。不过程序是死的,静态的。当你双击这个 .exe 执行的时候,这个 .exe 文件中的指令就会被加载,那么你就能得到一个有关这个 .exe 程序的一个进程。进程是 “活” 的,或者说是正在被执行的。图 2.1 使用任务管理器,显示了当前系统中的进程。
进程中可以容纳若干个线程。它们并不是看不见、摸不着的,也可以使用工具看到它们,如图 2.2 所示。
那线程和进程之间究竟是一种什么样的关系呢?简单地说,进程是一个容器。比如一间漂亮的小别墅。别墅里有电视、厨房、书房、洗手间等。当然,还有一家三口住在里面。当妈妈带女儿外出游玩时,爸爸一人在家。这时爸爸一个人在家里爱上哪里去哪里、爱干嘛干嘛,这时,爸爸就像一个线程(这个进程中只有一个活动线程),小别墅就像一个进程,家里的电视、厨房、书房就像这个进程占有的资源。当到三个人住在一起时(相当于三个线程),有时候可能就会有些小冲突,比如,当女儿占着电视机看动画片时,爸爸就不能看体育频道了,这就是线程间的资源竞争。当然,大部分时候,线程之间还是协作关系(如果我们创建线程是用来打架的,那创建它干嘛呢?)。比如,妈妈在厨房为爸爸和女儿做饭,爸爸在书房工作赚钱养家糊口,女儿在写作业,各司其职,那么这个家就是其乐融融了,相对的,这个进程也就在健康地执行。
用稍微专业点的术语说,线程就是轻量级进程,是程序执行的最小单位。使用多线程而不是用多进程去进行并发程序的设计,是因为线程间的切换和调度的成本远远小于进程。
接下来让我们更细致地观察一个线程的生命周期。我们可以绘制一张简单的状态图来描述这个概念,如图 2.3 所示。
线程的状态都在 Thread 中的 State 枚举中定义,如下所示:
- NEW 状态表示刚刚创建的线程,这种线程还没开始执行。
- 等到线程的 start() 方法调用时,才表示线程开始执行。当线程执行时,处于 RUNNABLE 状态,表示线程所需的一切资源都已经准备好了。
- 如果线程在执行过程中遇到了 synchronized 同步块,就会进入 BLOCKED 阻塞状态,这时线程就会暂停执行,直到获得请求的锁。
- WAITING 和 TIMED_WAITING 都表示等待状态,它们的区别是 WAITING 会进入一个无时间限制的等待,TIMED_WAITING 会进行一个有时限的等待。那等待的线程究竟在等什么呢?一般来说,WAITING 的线程正是在等待一些特殊的事件。比如,通过 wait() 方法等待的线程在等待 notify() 方法,而通过 join() 方法等待的线程则会等待目标线程的终止。一旦等到了期望的事件,线程就会再次执行,进入 RUNNABLE 状态。
- 当线程执行完毕后,则进入 TERMINATED 状态,表示结束。
注意: 从 NEW 状态出发后,线程不能再回到 NEW 状态,同理,处于 TERMINATED 的线程也不能再回到 RUNNABLE 状态。
2.2 初始线程:线程的基本操作
进行 Java 并发设计的第一步,就是必须要了解 Java 中为线程操作所提供的一些 API。比如,如何新建并且启动线程,如何终止线程、中断线程等。当然了,因为并行操作要比串行操作复杂得多,于是,围绕着这些常用接口,可能有些比较隐晦的 “坑” 等着你去踩。而本节也将尽可能地将一些潜在问题描述清楚。
2.2.1 新建线程
新建线程很简单。只要使用 new 关键字创建一个线程对象,并且将它 start() 起来即可。
那线程 start() 后,会干什么呢?这才是问题的关键。线程 Thread,有一个 run() 方法,start() 方法就会新建一个线程并让这个线程执行 run() 方法。
这里要注意,下面的代码也能通过编译,也能正常执行。但是,却不能新建一个线程,而是在当前线程中调用 run() 方法,只是作为一个普通的方法调用。
因此,在这里希望大家特别注意,调用 start() 方法和直接调用 run() 方法的区别。
注意: 不要用 run() 来开启新线程。它只会在当前线程中,串行执行 run() 中的代码。
默认情况下,Thread 的 run() 方法什么都没有做,因此,这个线程一启动就马上结束了。如果你想让线程做点什么,就必须重载 run() 方法,把你的 “任务” 填进去。
上述代码使用匿名内部类,重载了 run() 方法,并要求线程在执行时打印 “Hello,I am t1” 的字样。如果没有特别的需要,都可以通过继承 Thread,重载 run() 方法来自定义线程。但考虑到 Java 是单继承的,也就是说继承本身也是一种很宝贵的资源,因此,我们也可以使用 Runnable 接口来实现同样的操作。Runnable 接口是一个单方法接口,它只有一个 run() 方法:
此外,Thread 类有一个非常重要的构造方法:
它传入一个 Runnable 接口的实例,在 start() 方法调用时,新的线程就会执行 Runnable.run() 方法。实际上,默认的 Thread.run() 就是这么做的:
注意: 默认的 Thread.run() 就是直接调用内部的 Runnable 接口。因此,使用 Runnable 接口告诉线程该做什么,更为合理。
上述代码实现了 Runnable 接口,并将该实例传入 Thread。这样避免重载 Thread.run() ,单纯使用接口来定义 Thread,也是最常用的做法。
2.2.2 终止线程
一般来说,线程在执行完毕后就会结束,无须手工关闭。但是,凡事也都有例外。一些服务端的后台线程可能会常驻系统,它们通常不会正常终结。比如,它们的执行体本身就是一个大大的无穷循环,用于提供某些服务。
那如何正常的关闭一个线程呢?查阅 JDK,你不难发现 Thread 提供了一个 stop() 方法。如果你使用 stop() 方法,就可以立即将一个线程终止,非常方便。但如果你使用的是 eclipse 之类的 IDE 写代码的话,就会立即发现 stop() 方法是一个被标注为废弃的方法。也就是说,在将来,JDK 可能就会移除该方法。
为什么 stop() 被废弃而不推荐使用呢?原因是 stop() 方法太过于暴力,强行把执行到一半的线程终止,可能会引起一些数据不一致的问题。
为了让大家更好地理解本节内容,我先简单介绍一些有关数据不一致的概念。假设我们在数据库里维护着一张用户表,里面记录了用户 ID 和用户名。假设,这里有两条记录:
如果我们用一个 User 对象去保存这些记录,我们总是希望这个对象要么保存记录 1,要么保存记录 2。如果这个 User 对象一半存着记录 1,另外一半存在记录 2,我想大部分人都会抓狂吧!如果现在真的由于程序问题,出现了这么一个怪异的对象 u,u 的 ID 是 1,但是 u 的 Name 是小王。那么,我们说,在这种情况下,数据就已经不一致了。说白了就是系统有错误了。这种情况是相当危险的,如果我们把一个不一致的数据直接写入了数据库,那么就会造成数据永久地被破坏和丢失,后果不堪设想。
也许有人会问,怎么可能呢?跑得好好的系统,怎么会出这种问题呢?在单线程环境中,确实不会,但在并行程序中,如果考虑不周,就有可能出现类似的情况。不经思考地使用 stop() 就有可能导致这种问题。
Thread. stop() 方法在结束线程时,会直接终止线程,并且会立即释放这个线程所持有的锁。而这些锁恰恰是用来维持对象一致性的。如果此时,写线程写入数据正写到一半,并强行终止,那么对象就会被写坏,同时,由于锁已经被释放,另外一个等待该锁的读线程就顺理成章的读到了这个不一致的对象,悲剧也就此发生。整个过程如图 2.4 所示。
首先,对象 u 持有 ID 和 NAME 两个字段,简单起见,这里假设当 ID 等于 NAME 时表示对象是一致的,否则表示对象出错。写线程总是会将 ID 和 NAME 写成相同的值,并且在这里初始值都为 0。当写线程在写对象时,读线程由于无法获得锁,因此必须等待,所以读线程是看不见一个写了一半的对象的。当写线程写完 ID 后,很不幸地被 stop() ,此时对象 u 的 ID 为 1 而 NAME 仍然为 0,处于不一致状态。而被终止的写线程简单地将锁释放,读线程争夺到锁后,读取数据,于是,读到了 ID=1 而 NAME=0 的错误值。
这个过程可以用以下代码模拟,这里读线程 ReadObjectThread 在读到对象的 ID 和 NAME 不一致时,会输出这些对象。而写线程 ChangeObjectThread 总是会写入两个相同的值。注意,代码在第 56 行会通过 stop() 方法强行终止写线程。
执行以上代码,可以很容易得到类似如下输出,ID 和 NAME 产生了不一致。
如果在线上环境跑出以上结果,那么加班加点估计是免不了了,因为这类问题一旦出现,就很难排查,因为它们甚至没有任何错误信息,也没有线程堆栈。这种情况一旦混杂在动则十几万行的程序代码中时,发现它们就全凭经验、时间还有一点点运气了。因此,除非你很清楚你在做什么,否则不要随便使用 stop() 方法来停止一个线程。
那如果需要停止一个线程时,应该这么做呢?其实方法很简单,只是需要由我们自行决定线程何时退出就可以了。仍然用本例说明,只需要将 ChangeObjectThread 线程增加一个 stopMe() 方法即可。如下所示:
代码第 2 行,定义了一个标记变量 stopme,用于指示线程是否需要退出。当 stopMe() 方法被调用,stopme 就被设置为 true,此时,在代码第 10 行检测到这个改动时,线程就自然退出了。使用这种方式退出线程,不会使对象 u 的状态出现错误。因为,ChangeObjectThread 已经没有机会 “写坏” 对象了,它总是会选择在一个合适的时间终止线程。
2.2.3 线程中断
在 Java 中,线程中断是一种重要的线程协作机制。从表面上理解,中断就是让目标线程停止执行的意思,实际上并非完全如此。在上一节中,我们已经详细讨论了 stop() 方法停止线程的害处,并且使用了一套自有的机制完善线程退出的功能。那在 JDK 中是否有提供更强大的支持呢?答案是肯定的,那就是线程中断。
严格地讲,线程中断并不会使线程立即退出,而是给线程发送一个通知,告知目标线程,有人希望你退出啦!至于目标线程接到通知后如何处理,则完全由目标线程自行决定。这点很重要,如果中断后,线程立即无条件退出,我们就又会遇到 stop() 方法的老问题。
与线程中断有关的,有三个方法,这三个方法看起来很像,所以可能会引起混淆和误用,希望大家注意。
Thread.interrupt() 方法是一个实例方法。它通知目标线程中断,也就是设置中断标志位。中断标志位表示当前线程已经被中断了。Thread.isInterrupted() 方法也是实例方法,它判断当前线程是否有被中断(通过检查中断标志位)。最后的静态方法 Thread.interrupted() 也是用来判断当前线程的中断状态,但同时会清除当前线程的中断标志位状态。
下面这段代码对 t1 线程进行了中断,那么中断后,t1 会停止执行吗?
在这里,虽然对 t1 进行了中断,但是在 t1 中并没有中断处理的逻辑,因此,即使 t1 线程被置上了中断状态,但是这个中断不会发生任何作用。
如果希望 t1 在中断后退出,就必须为它增加相应的中断处理代码:
上述代码的加粗部分使用 Thread.isInterrupted() 函数判断当前线程是否被中断了,如果是,则退出循环体,结束线程。这看起来与前面增加 stopme 标记的手法非常相似,但是中断的功能更为强劲。比如,如果在循环体中,出现了类似于 wait() 或者 sleep() 这样的操作,则只能通过中断来识别了。
下面,先来了解一下 Thread.sleep() 函数,它的签名如下:
Thread.sleep() 方法会让当前线程休眠若干时间,它会抛出一个 InterruptedException 中断异常。InterruptedException 不是运行时异常,也就是说程序必须捕获并且处理它,当线程在 sleep() 休眠时,如果被中断,这个异常就会产生。
注意上述代码中第 10~15 行加粗部分,如果在第 11 行代码处,线程被中断,则程序会抛出异常,并进入第 13 行处理。在 catch 子句部分,由于已经捕获了中断,我们可以立即退出线程。但在这里,我们并没有这么做,因为也许在这段代码中,我们还必须进行后续的处理,保证数据的一致性和完整性,因此,执行了 Thread.interrupt() 方法再次中断自己,置上中断标记位。只有这么做,在第 6 行的中断检查中,才能发现当前线程已经被中断了。
注意: Thread.sleep() 方法由于中断而抛出异常,此时,它会清除中断标记,如果不加处理,那么在下一次循环开始时,就无法捕获这个中断,故在异常处理中,再次设置中断标记位。
2.2.4 等待(wait)和通知(notify)
为了支持多线程之间的协作,JDK 提供了两个非常重要的接口线程等待 wait() 方法和通知 notify() 方法。这两个方法并不是在 Thread 类中的,而是输出 Object 类。这也意味着任何对象都可以调用这两个方法。
这两个方法的签名如下:
当在一个对象实例上调用 wait() 方法后,当前线程就会在这个对象上等待。这是什么意思呢?比如,线程 A 中,调用了 obj.wait() 方法,那么线程 A 就会停止继续执行,而转为等待状态。等待到何时结束呢?线程 A 会一直等到其他线程调用了 obj.notify() 方法为止。这时,obj 对象就俨然成为多个线程之间的有效通信手段。
那 wait() 和 notify() 究竟是如何工作的呢?图 2.5 展示了两者的工作过程。如果一个线程调用了object.wait() ,那么它就会进入 object 对象的等待队列。这个等待队列中,可能会有多个线程,因为系统运行多个线程同时等待某一个对象。当 object.notify() 被调用时,它就会从这个等待队列中,随机选择一个线程,并将其唤醒。这里希望大家注意的是,这个选择是不公平的,并不是先等待的线程会优先被选择,这个选择完全是随机的。
除了 notify() 方法外,Object 对象还有一个类似的 notifyAll() 方法,它和 notify() 的功能基本一致,但不同的是,它会唤醒在这个等待队列中所有等待的线程,而不是随机选择一个。
这里还需要强调一点,Object.wait() 方法并不是可以随便调用的。它必须包含在对应的 synchronzied 语句中,无论是 wait() 或者 notify() 都需要首先获得目标对象的一个监视器。如图 2.6 所示,显示了 wait() 和 notify() 的工作流程细节。其中 T1 和 T2 表示两个线程。T1 在正确执行 wait() 方法前,首先必须获得 object 对象的监视器。而 wait() 方法在执行后,会释放这个监视器。这样做的目的是使得其他等待在 object 对象上的线程不至于因为 T1 的休眠而全部无法正常执行。
线程 T2 在 notify() 调用前,也必须获得 object的监视器。所幸,此时 T1 已经释放了这个监视器。因此,T2 可以顺利获得 object 的监视器。接着,T2 执行了 notify() 方法尝试唤醒一个等待线程,这里假设唤醒了 T1。T1 在被唤醒后,要做的第一件事并不是执行后续的代码,而是要尝试重新获得 object 的监视器,而这个监视器也正是 T1在 wait() 方法执行前所持有的那个。如果暂时无法获得,T1 还必须要等待这个监视器。当监视器顺利获得后,T1 才可以真正意义上的继续执行。
为了方便大家理解,这里给出一个简单地使用 wait() 和 notify() 的案例:
上述代码中,开启了两个线程 T1 和 T2。T1 执行了 object.wait() 方法。注意,在程序第 6 行,执行 wait() 方法前,T1 先申请 object 的对象锁。因此,在执行 object.wait() 时,它是持有 object 的锁的。wait() 方法执行后,T1 会进行等待,并释放 object 的锁。T2 在执行 notify() 之前也会先获得 object 的对象锁。这里为了让实验效果明显,特意安排在 notify() 执行之后,让 T2 休眠 2 秒钟,这样做可以更明显地说明,T1 在得到 notify() 通知后,还是会先尝试重新获得 object 的对象锁。上述代码的执行结果类似如下:
注意程序打印的时间戳信息,可以看到,在 T2 通知 T1 继续执行后,T1 并不能立即继续执行,而是要等待 T2 释放 object 的锁,并重新成功获得锁后,才能继续执行。因此,加粗部分时间戳的间隔为 2 秒(因为 T2 休眠了 2 秒)。
注意:Object.wait() 和 Thread.sleep() 方法都可以让线程等待若干时间。除了 wait() 可以被唤醒外,另外一个主要区别就是 wait() 方法会释放目标对象的锁,而 Thread.sleep() 方法不会释放任何资源。
2.2.5 挂起(suspend)和继续执行(resume)线程
如果你阅读 JDK 有关 Thread 类的 API 文档,可能还会发现两个看起来非常有用的接口,即线程挂起(suspend)和继续执行(resume)。这两个操作是一对相反的操作,被挂起的线程,必须要等到 resume() 操作后,才能继续指定。乍看之下,这对操作就像 Thread.stop() 方法一样好用。但如果你仔细阅读文档说明,会发现它们也早已被标注为废弃方法,并不推荐使用。
不推荐使用 suspend() 去挂起线程的原因,是因为 suspend() 在导致线程暂停的同时,并不会去释放任何锁资源。此时,其他任何线程想要访问被它暂用的锁时,都会被牵连,导致无法正常继续运行(如图 2.7 所示)。直到对应的线程上进行了 resume() 操作,被挂起的线程才能继续,从而其他所有阻塞在相关锁上的线程也可以继续执行。但是,如果 resume() 操作意外地在 suspend() 前就执行了,那么被挂起的线程可能很难有机会被继续执行。并且,更严重的是:它所占用的锁不会被释放,因此可能会导致整个系统工作不正常。而且,对于被挂起的线程,从它的线程状态上看,居然还是 Runnable,这也会严重影响我们对系统当前状态的判断。
为了方便大家理解 suspend() 的问题,这里准备一个简单的程序。演示了这种情况:
执行上述代码,开启 t1 和 t2 两个线程。他们会在第 12 行通过对象锁u实现对临界区的访问。线程 t1 和 t2 启动后,在主函数中,第 23~24 行,对其进行 resume() 。目的是让他们得以继续执行。接着,主函数等待着两个线程的结束。
执行上述代码后,我们可能会得到以下输出:
这表明两个线程先后进入了临界区。但是程序不会退出。而是会挂起。使用 jstack 命令打印系统的线程信息可以看到:
这时我们需要注意,当前系统中,线程 t2 其实是被挂起的。但是它的线程状态确实是 RUNNABLE,这很有可能使我们误判当前系统的状态。同时,虽然主函数中已经调用了 resume() ,但是由于时间先后顺序的缘故,那个 resume 并没有生效!这就导致了线程 t2 被永远挂起,并且永远占用了对象 u 的锁。这对于系统来说极有可能是致命的。
如果需要一个比较可靠的 suspend() 函数,那应该怎么办呢?回想一下上一节中提到的 wait() 和 notify() 方法,这也不是一件难事。下面的代码就给出了一个利用 wait() 和 notify() 方法,在应用层面实现 suspend() 和 resume() 功能的例子。
在代码第 5 行,给出一个标记变量 suspendme,表示当前线程是否被挂起。同时,增加了 suspendMe() 和 resumeMe() 两个方法,分别用于挂起线程和继续执行线程。
在代码第 21~28 行,线程会先检查自己是否被挂起,如果是,则执行 wait() 方法进行等待。否则,则进行正常的处理。当线程继续执行时,resumeMe() 方法被调用(代码第 11~16 行),线程 t1 得到一个继续执行的 notify() 通知,并且清除了挂起标记,从而得以正常执行。
2.2.6 等待线程结束(join)和谦让(yield)
在很多情况下,线程之间的协作和人与人之间的协作非常类似。一种非常常见的合作方式就是分工合作。以我们非常熟悉的软件开发为例,在一个项目进行时,总是应该有几位号称是 “需求分析师” 的同事,先对系统的需求和功能点进行整理和总结,然后,以书面形式给出一份需求说明或者类似的参考文档,然后,软件设计师、研发工程师才会一拥而上,进行软件开发。如果缺少需求分析师的工作输出,那么软件研发的难度可能会比较大。因此,作为一名软件研发人员,总是喜欢等待需求分析师完成他应该完成的任务后,才愿意投身工作。简单地说,就是研发人员需要等待需求分析师完成他的工作,然后,才能进行研发。
将这个关系对应到多线程应用中,很多时候,一个线程的输入可能非常依赖于另外一个或者多个线程的输出,此时,这个线程就需要等待依赖线程执行完毕,才能继续执行。JDK 提供了 join() 操作来实现这个功能,如下所示,显示了 2 个 join() 方法:
第一个 join() 方法表示无限等待,它会一直阻塞当前线程,直到目标线程执行完毕。第二个方法给出了一个最大等待时间,如果超过给定时间目标线程还在执行,当前线程也会因为 “等不及了”,而继续往下执行。
英文 join 的翻译,通常是加入的意思。在这里感觉也非常贴切。因为一个线程要加入另外一个线程,那么最好的方法就是等着它一起走。
这里提供一个简单点的 join() 实例,供大家参考:
主函数中,如果不使用 join() 等待 AddThread,那么得到的 i 很可能是 0 或者一个非常小的数字。因为 AddThread 还没开始执行,i 的值就已经被输出了。但在使用 join() 方法后,表示主线程愿意等待 AddThread 执行完毕,跟着 AddThread 一起往前走,故在 join() 返回时,AddThread 已经执行完成,故 i 总是 10000000。
有关 join() ,我还想再补充一点,join() 的本质是让调用线程 wait() 在当前线程对象实例上。下面是 JDK 中 join() 实现的核心代码片段:
可以看到,它让调用线程在当前线程对象上进行等待。当线程执行完成后,被等待的线程会在退出前调用 notifyAll() 通知所有的等待线程继续执行。因此,值得注意的一点是:不要在应用程序中,在 Thread 对象实例上使用类似 wait() 或者 notify() 等方法,因为这很有可能会影响系统 API 的工作,或者被系统 API 所影响。
另外一个比较有趣的方法,是 Thread.yield() ,它的定义如下:
这是一个静态方法,一旦执行,它会使当前线程让出 CPU。但要注意,让出 CPU 并不表示当前线程不执行了。当前线程在让出 CPU 后,还会进行 CPU 资源的争夺,但是是否能够再次被分配到,就不一定了。因此,对 Thread.yield() 的调用就好像是在说:我已经完成一些最重要的工作了,我应该是可以休息一下了,可以给其他线程一些工作机会啦!
如果你觉得一个线程不那么重要,或者优先级非常低,而且又害怕它会占用太多的 CPU 资源,那么可以在适当的时候调用 Thread.yield() ,给予其他重要线程更多的工作机会。