Java核心技术卷阅读随笔--第14章【并发】
并发
读者可能已经很熟悉操作系统中的多任务(multitasking):在同一刻运行多个程序的能力。 例如,在编辑或下载邮件的同时可以打印文件。今天,人们很可能有单台拥有多个 CPU 的 计算机,但是,并发执行的进程数目并不是由 CPU 数目制约的。操作系统将 CPU 的时间片分配给每一个进程, 给人并行处理的感觉。
多线程程序在较低的层次上扩展了多任务的概念:一个程序同时执行多个任务。通常, 每一个任务称为一个线程( thread), 它是线程控制的简称。可以同时运行一个以上线程的程 序称为多线程程序(multithreaded)。
那么,多进程与多线程有哪些区别呢? 本质的区别在于每个进程拥有自己的一整套变量, 而线程则共享数据。 这听起来似乎有些风险, 的确也是这样, 在本章稍后将可以看到这 个问题。然而,共享变量使线程之间的通信比进程之间的通信更有效、 更容易。 此外, 在有 些操作系统中,与进程相比较, 线程更“ 轻量级”, 创建、 撤销一个线程比启动新进程的开销要小得多。 在实际应用中, 多线程非常有用。例如, 一个浏览器可以同时下载几幅图片。一个 Web 服务器需要同时处理几个并发的请求。图形用户界面(GUI) 程序用一个独立的线程从宿主 操作环境中收集用户界面的事件。本章将介绍如何为 Java 应用程序添加多线程能力。
温馨提示:多线程可能会变得相当复杂。本章涵盖了应用程序可能需要的所有工具。尽 管如此,对于更复杂的系统级程序设计, 建议参看更高级的参考文献, 例如: Brian Goetz 等 编写的《Java Concurrency in Practice》(Addison-Wesley Professional, 2006) 。
14.1 什么是线程
这里从察看一个没有使用多线程的程序开始。用户很难让它执行多个任务。在对其进行 剖析之后,将展示让这个程序运行几个彼此独立的多个线程是很容易的。这个程序采用不断 地移动位置的方式实现球跳动的动画效果,如果发现球碰到墙壁, 将进行重绘(见图 14-1 )。
当点击 Start 按钮时, 程序将从屏幕的左上角弹出一个球,这个球便开始弹跳。Start 按 钮的处理程序将调用 addBall 方法。这个方法循 环运行 1000 次 move。 每调用一次 move, 球就 会移动一点, 当碰到墙壁时, 球将调整方向, 并重新绘制面板。
Ball ball = new Ball(); panel.add(ball); for (int i = 1; i <= STEPS;i++) { ball.move(panel.getBounds()); panel.paint(panel.getCraphics()); Thread.sleep(DELAY); }
调用 Threadsleep 不会创建一个新线程, sleep 是 Thread 类的静态方法,用于暂停当前线 程的活动。
sleep 方法可以抛出一个 InterruptedException 异常。稍后将讨论这个异常以及对它的处 理。现在,只是在发生异常时简单地终止弹跳。 如果运行这个程序,球就会自如地来回弹跳, 但是, 这个程序完全控制了整个应用程 序。如果你在球完成 1000 次弹跳之前已经感到厌倦了,并点击 Close 按钮会发现球仍然还在 弹跳。在球自己结束弹跳之前无法与程序进行交互。
注释: 如果仔细地阅读本节末尾的代码会看到 BounceFrame 类的 addBall 方法中有调用 comp.paint(comp.getGraphics())
这一点很奇怪。一般来说,应该调用 repaint 方法让 AWT 获得图形上下文并负责绘制。 但是, 如果试图在这个程序中调用 comp.repaint(), 在 addBall 方法返回以后才会重画画板。 另外,还要注意 ball 组件扩展于 JPanel ; 这会让擦除背景变得非常容易。接下来的程序将使 用一个专门的线程计算球的位置,并会重新使用大家熟悉的 repaint 和 JComponent。
显然,这个程序的性能相当糟糕。人们肯定不愿意让程序用这种方式完成一个非常耗时 的工作。毕竟,当通过网络连接读取数据时, 阻塞其他任务是经常发生的,有时确实想要中断读取操作。例如,假设下载一幅大图片。当看到一部分图片后,决定不需要或不想再看剩 余的部分了, 此时,肯定希望能够点击 Stop 按钮或 Back 按钮中断下载操作。下一节将介绍 如何通过运行一个线程中的关键代码来保持用户对程序的控制权。
程序清单丨4-1 ~ 程序清单 14-3 给出了这个程序的代码。略过
API java.lang.Thread 1.0 略过
14.1.1 使用线程给其他任务提供机会
可以将移动球的代码放置在一个独立的线程中, 运行这段代码可以提高弹跳球的响应能 力。实际上,可以发起多个球,每个球都在自己的线程中运行。另外,AWT 的事件分派线程 ( event dispatch thread) 将一直地并行运行,以处理用户界面的事件。由于每个线程都有机会 得以运行,所以在球弹跳期间,当用户点击 Close 按钮时,事件调度线程将有机会关注到这 个事件, 并处理“ 关闭” 这一动作。
这里用球弹跳代码作为示例,让大家对并发处理有一个视觉印象。通常, 人们总会提防 长时间的计算。这个计算很可能是某个大框架的一个组成部分, 例如,GUI 或 web 框架。无 论何时框架调用自身的方法都会很快地返回一个异常。如果需要执行一个比较耗时的任务, 应当并发地运行任务。 下面是在一个单独的线程中执行一个任务的简单过程:
1 ) 将任务代码移到实现了 Runnable 接口的类的 run 方法中。这个接口非常简单,只有 一个方法:
public interface Runnable { void run(); }
由于 Runnable 是一个函数式接口,可以用 lambda 表达式建立一个实例:
Runnable r = () -> { task code };
2 ) 由 Runnable 创建一个 Thread 对象:
Thread t = new Thread(r);
3 ) 启动线程:
t.start();
要想将弹跳球代码放在一个独立的线程中, 只需要实现一个类 BallRunnable, 然后,将 动画代码放在 run方法中,如同下面这段代码:
Runnable r = () -> { try { for (int i = 1; i <=STEPS; i ++) { ball.move(comp.getBounds()); comp.repaint(); Thread.sleep(DELAY); } } catch (InterruptedException e) { } }; Thread t = new Thread(r); t.start();
同样地, 需要捕获 sleep方法可能抛出的异常 InterruptedException。下一节将讨论这个异常。在 一般情况下, 线程在中断时被终止。因此,当发生 InterruptedException 异常时, run 方法将结束执行。 无论何时点击 Start 按钮, 球会移入一个新线程 (见图 14-2 ):
仅此而已!现在应该知道如何并行运行多个任务 了。本章其余部分将阐述如何控制线程之间的交互。 完整的代码见程序清单 14-4。
注释: 也可以通过构建一个 Thread 类的子类定义一个线程, 如下所示
class MyThread extends Thread { public void run() { task code } }
然后, 构造一个子类的对象, 并调用 start 方法。 不过, 这种方法已不再推荐。 应 该将要并行运行的任务与运行机制解耦合。如果有很多任务, 要为每个任务创建一个独立的线程所付出的代价太大了。可以使用线程池来解决这个问题, 有关内容请参看第 14.9 节。略过
API java..lang.Thread 1.0
• Thread(Runnable target) 构造一个新线程, 用于调用给定目标的 run() 方法。
• void start( ) 启动这个线程, 将引发调用 run() 方法。这个方法将立即返回, 并且新线程将并发运行。
• void run( ) 调用关联 Runnable 的 run 方法。
API java.lang.Runnable 1.0
• void run( ) 必须覆盖这个方法, 并在这个方法中提供所要执行的任务指令。
14.2 中断线程
当线程的 run 方法执行方法体中最后一条语句后, 并经由执行 return 语句返冋时,或者出现了在方法中没有捕获的异常时,线程将终止。 在 Java 的早期版本中, 还有一个 stop 方 法, 其他线程可以调用它终止线程。但是, 这个方法现在已经被弃用了。14.5.15 节将讨论它 被弃用的缘由。
没有可以强制线程终止的方法。然而,interrupt 方法可以用来请求终止线程。
当对一个线程调用 interrupt 方法时,线程的中断状态将被置位。这是每一个线程都具有 的 boolean 标志。每个线程都应该不时地检査这个标志, 以判断线程是否被中断。 要想弄清中断状态是否被置位,首先调用静态的 Thread.currentThread 方法获得当前线程, 然后调用 islnterrupted 方法:
while (!Thread.currentThread().islnterrupted() && more work to do) { do more work }
但是, 如果线程被阻塞, 就无法检测中断状态。这是产生 InterruptedException异常的地 方。当在一个被阻塞的线程(调用 sleep 或 wait) 上调用 interrupt 方法时,阻塞调用将会被 Interrupted Exception 异常中断。(存在不能被中断的阻塞 I/O 调用, 应该考虑选择可中断的调 用。有关细节请参看卷 2 的第 1 章和第 3 章) 。没有任何语言方面的需求要求一个被中断的线程应该终止。中断一个线程不过是引起它 的注意。被中断的线程可以决定如何响应中断。某些线程是如此重要以至于应该处理完异常 后, 继续执行,而不理会中断。但是,更普遍的情况是,线程将简单地将中断作为一个终止 的请求。这种线程的 run 方法具有如下形式:
Runnable r = () -> { try { ... while (!Thread.currentThread().islnterrupted() && more work to do) { do more work } } catch(InterruptedException e) { // thread was interr叩ted during sleep or wait } finally { cleanup,if required } // exiting the run method terminates the thread };
如果在每次工作迭代之后都调用 sleep 方法(或者其他的可中断方法,) islnterrupted 检测 既没有必要也没有用处。如果在中断状态被置位时调用 sleep 方法,它不会休眠。相反,它 将清除这一状态(!)并拋出 IntemiptedException。因此, 如果你的循环调用 sleep,不会检 测中断状态。相反,要如下所示捕获 InterruptedException 异常:
Runnable r = () -> { try { ... while { more work to do) { do more work Thread.sleep(delay); } } catch(InterruptedException e) { // thread was interrupted during sleep } finally { cleanup,if required } //exiting the run method terminates the thread };
注释: 有两个非常类似的方法,interrupted 和 islnterrupted。Interrupted 方法是一个静态 方法, 它检测当前的线程是否被中断。 而且, 调用 interrupted 方法会清除该线程的中断状态。另一方面,islnterrupted 方法是一个实例方法,可用来检验是否有线程被中断。调 用这个方法不会改变中断状态。
在很多发布的代码中会发现 InterruptedException 异常被抑制在很低的层次上, 像这样:
void mySubTask() { ... try { sleep(delay); } catch (InterruptedException e) {} // Don't ignore! }
不要这样做! 如果不认为在 catch 子句中做这一处理有什么好处的话,仍然有两种合理 的选择:
• 在 catch 子句中调用 Thread.currentThread().interrupt() 来设置中断状态。于是,调用者 可以对其进行检测。
void mySubTask() { ... try { sleep(delay); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } ... }
• 或者,更好的选择是,用 throws InterruptedException 标记你的方法, 不采用 try语句 块捕获异常。于是,调用者(或者, 最终的 run 方法)可以捕获这一异常。
void mySubTask() throws InterruptedException { ... sleep(delay); ... }
API java.lang.Thread 1.0
• void interrupts() 向线程发送中断请求。线程的中断状态将被设置为 true。如果目前该线程被一个 sleep 调用阻塞,那么,InterruptedException 异常被抛出。
• static boolean interrupted() 测试当前线程(即正在执行这一命令的线程)是否被中断。注意,这是一个静态方法。 这一调用会产生副作用—它将当前线程的中断状态重置为 false。
• boolean islnterrupted() 测试线程是否被终止。不像静态的中断方法,这一调用不改变线程的中断状态。
• static Thread currentThread() 返回代表当前执行线程的 Thread 对象。
14.3 线程状态
线程可以有如下 6 种状态:
• New (新创建)
• Runnable (可运行)
• Blocked (被阻塞)
• Waiting (等待)
• Timed waiting (计时等待)
• Terminated (被终止)
下一节对每一种状态进行解释。 要确定一个线程的当前状态, 可调用 getState 方法。
14.3.1 新创建线程
当用 new 操作符创建一个新线程时,如 newThread(r), 该线程还没有开始运行。这意味 着它的状态是 new。当一个线程处于新创建状态时,程序还没有开始运行线程中的代码。在 线程运行之前还有一些基础工作要做。
14.3.2 可运行线程
一旦调用 start 方法,线程处于 runnable 状态。一个可运行的线程可能正在运行也可能没 有运行, 这取决于操作系统给线程提供运行的时间。(Java 的规范说明没有将它作为一个单独 状态。一个正在运行中的线程仍然处于可运行状态。)
一旦一个线程开始运行,它不必始终保持运行。事实上,运行中的线程被中断,目的是 为了让其他线程获得运行机会。线程调度的细节依赖于操作系统提供的服务。抢占式调度系统给每一个可运行线程一个时间片来执行任务。当时间片用完,操作系统剥夺该线程的运行权, 并给另一个线程运行机会(见图 14-4 )。当选择下一个线程时, 操作系统考虑线程的优 先级—更多的内容见第 14.4.1 节。
现在所有的桌面以及服务器操作系统都使用抢占式调度。但是,像手机这样的小型设备 可能使用协作式调度。在这样的设备中,一个线程只有在调用 yield 方法、 或者被阻塞或等 待时,线程才失去控制权。
在具有多个处理器的机器上,每一个处理器运行一个线程, 可以有多个线程并行运行。 当然,如果线程的数目多于处理器的数目, 调度器依然采用时间片机制。
记住,在任何给定时刻,二个可运行的线程可能正在运行也可能没有运行(这就是为什么将这个状态称为可运行而不是运行)。
14.3.3 被阻塞线程和等待线程
当线程处于被阻塞或等待状态时,它暂时不活动。它不运行任何代码且消耗最少的资 源。直到线程调度器重新激活它。细节取决于它是怎样达到非活动状态的。
• 当一个线程试图获取一个内部的对象锁(而不是 java.util.concurrent 库中的锁) ,而该 锁被其他线程持有, 则该线程进入阻塞状态(我们在 14.5.3 节讨论 java.util.concurrent 锁,在 14.5.5 节讨论内部对象锁) 。当所有其他线程释放该锁,并且线程调度器允许本线程持有它的时候,该线程将变成非阻塞状态。
• 当线程等待另一个线程通知调度器一个条件时,它自己进入等待状态。我们在第 14.5.4 节来讨论条件。在调用 Object.wait 方法或 Thread.join 方法, 或者是等待 java, util.concurrent 库中的 Lock 或 Condition 时, 就会出现这种情况。实际上,被阻塞状态 与等待状态是有很大不同的。
• 有几个方法有一个超时参数。调用它们导致线程进入计时等待( timed waiting ) 状 态。这一状态将一直保持到超时期满或者接收到适当的通知。带有超时参数的方法有 Thread.sleep 和 Object.wait、Thread.join、 Lock.tryLock 以及 Condition.await 的计时版。
图 14-3 展示了线程可以具有的状态以及从一个状态到另一个状态可能的转换。当一个线 程被阻塞或等待时(或终止时,) 另一个线程被调度为运行状态。当一个线程被重新激活(例如, 因为超时期满或成功地获得了一个锁) ,调度器检查它是否具有比当前运行线程更高的优先级。如果是这样,调度器从当前运行线程中挑选一个, 剥夺其运行权,选择一个新的线程 运行。
14.3.4 被终止的线程
线程因如下两个原因之一而被终止:
• 因为 run 方法正常退出而自然死亡。
• 因为一个没有捕获的异常终止了 stop 方法而意外死亡。 特别是, 可以调用线程的 stop 方法杀死一个线程。 该方法抛出 ThreadDeath 错误对象, 由此杀死线程。但是,stop 方法已过时, 不要在自己的代码中调用这个方法。
API java.lang.Thread 1.0
• void join() 等待终止指定的线程。
• void join(long millis) 等待指定的线程死亡或者经过指定的毫秒数。
• Thread.State getState() 5.0 得到这一线程的状态;NEW、RUNNABLE、BLOCKED、 WAITING、 TIMED_WAITING 或 TERMINATED 之一。
• void stop( ) 停止该线程。这一方法已过时。
• void suspend() 暂停这一线程的执行。这一方法已过时。
• void resume() 恢复线程。这一方法仅仅在调用 suspendO 之后调用。这一方法已过时。
14.4 线程属性
下面将讨论线程的各种属性,其中包括:线程优先级、守护线程、 线程组以及处理未捕 获异常的处理器。
14.4.1 线程优先级
在 Java 程序设计语言中,每一个线程有一个优先级。默认情况下, 一个线程继承它的父线程的优先级。可以用 setPriority 方法提高或降低任何一个线程的优先级。可以将优先级设 置为在 MIN_PRIORITY (在 Thread 类中定义为 1 ) 与 MAX_PRIORITY (定义为 10 ) 之间的 任何值。NORM_PRIORITY 被定义为 5。
每当线程调度器有机会选择新线程时, 它首先选择具有较高优先级的线程。但是,线程优先级是高度依赖于系统的。当虚拟机依赖于宿主机平台的线程实现机制时, Java 线程的优先级被映射到宿主机平台的优先级上, 优先级个数也许更多,也许更少。
例如,Windows 有 7 个优先级别。一些 Java 优先级将映射到相同的操作系统优先级。在 Oracle 为 Linux 提供的 Java 虚拟机中,线程的优先级被忽略一所有线程具有相同的优先级。
初级程序员常常过度使用线程优先级。为优先级而烦恼是事出有因的。不要将程序构建为功能的正确性依赖于优先级。
警告: 如果确实要使用优先级, 应该避免初学者常犯的一个错误。如果有几个高优先级 的线程没有进入非活动状态, 低优先级的线程可能永远也不能执行。每当调度器决定运 行一个新线程时,首先会在具有高优先级的线程中进行选择, 尽管这样会使低优先级的线程完全饿死。
API java.lang.Thread 1.0 略过
14.4.2 守护线程
可以通过调用
t.setDaemon(true);
将线程转换为守护线程(daemon thread。) 这样一个线程没有什么神奇。守护线程的唯一用途 是为其他线程提供服务。计时线程就是一个例子,它定时地发送“ 计时器嘀嗒” 信号给其他线程或清空过时的高速缓存项的线程。当只剩下守护线程时, 虚拟机就退出了,由于如果只剩下守护线程, 就没必要继续运行程序了。
守护线程有时会被初学者错误地使用, 他们不打算考虑关机( shutdown) 动作。但是, 这是很危险的。守护线程应该永远不去访问固有资源, 如文件、 数据库,因为它会在任何时 候甚至在一个操作的中间发生中断。
API java.lang.Thread 1.0
• void setDaemon( boolean isDaemon ) 标识该线程为守护线程或用户线程。这一方法必须在线程启动之前调用。
14.4.3 未捕获异常处理器
线程的 run方法不能抛出任何受查异常, 但是,非受査异常会导致线程终止。在这种情 况下,线程就死亡了。
但是,不需要任何 catch子句来处理可以被传播的异常。相反,就在线程死亡之前, 异常被传递到一个用于未捕获异常的处理器。
该处理器必须属于一个实现 Thread.UncaughtExceptionHandler 接口的类。这个接口只有 —个方法。
void uncaughtException(Thread t, Throwable e)
可以用 setUncaughtExceptionHandler 方法为任何线程安装一个处理器。也可以用 Thread 类的静态方法 setDefaultUncaughtExceptionHandler 为所有线程安装一个默认的处理器。替换处理器可以使用日志 API 发送未捕获异常的报告到日志文件。
如果不安装默认的处理器, 默认的处理器为空。但是, 如果不为独立的线程安装处理 器,此时的处理器就是该线程的 ThreadGroup 对象。
注释: 线程组是一个可以统一管理的线程集合。默认情况下,创建的所有线程属于相同的线程组, 但是, 也可能会建立其他的组。现在引入了更好的特性用于线程集合的操作, 所以建议不要在自己的程序中使用线程组。
ThreadGroup 类实现 Thread.UncaughtExceptionHandler 接口。它的 uncaughtException 方 法做如下操作:
1 ) 如果该线程组有父线程组, 那么父线程组的 uncaughtException 方法被调用。
2 ) 否则, 如果 Thread.getDefaultExceptionHandler 方法返回一个非空的处理器, 则调用 该处理器。
3 ) 否则,如果 Throwable 是 ThreadDeath 的一个实例, 什么都不做。
4 ) 否则,线程的名字以及 Throwable 的栈轨迹被输出到 System.err 上。
这是你在程序中肯定看到过许多次的栈轨迹。
API java.lang.Thread 1.0 略过
API java.Iang.Thread.UncaughtExceptionHandler 5.0 略过
API java.lang.ThreadGroup 1.0 略过
14.5 同步
在大多数实际的多线程应用中, 两个或两个以上的线程需要共享对同一数据的存取。如果两个线程存取相同的对象, 并且每一个线程都调用了一个修改该对象状态的方法,将会发 生什么呢? 可以想象, 线程彼此踩了对方的脚。根据各线程访问数据的次序,可能会产生i化 误的对象。这样一个情况通常称为竞争条件(race condition)。
14.5.1 竞争条件的一个例子
为了避免多线程引起的对共享数据的说误,必须学习如何同步存取。在本节中,你会看 到如果没有使用同步会发生什么。在下一节中, 将会看到如何同步数据存取。
在下面的测试程序中,模拟一个有若干账户的银行。随机地生成在这些账户之间转移钱 款的交易。每一个账户有一个线程。每一笔交易中, 会从线程所服务的账户中随机转移一定 数目的钱款到另一个随机账户。
模拟代码非常直观。我们有具有 transfer 方法的 Bank 类。该方法从一个账户转移一定数 目的钱款到另一个账户(还没有考虑负的账户余额)。如下是 Bank类的 transfer 方法的代码。略过
这里是 Runnable 类的代码。它的 run 方法不断地从一个固定的银行账户取出钱款。在每 一次迭代中,run 方法随机选择一个目标账户和一个随机账户,调用 bank 对象的 transfer 方 法,然后睡眠。
当这个模拟程序运行时,不清楚在某一时刻某一银行账户中有多少钱。但是,知道所有 账户的总金额应该保持不变, 因为所做的一切不过是从一个账户转移钱款到另一个账户。
在每一次交易的结尾,transfer 方法重新计算总值并打印出来。
本程序永远不会结束。 只能按 CTRL+C 来终止这个程序。
正如前面所示,出现了错误。在最初的交易中, 银行的余额保持在 $100 000, 这是正确 的, 因为共 100 个账户, 每个账户 $1 000。但是, 过一段时间, 余额总量有轻微的变化。当 运行这个程序的时候, 会发现有时很快就出错了,有时很长的时间后余额发生混乱。这样的 状态不会带来信任感, 人们很可能不愿意将辛苦挣来的钱存到这个银行。
程序清单 14-5 和程序清单 14-6 中的程序提供了完整的源代码。 看看是否可以从代码中 找出问题。 下一节将解说其中奥秘。略过
14.5.2 竞争条件详解
上一节中运行了一个程序,其中有几个线程更新银行账户余额。一段时间之后, 错误不 知不觉地出现了,总额要么增加, 要么变少。当两个线程试图同时更新同一个账户的时候, 这个问题就出现了。假定两个线程同时执行指令:
accounts[to] += amount;
问题在于这不是原子操作。该指令可能被处理如下:
1 ) 将 accounts[to] 加载到寄存器。
2 ) 增 加 amount。
3 ) 将结果写回 accounts[to]。
现在,假定第 1 个线程执行步骤 1 和 2, 然后, 它被剥夺了运行权。假定第 2 个线程被 唤醒并修改了 accounts 数组中的同一项。然后,第 1 个线程被唤醒并完成其第 3 步。
这样, 这一动作擦去了第二个线程所做的更新。于是, 总金额不再正确。(见图 14-4)
我们的测试程序检测到这一讹误。(当然, 如果线程在运行这一测试时被中断,也有可能 会出现失败警告!)
注释:可以具体看一下执行我们的类中的每一个语句的虚拟机的字节码。运行命令
javap -c -v Bank 对 Bank.class 文件进行反编译。例如, 代码行 accounts[to] += amount; 被转换为下面的字节码 aload_0 getfield #2;//Field accounts:[D iload_2 dup2 daload dload_3 dadd dastore
这些代码的含义无关紧要。重要的是增值命令是由几条指令组成的, 执行它们的线 程可以在任何一条指令点上被中断。
出现这一讹误的可能性有多大呢? 这里通过将打印语句和更新余额的语句交织在一起执行,增加了发生这种情况的机会。
如果删除打印语句,讹误的风险会降低一点, 因为每个线程在再次睡眠之前所做的工作很少,调度器在计算过程中剥夺线程的运行权可能性很小。但是, 讹误的风险并没有完全 消失。如果在负载很重的机器上运行许多线程, 那么,即使删除了打印语句, 程序依然会出 错。这种错误可能会几分钟、几小时或几天出现一次。坦白地说, 对程序员而言,很少有比 无规律出现错误更糟的事情了。
真正的问题是 transfer 方法的执行过程中可能会被中断。如果能够确保线程在失去控制之前方法运行完成, 那么银行账户对象的状态永远不会出现讹误。
14.5.3 锁对象
有两种机制防止代码块受并发访问的干扰。Java语言提供一个 synchronized 关键字达 到这一目的,并且 Java SE 5.0 引入了 ReentrantLock 类。synchronized 关键字自动提供一个 锁以及相关的“ 条件”, 对于大多数需要显式锁的情况, 这是很便利的。但是, 我们相信在 读者分別阅读了锁和条件的内容之后, 理解 synchronized 关键字是很轻松的事情。java.util. concurrent 框架为这些基础机制提供独立的类,在此以及第 14.5.4 节加以解释这个内容。读 者理解了这些构建块之后,将讨论第 14.5.5 节。
用 ReentrantLock 保护代码块的基本结构如下:
myLock.lock(); // a ReentrantLock object try { critical section } finally { myLock.unlock();// make sure the lock is unlocked even if an exception is thrown }
这一结构确保任何时刻只有一个线程进入临界区。一旦一个线程封锁了锁对象, 其他任 何线程都无法通过 lock 语句。当其他线程调用 lock 时,它们被阻塞,直到第一个线程释放 锁对象。
警告: 把解锁操作括在 finally 子句之内是至关重要的。如果在临界区的代码抛出异常, 锁必须被释放。否则, 其他线程将永远阻塞。
注释: 如果使用锁, 就不能使用带资源的 try 语句。首先, 解锁方法名不是 close。不过, 即使将它重命名, 带资源的 try 语句也无法正常工作。它的首部希望声明一个新变量。但 是如果使用一个锁, 你可能想使用多个线程共享的那个变量(而不是新变量)。
让我们使用一个锁来保护 Bank 类的 transfer 方法。
public class Bank { private Lock bankLock = new ReentrantLock0;// ReentrantLock implements the Lock interface ... public void transfer(int from, int to, int amount) { bankLock.lock(); try { System.out.print(Thread.currentThread()); accounts[from] -= amount; System.out.printf(" %10.2f from %A to 5d", amount, from, to); accounts[to] += amount; System.out.printf(" Total Balance: X10.2fXn", getTotalBalance()); } finally { bankLock.unlock(); } } }
假定一个线程调用 transfer, 在执行结束前被剥夺了运行权。假定第二个线程也调用 transfer, 由于第二个线程不能获得锁, 将在调用 lock 方法时被阻塞。它必须等待第一个线程 完成 transfer 方法的执行之后才能再度被激活。当第一个线程释放锁时, 那么第二个线程才 能开始运行(见图 14-5)。
尝试一下。添加加锁代码到 transfer 方法并且再次运行程序。你可以永远运行它,而银行的余额不会出现讹误。
注意每一个 Bank 对象有自己的 ReentrantLock 对象。如果两个线程试图访问同一个 Bank 对象,那么锁以串行方式提供服务。但是, 如果两个线程访问不同的 Bank 对象, 每 一个线程得到不同的锁对象, 两个线程都不会发生阻塞。本该如此,因为线程在操纵不同的 Bank 实例的时候, 线程之间不会相互影响。
锁是可重入的, 因为线程可以重复地获得已经持有的锁。锁保持一个持有计数( hold count) 来跟踪对 lock 方法的嵌套调用。线程在每一次调用 lock 都要调用 unlock 来释放锁。 由于这一特性, 被一个锁保护的代码可以调用另一个使用相同的锁的方法。
例如,transfer 方法调用 getTotalBalance 方法, 这也会封锁 bankLock 对象,此时 bankLock 对象的持有计数为 2。当 getTotalBalance 方法退出的时候, 持有计数变回 1。当 transfer 方法退出的时候, 持有计数变为 0。线程释放锁。 通常, 可能想要保护需若干个操作来更新或检查共享对象的代码块。要确保这些操作完 成后, 另一个线程才能使用相同对象。
警告:要留心临界区中的代码,不要因为异常的抛出而跳出临界区。如果在临界区代码 结束之前抛出了异常,finally 子句将释放锁,但会使对象可能处于一种受损状态。
API java.util.concurrent.locks.Lock 5.0 略过
API java.util.concurrent.locks.ReentrantLock 5.0 略过
警告: 听起来公平锁更合理一些,但是使用公平锁比使用常规锁要慢很多。 只有当你确实了解自己要做什么并且对于你要解决的问题有一个特定的理由必须使用公平锁的时候, 才可以使用公平锁。 即使使用公平锁, 也无法确保线程调度器是公平的。 如果线程调度器选择忽略一个线程, 而该线程为了这个锁已经等待了很长时间, 那么就没有机会公平地处理这个锁了。
14.5.4 条件对象
通常, 线程进入临界区,却发现在某一条件满足之后它才能执行。要使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程。在这一节里, 我们介绍 Java 库中条件对象的实现。(由于历史的原因, 条件对象经常被称为条件变量( conditional variable ) 。 )
现在来细化银行的模拟程序。我们避免选择没有足够资金的账户作为转出账户。注意不 能使用下面这样的代码:
if (bank.getBalance(fron) >= amount) bank.transfer(from, to, amount);
当前线程完全有可能在成功地完成测试,且在调用 transfer 方法之前将被中断。
if (bank.getBalance(from) >= amount) // thread night be deactivated at this point bank.transfer(from, to, amount):
在线程再次运行前,账户余额可能已经低于提款金额。必须确保没有其他线程在本检査余额 与转账活动之间修改余额。通过使用锁来保护检査与转账动作来做到这一点:
public void transfer(int from, int to, int amount) { bankLock.1ock(); try { while (accounts[from] < amount) { // wait ... } // transfer funds ... } finally { bankLock.unlock(); } }
现在,当账户中没有足够的余额时, 应该做什么呢? 等待直到另一个线程向账户中注入 了资金。但是,这一线程刚刚获得了对 bankLock 的排它性访问, 因此别的线程没有进行存 款操作的机会。这就是为什么我们需要条件对象的原因。
一个锁对象可以有一个或多个相关的条件对象。你可以用 newCondition 方法获得一个条件对象。习惯上给每一个条件对象命名为可以反映它所表达的条件的名字。例如,在此设置 一个条件对象来表达“ 余额充足” 条件。
class Bank { private Condition sufficientFunds; ... public Bank() { ... sufficientFunds = bankLock.newCondition(); } }
如果 transfer 方法发现余额不足,它调用
sufficientFunds.await();
当前线程现在被阻塞了,并放弃了锁。我们希望这样可以使得另一个线程可以进行增加 账户余额的操作。 等待获得锁的线程和调用 await 方法的线程存在本质上的不同。一旦一个线程调用 await 方法, 它进入该条件的等待集。当锁可用时,该线程不能马上解除阻塞。相反,它处于阻塞 状态,直到另一个线程调用同一条件上的 signalAll 方法时为止。
当另一个线程转账时, 它应该调用
sufficientFunds.signalAll();
这一调用重新激活因为这一条件而等待的所有线程。当这些线程从等待集当中移出时, 它们再次成为可运行的,调度器将再次激活它们。同时, 它们将试图重新进人该对象。一旦 锁成为可用的,它们中的某个将从 await 调用返回, 获得该锁并从被阻塞的地方继续执行。 此时, 线程应该再次测试该条件。 由于无法确保该条件被满足—signalAll 方法仅仅是 通知正在等待的线程:此时有可能已经满足条件, 值得再次去检测该条件。
注释: 通常, 对 await 的调用应该在如下形式的循环体中
while (! (ok to proceed) ) condition.await();
至关重要的是最终需要某个其他线程调用 signalAll 方法。当一个线程调用 await 时,它 没有办法重新激活自身。它寄希望于其他线程。如果没有其他线程来重新激活等待的线程, 它就永远不再运行了。这将导致令人不快的死锁( deadlock) 现象。如果所有其他线程被阻塞, 最后一个活动线程在解除其他线程的阻塞状态之前就调用 await 方法, 那么它也被阻塞。 没有任何线程可以解除其他线程的阻塞,那么该程序就挂起了。
应该何时调用 signalAll 呢? 经验上讲, 在对象的状态有利于等待线程的方向改变时调用 signalAll。例如, 当一个账户余额发生改变时,等待的线程会应该有机会检查余额。在例子 中, 当完成了转账时, 调用 signalAll 方法。略过
注意调用 signalAll 不会立即激活一个等待线程。它仅仅解除等待线程的阻塞, 以便这些 线程可以在当前线程退出同步方法之后,通过竞争实现对对象的访问。
另一个方法 signal, 则是随机解除等待集中某个线程的阻塞状态。这比解除所有线程的 阻塞更加有效,但也存在危险。如果随机选择的线程发现自己仍然不能运行, 那么它再次被 阻塞。如果没有其他线程再次调用 signal, 那么系统就死锁了。
警告: 当一个线程拥有某个条件的锁时, 它仅仅可以在该条件上调用 await、signalAll 或 signal 方法。
如果你运行程序清单 14-7中的程序, 会注意到没有出现任何错误。总余额永远是 $100 000。没有任何账户曾出现负的余额(但是, 你还是需要按下 CTRL+C 键来终止程序)。你可能还 注意到这个程序运行起来稍微有些慢—这是为同步机制中的簿记操作所付出的代价。 略过
实际上, 正确地使用条件是富有挑战性的。 在开始实现自己的条件对象之前, 应该考虑 使用 14.10 节中描述的结构。
API java.util.concurrent.locks.Lock 5.0 略过
API java.util.concurrent.locks.Condition 5.0 略过
14.5.5 synchronized 关键字
在前面一节中, 介绍了如何使用 Lock 和 Condition 对象。在进一步深入之前,总结一下 有关锁和条件的关键之处:
• 锁用来保护代码片段, 任何时刻只能有一个线程执行被保护的代码。
• 锁可以管理试图进入被保护代码段的线程。
• 锁可以拥有一个或多个相关的条件对象。
• 每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程。
Lock 和 Condition 接口为程序设计人员提供了高度的锁定控制。然而,大多数情况下, 并不需要那样的控制,并且可以使用一种嵌入到 Java语言内部的机制。从 1.0 版开始,Java 中的每一个对象都有一个内部锁。如果一个方法用 synchronized关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,线程必须获得内部的对象锁。
换句话说,
public synchronized void method() { method body } 等价于 public void method() { this.intrinsidock.1ock(); try { method body } finally { this.intrinsicLock.unlock(); } }
例如, 可以简单地声明 Bank 类的 transfer 方法为 synchronized, 而不是使用一个显式的锁。 内部对象锁只有一个相关条件。wait 方法添加一个线程到等待集中,notifyAll / notify方 法解除等待线程的阻塞状态。换句话说,调用 wait 或 notityAll 等价于
intrinsicCondition.await();
intrinsicCondition.signalAll();
注释: wait、notifyAll 以及 notify 方法是 Object 类的 final 方法。Condition 方法必须被命 名为 await、signalAll 和 signal 以便它们不会与那些方法发生冲突。
例如,可以用 Java 实现 Bank 类如下:
class Bank { private double[] accounts; public synchronized void transfer(int from,int to, int amount) throws InterruptedException { while (accounts[from] < amount) wait(); // wait on intrinsic object lock's single condition accounts[from] -= amount ; accounts[to] += amount ; notifyAll();// notify all threads waiting on the condition } public synchronized double getTotalBalance(){ . . . } }
可以看到, 使用 synchronized 关键字来编写代码要简洁得多。当然,要理解这一代码,你 必须了解每一个对象有一个内部锁, 并且该锁有一个内部条件。由锁来管理那些试图进入 synchronized 方法的线程,由条件来管理那些调用 wait 的线程。
提示:Synchronized 方法是相对简单的。但是, 初学者常常对条件感到困惑。在使用 wait/ notifyAll 之前, 应该考虑使用第 14.10 节描述的结构之一。
将静态方法声明为 synchronized 也是合法的。如果调用这种方法,该方法获得相关的类对象的内部锁。例如,如果 Bank 类有一个静态同步的方法,那么当该方法被调用时,Bank.class 对象的锁被锁住。因此,没有其他线程可以调用同一个类的这个或任何其他的同步静态方法。
内部锁和条件存在一些局限。包括:
• 不能中断一个正在试图获得锁的线程。
• 试图获得锁时不能设定超时。
• 每个锁仅有单一的条件, 可能是不够的。
在代码中应该使用哪一种? Lock 和 Condition 对象还是同步方法?下面是一些建议:
• 最好既不使用 Lock/Condition 也不使用 synchronized 关键字。在许多情况下你可以使 用 java.util.concurrent 包中的一种机制,它会为你处理所有的加锁。例如, 在 14.6 节, 你会看到如何使用阻塞队列来同步完成一个共同任务的线程。还应当研究一下并行流,有关内容参见卷 2 第 1 章。
• 如果 synchronized 关键字适合你的程序, 那么请尽量使用它,这样可以减少编写的代 码数量,减少出错的几率。程序清单 14-8 给出了用同步方法实现的银行实例。略过
• 如果特别需要 Lock/Condition 结构提供的独有特性时,才使用 Lock/Condition。
API java.lang.Object 1.0 略过
14.5.6 同步阻塞
正如刚刚讨论的,每一个 Java 对象有一个锁。线程可以通过调用同步方法获得锁。还有另一种机制可以获得锁,通过进入一个同步阻塞。当线程进入如下形式的阻塞:
synchronized (obj) // this is the syntax for a synchronized block { critical section }
于是它获得 Obj 的锁。 有时会发现“ 特殊的” 锁,例如:
public class Bank { private double[] accounts; private Object lock = new Object(); public void transfer(int from, int to, int amount) { synchronized (lock) // an ad-hoc lock { accounts[from] -= amount; accounts[to] += amount; } System.out.print1n(...); } }
在此,lock 对象被创建仅仅是用来使用每个 Java 对象持有的锁。
有时程序员使用一个对象的锁来实现额外的原子操作, 实际上称为客户端锁定( clientside locking) 。例如,考虑 Vector 类,一个列表,它的方法是同步的。现在, 假定在 Vector 中存储银行余额。这里有一个 transfer 方法的原始实现:
public void transfer(Vector<Double> accounts, int from, int to, int amount)// Error { accounts.set(from, accounts.get(from)- amount); accounts.set(to, accounts.get(to) + amount); System.out.println(...); }
Vector 类的 get 和 set 方法是同步的, 但是,这对于我们并没有什么帮助。在第一次对 get 的调用已经完成之后,一个线程完全可能在 transfer 方法中被剥夺运行权。于是,另一个 线程可能在相同的存储位置存入不同的值。但是,我们可以截获这个锁:
public void transfer(Vector<Double> accounts, int from, int to, int amount) { synchronized (accounts) { accounts.set(fron, accounts.get(from)- amount): accounts.set(to, accounts.get(to) + amount); } Systen.out.print1n(. . .); }
这个方法可以工作,但是它完全依赖于这样一个事实, Vector 类对自己的所有可修改方法都使用内部锁。然而,这是真的吗? Vector 类的文档没有给出这样的承诺。不得不仔细研究源代码并希望将来的版本能介绍非同步的可修改方法。如你所见,客户端锁定是非常脆弱 的,通常不推荐使用。
14.5.7 监控器概念
锁和条件是线程同步的强大工具,但是,严格地讲,它们不是面向对象的。多年来,研究人员努力寻找一种方法,可以在不需要程序员考虑如何加锁的情况下,就可以保证多线程 的安全性。最成功的解决方案之一是监视器(monitor), 这一概念最早是由 PerBrinchHansen 和 Tony Hoare 在 20 世纪 70 年代提出的。用 Java 的术语来讲,监视器具有如下特性:
• 监视器是只包含私有域的类。
• 每个监视器类的对象有一个相关的锁。
• 使用该锁对所有的方法进行加锁。换句话说,如果客户端调用 obj.method(), 那 么 obj 对象的锁是在方法调用开始时自动获得, 并且当方法返回时自动释放该锁。因为所有的域是私有的,这样的安排可以确保一个线程在对对象操作时, 没有其他线程能访问该域。
• 该锁可以有任意多个相关条件。
监视器的早期版本只有单一的条件, 使用一种很优雅的句法。可以简单地调用 await accounts[from] >= balance 而不使用任何显式的条件变量。然而,研究表明盲目地重新测试条件是低效的。显式的条件变量解决了这一问题。每一个条件变量管理一个独立的线程集。
Java 设计者以不是很精确的方式采用了监视器概念, Java 中的每一个对象有一个内部的锁和内部的条件。如果一个方法用 synchronized 关键字声明,那么,它表现的就像是一个监视器方法。通过调用 wait/notifyAll/notify 来访问条件变量。
然而, 在下述的 3 个方面 Java 对象不同于监视器, 从而使得线程的安全性下降:
• 域不要求必须是 private。
• 方法不要求必须是 synchronized。
• 内部锁对客户是可用的。
这种对安全性的轻视激怒了 Per Brinch Hansen。 他在一次对原始 Java 中的多线程的严厉 评论中,写道:“ 这实在是令我震惊,在监视器和并发 Pascal 出现四分之一个世纪后,Java 的这种不安全的并行机制被编程社区接受。这没有任何益处。” [Java ’ s Insecure Parallelism, ACM SIGPLAN Notices 34:38-45, April 1999.]
14.5.8 Volatile 域
有时,仅仅为了读写一个或两个实例域就使用同步, 显得开销过大了。毕竟,什么地方能出错呢? 遗憾的是, 使用现代的处理器与编译器, 出错的可能性很大。
• 多处理器的计算机能够暂时在寄存器或本地内存缓冲区中保存内存中的值。结果是, 运行在不同处理器上的线程可能在同一个内存位置取到不同的值。 • 编译器可以改变指令执行的顺序以使吞吐量最大化。这种顺序上的变化不会改变代码语义,但是编译器假定内存的值仅仅在代码中有显式的修改指令时才会改变。然而, 内存的值可以被另一个线程改变!
如果你使用锁来保护可以被多个线程访问的代码,那么可以不考虑这种问题。 编译器被要求通过在必要的时候刷新本地缓存来保持锁的效应,并且不能不正当地重新排序 指令。详细的解释见 JSR 133 的 Java 内存模型和线程规范(参看 http://www.jcp.org/en/jsr/ detail?id=133 ) 。该规范的大部分很复杂而且技术性强,但是文档中也包含了很多解释得很 清晰的例子。在 http://www-106.ibm.com/developerworks/java/ library/j-jtp02244_html 有 Brian Goetz 写的一个更易懂的概要介绍。
注释:Brian Goetz 给出了下述 “ 同步格言”:“ 如果向一个变量写入值, 而这个变量接下 来可能会被另一个线程读取, 或者,从一个变量读值, 而这个变量可能是之前被另一个 线程写入的, 此时必须使用同步”。
volatile 关键字为实例域的同步访问提供了一种免锁机制。如果声明一个域为 volatile , 那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。 例如, 假定一个对象有一个布尔标记 done , 它的值被一个线程设置却被另一个线程査询,如同我们讨论过的那样,你可以使用锁:
private boolean done; public synchronized boolean isDone() { return done; } public synchronized void setDone() { done = true; }
或许使用内部锁不是个好主意。如果另一个线程已经对该对象加锁,isDone 和 setDone 方法可能阻塞。如果注意到这个方面, 一个线程可以为这一变量使用独立的 Lock。但是,这 也会带来许多麻烦。
在这种情况下,将域声明为 volatile 是合理的:
private volatile boolean done; public boolean isDone() { return done; } public void setDone() { done = true; }
警告: Volatile 变量不能提供原子性。例如, 方法 public void flipDone() { done = !done; } // not atomic
不能确保翻转域中的值。不能保证读取、 翻转和写入不被中断。
14.5.9 final 变置
上一节已经了解到, 除非使用锁或 volatile 修饰符,否则无法从多个线程安全地读取一 个域。
还有一种情况可以安全地访问一个共享域, 即这个域声明为 final 时。考虑以下声明:
final Map<string, double> accounts = new hashMap<>();
其他线程会在构造函数完成构造之后才看到这个 accounts 变量。 如果不使用 final,就不能保证其他线程看到的是 accounts 更新后的值,它们可能都只是 看到 null , 而不是新构造的 HashMap。
当然,对这个映射表的操作并不是线程安全的。如果多个线程在读写这个映射表,仍然需要进行同步。
14.5.10 原子性
假设对共享变量除了赋值之外并不完成其他操作,那么可以将这些共享变量声明为 volatile。
java.util.concurrent.atomic 包中有很多类使用了很高效的机器级指令(而不是使用锁) 来保证其他操作的原子性。 例如, Atomiclnteger 类提供了方法 incrementAndGet 和 decrementAndGet, 它们分别以原子方式将一个整数自增或自减。例如,可以安全地生成一个 数值序列,如下所示:
public static AtomicLong nextNumber = new AtomicLong() ; // In some thread... long id = nextNumber.increinentAndGet();
incrementAndGet 方法以原子方式将 AtomicLong 自增, 并返回自增后的值。也就是说, 获得值、 增 1 并设置然后生成新值的操作不会中断。可以保证即使是多个线程并发地访问同一个实例,也会计算并返回正确的值。
有很多方法可以以原子方式设置和增减值, 不过, 如果希望完成更复杂的更新,就必须使用 compareAndSet 方法。例如, 假设希望跟踪不同线程观察的最大值。下面的代码是不可行的:
public static AtonicLong largest = new AtomicLong(); // In some thread... largest.set(Math.max (largest,get(), observed)); // Error race condition!
这个更新不是原子的。实际上,应当在一个循环中计算新值和使用 compareAndSet:
do { oldValue = largest.get(); newValue = Math.max (oldValue , observed); } while (!largest.compareAndSet(oldValue, newValue));
如果另一个线程也在更新 largest,就可能阻止这个线程更新。这样一来,compareAndSet 会返回 false, 而不会设置新值。在这种情况下,循环会更次尝试,读取更新后的值,并尝试 修改。最终, 它会成功地用新值替换原来的值。这听上去有些麻烦, 不过 compareAndSet 方 法会映射到一个处理器操作, 比使用锁速度更快。
在 Java SE 8 中,不再需要编写这样的循环样板代码。实际上,可以提供一个 lambda 表 达式更新变量,它会为你完成更新。对于这个例子,我们可以调用:
largest. updateAndGet(x -> Math .max(x, observed)) ;
或
1argest.accumulateAndCet(observed , Math::max);
accumulateAndGet 方法利用一个二元操作符来合并原子值和所提供的参数。
还有 getAndUpdate 和 getAndAccumulate 方法可以返回原值。
注释: 类 Atomiclnteger、AtomicIntegerArray、AtomicIntegerFieldUpdater、AtomicLongArray、 AtomicLongFieldUpdater、AtomicReference、AtomicReferenceArray 和 AtomicReferenceFieldUpdater 也提供了这些方法。
如果有大量线程要访问相同的原子值,性能会大幅下降,因为乐观更新需要太多次重试。Java SE 8 提供了 LongAdder 和 LongAccumulator 类来解决这个问题。LongAdder 包括多 个变量(加数) ,其总和为当前值。可以有多个线程更新不同的加数,线程个数增加时会自动 提供新的加数。通常情况下, 只有当所有工作都完成之后才需要总和的值, 对于这种情况, 这种方法会很高效。性能会有显著的提升。
如果认为可能存在大量竞争, 只需要使用 LongAdder 而不是 AtomicLong。方法名稍有区 别。调用 increment 让计数器自增,或者调用 add 来增加一个量, 或者调用 sum 来获取总和。
final LongAdder adder = new LongAdder(); for (...) pool .submit(() -> { while (...) { ... if (...) adder.increment(); } }); ... long total = adder.sum());
注释: 当然,increment 方法不会返回原值。这样做会消除将求和分解到多个加数所带来 的性能提升。
LongAccumulator 将这种思想推广到任意的累加操作。在构造器中,可以提供这个操作 以及它的零元素。要加入新的值, 可以调用 accumulate。调用 get 来获得当前值。下面的代 码可以得到与 LongAdder 同样的效果:
LongAccumulator adder = new LongAccumulator(Long::sum, 0); // In some thread... adder.accumulate(value);
在内部,这个累加器包含变量 a1,a2,...,an。 每个变量初始化为零元素(这个例子中零元 素为 0) 。
调用 accumulate 并提供值 v 时,其中一个变量会以原子方式更新为ai = ai op v, 这里 op 是中缀形式的累加操作。在我们这个例子中,调用 accumulate 会对某个 i 计 算 ai = ai + v。
get 的结果是 a1 op a2 op ... op an 在我们的例子中, 这就是累加器的总和:a1+a2+...+an。
如果选择一个不同的操作,可以计算最小值或最大值。一般地, 这个操作必须满足结合律和交换律。这说明, 最终结果必须独立于所结合的中间值的顺序。
另外 DoubleAdder 和 DoubleAccumulator 也采用同样的方式, 只不过处理的是 double 值。
14.5.11 死锁
锁和条件不能解决多线程中的所有问题。考虑下面的情况:
账户 1: $200
账户 2: $300
线程 1: 从账户 1 转移 $300 到账户 2
线程 2: 从账户 2 转移 $400 到账户 1
如图 14-6 所示, 线程 1 和线程 2 都被阻塞了。因为账户 1 以及账户 2 中的余额都不足以 进行转账,两个线程都无法执行下去。 有可能会因为每一个线程要等待更多的钱款存入而导致所有线程都被阻塞。这样的状态 称为死锁(deadlock )。
在这个程序里,死锁不会发生, 原因很简单。每一次转账至多 $1 000。因为有 100 个账 户,而且所有账户的总金额是 $100 000, 在任意时刻, 至少有一个账户的余额髙于 $1 000。 从该账户取钱的线程可以继续运行。
但是, 如果修改 run 方法,把每次转账至多 $1 000 的限制去掉,死锁很快就会发生。试 试看。将 NACCOUNTS 设为 10。每次交易的金额上限设置为 2 * INITIAL_BALANCE, 然后运行该程序。程序将运行一段时间后就会挂起。
提示: 当程序挂起时, 键入 CTRL+\, 将得到一个所有线程的列表。每一个线程有一个栈踪迹, 告诉你线程被阻塞的位置。像第 7 章叙述的那样, 运行 jconsole 并参考线程面板 (见图 14-7 )。
导致死锁的另一种途径是让第i个线程负责向第i个账户存钱,而不是从第i个账户取钱。 这样一来,有可能将所有的线程都集中到一个账户上,每一个线程都试图从这个账户中取出大于该账户余额的钱。试试看。在 SynchBankTest 程序中,转用 TransferRunnable 类的 run方法。 在调用 transfer 时,交换 fromAccount 和 toAccount。运行该程序并查看它为什么会立即死锁。
还有一种很容易导致死锁的情况: 在 SynchBankTest 程序中, 将 signalAll 方法转换 为 signal , 会发现该程序最终会挂起(将 NACCOUNTS 设为 10 可以更快地看到结果)。 signalAll 通知所有等待增加资金的线程, 与此不同的是 signal方法仅仅对一个线程解锁。如果该线程不能继续运行,所有的线程可能都被阻塞。考虑下面这个会发生死锁的例子。
账户1:$ 1990
所有其他账户:每一个 $990
线程 1: 从账户 1 转移 $995 到账户 2
所有其他线程: 从他们的账户转移 $995 到另一个账户
显然, 除了线程 1, 所有的线程都被阻塞, 因为他们的账户中没有足够的余额。
线程1 继续执行,运行后出现如下状况:
账户 1: $995
账户 2: $1985
所有其他账户:每个 $990
然后,线程 1 调用 signal。signal 方法随机选择一个线程为它解锁。假定它选择了线程 3。 该线程被唤醒, 发现在它的账户里没有足够的金额,它再次调用 await。但是,线程 1 仍在 运行,将随机地产生一个新的交易,例如,
线程1:从账户 1 转移 $997 到账户 2
现在,线程 1 也调用 await, 所有的线程都被阻塞。系统死锁。
问题的起因在于调用 signal。它仅仅为一个线程解锁, 而且,它很可能选择一个不能继 续运行的线程(在我们的例子中,线程 2 必须把钱从账户 2 中取出)。
遗憾的是,Java 编程语言中没有任何东西可以避免或打破这种死锁现象。必须仔细设计 程序, 以确保不会出现死锁。
14.5.12 线程局部变量
前面几节中, 我们讨论了在线程间共享变量的风险。有时可能要避免共享变量, 使用 ThreadLocal 辅助类为各个线程提供各自的实例。 例如,SimpleDateFormat 类不是线程安全的。 假设有一个静态变量:
public static final SimpleDateFormat dateFormat = new SimpleDateForniat("yyyy-MM-dd");
如果两个线程都执行以下操作:
String dateStamp = dateFormat.format(new Date());
结果可能很混乱,因为 dateFormat 使用的内部数据结构可能会被并发的访问所破坏。当 然可以使用同步,但开销很大; 或者也可以在需要时构造一个局部 SimpleDateFormat 对象, 不过这也太浪费了。
要为每个线程构造一个实例,可以使用以下代码:
public static final ThreadLocal<SimpleDateFormat> dateFormat = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
要访问具体的格式化方法,可以调用:
String dateStamp = dateFormat.get().format(new Date());
在一个给定线程中首次调用 get 时, 会调用 initialValue 方法。在此之后, get 方法会返回 属于当前线程的那个实例。
在多个线程中生成随机数也存在类似的问题。java. util.Random 类是线程安全的。但是如果多个线程需要等待一个共享的随机数生成器, 这会很低效。 可以使用 ThreadLocal 辅助类为各个线程提供一个单独的生成器, 不过 Java SE 7 还另外 提供了一个便利类。只需要做以下调用:
int random = ThreadLocalRandom.current().nextlnt(upperBound):
ThreadLocalRandom.current() 调用会返回特定于当前线程的 Random 类实例。
API java.lang.ThreadLocal<T> 1.2 略过
API java.util.concurrent.ThrealLocalRandom 7 略过
14.5.13 锁测试与超时
线程在调用 lock 方法来获得另一个线程所持有的锁的时候,很可能发生阻塞。应该更加 谨慎地申请锁。tryLock 方法试图申请一个锁, 在成功获得锁后返回 true, 否则, 立即返回 false, 而且线程可以立即离开去做其他事情。
if (myLock.tryLock()) { // now the thread owns the lock try { . . . } finally { myLock.unlock(); } } else // do something else
可以调用 tryLock 时,使用超时参数,像这样:
if (myLock.tryLock(100, TimeUnit.MILLISECONDS)) . . .
TimeUnit 是一 枚举类型,可以取的值包括 SECONDS、MILLISECONDS, MICROSECONDS 和 NANOSECONDS。
lock 方法不能被中断。如果一个线程在等待获得一个锁时被中断,中断线程在获得锁之前一直处于阻塞状态。如果出现死锁, 那么,lock 方法就无法终止。
然而, 如果调用带有用超时参数的 tryLock, 那么如果线程在等待期间被中断,将抛出 InterruptedException 异常。这是一个非常有用的特性,因为允许程序打破死锁。
也可以调用 locklnterruptibly 方法。它就相当于一个超时设为无限的 tryLock 方法。
在等待一个条件时, 也可以提供一个超时:
myCondition.await(100, TineUniBILLISECONDS))
如果一个线程被另一个线程通过调用 signalAll 或 signal 激活 或者超时时限已达到,或 者线程被中断, 那么 await 方法将返回。
如果等待的线程被中断, await 方法将抛出一个 InterruptedException 异常。在你希望出 现这种情况时线程继续等待(可能不太合理,) 可以使用 awaitUninterruptibly 方法代替 await。
API java.util.concurrent.locks.Lock 5.0 略过
API java.util.concurrent.locks.Condition 5.0 略过
14.5.14 读/写锁
java.util.concurrent.locks 包 定 义 了 两 个 锁 类, 我 们 已 经 讨 论 的 ReentrantLock 类 和 ReentrantReadWriteLock 类。 如果很多线程从一个数据结构读取数据而很少线程修改其中数 据的话, 后者是十分有用的。在这种情况下, 允许对读者线程共享访问是合适的。当然,写 者线程依然必须是互斥访问的。
下面是使用读 / 写锁的必要步骤:
1 ) 构 造 一 个 ReentrantReadWriteLock 对象:
private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock():
2 ) 抽取读锁和写锁:
private Lock readLock = rwl . readLock();
private Lock writeLock = rwl .writeLock();
3 ) 对所有的获取方法加读锁:
public double getTotalBalance() { readLock.lock(); try { . . . } finally { readLock.unlock(); } }
4 ) 对所有的修改方法加写锁:
public void transfer(. . .) { writeLock.lock(); try { . . . } finally { writeLock.unlock(); } }
API java.util.concurrent.locks.ReentrantReadWriteLock 5.0
• Lock readLock( ) 得到一个可以被多个读操作共用的读锁, 但会排斥所有写操作。
• Lock writeLock( ) 得到一个写锁, 排斥所有其他的读操作和写操作。
14.5.15 为什么弃用 stop 和 suspend 方法
初始的 Java 版本定义了一个 stop 方法用来终止一个线程, 以及一个 suspend 方法用来阻 塞一个线程直至另一个线程调用 resume。stop 和 suspend 方法有一些共同点:都试图控制一 个给定线程的行为。
stop、 suspend 和 resume 方法已经弃用。stop 方法天生就不安全,经验证明 suspend 方法会经常导致死锁。在本节, 将看到这些方法的问题所在,以及怎样避免这些问题的出现。
首先来看看 stop 方法, 该方法终止所有未结束的方法, 包括 run方法。当线程被终止,立 即释放被它锁住的所有对象的锁。这会导致对象处于不一致的状态。例如 ’ 假定 TransferThread 在从一个账户向另一个账户转账的过程中被终止,钱款已经转出,却没有转入目标账户,现在银行对象就被破坏了。因为锁已经被释放,这种破坏会被其他尚未停止的线程观察到。
当线程要终止另一个线程时, 无法知道什么时候调用 stop 方法是安全的, 什么时候导致 对象被破坏。因此,该方法被弃用了。在希望停止线程的时候应该中断线程, 被中断的线程会在安全的时候停止。
注释:一些作者声称 stop 方法被弃用是因为它会导致对象被一个已停止的线程永久锁定。 但是,这一说法是错误的。 从技术上讲, 被停止的线程通过抛出 ThreadDeath 异常退出 所有它所调用的同步方法。结果是,该线程释放它持有的内部对象锁。
接下来, 看看 suspend方法有什么问题。与 stop 不同,suspend 不会破坏对象。但是, 如果用 suspend 挂起一个持有一个锁的线程, 那么,该锁在恢复之前是不可用的。如果调用 suspend 方法的线程试图获得同一个锁, 那么程序死锁: 被挂起的线程等着被恢复,而将其 挂起的线程等待获得锁。
在图形用户界面中经常出现这种情况。假定我们有一个图形化的银行模拟程序。Pause 按钮用来挂起转账线程, 而 Resume 按钮用来恢复线程。
pauseButton.addActionListener(event -> { for (int i = 0;i < threads.length; i++) threads[i].suspend();// Don't do this }); resumeButton.addActionListener(event -> { for (int i = 0; i < threads.length; i++) threads[i].resume(); };
假设有一个 paintComponent 方法, 通过调用 getBalances 方法获得一个余额数组, 从而 为每一个账户绘制图表。
就像在第 14.11 节所看到的, 按钮动作和重绘动作出现在同一个线程中—事件分配线程(event dispatch thread) 。考虑下面的情况:
1 ) 某个转账线程获得 bank 对象的锁。
2 ) 用户点击 Pause 按钮。
3 ) 所有转账线程被挂起;其中之一仍然持有 bank 对象上的锁。
4 ) 因为某种原因,该账户图表需要重新绘制。
5 ) paintComponent 方法调用 getBalances 方法。
6 ) 该方法试图获得 bank 对象的锁。
现在程序被冻结了。
事件分配线程不能继续运行, 因为锁由一个被挂起的线程所持有。因此,用户不能点击 Resume 按钮,并且这些线程无法恢复。
如果想安全地挂起线程,引入一个变量 suspendRequested 并在 run 方法的某个安全的 地方测试它,安全的地方是指该线程没有封锁其他线程需要的对象的地方。当该线程发现 suspendRequested 变量已经设置, 将会保持等待状态直到它再次获得为止。
14.6 阻塞队列
现在,读者已经看到了形成 Java 并发程序设计基础的底层构建块。然而,对于实际编程 来说,应该尽可能远离底层结构。使用由并发处理的专业人士实现的较高层次的结构要方便 得多、 要安全得多。
对于许多线程问题, 可以通过使用一个或多个队列以优雅且安全的方式将其形式化。生产者线程向队列插入元素, 消费者线程则取出它们。使用队列,可以安全地从一个线程向另 一个线程传递数据。例如,考虑银行转账程序, 转账线程将转账指令对象插入一个队列中, 而不是直接访问银行对象。另一个线程从队列中取出指令执行转账。只有该线程可以访问该 银行对象的内部。因此不需要同步。(当然, 线程安全的队列类的实现者不能不考虑锁和条件,但是, 那是他们的问题而不是你的问题。)
当试图向队列添加元素而队列已满, 或是想从队列移出元素而队列为空的时候, 阻塞队列(blocking queue ) 导致线程阻塞。在协调多个线程之间的合作时,阻塞队列是一个有用的 工具。工作者线程可以周期性地将中间结果存储在阻塞队列中。其他的工作者线程移出中间 结果并进一步加以修改。队列会自动地平衡负载。如果第一个线程集运行得比第二个慢, 第 二个线程集在等待结果时会阻塞。如果第一个线程集运行得快, 它将等待第二个队列集赶上 来。表 14-1 给出了阻塞队列的方法。
阻塞队列方法分为以下 3类, 这取决于当队列满或空时它们的响应方式。如果将队列当 作线程管理工具来使用, 将要用到 put 和 take 方法。当试图向满的队列中添加或从空的队列 中移出元素时,add、 remove 和 element 操作抛出异常。当然,在一个多线程程序中, 队列会 在任何时候空或满, 因此,一定要使用 offer、 poll 和 peek方法作为替代。这些方法如果不能 完成任务,只是给出一个错误提示而不会抛出异常。
注释: poll和 peek 方法返回空来指示失败。 因此,向这些队列中插入 null 值是非法的。 还有带有超时的 offer 方法和 poll 方法的变体。例如,下面的调用:
boolean success = q.offer(x, 100, TimeUnit.MILLISECONDS);
尝试在 100 毫秒的时间内在队列的尾部插入一个元素。如果成功返回 true ; 否则, 达到超时 时,返回 false。类似地,下面的调用:
Object head = q.poll(100, TimeUnit.MILLISECONDS)
尝试用 100 毫秒的时间移除队列的头元素;如果成功返回头元素,否则,达到在超时时, 返回 null。
如果队列满, 则 put 方法阻塞;如果队列空, 则 take 方法阻塞。在不带超时参数时, offer 和 poll 方法等效。 java.util.concurrent 包提供了阻塞队列的几个变种。 默认情况下,LinkedBlockingQueue 的容量是没有上边界的,但是,也可以选择指定最大容量。LinkedBlockingDeque 是一个双端 的版本。ArrayBlockingQueue 在构造时需要指定容量,并且有一个可选的参数来指定是否需 要公平性。若设置了公平参数, 则那么等待了最长时间的线程会优先得到处理。通常,公平 性会降低性能,只有在确实非常需要时才使用它。
PriorityBlockingQueue 是一个带优先级的队列, 而不是先进先出队列。元素按照它们的 优先级顺序被移出。该队列是没有容量上限,但是,如果队列是空的, 取元素的操作会阻 塞。(有关优先级队列的详细内容参看第 9 章)。
最后, DelayQueue 包含实现 Delayed 接口的对象:
interface Delayed extends Comparable<Delayed> { long getDel ay(TimeUnit unit); }
getDelay方法返回对象的残留延迟。负值表示延迟已经结束。元素只有在延迟用完的情 况下才能从 DelayQueue 移除。还必须实现 compareTo 方法。DelayQueue 使用该方法对元素 进行排序。
JavaSE 7增加了一个 TransferQueue 接口,允许生产者线程等待, 直到消费者准备就绪 可以接收一个元素。如果生产者调用
q.transfer(item);
这个调用会阻塞, 直到另一个线程将元素( item) 删除。LinkedTransferQueue 类实现了这个接口。
程序清单 14-9 中的程序展示了如何使用阻塞队列来控制一组线程。程序在一个目录及它 的所有子目录下搜索所有文件, 打印出包含指定关键字的行。略过
生产者线程枚举在所有子目录下的所有文件并把它们放到一个阻塞队列中。这个操作很 快, 如果没有上限的话, 很快就包含了所有找到的文件。
我们同时启动了大量搜索线程。 每个搜索线程从队列中取出一个文件, 打开它, 打印所有包含该关键字的行, 然后取出下一个文件。我们使用一个小技巧在工作结束后终止这个应 用程序。为了发出完成信号, 枚举线程放置一个虚拟对象到队列中(这就像在行李输送带上 放一个写着“ 最后一个包” 的虚拟包。) 当搜索线程取到这个虚拟对象时, 将其放回并终止。
注意,不需要显式的线程同步。在这个应用程序中, 我们使用队列数据结构作为一种同 步机制。
API java.util.concurrent.ArrayBlockingQueue<E> 5.0
API java,util.concurrent.LinkedBlockingQueue<E> 5.0
API java.util.concurrent.LinkedBlockingDeque<E> 6
API java.util.concurrent.DelayQueue<E extends Delayed〉5.0
API java.util.concurrent.Delayed 5.0
API java.util.concurrent.PriorityBlockingQueue<E> 5.0
API java.util.concurrent.BlockingQueue<E> 5.0
API java.util.concurrent.BiockingDeque<E> 6
API java.util.concurrent.Transfer Queue<E> 7
14.7 线程安全的集合
如果多线程要并发地修改一个数据结构, 例如散列表, 那么很容易会破坏这个数据结构 (有关散列表的详细信息见第 9 章) 。例如, 一个线程可能要开始向表中插入一个新元素。假 定在调整散列表各个桶之间的链接关系的过程中, 被剥夺了控制权。如果另一个线程也开始 遍历同一个链表,可能使用无效的链接并造成混乱, 会抛出异常或者陷入死循环。
可以通过提供锁来保护共享数据结构, 但是选择线程安全的实现作为替代可能更容易些。当然,前一节讨论的阻塞队列就是线程安全的集合。在下面各小节中, 将讨论 Java 类库 提供的另外一些线程安全的集合。
14.7.1 高效的映射、集和队列
java.util.concurrent 包提供了映射、 有序集和队列的高效实现:ConcurrentHashMap、 ConcurrentSkipListMap 、 ConcurrentSkipListSet 和 ConcurrentLinkedQueue。
这些集合使用复杂的算法,通过允许并发地访问数据结构的不同部分来使竞争极小化。 与大多数集合不同,size 方法不必在常量时间内操作。确定这样的集合当前的大小通常 需要遍历。
注释: 有些应用使用庞大的并发散列映射,这些映射太过庞大, 以至于无法用 size 方法 得到它的大小, 因为这个方法只能返回 int。对于一个包含超过 20 亿条目的映射该如何 处理? JavaSE 8 引入了一个 mappingCount 方法可以把大小作为 long 返回。
集合返回弱一致性( weakly consistent) 的迭代器。这意味着迭代器不一定能反映出它们被构造之后的所有的修改,但是,它们不会将同一个值返回两次,也不会拋出 Concurrent ModificationException 异常。
注释:与之形成对照的是, 集合如果在迭代器构造之后发生改变,java.util 包中的迭代器 将抛出一个 ConcurrentModificationException 异常。
并发的散列映射表, 可高效地支持大量的读者和一定数量的写者。默认情况下,假定 可以有多达 16 个写者线程同时执行。可以有更多的写者线程,但是, 如果同一时间多于 16 个,其他线程将暂时被阻塞。可以指定更大数目的构造器,然而, 恐怕没有这种必要。
注释:散列映射将有相同散列码的所有条目放在同一个“ 桶” 中。有些应用使用的散列函 数不当, 以至于所有条目最后都放在很少的桶中,这会严重降低性能。即使是一般意义上 还算合理的散列函数, 如 String 类的散列函数, 也可能存在问题。例如,攻击者可能会制造大量有相同散列值的字符串, 让程序速度减慢。在 JavaSE 8 中,并发散列映射将桶组织为树, 而不是列表, 键类型实现了 Comparable, 从而可以保证性能为 O(log(n))。
API java.util.concurrent.ConcurrentLinkedQueue<E> 5.0
API java.util.concurrent.ConcurrentLinkedQueue<E> 6
API java.util.concurrent.ConcurrentHashMap<K, V> 5.0
API java.util.concurrent.ConcurrentSkipListMap<K, V> 6
14.7.2 映射条目的原子更新
ConcurrentHashMap 原来的版本只有为数不多的方法可以实现原子更新, 这使得编程多 少有些麻烦。假设我们希望统计观察到的某些特性的频度。作为一个简单的例子, 假设多个 线程会遇到单词,我们想统计它们的频率。
可以使用 ConcurrentHashMap<String,Long> 吗? 考虑让计数自增的代码。显然,下面的 代码不是线程安全的:
Long oldValue = map.get(word); Long newValue = oldValue == null ? 1: oldValue + 1; map.put(word, newValue); // Error-might not replace oldValue
可能会有另一个线程在同时更新同一个计数。
注释: 有些程序员很奇怪为什么原本线程安全的数据结构会允许非线程安全的操作。不 过有两种完全不同的情况。如果多个线程修改一个普通的 HashMap,它们会破坏内部结 构 (一个链表数组)。有些链接可能丢失, 或者甚至会构成循环,使得这个数据结构不再 可用。对于 ConcurrentHashMap 绝对不会发生这种情况。在上面的例子中,get 和 put 代 码不会破坏数据结构。不过,由于操作序列不是原子的,所以结果不可预知。
传统的做法是使用 replace 操作, 它会以原子方式用一个新值替换原值,前提是之前没有 其他线程把原值替换为其他值。必须一直这么做, 直到 replace 成功:
do { oldValue = map.get(word); newValue = oldValue = null ? 1 : oldValue + 1; } while (!map.replace(word, oldValue, newValue));
或者, 可以使用一个 ConcurrentHashMap<string,Atomiclong>, 或者在 Java SE 8中, 还可以使用 ConcurrentHashMap<string,Longadder> 。更新代码如下:
map.putlfAbsent(word, new LongAdder()); map.get(word).increment();
第一个语句确保有一个 LongAdder 可以完成原子自增。由于 putlfAbsent 返回映射的的 值(可能是原来的值, 或者是新设置的值,) 所以可以组合这两个语句:
map.putlfAbsent(word, new LongAdder()).increraent():
Java SE 8 提供了一些可以更方便地完成原子更新的方法。调用 compute 方法时可以提供 一个键和一个计算新值的函数。这个函数接收键和相关联的值(如果没有值,则为 null), 它 会计算新值。例如,可以如下更新一个整数计数器的映射:
map.compute(word , (k, v) -> v = null ? 1: v + 1);
注释:ConcurrentHashMap 中不允许有 null 值。有很多方法都使用 null 值来指示映射中 某个给定的键不存在。
另外还有 computelfPresent 和 computelfAbsent 方法,它们分别只在已经有原值的情况下计 算新值,或者只有没有原值的情况下计算新值。可以如下更新一个 LongAdder 计数器映射:
map.computelfAbsent(word , k -> new LongAdder()).increment() ;
这与之前看到的 putlfAbsent 调用几乎是一样的,不过 LongAdder 构造器只在确实需要 一个新的计数器时才会调用。
首次增加一个键时通常需要做些特殊的处理。利用 merge 方法可以非常方便地做到这一 点。这个方法有一个参数表示键不存在时使用的初始值。否则, 就会调用你提供的函数来结合原值与初始值。(与 compute 不同,这个函数不处理键。)
map.merge(word, 1L, (existi ngValue, newValue) -> existingValue + newValue); 或者,更简单地可以写为: map.merge(word, 1L, Long::sum); 再不能比这更简洁了。
注释:如果传入 compute 或 merge 的函数返回 null, 将从映射中删除现有的条目。
警告: 使用 compute 或 merge 时, 要记住你提供的函数不能做太多工作。这个函数运行 时,可能会阻塞对映射的其他更新。 当然, 这个函数也不能更新映射的其他部分。
14.7.3 对并发散列映射的批操作
Java SE 8 为并发散列映射提供了批操作,即使有其他线程在处理映射,这些操作也能安 全地执行。批操作会遍历映射,处理遍历过程中找到的元素。无须冻结当前映射的快照。除非你恰好知道批操作运行时映射不会被修改, 否则就要把结果看作是映射状态的一个近似。
有 3 种不同的操作:
• 搜索(search) 为每个键或值提供一个函数,直到函数生成一个非 null 的结果。然后搜 索终止,返回这个函数的结果。
• 归约(reduce) 组合所有键或值, 这里要使用所提供的一个累加函数。
• forEach 为所有键或值提供一个函数。
每个操作都有 4 个版本:
• operationKeys: 处理键。
• operatioriValues: 处理值。
• operation: 处理键和值。
• operationEntries: 处理 Map.Entry 对象。
对于上述各个操作, 需要指定一个参数化阈值(parallelism threshold)。如果映射包含的 元素多于这个阈值, 就会并行完成批操作。如果希望批操作在一个线程中运行,可以使用阈 值 Long.MAX_VALUE。如果希望用尽可能多的线程运行批操作,可以使用阈值 1。
下面首先来看 search方法。有以下版本:
U searchKeys(long threshold, BiFunction<? super K , ? extends U> f) U searchVaiues(long threshold, BiFunction<? super V, ? extends U> f) U search(long threshold, BiFunction<? super K, ? super V,? extends U> f) U searchEntries(long threshold, BiFunction<Map.Entry<K, V>, ? extends U> f)
例如, 假设我们希望找出第一个出现次数超过 1000 次的单词。需要搜索键和值:
String result = map.search(threshold, (k, v) -> v > 1000 ? k : null);
result 会设置为第一个匹配的单词,如果搜索函数对所有输入都返回 null, 则 返 回 null。 forEach方法有两种形式。第一个只为各个映射条目提供一个消费者函数, 例如:
map.forEach(threshold,
(k, v) -> System.out.println(k + " -> " + v));
第二种形式还有一个转换器函数, 这个函数要先提供, 其结果会传递到消费者:
map.forEach(threshold,(k, v)•> k + " -> " + v,// Transformer System.out::println); // Consumer
转换器可以用作为一个过滤器。只要转换器返回 null , 这个值就会被悄无声息地跳过。 例如,下面只打印有大值的条目:
map.forEach(threshold, (k, v) -> v > 1000 ? k + "-> " + v : null , // Filter and transformer System.out::println); // The nulls are not passed to the consumer
reduce 操作用一个累加函数组合其输入。例如,可以如下计算所有值的总和:
Long sum = map.reduceValues(threshold, Long::sum);
与 forEach 类似,也可以提供一个转换器函数。可以如下计算最长的键的长度:
Integer maxlength = map.reduceKeys(threshold, String::length, // Transformer Integer::max); // Accumulator
转换器可以作为一个过滤器,通过返回 null 来排除不想要的输入。 在这里,我们要统计多少个条目的值 > 1000:
Long count = map. reduceValues(threshold, v -> v > 1000 ? 1L : null , Long::sum);
注释: 如果映射为空, 或者所有条目都被过滤掉, reduce 操作会返回 null。如果只有一 个元素, 则返回其转换结果, 不会应用累加器。
对于 int、 long 和 double 输出还有相应的特殊化操作, 分别有后缀 Tolnt、 ToLong 和 ToDouble。需要把输入转换为一个基本类型值,并指定一个默认值和一个累加器函数。映射 为空时返回默认值。
long sum = map.reduceValuesToLong(threshold, Long::longValue, // Transformer to primitive type 0, // Default value for empty map Long::sum); // Primitive type accumulator
警告: 这些特殊化操作与对象版本的操作有所不同, 对于对象版本的操作, 只需要考虑 一个元素。这里不是返回转换得到的元素, 而是将与默认值累加。 因此, 默认值必须是 累加器的零元素。
14.7.4 对并发散列映射的批操作
假设你想要的是一个大的线程安全的集而不是映射。并没有一个 ConcurrentHashSet 类,而且你肯定不想自己创建这样一个类。当然,可以使用 ConcurrentHashMap (包含“ 假” 值),不过这会得到一个映射而不是集, 而且不能应用 Set 接口的操作。
静态 newKeySet 方法会生成一个 Set<K>, 这实际上是 ConcurrentHashMap<K, Boolean〉的一个包装器。(所有映射值都为 Boolean.TRUE, 不过因为只是要把它用作一个集,所以并不关心具体的值。)
Set<String> words = ConcurrentHashMap.<String>newKeySet();
当然, 如果原来有一个映射,keySet 方法可以生成这个映射的键集。这个集是可变的。 如果删除这个集的元素,这个键(以及相应的值)会从映射中删除。不过,不能向键集增加 元素,因为没有相应的值可以增加。Java SE 8 为 ConcurrentHashMap 增加了第二个 keySet 方 法,包含一个默认值,可以在为集增加元素时使用:
Set<String> words = map.keySet(1L);
words.add("Java”);
如果 "Java ” 在 words 中不存在, 现在它会有一个值 1。
14.7.5 写数组的拷贝
CopyOnWriteArrayList 和 CopyOnWriteArraySet 是线程安全的集合,其中所有的修改线 程对底层数组进行复制。如果在集合上进行迭代的线程数超过修改线程数, 这样的安排是 很有用的。当构建一个迭代器的时候, 它包含一个对当前数组的引用。如果数组后来被修改 了,迭代器仍然引用旧数组, 但是,集合的数组已经被替换了。因而,旧的迭代器拥有一致 的(可能过时的)视图,访问它无须任何同步开销。
14.7.6 并行数组算法
在 Java SE 8中, Arrays 类提供了大量并行化操作。静态 Arrays.parallelSort 方法可以对 一个基本类型值或对象的数组排序。例如,
String contents = new String(Fi1es.readAl1Bytes( Paths.get("alice.txt")), StandardCharsets.UTF_8); // Read file into string String[] words = contents.split("[\\P{L}]+"); // Split along nonletters Arrays.parallelSort(words):
对对象排序时,可以提供一个 Comparator。
Arrays,parallelSort(words, Comparator.comparing(String::length));
对于所有方法都可以提供一个范围的边界,如:
values.parallelSort(values.length / 2, values.length); // Sort the upper half
注释: 乍一看,这些方法名中的 parallel可能有些奇怪, 因为用户不用关心排序具体怎样 完成。 不过,API 设计者希望清楚地指出排序是并行化的。这样一来, 用户就会注意避 免使用有副作用的比较器。
parallelSetAll 方法会用由一个函数计算得到的值填充一个数组。这个函数接收元素索引, 然后计算相应位置上的值。
Arrays.parallelSetAll(values,i-> i % 10); // Fills values with 0 12 3 4 5 6 7 8 9 0 12 . . .
显然,并行化对这个操作很有好处。这个操作对于所有基本类型数组和对象数组都有相 应的版本。
最后还有一个 parallelPrefix 方法,它会用对应一个给定结合操作的前缀的累加结果替换 各个数组元素。这是什么意思? 这里给出一个例子。考虑数组 [1,2, 3, 4, . . .] 和 x 操作。执 行 Arrays.parallelPrefix(values, (x, y) -> x * y) 之后,数组将包含:
[1, 1x 2, 1x 2 x 3, l x 2 x B x 4, . . .]
可能很奇怪,不过这个计算确实可以并行化。首先,结合相邻元素,如下所示:
[1, 1x 2, 3, 3 x 4, 5 , 5 x 6, 7, 7 x 8]
灰值保持不变。显然, 可以在不同的数组区中并行完成这个计算。下一步中, 通过将所 指示的元素与下面一个或两个位置上的元素相乘来更新这些元素:
[1, 1 x 2, 1 x 2 x B, 1 x 2 x 3 x 4, 5, 5 x 6, 5 x 6 x 7, 5 x 6 x 7 x 8]
这同样可以并行完成。log(n) 步之后, 这个过程结束。如果有足够多的处理器,这会远 远胜过直接的线性计算。这个算法在特殊用途硬件上很常用, 使用这些硬件的用户很有创造 力,会相应地调整算法来解决各种不同的问题。
14.7.7 较早的线程安全集合
从 Java 的初始版本开始,Vector 和 Hashtable 类就提供了线程安全的动态数组和散列表的 实现。现在这些类被弃用了, 取而代之的是 ArrayList 和 HashMap 类。这些类不是线程安全 的,而集合库中提供了不同的机制。任何集合类都可以通过使用同步包装器(synchronization wrapper) 变成线程安全的:
List<E> synchArrayList = Collections.synchronizedList(new ArrayList<E>()); Map<K , V> synchHashMap = Col1ections.synchroni zedMap(new HashMap<K , V>0);
结果集合的方法使用锁加以保护,提供了线程安全访问。 应该确保没有任何线程通过原始的非同步方法访问数据结构。最便利的方法是确保不保存任何指向原始对象的引用, 简单地构造一个集合并立即传递给包装器,像我们的例子中所 做的那样。
如果在另一个线程可能进行修改时要对集合进行迭代,仍然需要使用“ 客户端” 锁定:
synchronized (synchHashMap) { Iterator<K> iter = synchHashMap.keySet().iterator(); while (iter.hasNext()) ...
如果使用“ foreach” 循环必须使用同样的代码, 因为循环使用了迭代器。注意:如果在 迭代过程中,别的线程修改集合,迭代器会失效,抛出 ConcurrentModificationException 异 常。同步仍然是需要的, 因此并发的修改可以被可靠地检测出来。
最好使用 java.Util.Concurrent 包中定义的集合, 不使用同步包装器中的。特别是, 假如它 们访问的是不同的桶, 由于 ConcurrentHashMap 已经精心地实现了,多线程可以访问它而且 不会彼此阻塞。有一个例外是经常被修改的数组列表。在那种情况下,同步的 ArrayList 可 以胜过 CopyOnWriteArrayList。
API java.util.Colletions 1.2 略过
14.8 Callable 与 Future
Runnable 封装一个异步运行的任务,可以把它想象成为一个没有参数和返回值的异步方 法。Callable 与 Runnable 类似,但是有返回值。Callable 接口是一个参数化的类型, 只有一 个方法 call。
public interface Ca11able<V> { V call() throws Exception; }
类型参数是返回值的类型。例如, Callable<Integer> 表示一个最终返回 Integer 对象的异 步计算。 Future 保存异步计算的结果。可以启动一个计算,将 Future 对象交给某个线程,然后忘 掉它。Future 对象的所有者在结果计算好之后就可以获得它。 Future 接口具有下面的方法:
public interface Future<V> { V get() throws ... V get(long timeout, TimeUnit unit) throws . . void cancel(boolean maylnterrupt); boolean isCancelled(); boolean isDone(); }
第一个 get 方法的调用被阻塞, 直到计算完成。如果在计算完成之前, 第二个方法的调 用超时,拋出一个 TimeoutException 异常。如果运行该计算的线程被中断,两个方法都将拋 出 InterruptedException。如果计算已经完成, 那么 get 方法立即返回。
如果计算还在进行,isDone 方法返回 false; 如果完成了, 则返回 true。
可以用 cancel 方法取消该计算。如果计算还没有开始,它被取消且不再开始。如果计算 处于运行之中,那么如果 maylnterrupt 参数为 true, 它就被中断。
FutureTask 包装器是一种非常便利的机制, 可将 Callable转换成 Future 和 Runnable, 它 同时实现二者的接口。例如:
Callable<Integer> myComputation = . . .; FutureTask<Integer> task = new FutureTask<Integer>(myConiputation); Thread t = new Thread(task); // it's a Runnable t.start(); ... Integer result = task.get();// it's a Future
程序清单 14-10 中的程序使用了这些概念。这个程序与前面那个寻找包含指定关键字的 文件的例子相似。然而,现在我们仅仅计算匹配的文件数目。因此,我们有了一个需要长时 间运行的任务,它产生一个整数值,一个 Callable 的例子。 略过
class MatchCounter implements Callable<Integer> { public MatchCounter(File directory, String keyword) { ... } public Integer call() { . . . } // returns the number of matching files }
然后我们利用 MatchCounter 创建一个 FutureTask 对象, 并用来启动一个线程。
FutureTask<Integer> task = new FutureTask<Integer>(counter): Thread t = new Thread(task); t.start();
最后,我们打印结果。
System.out.println(task.get() + " matching files.");
当然, 对 get 的调用会发生阻塞, 直到有可获得的结果为止。在 call 方法内部, 使用相同的递归机制。 对于每一个子目录, 我们产生一个新的MatchCounter 并为它启动一个线程。此外, 把 FutureTask 对象隐藏在 ArrayList<Future<Integer>>中。最后, 把所有结果加起来:
for (Future<Integer> result : results) count += result.get();
每一次对 get 的调用都会发生阻塞直到结果可获得为止。当然,线程是并行运行的, 因 此, 很可能在大致相同的时刻所有的结果都可获得。
API java.util.concurrent.Callable<V> 5.0
API java.util.concurrent.Future<V> 5.0
API java.util.concurrent.FutureTask<V> 5.0
14.9 执行器
构建一个新的线程是有一定代价的, 因为涉及与操作系统的交互。如果程序中创建了大量的生命期很短的线程,应该使用线程池( thread pool)。一个线程池中包含许多准备运行的 空闲线程。将 Runnable 对象交给线程池, 就会有一个线程调用 run 方法。 当 run 方法退出 时,线程不会死亡,而是在池中准备为下一个请求提供服务。
另一个使用线程池的理由是减少并发线程的数目。创建大量线程会大大降低性能甚至使 虚拟机崩溃。如果有一个会创建许多线程的算法, 应该使用一个线程数“ 固定的” 线程池以 限制并发线程的总数。
执行器( Executor) 类有许多静态工厂方法用来构建线程池, 表 14-2 中对这些方法进行 了汇总。
14.9.1 线程池
先来看一下表 14-2 中的 3 个方法。在第 14.9.2 节中,我们讨论其余的方法。newCachedThreadPool方法构建了一个线程池, 对于每个任务, 如果有空闲线程可用,立即让它执行 任务,如果没有可用的空闲线程, 则创建一个新线程。newFixedThreadPool 方法构建一个具 有固定大小的线程池。 如果提交的任务数多于空闲的线程数, 那么把得不到服务的任务放置到队列中。当其他任务完成以后再运行它们。newSingleThreadExecutor 是一个退化了的大小为 1 的线程池: 由一个线程执行提交的任务,一个接着一个。这 3 个方法返回实现了 ExecutorService 接口的 ThreadPoolExecutor 类的对象。
可用下面的方法之一将一个 Runnable 对象或 Callable 对象提交给 ExecutorService:
Future<?> submit(Runnable task) Future<T> submit(Runnable task, T result) Future<T> submit(Callable<T> task)
该池会在方便的时候尽早执行提交的任务。调用 submit 时,会得到一个 Future 对象, 可 用来查询该任务的状态。
第一个 submit 方法返回一个奇怪样子的 Future。可以使用这样一个对象来调用 isDone、 cancel 或 isCancelled。但是, get 方法在完成的时候只是简单地返回 null。
第二个版本的 Submit 也提交一个 Runnable, 并且 Future 的 get 方法在完成的时候返回指 定的 result 对象。
第三个版本的 Submit 提交一个 Callable, 并且返回的 Future 对象将在计算结果准备好的 时候得到它。
当用完一个线程池的时候, 调用 shutdown。该方法启动该池的关闭序列。被关闭的执行器不再接受新的任务。当所有任务都完成以后,线程池中的线程死亡。另一种方法是调用 shutdownNow。该池取消尚未开始的所有任务并试图中断正在运行的线程。
下面总结了在使用连接池时应该做的事:
1 ) 调用 Executors 类中静态的方法 newCachedThreadPool 或 newFixedThreadPool。
2 ) 调用 submit 提交 Runnable 或 Callable 对象。
3 ) 如果想要取消一个任务, 或如果提交 Callable 对象, 那就要保存好返回的 Future 对象。
4 ) 当不再提交任何任务时,调用 shutdown。
例如,前面的程序例子产生了大量的生命期很短的线程, 每个目录产生一个线程。程序 清单 14-11 中的程序使用了一个线程池来运行任务。略过
出于信息方面的考虑, 这个程序打印出执行中池中最大的线程数。 但是不能通过 ExecutorService 这个接口得到这一信息。因此, 必须将该pool 对象强制转换为 ThreadPoolExecutor 类对象。
API java.util.concurrent.Executors 5.0
API java.util.concurrent.ExecutorService 5.0
API java.util.concurrent.ThreadPoolExecutor 5.0
14.9.2 预定执行
ScheduledExecutorService 接口具有为预定执行( Scheduled Execution) 或 重 复 执 行 任 务而设计的方法。它是一种允许使用线程池机制的 java.util.Timer 的泛化。Executors 类的 newScheduledThreadPool 和 newSingleThreadScheduledExecutor 方法将返回实现了 Scheduled-ExecutorService 接口的对象。
可以预定 Runnable 或 Callable 在初始的延迟之后只运行一次。也可以预定一个 Runnable 对象周期性地运行。详细内容见 API 文档。
API java.util.concurrent.Executors 5.0
API java.util.concurrent.ScheduledExecutorService 5.0
14.9.3 控制任务组
你已经了解了如何将一个执行器服务作为线程池使用, 以提高执行任务的效率。 有 时, 使用执行器有更有实际意义的原因, 控制一组相关任务。例如, 可以在执行器中使用 shutdownNow 方法取消所有的任务。
invokeAny 方法提交所有对象到一个 Callable 对象的集合中,并返回某个已经完成了的 任务的结果。无法知道返回的究竟是哪个任务的结果, 也许是最先完成的那个任务的结果。 对于搜索问题, 如果你愿意接受任何一种解决方案的话,你就可以使用这个方法。例如, 假 定你需要对一个大整数进行因数分解计算来解码 RSA 密码。可以提交很多任务, 每一个任 务使用不同范围内的数来进行分解。只要其中一个任务得到了答案,计算就可以停止了。
invokeAll 方法提交所有对象到一个 Callable 对象的集合中,并返回一个 Future 对象的列 表,代表所有任务的解决方案。当计算结果可获得时, 可以像下面这样对结果进行处理:
List<Callab1e<T>> tasks = . . .; List<Future<T>> results = executor.invokeAll(tasks): for (Future<T> result : results) processFurther(result.get());
这个方法的缺点是如果第一个任务恰巧花去了很多时间,则可能不得不进行等待。将结 果按可获得的顺序保存起来更有实际意义。可以用 ExecutorCompletionService 来进行排列。
用常规的方法获得一个执行器。然后, 构建一个 ExecutorCompletionService, 提交任务 给完成服务( completion service) 。该服务管理 Future 对象的阻塞队列,其中包含已经提交的 任务的执行结果(当这些结果成为可用时。) 这样一来,相比前面的计算, 一个更有效的组织 形式如下:
ExecutorCompletionService<T> service = new ExecutorCompletionService<>(executor): for (Callable<T> task : tasks) service.submit(task); for (int i = 0; i < tasks.size();i ++) processFurther(service.take().get());
API java.util.concurrent.ExecutorService 5.0
API java.util.concurrent.ExecutorCompletionService<V> 5.0
14.9.4 Fork-Join 框架
有些应用使用了大量线程, 但其中大多数都是空闲的。举例来说, 一个 Web 服务器可能 会为每个连接分别使用一个线程。另外一些应用可能对每个处理器内核分别使用一个线程, 来完成计算密集型任务, 如图像或视频处理。Java SE 7中新引入了 fork-join 框架,专门用来 支持后一类应用。假设有一个处理任务, 它可以很自然地分解为子任务, 如下所示:
if (problemSize < threshold) solve problem directly else { break problem into subproblems recursively solve each subproblem combine the results }
图像处理就是这样一个例子。要增强一个图像, 可以变换上半部分和下部部分。如果有 足够多空闲的处理器,这些操作可以并行运行。(除了分解为两部分外,还需要做一些额外的 工作, 不过这属于技术细节, 我们不做讨论)。
在这里, 我们将讨论一个更简单的例子。假设想统计一个数组中有多少个元素满足某个 特定的属性。可以将这个数组一分为二,分别对这两部分进行统计, 再将结果相加。 要采用框架可用的一种方式完成这种递归计算, 需要提供一个扩展 RecursiveTask<T> 的 类(如果计算会生成一个类型为 T 的结果)或者提供一个扩展 RecursiveAction 的类(如果不 生成任何结果)。再覆盖 compute 方法来生成并调用子任务, 然后合并其结果。
class Counter extends RecursiveTask<Integer> { protected Integer compute() { if (to - fro« < THRESHOLD) { solve problem directly } else { int mid = (from + to) / 2; Counter first = new Counter(va1ues, from, mid, filter); Counter second = new Counter(va1ues, mid, to, filter); i nvokeAll (fi rst, second): return first.join() + second.join(); } } }
在这里,invokeAll 方法接收到很多任务并阻塞, 直到所有这些任务都已经完成。join 方 法将生成结果。我们对每个子任务应用了 join, 并返回其总和。
注释: 还有一个 get 方法可以得到当前结果, 不过一般不太使用, 因为它可能抛出已检 查异常, 而在 compute 方法中不允许抛出这些异常。
程序清单 14-12 给出了完整的示例代码。 在后台, fork-join 框架使用了一种有效的智能方法来平衡可用线程的工作负载,这种方 法称为工作密取(work stealing)。每个工作线程都有一个双端队列 ( deque ) 来完成任务。一 个工作线程将子任务压入其双端队列的队头。(只有一个线程可以访问队头,所以不需要加锁)。一个工作线程空闲时,它会从另一个双端队列的队尾“ 密取” 一个任务。由于大的子任 务都在队尾, 这种密取很少出现。略过
14.9.5 可完成 Future
处理非阻塞调用的传统方法是使用事件处理器, 程序员为任务完成之后要出现的动作注册一个处理器。当然, 如果下一个动作也是异步的, 在它之后的下一个动作会在一个不同 的事件处理器中。尽管程序员会认为“ 先做步骤 1 , 然后是步骤 2, 再完成步骤 3”,但实际 上程序逻辑会分散到不同的处理器中。如果必须增加错误处理,情况会更糟糕。假设步骤 2 是“ 用户登录”。可能需要重复这个步骤, 因为用户输入凭据时可能会出错。要尝试在一组 事件处理器中实现这样一个控制流,或者想要理解所实现的这样一组事件处理器,会很有 难度。
Java SE 8 的 CompletableFuture 类提供了一种候选方法。与事件处理器不同,“ 可完成 future" 可以“ 组合”(composed )。
例如,假设我们希望从一个 Web 页面抽取所有链接来建立一个网络爬虫。下面假设有这 样一个方法:
public void CorapletableFuture<String> readPage(URL url)
Web 页面可用时这会生成这个页面的文本。如果方法:
public static List<URL> getLinks(String page)
生成一个 HTML 页面中的 URL,可以调度当页面可用时再调用这个方法:
ConipletableFuture<String> contents = readPage(url);
CompletableFuture<List<URL>>links = contents.thenApply(Parser::getlinks);
thenApply 方法不会阻塞。它会返回另一个 future。第一个 future完成时,其结果会提供 给 getLinks 方法, 这个方法的返回值就是最终的结果。
利用可完成 future,可以指定你希望做什么, 以及希望以什么顺序执行这些工作。当然, 这不会立即发生,不过重要的是所有代码都放在一处。
从概念上讲, CompletableFuture 是一个简单 API, 不过有很多不同方法来组合可完成 future。下面先来看处理单个 future的方法(如表 14-3 所示。) (对于这里所示的每个方法,还 有两个 Async 形式,不过这里没有给出,其中一种形式使用一个共享 ForkJoinPool,另一种 形式有一个 Executor 参数)。在这个表中, 我使用了简写记法来表示复杂的函数式接口,这里会把 Function<? super T,U> 写为 T -> U。当然这并不是真正的 Java 类型。
你已经见过 thenApply 方法。以下调用:
CompletableFuture<U> future .thenApply (f);
CompletableFuture<U> future . thenApplyAsync (f) ;
会返回一个 future , 可用时会对 future 的结果应用 f。 第二个调用会在另一个线程中运行 。
thenCompose 方法没有取函数 T -> U, 而是取函数 T ->CompletableFuture<U>。这听上 去相当抽象,不过实际上也很自然。考虑从一个给定 URL 读取一个 Web 页面的动作。不用 提供方法:
public String b1ockingReadPage (URL url)
更精巧的做法是让方法返回一个 future:
public CompletableFuture<String> readPage(URL url)
现在, 假设我们还有一个方法可以从用户输入得到 URL, 这可能从一个对话框得到, 而 在用户点击 OK 按钮之前不会得到答案。这也是将来的一个事件:
public CompletableFuture<URL> getURLInput (String prompt)
这 里 我 们 有 两 个 函 数 T -> CompletableFuture 和 U -> CompletableFuture。 显然, 如果第二个函数在第一个函数完成时调用, 它们就可以组合为一个函数 T -> CompletableFuture 。这正是 thenCompose 所做的。
表 14-3 中的第 3 个方法强调了目前为止我一直忽略的另一个方面: 失败( failure) 。CompletableFuture 中拋出一个异常时, 会捕获这个异常并在调用 get 方法时包装在一个受 查异常 ExecutionException 中。 不过, 可能 get 永远也不会被调用。要处理异常, 可以使 用 handle 方法。调用指定的函数时要提供结果(如果没有则为 null ) 和异常(如果没有则为 null ), 这种情况下就有意义了。
其余的方法结果都为 void, 通常用在处理管线的最后。
下面来看组合多个 future 的方法(见表 14-4)。
前 3 个方法并行运行一个 CompletableFuture<T> 和一个 CompletableFuture<U> 动作, 并 组合结果。
接下来 3 个方法并行运行两个 CompletableFuture<T> 动作。一旦其中一个动作完成,就 传递它的结果,并忽略另一个结果。
最 后 的 静 态 allOf 和 anyOf 方 法 取 一 组 可 完 成 future( 数 目 可 变) ,并 生 成 一 个 CompletableFuture<void> , 它会在所有这些 future都完成时或者其中任意一个 future完成时 结束。不会传递任何结果。
注 释: 理 论 上 讲, 这 一 节 介 绍 的 方 法 接 受 CompletionStage 类 型 的 参 教, 而 不 是 CompletableFuture。这个接口有几乎 40 个抽象方法, 只由 CompletableFuture 实现。提供 这个接口是为了让第三方框架可以实现这个接口。
14.10 同步器
java.util.concurrent 包包含了几个能帮助人们管理相互合作的线程集的类见表 14-5。这 些机制具有为线程之间的共用集结点模式(common rendezvous patterns) 提供的“ 预置功能” ( canned functionality ) 。 如果有一个相互合作的线程集满足这些行为模式之一, 那么应该直接 重用合适的库类而不要试图提供手工的锁与条件的集合。
14.10.1 信号量
概念上讲,一个信号量管理许多的许可证(permit)。为了通过信号量,线程通过调用 acquire 请求许可。其实没有实际的许可对象, 信号量仅维护一个计数。许可的数目是固定 的,由此限制了通过的线程数量。其他线程可以通过调用 release 释放许可。而且,许可不是必须由获取它的线程释放。事实上,任何线程都可以释放任意数目的许可,这可能会增加许可数目以至于超出初始数目。
信号量在 1968 年由 Edsger Dijkstra 发明, 作为同步原语( synchronization primitive ) 。Dijkstra 指出信号量可以被有效地实现, 并且有足够的能力解决许多常见的线程同步问题。 在几乎任何一本操作系统教科书中, 都能看到使用信号量实现的有界队列。
当然,应用程序员不必自己实现有界队列。通常, 信号量不必直接映射到通用应用场景。
14.10.2 倒计时门栓
一个倒计时门栓( CountDownLatch) 让一个线程集等待直到计数变为 0。倒计时门栓是 一次性的。一旦计数为 0, 就不能再重用了。
一个有用的特例是计数值为 1 的门栓。实现一个只能通过一次的门。线程在门外等候直 到另一个线程将计数器值置为0。
举例来讲, 假定一个线程集需要一些初始的数据来完成工作。工作器线程被启动并在门 外等候。另一个线程准备数据。当数据准备好的时候, 调用 countDown, 所有工作器线程就 可以继续运行了。
然后,可以使用第二个门栓检査什么时候所有工作器线程完成工作。用线程数初始化门 栓。每个工作器线程在结束前将门栓计数减 1。另一个获取工作结果的线程在门外等待,一 旦所有工作器线程终止该线程继续运行。
14.10.3 障栅
CyclicBarrier 类实现了一个集结点(rendezvous) 称为障栅( barrier)。考虑大量线程运行 在一次计算的不同部分的情形。当所有部分都准备好时,需要把结果组合在一起。当一个线 程完成了它的那部分任务后, 我们让它运行到障栅处。一旦所有的线程都到达了这个障栅, 障栅就撤销, 线程就可以继续运行。
下面是其细节。首先, 构造一个障栅, 并给出参与的线程数:
CyclicBarrier barrier = new CydicBarrier(nthreads);
每一个线程做一些工作,完成后在障栅上调用 await :
public void run() { doWork() ; bamer.await(); ... }
await 方法有一个可选的超时参数:
barrier.await(100, TineUnit.MILLISECONDS);
如果任何一个在障栅上等待的线程离开了障栅, 那么障栅就被破坏了(线程可能离开是 因为它调用 await 时设置了超时,或者因为它被中断了)。在这种情况下,所有其他线程的 await 方法抛出 BrokenBarrierException 异常。那些已经在等待的线程立即终止 await 的调用。
可以提供一个可选的障栅动作( barrier action), 当所有线程到达障栅的时候就会执行这 一动作。
Runnable barrierAction = ...; CyclicBarrier barrier = new Cyc1icBarrier(nthreads, barrierAction);
该动作可以收集那些单个线程的运行结果。 障栅被称为是循环的( cyclic), 因为可以在所有等待线程被释放后被重用。在这一点上, 有别于 CountDownLatch, CountDownLatch 只能被使用一次。
Phaser 类增加了更大的灵活性,允许改变不同阶段中参与线程的个数。
14.10.4 交换器
当两个线程在同一个数据缓冲区的两个实例上工作的时候, 就可以使用交换器 ( Exchanger) 典型的情况是, 一个线程向缓冲区填入数据, 另一个线程消耗这些数据。当它 们都完成以后,相互交换缓冲区。
14.10.5 同步队列
同步队列是一种将生产者与消费者线程配对的机制。当一个线程调用 SynchronousQueue 的 put 方法时,它会阻塞直到另一个线程调用 take 方法为止,反之亦然。与 Exchanger 的情 况不同, 数据仅仅沿一个方向传递,从生产者到消费者。
即使 SynchronousQueue 类实现了 BlockingQueue 接口, 概念上讲,它依然不是一个队列。它没有包含任何元素,它的 size方法总是返回 0。
14.11 线程与 Swing
在有关本章的介绍里已经提到,在程序中使用线程的理由之一是提高程序的响应性能。 当程序需要做某些耗时的工作时,应该启动另一个工作器线程而不是阻塞用户接口。
但是,必须认真考虑工作器线程在做什么,因为这或许令人惊讶,Swing 不是线程安全 的。如果你试图在多个线程中操纵用户界面的元素,那么用户界面可能崩溃。
要了解这一问题,运行程序清单 14-13 中的测试程序。当你点击 Bad 按钮时, 一个新的 线程将启动,它的 run方法操作一个组合框,随机地添加值和删除值。略过
试试看。点击 Bad 按钮。点击几次组合框, 移动滚动条, 移动窗口, 再次点击 Bad 按 钮, 不断点击组合框。最终,你会看到一个异常报告(见图 14-8 )。
发生了什么? 当把一个元素插入组合框时,组合框将产生一个事件来更新显示。然后, 显示代码开始运行, 读取组合框的当前大小并准备显示这个值。但是, 工作器线程保持运 行,有时候会造成组合框中值的数目减少。显示代码认为组合框中的值比实际的数量多, 于 是会访问不存在的值, 触发 ArraylndexOutOfBounds 异常。
在显示时对组合框加锁可以避免这种情况出现。 但是, Swing 的设计者决定不再付出更 多的努力实现 Swing 线程安全, 有两个原因。首先, 同步需要时间, 而且, 已经没有人想要 降低 Swing 的速度。更重要的是,Swing 小组调查了其他小组在线程安全的用户界面工具包 方面的经验。他们的发现并不令人鼓舞。使用线程安全包的程序员被同步命令搞昏了头, 常 常编写出容易造成死锁的程序。
14.11.1 运行耗时的任务
将线程与 Swing—起使用时, 必须遵循两个简单的原则。
( 1 ) 如果一个动作需要花费很长时间,在一个独立的工作器线程中做这件事不要在事件 分配线程中做。
( 2 ) 除了事件分配线程,不要在任何线程中接触 Swing 组件。
制定第一条规则的理由易于理解。如果花很多时间在事件分配线程上,应用程序像“ 死 了” 一样, 因为它不响应任何事件。特别是, 事件分配线程应该永远不要进行 input/output 调 用,这有可能会阻塞, 并且应该永远不要调用 sleep。(如果需要等待指定的时间,使用定时 器事件。)
第二条规则在 Swing 编程中通常称为单一线程规则 ( single-thread rule )。我们在后面的 内容中进一步讨论。 这两条规则看起来彼此冲突。假定要启动一个独立的线程运行一个耗时的任务。线程工 作的时候, 通常要更新用户界面中指示执行的进度。任务完成的时候,要再一次更新 GUI 界 面。但是,不能从自己的线程接触 Swing 组件。例如,如果要更新进度条或标签文本,不能 从线程中设置它的值。 要解决这一问题,在任何线程中,可以使用两种有效的方法向事件队列添加任意的动 作。例如, 假定想在一个线程中周期性地更新标签来表明进度。
不可以从自己的线程中调用 label.setText,而应该使用 EventQueue 类的 invokeLater 方法 和 invokeAndWait 方法使所调用的方法在事件分配线程中执行。
应该将 Swing 代码放置到实现 Runnable 接口的类的 run 方法中。然后,创建该类的一个 对象, 将其传递给静态的 invokeLater 或 invokeAndWait 方法。例如, 下面是如何更新标签内 容的代码:
EventQueue.invokeLater(()-> { label.setText(percentage + "% complete"); });
当事件放入事件队列时,invokeLater 方法立即返回,而 run 方法被异步执行。invokeAnd Wait 方法等待直到 run 方法确实被执行过为止。 在更新进度标签时, invokeLater 方法更适宜。用户更希望让工作器线程有更快完成工作 而不是得到更加精确的进度指示器。
这两种方法都是在事件分配线程中执行 run 方法。没有新的线程被创建。
程序清单 14-13 演示了如何使用 invokeLater 方法安全地修改组合框的内容。如果点击 Good 按钮, 线程插入或移除数字。但是,实际的修改是发生在事件分配线程中。略过
API java.awt.EventQueue 1.1
14.11.2 运行耗时的任务
当用户发布一条处理过程很耗时的命令时, 你可能打算启动一个新的线程来完成这个工 作。 如同上一节介绍的那样, 线程应该使用 EventQueue.invokeLater 方法来更新用户界面。
SwingWorker 类使后台任务的实现不那么繁琐。
程序清单 14-14 中的程序有加载文本文件的命令和取消加载过程的命令。应该用一个长的文件来测试这个程序, 例如 The Count of Monte Cristo 的全文,它在本书的附赠代码的 gutenberg 目录下。该文件在一个单独的线程中加载。在读取文件的过程中, Open 菜单项被 禁用, Cancel 菜单项为可用( 见图 14-9。) 读取每一行后,状态条中的线性计数器被更新。 读取过程完成之后, Open 菜单项重新变为可用, Cancel 项被禁用,状态行文本置为 Done。
这个例子展示了后台任务的典型 UI 活动:
• 在每一个工作单位完成之后,更新 UI 来 显示进度。
• 整个工作完成之后, 对 UI 做最后的更新。
SwingWorker 类使得实现这一任务轻而易 举。 覆盖 doInBackground 方法来完成耗时的工 作, 不时地调用 publish 来报告工作进度。这 一方法在工作器线程中执行。publish 方法使得 process 方法在事件分配线程中执行来处理进度数据。当工作完成时, done方法在事件分配线程中被调用以便完成 UI 的更新。
每当要在工作器线程中做一些工作时, 构建一个新的工作器(每一个工作器对象只能被 使用一次)。然后调用 execute 方法。典型的方式是在事件分配线程中调用 execute, 但没有这 样的需求。
假定工作器产生某种类型的结果;因此,SwingWorker<T,V> 实现 Future。这一结 果可以通过 Future 接口的 get 方法获得。由于 get 方法阻塞直到结果成为可用,因此不要在 调用 execute 之后马上调用它。只在已经知道工作完成时调用它,是最为明智的。典型地, 可以从 done方法调用 get。(有时,没有调用 get 的需求, 处理进度数据就是你所需要的。)
中间的进度数据以及最终的结果可以是任何类型。SwingWorker 类有 3 种类型作为类型 参数。 SwingWorker<T,V> 产生类型为 T 的结果以及类型为 V 的进度数据。 要取消正在进行的工作,使用 Future 接口的 cancel 方法。当该工作被取消的时候, get 方法抛出 CancellationException 异常。
要取消正在进行的工作,使用 Future 接口的 cancel 方法。当该工作被取消的时候, get 方法抛出 CancellationException 异常。
正如前面已经提到的,工作器线程对 publish 的调用会导致在事件分配线程上的 process 的调用。为了提高效率, 几个对 publish 的调用结果, 可用对 process 的一次调用成批处理。 process 方法接收一个包含所有中间结果的列表<V> 。
把这一机制用于读取文本文件的工作中。正如所看到的, JTextArea 相当慢。在一个长的 文本文件(比如, The Count of Monte Cristo) 中追加行会花费相当可观的时间。
为了向用户展示进度,要在状态行中显示读入的行数。因此, 进度数据包含当前行号以 及文本的当前行。将它们打包到一个普通的内部类中:
private class ProgressData { public int number; public String line; }
最后的结果是已经读入 StringBuilder 的文本。因此, 需要一个 SwingWorker<StringBuilder,ProgressData>。在 doInBackground方法中, 读取一个文件, 每次一行。在读取每一行之后, 调用publish方法发布行号和当前行的文本。略过
在读取每一行之后休眠 1 毫秒, 以便不使用重读就可以检测取消动作, 但是, 不要使用 休眠来减慢程序的执行速度。如果对这一行加注解, 会发现 The Count of Monte Cristo 的加载相当快, 只有几批用户接口更新。
注释: 从工作器线程来更新文本区可以使这个程序的处理相当顺杨, 但是, 对大多数 Swing 组件来说不可能做到这一点。 这里, 给出一种通用的方法, 其中所有组件的更新都出现在事件分配线程中。
在这个 process 方法中, 忽略除最后一行行号之外的所有行号, 然后, 我们把所有的行 拼接在一起用于文本区的一次更新。
©Override public void process(List<ProgressData> data) { if (isCancelled()) return; StringBuilder b = new StringBuilder(); statusLine.setText("" + data.get(data.size() - 1).number); for (ProgressData d : data) b•append(d.line).append("\n"); textArea.append(b.toString()); }
在 done 方法中, 文本区被更新为完整的文本, 并且 Cancel 菜单项被禁用。
在 Open 菜单项的事件监听器中, 工作器是如何启动的。
这一简单的技术允许人们在保持对用户界面的正常响应的同时, 执行耗时的任务。
API javax.swing.SwingWorker<T,V> 6 略过
14.11.3 单一线程规则
每一个 Java 应用程序都开始于主线程中的 main 方法。在 Swing 程序中,main 方法的 生命周期是很短的。它在事件分配线程中规划用户界面的构造然后退出。在用户界面构造之 后,事件分配线程会处理事件通知, 例如调用 actionPerformed 或 paintComponent。其他线程 在后台运行, 例如将事件放入事件队列的进程,但是那些线程对应用程序员是不可见的。
本章前面介绍了单一线程规则:“ 除了事件分配线程, 不要在任何线程中接触 Swing 组 件。” 本节进一步研究此规则。
对于单一线程规则存在一些例外情况。
• 可在任一个线程里添加或移除事件监听器。 当然该监听器的方法会在事件分配线程中被触发。
• 只有很少的 Swing 方法是线程安全的。在 API 文档中用这样的句子特别标明:“ 尽管 大多数 Swing 方法不是线程安全的, 但这个方法是。” 在这些线程安全的方法中最有 用的是:
JTextComponent.setText
ITextArea.insert
JTextArea.append
JTextArea.replaceRange
JCouponent.repaint
JComponent.revalidate
注释: 在本书中多次使用 repaint 方法, 但是, revalidate 方法不怎么常见。这样做的目的 是在内容改变之后强制执行组件布局。传统的 AWT 有一个 validate 方法强制执行组件布 局。对于 Swing 组件,应该调用 revalidate 方法。(但是, 要强制执行 JFrame 的布局, 仍 然要调用 validate 方法, 因为 JFrame 是一个 Component 不是一个 JComponent。)
历史上,单一线程规则是更加随意的。任何线程都可以构建组件,设置优先级, 将它们 添加到容器中,只要这些组件没有一个是已经被实现的( realized) 。如果组件可以接收 paint 事件或 validation 事件,组件被实现。一旦调用组件的 setVisible(true) 或 pack(!) 方法或者组件已经被添加到已经被实现的容器中,就出现这样的情况。
单一线程规则的这一版本是便利的, 它允许在 main方法中创建 GUI, 然后,在应用程 序的顶层框架调用 setVisible(true) 。在事件分配线程上没有令人讨厌的 Runnable 的安排。
遗憾的是, 一些组件的实现者没有注意原来的单一线程规则的微妙之处。他们在事件分 配线程启动活动,而没有检査组件是否是被实现的。例如,如果在 JTextComponent 上调用 setSelectionStart 或 setSelectionEnd,在事件分配线程中安排了一个插入符号的移动, 即使该组件不是可见的。
检测并定位这些问题可能会好些,但是 Swing 的设计者没有走这条轻松的路。他们认定 除了使用事件分配线程之外, 从任何其他线程访问组件永远都是不安全的。因此,你需要在 事件分配线程构建用户界面,像程序示例中那样调用 EventQueue.invokeLater。
当然,有不少程序使用旧版的单一线程规则, 在主线程初始化用户界面。那些程序有一 定的风险, 某些用户界面的初始化会引起事件分配线程的动作与主线程的动作发生冲突。如 同我们在第 10 章讲到的,不要让自己成为少数不幸的人之一, 为时有时无的线程 bug 烦恼 并花费时间。因此,一定要遵循严谨的单一线程规则。
现在读者已经读到本书卷 1 的末尾。这一卷涵盖了 Java 程序设计语言的基础知识以及大 多数编程项目所需要的标准库中的部分内容。希望读者在学习Java 基础知识的过程中感到愉 快并得到了有用的信息。有关高级知识内容,如网络、 高级的 AWT/Swing、 安全性以及国际 化, 请阅读卷 2。
恭喜,本章完!