前言
Q: 为什么学习并发?
A: 到目前为止,你学到的都是有关顺序编程的知识,即程序中的所有事物在任意时刻都只能执行一个步骤。
A: 编程问题中相当大的一部分都可以通过使用顺序编程来解决,然而,对于某些问题,如果能够并行地执行程序的多个部分,则会变得非常方便而且很有必要。
A: 并发编程可以使程序执行速度得到极大的提高,或者为设计某些类型的程序提供更易用的模型,或者两者皆有。
A: 另一个问题你必须意识到,当并发执行的任务彼此开始相互干涉时,实际的并发问题就会接踵而来。实际上你可能无法编写出能够针对你的并发程序生成故障条件的测试代码。所产生的故障经常是偶尔发生,并且经常是以客户抱怨的形式出现,这就是为什么要学习并发的强烈理由:如果视而不见,你就会遭其反噬。
A: 有很多Java程序,要么只是偶尔工作,要么在大多数时间里工作,并且会由于未发现的并发缺陷而时不时地神秘崩溃,有时这种崩溃是温和的,但有时却意味着重要数据的丢失,并且如果没有意识到并发问题,你可能最终会认为问题出在其他什么地方,而不在你的软件中。基本上,了解并发可以使你意识到明显正确的程序可能会展示出不正确的行为。
并发的多面性
Q: 如何理解并发的多面性?
A: 并发编程令人困惑的一个最主要原因是:使用并发时需要解决的问题有多个,而实现并发的方式也有多重,并且在这两者之间没有明显的映射关系。因此你必须理解所有这些问题的特性,以便有效地使用并发。
A: 用并发解决的问题大体上可以分成“速度”和“设计可管理性”两种。
Q: 如何理解并发提高了速度?
A: 速度问题听起来很简单:如果你想要一个程序运行得更快,那么可以将其断开为多个片段,在单独的处理器上运行每个片段。
A: 如果你有一台多处理器的机器,那么就可以在这些处理器之间分布多个任务,从而可以极大地提高吞吐量。这是使用强有力的多处理器Web服务器的常见场景,在为每个请求分配一个线程的程序,它可以将大量的用户请求分布到多个CPU上。
A: 但是,并发通常是提高运行在单处理器上的程序的性能。
A: 貌似在单处理器上运行的并发程序开销要比该程序所有部分都顺序执行的开销大,因为要多加一个环节:上下文切换,即从一个任务切换到另一个任务。表面上看,将程序的所有部分当做单个的任务运行好像是开销小一点,并且可以节省上下文切换的代价。使这个问题变得有些不同的是阻塞。
Q: 什么是阻塞?
A: 如果程序中的某个任务因为该程序控制范围之外的某些条件(通常是I/O)而导致不能继续执行,我们就说这个任务阻塞了。
A: 将程序所有部分都顺序执行,如果没有并发,则整个程序都将停止下来,直到外部条件发生变化。
A: 但是如果使用并发编程,那么当一个任务阻塞时,程序中的其他任务还可以继续执行,因此这个程序可以保持继续向前执行。
A: 事实上,从性能的角度看,如果没有任务会阻塞,那么在单处理器机器上使用并发就没有任何意义。
Q: 什么是进程?
A: 实现并发最直接地方式是在操作系统级别使用进程。进程是运行在它自己的地址空间内的自包容的程序。
A: 多任务操作系统可以通过周期性地将CPU从一个进程切换到另一个进程,来实现同时运行多个进程,尽管这使得每个进程看起来在其执行过程中都是歇歇停停。
A: 使用进程总是很吸引人,操作系统通常会将进程相互隔离分开,因此它们不会彼此干涉,这使得用进程编程相对容易一些。与此相反的是,像Java所使用的这种并发系统会共享诸如内存和I/O这样的资源,因此编写多线程程序最基本的困难在于在协调不同线程驱动的任务之间对这些资源的使用,以便使得这些资源不会同时被多个任务访问。
A: 这里有一个利用操作系统进程的简单示例。在编写本书时,我会有规律地创建本书当前状态的多个冗余备份副本。我会在本地目录中保存一个副本,在存储卡上保存一个副本,在移动硬盘上保存一个副本,还会在远程FTP站点上保存一个副本。为了自动化这个过程,我还编写一个小程序把本书压缩成一个文件,其文件名中带有版本号,然后执行复制操作。最初,我会顺序执行所有的复制操作,在启动下一个复制操作之前先等待一个操作的完成。但随后我意识到,每个复制操作会依存储介质I/O速度的不同而花费不同的时间。既然我在使用多任务操作系统,那就可以将每个复制操作当做单独的进程来启动,并让它们并发地运行,这样就可以加速整个程序的执行速度。当一个进程受阻时,另一个进程可以继续向前运行。
Q: 有些人提倡将进程作为唯一合理的并发方式?
A: 有人提倡将进程作为唯一合理的并发方式,例如,Eric Raymond在《The Art of UNIX Programming》中提出了这种极端情况。但遗憾的是,对进程通常会有数量和开销的限制,以避免它们在不同的并发系统之间的可应用性。
A: 某些编程语言被设计成可以将并发任务彼此隔离,这些语言通常被称为函数型语言,其中每个函数调用都不会产生任何副作用(并因此而不能干涉其他函数),并因此可以当做独立的任务来驱动。Erlang就是这样的语言,它包含针对任务之间彼此通信的安全机制。
A: Java采取了更加传统的方式,在顺序型语言的基础上提供对线程的支持。与在多任务操作系统中分叉外部进程不同,线程机制是在由执行程序表示的单一进程中创建任务。这种方式产生的一个好处是操作系统的透明性。
Q: 如何理解并发改进代码设计?
A: 并发提供了一个重要的代码组织结构上的好处:你的程序设计可以极大地简化。某些类型的问题,例如仿真,没有并发的支持是很难解决的。
A: 完整的仿真可能涉及非常大量的任务,但是多线程系统对可用的线程数量往往都会有限制,通常是一个相对较小的数字,有时就是数十或数百这样的数量级。在Java中,通常你不会获得足够的线程,从而使得每个任务都能提供一个线程。解决这个问题的典型方式是使用协作多线程(cooperative multithreading)。
A: Java的线程机制是抢占式的,这表示调度机制会周期性地中断线程,将上下文切换到另一个线程,从而为每个线程都提供时间片,使得每个线程都会分配到数量合理的时间去驱动它的任务。
A: 在协作式系统中,每个任务都会自动地放弃控制,这要求程序员要有意识地在每个任务中插入某种类型的让步语句。
A: 协作式系统的优势是双重的:上下文切换的开销通常比抢占式系统要低廉很多,并且对可以同时执行的线程数量在理论上没有任何限制。在另一个极端,当你用流行的消息系统工作时,由于消息系统设计分布在整个网络中的许多独立的计算机,因此并发就会成为一种非常有用的模型。在这种情形中,所有的进程都彼此完全独立地运行,甚至没有任何可能去共享资源。但是,你仍旧必须在进程间同步信息,使得整个消息系统不会丢失信息或在错误的时刻混进信息。
A: 并发需要付出代价,包含复杂性代价,但是这些代价与在程序设计、资源负载均衡以及用户方便使用方面来比,就显得微不足道了。通常,线程使你能够创建更加松散耦合的设计,否则,你的代码中各个部分都必须显示地关注那些通常可以由线程来处理的任务。
基本的线程机制
Q: 线程的机制原理?
A: 并发编程使我们可以将程序划分成多个分离的、独立运行的任务。通过使用多线程机制,这些独立任务(子任务)中的每一个都将由执行线程来驱动。
A: 一个线程就是在进程中的一个单一的顺序控制流,因此单个进程都可以拥有多个并发执行的任务,但是你的程序使得每个任务都好像有其自己的CPU一样。其底层机制是切换CPU时间,但通常你不需要考虑它。
A: 在使用线程时,CPU将轮流给每个任务分配其占用时间。每个任务都觉得自己在一直占用CPU,但事实上CPU时间是划分成片段分配给了所有的任务。
Q: 如何定义任务?
A: 要想定义任务,只需实现Runnable接口并编写run()方法。
A: LiftOff任务将显示发射之前的倒计时,示例: LiftOff.java,执行结果link
A: 任务的run()方法通常总会有某种形式的循环,使得任务一直运行下去直到不再需要,所以要设定调出循环的条件(有一种选择是直接从run()返回)。通常,run()被写成无限循环的形式,这意味着,除非有某个条件使得run()终止,否则它将永远运行下去,在本篇后面将会看到如何安全地终止线程。
A: 在run()中对静态方法Thread.yield()的调用时对线程调度器的一种建议。所谓的线程调度器是Java线程机制的一部分,可以将CPU从一个线程转移给另一个线程,它在声明:“我已经执行完生命周期中最重要的部分了,此刻正是切换给其他任务执行一段时间的大好时机”。
A: Thread.yield()这完全是选择性的,但是这里使用它是因为它会在这些示例中产生更加有趣的输出:你更有可能会看到任务换进换出的证据。
Q: 使用Thread类?
A: 将Runnable对象转变成工作任务的传统方式就是把它交给一个Thread构造器。
示例:BasicThreads.java,执行结果link
A: 调用Thread对象的start()方法为该线程执行必需的初始化操作,然后调用Runnable的run()方法,以便在这个新线程启动该任务。
A: 尽管start()看起来是产生了一个对长期运行方法的调用,但是从输出中可以看到,start()迅速地返回了,因为Waiting for LiftOff消息在倒计时完成之前就出现了。这是由于程序会同时运行main()和LiftOff.run()这两个方法。这两个方法是程序中与其他线程“同时”执行的代码。
A: 线程启动的能力并不局限于main()线程,任何线程都可以启动另一个线程。
Q: 更多线程去驱动更多任务?
A: 示例:MoreBaseThreads.java,执行结果link
A: 输出说明不同任务的执行在线程被换进换出时混在了一起。这种交换时由线程调度器自动控制的。
A: 这个程序一次运行的结果可能跟另一次运行的结果不同,因为线程调度机制是非确定性的。
A: 事实上,你可以看到,在某个版本的JDK与下个版本之间,这个简单程序的输出会产生巨大的差异。例如,较早的JDK不会频繁地对时间切片,因此线程1可能会首先循环到尽头,然后线程2会经历其所有循环,等等,这实际上与调用一个程序去同时执行所有的循环一样,只是启动所有线程的代价要更加高昂。较晚的JDK看起来会产生更好的时间切片行为,因此每个线程看起来都会获得更加正规的服务。
A: 通常sun并未提及这些种类的JDK的行为变化,因此你不能依赖于任何线程行为的一致性。
A: 当main()创建Thread对象时,它并没有捕获任何对这些对象的引用。在使用普通的对象时,垃圾回收可以正常回收并清除这些对象,但是在使用Thread时,情况就不同了。每个Thread都“注册”了它自身,因此确实有一个对它的引用,而且在它的任务退出其run()并死亡之前,垃圾回收器无法清除它。
A: 你可以从输出中看到,这些任务确实运行到结束,因此一个线程会创建一个单独的执行线程,在对start()的调用完成之后,它依旧会继续存在。
Q: 使用Executor?
A: Java SE5的Java.util.concurrent包中的执行器(Executor)将为你管理Thread对象,从而简化了并发编程。Executor在Java SE5/6中是启动任务的优选方法。
A: Executor在客户端和任务执行之间提供了一个间接层,与客户端直接执行任务不同,这个中介对象将执行任务。
A: Executor允许你管理异步任务的执行,而无须显示地管理线程的生命周期。
A: 我们可以使用Executor来代替显示地创建Thread对象。LiftOff对象知道如何运行具体的任务,与命令设计模式一样,它暴露了要执行的单一方法。请参阅Executor的API。
A: ExecutorService(具有服务生命周期的Executor,例如关闭)知道如何构建恰当的上下文来执行Runnable对象。
A: 非常常见的情况是,单个的Executor被用来创建和管理系统中的所有任务。
A: 对shutdown()方法的调用可以防止新任务被提交给这个Executor,当前线程将继续运行在shutdown()被调用之前提交的所有任务。这个程序将在Executor中的所有任务完成之前尽快退出。
Q: Executors.newCachedThreadPool()?
A: ExecutorTestCase.testNewCachedThreadPool()将为每个任务都创建一个线程。
A: 注意,ExecutorService对象是使用静态的Executor方法创建的,这个方法可以确定其Executor类型
Q: Executors.newFixedThreadPool(int)?
A: ExecutorTestCase.testNewFixedThreadPool()使用了有限的线程集来执行所提交的任务。
A: 你可以一次性预先执行代价高昂的线程分配,即限制线程的数量。这可以节省时间,因为你不用为每个任务都固定地付出创建线程的开销。
A: 注意,在任何线程池中,现有线程在可能的情况下,都会被自动复用。
Q: Executors.newSingleThreadExecutor()?
A: ExecutorTestCase.testNewSingleThreadExecutor()就像是线程数量为1的线程池。这对于你希望在另一个线程中持续运行的任何事物来说,都是很有用的,例如监听进入的套接字连接的任务。
A: 如果向SingleThreadExecutor提交了多个任务,那么这些任务将排队。所有的任务都将使用相同的线程。
A: 在本测试用例中,你可以看到每个任务都是按照它们被提交的顺序,并且是在下一个任务开始之前完成的。因此SingleThreadExecutor会序列化所有提交给它的任务,并会维护它自己的悬挂任务队列。
A: 假设有许多线程正在运行任务,你可以用SingleThreadExecutor来运行这些线程,以确保任意时刻多个任务在同一条线程上执行。在这种方式中,你不需要在共享资源上处理同步.
Q: 从任务中产生返回值 ?
A: 如果你希望任务在完成时能返回一个值,那么可以实现Callable接口而不是Runnable接口。
示例:CallableTestCase.java,执行结果link
A: 在Java SE5中引入的Callable是一种具有类型参数的泛型,它的类型参数表示的是从方法call()中返回的值,并且必须使用ExecutorService.submit()方法调用它。
A: 你可以使用isDone()方法来查询Future是否已经完成。当任务完成时,它具有一个结果,你可以调用get()方法来获取该结果。
Q: 休眠?
A: 影响任务行为的一种简单方法是调用sleep(),这将使任务中止执行给定的时间。
示例:SleepingTask.java,执行结果link
A: 对sleep()的调用可以抛出InterruptedException异常,并且你可以看到,它在run()中被捕获。因为异常不能跨线程传播到main(),所以你必须在本地处理所有在任务内部产生的异常。
A: Java SE5引入了更加显示的sleep()版本,作为TimeUnit类的一部分。
A: 我们可以看到在5个线程进入sleep后,主线程执行结束后,程序就直接结束了,后面没有打印了。每个任务都将要睡眠(即阻塞),这使得线程调度器可以切换到另一个线程,进而驱动另一个任务。
Q: 优先级?
A: 线程的优先级将该线程的重要性传递给了调度器。尽管CPU处理现有线程集的顺序是不确定的,但是调度器将倾向于让优先级最高的线程先执行。然而,这并不意味这优先级较低的线程将得不到执行。优先级较低的线程仅仅是执行的频率较低而已。
A: 在绝大多数时间里,所有线程都应该以默认的优先级运行。试图操纵线程优先级通常是一种错误。
A: 使用Thread.toString()方法打印线程名称、线程的优先级、线程所属的“线程组”。这里是默认生成的名称,如pool-1-thread-1, pool-1-thread-4。
A: 注意,你可以在一个任务的内部,通过调用Thread.currentThread()来获得对驱动该任务的Thread对象的引用。
示例:SimplePriorities.java,执行结果link
A: 可以看到,最后一个线程的优先级最高,其余所有线程的优先级都被设为最低。注意,优先级是在run()的开头部分设定的,在构造器中设置它们不会有任何好处,因为Executor在此刻还没有开始执行任务。
A: 在run()里,执行1000万次开销相当大的耗时运算。变量mD是volatile的,以努力确保不进行编译器优化。正是加了这些耗时运算操作,才能观察到优先级为MAX_PRIORITY的线程被线程调度器优先选择。这里的运算时间需足够长,因此线程调度机制才来得及介入。交换任务并关注优先级,使得最高优先级线程被优先选择。
A: 尽管JDK有10个优先级,但它与多数操作系统都不能映射的很好。比如,Windows有7个优先级且不是固定的;Sun的Solaris有231个优先级。唯一可移植的方法是当调整优先级的时候,只使用MAX_PRIORITY、NORM_PRIORITY和MIN_PRIORITY这3种级别。
Q: 让步?
A: 如果知道已经完成了在run()方法的循环的一次迭代过程中所需的工作,就可以给线程调度机制一个暗示:你的工作已经做得差不多了,可以让别的线程使用CPU了。这个暗示将通过调用yield()方法来作出。不过这只是一个暗示,没有任何机制能保证它将会被采纳。
A: 当调用yield()方法时,你也是在建议具有相同优先级的其他线程可以运行。
A: LiftOff.java使用yield()在各种不同的任务之间产生分布良好的处理机制。尝试着注释掉LiftOff.run()中的Thread.yield(),执行结果link
A: 大体上对于任何重要的控制或在调整应用时,都不能依赖于yield()。实际上,这个方法经常被误用。
Q: 后台线程?
A: 所谓后台(daemon)线程,是指程序运行的时候在后台提供一种通用服务的线程,并且这种线程并不属于程序中不可或缺的部分。因此,当所有的非后台线程结束时,程序也就终止了,同时会杀死进程中的所有后台线程。
A: 反过来说,只要有任何非后台线程还在运行,程序就不会终止,比如,执行main()的就是一个非后台线程。
A: 必须在线程启动之前调用setDaemon()方法,才能将它设置为后台线程。
示例:SimpleDaemons.java,执行结果link
A: main()线程被设定为短暂睡眠,所以可以观察到所有后台线程启动后的结果。试试调整sleep()休眠的时间,以观察这个行为。
以下是设置80毫秒的执行结果link
A: SimpleDaemon.java创建了显示的线程,以便可以设置它们的后台标志。
Q: ThreadFactory?
A: 每个静态的ExecutorService创建方法都被重载为接受一个ThreadFactory对象,而这个ThreadFactory对象将被用来定制线程的属性(后台、优先级、名称)
示例:DaemonFromFactory.java,执行结果link
Q: 后台模式派生出的子线程?
A: 如果是一个后台线程,那么它创建的任何线程将被自动设置为后台线程。
示例:Daemons.java,执行结果link
A: Daemon线程被设置为后台模式,然后派生出许多子线程,这些线程并没有被显示地设置为后台模式,不过它们的确是后台线程。接着,Daemon线程就进入了无限循环,并且在循环里调用yield()方法把控制权交给其他线程。
Q: 后台线程的finally块执行情况?
A: 你应该意识到后台进程在不执行finally字句的情况下就会终止其run()方法
示例:DaemonsDontRunFinally.java,执行结果link
A: 注意注释掉setDaemon()的调用时,就会看到finally字句将会被执行。
A: 当最后一个非后台线程终止时,后台线程会"突然"终止,因此一旦main()线程退出,JVM就会立刻关闭所有后台线程,而不会有任何你希望出现的确认形式。
Q: 编码的变体(一): 直接继承Thread类?
A: 到目前为止,在你所看到的的示例中,任务类都实现了Runnable。在非常简单的情况下,你可能会希望直接从Thread类继承,就像这个示例:SimpleThread.java,执行结果link
Q: 编码的变体(二): 自管理的Runnable?
A: 另一种可能会看到的惯用法是自管理的Runnable。
示例:SelfManaged.java
A: 这与从Thread继承并没有什么特别的差别,只是语法稍微晦涩一些。
A: 实现接口使得你可以继承另一个不同的类,而从Thread继承将不行。
A: 在构造器中启动线程可能会有问题,因为另一个任务可能会在构造器结束之前开始执行,这意味着该任务能够访问处于不稳定状态的对象。这就是为什么优先选择Executor而不是显示地创建Thread对象的另一个原因。
Q: 编码的变体(三): 内部类隐藏线程?
A: 有时通过使用内部类来将线程代码隐藏在类中将会很有用。
示例:ThreadVariations.java,执行结果link
Q: 术语?
A: 任务是拿来执行的,线程是拿来驱动的。理解这种细微的术语差别。你创建任务,并通过某种方式将一个线程附着到任务上,以便这个线程可以驱动任务。
A: 要执行的工作使用术语“任务”,只有在引用到驱动任务的具体机制时,才使用“线程”
Q: 加入一个线程?
A: 一个线程可以在其他线程之上调用join()方法,其效果是等待一段时间直到第二个线程结束才继续执行。
A: 如果某个线程在另一个线程t上调用t.join(),此线程将被挂起,直到目标线程t结束才恢复。(即t.isAlive()返回为假)
A: 也可以在调用join()是带上一个超时参数(毫秒/纳秒等),这样如果目标线程在这段时间到期时还没有结束的话,join()方法总能返回
A: 对join()方法的调用可以被中断,做法是在调用线程上调用interrupt()方法
示例:Joining.java,执行结果link
A: 在run()方法中sleep()方法有可能在指定的时间期是返回,但也可能被中断。在catch()子句中,将根据isInterrupted()的返回值报告这个中断。
A: 当另一个线程在该线程上调用interrupt()时,将给该线程设定一个标志,表明该线程已经被中断。而然后,异常被捕获时将清理该标志,因此在catch字句中这个标志总是为假。
A: 注意,Java SE5的java.util.concurrent类库中包括诸如CyclicBarrier这样的工具,它们可能比最初的线程类库中的join()更加适合。
Q: 创建有响应的用户界面?
A: 如前所述,使用线程的动机之一就是建立有响应的用户界面。
A: 下面的示例有两个版本:一个关注于运算,所以不能读取控制台输入;另一个把运算放在任务里单独运行,此时就可以进行运算的同时监听控制台输入。
ResponsiveUI.java
A: 如果sData是一个正的浮点数的话,UnresponsiveUI在一个无限循环的执行运算,因此程序不可能到达读取控制台输入的那一行。要想终止程序的话,就只能杀掉这个进程。
Q: 线程抛出的异常?
A: 由于线程的本质特性,使得你不能捕获从线程中逃逸的异常,一旦异常逃出任务的run()方法,它就会向外传播到控制台。
A: 除非你采取特殊的步骤捕获这些错误的异常,在Java SE5之前,你可以使用线程组来捕获这些异常,但是有了Java SE5,就可以用Executor来解决这个问题,因此你就不在需要了解有关线程组的任何知识了。
A: 下面的任务总是会抛出一个异常,该异常会传播到run()方法的外部,并且main()展示了当你当你运行时所发生的事情:
示例:ExceptionThread.java,执行结果link
A: 即使在main()里面加上try-catch语句块也是没用的。产生的结果与前面示例结果是一样的:未捕获的异常。
Q: 如何捕获线程抛出的异常?
A: Thread.UncaughtExceptionHandler是Java SE5中的新接口,它允许你在每个Thread对象上都附着一个异常处理器。
A: Thread.UncaughtExceptionHandler.uncaughtException()会在线程因未捕获的异常而临近死亡时被调用
A: 为了使用它,我们创建了一个新类型的ThreadFactory,它将在每个新创建的Thread对象上附着一个Thread.UncaughtExceptionHandler
示例:CaptureUncaughtEception.java,执行结果link
A: 你可以看到,未捕获的异常是通过uncaughtException来捕获的
Q: 设置默认的UncaughtExceptionHandler?
A: 如果你想要在代码中处处使用相同的异常处理器,那么更简单的方式是在Thread类中设置一个静态域,并将这个处理器设置为默认的未捕获异常处理器
示例:SettingDefaultHandler.java,执行结果link
A: 这个处理器只有在不存在线程专有的UncaughtExceptionHandler的情况下才会被调用。系统会检查线程专有版本,如果没有发现,则检查线程组是否有其专有的uncaughtException()方法,如果也没有,在调用setDefaultUncaughtExceptionHandler()
共享资源(Sharing resources)
Q: 并发可能面临的问题?
A: 有了并发就可以同时做多件事情了,但是,两个或多个线程彼此相互干涉的问题也就出现了。如果不防范这种冲突,就可能发生两个线程同时试图访问同一个银行账户,或向同一个打印机打印,改变同一个值等诸如此类的问题。
Q: 不正确地访问资源
A: 下面的示例,其中一个任务产生偶数,而其他任务消费这些数字(这里的消费工作是检查偶数的有效性)
示例:EvenGenerator.java,执行结果link
A: EvenChecker - 消费者任务,为了将EvenChecker与我们要试验的各种类型的生成器解耦,我们将创建一个名为IntGenerator的抽象类,该抽象类包含了EvenChecker必须了解的必不可少的方法:next()方法和可以执行撤销的cancel()方法。
A: IntGenerator有一个cancel()方法,可以修改boolean类型的mIsCancel标志的状态;还有一个isCanceled()方法,可以查看该对象是否已经被取消。因为IntGenerator.mIsCancel标志是boolean类型,所以它是原子性,即诸如赋值和返回值这样的简单操作在发生时没有中断的可能,因此你不会看到这个域处于在执行这些简单操作的过程中的中间状态。为了保证可视性,IntGenerator.mIsCancel标志还是volatile的
A: 一个任务有可能在另一个任务执行第一个对mCurrentValue的递增操作之后,但是没有执行第二个操作之前调用next()方法(代码中被注释danger point here!的地方),这将使这个值处于“不恰当”的状态
A: 为了证明上面说的会可能发生,EvenChecker.test()创建了一组EvenChecker任务,已连续地读取并输出同一个EvenGenerator,并测试检查每个数值是否都是偶数,如果不是,则会报告错误,而程序也将关闭。
A: 这个程序最终将失败,因为各个EvenChecker任务在EvenGenerator处于“不恰当的”状态时,仍能够访问其中的信息。但是根据你使用的特定的操作系统和其他实现细节,直到EvenGenerator完成多次循环之前这个问题都不会被探测到。如果你希望更快地发现失败,可以尝试着将yield()的调用放置到第一个和第二个递增操作之间。
A: 有一点很重要,那就是要注意到递增程序自身也需要多个步骤,并且在递增过程中任务可能会被线程机制挂起,也就是说,Java的递增不是原子性操作,因此,如果不保护任务,即使单一的递增也是不安全的
Q: 解决共享资源竞争?
A: 前面的示例向你展示了使用线程时的一个基本问题:你永远都不知道一个线程何时在运行
A: 想象一下,你坐在桌边手拿叉子,正要去叉盘子中最后一片食物,当你叉子就要够着它时,这片食物突然消失了,因为你的线程被挂起了,而另一个餐者进入并吃掉它。这正是在你编写并发程序时需要处理的问题
A: 对于并发工作,你需要某种方式来防止两个任务访问相同的资源,而这种解决的方法就是当资源被一个任务使用时,在其过程中加锁。第一个访问某项资源的任务必须锁定这项资源,使其他任务在其被解锁之前,就无法访问它,而在其被解锁之时,另一个任务就可以锁定并使用它,以此类推。
A: 基本上所有的并发模式在解决线程冲突问题的时候,都采用“序列化访问共享资源(serialize access to shared resources)”的方案。这意味着在给定时刻只允许一个任务访问共享资源。通常是通过在代码前面加上一条“锁语句”,这使得在一段时间内只有一个任务可以运行这段代码。因为锁语句产生一种互相排斥的效果,所以这种机制常常被称为互斥量(mutex)
A: Java以提供关键字synchronized的形式,为防止资源冲突而提供的内置支持。当任务要执行被synchronized关键字保护的代码片段时,它将检查锁是否可用,然后获取锁,执行代码,释放锁。
A: 共享资源一般是以对象形式存在的内存片段,但也可以是文件、输入/输出端口,或者是打印机。要控制对共享资源的访问,得先把它包装进一个对象,然后把所有要访问这个资源的方法标记位synchronized。
A: 如果某个任务处在对标记为synchronized的方法的调用中,那么在这个线程从该方法返回之前,其他所有要调用类中任何标记位synchronized方法的线程都会被阻塞
A: 将类的数据成员都声明为private,而且只能通过方法来访问这些数据,所以可以把这些方法标记位synchronized来防止资源冲突。
例如下面声明synchronized方法的方式:
synchronized void f() {}
synchronized void g() {}
A: 如果某个任务对对象调用了f(),对于同一个对象而言,就只能等到f()调用结束并释放锁之后,其他任务才能调用f()和g()。所以对于某个特定对象来说,其所有synchronized方法共享同一个锁,这可以被用来防止多个任务同时访问和写对象内存
A: 注意,使用并发时,将域设置为private是非常重要的,否则,synchronize关键字就不能防止其他任务直接访问域,这样就会产生冲突
Q: 锁的实现原理?
A: 一个任务可以多次获得对象的锁,如果一个方法在同一个对象上调用了第二个方法,后者又调用同一个对象的另一个方法,那么这个任务就获得多次锁,JVM负责跟踪对象被加锁的次数。
A: 如果一个对象被解锁,其计数就变成0。在任务第一次给对象加锁时,计数就变成1。每当这个相同的任务在这个对象上获得锁时,计数都会递增。显然,只有首先获得了锁的任务才能允许继续获取多个锁。每当任务离开一个synchronized方法时,计数递减。当计数为0的时候,锁被完全释放,此时别的任务就可以使用此资源了。
Q: 如何给类加锁?
A: 除了对象外,其实也可以给每个类加一个锁,所以synchronize static方法可以在类的范围内防止对static数据的并发访问
Q: 使用锁必须值得注意的地方?
A: 如果在你的类中有超过一个方法在处理临界数据(critical data),那么你必须同步所有相关的方法。如果只同步一个方法,那么其他方法将会随意地忽略这个对象锁,并可以在无任何惩罚的情况下被调用。这一点再次强调:每个访问临界共享资源的方法都必须被同步,否则它们就不会正确地工作。
Q: 同步控制EvenGenerator
A: 通过在EvenGenerator中加入synchronized关键字,可以防止不希望的线程访问
示例:SynchronizedEvenGenerator.java,执行结果link
A: 对Thread.yield()的调用被插入到两个递增操作之间,以提高在mCurrentValue是奇数状态时上下文切换的可能性。因为互斥可以防止多个任务同时进入临界区,所以不会产生任何失败
A: 第一个进入next()的任务将获得锁,任何其他试图获取锁的任务都将从其开始尝试之时被阻塞,直到第一个任务被释放锁
A: 通过这种方式,任何时刻只有一个任务可以通过由互斥量看护的代码
Q: 使用显示的Lock对象?
A: Java SE5的java.util.concurrent类库还包含定义在java.util.concurrent.locks中的显示的互斥机制。Lock对象必须被显示地创建、锁定和释放,因此它与内建的锁形式相比,代码缺乏优雅性。但是对于解决某些类型的问题来说,它更加灵活。
示例:MutexEvenGenerator.java,执行结果link
A: 使用lock()和unlock()方法在next()内部创建了临界资源,你必须放置把unlock放置在finally子句中,return语句必须放在try子句中,以确保unlock()不会过早发生,从而将数据暴露给了第二个任务
A: 尽管try-finally所需的代码比synchronized关键字要多,但是这也代表了显示的Lock对象的优点之一。如果在使用synchronized关键字时,内部失败了,那么就会抛出一个异常,但是你没有机会去做任何清理的工作,以维护系统处在良好的状态。有了显示的Lock对象,你就可以使用finally子句将系统维护在正确的状态了
Q: 什么时候使用显示的Lock?
A: 大体上,当你使用synchronized关键字时,需要写的代码量更少,并且用户错误出现的可能性也会降低,因此通常只有在解决特殊问题时,才使用显示的Lock对象。例如,用synchronized关键字不能尝试获取锁且最终获取锁会失败,或者尝试获取锁一段时间然后放弃锁,要实现这些,你必须使用concurrent类库:
示例:AttemptLocking.java,执行结果link
A: ReentrantLock允许你尝试着获取但最终未获取锁,这样如果其他人已经获取到这个锁,那么你就可以决定离开去执行其他事情,而不是等待直至这个锁被释放,就像untimed()方法中所看到的。
在timed()方法中,做出了尝试去获取锁,该尝试可以在2s之后失败。
A: 在main()中,作为匿名类而创建了一个单独的Thread,它将获取锁,这使得untimed()和timed()方法对某些事物将产生竞争
A: 显示的Lock对象在加锁和释放锁方面,相对于内建的synchronized锁来说,赋予了你更细粒度的控制力。这对于实现专有同步结构是很有用的,例如遍历链接链表中的节点所采用的加锁机制,这种遍历代码必须在释放当前节点的锁之前捕获下一个结点的锁。
Q: 原子性(atomicity)?
A: 在有关Java线程的讨论中,一个常不正确的知识是“原子操作不需要进行同步控制”。原子操作是不能被线程调度机制中断的操作。一旦操作开始,那么它一定可以在可能发生的上下文切换之前执行完毕。
A: 依赖于原子性是很棘手且很危险
A: 原子性可以应用于除long和double之外的所有基本类型。对于读取和写入除long和double之外的基本类型变量的操作,可以保证它们会被当做不可分的操作来操作内存。
A: JVM可以将64位long和double变量的读取和写入当做两个分离的32位操作来执行,这就产生了在一个读取和写入操作中间发生上下文切换,从而导致不同的任务可以看到不正确结果的可能性(这有时被称为字撕裂,因为你可能会看到部分被修改过的数值),但是当你使用volatile关键字修饰long或者double变量时,就会获得简单赋值与返回操作的原子性。
A: 你不应该依赖于平台相关的特性,注意在JAVA SE5之前,volatile一直未能正确地工作
Q: 易变性(volatility)?
A: 在多处理器系统上(which are now appearing in the form of multicore processors—multiple CPUs on a single chip),相对于单处理器系统而言,可视性(visibility)问题远比原子性(atomicity)问题多得多。一个任务作出的修改,即使在不中断的意义上讲是原子性,但对其他任务来讲可能是不可视的。例如修改只是暂时性地存储在本地处理器的缓存中,因此不同的任务对应用的状态有不同的视图。
A: 另一个方面,同步机制强制在处理器系统中,一个任务做出的修改必须在应用中是可视的,如果没有同步机制,那么修改时可视将无法确定
A: volatile关键字确保了应用中的可视性,如果你将一个域声明为volatile,那么只要对这个域产生了写操作,那么所有的读操作就可以看到这个修改。即使本地缓存(local caches),情况也确实如此,volatile域会被立即写入到主存(main memory)中,而读取操作就发生在主存中
A: 理解原子性和易变性是不同的概念至关重要。在非volatile域上的原子性不必刷新到主存中,因此其他读取该域的任务也不必看到这个新值。如果多个任务在同时访问某个域,那么这个域必须是volatile的,否则,这个域就应该只能经由同步来访问,同步也会向主存刷新,因此如果一个域完全有synchronized方法或语句来防护,那就不必将其设置为volatile
A: 使用volatile而不是synchronized的唯一安全的情况是类中只有一个可变的域。再次提醒,你的第一选择应该是使用synchronized关键字,这是最安全的方式,而尝试其他任何方式都是有风险的。
Q: 原子性值得注意的地方一?
A: 什么才属于原子操作呢?对域中的值做赋值和返回操作通常都是原子性的。在C++中,下面的语句有可能是原子性:
i++;
i += 2;
C++主要取决于编译器,但在Java中,上面的语句不是原子性,正如下面的示例所产生的JVM指令中可以看到:
/**
* javap -c XXXX
*
* @author fireway
*/
public class Atomicity {
int i;
void f1() {
i++;
}
void f2() {
i += 3;
}
}
编译命令如下:
javac concurrency/Atomicity.java javap -c concurrency.Atomicity
编译执行结果link
每条指令都会产生一个get和put,它们之间还有一些其他的指令。因此在获取和放置之间,另一个任务可能会修改这个域,所以这些操作不是原子性
A: 如果你盲目地应用原子性概念,那么就会看到这个示例中getValue()就符合上面的描述
示例:AtomicityTest.java,执行结果link
A: 尽管return mI确实是原子性操作,但是缺少同步使得其数值可以在处于不稳定的中间状态时被读取,除此之外,由于mI也不是volatile的,因此还存在可视性问题。
A: getValue()和evenIncrement()必须是synchronized的。在诸如此类情况下,只有并发专家才有能力进行优化,而你还是应该运用Brian的同步规则。
Q: 原子性值得注意的地方二?
A: 一个产生序列数字的类,每当nextSerialNumber()被调用时,它必须向调用者返回唯一的值:
public class SerialNumberGenerator {
private static volatile int sSerialNumber = 0;
public static /* synchronized */ int nextSerialNumber() {
// not thread safe
return sSerialNumber++;
}
}
A: 正如前面所示,Java递增操作不是原子性的,并且涉及一个读操作和一个写操作,所以即便是在这么简单的操作中,也为产生线程问题留下了空间
A: 正如你所看到的,易变性(volatility)在这里实际上不是什么问题,真正的问题在于nextSerialNumber()方法在没有同步的情况下对共享可变值(a shared, mutable value)进行了访问。
A: 为了测试SerialNumberGenerator,我们需要不会耗尽内存的Set,以防需要花费很长的时间来探测问题。这里所示的CircularSet重用了存储int数值的内存,并假设在你生成序列数时,产生数值覆盖冲突的可能性极小。add()和contains()方法都是synchronized,以防止线程冲突。
示例:SerialNumberChecker
A: SerialNumberChecker包含一个静态的CircularSet类(组合关系),它持有产生的所有的序列数;另外还包含一个内嵌的SerialChecker内部类(它可以确保序列数是唯一的)。通过创建多个任务来竞争序列数,你将会发现这些任务最终会得到重复的序列数。为了解决这个问题,在nextSerialNumber()前面添加synchronized关键字。
A: 基本上如果一个域可能会被多个任务同时访问,或者这些任务中至少有一个是写入任务,那么你就应该将这个域设置为volatile
的。如果你将一个域定义为volatile
,那么它就会告诉编译器不要执行任何移除读取和写入操作的优化,这些操作的目的是用线程中的局部变量维护对这个域的精准同步。实际上读取和写入都是直接针对内存的,并没有缓存,volatile
在优化过程中限制编译器对共享可变值访问的重新排序,但是volatile
并不能对递增不是原子性操作这一事实产生影响。
A: 对基本类型的读取和赋值操作被认为是安全的原子性操作,但是,正如你在AtomicityTest.java中看到的,当对象处于不稳定状态时,依旧很有可能使用原子性操作来访问它们。对这个问题做出的假设是棘手而危险的,最明智的做法就是遵循Brian的同步规则。
Q: 使用原子类示例一?
A: Java SE5 引入了诸如AtomicInteger、AtomicLong、AtomicReference等特殊的原子性变量类。它们提供了下面形式的原子性条件更新操作:
public final boolean compareAndSet(int expect, int update);
A: 这些类被调整为机器级别上的原子性,因此你不必担心使用它们。我们可以使用AtomicInteger来改写上面的AtomicityTest.java,我们通过使用AtomicInteger而消除了synchronized关键字
示例:AtomicIntegerTest
Q: 使用原子类示例二?
A: 下面是用AtomicInteger重写的MutexEvenGenerator.java
AtomicEvenGenerator
A: 应该强调的是,Atomic类被设计用来构建java.util.concurrent中的类,因此只有在特殊情况下才在自己的代码中使用它们,即便使用了也需要确保不存在其他可能出现的问题。通常依赖于锁要更安全一些(要么是synchronized关键字,要么是显示的Lock对象)
Q: 什么是临界区(一)?
A: 有时,你只是希望防止多个线程同时访问方法内部的部分代码而不是防止整个方法,通过这种方式分离出来的代码段被称为临界区(critical section),它也使用synchronized关键字建立,这也被称为同步控制块(synchronized block)
synchronized(syncObject) {
// This code can be accessed
// by only one task at a time
}
A: 在进入同步控制块代码之前,必须得到syncObject对象的锁,如果其他线程已经得到这个锁,那么就得等到锁被释放以后,才能进入临界区
A: 通过使用同步控制块,而不是对整个方法进行同步控制,可以使多个任务访问对象的时间性能得到显著提高。下面的示例比较了这两种同步控制方法:
示例:CriticalSection.java,执行结果link
A: 正如注释中注明的,Pair不是线程安全的,因为它的约束条件需要两个变量要维护成相同的值,而自增操作不是线程安全的,并且没有任何方法被标记为synchronized,所以不能保证一个Pair对象在多线程中不会被破坏。
A: 你可以想象一下,某人交给你一个非线程安全的Pair类,而你需要在一个线程环境中使用它。通过创建PairManager类就可以实现这一点,PairManager类持有一个Pair对并控制它的一切访问。注意唯一的public方法是getPair(),它是synchronized的。对于抽象方法increase(),其同步控制将在实现的时候进行处理。
A: 在子类PairManager1中,整个increase()方法是被同步控制的,而PairManager2中,increase()方法使用同步控制块进行同步。注意,synchronized关键字不属于方法特征签名的组成部分,所以可以在覆盖的时候加上去。
A: 对于PairManager类的结构,它的一些功能基类中实现,并且其一个或多个抽象方法在派生类中定义,这种结构在设计模式(Design Patterns)中称为模板方法(a Template Method)
。设计模式使你得以把变化封装在代码里,在此发生变化的部分是模板方法increase()。
A: store()方法将一个Pair对象添加到synchronized ArrayList中,所以这个操作是线程安全的。因此该方法不必进行防护,可以放置在PairManager2的synchronized语句块的外部。
A: PairManipulator被创建用来测试不同类型的PairManager,其方法是在某个任务中调用increase(),而PairChecker则在另一个任务中执行。为了跟踪可以运行测试的频度,PairChecker在每次成功时都递增mCounter。在main()中创建了两个PairManipulator对象,并允许它们运行一段时间,之后每个PairManipulator的结果都会得到展示。尽管每次运行的结果可能会不同,但一般来说,对于PairChecker的检查频率,PairManager2.increase()的会比PairManager1的多,因此这也是宁愿使用同步控制块而不是对整个方法进行同步控制的典型原因。
A: 可以尝试把PairManager1.increase()前面的synchronized修饰去掉看看。
Q: 什么是临界区(二)?
A: 你还可以使用显示的Lock对象来创建临界区:
示例:ExplicitCriticalSection.java,执行结果link
A: ExplicitPairManager2展示了如何使用Lock对象来创建临界区,而对store()的调用则在这个临界区的外部。
Q: 在其他对象上同步?
A: synchronized(this)是在当前对象进行同步,正如PairManager2所使用的方式。在这种方式下,如果获得了synchronized块上的锁,那么该对象其他的synchronized()方法和临界区就不能被调用了。
A: 有时必须在另一个对象上同步,但是如果你要这么做,就必须确保所有相关的任务都是在同一个对象上同步。
示例:SyncObject.java,执行结果link
A: 这个示例演示了两个任务可以同时进入同一个对象,只要确保这个对象上的方法是在不同的锁上同步即可。
A: DualSync.f()通过同步整个方法在this同步,而g()有一个在mSyncObject上同步的synchronized块,因此这两个同步是相互独立的。
A: 通过在main()创建调用f()的Thread,而main()线程时被用来调用g()的。从输出中可以看到,这两个方式在同时运行,因此任何一个方法都没有因为对另一个方法的同步而被阻塞。
Q: 线程本地存储?
A: 线程本地存储(Thread local storag)是一种自动化机制,可以为使用相同变量的每个不同的线程都创建不同的存储,因此如果你有5个线程都要使用变量x所表示的对象,那线程本次存储就会生成5个用于x的不同的存储块。
A: 创建和管理线程本地存储可以使用java.lang.ThreadLocal
类来实现。
示例:ThreadLocalVariableHolder.java,执行结果link
A: ThreadLocal对象通常当做静态域存储,在创建ThreadLocal时,你只能通过get()/set()的方法来访问该对象的内容,其中,get()方法将返回与其线程相关联的对象的副本,而set()会将参数插入到其线程存储的对象中,并返回存储中原有的对象。
A: 注意,increase()和get()方法都不是synchronized的,因为ThreadLocal保证不会出现竞争条件。
A: 当运行这个程序时,你可以看到每个单独的线程都会分配了自己的存储,因为它们每个都需要跟踪自己的计数值,即便只有一个ThreadLocalVariableHolder对象。
终结任务
在前面的某些示例中,cancel()和isCanceled()方法被放到一个所有任务都可以看到的类中(如示例EvenGenerator.java中,EvenChecker类作为任务,看到IntGenerator类的cancel/isCanceled这两个方法)。这些任务通过检查isCanceled()来确定何时终止它们自己,对于这个问题来说,这是一种合理的方式。但是还有一些更好的方式去处理如何终止任务,而本节将学习到这些有关终止任务的各种话题。
首先,让我们观察一个示例,它不仅演示了终止问题,而且还是一个资源共享的示例。
Q: 装饰性花园(The ornamental garden)?
A: 在这个示例里,花园希望能统计通过各个入口后的总人数,以及通过每个入口的人数。
示例:OrnamentalGarden.java,执行结果link
A: 这里使用单个的Count对象sCount作为跟踪花园参观者的主计数器,因此设计成Entrance类中的一个静态域进行存储。Count的increase()和value()方法都是synchronized的,用来控制对mValue域的访问。increase()方法使用了Random对象,目的是在从把mValue读取到tmp中,到递增tmp并将其存储回mValue的这段时间,尽可能有大约一半的时间产生让步。如果你将increase()上的synchronized关键字注释掉,那么这个程序就会崩溃,因为多个任务访问并修改sCount。
A: 每个Entrance任务都维护着一个本地值mNumber,它是通过某个特定入口的人数,Entrance.run()只是递增mNumber和sCount对象,然后休眠100毫秒。
A: 因为Entrance.sCanceled是一个volatile布尔标识,而它只会被读取和赋值,所以不需要同步对其的访问,就可以安全地操作它。如果你对诸如此类的情况有任何疑虑,那么最好总是使用syncronized。
A: 这个程序以稳定的方式关闭所有事物方面还有一些小麻烦,其部分原因是为了说明在终止多线程程序时你必须相当小心,而另一部分原因是为了说明后面学到的interrupt()知识点。
A: 在3秒之后,main()向Entrance发送static cancel消息,然后调用exec对象的shutdown()方法,之后调用exec上的awaitTermination()方法。而ExecutorService.awaitTermination()方法是等待每个任务结束,如果所有的任务在超时时间到达之前结束,则返回true,否则返回false,表示不是所有的任务都已经结束。尽管这会导致每个任务都退出其run()方法,并因此作为任务而终止,但是Entrance对象仍旧存在,因为在构造器中,每个Entrance对象都存储在称为sEntrances的静态List中,因此sumEntrances()仍旧可以作用于这些有效的Entrance对象。
A: 再次强调,Count.increase()通过使用tmp和yield,仅仅是为了测试多线程同步问题(可以尝试去掉synchronized修饰词),它增加了失败的可能性。在真正的多线程问题中,失败的可能性从统计学角度看可能非常小,因此你可能容易就掉进了轻信所有事物都将正确工作的陷进里。就像本示例,有些还未发生的问题就有可能会隐藏起来,因此在审核并发代码时,要格外地仔细。
Q: 在阻塞时终结?
A: 在示例OrnamentalGarden.java中Entrance.run()在其循环中包含对sleep()的调用,我们知道sleep()最终将唤醒,而任务也将返回循环的开始部分,去检查sCanceled标识,从而决定是否调出循环。但是sleep()一种情况使任务从执行状态变为被阻塞状态,而有时你必须终止被阻塞的任务。
A: 现在我们需要查看的问题是:有时你希望能够终止处于阻塞状态的任务,如果对于处于阻塞状态的任务,你不能等待其到达代码中可以检查其状态值的某一点,从而决定让它主动地终止,那么你就必须强制这个任务跳出阻塞状态。
A: 在较早的代码中,可以看到suspend()和resume()来阻塞和唤醒线程,但是在现代Java中这些方法都被废止(原因可能导致死锁)。所以这里就不在讨论这些内容。
A: stop()方法也已经被废止了,原因是它不释放线程获得的锁,并且如果线程处于不一致的状态,其他任务可以在这种状态下浏览并修改它们,这样所产生的问题微妙而难以被发现。
Q: 线程状态有哪些?
A: 一个线程可以处于以下四种状态之一:
1) 新建(new):当线程被创建时,它只会短暂地处于这种状态,此时它已经分配了必需的系统资源,并执行了初始化。此刻线程已经有资格获得CPU时间了,之后调度器将把这个线程转变为可运行状态或阻塞状态;
2) 就绪(Runnable):在这种状态下,只要调度器把时间片分配给线程,线程就可以运行。也就是说,在任意时刻,线程可以运行也可以不运行。只要调度器能分配时间片给线程,它就可以运行;这不同于死亡和阻塞状态;
3) 阻塞(Blocked):线程能够运行,但有某个条件阻止它的运行。当线程处于阻塞状态时,调度器将忽略线程,不会分配给线程任何CPU时间。直到线程重新进入了就绪状态,它才有可能执行操作;
4) 死亡(Dead):处于死亡或终止状态的线程将不再是可调度的,并且在也不会得到CPU时间,它的任务已结束,或不再是可运行的。任务死亡的通常方式是从run()方法返回。但是如果还没从run()返回时任务的线程还可以被中断。
A: 可以参考jdk源码Thread.State枚举。
Q: 进入阻塞状态的原因?
A: 一个任务进入阻塞状态,可能有如下原因:
1) 通过调用sleep(milliseconds)使任务进入休眠状态,在这种情况下,任务在指定的时间内不会运行;
2) 通过调用wait()使线程挂起,直到线程得到了notify()或notifyAll()消息,或者在Java SE5的java.util.concurrent类库中等价的signal()或signalAll()消息,线程才会进入就绪状态;
3) 任务在等待某个输入/输出完成;
4) 任务试图在某个对象上调用其同步控制方法,但是对象锁不可用,因为另一个任务已经获取了这个锁。
Q: 如何中断某个单一任务?
A: 当你打断被阻塞的任务时,可能需要清理资源,正因为这一点,在任务的run()方法中间打断,更像是抛出的异常,因此在Java线程中的这种类型的异常中断用到了InterruptedException异常。
A: 但是异常从来都不能异步地传递,因此在指令/方法调用的中间突然中断没有任何危险。只要在使用对象互斥机制时使用try-finnally惯用法,如果抛出异常,这些互斥就会自动被释放。同时你必须仔细考虑代码的执行路径,并仔细编写catch子句以正确清除所有事物。
A: Thread类包含interrupt()方法,因此你可以终止被阻塞的任务,这个方法将设置线程的中断状态。如果一个线程已经被阻塞,或者试图执行一个阻塞状态,那么设置这个线程的中断状态将抛出InterruptedException。当抛出该异常或者该任务调用Thread.interrupted()时,中断状态将被复位。正如你将看到的,Thread.interrupted()提供了离开run()循环而不抛出异常的第二种方式。
A: 为了调用interrupt(),你必须持有Thread对象,你可能已经注意到了,新的concurrent类库似乎在避免对Thread对象的直接操作,转而尽量通过Executor来执行所有操作。如果你在Executor上调用shutdownNow(),那么它就发送一个interrupt()调用给它启动的所有线程。这么做是有意义的,因为当你完成工程中的某个部分或者整个程序时,通常会希望同时关闭某个特定Executor的所有任务。
A: 然而,你有时也会希望只中断某个单一任务,如果使用Executor,那么通过调用submit()而不是executor()来启动任务,就可以持有该任务的上下文。submit()将返回一个泛型Future<?>
,其中有一个未修饰的参数,因为你永远都不会在其上调用get()——持有这种Future的关键在于你可以在其上调用cancel(),并因此可以使用它来中断某个特定任务,如果你将true传递给cancel(),那么它就会拥有在该线程上调用interrupt()以停止这个线程的权限,因此cancel()是一种中断由Executor启动的单个线程的方式。
示例:Interrupting.java,执行结果link
A: 上面示例的每个任务都表示了一种不同类型的阻塞。SleepBlock是可中断的阻塞示例,而IOBlocked和SynchronizedBlocked是不可中断的阻塞示例。这个程序证明I/O和在synchronized块上的等待是不可中断的,通过浏览代码无论是I/O还是尝试调用synchronized方法,都不需要任何InterruptedException处理器。
A: 为了演示SynchronizedBlocked,我们必须首先获取锁,这是通过在构造器中创建匿名的Thread类的实例来实现的,这个匿名Thread类的对象通过调用f()获取了对象锁,该线程必须有别于为SynchronizedBlocked驱动run()的线程,因为一个线程可以多次获得某个对象锁。由于f()永远都不返回,因此这个锁永远不会释放,而SynchronizedBlocked.run()在试图调用f(),并阻塞以等待这个锁被释放。
A: 从输出中可以看到,你能够中断对sleep()的调用或者任何要求抛出InterruptedException的调用,但是你不能中断正在试图获取synchronized锁或者试图执行I/O操作的线程,这有点令人烦恼,特别是在创建执行I/O的任务时,因为这意味着I/O具有锁住你的多线程的潜在可能。
Q: 如何中断I/O阻塞的任务(一)?
A: 对于这类问题,有一个略显笨拙但是有时确实行之有效的解决方案,即关闭掉任务在其上发生阻塞的底层资源。
示例:CloseResource.java,执行结果link
A: 在ExecutorService.shutdownNow()被调用之后以及在两个输入流上调用close()之前的延迟强调的是一旦底层资源被关闭, 任务将解除阻塞。
A: 请注意,有一点很有趣,interrupt()看起来发生在关闭Socket而不是关闭System.in的时刻
Q: 如何中断I/O阻塞的任务(二)?
A: 幸运的是,nio类提供了更人性化的I/O中断。被阻塞的nio通道会自动地响应中断:
示例:NIOInterruption.java,执行结果link
A: 如你所见,两种方法中断正在阻塞的任务,而不干扰。或者你也可以使用main2()的ExecutorService.shutdownNow()方式很容易地终止所有事物,执行结果link
Q: 被互斥阻塞(Blocked by a mutex)?
A: 就像在示例Interrupting.java中看到的,如果你尝试着在一个对象上调用其synchroinized方法,而这个对象的锁已经被其他任务获得,那么调用任务将被挂起(阻塞),直至这个锁可获得。
示例:MultiLock.java说明了同一个互斥可以如何能被同一个任务多次获得,执行结果link
A: 在main()中创建了一个调用f1()的Thread, 然后f1()和f2()相互调用直至count变为0,由于这个任务已经在第一个对f1()的调用中获得了multiLock对象锁,因此同一个任务将在对f2()的调用中再次获取这个锁,依次类推。
A: 这么做是有意义的,因为一个任务应该能够调用在同一个对象中的其他的synchronized方法,而这个任务已经持有锁了。
Q: 被互斥阻塞如何中断?
A: 就像前面在不可中断的I/O中所观察到的,无论在任何时刻,只要任务以不可中断的方式被阻塞,那么都有潜在的会锁住程序的可能。
A: Java SE5并发类库中添加了一个特性,即在ReentrantLock上阻塞的任务具备可以中断的能力,这与synchronized方法或临界区上阻塞的任务完全不同,示例:Interrupting2.java,执行结果link
A: BlockedMutex类有一个构造器,它要获取所创建对象上自身的Lock,并且从不释放这个锁。出于这个原因,如果你试图从第二个任务中调用f(),那么将会总是因为Mutex不可获得而被阻塞。
A: 在Blocked2中,run()方法总是在调用mBM.f()的地方停止,当运行这个程序时,你将会看到,与I/O调用不同,interrupt()可以打断被互斥所阻塞的调用。注意尽管不太可能,但是对t.interrupt()的调用确实可以发生在对mBM.f()的调用之前。
Q: 检查中断?
A: 注意,当你在线程上调用Thread.interrupt()时,中断发生的唯一时刻是在任务要进入阻塞操作,或者已经在阻塞操作内部。
A: 你可以通过调用interrupted()来检查中断状态,这不仅可以告诉你interrupt()是否被调用过,而且还可以清除中断状态。
A: 清除中断状态可以确保并发结构不会就某个任务被中断这个问题通知你两次,你可以经由单一的InterruptedException或者单一的成功的Thread.interrupted()测试来得到这种通知。
A: 如果想要再次检查以了解是否被中断,则可以在调用Thread.inerrupted()时将结果存储起来。
A: 下面的示例展示了典型的Thread.interrupted()的惯用法,你应该在run()方法中使用它来处理在中断状态被设置时,被阻塞和不被阻塞的各种可能:
示例:InterruptingIdiom.java ,执行结果link
A: NeedsCleanup类强调在你经由异常离开循环时,正确清理资源的必要性。注意所有在Blocked3.run()中创建的NeedsCleanup资源都必须在其后面紧跟try-finally子句,以确保cleanup()方法总是会被调用。
A: 你必须给程序提供一个命令行参数,来表示在它调用interrupt()之前以毫秒为单位的延迟时间。
A: 通过使用不同的延迟,你可以在不同地点退出Blocked3.run():在阻塞的sleep()调用中,或者在非阻塞的数学计算中,你将刚看到如果interrupt()在注释point2之后被调用,那么首先循环将结束,然后所有的本地对象将被撤销,最后循环会经由while语句的顶部退出。
A: 但是,如果interrupt()在point1和point2之间,也就是在while语句之后,但是在阻塞操作sleep()之前或者其过程中被调用,那么这个任务将会在第一次试图调用阻塞操作之前,经由InterruptedException退出,在这种情况下了,在异常被抛出之时唯一被创建出来的NeedsCleanup对象将被清除,而你也就有了在catch子句中执行其他任务清除工作的机会。
A: 被设计用来响应interrupt()的类必须建立一种策略,来确保它将保持一致的状态,这通常意味着所有需要清理的对象创建操作的后面,都必须紧跟try-finally子句,从而使得无论run()循环如何退出,清理都会发生。由于在Java中缺乏自动的析构函数调用,因此这将依赖于客户端程序员去编写正确的try-finally子句。
线程之间的协作
Q: 为什么要进行协作?
A: 正如你所见到的,当你使用线程来同时运行多个任务时,可以通过使用锁(互斥)来同步两个任务的行为,从而使得一个任务不会干涉另一个任务的资源,即如果两个任务在交替着步入某项共享资源(通常是内存),你可以使用互斥来使得任何时刻只有一个任务可以访问这项资源。
A: 现在的问题不是彼此之间的干涉,而是彼此之间的协调,因为在这类问题中,某些部分必须在其他部分被解决之前解决。
A: 这非常像项目规划:必须先挖房子的地基,但是接下来可以并行地铺设钢筋结构和构建水泥部分,而这两项任务必须在混凝土浇注之前完成。在这些任务中,某些可以并行执行,但是某些步骤需要所有的任务都结束之后才能开动。
Q: 协同的实现原理是什么?
A: 当任务协作时,关键问题是这些任务之间的握手。为了实现握手我们使用了相同的基础特性:互斥。在这种情况下,互斥能够确保一个任务可以响应某个信号,这样就可以根除任何可能的竞争条件。在互斥之上,我们为任务添加一种途径,可以将其自身挂起,直至某些外部条件发生变化,表示是时候让这个任务向前开动了为止。
A: 在本节,我们将浏览任务间的握手问题,这种握手可以通过Object的wait()和notify()来安全实现。
A: Java SE5的并发库还提供了具有await()和signal()方法的Condition对象。
Q: wait()方法和sleep()方法的区别?
A: wait()使你可以等待某个条件发生变化,而改变这个条件超出了当前方法的控制能力,通常这种条件将由另一个任务来改变。
A: 你肯定不想在你的任务测试这个条件的同时,不断地进行空循环,这被称为忙等待,通常是一种不良的CPU周期使用方式。因此wait()会在等待外部世界产生变化时将任务挂起,并且只有在notify()或notifyAll()发生时,即表示发生了某些感兴趣的事物,这个任务将会被唤醒并去检查所产生的变化。
A: 需要理解的是,调用sleep()的时候锁并没有被释放,调用yield()也属于这种情况。但是wait()不同,当一个任务在方法里遇到了对wait()的调用的时候,线程的执行将被挂起,对象的锁被释放,这就意味着另一个任务可以获得这个锁,因此在该对象中的其他synchronized方法可以在wait()期间被调用。理解这一点至关重要,因为这些其他的方法通常会产生变化,而这种改变正是使被挂起的任务重新唤醒所感兴趣的变化。
A: 因此,当你调用wai()时,就是在声明:“我已经刚刚做完能做的所有事情,因此我要在这里等待,但是我希望其他的synchronized操作在条件适合的情况下能够执行。”
Q: wait()方法有哪些形式?
A: 有两种形式的wai()。
A: 第一种版本接受毫秒数作为形参,含义与sleep()方法里形参意思相同,都是指“在此期间暂停”。但是与sleep不同的是:
1) 对于wait()而言,wait()期间对象锁是释放的;
2) 可以通过notify()、notifyAll(),或者令时间到期,从wait()中恢复执行。
A: 第二种版本也是更常用形式的wait()不接受任何形参,这种wait()将无限等待下去,直到线程接受到notify()或者notifyAll()消息。
Q: 为什么wait()方法是属于Object的,而不属于Thread?
A: wai()、notify()以及notifyAll()有一个比较特殊的方面,那就是这些方法是基类Object的一部分,而不是属于Thread的一部分。
A: 尽管开始看起来有点奇怪,仅仅针对线程的功能却作为通用基类的一部分而实现,不过这是有道理的,因为这些方法操作的锁也是所有对象的一部分。所以你可以把wait()放进任何同步控制方法里,而不用考虑这个类时继承自Thread还是实现了Runnable接口。
A: 实际上,只能在同步控制方法或者同步控制块里调用wait()、notify()和notifyAll();如果在非同步控制方法调用这些方法,程序能通过编译,但运行时将得到IllegalMonitorStateException异常,并伴随着一些含糊的消息,比如“当前线程不是拥有者”。消息的意思是调用wait()、notify()和notifyAll()的任务在这些方法前必须拥有相同的对象的锁。
比如,如果要向对象x发送notifyAll(),那么就必须在能够取得x的锁的同步控制块中这么做:
synchronized(x) {
x.notifyAll();
}
Q: wait()方法与while循环的结合?
A: 示例WaxOMatic.java有两个过程:一个是将蜡涂到Car上,一个是抛光它。抛光任务在涂腊任务完成之前是不能执行其工作的,而涂腊任务在涂另一层蜡之前,必须等待抛光任务完成。执行结果link
A: Car有一个单一的布尔属性mWaxState,表示涂腊-抛光处理的状态。
A: 在waitForWaxing()中检查mWaxState标志,如果它为false。那么这个调用任务将通过调用wait()而被挂起。这个行为发生在synchronized方法中这一点很重要,因为在这样的方法中,任务已经获得锁了,此时当你调用wait()时,线程被挂起,而锁被释放。锁被释放这一点是本质所在,因为为了安全地改变对象的状态,其他某个任务就必须能够获得该锁。在本示例中,如果另一个任务调用waxed()来表示“是时候该干点什么了”,那么就必须获得这个锁,从而将mWaxState改变为true。之后waxed()调用notifyAll(),这将唤醒在对wait()的调用中被挂起的任务。
A: WaxOn.run()表示给汽车打蜡过程的第一个步骤,因此它将执行它的操作:调用sleep()以模拟需要涂腊的时间,然后告知汽车涂腊结束,并调用waitForBuffing(),这个方法会用一个wait()调用来挂起这个任务,直至WaxOff任务调用这辆车的buffed(),从而改变状态并调用notifyAll()为止。另一方面,WaxOff.run()立即进入waitForWaxing(),并因此而被挂起,直至WaxOn涂完蜡并且waxed()被调用。
A: 在运行这个程序时,你可以看到当控制权在两个任务之间来回互相传递时,这两步骤过程在不断重复。在5秒之后,interrupt()会中止这两个线程。当你调用某个ExecutorService对象的shutdownNow()时,它会调用所有由它控制的线程的interrupt()。
A: 有一个地方值得强调一下,就是用了一个检查感兴趣的条件的while循环包围wait(),这很重要,因为:
1) 你可能有多个任务出于相同的原因在等待同一个锁,而第一个唤醒任务可能会改变这种状态。如果属于这种情况,那么这个任务应该被再次挂起,直至其感兴趣的条件发生变化;
2) 在这个任务从其wait()中被唤醒时刻,有可能会有某个其他的任务已经做出了改变,从而使得这个任务在此时不能执行,或者执行其操作已显得无关紧要。此时,应该通过再次调用wait()来将其重新挂起;
3) 也有可能某些任务出于不同的原因在等待你的对象上锁,在这种情况下必须使用notifyAll,你需要检查是否已经由正确的原因唤醒,如果不是,就再次调用wait()。
因此,其本质就是要检查所感兴趣的特定条件,并在条件不满足的情况下返回wait()中,惯用的方法就是使用while来编写这种代码。
Q: 错失的信号?
A: 当两个线程使用notify()/wait()或notifyAll()/wait()进行协作时,有可能会错过某个信号。
A: 假设T1是通知T2的线程,而下面的实现方式是有缺陷的:
T1:
synchronized (sharedMonitor) {
<setup condition for T2>
sharedMonitor.notify();
}
T2
while(someCondition) {
// Point 1
synchronized (sharedMonitor) {
sharedMonitor.wait();
}
}
A: <setup condition for T2>
是防止T2调用wait()的一个动作,当然前提是T2还没有调用wait()。假设T2对someCondition求值并发现其为true。在Point1,线程调度器可能切换到T1,而T1将其someCondition设置,然后调用notify。当T2得以继续执行时,此时对于T2来说,实际已经太晚了,以至于不能意识到这个条件已经发生了变化,因此会盲目进入wait()。此时notify()将错失,而T2也将无限地等待这个已经发送过的信号,从而产生了死锁。
A: 该问题的解决方案是防止在someCondition变量上产生竞争条件。下面是T2正确的执行方式:
synchronized(sharedMonitor) {
while(someCondition) {
sharedMonitor.wait();
}
}
A: 现在如果T1首先执行,当控制返回T2时,它将发现条件发生了变化,从而不会进入wait()。反过来,如果T2首先执行,那它将进入wait(),并且稍后回由T1唤醒,因此信号不会错失。
Q: 选择notify()还是notifyAll()?
A: 在技术上可能会有多个任务在单个Car对象上处于wait()状态,因此调用notifyAll()比只调用notify()要更安全。但是,上面示例WaxOMatic.java的程序结构只会有一个任务实际处于wait()状态。因此你可以使用notify()来代替notifyAll()
A: 使用notify()而不是notifyAll()是一种优化。下面规则中任何一条不满足,你就必须使用notifyAll()而不是notify():
1) 使用notify()时,在众多等待同一个锁的任务中只有一个会被唤醒,因此如果你希望使用notify()就必须保证被唤醒的是恰当的任务;
2) 为了使用notify(),所有任务必须等待相同的条件,因为如果你有多个任务在等待不同的条件,那么你就不会知道是否唤醒了恰当的任务。如果使用notify(),当条件发生变化时,必须只有一个任务能够从中受益;
3) 这些限制对所有可能存在的子类都必须总是起作用
Q: notifyAll()的困惑问题?
A: 在有关Java的线程机制讨论中,有一个令人困惑的描述:notifyAll()将唤醒“所有正在等待的任务”,这是否意味着在程序中任何地方,任何处于wait()状态中的任务都将被任何对notifyAll的调用唤醒呢?
A: 示例:NotifyVsNotifyAll.java,与Task2相关的代码说明了情况并非如此,事实上,当notifyAll()因某个特定锁而被调用时,只有等待这个锁的任务才会被唤醒。执行结果link
A: Task和Task2每个都有其自己的Blocker对象,因此每个Task对象都会在Task.sBlocker上阻塞,而每个Task2都会在Task2.sBlocker上阻塞。在main()中,java.util.Timer计时器对象被设置为每0.4秒执行一次run()方法,而这个run()方法将经由mProd交替地在Task.sBlocker上调用notify()和notifyAll()。
A: 从输出中你可以看到,即使存在Task2.sBlocker上阻塞的Task2对象,也没有任何在Task.sBlock上的notify()或notifyAll()调用会导致Task2对象被唤醒。
A: 在main()的结尾调用了timer的cancel(),即使计时器被撤销了,前5个任务也依然在运行,并依旧在它们对Task.sBlocker.waitingCall()的调用中被阻塞。
A: 对Task2.sBlocker.prodAll()的调用所产生的输出不包括任何在Task.sBlocker中的锁上等待的任务。
A: 如果你浏览Blocker中的prod()和prodAll(),会发现这些方法是synchronized的,这意味着它们将获取自身的锁,因此当它们调用notify()或notifyAll()时,只在这个锁上调用是符合逻辑的,因此这将只唤醒在等待这个特定锁的任务。
A: Blocker.waitingCall()非常简单,以至于在本例中,你只需声明for(;;)而不是while(!Thread.interrupted())就可以达到相同的效果,因为在本示例中,由于异常而离开循环和通过检查interrupted()标志离开循环时没有任何区别的。但是事实上这个示例选择了检查interrupted(),因为存在着两种离开循环的方式,如果在以后你决定要在循环中添加更多的代码,那么如果没有覆盖从这个循环中退出的这两条路径,就会产生引入错误的风险。
Q: 生产者与消费者?
A: 请考虑这样一个场景,饭店里有一个厨师和一个服务员,这个服务员必须等待厨师准备好膳食,当厨师准备好时,他会通知服务员,之后服务员上菜,然后返回继续等待。这是一个任务协作的示例。
A: 厨师代表生产者,而服务员代表消费者,两个任务必须在膳食被生产和消费时进行握手,而系统必须以有序的方式关闭。示例:Restaurant.java,执行结果link
A: Restaurant是WaitPerson和Chef的焦点,他们都必须知道在为哪个Restaurant工作,因为他们必须和这家饭店的餐窗(meal window)打交道,以便放置或拿取膳食Restaurant.mMeal。
A: 在WaitPerson.run()中,WaitPerson进入wait()模式,停止其任务,直至被Chef的notifyAll()唤醒。由于这是一个非常简单的程序,因此我们知道只有一个任务将在mRestaurant.mWaitPerson锁上等待。出于这个原因,理论上可以调用notfify()而不是notifyAll(),但是,在更复杂的情况下,可能会有多个任务在某个特定对象锁上等待,因此你不知道哪个任务应该被唤醒。因此,调用mRestaurant.mWaitPerson.notifyAll()要更安全一些,这样可以唤醒等待这个锁的所有任务,而每个任务都必须决定这个通知是否与自己相关。
A: 一旦Chef送上Meal并通知WaitPerson,这个Chef就将等待,直至WaitPerson收集到订单并通知Chef,之后Chef就可以烧下一份Meal了。注意wait()被包在一个while()语句中,这个语句在不断测试正在等待的同样的事情。正如前面注意到的,问题是在并发应用中,某个其他的任务可能会在WaitPerson被唤醒时,会突然插足并拿走订单,唯一安全的方式是使用下面这种wait()的惯用法,采用防止错失信号可能性的程序设计:
synchronized(sharedMonitor) {
while(someCondition) {
sharedMonitor.wait();
}
}
这可以保证在你退出等待循环之前,条件将得到满足,并且如果你收到关于某事情的通知,而它与这个条件并无关系,或者在你完全退出等待循环之前,这个条件发生了变化,都可以确保你可以重返等待状态。
请注意观察,对notifyAll()的调用必须首先获取mWaitPerson上的锁,而在WaitPerson.run()中对wait()的调用会自动地释放这个锁。
A: 通过把整个run()方法体放到一个try语句块中,可使得这两个run()方法都被设计为可以有序地关闭。catch子句将紧挨着run()方法的结束括号之前结束,因此如果这个任务收到了InterruptedException异常,它将在捕获异常之后立即结束。
A: 注意,在Chef中,在调用shutdownNow()之后,你应该直接从run()返回,并且通常这就是你应该做的。但是,以这种方式执行还有一些更有趣的东西。记住,shutdownNow()将向所有由ExecutorService启动的任务发送interrupt(),但是在Chef中,任务并没有在获得该interrupt()之后立即关闭,因为当任务试图进入一个可中断的阻塞操作时,这个中断只能抛出InterruptedException。因此,你将看到首先显示了“Order up!”,然后当Chef试图调用sleep()时,抛出了InterruptedException。如果移除对sleep()的调用,那么这个任务将回到run()循环的顶部,并由于Thread.interrupted()测试而退出,同时并不抛出异常。
A: 在本示例中,对于一个任务而言,只有一个单一的地点用于存放对象,从而使得另一个任务稍后可以使用这个对象。但是在典型的生产者-消费者实现中,应使用先进先出队列来存储被生产和消费的对象。
Q: 使用显示的Lock和Condition对象?
A: 使用互斥并允许任务挂起的基本类Condition,你可以通过在Condition上调用await()来挂起一个任务。当外部条件发生变化时,意味着某个任务应该继续执行时,你可以通过调用signal()来通知这个任务,从而唤醒一个任务,或者调用signalAll()来唤醒所有在这个Condition上被其自身挂起的任务。
A: 与使用Object.notifyAll()相比,Condition.signalAll()是更安全的方式。
示例:WaxOMatic2.java是WaxOMatic.java的重写版本,它包含一个Condition,用来在waitForWaxing()和waitForBuffering()内部挂起一个任务。执行结果link
A: 在Car对象中,单个的Lock将产生一个Condition对象,这个对象将被用来管理任务间的通信,但是这个Condition对象不包含任何有关处理状态的信息,因此你需要额外的表示处理状态的信息,即boolean mWaxState。
A: 每个对mLock.lock()的调用都必须紧跟一个try-finally子句,用来保证在所有情况下都可以释放锁。在使用内建(build-in)版本时,任务在可以调用await()、signal()或signalAll()之前,必须拥有这个锁。
A:
注意,这个解决方案比前一个更加复杂,在本示例中这种复杂性并未使你收获更多。Lock和Condition对象只有在更加困难的多线程问题中才是必需的。
Q: 生产者-消费者与队列?
A: wait()和notifyAll()方法以一种非常低级的方式解决了任务互操作问题,即每次交互时都握手。
A: 在许多情况下,你可以使用同步队列这种更高的抽象级别来解决任务协作问题,同步队列在任何时刻都只允许一个任务插入或移除元素。
A: 同步队列java.util.concurrent.BlockingQueue接口,该接口有大量的标准实现,你可以使用LinkedBlockingQueue无界队列(unbounded queue),还可以使用ArrayBlockingQueue固定尺寸的队列,因此你可以在它被阻塞之前,向其中放置有限数量的元素。
A: 如果消费者任务试图从队列中获取对象,而该队列此时为空,那么这些队列还可以挂起消费者任务,并且当有更多的元素可用时才恢复消费者任务。
A: 阻塞队列BlockingQueue可以解决显著的大量的问题,而其方式与wait()和notifyAll()相比,则简单并可靠得多。
A: 示例:TestBlockingQueues.java,这是一个简单的测试,它将多个LiftOff对象的执行串行化了,消费者是LiftOffRunner,它将每个LiftOff对象从BlockingQueue中pull出来并直接运行。选择ArrayBlockingQueue执行结果link
A: 注意,消费者LiftOffRunner是通过显示地调用run()而使用自己的线程来运行,而不是为每个任务启动一个新线程。
A: 注意,LiftOffRunner可以忽略同步问题,因为它们已经由BlockingQueue解决了。
A: LinkedBlockingQueue的实现实际是一个单链表,由表头和表尾维护
A: ArrayBlockingQueue的实现是一个循环队列
Q: 生产者-消费者与队列(二)?
A: 下面是吐司面包使用BlockingQueue的示例,有一台机器具有3个任务:一个专门做吐司面包的任务、一个专门给吐司面包抹黄油的任务、一个专门给抹过黄油的吐司面包涂果酱的任务,我们可以通过各个处理过程之间的BlockingQueue来运行这个吐司制作程序。
示例:ToastOMatic.java,执行结果link
A: 注意,这个示例中没有任何显示的同步,即使用Lock对象或者synchronized关键字的同步,因为同步由同步队列和系统的设计隐式地管理了,每一片Toast在任何时刻都只由一个任务在操作。因为队列的阻塞,使得处理过程将被自动地挂起和恢复。你可以看到由BlockingQueue产生的简化十分明显。
A: 在使用显示的wait()和notifyAll()时存在的类和类之间的耦合被消除了,因为每个类都只和它的BlockingQueue通信。
Q: 任务间使用管道进行输入/输出?
A: 通过输入/输出在线程间进行通信通常很有用,提供线程功能的类库以“管道”的形式对线程间的输入/输出提供了支持。
A: Java I/O类库中提供了PipedWriter类和PipedReader类,PipedWriter类允许任务向管道写,PipedReader类允许不同任务从同一个管道中读取,这个模型可以看成是“生产者-消费者”问题的变体,这里的管道就是一个封装好的解决方案。
A: 管道基本上是一个阻塞队列。
示例:PipedIO.java,执行结果link
A: Sender和Receiver代表了需要互相通信两个任务,Sender创建了一个PipedWriter,它是一个独立的对象;但是对于Receiver,PipedReader的建立必须在构造器中与一个PipedWriter相关联。 Sender把数据放进Writer,然后休眠一段时间(随机数),然而Receiver没有sleep()和wait(),但当它调用read()时,如果没有更多的数据,管道将自动阻塞。
A: 在shutdownNow()被调用时,可以看到PipedReader与普通I/O之间最重要的差异就是,PipedReader是可中断的,如果你将mPipedReader.read()调用修改为System.in.Read(),那么interrupt()将不能打断read()调用。
死锁
什么是死锁?
A: 现在你理解了,一个对象可以有synchronized方法或其他形式的加锁机制来防止别的任务在互斥还没有释放的时候就访问这个对象,任务可以变成阻塞状态,所以就可能出现这种情况:某个任务在等待另一个任务,而后者又等待别的任务,这样一直下去,直到这个链条上的任务又在等待第一个任务释放锁,你会得到一个连续的任务循环等待对方,没有哪个任务可以移动导致程序卡死,这被称为死锁(deadlock)
A: 如果你运行一个程序,而它马上就死锁了,你可以立即跟踪下去。真正的问题在于,程序可能看起来工作良好,但是具有潜在的死锁危险,这时死锁可能发生,而事先却没有任何征兆,所以缺陷会潜伏在你的程序里,直到客户发现它出乎意料地发生,以一种几乎肯定是很难复现的方式发生。因此,在编写并发程序的时候,进行仔细的程序设计以防止死锁是关键部分。
A: 由Edsger Dijkstra提出的哲学家就餐问题是一个经典的死锁案例。该问题的基本描述是指定5个哲学家(示例中允许任意数目),这些哲学家将花部分时间思考,花部分时间就餐。假设哲学家们都很穷,只能买5根筷子(假设筷子和哲学家的数量相同),他们围坐在桌子周围,每人之间放一个筷子,当一个哲学家要就餐时,他必须同时得到左边和右边的筷子。如果一个哲学家左边或右边已经有人在使用筷子了,那么这个哲学家就必须等待,直至可得到必需的筷子。
A: 任何两个Philosopher都不能成功take()同一根筷子。如果一根Chopstick已经被某个Philosopher获得,那么另一个Philosopher就只能wait()的了,直至这根Chopstick的当前持有者调用drop使其可用为止。
A: 当一个Philosopher任务调用take()时,这个Philosopher将等待,直至taken状态变为false(也就是当前持有Chopstick的Philosopher释放它),然后这个任务会将taken状态设置为true,以表示现在由新的Philosopher持有这根Chopstick。当这个Philosopher使用完这根Chopstick时,他会调用drop()来修改taken状态,并notifyAll()所有的其他Philosopher,这些Philosopher中有些可能就在wait()这根Chopstick。
A: 在Philosopher.run()中,每个Philosopher只是不断地思考和吃饭,如果mPonderFactor不为0,则pause()方法会休眠一段随机事件,通过使用这种方式,你将看到Philosopher会在思考上花费一段随机化时间,然后尝试获取右边和左边的Chopstick,随后在吃饭上再花费一段随机化时间,之后重复此过程。
A: 死锁示例:DeadlockingDiningPhilosophers.java,执行结果link
A: 你会发现,如果Philosopher花在思考上的时间非常少,那么当他们想要进餐时,全都会在Chopstick上产生竞争,而死锁也会更快地发生。
A: 第一个命令行参数可以调整ponder因子,从而影响每个Philosopher花费在思考上的时间长度;如果有许多Philosopher,或者他们花费很多时间去思考,那么尽管存在死锁的可能,但你可能永远也看不到死锁。值为0的命令行参数倾向于使死锁尽快发生。
A: Chopstick对象不需要内部标识符,它们都是由在数组sticks中的位置来表示。每个Philosopher构造器都会得到一个对左边和右边Chopstick对象的引用。注意,最后一个Philosopher右边的Chopstick是第0个Chopstick,这样整个循环表就结束了,现在所有的Philosopher都有可能希望进餐,从而等待其临近的Philosopher放下他们的Chopstick,这将使程序死锁。
Q: 如何解决死锁?
A: 要修正死锁问题,你必须明白,当以下4个条件同时满足时,就会发生死锁:
1) 互斥条件。任务使用的资源中至少有一个是不能共享的。上面哲学家就餐问题中,一个Chopstick一次就只能被一个Philosopher使用。
2) 至少有一个任务它必须持有一个资源且正在等待获取一个当前被别的任务持有的资源。也就是说,要发生死锁,Philosopher必须拿着一根Chopstick并且等待另一个根。
3) 资源不能被任务抢占,任务必须把资源释放当做普通事件。Philosopher很有礼貌,他们不会从其他Philosopher那里抢Chopstick。
4) 必须有循环等待,这时,一个任务等待其他任务持有的资源,后者又在等待另一个任务所持有的资源,这样一直下去,直到有一个任务在等待第一个任务所持有的资源,使得大家都被锁住。在DeadlockingDiningPhilosophers.java中,因为每个Philosopher都试图先得到右边的Chopstick,然后再得到左边的,所以发生了循环等待。
A: 因为要发生死锁的话,所有这些条件都必须全部满足,所以要防止死锁的话,只需破坏其中一个即可。
A: 在程序中,防止死锁最容易的方法是破坏第4个条件。有这个条件的原因是每个Philosopher都试图用特定的顺序拿Chopstick:先右后左。正因为如此,就可能会发生“每个人都拿着右边的Chopstick,并等待左边的Chopstick”的情况,这就是循环等待的条件。然而,如果最后一个Philosopher被初始化成先拿左边的Chopstick,后拿右边的Chopstick,那么这个Philospher将永远不会阻止其右边的Philosopher拿起他们的Chopstick了。
示例:FixedDiningPhilosophers.java,通过确保最后一个Philosopher先拿起和放下左边的Chopstick,我们可以移除死锁,从而使这个程序平滑地运行。
A: 在本例中,破坏第4条可以防止循环等待,这只是问题的解决方法之一,也可以通过破坏其他条件来防止死锁。具体细节请参考更高级的讨论线程的书籍。
A: Java对死锁并没有提供语言层面上的支持,能够通过仔细地设计程序来避免死锁,这取决于你自己。
新类库中的构件
Java SE5的java.util.concurrent引入了大量设计用来解决问题的新类,学习使用它们将有助于你编写出更加简单而健壮的并发程序。
Q: CountDownLatch类?
A: 它被用来同步一个或多个任务,强制它们等待由其他任务执行的一组操作完成。
A: 你可以向CountDownLatch对象设置一个初始计数值,任何在该对象上调用await()的方法都将阻塞,直至这个计数值到达0。其他任务在结束其工作时,可以在该对象上调用countDown()方法来减少这个计数值。
A: CountDownLatch被设计只触发一次,计数值不能被重置,如果你需要能够重置计数值的版本,则可以使用CyclicBarrier。
A: 调用countDown()的任务在产生这个调用时并没有被阻塞,只有对await()的调用会被阻塞,直至计数值到达0。
A: CountDownLatch的典型用法是将一个程序分为N个互相独立的可解决任务,并创建值为N的CountDownLatch。当每个任务完成时,都会在这个锁存器(latch)调用countDown()。等待问题被解决的任务在这个锁存器上调用await(),将它们自己拦住,直至锁存器计数结束。
示例:CountDownLatchDemo.java,执行结果link
A: TaskPortion将随机地休眠一段时间,以模拟这部分工作的完成,而WaitingTask表示系统中必须等待的部分,它要等待到问题的初始部分完成为止。所有任务都使用了在main()中定义的同一个单一的CountDownLatch。
Q: 类库的线程安全?
A: 注意,上面示例CountDownLatchDemo.java中的TaskPortion包含一个静态的Random对象,这意味着多个任何可能会同时调用Random.nextInt(),这是否安全呢?
A: 如果存在问题,在这种情况下,可以通过向TaskPortion提供其自己的Random对象来解决,也就是说,通过移除static限定符的方式解决。但是这个问题对于Java标准类库中的方法来说,也大都存在:哪些是线程安全的?哪些不是?
A: 遗憾的是,JDK文档并没有指出这一点,Random.nextInt()碰巧是安全的,但是唉你必须通过使用诸如google搜索引擎,或者审视Java类库代码去逐个揭示这一点,这对于被设计为支持,至少理论上支持并发的程序设计语言来说,并非是一件好事。
Q: CyclicBarrier类?
A: CyclicBarrier适用于这种情况:你希望创建一组任务,它们并行地执行工作,然后在进行下一个步骤之前等待,直至所有任务都完成,这有点像join()。
A: CyclicBarrier使得所有的并行任务(parallel tasks)都将在栅栏处队列(barrier),因此可以一致地向前移动,这非常像CountDownLatch,只是CountDownLatch是只触发一次的事件,而CyclicBarrier可以多次重用,这是区别一。
A: 赛马游戏示例:HorseRace.java,执行结果link
A: 可以向CyclicBarrier提供一个“栅栏动作”,它是一个Runnable,当计数值到达0时自动执行,这是CyclicBarrier和CountDownLatch的区别二(还记得吗CountDownLatch是需要主动调用countDown()的)。
A: 这里栅栏动作是作为匿名内部类创建的,它被提交给了CyclicBarrier的构造器。
A: CyclicBarrier使得每匹马都要执行为了向前移动所需执行的所有工作,然后必须要在栅栏处等待其他所有的马都准备完毕。当所有的马都要向前移动时,CyclicBarrier将自动调用Runnable栅栏动作任务,按顺序显示马和终点线的位置。
A: 一旦所有的任务都越过了栅栏,它就会自动地为下一回合比赛做好准备。这就是CyclicBarrier多次重用的结果。
Q: DelayQueue类?
A: 这是一个无界的BlockingQueue,用于放置实现了Delayed接口的对象,其中的对象只能在其到期时才能从队列中取走。
A: 这种队列是有序的,即队头的对象已经延迟到期的时间最长( has expired for the longest time)。如果没有任何延期到期,那么就不会有任何头元素,并且poll()将返回null,正因为如此,你不能将null放置到这种队列中。
A: 示例:DelayQueueDemo.java,执行结果link,其中的Delayed对象自身就是任务,而DelayedTaskConsumer将最“紧急”的任务(到期时间最长的任务,the one that has been expired for the longest time )从队列中取出,然后运行它。注意,这样DelayQueue就成了优先级队列的一种变体(variation)。
A: DelayedTask包含一个称为sSequence的List,它保存了任务被创建的顺序,因此我们可以看到排序是按照实际发生的顺序执行。
A: Delayed接口有一个方法名为getDelay(),它可以用来告知剩余延迟时间,返回值为零或负值指示延迟时间已经用尽(你可以理解返回值为how long it is until the delay time expires or how long ago the delay time has expired)。这个方法将强制我们使用TimeUnit类,因为这是形参类型,这会产生一个非常方便的类,因为你可以很容易地转换单位而无需做任何声明。例如,delta的值时以毫秒为单位存储的,但是Java SE5的方法System.nanoTime()产生的时间则是以纳秒为单位的,你可以转换delta的值,方法是声明它的单位以及你希望以什么单位来表示,就像下面这样:
TimeUnit.NANOSECONDS.convert(mDelta, TimeUnit.MILLISECONDS);
A: 在getDelay()中,希望使用的单位是作为unit形参传递过来的,你使用它将触发时间与当前时间之间的差转换为调用者要求的单位,而无需知道这些单位是什么。这就是策略设计模式(Strategy Design Pattern)的一种简单示例,在这种模式中,算法的一部分是作为形参传递进来的。
A: 为了排序,Delayed接口还继承了Comparable接口,因此必须实现compareTo(),使其可以产生合理的比较。
A: 嵌套的EndSentinel内部类提供了一种关闭所有任务的途径,具体做法就是将其放置为队列的最后一个元素,充当哨兵。
A: 注意,因为DelayedTaskConsumer自身是一个消费者任务,所以它有自己的Thread,它可以使用这个线程来运行从队列中获取的所有任务,由于任务是按照优先级队列的顺序执行,因此在本例中不需要启动任何单独的线程来运行DelayedTask。
A: 从输出中可以看到,任务创建的顺序对执行顺序没有任何影响,任务时按照所期望的延迟顺序执行的。
Q: PriorityBlockingQueue类?
A: 这是一个很基础的优先级队列,它具有可阻塞的读取操作。
A: 示例:PriorityBlockingQueueDemo.java,执行结果link,其中在优先级队列中的对象是按照优先级顺序从队列中出现的任务。PrioritizedTask被赋予了一个优先级数字,依次来提供这种顺序。
A: 与前一个示例相同,PrioritizedTask对象的创建序列被记录在sSequence List中,用于和实际的执行顺序比较。PrioritizedTask.run()方法将休眠一小段随机的时间,然后打印该对象信息,而PrioritizedTask.EndSentinel提供了和前面相同的功能,要确保它是队列里面中最后一个对象。
A: PrioritizedTaskProducer和PrioritizedTaskConsumer通过PriorityBlockingQueue彼此连接。因为这种队列的阻塞特性提供了所有必需的同步,所以你应该注意到了,这里不需要任何显示的同步,因此不必考虑当你从这种队列中读取时,其中是否有元素,因为这种队列在没有元素时,将直接阻塞读取者(reader)。
Q: ScheduledThreadPoolExecutor类?
A: 温室的控制系统示例:GreenhouseScheduler.java,执行结果link。
它可以控制各种设施的开关,或者对它们进行调节。这可以被看作是一种并发问题,每个期望的温室事件都是一个在预定时间运行的任务。
A: ScheduledThreadPoolExecutor提供了解决该问题的服务,通过使用schedule()或者scheduleAtFixedRate(),你可以将Runnable对象设置为在将来的某个时刻执行。这就是预定义工具(predefined tool)的好处。
- schedule() - 运行一次任务
- scheduleAtFixedRate() - 每隔规则的时间重复执行任务
A: 注意,volatile和synchronized在适当的场合都得到了应用,以防止任务之间的互相干涉,在持有DataPoint的List中的所有方法都是synchronized的,这是因为在List被创建时,使用了java.util.Collections实用工具synchronizedList()。
Q: Semaphore类?
A: 正常锁,即来自concurrent.locks或者内建的synchronized锁,在任何时刻都只允许一个任务访问一个资源,而计数信号量(counting semaphore)允许n个任务同时访问这个资源。你还可以将信号量看作是在向外分发使用资源的“许可证(permits)”,尽管实际上没有使用任何许可证对象。
A: 对象池(object pool)这种概念它管理着数量有限的对象,当要使用对象时可以签出(check out)它们,而在用户使用完毕时,可以将它们签回(check back in)。这种功能可以封装到一个泛型类Pool.java中。
A: Pool类在这个简化的形式中,构造器使用newInstance()来把对象加载到池中。如果你需要一个新对象,那么可以调用checkOut(),并且在使用完之后,将其递交给checkIn()。
A: boolean类型的数组mCheckedOutStates可以跟踪被签出的对象,并且可以通过getObject()和releaseObject()方法来管理,而这些都将由Semaphore类型的mAvailable来加以确保,因此,在checkOut()中,如果没有任何信号量许可证可用,这意味着在池中没有更多的对象了,mAvailable将阻塞调用过程。在checkIn()中,如果被签入的对象有效,则会将信号量返回一个许可证。
A: 为了创建一个示例,我们创建了Fat对象,它是一种创建代价高昂的对象类型,因为它的构造器运行起来非常耗时。我们在池中管理这些对象,以限制这个构造器所造成的影响。
A: 我们可以创建一个任务CheckoutTask,它将签出Fat对象,持有一段时间之后再将它们签入,以此来测试Pool这个类。
示例:SemaphoreDemo.java,执行结果link
A: 在main()中,创建了一个持有Fat对象的Pool,而一组CheckoutTask则开始操练这个Pool。然后main()线程签出池中的Fat对象,但是并不签入它们。
A: 一旦池中所有的对象都被签出,Semaphore将不再允许执行任何签出操作,blocked对象的run()方法因此会被阻塞,2s之后,cancel()方法被调用,依次来挣脱Future的束缚。
A: 注意,冗余的签入将被Pool忽略。
Q: Exchanger类?
A: Exchanger是在两个任务之间交换对象的栅栏(barrier)。当这些任务进入栅栏时,它们各自拥有一个对象,当它们离开时,它们都拥有之前由对象持有的对象。
A: 典型应用场景:一个任务在创建对象,这些对象的生产代价很高昂,而另一个任务在消费这些对象。通过这种方式,可以有更多的对象在被创建的同时被消费。
A: 示例:ExchangerDemo.java,执行结果link。经由泛型和Generator创建生产者和消费者任务,可以工作于任何类型的对象,我们将它们应用于Fat类。
A: ExchangerProducer和ExchangerConsumer使用一个List<T>
作为要交换的对象,它们包含一个用于这个List<T>
的Exchanger。当你调用Exchanger.exchange()方法时,它将阻塞直至对方任务(partner task)调用它自己的exchange()方法,那时,这两个exchange()方法将全部完成,而List则被互换。
A: 在main()中,创建了用于两个任务的单一的Exchanger,以及两个用于交换的CopyOnWriteArrayList。这个特定的List变体允许在列表被遍历时调用remove()方法,而不会抛出ConcurrentModificationException异常。
A: ExchangeProducer将填充这个List,然后将这个满列表交换为ExchangerConsumer传递给它的空列表,因为有了Exchanger,填充一个列表和消费一个列表可以同时发生了。
仿真
通过使用并发,可以模拟现实的一些场景,这就是仿真(Simulation)。许多视频游戏和电影中的CGI动画都是仿真,前面的HorseRace.java和GreenhouseScheduler.java也可以被认为是仿真。
Q: 银行柜员仿真
A: 这个典型的仿真可以表示任何属于下面这种类型的情况:对象随机地出现,并且要求由数量有限的服务器提供随机数量的服务时间。
A: 示例:BankTellerSimulation.java,执行结果link
A: 在这个示例中,每个银行客户都需要一定的服务时间,即银行柜员为满足客户的需要而必须花费在客户身上的时间单位。服务时间的数量对每个客户来说是不同的,另外,你不知道每个时间间隔内有多少客户会到达,因此这些都是随机数。
A: Customer对象非常简单,只包含一个域mServiceTime,因为这个mServiceTime从来都不发生变化,因此mServiceTime是只读对象,并且不需要同步或使用volatile。在这之上,每个Teller任务在任何时刻都只能从输入队列中移除Customer,并且在这个Customer上工作直至完成,因此Custommer在任何时刻都只由一个任务访问。
A: CustomerLine表示客户在等待被某个Teller服务时所排成的单一的行。
A: CustomerGenerator附着在CustomerLine上,按照随机的时间间隔向这个队列中添加Customer。
A: Teller从CustomerLine中取走Customer,在任何时刻他都只能处理一个客户,并且跟踪在这个特定的班次中有他服务的Customer的数量mCustomersServedNumber。当没有足够多的客户时,他会被告知去执行doSomethingElse(),而当出现了许多客户时,它会被告知去执行serveCustomerLine()。
A: 为了选择下一个柜员,让其回到服务客户的业务上,compareTo()方法将查看柜员服务过的客户数量,使得PriorityQueue可以自动地将工作量最小的柜员推向前台。
A: TellerManager是各种活动的中心,它跟踪所有的柜员以及等待服务的客户,它试图发现对于给定的客户流,给出最优的柜员的数量。你可以在adjustTellerNumber()中看到这一点,这是一个控制系统,它能够以稳定的方式添加或移除柜员。所有的控制系统都具有稳定性问题,如果它们对变化反应过快,那么它们就是不稳定的,而如果它们反应过慢,则系统会迁移到它的某种极端情况。
Q: 饭店仿真?
A: 本示例RestaurantWithQueues.java,main形参传入5执行结果link,扩充了前面描述的Restaurant.java示例,它还引用了Java SE5的SynchronousQueue,这是一个没有内容容量的阻塞队列,因此每个put()都必须等待一个take(),反之亦然。
A: 需要观察的一项非常重要的事项,就是使用队列在任务间通信所带来的管理复杂度,这个单项技术通过反转控制极大地简化了并发编程的过程:任务没有直接地互相干涉,而是经由队列互相发送对象。接收任务将处理对象,将其当做一个消息来对待,而不是像它发送消息。如果只要可能地遵循这项技术,那么你构建出健壮的并发系统的可能性就会大大增加。
Q: 分发工作?
A: 分发工作(distributing work)场景,假设有一条生产汽车的机器人装配线(robotic assembly line),每辆Car都将分成多个阶段构建,从创建底盘(chassis)开始,紧跟着安装发动机(engine)、传动系(drive train)和轮子(wheels)。
A: 示例:CarBuilder.java,执行结果link
A: Car是经由CarQueue从一个地方传送到另一个地方的,CarQueue是一种LinkedBlockingQueue类型。
A: ChassisBuilder创建了一个未加修饰的Car,并将它放到了一个CarQueue中。
A: Assembler从一个CarQueue中取走Car,并雇请Robot对其加工
A: CyclicBarrier使Assembler等待,直至所有的Robot都完成,并且在那一时刻它会将Car放置到即将离开它的CarQueue,然后传送到下一个操作。
A: 最终的CarQueue的消费者是一个Report对象,它只是打印Car,以显示所有的任务都已经正确地完成了。
A: Robot是在池中管理的,当需要完成工作时,就会从池中雇请适当的Robot。在工作完成时,这个Robot将会返回到池中。
A: 在main()中创建了所有必需的对象,并初始化了各个任务,最后启动ChassisBuilder,从而启动了整个过程(由于LinkedBlockingQueue的行为,使得最先启动它也没有问题)。注意,这个过程遵循了本章描述的所有有关对象和任务生命周期的设计原则,因此关闭这个过程将是安全的。
A: 你可能注意到,Car将其所有的方法都设置成了synchronized的,正如它所表现出来的那样,在本示例中这是多余的,因为在Car是通过队列移动的,并且在任何时刻,只有一个任务能够在某辆Car上工作。基本上,队列可以强制串行地访问Car。但是这正是你可能会落入的陷阱,你可能会说:“让我们尝试通过不对Car类同步来进行优化,因为看起来Car在这里并不需要同步”,但是稍后,当这个系统连接到另一个需要Car被同步的系统时,它就会崩溃。
性能调优
在Java SE5中的java.util.concurrent类库中存在着大量的用于性能提高的类,当你细读concurrent类库时就会发现很难辨认哪些类适用于常规应用(例如BlockingQueue),而哪些类只适用于提供性能。在本节我们将围绕性能调优来讨论
Q: 比较各类互斥技术(一)?
A: 下面的示例是一种比较天真的比较方法,它的意图是想比较synchronized关键字和Lock的性能。
示例:SimpleMicroBenchmark.java,执行结果link
A: 从输出的结果看,貌似synchronized方法的调用比使用ReentrantLock快,这是为什么呢?
A: 本示例演示了所谓的“微基准测试(microbenchmarking)”危险,这个术语通常指在隔离的、脱离上下文环境的情况下对某个特性进行性能测试。当然,你仍旧必须编写测试来验证诸如“Lock比synchronized更快”这样的断言,但是你需要在编写这些测试用例的时候意识到,在编译过程中和运行时,实际会发生了哪些。
A: 上面的示例存在着大量的问题,首先也是最重要的,我们只有在这些互斥存在竞争的情况下,才能看到真正的性能差异,因此必须保证有多个任务尝试访问互斥代码区,但上面的示例,每个互斥都是由单个的main线程在隔离的情况下测试。
A: 其次,当编译器看到synchronized关键字时,可能会执行特殊的优化,甚至有可能会注意到这个程序是单线程的。编译器甚至可能识别出counter被递增的次数是固定数量的,因此会预先计算出其结果。不同的编译器和运行时系统在这方面会有所差异,因此,很难确切地知道发生了什么,但是我们需要防止编译器去预测结果的可能性。
Q: 比较各类互斥技术(二)?
A: 为了创建有效的测试,我们必须使程序更加复杂,首先我们需要多个任务,但并不只是简单地修改内部值,还应该包括诸如读取值等操作,否则优化器可以识别出这些值重来都不会被使用。另外,计算过程也必须足够复杂和不可预测,以使得优化器没有机会执行积极优化。
A: 示例:SynchronizationComparisons.java,执行结果link
A: 该示例使用了模板方法的设计模式(Template Method design pattern),将所有共用的代码都放置到基类Accumulator中,并将所有不同的代码隔离在导出类的accumulate()和read()的实现中,在每个导出类SynchronizedTest、LockTest和AtomicTest中,你可以看到accumulate()和read()如何表达了实现互斥现象的不同方式。
A: 各个任务都是经由Executors.newFixedThreadPool执行,在执行过程中尝试着在开始时跟踪所有线程的创建,并且在测试过程中防止产生任何额外的开销,为了保险起见,初始测试执行了两次,而第一次的结果被丢弃,因为它包含了初始线程的创建。
A: 程序中必须有一个CyclicBarrier,因为我们希望确保所有的任务在声明每个测试完成之前都已经完成。
A: 每次调用accumulate()时,它都会移动到sPreLoaded数组的下一个位置,当到达数组尾部时在回到起始位置,并将这个位置的随机生成数字加到mValue上。
A: 多个Modifier和Reader任务提供了在Accumulator对象上的竞争。
A: 注意,在AtomicTest中,我发现情况过于复杂,使用Atomic对象已经不适合了,基本上如果涉及多个Atomic对象,你就有可能会被强制要求放弃这种用法,转而使用更加常规的互斥,JDK文档特别声明:当对一个对象的临界更新被限制为只涉及单个变量时,只有使用Atomic对象这种方式才能工作。但是这个测试仍旧保留下来,使你能够感受到Atomic对象的性能优势。
A: 在main()中,测试是重复运行的,并且你可以要求其重复次数超过5次,默认次数为5。对于每次重复,测试循环的数量都会加倍,因此你可以看到当运行次数越来越多时,这些不同的互斥在行为方面存在着怎样的差异。
A: 正如你从输出中可以看到的那样,测试结果相当惊人。对于前4次迭代,synchronized关键字看起来比使用Lock或Atomic要更高效,但是突然间越过某个门槛值之后,synchronized关键字似乎变得非常低效,而Lock和Atomic则显得大体维持着与BaseLine测试之间的比例关系,因此也就变得比synchronize关键字高效得多。
A: 记住,这个程序只给出了各种互斥方式之间的差异的趋势,而上面的输出也仅仅表示这些差异在我的特定环境下的特定机器上的表现。如你所见,如果自己动手试验,当所使用的线程数量不同,或者程序运行的时间更长时,在行为方面肯定会存在着明显的变化。
A: 也就是说,很明显使用Lock通常会比使用Synchronized要高效许多,而且synchronized的开销看起来变化范围太大,而Lock相对比较一致。
Q: 应选用哪种互斥技术?
A: 这是否意味着你永远都不应该使用synchronized关键字呢?这里有两个因素需要考虑。
A: 第一,在SynchronizationComparisons.java示例中,互斥方法的方法体是非常小的。通常这是一个很好的习惯,只互斥那些你绝对必须互斥的部分。但是在实际中被互斥部分可能要比这个示例的这些要大的多,因此在这些方法体中花费的时间的百分比可能会明显大于进入和退出互斥的开销,这样也就湮没了提高互斥速度带来的所有好处。当然,唯一了解这一点的方式是,当你对性能调优时,应该立即尝试各种不同的方法并观察它们造成的影响。
A: 第二,阅读本篇中的代码示例时会发现,很明显,synchronized关键字所产生的代码,与Lock所需的“lock try/finally unlock”惯用法所产生的代码相比,可读性提高了很多,这就是为什么本篇主要使用synchronized关键字的原因。代码被阅读的次数远多于被编写的次数,在编程时,与其他人交流相对于与计算机交流而言,要重要得多,因此代码可读性至关重要。因此,以synchronized关键字入手,只有在性能调优时才替换Lock对象,这种做法才具有实际意义。
Q: 免锁容器(Lock-free containers)?
A: 像Vector和Hashtable这类早期容器具有许多synchronized方法,当它们用于非多线程的场景时,便会导致不可接受的开销。
A: 在Java 1.2中,新的容器类库是不同步的(unsynchronized),并且Collections类提供了各种static的同步装饰方法,从而同步不同类型的容器,尽管这是一种改进,因为它使得你可以选择是否需要使用同步,但是这种开销仍旧是基于synchronized加锁机制的。
A: Java SE5特别添加了新的容器,通过使用更加灵巧的技术来取消加锁,从而提高线程安全的性能。
A: 这些免锁容器背后的通用策略是:对容器的修改可以与读取同时发生,只要读取能看到完成修改的结果即可。修改是在容器数据结构的某个部分的一个单独的副本,有时是整个数据结构的副本上执行的,并且这个副本在修改过程中是不可视的。只有当修改完成时,被修改的结构才会自动地与主数据结构进行交换,之后读取就可以看到这个修改了。
A: 在CopyOnWriteArrayList中,写入将导致创建整个底层数组的副本,而源数组将保留在原地,使得复制的数组在被修改时,读取操作可以安全地执行。当修改完成时,一个原子性操作将把新的数组换入,使得新的读取操作可以看到这个新的修改。
A: CopyOnWriteArrayList的好处之一是当多个迭代器同时遍历和修改这个列表时,不会抛出ConcurrentModificationException,因此你看不必编写特殊的代码去防范这种异常。
A: CopyOnWriteArraySet将使用CopyOnWriteArrayList来实现其免锁行为。
A: ConcurrentHashMap和ConcurrentLinkedQueue使用了类似的技术,允许并发地读取和写入,但是容器中只有部分内容而不是整个容器可以被复制和修改。然而,任何修改在完成之前,读取仍旧不能看到它们。ConcurrentHashMap不会抛出ConcurrentModificationException异常。
Q: CopyOnWriteArrayList和Collections.synchronizedList()的性能比较?
A: 只要你主要是从免锁容器中读取,那么它就会比其synchronized对应物快许多,因为获取和释放锁的开销被省掉了。如果需要向免锁容器执行“少量”写入,那么情况依旧如此,但是什么样才算是“少量”呢?
A: 我将从一个泛型框架着手,它专门用于在任何类型的容器上执行测试,包括各种Map在内,其中泛型参数C表示容器的类型。
A: 示例:ListComparisons.java,执行结果link
A: abstract修饰的Tester.containerInitializer()方法返回将被测试的初始化后的容器,它被存储在Tester<C>.mTestContainer
域中。另一个abstract修饰的Tester.startReadersAndWriters()方法启动读取和写入任务,它们将读取和修改待测容器。
A: 对不同数量的读取和写入进行不同的测试,以便观察到锁竞争(针对synchronized容器而言)和写入(针对免锁容器而言)的效果。
A: 我们向构造器提供了各种有关测试的信息(参数标识符应该是自解释的),然后它会调用runTest()方法repetitions次。runTest()将创建一个CountDownLatch(因此测试可以知道所有任务何时完成)、初始化容器,然后调用startReadersAndWriters(),并等待它们全部完成。
A: 每个Reader和Writer类都基于TestTask,它可以度量其抽象方法test()的执行时间,然后在一个synchronized块中调用putResults()去存储度量结果。
A: 为了使用这个框架(模板方法的设计模式the Template Method design pattern),我们必须让想要测试的特定类型的容器继承Tester,并提供合适的Reader和Writer类。
A: 在ListTest中,Reader和Writer类执行针对List的具体动作,在Reader.putResults()中mDuration被存储起来,mResult也一样,这样可以防止这些计算被优化掉。ListTest.startReadersAndWriters()被定义为创建和执行具体的Readers和Writers。
A: 一旦创建了ListTest,它就必须被进一步继承,以覆盖containerInitializer(),从而可以创建和初始化具体的测试容器。
A: 在main()中,你可以看到各种测试变体,它们具有不同数量的读取者和写入者。
A: 默认行为是为每个测试运行10次,这有助于稳定输出,而输出是可以变化的,因为存在着诸如热点优化(hotspot optimization)和垃圾回收(garbage collection)这样的JVM活动。
A: 你看到的样本输出已经被编辑为只显示每个测试的最后一个迭代,从输出中可以看到,Synchronized ArrayList无论读取和写入的数量是多少,都有大致的相同性能,即读取者与其他读取者竞争锁的方式与写入者相同。
A: 但是,CopyOnWriterArrayList在没有写入者时,速度会快许多,并且在有5个写入者时,速度仍旧明显很快。
A: 看起来你应该尽量使用CopyOnWriteArrayList,对列表写入的影响并没有超过短期同步整个列表的影响。当然你必须在你的具体应用中尝试这两种不同的方式,以了解到底哪个更好一些。
A: 再次注意,这还不是测试结果绝对不变的良好的基准测试(benchmark),你的结果几乎肯定是不同的,这里的目标只是让你对这两种不同类型的容器的相对行为有一个概念上的认识。
Q: ConcurrentHashMap和Collections.synchronizedMap()的性能比较?
A: 我们可以继续使用上面的代码框架得出SynchronizedHashMap和ConcurrentHashMap在性能方面的比较结果。
示例:MapComparisons.java,执行结果link
A: 向ConcurrentHashMap添加写入者的影响甚至还不如CopyOnWriteArrayList明显,这是因为ConcurrentHashMap使用了一种不同的技术,它可以明显地最小化写入所造成的影响。
A: 我们在跟到MapData.map()方法的内部时,看到会实例化一个MapData对象,由于该MapData是继承自LinkedHashMap类,而我们注意到MapData(Generator<K> genK, Generator<V> genV, int quantity)
构造器里,子类构造器执行体中既没有super调用,也没有this调用,系统将会在执行子类构造器之前,隐式调用父类无参数的构造器。 当调用子类构造器来初始化子类对象时,父类构造器总会在子类构造器之前执行;不仅如此,执行父类构造器时,系统会再次上溯执行其父类构造器……以此类推,创建任何java对象,最先执行的总是java.lang.Object类的构造器。
Q: 乐观加锁(Optimistic locking)?
A: 尽管Atomic对象将执行诸如decrementAndGet()这样的原子性操作,但是某些Atomic类还允许你执行所谓的“乐观加锁”,这意味着当你执行某项计算时,实际上没有使用互斥,但是在这项计算完成,并且准备更新这个Atomic对象时,你需要使用一个成为compareAndSet()的方法。你将旧值和新值一起提交给这个方法,如果旧值与它在Atomic对象中发现的值不一致,那么这个操作就失败,这意味着某个其他的任务已经在此操作执行期间修改了这个对象。
A: 记住,我们在正常情况下将使用互斥(synchronized或Lock)来防止多个任务同时修改一个对象,但是这里我们是“乐观的(optimistic)”,因为我们保持数据为未加锁状态,并希望没有任何其他任务插入修改它。所有这些又都是以性能的名义执行的,通过使用Atomic来替代synchronized或者Lock,可以获得性能上的好处。
A: 如果compareAndSet()操作失败会发生什么?这正是棘手的地方,也是你在应用这项技术时的受限之处,即只能针对能够吻合这些需求的问题。如果compareAndSet()失败,那么就必须决定做些什么,这是一个非常重要的问题,因为如果不能执行某些恢复操作,那么你就不能使用这项技术,从而必须使用传统的互斥。你可能会重试这个操作,如果在第二次成功,那么就万事大吉;或者可能会忽略这次失败,直接结束程序。
A: 考虑一个想象的仿真,它由长度为30的100000个基因构成,这可能是某种类型的遗传算法的起源。假设伴随着遗传算法的每次进化,都会发生某些代价高昂的计算,因此你决定使用一台多处理器机器来分布这些任务以提高性能。
示例:FastSimulation.java,执行结果link
A: 一开始,你使用synchronized关键字以最简单的方式编写了代码,一旦你运行该程序,发现它太慢了,并开始使用性能调优技术,你将使用Atomic对象而不是Lock对象来防止互斥开销。因为你的模型的特性,使得如果在计算过程中产生冲突,那么发现冲突的任务将直接忽略它,并不会更新它的值。
A: 每个Evolver对象会用它前一个元素和后一个元素来平均它的值,如果在更新时失败,那么将直接打印这个值并继续执行,注意这个程序中没有出现任何互斥。
Q: ReadWriteLock接口?
A: ReadWriteLock接口对向数据结构相对不频繁地写入,但是有多个任务要经常读取这个数据结构的这类情况进行了优化。ReadWriteLock使得你可以同时有多个读取者,只要它们都不试图写入即可。如果写锁已经被其他任务持有,那么任何读取者都不能访问,直至这个写锁被释放为止。
A: ReadWriteLock是否能够提高程序的性能是完全不可确定的,它取决于诸如数据被读取的频率与被修改的频率相比较的结果,读取和写入操作的时间有多少线程竞争以及是否在多处理器上运行等因素。最终,唯一可以了解ReadWriteLock接口是否能够给你的程序带来好处的方式就是用试验来证明。
A: 示例:ReaderWriterList.java,执行结果link
A: ReadWriterList可以持有固定数量的任何类型的对象,你必须向构造器提供所希望的列表尺寸和组装这个列表时所有的初始对象。set()方法要获取一个写锁,以调用底层的ArrayList.set(),而get()方法要获取一个读锁,一调用底层的ArrayList.get()。另外,get()将检查是否已经有多个读取者获取了读锁,如果是,则将显示这种读取者的数量,以证明可以有多个读取者获得读锁。
A: 为了测试ReaderWriterList,测试类ReaderWriterListTest为ReaderWriterList<Integer>
创建了读取者和写入者。写入者的数量远少于读取者。
A: 如果你在JDK文档中查看ReentrantReadWriteLock类,就会发现还有大量的其他方法可用,涉及“公平性”和“政策性问题”等问题。这是一个相当复杂的工具,只有当你在搜索可以提高性能的方法时,才应该想到用它。你的程序的第一个草案应该使用更直观的同步,并且只有在必需时再引入ReadWriteLock。
活动对象
当你通读本篇文章之后,可能会发现,Java中的线程机制看起来非常复杂并难以正确使用。另外它好像还有点达不到预期效果的味道,尽管多个任务可以并行工作,但是你必须花很大的力气去实现防止这些任务彼此相互干涉的技术。
是多线程模型自身有问题吗?毕竟,它来自于过程型编程世界,并且几乎没做什么改变。可能还存在着另一种不同的并发模型,它更加适合面向对象编程。
Q: 为什么会提出这个解决方案?
A: 有一种可替换的方式被称为活动对象(active objects)或行动者(actors)。之所以称这些对象是“活动的”,是因为每个对象维护着它自己的工作器线程和消息队列,并且所有对这种对象的请求都将进入队列排队,任何时刻都只能运行其中的一个。因此,有了活动对象,我们就可以串行化消息而不是方法,这意味着不再需要防备一个任务在其循环的中间被中断这种问题了。
A: 当你向一个活动对象发送消息时,这条消息就会转变为一个任务,该任务会被插入到这个对象的队列中,等待在以后的某个时刻运行。Java SE5的Future在实现这种模式时将派上用场。
示例:ActiveObjectDemo.java,执行结果link
A: 由对Executors.newSingleThreadExecutor()的调用产生的单线程执行器维护着它自己的无界阻塞队列,并且只有一个线程从该队列中取走任务并执行它们直到完成,我们需要在calculateInt()和calculateFloat()中做的就是用submit()提交一个新的Callable对象,以响应对这些方法的调用,这样就可以把方法调用转变为消息,而submit()的方法体包含在匿名内部类的call()方法中,注意,每个活动对象方法的返回值都是一个具有泛型参数的Future,而这个泛型参数就是该方法中实际的返回类型。通过这种方式,方法调用几乎可以立即返回,调用者可以使用Future来发现何时任务完成,并收集实际的返回值。这样可以处理最复杂的情况,但是如果调用没有任何返回值,那么这个过程将被简化。
A: 在main()中,创建一个List<Future<?>>
来捕获由发送给活动对象的calculateFloat()和calculateInt()消息返回的Future对象。对于每个Future,都是使用isDone()来从这个列表中抽取的,这种方式使得当Future完成并且其结果被处理过之后,就会从List中移除。注意使用CopyOnWriteArrayList可以移除为了防止ConcurrentModificationException而复制List的这种需求。
Q: 该方案有什么优势?
A: 为了能够在不经意间就可以防止线程之间的耦合,任何传递给活动对象方法调用的参数都必须是只读的其他活动对象,或者是不连接对象(disconnected objects),即没有连接任何其他任务的对象。
A: 有了活动对象:
1) 每个对象都可以拥有自己的工作器线程(worker thread);
2) 每个对象都将维护队它自己的域的全部控制权,这比普通的类要更严苛一些,普通的类只是拥有防护它们的域的选择权;
3) 所有在活动对象之前的通信都将以在这些对象之间的消息形式发生;
4) 活动对象之间的所有消息都要排队;
A: 这些结果很吸引人。由于从一个活动对象到另一个活动对象的消息只能被排队时的延迟所阻塞,并且因为这个延迟总是非常短且独立于任何其他对象的,所以发送消息实际上是不可阻塞的,最坏情况也只是很短的延迟。
A: 由于一个活动对象系统只是经由消息来通信,所以两个对象在竞争调用另一个对象上的方法时,是不会阻塞的,而这意味着不会发生死锁,这是一种巨大的进步。因为在活动对象的工作器线程在任何时刻只执行一个消息,所以不存在任何资源竞争,而你也不必操心应该如何同步方法。同步依旧会发生,但是它通过将方法调用排队,使得任何时刻都只能发生一个调用,从而将同步控制在消息级别上发生。
A: 遗憾的是,如果没有直接的编译器支持,上面这种活动对象编码方式实在是太过于麻烦了。但是这在活动对象和行动者领域,或者更有趣的被称为“基于代理的编程领域”,确实产生了进步。代理实际上就是活动对象,但是代理系统还支持跨网络和机器的透明性。通过搜索Web,你会发现更多有关活动对象、行动者或代理的信息,特别是某些在活动对象幕后的概念,它们来自C.A.R. Hoare的通信进程理论(Theory of Communicating Sequential Processes, CSP)。
总结
A: 介绍了使用Java线程进行并发程序设计的基础知识,你需要理解如下:
1) 可以运行多个独立的任务
2) 必须考虑当这些任务关闭时,可能出现的所有问题
3) 任何可能会在共享资源上彼此干涉。互斥(锁)是用来防止这种冲突的基本工具
4) 如果任务设计的不够仔细,就有可能会死锁
A: 明白什么时候应该使用并发、什么时候应该避免使用并发时非常关键的,使用它的原因主要有:
1) 要处理很多任务,它们交织在一起,应用并发能够更有效地使用计算机(包括在多个CPU上透明地分配任务的能力)
2) 要能够更好地组织代码
3) 要更便于用户使用
A: 均衡资源的经典案例是在等待输入/输出时使用CPU;更好的代码组织可以在仿真中看到;使用户方便的经典案例是长时间的下载过程中监视“停止”按钮是否被按下。
A: 线程的一个额外好处是它们提供了轻量级的执行上下文切换(大约100条指令),而不是重量级的进程上下文切换(要上千条指令)。因为一个给定进程内的所有线程共享相同的内存空间,轻量级的上下文切换只是改变了程序的执行序列和局部变量。进程切换(重量级的上下文切换)必须改变所有内存空间。
A: 多线程的主要缺陷有:
1) 等待共享资源的时候性能降低;
2) 需要处理线程的额外CPU花费;
3) 糟糕的程序设计导致不必要的复杂度;
4) 有可能产生一些病态行为,如饿死、竞争、死锁和活锁,多个运行各自任务的线程使得整体无法完成;
5) 不同平台导致的不一致性。
A: 因为多个线程可能共享一个资源,比如一个对象的内存,而且你必须确定多个线程不会同时读取和改变这个资源,这就是线程产生的最大难题,这需要明智地使用可用的加锁机制,例如synchronized关键字,它们仅仅是个工具,同时它们会引入潜在的死锁条件。所以要对它们有透彻的理解。
A: 此外,线程应用也需要一些技巧,Java允许你建立足够多的对象来解决问题,为工程上的有限元素分析而创建几百万个对象在Java中,如果不使用享元设计模式(Flyweight Design Pattern),是并不可行。然而,你要创建的线程数目看起来还是要有一个上界,因为达到一定数量之后,线程性能会很差。而这个临界点很难检测,通常依赖于操作系统和JVM,它可以是不足一百个线程,也可能是几千个线程。不过通常我们只是创建少数线程来解决问题,所以这个限制并不严重。