Java-线程和并发工具教程-全-

Java 线程和并发工具教程(全)

原文:JJava Threads and the Concurrency Utilities

协议:CC BY-NC-SA 4.0

一、线程和可运行对象

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-1700-9_​1) contains supplementary material, which is available to authorized users.

Java 应用通过线程执行,线程是应用代码中独立的执行路径。当多个线程正在执行时,每个线程的路径可以不同于其他线程的路径。例如,一个线程可能执行一个switch语句的case之一,而另一个线程可能执行该语句的另一个case

每个 Java 应用都有一个执行main()方法的默认主线程。应用还可以创建线程来在后台执行时间密集型任务,以便保持对用户的响应。这些线程执行封装在称为 runnables 的对象中的代码序列。

Java 虚拟机(JVM)为每个线程提供了自己的 JVM 栈,以防止线程相互干扰。独立的栈让线程能够跟踪它们要执行的下一条指令,这些指令可能因线程而异。栈还为线程提供自己的方法参数、局部变量和返回值的副本。

Java 主要通过它的java.lang.Thread类和java.lang.Runnable接口来支持线程。本章将向您介绍这些类型。

引入线程和 Runnable

Thread类为底层操作系统的线程架构提供了一致的接口。(操作系统通常负责创建和管理线程。)单个操作系统线程与一个Thread对象相关联。

Runnable接口提供了由与Thread对象相关联的线程执行的代码。这段代码位于Runnablevoid run()方法中——一个线程不接收任何参数,也不返回值,尽管它可能会抛出一个异常,我会在第四章中讨论。

创建线程和可运行对象

除了默认的主线程,线程是通过创建适当的ThreadRunnable对象引入到应用中的。Thread声明了几个用于初始化Thread对象的构造函数。这些构造函数中有几个需要一个Runnable对象作为参数。

创建一个Runnable对象有两种方法。第一种方法是创建实现Runnable的匿名类,如下所示:

Runnable r = new Runnable()

{

@Override

public void run()

{

// perform some work

System.out.println("Hello from thread");

}

};

在 Java 8 之前,这是创建 runnable 的唯一方法。Java 8 引入了 lambda 表达式来更方便地创建一个 runnable:

Runnable r = () -> System.out.println("Hello from thread");

lambda 肯定没有匿名类冗长。我将在本章和后续章节中使用这两种语言特性。

Note

lambda 表达式(lambda)是一个匿名函数,它被传递给构造函数或方法以供后续执行。Lambdas 使用函数接口(声明单一抽象方法的接口),例如Runnable

在创建了Runnable对象之后,您可以将它传递给一个接收Runnable参数的Thread构造函数。例如,Thread(Runnable runnable)将新的Thread对象初始化为指定的runnable。以下代码片段演示了这项任务:

Thread t = new Thread(r);

一些构造函数不接受Runnable参数。例如,Thread()不会将Thread初始化为Runnable参数。您必须扩展Thread并覆盖它的run()方法(Thread实现Runnable)来提供要运行的代码,下面的代码片段实现了这一点:

class MyThread extends Thread

{

@Override

public void run()

{

// perform some work

System.out.println("Hello from thread");

}

}

// ...

MyThread mt = new MyThread();

获取和设置线程状态

一个Thread对象将状态与一个线程相关联。这个状态由一个名字、一个线程是活的还是死的指示、线程的执行状态(它是可运行的吗?),线程的优先级,以及线程是守护进程还是非守护进程的指示。

获取和设置线程的名称

一个Thread对象被赋予一个名字,这对调试很有用。除非明确指定名称,否则将选择以前缀Thread-开头的默认名称。通过调用ThreadString getName()方法可以得到这个名字。要设置名称,将其传递给合适的构造函数,如Thread(Runnabler,Stringname),或者调用Threadvoid setName( String name)方法。考虑下面的代码片段:

Thread t1 = new Thread(r, "thread t1");

System.out.println(t1.getName()); // Output: thread t1

Thread t2 = new Thread(r);

t2.setName("thread t2");

System.out.println(t2.getName()); // Output: thread t2

Note

Threadlong getId()方法为一个线程返回一个唯一的基于长整数的名字。这个数字在线程的生命周期中保持不变。

获取线程的活动状态

你可以通过调用Threadboolean isAlive()方法来确定一个线程是活的还是死的。当线程处于活动状态时,该方法返回true;否则,它返回false。线程的生命周期从它实际从start()方法中启动之前(稍后讨论)到它离开run()方法之后,在这一点上它死亡。以下代码片段输出新创建线程的活动/死亡状态:

Thread t = new Thread(r);

System.out.println(t.isAlive()); // Output: false

获取线程的执行状态

线程的执行状态由Thread.State枚举的常量之一标识:

  • NEW:尚未启动的线程处于这种状态。
  • 在 JVM 中执行的线程处于这种状态。
  • BLOCKED:等待监视器锁而被阻塞的线程处于这种状态。(我将在第二章的中讨论监视器锁。)
  • WAITING:无限期等待另一个线程执行特定动作的线程就是这种状态。
  • TIMED_WAITING:等待另一个线程执行一个动作长达指定等待时间的线程处于这种状态。
  • TERMINATED:已经退出的线程就是这种状态。

Thread让应用通过提供Thread.State getState()方法来确定线程的当前状态,如下所示:

Thread t = new Thread(r);

System.out.println(t.getState()); // Output: NEW

获取和设置线程的优先级

当计算机具有足够的处理器和/或处理器内核时,计算机的操作系统会为每个处理器或内核分配一个单独的线程,以便线程同时执行。当计算机没有足够的处理器和/或内核时,各种线程必须等待轮到它们使用共享的处理器/内核。

Note

您可以通过调用java.lang.Runtime类的int availableProcessors()方法来确定 JVM 可用的处理器和/或处理器内核的数量。返回值在 JVM 执行期间可能会改变,并且永远不会小于 1。

操作系统使用一个调度器( http://en.wikipedia.org/wiki/Scheduling_(computing) )来决定一个等待线程何时执行。下表列出了三种不同的调度程序:

多级反馈队列和许多其他线程调度器考虑了优先级(线程相对重要性)。它们通常将抢占式调度(优先级较高的线程抢占—中断并运行,而不是—优先级较低的线程)与循环调度(优先级相等的线程被给予相等的时间片,这些时间片被称为时间片,并轮流执行)结合起来。

Note

探索线程时经常遇到的两个术语是并行性和并发性。根据 Oracle 的“多线程指南”( http://docs.oracle.com/cd/E19455-01/806-5257/6je9h032b/index.html ),并行性是“当至少两个线程同时执行时出现的一种情况。”相比之下,并发是“至少有两个线程正在取得进展的情况。这是一种更普遍的并行形式,可以将时间片作为虚拟并行的一种形式。”

Thread通过其返回当前优先级的int getPriority()方法和将优先级设置为priorityvoid setPriority(int priority)方法支持优先级。传递给priority的值范围从Thread.MIN_PRIORITYThread.MAX_PRIORITYThread.NORMAL_PRIORITY标识默认优先级。考虑下面的代码片段:

Thread t = new Thread(r);

System.out.println(t.getPriority());

t.setPriority(Thread.MIN_PRIORITY);

Caution

使用setPriority()会影响应用跨操作系统的可移植性,因为不同的调度程序可以用不同的方式处理优先级的变化。例如,一个操作系统的调度程序可能会延迟低优先级线程的执行,直到高优先级线程完成。这种延迟会导致无限期的推迟或饥饿,因为低优先级线程在无限期等待执行时会“饥饿”,这会严重影响应用的性能。另一个操作系统的调度程序可能不会无限期地延迟较低优先级的线程,从而提高应用的性能。

获取和设置线程的守护进程状态

Java 允许将线程分为守护线程和非守护线程。守护线程是一种充当非守护线程助手的线程,当应用的最后一个非守护线程终止时,它会自动终止,以便应用可以终止。

您可以通过调用Threadboolean isDaemon()方法来确定线程是守护进程还是非守护进程,该方法返回守护进程线程的true:

Thread t = new Thread(r);

System.out.println(t.isDaemon()); // Output: false

默认情况下,与Thread对象相关联的线程是非守护线程。要创建一个守护线程,必须调用Threadvoid setDaemon(boolean isDaemon)方法,将true传递给isDaemon。此处演示了这项任务:

Thread t = new Thread(r);

t.setDaemon(true);

Note

当非守护进程默认主线程终止时,应用不会终止,直到所有后台非守护进程线程都终止。如果后台线程是守护线程,应用将在默认主线程终止时立即终止。

开始线程

在创建了一个ThreadThread子类对象后,通过调用Threadvoid start()方法来启动与该对象相关的线程。当线程先前被启动并且正在运行时或者当线程已经死亡时,该方法抛出java.lang. IllegalThreadStateException:

Thread t = new Thread(r);

t.start();

调用start()会导致运行时创建底层线程,并为调用 runnable 的run()方法的后续执行进行调度。(start()不会等这些任务完成再返回。)当执行离开run()时,线程被销毁,调用start()Thread对象不再可用,这就是调用start()导致IllegalThreadStateException的原因。

我创建了一个应用,演示了从线程和可运行线程创建到线程启动的各种基础知识。查看列表 1-1 。

Listing 1-1. Demonstrating Thread Fundamentals

public class ThreadDemo

{

public static void main(String[] args)

{

boolean isDaemon = args.length != 0;

Runnable r = new Runnable()

{

@Override

public void run()

{

Thread thd = Thread.currentThread();

while (true)

System.out.printf("%s is %salive and in %s " +

"state%n",

thd.getName(),

thd.isAlive() ? "" : "not ",

thd.getState());

}

};

Thread t1 = new Thread(r, "thd1");

if (isDaemon)

t1.setDaemon(true);

System.out.printf("%s is %salive and in %s state%n",

t1.getName(),

t1.isAlive() ? "" : "not ",

t1.getState());

Thread t2 = new Thread(r);

t2.setName("thd2");

if (isDaemon)

t2.setDaemon(true);

System.out.printf("%s is %salive and in %s state%n",

t2.getName(),

t2.isAlive() ? "" : "not ",

t2.getState());

t1.start();

t2.start();

}

}

默认的主线程首先根据参数是否在命令行上传递给这个应用来初始化isDaemon变量。当至少有一个参数被传递时,true被分配给isDaemon。否则,false被赋值。

接下来,创建一个 runnable。runnable 首先调用Threadstatic Thread currentThread()方法,获取对当前执行线程的Thread对象的引用。该引用随后被用于获得关于该线程的信息,该信息被输出。

此时,创建了一个Thread对象,它被初始化为 runnable 和线程名thd1。如果isDaemontrue,则Thread对象被标记为守护进程。然后输出它的名称、存活/死亡状态和执行状态。

第二个Thread对象被创建,并和线程名thd2一起初始化为 runnable。同样,如果isDaemontrue,那么Thread对象被标记为守护进程。它的名称、存活/死亡状态和执行状态也被输出。

最后,两个线程都被启动。

编译清单 1-1 如下:

javac ThreadDemo.java

运行生成的应用,如下所示:

java ThreadDemo

在 64 位 Windows 7 操作系统上的一次运行中,我观察到无休止输出的以下前缀:

thd1 is not alive and in NEW state

thd2 is not alive and in NEW state

thd1 is alive and in RUNNABLE state

thd2 is alive and in RUNNABLE state

您可能会在操作系统上观察到不同的输出顺序。

Tip

要停止一个无休止的应用,在 Windows 上同时按下 Ctrl 和 C 键,或者在非 Windows 操作系统上执行相同的操作。

现在,运行生成的应用,如下所示:

java ThreadDemo x

与前面的执行不同,在前面的执行中,两个线程都作为非守护线程运行,命令行参数的存在导致两个线程都作为守护线程运行。因此,这些线程会一直执行,直到默认主线程终止。您应该观察到更简短的输出。

执行更高级的线程任务

前面的线程任务与配置一个Thread对象和启动相关线程有关。然而,Thread类还支持更高级的任务,包括中断另一个线程,将一个线程加入另一个线程,以及使一个线程进入睡眠状态。

中断线程

Thread类提供了一种中断机制,其中一个线程可以中断另一个线程。当一个线程中断时,它抛出java.lang.InterruptedException。这种机制由以下三种方法组成:

  • void interrupt():中断调用该方法的Thread对象所标识的线程。当一个线程因为调用Threadsleep()join()方法之一而被阻塞时(在本章后面讨论),该线程的中断状态被清除并且InterruptedException被抛出。否则,设置中断状态,并根据线程正在做的事情采取一些其他动作。(有关详细信息,请参见 JDK 文档。)
  • static boolean interrupted():测试当前线程是否被中断,如果被中断,返回true。这个方法可以清除线程的中断状态。
  • boolean isInterrupted():测试该线程是否被中断,如果被中断,返回true。线程的中断状态不受此方法的影响。

我创建了一个演示线程中断的应用。查看列表 1-2 。

Listing 1-2. Demonstrating Thread Interruption

public class ThreadDemo

{

public static void main(String[] args)

{

Runnable r = new Runnable()

{

@Override

public void run()

{

String name = Thread.currentThread().getName();

int count = 0;

while (!Thread.interrupted())

System.out.println(name + ": " + count++);

}

};

Thread thdA = new Thread(r);

Thread thdB = new Thread(r);

thdA.start();

thdB.start();

while (true)

{

double n = Math.random();

if (n >= 0.49999999 && n <= 0.50000001)

break;

}

thdA.interrupt();

thdB.interrupt();

}

}

默认主线程首先创建一个 runnable,它获取当前线程的名称。然后,runnable 清除一个计数器变量,并进入一个while循环,重复输出线程名称和计数器值,并递增计数器,直到线程被中断。

接下来,默认主线程创建一对Thread对象,它们的线程执行这个 runnable 并启动这些后台线程。

为了给后台线程一些时间在中断前输出几条消息,默认主线程进入一个基于while的繁忙循环,这是一个旨在浪费一些时间的语句循环。循环反复获得一个随机值,直到它位于一个狭窄的范围内。

Note

繁忙的循环不是一个好主意,因为它浪费处理器周期。我将在本章的后面揭示一个更好的解决方案。

while循环终止后,默认主线程在每个后台线程的Thread对象上执行interrupt()。下一次每个后台线程执行Thread.interrupted()时,该方法将返回true,循环将终止。

编译清单 1-2 ( javac ThreadDemo.java)并运行结果应用(java ThreadDemo)。您应该会看到在Thread-0Thread-1之间交替出现的消息,其中包括递增的计数器值,如下所示:

Thread-1: 67

Thread-1: 68

Thread-0: 768

Thread-1: 69

Thread-0: 769

Thread-0: 770

Thread-1: 70

Thread-0: 771

Thread-0: 772

Thread-1: 71

Thread-0: 773

Thread-1: 72

Thread-0: 774

Thread-1: 73

Thread-0: 775

Thread-0: 776

Thread-0: 777

Thread-0: 778

Thread-1: 74

Thread-0: 779

Thread-1: 75

连接螺纹

一个线程(比如默认的主线程)偶尔会启动另一个线程来执行一个冗长的计算、下载一个大文件或者执行其他一些耗时的活动。在完成其他任务后,启动工作线程的线程准备好处理工作线程的结果,并等待工作线程完成和终止。

Thread类提供了三个join()方法,允许调用线程等待其Thread对象join()被调用的线程死亡:

  • 无限期等待这个线程死亡。当任何线程中断了当前线程时,抛出InterruptedException。如果抛出该异常,中断状态将被清除。
  • void join(long millis):最多等待millis毫秒该线程死亡。将0传递给millis以无限期等待——join()方法调用 join(0)。java.lang.IllegalArgumentException为负时抛出。当任何线程中断了当前线程时抛出。如果抛出该异常,中断状态将被清除。
  • void join(long millis, int nanos):最多等待millis毫秒和nanos纳秒,让这个线程死掉。当millis为负、nanos为负或者nanos大于 999999 时,抛出IllegalArgumentException。当任何线程中断了当前线程时抛出。如果抛出该异常,中断状态将被清除。

为了演示 noargument join()方法,我创建了一个计算数学常数 pi 到 50,000 位的应用。它通过英国数学家约翰·麦金( https://en.wikipedia.org/wiki/John_Machin )在 18 世纪早期开发的算法来计算圆周率。该算法首先计算 pi/4 = 4 * arctan(1/5)-arctan(1/239),然后将结果乘以 4 以获得 pi 的值。因为反正切是使用幂级数项计算的,项数越多,圆周率就越精确(根据小数点后的位数)。清单 1-3 展示了源代码。

Listing 1-3. Demonstrating Thread Joining

import java.math.BigDecimal;

public class ThreadDemo

{

// constant used in pi computation

private static final BigDecimal FOUR = BigDecimal.valueOf(4);

// rounding mode to use during pi computation

private static final int roundingMode = BigDecimal.ROUND_HALF_EVEN;

private static BigDecimal result;

public static void main(String[] args)

{

Runnable r = () ->

{

result = computePi(50000);

};

Thread t = new Thread(r);

t.start();

try

{

t.join();

}

catch (InterruptedException ie)

{

// Should never arrive here because interrupt() is never

// called.

}

System.out.println(result);

}

/*

* Compute the value of pi to the specified number of digits after the

* decimal point. The value is computed using Machin’s formula:

*

* pi/4 = 4*arctan(1/5)-arctan(1/239)

*

* and a power series expansion of arctan(x) to sufficient precision.

*/

public static BigDecimal computePi(int digits)

{

int scale = digits + 5;

BigDecimal arctan1_5 = arctan(5, scale);

BigDecimal arctan1_239 = arctan(239, scale);

BigDecimal pi = arctan1_5.multiply(FOUR).

subtract(arctan1_239).multiply(FOUR);

return pi.setScale(digits, BigDecimal.ROUND_HALF_UP);

}

/*

* Compute the value, in radians, of the arctangent of the inverse of

* the supplied integer to the specified number of digits after the

* decimal point. The value is computed using the power series

* expansion for the arc tangent:

*

* arctan(x) = x-(x³)/3+(x⁵)/5-(x⁷)/7+(x⁹)/9 ...

*/

public static BigDecimal arctan(int inverseX, int scale)

{

BigDecimal result, numer, term;

BigDecimal invX = BigDecimal.valueOf(inverseX);

BigDecimal invX2 = BigDecimal.valueOf(inverseX * inverseX);

numer = BigDecimal.ONE.divide(invX, scale, roundingMode);

result = numer;

int i = 1;

do

{

numer = numer.divide(invX2, scale, roundingMode);

int denom = 2 * i + 1;

term = numer.divide(BigDecimal.valueOf(denom), scale,

roundingMode);

if ((i % 2) != 0)

result = result.subtract(term);

else

result = result.add(term);

i++;

}

while (term.compareTo(BigDecimal.ZERO) != 0);

return result;

}

}

默认主线程首先创建一个 runnable 来计算 50,000 位数的 pi,并将结果赋给一个名为resultjava.math.BigDecimal对象。为了代码简洁,它使用了 lambda。

然后这个线程创建一个Thread对象来执行 runnable,并启动一个工作线程来执行。

此时,默认主线程调用Thread对象上的join(),等待直到工作线程死亡。当这种情况发生时,默认的主线程输出BigDecimal对象的值。

编译清单 1-3 ( javac ThreadDemo.java)并运行结果应用(java ThreadDemo)。我观察到输出的以下前缀:

3.141592653589793238462643383279502884197169399375105820974944592307816406286208998628034825342117067982148086513282306647093844609550582231725359408128481117450284102701938521105559644622948954930381964428810975665933446128475648233786783165271201909145648566923460348610454326648213393607260249141273724587006606315588174881520920962829254091715364367892590360011330530548820466521384146951941511609433057270365759591953092186117381932611793105118548074462379962749567351885752724891227938183011949129833673362440656643086021394946395224737190702179860943702770539217176293176752384674818467669405132000568127

睡眠

Thread类声明了一对static方法,用于使线程休眠(暂时停止执行):

  • void sleep(long millis):休眠millis毫秒。线程睡眠的实际毫秒数取决于系统定时器和调度程序的精度和准确度。当millis为负时,该方法抛出IllegalArgumentException,当任何线程中断当前线程时,抛出InterruptedException。当抛出这个异常时,当前线程的中断状态被清除。
  • void sleep(long millis, int nanos):休眠millis毫秒和nanos纳秒。线程睡眠的实际毫秒数和纳秒数取决于系统定时器和调度程序的精度和准确度。当millis为负,nanos为负,或者nanos大于999999时,该方法抛出IllegalArgumentException;以及InterruptedException当任何线程中断当前线程时。当抛出这个异常时,当前线程的中断状态被清除。

sleep()方法比使用繁忙循环更可取,因为它们不会浪费处理器周期。

我重构了清单 1-2 的应用来演示线程睡眠。查看列表 1-4 。

Listing 1-4. Demonstrating Thread Sleep

public class ThreadDemo

{

public static void main(String[] args)

{

Runnable r = new Runnable()

{

@Override

public void run()

{

String name = Thread.currentThread().getName();

int count = 0;

while (!Thread.interrupted())

System.out.println(name + ": " + count++);

}

};

Thread thdA = new Thread(r);

Thread thdB = new Thread(r);

thdA.start();

thdB.start();

try

{

Thread.sleep(2000);

}

catch (InterruptedException ie)

{

}

thdA.interrupt();

thdB.interrupt();

}

}

清单 1-2 和 1-4 之间的唯一区别是用Thread.sleep(2000);替换了繁忙循环,休眠 2 秒钟。

编译清单 1-4 ( javac ThreadDemo.java)并运行结果应用(java ThreadDemo)。因为休眠时间是近似值,所以您应该会看到两次运行之间输出的行数有所不同。然而,这种变化不会过分。例如,您不会在一次运行中看到 10 行,而在另一次运行中看到 1000 万行。

Exercises

以下练习旨在测试您对第一章内容的理解:

Define thread.   Define runnable.   What do the Thread class and the Runnable interface accomplish?   Identify the two ways to create a Runnable object.   Identify the two ways to connect a runnable to a Thread object.   Identify the five kinds of Thread state.   True or false: A default thread name starts with the Thd- prefix.   How do you give a thread a nondefault name?   How do you determine if a thread is alive or dead?   Identify the Thread.State enum’s constants.   How do you obtain the current thread execution state?   Define priority.   How can setPriority() impact an application’s portability across operating systems?   Identify the range of values that you can pass to Thread’s void setPriority(int priority) method.   True or false: A daemon thread dies automatically when the application’s last nondaemon thread dies so that the application can terminate.   What does Thread’s void start() method do when called on a Thread object whose thread is running or has died?   How would you stop an unending application on Windows?   Identify the methods that form Thread’s interruption mechanism.   True or false: The boolean isInterrupted() method clears the interrupted status of this thread.   What does a thread do when it’s interrupted?   Define a busy loop.   Identify Thread’s methods that let a thread wait for another thread to die.   Identify Thread’s methods that let a thread sleep.   Write an IntSleep application that creates a background thread to repeatedly output Hello and then sleep for 100 milliseconds. After sleeping for 2 seconds, the default main thread should interrupt the background thread, which should break out of the loop after outputting interrupted.

摘要

Java 应用通过线程执行,线程是应用代码中独立的执行路径。每个 Java 应用都有一个执行main()方法的默认主线程。应用还可以创建线程来在后台执行时间密集型任务,以便保持对用户的响应。这些线程执行封装在称为 runnables 的对象中的代码序列。

Thread类为底层操作系统的线程架构提供了一致的接口。(操作系统通常负责创建和管理线程。)单个操作系统线程与一个Thread对象相关联。

Runnable接口提供了由与Thread对象相关联的线程执行的代码。这段代码位于Runnablevoid run()方法中——一个线程不接收任何参数,也不返回值,尽管它可能抛出一个异常。

除了默认的主线程,线程是通过创建适当的ThreadRunnable对象引入到应用中的。Thread声明了几个用于初始化Thread对象的构造函数。这些构造函数中有几个需要一个Runnable对象作为参数。

一个Thread对象将状态与一个线程相关联。这个状态由一个名字、一个线程是活的还是死的指示、线程的执行状态(它是可运行的吗?),线程的优先级,以及线程是守护进程还是非守护进程的指示。

在创建了一个ThreadThread子类对象后,通过调用Threadvoid start()方法来启动与该对象相关的线程。当线程先前被启动并且正在运行或者线程已经死亡时,这个方法抛出IllegalThreadStateException

除了配置一个Thread对象和启动相关线程的简单线程任务之外,Thread类还支持更高级的任务,包括中断另一个线程,将一个线程加入另一个线程,以及使一个线程进入睡眠状态。

第二章呈现同步。

二、同步

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-1700-9_​2) contains supplementary material, which is available to authorized users.

当线程不交互时,开发多线程应用要容易得多,通常是通过共享变量。当交互发生时,会出现各种问题,使应用线程不安全(在多线程环境中是不正确的)。在本章中,您将了解这些问题,并学习如何通过正确使用 Java 面向同步的语言特性来克服它们。

线程的问题

Java 对线程的支持促进了响应性和可伸缩性应用的开发。然而,这种支持是以增加复杂性为代价的。如果不小心的话,您的代码可能会充满与竞争条件、数据竞争和缓存变量相关的难以发现的错误。

竞赛条件

当计算的正确性取决于调度程序对多个线程的相对计时或交错时,就会出现争用情况。考虑下面的代码片段,只要某个前提条件成立,它就会执行计算:

if (a == 10.0)

b = a / 2.0;

这个代码片段在单线程上下文中没有问题,在多线程上下文中当ab是局部变量时也没有问题。然而,假设ab标识实例或类(static)字段变量,并且两个线程同时访问这段代码。

假设一个线程已经执行了if (a == 10.0)并且即将执行b = a / 2.0时被调度器挂起,调度器恢复另一个改变a的线程。当前一个线程恢复执行时,变量b将不等于5.0。(如果ab是局部变量,这种竞争情况就不会发生,因为每个线程都有自己的这些局部变量的副本。)

代码片段是一个常见类型的竞争条件的示例,称为 check-then-act,其中使用一个可能过时的观察来决定下一步做什么。在前面的代码片段中,“检查”由if (a == 10.0)执行,“动作”由b = a / 2.0;执行。

另一种类型的竞争条件是读-修改-写,其中新的状态是从以前的状态派生出来的。先前的状态被读取,然后被修改,最后通过三个不可分的操作被更新以反映修改后的结果。然而,这些操作的组合并不是不可分割的。

读-修改-写的一个常见例子涉及到一个变量,该变量递增以生成一个唯一的数字标识符。例如,在下面的代码片段中,假设counter是类型为int(初始化为1)的实例字段,并且两个线程同时访问该代码:

public int getID()

{

return counter++;

}

虽然看起来像是一个操作,但是表达式counter++实际上是三个独立的操作:读取counter的值,将1加到这个值上,并将更新后的值存储在counter中。读取的值成为表达式的值。

假设线程 1 在被调度器挂起之前调用了getID()并读取了counter的值,这个值恰好是1。现在假设线程 2 运行,调用getID(),读取counter的值(1,将1加到这个值上,将结果(2)存储在counter中,并将1返回给调用者。

此时,假设线程 2 恢复,将1与之前读取的值(1)相加,将结果(2)存储在counter中,并将1返回给调用者。因为线程 1 撤销了线程 2,所以我们丢失了一个增量,并且生成了一个非唯一的 ID。这个方法没用。

数据竞争

竞争条件通常与数据竞争相混淆,在数据竞争中,两个或多个线程(在单个应用中)同时访问同一个内存位置,其中至少一个访问是为了写入,并且这些线程不协调它们对该内存的访问。当这些条件成立时,访问顺序是不确定的。根据运行顺序,每次运行可能会产生不同的结果。考虑以下示例:

private static Parser parser;

public static Parser getInstance()

{

if (parser == null)

parser = new Parser();

return parser;

}

假设线程 1 首先调用getInstance()。因为它在parser字段中观察到了一个null值,所以线程 1 实例化了Parser,并将其引用分配给了parser。当线程 2 随后调用getInstance()时,它可以观察到parser包含一个非null引用,并简单地返回parser的值。或者,线程 2 可以观察到parser中的null值,并创建一个新的Parser对象。因为线程 1 对parser的写入和线程 2 对parser的读取之间没有先发生后排序(一个动作必须先于另一个动作)(因为没有对parser的协调访问),所以发生了数据竞争。

缓存变量

为了提高性能,编译器、Java 虚拟机(JVM)和操作系统可以协作将变量缓存在寄存器或处理器本地缓存中,而不是依赖于主存。每个线程都有自己的变量副本。当一个线程写入这个变量时,它也在写入它的副本;其他线程不太可能在其副本中看到更新。

第一章展示了一个ThreadDemo应用(见清单 1-3 )展示了这个问题。作为参考,我在这里重复部分源代码:

private static BigDecimal result;

public static void main(String[] args)

{

Runnable r = () ->

{

result = computePi(50000);

};

Thread t = new Thread(r);

t.start();

try

{

t.join();

}

catch (InterruptedException ie)

{

// Should never arrive here because interrupt() is never

// called.

}

System.out.println(result);

}

名为result的类字段演示了缓存变量问题。该字段由在 lambda 上下文中执行result = computePi(50000);的工作线程访问,当默认主线程执行System.out.println(result);时,该字段由默认主线程访问。

工作线程可以将computePi()的返回值存储在其result的副本中,而默认主线程可以打印其副本的值。默认主线程可能看不到result = computePi(50000);赋值,它的副本将保持默认的null。这个值将代替result的字符串表示(计算出的 pi 值)输出。

同步访问关键部分

您可以使用同步来解决前面的线程问题。同步是 JVM 的一个特性,它确保两个或多个并发线程不会同时执行一个临界段,临界段是一个必须以串行方式(一次一个线程)访问的代码段。

同步的这种特性被称为互斥,因为当另一个线程在临界区中时,每个线程都被互斥地禁止在临界区中执行。因此,线程获得的锁通常被称为互斥锁。

同步还展示了可见性的属性,其中它确保在临界区中执行的线程总是看到共享变量的最新变化。它在进入临界区时从主存中读取这些变量,并在退出时将它们的值写入主存。

同步是根据监视器实现的,监视器是用于控制对关键部分的访问的并发结构,必须不可分割地执行。每个 Java 对象都与一个监视器相关联,线程可以通过获取和释放监视器的锁(一个令牌)来锁定或解锁该监视器。

Note

已经获得锁的线程在调用Threadsleep()方法之一时不会释放这个锁。

只有一个线程可以持有监视器的锁。试图锁定该监视器的任何其他线程都会阻塞,直到它可以获得锁。当一个线程退出一个临界区时,它通过释放锁来解锁监视器。

锁被设计成可重入的,以防止死锁(稍后讨论)。当线程试图获取它已经持有的锁时,请求成功。

Tip

java.lang.Thread类声明了一个static boolean holdsLock(Object o)方法,当调用线程持有对象o的锁时,该方法返回true。您会发现这种方法在断言语句中很方便,比如assert Thread.holdsLock(o);

Java 提供了synchronized关键字来序列化线程对方法或语句块的访问(临界区)。

使用同步方法

同步的方法在它的头中包含关键字synchronized。例如,您可以使用这个关键字来同步前面的getID()方法,并克服它的读-修改-写竞争条件,如下所示:

public synchronized int getID()

{

return counter++;

}

当对实例方法进行同步时,锁与调用该方法的对象相关联。例如,考虑下面的ID类:

public class ID

{

private int counter; // initialized to 0 by default

public synchronized int getID()

{

return counter++;

}

}

假设您指定了以下代码序列:

ID id = new ID();

System.out.println(id.getID());

锁与ID对象相关联,该对象的引用存储在id中。如果另一个线程在这个方法执行的时候调用了id.getID(),那么这个线程将不得不等待,直到执行线程释放锁。

当对类方法进行同步时,锁与对应于调用其类方法的类的java.lang.Class对象相关联。例如,考虑下面的ID类:

public class ID

{

private static int counter; // initialized to 0 by default

public static synchronized int getID()

{

return counter++;

}

}

假设您指定了以下代码序列:

System.out.println(ID.getID());

锁与ID.class关联,Class对象与ID关联。如果另一个线程在这个方法执行时调用了ID.getID(),那么这个线程将不得不等待,直到执行线程释放锁。

使用同步块

同步语句块以一个头为前缀,该头标识要获取其锁的对象。它具有以下语法:

synchronized(``object

{

/* statements */

}

根据这种语法,object 是一个任意的对象引用。锁与此对象相关联。

我之前摘录了一个第一章的应用,它遭受了缓存变量的问题。您可以用两个同步块来解决这个问题:

Runnable r = () ->

{

synchronized(FOUR)

{

result = computePi(50000);

}

};

// …

synchronized(FOUR)

{

System.out.println(result);

}

这两个块标识了一对临界区。每个块都由相同的对象保护,因此一次只有一个线程可以在其中一个块中执行。每个线程必须获得与常量FOUR引用的对象相关联的锁,然后才能进入其临界区。

这段代码提出了关于同步块和同步方法的重要观点。访问相同代码序列的两个或多个线程必须获得相同的锁,否则将不会有同步。这意味着必须访问同一个对象。在前面的例子中,FOUR在两个地方被指定,因此在任一临界区中只能有一个线程。如果我在一个地方指定了synchronized(FOUR),在另一个地方指定了synchronized("ABC"),就不会有同步,因为会涉及到两个不同的锁。

当心活跃度问题

活跃度这个术语指的是一些有益的事情最终会发生。当应用达到无法继续前进的状态时,就会发生活动失败。在单线程应用中,无限循环就是一个例子。多线程应用面临死锁、活锁和饥饿的额外活动挑战:

  • 死锁:线程 1 等待线程 2 独占的资源,线程 2 等待线程 1 独占的资源。两个线程都无法取得进展。
  • 活锁:线程 x 不断重试一个总是会失败的操作。由于这个原因,它无法取得进展。
  • 饥饿:线程 x 不断被拒绝(被调度程序)访问所需的资源以取得进展。也许调度程序在低优先级线程之前执行高优先级线程,并且总是有一个高优先级线程可供执行。饥饿通常也被称为无限期推迟。

考虑死锁。出现这种病态问题是因为通过synchronized关键字进行了太多的同步。如果不小心的话,您可能会遇到这样的情况:锁被多个线程获取,没有一个线程持有自己的锁,而是持有其他线程需要的锁,没有一个线程可以进入并在以后退出其临界区来释放其持有的锁,因为另一个线程持有该临界区的锁。清单 2-1 的非典型例子展示了这个场景。

Listing 2-1. A Pathological Case of Deadlock

public class DeadlockDemo

{

private final Object lock1 = new Object();

private final Object lock2 = new Object();

public void instanceMethod1()

{

synchronized(lock1)

{

synchronized(lock2)

{

System.out.println("first thread in instanceMethod1");

// critical section guarded first by

// lock1 and then by lock2

}

}

}

public void instanceMethod2()

{

synchronized(lock2)

{

synchronized(lock1)

{

System.out.println("second thread in instanceMethod2");

// critical section guarded first by

// lock2 and then by lock1

}

}

}

public static void main(String[] args)

{

final DeadlockDemo dld = new DeadlockDemo();

Runnable r1 = new Runnable()

{

@Override

public void run()

{

while(true)

{

dld.instanceMethod1();

try

{

Thread.sleep(50);

}

catch (InterruptedException ie)

{

}

}

}

};

Thread thdA = new Thread(r1);

Runnable r2 = new Runnable()

{

@Override

public void run()

{

while(true)

{

dld.instanceMethod2();

try

{

Thread.sleep(50);

}

catch (InterruptedException ie)

{

}

}

}

};

Thread thdB = new Thread(r2);

thdA.start();

thdB.start();

}

}

列表 2-1 的线程 A 和线程 B 分别在不同的时间调用instanceMethod1()instanceMethod2()。考虑以下执行顺序:

Thread A calls instanceMethod1(), obtains the lock assigned to the lock1-referenced object, and enters its outer critical section (but has not yet acquired the lock assigned to the lock2-referenced object).   Thread B calls instanceMethod2(), obtains the lock assigned to the lock2-referenced object, and enters its outer critical section (but has not yet acquired the lock assigned to the lock1-referenced object).   Thread A attempts to acquire the lock associated with lock2. The JVM forces the thread to wait outside of the inner critical section because thread B holds that lock.   Thread B attempts to acquire the lock associated with lock1. The JVM forces the thread to wait outside of the inner critical section because thread A holds that lock.   Neither thread can proceed because the other thread holds the needed lock. You have a deadlock situation and the program (at least in the context of the two threads) freezes up.

编译清单 2-1 如下:

javac DeadlockDemo.java

运行生成的应用,如下所示:

java DeadlockDemo

您应该在标准输出流中观察交错的first thread in instanceMethod1second thread in instanceMethod2消息,直到应用因为死锁而停止运行。

尽管前面的例子清楚地标识了死锁状态,但是检测死锁通常并不容易。例如,您的代码可能包含不同类之间的以下循环关系(在几个源文件中):

  • 类 A 的同步方法调用类 B 的同步方法。
  • B 类的同步方法调用 C 类的同步方法。
  • C 类的同步方法调用 A 类的同步方法。

如果线程 A 调用类 A 的 synchronized 方法,而线程 B 调用类 C 的 synchronized 方法,那么当线程 B 试图调用类 A 的 synchronized 方法,而线程 A 仍在该方法内部时,线程 B 将会阻塞。线程 A 将继续执行,直到它调用类 C 的 synchronized 方法,然后阻塞。结果就是僵局。

Note

Java 语言和 JVM 都没有提供防止死锁的方法,因此这个负担就落在了您的身上。防止死锁的最简单方法是避免同步方法或同步块调用另一个同步方法/块。虽然这个建议防止了死锁的发生,但是它是不切实际的,因为您的一个同步方法/块可能需要调用 Java API 中的一个同步方法,并且这个建议是多余的,因为被调用的同步方法/块可能不会调用任何其他同步方法/块,所以不会发生死锁。

可变变量和最终变量

您之前已经了解到同步展示了两个属性:互斥和可见性。synchronized关键字与这两个属性相关联。Java 还提供了一种较弱的同步形式,只涉及可见性,并且只将这个属性与关键字volatile相关联。

假设您设计了自己的停止线程的机制(因为您不能使用Thread的不安全的stop()方法来完成这个任务)。清单 2-2 向一个ThreadStopping应用展示了源代码,展示了如何完成这项任务。

Listing 2-2. Attempting to Stop a Thread

public class ThreadStopping

{

public static void main(String[] args)

{

class StoppableThread extends Thread

{

private boolean stopped; // defaults to false

@Override

public void run()

{

while(!stopped)

System.out.println("running");

}

void stopThread()

{

stopped = true;

}

}

StoppableThread thd = new StoppableThread();

thd.start();

try

{

Thread.sleep(1000); // sleep for 1 second

}

catch (InterruptedException ie)

{

}

thd.stopThread();

}

}

清单 2-2 的main()方法声明了一个名为StoppableThread的局部类,它是Thread的子类。实例化StoppableThread后,默认主线程启动与这个Thread对象关联的线程。然后它休眠一秒钟,并在死亡前调用StoppableThreadstop()方法。

StoppableThread声明一个初始化为falsestopped实例字段变量,一个将该变量设置为truestopThread()方法,以及一个run()方法,其while循环在每次循环迭代时检查stopped以查看其值是否已更改为true

编译清单 2-2 如下:

javac ThreadStopping.java

运行生成的应用,如下所示:

java ThreadStopping

您应该观察到一系列的running消息。

当您在单处理器/单核机器上运行这个应用时,您可能会看到应用停止了。在多处理器机器或具有多个内核的单处理器机器上,您可能看不到这种中断,在这些机器上,每个处理器或内核可能都有自己的缓存,其中有自己的stopped副本。当一个线程修改这个字段的副本时,另一个线程的stopped副本不会改变。

您可能会决定使用synchronized关键字来确保只访问stopped的主内存副本。经过一番思考,您最终同步了对清单 2-3 中给出的源代码中的一对关键部分的访问。

Listing 2-3. Attempting to Stop a Thread via the synchronized Keyword

public class ThreadStopping

{

public static void main(String[] args)

{

class StoppableThread extends Thread

{

private boolean stopped; // defaults to false

@Override

public void run()

{

synchronized(this)

{

while(!stopped)

System.out.println("running");

}

}

synchronized void stopThread()

{

stopped = true;

}

}

StoppableThread thd = new StoppableThread();

thd.start();

try

{

Thread.sleep(1000); // sleep for 1 second

}

catch (InterruptedException ie)

{

}

thd.stopThread();

}

}

列出 2-3 是个坏主意,原因有二。首先,尽管您只需要解决可见性问题,synchronized也解决了互斥问题(这在这个应用中不是问题)。更重要的是,您已经在应用中引入了一个严重的问题。

您已经正确地同步了对stopped的访问,但是仔细看看run()方法中的 synchronized 块。注意这个while循环。该循环是无止境的,因为执行该循环的线程已经获得了当前StoppableThread对象的锁(通过synchronized(this)),并且默认主线程对该对象调用stopThread()的任何尝试都将导致默认主线程阻塞,因为默认主线程需要获得相同的锁。

您可以通过使用一个局部变量并将stopped的值赋给同步块中的这个变量来解决这个问题,如下所示:

public void run()

{

boolean _stopped = false;

while (!_stopped)

{

synchronized(this)

{

_stopped = stopped;

}

System.out.println("running");

}

}

然而,这种解决方案是混乱和浪费的,因为当试图获取锁时存在性能成本(现在不像以前那么大了),并且在每次循环迭代中都要进行这项任务。清单 2-4 揭示了一种更有效、更干净的方法。

Listing 2-4. Attempting to Stop a Thread via the volatile Keyword

public class ThreadStopping

{

public static void main(String[] args)

{

class StoppableThread extends Thread

{

private``volatile

@Override

public void run()

{

while(!stopped)

System.out.println("running");

}

void stopThread()

{

stopped = true;

}

}

StoppableThread thd = new StoppableThread();

thd.start();

try

{

Thread.sleep(1000); // sleep for 1 second

}

catch (InterruptedException ie)

{

}

thd.stopThread();

}

}

因为stopped已经被标记为volatile,所以每个线程都会访问这个变量的主存副本,而不会访问缓存副本。即使在基于多处理器或多核的机器上,应用也会停止。

Caution

仅在可见度成问题的情况下使用volatile。此外,您只能在字段声明的上下文中使用这个保留字(如果您试图创建一个局部变量volatile,您将会收到一个错误)。最后,您可以声明doublelong字段volatile,但是应该避免在 32 位 JVM 上这样做,因为访问一个doublelong变量的值需要两次操作,并且需要互斥(通过synchronized)来安全地访问它们的值。

当字段变量被声明为volatile时,它也不能被声明为final。然而,这不是问题,因为 Java 也允许您安全地访问final字段,而不需要同步。为了克服DeadlockDemo中的缓存变量问题,我标记了lock1lock2 final,尽管我本可以标记它们为volatile

您将经常使用final来帮助确保不可变类上下文中的线程安全。考虑上市 2-5 。

Listing 2-5. Creating an Immutable and Thread-Safe Class with Help from final

import java.util.Set;

import java.util.TreeSet;

public final class Planets

{

private final Set<String> planets = new TreeSet<>();

public Planets()

{

planets.add("Mercury");

planets.add("Venus");

planets.add("Earth");

planets.add("Mars");

planets.add("Jupiter");

planets.add("Saturn");

planets.add("Uranus");

planets.add("Neptune");

}

public boolean isPlanet(String planetName)

{

return planets.contains(planetName);

}

}

清单 2-5 给出了一个不可变的Planets类,它的对象存储了多组行星名称。尽管该集合是可变的,但该类的设计防止了在构造函数退出后该集合被修改。通过声明planets final,存储在该域的参考不能被修改。此外,这个引用不会被缓存,所以缓存变量的问题就解决了。

Java 为不可变对象提供了特殊的线程安全保证。只要遵守以下规则,即使不使用同步来发布(公开)这些对象的引用,也可以从多个线程安全地访问这些对象:

  • 不可变对象不允许状态被修改。
  • 所有字段都必须声明为final
  • 对象必须正确构造,这样“this”引用就不会从构造函数中逸出。

最后一点可能令人困惑,所以这里有一个简单的例子,其中this显式地从构造函数中转义:

public class ThisEscapeDemo

{

private static ThisEscapeDemo lastCreatedInstance;

public ThisEscapeDemo()

{

lastCreatedInstance = this;

}

}

www.ibm.com/developerworks/library/j-jtp0618/ 查看“Java 理论与实践:安全构造技术”,了解更多关于这种常见线程危害的信息。

Exercises

以下练习旨在测试您对第二章内容的理解:

Identify the three problems with threads.   True or false: When the correctness of a computation depends on the relative timing or interleaving of multiple threads by the scheduler, you have a data race.   Define synchronization.   Identify the two properties of synchronization.   How is synchronization implemented?   True or false: A thread that has acquired a lock doesn’t release this lock when it calls one of Thread’s sleep() methods.   How do you specify a synchronized method?   How do you specify a synchronized block?   Define liveness.   Identify the three liveness challenges.   How does the volatile keyword differ from synchronized?   True or false: Java also lets you safely access a final field without the need for synchronization.   Identify the thread problems with the following CheckingAccount class: public class CheckingAccount {    private int balance;    public CheckingAccount(int initialBalance)    {       balance = initialBalance;    }    public boolean withdraw(int amount)    {       if (amount <= balance)       {          try          {             Thread.sleep((int) (Math.random() * 200));          }          catch (InterruptedException ie)          {          }          balance -= amount;          return true;       }       return false;    }    public static void main(String[] args)    {       final CheckingAccount ca = new CheckingAccount(100);       Runnable r = new Runnable()                    {                       @Override                       public void run()                       {                          String name = Thread.currentThread().getName();                          for (int i = 0; i < 10; i++)                              System.out.println (name + " withdraws $10: " +                                              ca.withdraw(10));                       }                    };       Thread thdHusband = new Thread(r);       thdHusband.setName("Husband");       Thread thdWife = new Thread(r);       thdWife.setName("Wife");       thdHusband.start();       thdWife.start();    } }   Fix the thread problems in the previous CheckingAccount class.

摘要

当线程不交互时,开发多线程应用要容易得多,通常是通过共享变量。当交互发生时,会出现竞争条件、数据竞争和缓存变量问题,使应用变得不安全。

您可以使用同步来解决竞争条件、数据竞争和缓存变量问题。同步是一个 JVM 特性,它确保两个或多个并发的线程不会同时执行一个必须以串行方式访问的临界区。

活跃度指的是一些有益的事情最终会发生。当应用达到无法继续前进的状态时,就会发生活动失败。多线程应用面临着死锁、活锁和饥饿的挑战。

同步展示了两个属性:互斥和可见性。synchronized关键字与这两个属性相关联。Java 还提供了一种较弱的同步形式,只涉及可见性,并且只将这个属性与关键字volatile相关联。

当字段变量被声明为volatile时,它也不能被声明为final。然而,这不是问题,因为 Java 也允许您安全地访问final字段,而不需要同步。您将经常使用final来帮助确保不可变类上下文中的线程安全。

第三章呈现等待和通知。

三、等待和通知

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-1700-9_​3) contains supplementary material, which is available to authorized users.

Java 提供了一个支持线程间通信的小 API。使用这个 API,一个线程等待一个条件(继续执行的先决条件)的存在。将来,另一个线程将创建条件,然后通知等待的线程。在本章中,我将向您介绍这个 API。

等待并通知 API 程序

java.lang.Object类提供了一个等待和通知 API,它由三个wait()方法、一个notify()方法和一个notifyAll()方法组成。wait()方法等待一个条件存在;当条件存在时,notify()notifyAll()方法通知等待线程:

  • void wait():使当前线程等待,直到另一个线程调用该对象的notify ()notifyAll ()方法,或者其他线程在等待时中断当前线程。
  • void wait(long timeout):使当前线程等待,直到另一个线程调用该对象的notify ()notifyAll ()方法,或者等待指定的以毫秒计的时间(由timeout标识)过去,或者等待某个其他线程在等待时中断当前线程。当timeout为负时,该方法抛出java.lang.IllegalArgumentException
  • void wait(long timeout, int nanos):使当前线程等待,直到另一个线程调用该对象的notify ()notifyAll ()方法,或者等待以毫秒计的指定时间量(由timeout标识)加上纳秒(由nanos标识)过去,或者等待某个其他线程在等待时中断当前线程。当timeout为负,nanos为负,或者nanos大于999999时,该方法抛出IllegalArgumentException
  • 唤醒一个正在这个对象的监视器上等待的线程。如果有任何线程正在等待这个对象,它们中的一个将被唤醒。这种选择是任意的,由实现来决定。被唤醒的线程将无法继续,直到当前线程放弃对该对象的锁定。被唤醒的线程将以通常的方式与任何其他可能主动竞争同步该对象的线程竞争;例如,被唤醒的线程在成为下一个锁定该对象的线程时不享有任何可靠的特权或不利条件。
  • void notifyAll():唤醒所有等待这个对象的监视器的线程。被唤醒的线程将无法继续,直到当前线程放弃对该对象的锁定。被唤醒的线程将以通常的方式与任何其他可能主动竞争同步该对象的线程竞争;例如,被唤醒的线程在成为下一个锁定该对象的线程时不享有任何可靠的特权或不利条件。

当任何线程在当前线程等待通知之前或期间中断当前线程时,这三个wait()方法抛出java.lang.InterruptedException。当抛出这个异常时,当前线程的中断状态被清除。

Note

一个线程释放与调用其wait()方法的对象相关联的监视器的所有权。

这个 API 利用对象的条件队列,这是一个存储等待条件存在的线程的数据结构。等待线程被称为等待集。因为条件队列与对象的锁紧密绑定,所以所有五个方法都必须从同步上下文中调用(当前线程必须是对象监视器的所有者);否则,抛出java.lang.IllegalMonitorStateException

以下代码/伪代码片段演示了 noargument wait()方法:

synchronized(obj)

{

while (<condition does not hold>)

obj.wait();

// Perform an action that’s appropriate to condition.

}

从同步块中调用wait()方法,该同步块与调用wait()的对象(obj)同步。由于可能会出现虚假唤醒(线程在没有被通知、中断或超时的情况下被唤醒),所以从测试条件保持的while循环中调用wait(),并在条件仍然不保持时重新执行wait()。在while循环退出后,条件存在,可执行适合该条件的动作。

Caution

永远不要在循环之外调用wait()方法。该循环在调用wait()之前和之后测试条件。在调用wait()之前测试条件可以确保活跃度。如果这个测试不存在,并且如果条件成立,并且在调用wait()之前已经调用了notify(),那么等待线程就不太可能醒来。调用wait()后重新测试条件确保安全。如果重新测试没有发生,并且如果条件在线程从wait()调用中唤醒后不成立(当条件不成立时,可能另一个线程意外地调用了notify()),线程将继续破坏锁的受保护不变量。

下面的代码片段演示了前面示例中通知等待线程的notify()方法:

synchronized(obj)

{

// Set the condition.

obj.notify();

}

注意,notify()是从与wait()方法的临界区相同的对象(obj)保护的临界区调用的。同样,notify()使用相同的obj参考来调用。遵循这种模式,你应该不会陷入困境。

Note

关于哪种通知方式更好,一直有很多讨论:notify()还是notifyAll()。例如,查看“?? 与 ?? 的区别”( http://stackoverflow.com/questions/14924610/difference-between-notify-and-notifyall )。如果您想知道使用哪种方法,我会在只有两个线程的应用中使用notify(),其中一个线程偶尔会等待并需要另一个线程的通知。不然我就用notifyAll()

生产者和消费者

涉及条件的线程通信的一个经典例子是生产者线程和消费者线程之间的关系。生产者线程产生将由消费者线程消费的数据项。每个产生的数据项都存储在一个共享变量中。

假设线程以不同的速度运行。生产者可能会生成一个新的数据项,并在消费者检索前一个数据项进行处理之前将其记录在共享变量中。此外,消费者可能会在生成新的数据项之前检索共享变量的内容。

为了克服这些问题,生产者线程必须等待,直到它被通知先前产生的数据项已经被消费,并且消费者线程必须等待,直到它被通知已经产生了新的数据项。清单 3-1 向你展示了如何通过wait()notify()来完成这个任务。

Listing 3-1. The Producer-Consumer Relationship Version 1

public class PC

{

public static void main(String[] args)

{

Shared s = new Shared();

new Producer(s).start();

new Consumer(s).start();

}

}

class Shared

{

private char c;

private volatile boolean writeable = true;

synchronized void setSharedChar(char c)

{

while (!writeable)

try

{

wait();

}

catch (InterruptedException ie)

{

}

this.c = c;

writeable = false;

notify();

}

synchronized char getSharedChar()

{

while (writeable)

try

{

wait();

}

catch (InterruptedException ie)

{

}

writeable = true;

notify();

return c;

}

}

class Producer extends Thread

{

private final Shared s;

Producer(Shared s)

{

this.s = s;

}

@Override

public void run()

{

for (char ch = 'A'; ch <= 'Z'; ch++)

{

s.setSharedChar(ch);

System.out.println(ch + " produced by producer.");

}

}

}

class``Consumer

{

private final Shared s;

Consumer(Shared s)

{

this.s = s;

}

@Override

public void run()

{

char ch;

do

{

ch = s.getSharedChar();

System.out.println(ch + " consumed by consumer.");

}

while (ch != 'Z');

}

}

这个应用创建了一个Shared对象和两个获取对象引用副本的线程。生产者调用对象的setSharedChar()方法来保存 26 个大写字母中的每一个;消费者调用对象的getSharedChar()方法来获取每个字母。

writeable实例字段跟踪两个条件:生产者等待消费者消费一个数据项,消费者等待生产者产生一个新的数据项。它有助于协调生产者和消费者的执行。下面的场景说明了这种协调,在该场景中,使用者首先执行:

The consumer executes s.getSharedChar() to retrieve a letter.   Inside of that synchronized method, the consumer calls wait() because writeable contains true. The consumer now waits until it receives notification from the producer.   The producer eventually executes s.setSharedChar(ch)``;.   When the producer enters that synchronized method (which is possible because the consumer released the lock inside of the wait() method prior to waiting), the producer discovers writeable’s value to be true and doesn’t call wait().   The producer saves the character, sets writeable to false (which will cause the producer to wait on the next setSharedChar() call when the consumer has not consumed the character by that time), and calls notify() to awaken the consumer (assuming the consumer is waiting).   The producer exits setSharedChar(char c).   The consumer wakes up (and reacquires the lock), sets writeable to true (which will cause the consumer to wait on the next getSharedChar() call when the producer has not produced a character by that time), notifies the producer to awaken that thread (assuming the producer is waiting), and returns the shared character.

编译清单 3-1 如下:

javac PC.java

运行生成的应用,如下所示:

java PC

在一次运行中,您应该观察到如下摘录所示的输出:

W produced by producer.

W consumed by consumer.

X produced by producer.

X consumed by consumer.

Y produced by producer.

Y consumed by consumer.

Z produced by producer.

Z consumed by consumer.

尽管同步工作正常,但您可能会在多个消费消息之前观察到多个生产消息:

A produced by producer.

B produced by producer.

A consumed by consumer.

B consumed by consumer.

此外,您可能会在生成消息之前观察到一条消费消息:

V consumed by consumer.

V produced by producer.

奇怪的输出顺序并不意味着生产者线程和消费者线程不同步。相反,这是对setSharedChar()的调用后跟其同伴System.out.println()方法调用不同步,以及对getSharedChar()的调用后跟其同伴System.out.println()方法调用不同步的结果。通过将这些方法调用对中的每一个包装在同步块中,可以纠正输出顺序,该同步块与由s引用的Shared对象同步。清单 3-2 展示了这种增强。

Listing 3-2. The Producer-Consumer Relationship Version 2

public class PC

{

public static void main(String[] args)

{

Shared s = new Shared();

new Producer(s).start();

new Consumer(s).start();

}

}

class Shared

{

private char c;

private volatile boolean writeable = true;

synchronized void setSharedChar(char c)

{

while (!writeable)

try

{

wait();

}

catch (InterruptedException ie)

{

}

this.c = c;

writeable = false;

notify();

}

synchronized char getSharedChar()

{

while (writeable)

try

{

wait();

}

catch (InterruptedException ie)

{

}

writeable = true;

notify();

return c;

}

}

class Producer extends Thread

{

private final Shared s;

Producer(Shared s)

{

this.s = s;

}

@Override

public void run()

{

for (char ch = 'A'; ch <= 'Z'; ch++)

{

synchronized(s)

{

s.setSharedChar(ch);

System.out.println(ch + " produced by producer.");

}

}

}

}

class Consumer extends Thread

{

private final Shared s;

Consumer(Shared s)

{

this.s = s;

}

@Override

public void run()

{

char ch;

do

{

synchronized(s)

{

ch = s.getSharedChar();

System.out.println(ch + " consumed by consumer.");

}

}

while (ch != 'Z');

}

}

编译清单 3-2 ( javac PC.java)并运行这个应用(java PC)。它的输出应该总是以相同的交替顺序出现,如下所示(为简洁起见,只显示了前几行):

A produced by producer.

A consumed by consumer.

B produced by producer.

B consumed by consumer.

C produced by producer.

C consumed by consumer.

D produced by producer.

D consumed by consumer.

Exercises

以下练习旨在测试您对第三章内容的理解:

Define condition.   Describe the API that supports conditions.   True or false: The wait() methods are interruptible.   What method would you call to wake up all threads that are waiting on an object’s monitor?   True or false: A thread that has acquired a lock doesn’t release this lock when it calls one of Object’s wait() methods.   Define condition queue.   What happens when you call any of the API’s methods outside of a synchronized context?   Define spurious wakeup.   Why should you call a wait() method in a loop context?   Create an Await application that demonstrates a higher-level concurrency construct known as a gate. This construct permits multiple threads to arrive at a synchronization point (the gate) and wait until the gate is unlocked by another thread so that they can all proceed. The main() method first creates a runnable for the threads that will wait at the gate. The runnable prints a message stating that the thread is waiting, increments a counter, sleeps for 2 seconds, and waits (make sure to account for spurious wakeups). Upon wakeup, the thread outputs a message stating that the thread is terminating. main() then creates three Thread objects and starts three threads to execute the runnable. Next, main() creates another runnable that repeatedly sleeps for 200 milliseconds until the counter equals 3, at which point it notifies all waiting threads. Finally, main() creates a Thread object for the second runnable and starts the thread.

摘要

Java 提供了一个支持线程间通信的 API。这个 API 由Object的三个wait()方法、一个notify()方法和一个notifyAll()方法组成。wait()方法等待一个条件存在;notify()notifyAll()在条件存在时通知等待线程。

从同步块中调用wait()notify()notifyAll()方法,该同步块与调用它们的对象同步。由于虚假唤醒,当条件不成立时,从重新执行wait()while循环中调用wait()

涉及条件的线程通信的一个经典例子是生产者线程和消费者线程之间的关系。生产者线程产生将由消费者线程消费的数据项。每个产生的数据项都存储在一个共享变量中。

为了克服一些问题,例如消耗一个还没有产生的数据项,生产者线程必须等待,直到它被通知先前产生的数据项已经被消耗,而消费者线程必须等待,直到它被通知一个新的数据项已经被产生。

第四章介绍了额外的线程功能。

四、附加线程功能

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-1700-9_​4) contains supplementary material, which is available to authorized users.

第一章到第三章向你介绍了java.lang.Thread类和java.lang.Runnable接口,同步,等待和通知。在这一章中,我将通过向您介绍线程组和线程局部变量来完成我对线程基础知识的介绍。此外,我还介绍了定时器框架,它在幕后利用Thread来简化面向定时器的任务。

线程组

在探索Thread类时,您可能会在构造函数中遇到对java.lang.ThreadGroup类的引用,如Thread(ThreadGroupgroup,Runnabletarget),以及在方法中,如static int activeCount()``static int enumerate(Thread[] tarray)

ThreadGroup的 JDK 文档指出线程组“代表一组线程”。此外,一个线程组还可以包括其他线程组。线程组形成一棵树,其中除了初始线程组之外的每个线程组都有一个父线程

使用一个ThreadGroup对象,您可以对所有包含的Thread对象执行操作。例如,假设一个线程组被变量tg引用,tg.suspend();挂起该线程组中的所有线程。线程组简化了许多线程的管理。

虽然ThreadGroup看起来非常有用,但是您应该尽量避免这个类,原因如下:

  • 最有用的ThreadGroup方法是void suspend()void resume()void stop()。这些方法已经被弃用,因为像它们的Thread对应物(这些方法为线程组中的每个线程委托给它)一样,它们容易出现死锁和其他问题。
  • 不是线程安全的。例如,要获得一个线程组中活动线程的数量,可以调用ThreadGroupint activeCount()方法。然后,您将使用这个值来确定传递给ThreadGroupenumerate()方法之一的数组的大小。但是,不能保证计数保持准确,因为在创建数组和将数组传递给enumerate()之间,这个计数可能会因为线程的创建和终止而改变。如果数组太小,enumerate()会忽略多余的线程。同样的情况也适用于ThreadactiveCount()enumerate()方法,它们委托给当前线程的ThreadGroup方法。这个问题是“检查时间到使用时间”( https://en.wikipedia.org/wiki/Time_of_check_to_time_of_use )类软件 bug 的一个例子。(在您需要在对文件执行操作之前检查文件是否存在的情况下,这种错误也会出现。在文件检查和操作之间,可能会删除或创建文件。)

然而,您仍然应该知道ThreadGroup,因为它在处理线程执行时抛出的异常方面做出了贡献。清单 4-1 通过呈现一个试图用0除一个整数的run()方法,为学习异常处理搭建了舞台,这导致了一个抛出的java.lang.ArithmeticException对象。

Listing 4-1. Throwing an Exception from the run() Method

public class ExceptionThread

{

public static void main(String[] args)

{

Runnable r = new Runnable()

{

@Override

public void run()

{

int x = 1 / 0; // Line 10

}

};

Thread thd = new Thread(r);

thd.start();

}

}

默认的主线程创建了一个 runnable,它通过试图将整数除以整数0来故意抛出一个ArithmeticException对象。

编译清单 4-1 如下:

javac ExceptionThread.java

运行生成的应用,如下所示:

java ExceptionThread

您将看到一个异常跟踪,它标识了被抛出的ArithmeticException类的实例:

Exception in thread "Thread-0" java.lang.ArithmeticException: / by zero

at ExceptionThread$1.run(ExceptionThread.java:10)

at java.lang.Thread.run(Thread.java:745)

当从run()方法中抛出异常时,线程终止,并发生以下活动:

  • Java 虚拟机(JVM)寻找通过Threadvoid setUncaughtExceptionHandler(Thread.UncaughtExceptionHandler eh)方法安装的Thread.UncaughtExceptionHandler的实例。当找到这个处理程序时,它将执行传递给实例的void uncaughtException(Thread t, Throwable e)方法,其中t标识抛出异常的线程的Thread对象,而e标识抛出的异常或错误——可能是抛出了java.lang.OutOfMemoryError对象。如果uncaughtException()抛出异常/错误,异常/错误将被 JVM 忽略。
  • 假设没有调用setUncaughtExceptionHandler()来安装处理程序,JVM 将控制权传递给关联的ThreadGroup对象的uncaughtException(Thread t, Throwable e)方法。假设ThreadGroup没有被扩展,并且它的uncaughtException()方法没有被覆盖来处理异常,当父ThreadGroup存在时,uncaughtException()将控制传递给父ThreadGroup对象的uncaughtException()方法。否则,它会检查是否安装了默认的未捕获异常处理程序(通过Threadstatic void setDefaultUncaughtExceptionHandler (Thread.UncaughtExceptionHandler handler)方法)。如果已经安装了一个默认的未捕获异常处理程序,那么它的uncaughtException()方法会用同样的两个参数来调用。否则,uncaughtException()检查它的Throwable参数以确定它是否是java.lang.ThreadDeath的实例。如果是,则不做任何特殊处理。否则,如清单 4-1 的异常消息所示,使用Throwable参数的printStackTrace()方法,将包含从线程的getName()方法返回的线程名称和栈回溯的消息打印到标准错误流中。

清单 4-2 演示了ThreadsetUncaughtExceptionHandler()setDefaultUncaughtExceptionHandler()方法。

Listing 4-2. Demonstrating Uncaught Exception Handlers

public class ExceptionThread

{

public static void main(String[] args)

{

Runnable r = new Runnable()

{

@Override

public void run()

{

int x = 1 / 0;

}

};

Thread thd = new Thread(r);

Thread.UncaughtExceptionHandler uceh;

uceh = new Thread.UncaughtExceptionHandler()

{

@Override

public void uncaughtException(Thread t, Throwable e)

{

System.out.println("Caught throwable " + e +

" for thread " + t);

}

};

thd.setUncaughtExceptionHandler(uceh);

uceh = new Thread.UncaughtExceptionHandler()

{

@Override

public void uncaughtException(Thread t, Throwable e)

{

System.out.println("Default uncaught exception handler");

System.out.println("Caught throwable " + e +

" for thread " + t);

}

};

thd.setDefaultUncaughtExceptionHandler(uceh);

thd.start();

}

}

编译清单 4-2 ( javac ExceptionThread.java)并运行结果应用(java ExceptionThread)。您应该观察到以下输出:

Caught throwable java.lang.ArithmeticException: / by zero for thread Thread[Thread-0,5,main]

您也不会看到默认的未捕获异常处理程序的输出,因为默认的处理程序没有被调用。要查看输出,您必须注释掉thd.setUncaughtExceptionHandler(uceh);。如果你也注释掉thd.setDefaultUncaughtExceptionHandler(uceh);,你会看到清单 4-1 的输出。

线程局部变量

有时,您会希望将每个线程的数据(如用户 ID)与一个线程相关联。虽然您可以使用局部变量来完成这项任务,但是您只能在局部变量存在时才能这样做。您可以使用实例字段将这些数据保存更长时间,但是这样您就必须处理同步问题。幸运的是,Java 提供了java.lang.ThreadLocal类作为简单(并且非常方便)的替代。

每个ThreadLocal实例描述一个线程本地变量,这是一个为每个访问该变量的线程提供单独存储槽的变量。您可以将线程局部变量视为一个多时隙变量,其中每个线程可以在同一个变量中存储不同的值。每个线程只看到自己的值,不知道其他线程在这个变量中有自己的值。

ThreadLocal一般被声明为ThreadLocal<T>,其中T标识存储在变量中的值的类型。该类声明了以下构造函数和方法:

  • ThreadLocal():创建一个新的线程局部变量。
  • T get():返回调用线程存储槽中的值。如果线程调用这个方法时条目不存在,get()调用initialValue()
  • T initialValue():创建调用线程的存储槽,并在该槽中存储一个初始值(默认值)。初始值默认为null。您必须子类化ThreadLocal并覆盖这个protected方法来提供一个更合适的初始值。
  • void remove():移除调用线程的存储槽。如果这个方法后面跟随着get(),没有中间的set()get()调用initialValue()
  • void set(T value):将调用线程的存储槽的值设置为value

清单 4-3 展示了如何使用ThreadLocal将不同的用户 id 与两个线程关联起来。

Listing 4-3. Different User IDs for Different Threads

public class ThreadLocalDemo

{

private static volatile ThreadLocal<String> userID =

new ThreadLocal<String>();

public static void main(String[] args)

{

Runnable r = new Runnable()

{

@Override

public void run()

{

String name = Thread.currentThread().getName();

if (name.equals("A"))

userID.set("foxtrot");

else

userID.set("charlie");

System.out.println(name + " " + userID.get());

}

};

Thread thdA = new Thread(r);

thdA.setName("A");

Thread thdB = new Thread(r);

thdB.setName("B");

thdA.start();

thdB.start();

}

}

在实例化ThreadLocal并将引用分配给名为userIDvolatile类字段(该字段为volatile,因为它由不同的线程访问,这些线程可能在多处理器/多核机器上执行——我可以指定final)之后,默认主线程创建另外两个线程,在userID中存储不同的java.lang.String对象并输出它们的对象。

编译清单 4-3 如下:

javac ThreadLocalDemo.java

运行生成的应用,如下所示:

java ThreadLocalDemo

您应该观察到以下输出(可能不是这个顺序):

A foxtrot

B charlie

存储在线程局部变量中的值是不相关的。当一个新线程被创建时,它获得一个包含initialValue()值的新存储槽。也许您更愿意将值从父线程(创建另一个线程的线程)传递给子线程(创建的线程)。你用InheritableThreadLocal完成这个任务。

InheritableThreadLocalThreadLocal的子类。除了声明一个InheritableThreadLocal()构造函数,这个类还声明了下面的protected方法:

  • T childValue(T parentValue):在创建子线程时,根据父线程的值计算子线程的初始值。在子线程启动之前,从父线程调用此方法。该方法返回传递给parentValue的参数,并且应该在需要另一个值时被覆盖。

清单 4-4 展示了如何使用InheritableThreadLocal将父线程的Integer对象传递给子线程。

Listing 4-4. Passing an Object from a Parent Thread to a Child Thread

public class InheritableThreadLocalDemo

{

private static final InheritableThreadLocal<Integer> intVal =

new InheritableThreadLocal<Integer>();

public static void main(String[] args)

{

Runnable rP = () ->

{

intVal.set(new Integer(10));

Runnable rC = () ->

{

Thread thd = Thread.currentThread();

String name = thd.getName();

System.out.printf("%s %d%n", name,

intVal.get());

};

Thread thdChild = new Thread(rC);

thdChild.setName("Child");

thdChild.start();

};

new Thread(rP).start();

}

}

在实例化InheritableThreadLocal并将其分配给一个名为intValfinal类字段(我本可以使用volatile来代替)后,默认主线程创建一个父线程,它在intVal中存储一个包含10java.lang.Integer对象。父线程创建一个子线程,子线程访问intVal并检索其父线程的Integer对象。

编译清单 4-4 如下:

javac InheritableThreadLocalDemo.java

运行生成的应用,如下所示:

java InheritableThreadLocalDemo

您应该观察到以下输出:

Child 10

Note

要更深入地了解ThreadLocal及其实现方式,请查看 Patson Luk 的“Java 线程本地存储的无痛介绍”博文( http://java.dzone.com/articles/painless-introduction-javas-threadlocal-storage )。

计时器框架

通常有必要将任务(工作单元)安排为一次性执行(任务只运行一次)或定期重复执行。例如,您可以安排闹钟任务只运行一次(也许是为了在早上叫醒您),或者安排每夜备份任务定期运行。对于任何一种任务,您可能希望任务在未来的特定时间运行,或者在初始延迟后运行。

您可以使用Thread和相关类型来构建一个完成任务调度的框架。然而,Java 1.3 以java.util.Timerjava.util.TimerTask类的形式引入了一个更方便、更简单的替代方法。

Timer允许您调度TimerTask在后台线程上执行(以连续的方式),这就是所谓的任务执行线程。定时器任务可以被安排为一次性执行或定期重复执行。

清单 4-5 展示了一个应用,演示了定时器任务的一次性执行。

Listing 4-5. Demonstrating One-Shot Execution

import java.util.Timer;

import java.util.TimerTask;

public class TimerDemo

{

public static void main(String[] args)

{

TimerTask task = new TimerTask()

{

@Override

public void run()

{

System.out.println("alarm going off");

System.exit(0);

}

};

Timer timer = new Timer();

timer.schedule(task, 2000); // Execute one-shot timer task after

// 2-second delay.

}

}

清单 4-5 描述了一个应用,它的默认主线程首先实例化一个TimerTask匿名子类,其覆盖的run()方法输出一个警报消息,然后执行System.exit(0) ;,因为应用不会终止,直到非守护进程任务执行线程终止。然后默认主线程实例化Timer并调用它的schedule()方法,这个task作为第一个参数。第二个参数在初始延迟2000毫秒后,将这个task调度为单次执行。

编译清单 4-5 如下:

javac TimerDemo.java

运行生成的应用,如下所示:

java TimerDemo

您应该观察到类似于以下输出的输出:

alarm going off

清单 4-6 展示了一个应用,它演示了定时任务的定期重复执行。

Listing 4-6. Displaying the Current Millisecond Value at Approximately One-Second Intervals

import java.util.Timer;

import java.util.TimerTask;

public class TimerDemo

{

public static void main(String[] args)

{

TimerTask task = new TimerTask()

{

@Override

public void run()

{

System.out.println(System.currentTimeMillis());

}

};

Timer timer = new Timer();

timer.schedule(task, 0, 1000);

}

}

清单 4-6 描述了一个应用,其默认主线程首先实例化一个TimerTask匿名子类,其覆盖的run()方法输出当前时间(以毫秒为单位)。然后默认主线程实例化Timer并调用它的schedule()方法,这个task作为第一个参数。第二个和第三个参数安排这个task在没有初始延迟和每隔1000毫秒后重复执行。

编译清单 4-6 ( javac TimerDemo.java)并运行结果应用(java TimerDemo)。您应该在这里看到截断的输出:

1445655847902

1445655848902

1445655849902

1445655850902

1445655851902

1445655852902

深度计时器

以前的应用在非守护进程任务执行线程上运行它们的任务。此外,一个任务作为一次性任务运行,而另一个任务重复运行。要理解这些选择是如何做出的,你需要了解更多关于Timer的知识。

Note

Timer扩展到大量并发调度的定时器任务(数千个任务应该不成问题)。在内部,这个类使用二进制堆来表示它的计时器任务队列,因此调度计时器任务的开销是 O(log n),其中 n 是并发调度的计时器任务的数量。要了解关于 O()符号的更多信息,请查看维基百科的“大 O 符号”主题( http://en.wikipedia.org/wiki/Big_O_notation )。

Timer声明了以下构造函数:

  • Timer():创建一个新的定时器,它的任务执行线程不作为守护线程运行。
  • Timer(boolean isDaemon):创建一个新的定时器,它的任务执行线程可以被指定为守护进程(将true传递给isDaemon)。在计时器将被用于安排重复的“维护活动”的情况下,调用守护线程,只要应用在运行,就必须执行维护活动,但是不应该延长应用的生命周期。
  • Timer(String name):创建一个新的定时器,其任务执行线程具有指定的name。任务执行线程不作为守护线程运行。这个构造函数在namenull时抛出java.lang.NullPointerException
  • Timer(String name, boolean isDaemon):创建一个新的定时器,其任务执行线程具有指定的name,并且可以作为守护线程运行。这个构造函数在namenull时抛出NullPointerException

Timer还声明了以下方法:

  • void cancel():终止此定时器,放弃任何当前计划的定时器任务。该方法不会干扰当前正在执行的计时器任务(如果存在的话)。定时器终止后,它的执行线程优雅地终止,不再有定时器任务被调度。(从这个定时器调用的定时器任务的run()方法中调用cancel()绝对保证了正在进行的任务执行是这个定时器将执行的最后一个任务执行。)这个方法可能会被重复调用;第二次和随后的调用没有效果。
  • int purge():删除该定时器队列中所有已取消的定时器任务,并返回已删除的定时器任务数。调用purge()对计时器的行为没有影响,但是从队列中删除了对取消的计时器任务的引用。当没有对这些计时器任务的外部引用时,它们就有资格进行垃圾收集。(大多数应用不需要调用这个方法,它是为取消大量计时器任务的罕见应用设计的。调用purge()用时间换空间:这种方法的运行时间可能与 n + c * log n 成正比,其中 n 是队列中的定时器任务数,c 是取消的定时器任务数。)允许从该定时器上调度的定时器任务中调用purge()
  • void schedule(TimerTask task, Date time):安排tasktime执行。当time过去后,task被安排立即执行。当time.getTime()为负时,该方法抛出java.lang.IllegalArgumentExceptionjava.lang.IllegalStateExceptiontask已经被调度或取消时,定时器被取消,或者任务执行线程被终止;当tasktimenullNullPointerException
  • void schedule(TimerTask task, Date firstTime, long period):预定task重复固定延时执行,从firstTime开始。随后的执行以大约固定的时间间隔进行,间隔为period毫秒。在固定延迟执行中,每次执行都是相对于前一次执行的实际执行时间来调度的。当一个执行由于某种原因(比如垃圾收集)被延迟时,后续的执行也会被延迟。从长远来看,执行的频率一般会略低于period的倒数(假设Object.wait(long)底层的系统时钟是准确的)。结果,当预定的firstTime值过去时,task被预定立即执行。固定延迟执行适用于需要“流畅度”的周期性任务换句话说,这种执行方式适用于短期内保持频率准确比长期更重要的任务。这包括大多数动画任务,如定期闪烁光标。它还包括响应人类输入而执行常规活动的任务,例如只要按下一个键,就自动重复一个字符。当firstTime.getTime()为负或者period为负或者为零时,该方法抛出IllegalArgumentExceptionIllegalStateExceptiontask已经被调度或取消时,定时器被取消,或者任务执行线程终止;当taskfirstTimenull时为NullPointerException
  • void schedule(TimerTask task, long delay):调度taskdelay毫秒后执行。当delay为负或者delay + System.currentTimeMillis()为负时,该方法抛出IllegalArgumentExceptionIllegalStateExceptiontask已经被调度或取消时,定时器被取消,或者任务执行线程被终止;当tasknullNullPointerException
  • void schedule(TimerTask task, long delay, long period):调度task重复固定延时执行,在delay毫秒后开始。随后的执行以大约固定的间隔进行,间隔为period毫秒。当delay为负、delay + System.currentTimeMillis()为负、或者period为负或者为零时,该方法抛出IllegalArgumentExceptionIllegalStateExceptiontask已经被调度或取消时,定时器被取消,或者任务执行线程被终止;当tasknullNullPointerException
  • void scheduleAtFixedRate(TimerTask task, Date firstTime, long period):从time开始,调度task重复固定速率执行。随后的执行以大约固定的时间间隔进行,间隔为period毫秒。在固定速率执行中,每次执行都是相对于初始执行的预定执行时间进行调度的。当一个执行由于任何原因(比如垃圾收集)被延迟时,两个或更多的执行将快速连续地发生以“赶上”从长远来看,执行频率将正好是period的倒数(假设Object.wait(long)的系统时钟是准确的)。因此,当调度的firstTime过去时,任何“错过的”执行将被调度为立即“赶上”执行。固定速率执行适用于对绝对时间敏感的重复性活动(例如每小时整点鸣响一次,或者每天在特定时间运行计划维护)。它也适用于执行固定次数的总时间很重要的重复性活动,例如每秒钟滴答一次的倒计时计时器,持续 10 秒钟。最后,固定速率执行适用于调度多个重复的计时器任务,这些任务必须保持彼此同步。当firstTime.getTime()为负,或者period为负或零时,该方法抛出IllegalArgumentExceptionIllegalStateExceptiontask已经被调度或取消时,定时器被取消,或者任务执行线程终止;当taskfirstTimenullNullPointerException
  • void scheduleAtFixedRate(TimerTask task, long delay, long period):调度task重复固定速率执行,在delay毫秒后开始。随后的执行以大约固定的时间间隔进行,间隔为period毫秒。当delay为负、delay + System.currentTimeMillis()为负、或者period为负或者为零时,该方法抛出IllegalArgumentExceptionIllegalStateExceptiontask已经被调度或取消时,定时器被取消,或者任务执行线程被终止;当tasknullNullPointerException

在对一个Timer对象的最后一个活引用消失并且所有未完成的定时器任务已经完成执行之后,定时器的任务执行线程优雅地终止(并且成为垃圾收集的对象)。然而,这可能需要任意长时间才能发生。(默认情况下,任务执行线程不作为守护线程运行,因此它能够防止应用终止。)当应用想要快速终止计时器的任务执行线程时,应用应该调用Timercancel()方法。

当定时器的任务执行线程意外终止时,例如,因为它的stop()方法被调用(你永远不应该调用任何Threadstop()方法,因为它们本质上是不安全的),任何在定时器上调度定时器任务的进一步尝试都会导致IllegalStateException,就好像Timercancel()方法被调用一样。

TimerTask 深度

计时器任务是抽象TimerTask类的子类,实现了Runnable接口。当子类化TimerTask时,你覆盖它的void run()方法来提供定时器任务的代码。

Note

计时器任务应该很快完成。当一个定时器任务花费太长时间来完成时,它会“霸占”定时器的任务执行线程,延迟后续定时器任务的执行,这些任务可能会“聚集”起来,并在违规的定时器任务最终完成时快速连续执行。

您还可以从覆盖计时器任务的run()方法中调用以下方法:

  • boolean cancel():取消该定时器任务。当计时器任务已被安排为一次性执行且尚未运行时,或者当它尚未被安排时,它将永远不会运行。当计时器任务被安排重复执行时,它将不再运行。(当此调用发生时计时器任务正在运行,计时器任务将运行到完成,但不会再次运行。)从重复计时器任务的run()方法中调用cancel()绝对可以保证计时器任务不会再次运行。此方法可能会被重复调用;第二次和随后的调用没有效果。当该定时器任务被安排为一次性执行且尚未运行时,或者当该定时器任务被安排为重复执行时,该方法返回true。当定时器任务被调度为一次性执行并且已经运行时,当定时器任务从未被调度时,或者当定时器任务已经被取消时,它返回false。(不严格地说,当这个方法阻止一个或多个预定的执行发生时,它返回true。)
  • long scheduledExecutionTime():返回该定时器任务最近一次实际执行的计划执行时间。(当正在执行计时器任务时调用此方法,返回值是正在执行的计时器任务的计划执行时间。)该方法通常从任务的run()方法中调用,以确定计时器任务的当前执行是否足够及时,以保证执行计划的活动。例如,您可以在run()方法的开头指定类似于if (System.currentTimeMillis() -scheduledExecutionTime() >= MAX_TARDINESS) return;的代码,以便在不及时的时候中止当前计时器任务的执行。这种方法通常不与固定延迟执行重复计时器任务结合使用,因为它们的计划执行时间可以随时间漂移,因此并不十分重要。scheduledExecutionTime()java.util.Date.getTime()返回的格式返回该定时器任务最近一次计划执行的时间。当计时器任务尚未开始第一次执行时,返回值是未定义的。

Exercises

以下练习旨在测试您对第四章内容的理解:

Define thread group.   Why might you use a thread group?   Why should you avoid using thread groups?   Why should you be aware of thread groups?   Define thread-local variable.   True or false: If an entry doesn’t exist in the calling thread’s storage slot when the thread calls get(), this method calls initialValue().   How would you pass a value from a parent thread to a child thread?   Identify the classes that form the Timer Framework.   True or false: Timer() creates a new timer whose task-execution thread runs as a daemon thread.   Define fixed-delay execution.   Which methods do you call to schedule a task for fixed-delay execution?   Define fixed-rate execution.   What is the difference between Timer’s cancel() method and TimerTask’s cancel() method?   Create a BackAndForth application that uses Timer and TimerTask to repeatedly move an asterisk forward 20 steps and then backward 20 steps. The asterisk is output via System.out.print().

摘要

ThreadGroup类描述了一个线程组,它存储了一组线程。它通过对所有包含的线程应用方法调用来简化线程管理。您应该避免使用线程组,因为最有用的方法已被弃用,并且存在争用情况。

ThreadLocal类描述了一个线程局部变量,它允许您将每个线程的数据(比如用户 ID)与一个线程相关联。它为每个访问该变量的线程提供一个单独的存储槽。可以把线程局部变量想象成一个多时隙变量,其中每个线程可以在同一个变量中存储不同的值。每个线程只看到自己的值,不知道其他线程在这个变量中有自己的值。存储在线程局部变量中的值是不相关的。父线程可以使用InheritableThreadLocal类将值传递给子线程。

通常有必要将任务安排为一次性执行或定期重复执行。Java 1.3 引入了定时器框架,它由TimerTimerTask类组成,以便于在定时器上下文中使用线程。

第五章介绍并发工具并展示执行器。

五、并发工具和执行器

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-1700-9_​5) contains supplementary material, which is available to authorized users.

前四章关注的是 Java 对线程的底层支持。本章将重点转移到 Java 的高级线程支持上,这就是所谓的并发工具。可以把并发工具想象成用高级语言编写应用,把它的低级线程支持想象成用汇编语言编写应用。在简单地向您介绍了这些工具之后,我将带您参观一下遗嘱执行人。接下来的三章将介绍各种并发工具的其他子集。

并发工具简介

Java 的低级线程支持允许您创建多线程应用,与单线程应用相比,它提供了更好的性能和响应能力。但是,也有问题:

  • 诸如synchronizedwait() / notify()这样的低级并发原语通常很难正确使用。不正确地使用这些原语会导致竞争情况、线程饥饿、死锁和其他危险,这可能很难检测和调试。
  • 过分依赖synchronized原语会导致性能问题,从而影响应用的可伸缩性。对于像 web 服务器这样的高度线程化的应用来说,这是一个很大的问题。
  • 开发人员通常需要更高级别的结构,比如线程池和信号量。因为这些构造不包括在 Java 的底层线程支持中,所以开发人员不得不自己构建,这是一项耗时且容易出错的活动。

为了解决这些问题,Java 5 引入了并发工具,这是一个强大且可扩展的高性能线程工具框架,如线程池和阻塞队列。该框架由以下包中的各种类型组成:

  • java.util.concurrent:并发编程中经常用到的工具类型,比如执行器。
  • java.util.concurrent.atomic:支持单变量无锁线程安全编程的工具类。
  • java.util.concurrent.locks:根据条件锁定和等待的工具类型(让线程暂停执行[等待]直到被其他线程通知某个布尔状态现在可能为真的对象)。通过这些类型的锁定和等待比通过 Java 的基于监视器的同步和等待/通知机制更具性能和灵活性。

这个框架还向java.lang.System类引入了一个long nanoTime()方法,它允许您访问纳秒粒度的时间源来进行相对时间测量。

并发工具可以分为执行器、同步器、锁定框架等等。我将在下一节探讨遗嘱执行人,并在后续章节中探讨这些类别。

探索执行者

Threads API 允许您通过像new java.lang.Thread (new RunnableTask()).start();这样的表达式来执行可运行的任务。这些表达式将任务提交与任务的执行机制紧密耦合(在当前线程、新线程或从线程池[组]中任意选择的线程上运行)。

Note

任务是一个对象,它的类实现了java.lang.Runnable接口(一个可运行的任务)或java.util.concurrent.Callable接口(一个可调用的任务)。在这一章的后面我会说更多关于Callable的事情。

并发工具包括执行器,作为执行可运行任务的低级线程表达式的高级替代。执行器是一个对象,它的类直接或间接地实现了java.util.concurrent.Executor接口,该接口将任务提交与任务执行机制相分离。

Note

Executor 框架使用接口将任务提交从任务执行中分离出来,类似于 Collections 框架使用核心接口将列表、集合、队列和映射从它们的实现中分离出来。解耦产生了更容易维护的灵活代码。

Executor声明了一个单独的void execute(Runnable runnable)方法,该方法在未来的某个时间执行名为runnable的可运行任务。execute()runnablenull时抛出java.lang.NullPointerException,不能执行runnable时抛出java.util.concurrent.RejectedExecutionException

Note

当一个执行程序正在关闭并且不想接受新的任务时会抛出。此外,当执行器没有足够的空间来存储任务时,也会抛出这个异常(也许执行器使用了一个有界的阻塞队列来存储任务,而队列已经满了——我在第八章中讨论了阻塞队列)。

下面的例子给出了前面提到的new Thread(new RunnableTask()).start();表达式的Executor等价物:

Executor executor = ...; //  ... represents some executor creation

executor.execute(new RunnableTask());

虽然Executor很容易使用,但是这个接口在各方面都有限制:

  • Executor只关注Runnable。因为Runnablerun()方法不返回值,所以对于一个可运行的任务来说,没有简单的方法向它的调用者返回值。
  • Executor没有提供一种方法来跟踪正在执行的可运行任务的进度,取消正在执行的可运行任务,或者确定可运行任务何时完成执行。
  • Executor无法执行可运行任务的集合。
  • 没有为应用提供关闭执行程序的方法(更不用说正确关闭执行程序了)。

这些限制由java.util.concurrent.ExecutorService接口解决,该接口扩展了Executor,其实现通常是一个线程池。表 5-1 描述了ExecutorService的方法。

表 5-1。

ExecutorService’s Methods

| 方法 | 描述 | | --- | --- | | `boolean awaitTermination(long timeout, TimeUnit unit)` | 阻塞(等待)直到关闭请求后所有任务都已完成,`timeout`(以`unit`时间单位测量)到期,或者当前线程被中断,无论哪种情况先发生。当该执行人终止时返回`true`,当`timeout`在终止前结束时返回`false`。该方法中断时抛出`java.lang.InterruptedException`。 | | ` List> invokeAll(Collection> tasks)` | 执行`tasks`集合中的每个可调用任务,并返回一个`java.util.concurrent.Future`实例的`java.util.List`(将在本章后面讨论),当所有任务完成时,这些实例保存任务状态和结果——任务通过正常终止或抛出异常来完成。`Future` s 的`List`与`tasks`迭代器返回的任务顺序相同。当这个方法在等待中被中断时抛出`InterruptedException`,在这种情况下,未完成的任务被取消;`NullPointerException`当`tasks`或其任意元素为`null`时;以及当任何一个`tasks`任务不能被调度执行时的`RejectedExecutionException`。 | | ` List> invokeAll(Collection> tasks, long timeout, TimeUnit unit)` | 执行`tasks`集合中的每个可调用任务,并返回一个`Future`实例的`List`,当所有任务完成时(通过正常终止或抛出异常来完成任务)或`timeout`(以`unit`时间单位度量)到期时,这些实例保存任务状态和结果。到期时未完成的任务将被取消。`Future` s 的`List`与`tasks`迭代器返回的任务顺序相同。这个方法在等待中被中断时抛出`InterruptedException`(未完成的任务被取消)。当`tasks`、其任意元素或`unit`为`null`时,它也会抛出`NullPointerException`;并且当任何一个`tasks`任务不能被调度执行时抛出`RejectedExecutionException`。 | | ` T invokeAny(Collection> tasks)` | 执行给定的`tasks`,返回成功完成的任意任务的结果(换句话说,没有抛出异常),如果有的话。在正常或异常返回时,未完成的任务被取消。该方法在等待过程中被中断时抛出`InterruptedException`,当`tasks`或其任何元素为`null`时抛出`NullPointerException`,当`tasks`为空时抛出`java.lang.IllegalArgumentException`,当没有任务成功完成时抛出`java.util.concurrent.ExecutionException`,当没有任务可以被调度执行时抛出`RejectedExecutionException`。 | | ` T invokeAny(Collection> tasks, long timeout, TimeUnit unit)` | 执行给定的`tasks`,返回成功完成的任意任务的结果(没有抛出异常),如果在`timeout`(以`unit`时间单位测量)到期之前有任何任务成功完成,则取消到期时未完成的任务。在正常或异常返回时,未完成的任务将被取消。该方法在等待过程中被中断时抛出`InterruptedException`;`NullPointerException`当`tasks`时,其任一元素,或`unit`为`null`;`IllegalArgumentException`当`tasks`为空时;`java.util.concurrent.TimeoutException`当`timeout`在任何任务成功完成之前过去时;`ExecutionException`没有任务成功完成时;以及`RejectedExecutionException`当没有任务可以被调度执行时。 | | `boolean isShutdown()` | 当该执行程序被关闭时返回`true`;否则,返回`false`。 | | `boolean isTerminated()` | 关机后所有任务完成后返回`true`;否则,返回`false`。在调用`shutdown()`或`shutdownNow()`之前,这个方法永远不会返回`true`。 | | `void shutdown()` | 启动有序关机,执行以前提交的任务,但不接受新任务。执行程序关闭后,调用此方法没有任何效果。该方法不等待先前提交的任务完成执行。当需要等待时,使用`awaitTermination()`。 | | `List shutdownNow()` | 尝试停止所有正在执行的任务,暂停正在等待的任务的处理,并返回正在等待执行的任务列表。除了尽最大努力停止处理正在执行的任务之外,没有任何保证。例如,典型的实现将通过`Thread.interrupt()`取消,因此任何未能响应中断的任务可能永远不会终止。 | | ` Future submit(Callable task)` | 提交一个可调用的`task`来执行,并返回一个代表`task`的未决结果的`Future`实例。`Future`实例的`get()`方法在成功完成时返回`task`的结果。当`task`不能被调度执行时,该方法抛出`RejectedExecutionException`,当`task`为`null`时,抛出`NullPointerException`。如果您想在等待任务完成时立即阻塞,您可以使用形式为`result = exec.submit(aCallable).get();`的结构。 | | `Future submit(Runnable task)` | 提交一个可运行的`task`来执行,并返回一个代表`task`的未决结果的`Future`实例。`Future`实例的`get()`方法在成功完成时返回`task`的结果。当`task`不能被调度执行时,该方法抛出`RejectedExecutionException`,当`task`为`null`时,抛出`NullPointerException`。 | | ` Future submit(Runnable task, T result)` | 提交一个可运行的`task`来执行,并返回一个`Future`实例,其`get()`方法在成功完成时返回`result`的值。该方法在`task`不能被调度执行时抛出`RejectedExecutionException`,在`task`为`null`时抛出`NullPointerException`。 |

表 5-1 是指java.util.concurrent.TimeUnit,一个以给定粒度单位表示持续时间的枚举:DAYSHOURSMICROSECONDSMILLISECONDSMINUTESNANOSECONDSSECONDS。此外,TimeUnit声明了跨单元转换的方法(如long toHours(long duration)),以及在这些单元中执行定时和延迟操作的方法(如void sleep(long timeout))。

表 5-1 也指可调用任务。与Runnable不同,它的void run()方法不能返回值和抛出被检查的异常,Callable<V>V call()方法返回值并能抛出被检查的异常,因为它是用throws Exception子句声明的。

最后,表 5-1 引用了Future接口,它代表了异步计算的结果。结果被称为未来,因为它通常要到未来的某个时刻才可用。Future,其泛型类型为Future<V>,提供了取消任务、返回任务值以及确定任务是否完成的方法。表 5-2 描述了Future的方法。

表 5-2。

Future’s Methods

| 方法 | 描述 | | --- | --- | | `boolean cancel(boolean mayInterruptIfRunning)` | 尝试取消此任务的执行,并在任务取消时返回`true`;否则,返回`false`(任务可能在`cancel()`被调用之前已经正常完成)。当任务已完成、已取消或由于其他原因无法取消时,取消会失败。如果成功,并且该任务尚未开始,则该任务不应运行。如果任务已经开始,`mayInterruptIfRunning`确定是否(`true`)应该中断运行该任务的线程以试图停止该任务。返回后,后续对`isDone()`的调用总是返回`true`;`isCancelled()`当`cancel()`返回`true`时,总是返回`true`。 | | `V get()` | 如果需要,等待任务完成,然后返回结果。当任务在这个方法被调用之前被取消时,这个方法抛出`java.util.concurrent.CancellationException`,当任务抛出异常时抛出`ExecutionException`,当当前线程在等待时被中断时抛出`InterruptedException`。 | | `V get(long timeout, TimeUnit unit)` | 等待最多`timeout`个单位(由`unit`指定)来完成任务,然后返回结果(如果可用)。当任务在该方法被调用之前被取消时,该方法抛出`CancellationException`,当任务抛出异常时抛出`ExecutionException`,当当前线程在等待时被中断时抛出`InterruptedException`,当该方法的`timeout`值到期(等待超时)时抛出`TimeoutException`。 | | `boolean isCancelled()` | 当该任务在正常完成前被取消时,返回`true`;否则,返回`false`。 | | `boolean isDone()` | 该任务完成后返回`true`;否则,返回`false`。完成可能是由于正常终止、异常或取消——在所有这些情况下,该方法都返回`true`。 |

假设您打算编写一个应用,它的图形用户界面允许用户输入单词。用户输入单词后,应用将这个单词呈现给几个在线词典,并获得每个词典的条目。这些条目随后显示给用户。

因为在线访问可能很慢,而且用户界面应该保持响应(也许用户想要结束应用),所以您将“获取单词条目”任务卸载到一个在单独线程上运行该任务的执行器。以下示例使用ExecutorServiceCallableFuture来实现这一目标:

ExecutorService executor = ...; // ... represents some executor creation

Future<String[]> taskFuture =

executor.submit(new Callable<String[]>()

{

@Override

public String[] call()

{

String[] entries = ...;

// Access online dictionaries

// with search word and populate

// entries with their resulting

// entries.

return entries;

}

});

// Do stuff.

String entries = taskFuture.get();

在以某种方式获得一个执行程序后(您将很快了解如何获得),该示例的线程向执行程序提交一个可调用的任务。submit()方法立即返回一个对用于控制任务执行和访问结果的Future对象的引用。线程最终调用这个对象的get()方法来获得这些结果。

Note

java.util.concurrent.ScheduledExecutorService接口扩展了ExecutorService,并描述了一个执行器,让您调度任务运行一次或在给定延迟后定期执行。

虽然您可以创建自己的ExecutorExecutorServiceScheduledExecutorService实现(比如class DirectExecutor implements Executor { @Override public void execute(Runnable r) { r.run(); } }—直接在调用线程上运行 executor),但是还有一个更简单的选择:java.util.concurrent.Executors

Tip

如果您打算创建自己的ExecutorService实现,您会发现使用java.util.concurrent.AbstractExecutorServicejava.util.concurrent.FutureTask类会很有帮助。

Executors工具类声明了几个类方法,这些方法返回各种ExecutorServiceScheduledExecutorService实现的实例(以及其他类型的实例)。这个类的static方法完成以下任务:

  • 创建并返回一个用常用配置设置配置的ExecutorService实例。
  • 创建并返回一个用常用配置设置配置的ScheduledExecutorService实例。
  • 创建并返回一个“包装的”ExecutorServiceScheduledExecutorService实例,通过使特定于实现的方法不可访问来禁用执行器服务的重新配置。
  • 创建并返回一个java.util.concurrent.ThreadFactory实例(即实现了ThreadFactory接口的类的实例),用于创建新的Thread对象。
  • 从其他类似闭包的形式中创建并返回一个Callable实例,这样它就可以用在需要Callable参数的执行方法中(比如ExecutorServicesubmit(Callable)方法)。维基百科 http://en.wikipedia.org/wiki/Closure_(computer_science) 的“闭包(计算机科学)”条目介绍了闭包的主题。

例如,static ExecutorService newFixedThreadPool(int nThreads)创建一个线程池,它重用固定数量的线程,这些线程在一个共享的无界队列中运行。最多nThreads线程主动处理任务。如果在所有线程都处于活动状态时提交了额外的任务,它们将在队列中等待一个可用的线程。

如果在执行器关闭之前,任何线程由于执行过程中的故障而终止,那么在需要执行后续任务时,一个新的线程将取代它的位置。在明确关闭执行器之前,线程池中的线程将一直存在。当您将零或负值传递给nThreads时,该方法抛出IllegalArgumentException

Note

线程池用于消除为每个提交的任务创建新线程的开销。线程创建并不便宜,而且必须创建许多线程会严重影响应用的性能。

您通常会在文件和网络输入/输出上下文中使用执行器、可运行对象、可调用程序和未来程序。执行冗长的计算提供了使用这些类型的另一个场景。例如,清单 5-1 在欧拉数 e (2.71828)的计算上下文中使用了一个执行器、一个可调用函数和一个未来值...).

Listing 5-1. Calculating Euler’s Number e

import java.math.BigDecimal;

import java.math.MathContext;

import java.math.RoundingMode;

import java.util.concurrent.Callable;

import java.util.concurrent.ExecutionException;

import java.util.concurrent.ExecutorService;

import java.util.concurrent.Executors;

import java.util.concurrent.Future;

public class CalculateE

{

final static int LASTITER = 17;

public static void main(String[] args)

{

ExecutorService executor = Executors.newFixedThreadPool(1);

Callable<BigDecimal> callable;

callable = new Callable<BigDecimal>()

{

@Override

public BigDecimal call()

{

MathContext mc =

new MathContext(100, RoundingMode.HALF_UP);

BigDecimal result = BigDecimal.ZERO;

for (int i = 0; i <= LASTITER; i++)

{

BigDecimal factorial =

factorial(new BigDecimal(i));

BigDecimal res = BigDecimal.ONE.divide(factorial,

mc);

result = result.add(res);

}

return result;

}

public BigDecimal factorial(BigDecimal n)

{

if (n.equals(BigDecimal.ZERO))

return BigDecimal.ONE;

else

return n.multiply(factorial(n.

subtract(BigDecimal.ONE)));

}

};

Future<BigDecimal> taskFuture = executor.submit(callable);

try

{

while (!taskFuture.isDone())

System.out.println("waiting");

System.out.println(taskFuture.get());

}

catch(ExecutionException ee)

{

System.err.println("task threw an exception");

System.err.println(ee);

}

catch(InterruptedException ie)

{

System.err.println("interrupted while waiting");

}

executor.shutdownNow();

}

}

执行main()的默认主线程首先通过调用Executors ' newFixedThreadPool()方法获得一个执行器。然后,它实例化一个实现了Callable接口的匿名类,并将这个任务提交给执行器,接收一个Future实例作为响应。

提交任务后,线程通常会做一些其他工作,直到它需要任务的结果。我通过让主线程重复输出等待消息来模拟这项工作,直到Future实例的isDone()方法返回true。(在实际应用中,我会避免这种循环。)此时,主线程调用实例的get()方法获得结果,然后输出结果。然后主线程关闭执行器。

Caution

在执行程序完成后关闭它是很重要的;否则,应用可能不会结束。前一个执行者通过调用shutdownNow()来完成这个任务。(您也可以使用shutdown()方法。)

callable 的call()方法通过计算数学幂级数 e = 1 / 0 来计算 e!+ 1 / 1!+ 1 / 2!+ .。。。这个级数可以用求和 1 / n 来求值!,其中 n 的范围是从 0 到无穷大(并且!代表阶乘)。

call()首先实例化java.math.MathContext封装一个精度(位数)和一个舍入方式。我选择了100作为 e 的精度上限,也选择了HALF_UP作为舍入模式。

Tip

增加精度和LASTITER的值,使级数收敛到更长更精确的 e 的近似值。

call()接下来将名为result的局部变量java.math.BigDecimal初始化为BigDecimal.ZERO。然后它进入一个循环,计算阶乘,用阶乘除BigDecimal.ONE,并将除法结果加到result

divide()方法将MathContext实例作为其第二个参数来提供舍入信息。(如果我将0指定为数学上下文的精度和一个非终止的十进制展开[除法的商结果不能精确地表示为-0.3333333...例如]发生时,java.lang.ArithmeticException将被抛出以警告调用者商不能被精确表示的事实。遗嘱执行人会以ExecutionException的名义重新抛出这个例外。)

编译清单 5-1 如下:

javac CalculateE.java

运行生成的应用,如下所示:

java CalculateE

您应该观察到类似如下的输出(您可能会观察到更多的waiting消息):

waiting

waiting

waiting

waiting

waiting

2.718281828459045070516047795848605061178979635251032698900735004065225042504843314055887974344245741730039454062711

Exercises

以下练习旨在测试您对第五章内容的理解:

What are the concurrency utilities?   Identify the packages in which the concurrency utilities types are stored.   Define task.   Define executor.   Identify the Executor interface’s limitations.   How are Executor’s limitations overcome?   What differences exist between Runnable’s run( ) method and Callable’s call( ) method?   True or false: You can throw checked and unchecked exceptions from Runnable’s run( ) method but can only throw unchecked exceptions from Callable’s call( ) method.   Define future.   Describe the Executors class’s newFixedThreadPool( ) method.   Refactor the following CountingThreads application to work with Executors and ExecutorService: public class CountingThreads {    public static void main(String[] args)    {       Runnable r = new Runnable()                    {                       @Override                       public void run()                       {                          String name = Thread.currentThread().getName();                          int count = 0;                          while (true)                             System.out.println(name + ": " + count++);                       }                    };       Thread thdA = new Thread(r);       Thread thdB = new Thread(r);       thdA.start();       thdB.start();    } }   When you execute the previous exercise’s CountingThreads application, you’ll observe output that identifies the threads via names such as pool-1-thread-1. Modify CountingThreads so that you observe names A and B. Hint: You’ll need to use ThreadFactory.

摘要

Java 的低级线程功能使您可以创建多线程应用,这些应用比单线程应用提供更好的性能和响应能力。然而,影响应用可伸缩性的性能问题和其他问题导致 Java 5 引入了并发工具。

并发工具将各种类型组织成三个包:java.util.concurrentjava.util.concurrent.atomicjava.util.concurrent.locks。执行器、线程池、并发哈希表和其他高级并发结构的基本类型存储在java.util.concurrent中;支持单变量无锁、线程安全编程的类存储在java.util.concurrent.atomic;锁定和等待条件的类型存储在java.util.concurrent.locks中。

执行器将任务提交从任务执行机制中分离出来,由ExecutorExecutorServiceScheduledExecutorService接口描述。您可以通过调用Executors类中的一个实用方法来获得一个执行器。遗嘱执行人与可赎回和期货相关联。

第六章介绍同步器。

六、同步器

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-1700-9_​6) contains supplementary material, which is available to authorized users.

Java 提供了synchronized关键字来同步线程对临界区的访问。因为很难正确编写基于synchronized的同步代码,所以在并发工具中包含了高级同步器(促进常见形式的同步的类)。在这一章中,我将向您介绍倒计时锁、循环屏障、交换器、信号量和相位同步器。

倒计时闩锁

倒计时锁存导致一个或多个线程在“门”处等待,直到另一个线程打开这个门,此时这些其他线程可以继续。它由一个计数和“使线程等待,直到计数达到零”以及“递减计数”的操作组成

java.util.concurrent.CountDownLatch类实现了倒计时锁存同步器。通过调用这个类的CountDownLatch(int count)构造函数,将一个CountDownLatch实例初始化为一个特定的计数,当传递给count的值为负时,这个构造函数抛出java.lang.IllegalArgumentException

CountDownLatch还提供了以下方法:

  • void await():强制调用线程等待,直到锁存器已经倒计数到零,除非线程被中断,在这种情况下抛出java.lang.InterruptedException。当计数为零时,此方法立即返回。
  • boolean await(long timeout, TimeUnit unit):强制调用线程等待,直到锁存器已经倒计数到零,或者unit时间单位中指定的timeout值已经到期,或者线程被中断,在这种情况下InterruptedException被抛出。当计数为零时,此方法立即返回。当计数达到零时,它返回true,或者当等待时间过去时,它返回false
  • void countDown():递减计数,当计数达到零时,释放所有等待线程。调用此方法时,如果计数已经为零,则不会发生任何事情。
  • long getCount():返回当前计数。此方法对于测试和调试非常有用。
  • String toString():返回一个标识该闩锁及其状态的字符串。括号中的状态包括字符串文字"Count =",后跟当前计数。

您通常会使用一个倒计时闩锁来确保线程几乎同时开始工作。例如,查看清单 6-1 。

Listing 6-1. Using a Countdown Latch to Trigger a Coordinated Start

import java.util.concurrent.CountDownLatch;

import java.util.concurrent.ExecutorService;

import java.util.concurrent.Executors;

public class CountDownLatchDemo

{

final static int NTHREADS = 3;

public static void main(String[] args)

{

final CountDownLatch startSignal = new CountDownLatch(1);

final CountDownLatch doneSignal = new CountDownLatch(NTHREADS);

Runnable r = new Runnable()

{

@Override

public void run()

{

try

{

report("entered run()");

startSignal.await();  // wait until told to ...

report("doing work"); // ... proceed

Thread.sleep((int) (Math.random() * 1000));

doneSignal.countDown(); // reduce count on which

// main thread is ...

}                          // waiting

catch (InterruptedException ie)

{

System.err.println(ie);

}

}

void report(String s)

{

System.out.println(System.currentTimeMillis() +

": " + Thread.currentThread() +

": " + s);

}

};

ExecutorService executor = Executors.newFixedThreadPool(NTHREADS);

for (int i = 0; i < NTHREADS; i++)

executor.execute(r);

try

{

System.out.println("main thread doing something");

Thread.sleep(1000); // sleep for 1 second

startSignal.countDown(); // let all threads proceed

System.out.println("main thread doing something else");

doneSignal.await(); // wait for all threads to finish

executor.shutdownNow();

}

catch (InterruptedException ie)

{

System.err.println(ie);

}

}

}

清单 6-1 的默认主线程首先创建一对倒计时锁存器。startSignal倒计时闩锁阻止任何工作线程继续运行,直到默认主线程准备好让它们继续运行。doneSignal倒计时闩锁导致默认主线程等待,直到所有工作线程完成。

默认主线程接下来用一个run()方法创建一个 runnable,该方法由随后创建的工作线程执行。

run()首先输出一条消息,然后调用startSignalawait()方法,在继续之前等待这个倒计时锁存器的计数读取零,此时run()输出一条消息,指示工作正在进行,并休眠一段随机的时间(0 到 999 毫秒)来模拟这项工作。

此时,run()调用doneSignalcountDown()方法来减少这个锁存器的计数。一旦该计数达到零,等待该信号的默认主线程将继续,关闭执行器并终止应用。

在创建 runnable 之后,默认主线程获得一个基于NTHREADS线程的线程池的执行程序,然后调用执行程序的execute()方法NTHREADS次,将 runnable 传递给每个基于NTHREADS池的线程。这个动作启动进入run()的工作线程。

接下来,默认主线程输出一条消息并休眠一秒钟,以模拟做额外的工作(给所有工作线程一个进入run()并调用startSignal.await()的机会),调用startSignalcountDown()方法以使工作线程开始运行,输出一条消息以指示它正在做其他事情,并调用doneSignalawait()方法以等待这个倒计时闩锁的计数达到零,然后才能继续。

编译清单 6-1 如下:

javac CountDownLatchDemo.java

运行生成的应用,如下所示:

java CountDownLatchDemo

您应该观察到类似于以下内容的输出(消息顺序可能有所不同):

main thread doing something

1445802274931: Thread[pool-1-thread-2,5,main]: entered run()

1445802274931: Thread[pool-1-thread-3,5,main]: entered run()

1445802274931: Thread[pool-1-thread-1,5,main]: entered run()

main thread doing something else

1445802275931: Thread[pool-1-thread-2,5,main]: doing work

1445802275931: Thread[pool-1-thread-3,5,main]: doing work

1445802275933: Thread[pool-1-thread-1,5,main]: doing work

环状屏障

循环障碍让一组线程相互等待到达一个公共障碍点。屏障是循环的,因为它可以在等待线程被释放后被重用。这种同步器在应用中非常有用,这些应用包含一组固定大小的线程,它们偶尔会相互等待。

java.util.concurrent.CyclicBarrier类实现了循环屏障同步器。通过调用这个类的CyclicBarrier(int parties)构造函数,将一个CyclicBarrier实例初始化为特定数量的参与方(为一个共同目标工作的线程)。当传递给parties的值小于 1 时,这个构造函数抛出IllegalArgumentException

或者,您可以调用CyclicBarrier(int parties, Runnable barrierAction)构造函数将循环屏障初始化为特定数量的parties和一个barrierAction,当屏障被触发时执行。换句话说,当parties - 1线程正在等待并且又有一个线程到达时,到达的线程执行barrierAction,然后所有线程继续。这个 runnable 对于在任何线程继续之前更新共享状态非常有用。当传递给parties的值小于 1 时,这个构造函数抛出IllegalArgumentException。(前一个构造函数调用这个构造函数,将null传递给barrierAction——当障碍被触发时,将不执行任何 runnable。)

CyclicBarrier还提供了以下方法:

  • int await():强制调用线程等待,直到各方都调用了此循环屏障上的await()。当它或另一个等待线程被中断,另一个线程等待超时,或者另一个线程在这个循环屏障上调用reset()时,调用线程也会停止等待。如果调用线程在入口设置了中断状态,或者在等待时被中断,该方法抛出InterruptedException,调用线程的中断状态被清除。当任何线程正在等待时屏障被重置(通过reset())时,该方法抛出java.util.concurrent.BrokenBarrierException,或者当await()被调用或任何线程正在等待时屏障被破坏。当任何一个线程在等待时被中断,所有其他等待的线程抛出BrokenBarrierException,屏障被置于中断状态。如果调用线程是最后到达的线程,并且在构造函数中提供了 non-??,那么调用线程在允许其他线程继续之前执行这个 runnable。这个方法返回调用线程的到达索引,其中索引getParties() - 1表示第一个到达的线程,零表示最后一个到达的线程。
  • int await(long timeout, TimeUnit unit):这个方法等同于前面的方法,除了它让你指定调用线程愿意等待多长时间。当线程等待超时时,这个方法抛出java.util.concurrent.TimeoutException
  • int getNumberWaiting():返回当前在关卡等待的队伍数量。此方法对于调试和与断言的合作非常有用。
  • int getParties():返回需要穿越障碍的队伍数量。
  • boolean isBroken():当一方或多方由于循环屏障构建或最后一次重置后的中断或超时而突破此屏障时,或当屏障动作因异常而失败时,返回true;否则,返回false
  • void reset():将栅栏复位到初始状态。如果有任何一方正在关卡处等待,他们将返回一个BrokenBarrierException。请注意,由于其他原因发生破损后,重置操作可能会很复杂;线程需要以其他方式重新同步,并选择一个线程来执行重置。因此,最好为后续使用创建一个新的屏障。

循环障碍在并行分解场景中很有用,在并行分解场景中,一个冗长的任务被分成多个子任务,这些子任务的单个结果随后被合并到任务的整体结果中。CyclicBarrier的 Javadoc 展示了清单 6-2 中完成的示例代码。

Listing 6-2. Using a Cyclic Barrier to Decompose a Task into Subtasks

import java.util.concurrent.BrokenBarrierException;

import java.util.concurrent.CyclicBarrier;

public class CyclicBarrierDemo

{

public static void main(String[] args)

{

float[][] matrix = new float[3][3];

int counter = 0;

for (int row = 0; row < matrix.length; row++)

for (int col = 0; col < matrix[0].length; col++)

matrix[row][col] = counter++;

dump(matrix);

System.out.println();

Solver solver = new Solver(matrix);

System.out.println();

dump(matrix);

}

static void dump(float[][] matrix)

{

for (int row = 0; row < matrix.length; row++)

{

for (int col = 0; col < matrix[0].length; col++)

System.out.print(matrix[row][col] + " ");

System.out.println();

}

}

}

class Solver

{

final int N;

final float[][] data;

final CyclicBarrier barrier;

class Worker implements Runnable

{

int myRow;

boolean done = false;

Worker(int row)

{

myRow = row;

}

boolean done()

{

return done;

}

void processRow(int myRow)

{

System.out.println("Processing row: " + myRow);

for (int i = 0; i < N; i++)

data[myRow][i] *= 10;

done = true;

}

@Override

public void run()

{

while (!done())

{

processRow(myRow);

try

{

barrier.await();

}

catch (InterruptedException ie)

{

return;

}

catch (BrokenBarrierException bbe)

{

return;

}

}

}

}

public Solver(float[][] matrix)

{

data = matrix;

N = matrix.length;

barrier = new CyclicBarrier(N,

new Runnable()

{

@Override

public void run()

{

mergeRows();

}

});

for (int i = 0; i < N; ++i)

new Thread(new Worker(i)).start();

waitUntilDone();

}

void mergeRows()

{

System.out.println("merging");

synchronized("abc")

{

"abc".notify();

}

}

void waitUntilDone()

{

synchronized("abc")

{

try

{

System.out.println("main thread waiting");

"abc".wait();

System.out.println("main thread notified");

}

catch (InterruptedException ie)

{

System.out.println("main thread interrupted");

}

}

}

}

清单 6-2 的默认主线程首先创建一个浮点值的方阵,并将这个矩阵转储到标准输出流。这个线程然后实例化Solver类,该类创建一个单独的线程来对每一行执行计算。然后,修改后的矩阵被转储。

Solver表示一个构造函数,该构造函数接收它的matrix参数,并将它的引用保存在字段data中,并将行数保存在字段N中。然后,构造函数创建一个包含N方的循环屏障和一个负责将所有行合并成最终矩阵的屏障动作。最后,构造函数创建一个工作线程,执行一个单独的Worker runnable,负责处理矩阵中的一行。构造函数然后等待,直到工作线程完成。

Worker 的run()方法在其特定的行上重复调用processRow(),直到done()返回true,在processRow()执行一次后,它就会返回true(在本例中)。在processRow()返回后,表明该行已经被处理,工作线程在循环屏障上调用await();它不能继续。

在某个时刻,所有的工作线程都会调用await()。当处理矩阵中最后一行的最后一个线程调用await()时,它将触发 barrier 动作,将所有处理过的行合并到一个最终矩阵中。在这个例子中,不需要合并,但是在更复杂的例子中需要合并。

mergeRows()执行的最后一个任务是通知调用Solver的构造函数的主线程。该线程正在等待与String对象"abc"相关联的监视器。对notify()的调用足以唤醒正在等待的线程,它是这个监视器上唯一正在等待的线程。

编译清单 6-2 如下:

javac CyclicBarrierDemo.java

运行生成的应用,如下所示:

java CyclicBarrierDemo

您应该观察到类似于以下内容的输出(消息顺序可能有所不同):

0.0 1.0 2.0

3.0 4.0 5.0

6.0 7.0 8.0

main thread waiting

Processing row: 0

Processing row: 1

Processing row: 2

merging

main thread notified

0.0 10.0 20.0

30.0 40.0 50.0

60.0 70.0 80.0

交换器

交换器提供了一个同步点,线程可以在这里交换对象。每个线程在交换器的exchange()方法的入口提供一些对象,与一个伙伴线程匹配,并在返回时接收其伙伴的对象。交换器在遗传算法(见 http://en.wikipedia.org/wiki/Genetic_algorithm )和管道设计等应用中非常有用。

通用的java.util.concurrent.Exchanger<V>类实现了交换器同步器。您可以通过调用Exchanger()构造函数来初始化一个交换器。然后,您可以调用以下任一方法来执行交换:

  • V exchange(V x):等待另一个线程到达这个交换点(除非调用线程被中断),然后将给定的对象传递给它,作为回报接收另一个线程的对象。如果另一个线程已经在交换点等待,它将继续执行线程调度,并接收调用线程传入的对象。当前线程立即返回,接收另一个线程传递给交换器的对象。这个方法在调用线程中断时抛出InterruptedException
  • V exchange(V x, long timeout, TimeUnit unit):这个方法等同于前面的方法,除了它让你指定调用线程愿意等待多长时间。当线程等待超时时,它抛出TimeoutException

清单 6-3 扩展了Exchanger的 Javadoc 中给出的重复填充和清空Exchanger的例子。

Listing 6-3. Using an Exchanger to Swap Buffers

import java.util.ArrayList;

import java.util.List;

import java.util.concurrent.Exchanger;

public class ExchangerDemo

{

final static Exchanger<DataBuffer> exchanger =

new Exchanger<DataBuffer>();

final static DataBuffer initialEmptyBuffer = new DataBuffer();

final static DataBuffer initialFullBuffer = new DataBuffer("I");

public static void main(String[] args)

{

class FillingLoop implements Runnable

{

int count = 0;

@Override

public void run()

{

DataBuffer currentBuffer = initialEmptyBuffer;

try

{

while (true)

{

addToBuffer(currentBuffer);

if (currentBuffer.isFull())

{

System.out.println("filling thread wants to exchange");

currentBuffer = exchanger.exchange(currentBuffer);

System.out.println("filling thread receives exchange");

}

}

}

catch (InterruptedException ie)

{

System.out.println("filling thread interrupted");

}

}

void addToBuffer(DataBuffer buffer)

{

String item = "NI" + count++;

System.out.println("Adding: " + item);

buffer.add(item);

}

}

class EmptyingLoop implements Runnable

{

@Override

public void run()

{

DataBuffer currentBuffer = initialFullBuffer;

try

{

while (true)

{

takeFromBuffer(currentBuffer);

if (currentBuffer.isEmpty())

{

System.out.println("emptying thread wants to " +

"exchange");

currentBuffer = exchanger.exchange(currentBuffer);

System.out.println("emptying thread receives " +

"exchange");

}

}

}

catch (InterruptedException ie)

{

System.out.println("emptying thread interrupted");

}

}

void takeFromBuffer(DataBuffer buffer)

{

System.out.println("taking: " + buffer.remove());

}

}

new Thread(new EmptyingLoop()).start();

new Thread(new FillingLoop()).start();

}

}

class DataBuffer

{

private final static int MAXITEMS = 10;

private final List<String> items = new ArrayList<>();

DataBuffer()

{

}

DataBuffer(String prefix)

{

for (int i = 0; i < MAXITEMS; i++)

{

String item = prefix + i;

System.out.printf("Adding %s%n", item);

items.add(item);

}

}

synchronized void add(String s)

{

if (!isFull())

items.add(s);

}

synchronized boolean isEmpty()

{

return items.size() == 0;

}

synchronized boolean isFull()

{

return items.size() == MAXITEMS;

}

synchronized String remove()

{

if (!isEmpty())

return items.remove(0);

return null;

}

}

清单 6-3 的默认主线程通过静态字段初始化器创建一个交换器和一对缓冲区。然后实例化EmptyingLoopFillingLoop局部类,并将这些可运行的类传递给新的Thread实例,然后启动这些实例的线程。(我本来可以用遗嘱执行人。)每个 runnable 的run()方法进入一个无限循环,重复地增加或删除它的缓冲区。当缓冲液满或空时,交换器用于交换这些缓冲液,继续填充或清空。

编译清单 6-3 如下:

javac ExchangerDemo.java

运行生成的应用,如下所示:

java ExchangerDemo

您应该观察到类似如下的输出前缀(消息顺序可能有所不同):

Adding I0

Adding I1

Adding I2

Adding I3

Adding I4

Adding I5

Adding I6

Adding I7

Adding I8

Adding I9

taking: I0

taking: I1

taking: I2

taking: I3

taking: I4

taking: I5

taking: I6

taking: I7

taking: I8

taking: I9

emptying thread wants to exchange

Adding: NI0

Adding: NI1

Adding: NI2

Adding: NI3

Adding: NI4

Adding: NI5

Adding: NI6

Adding: NI7

Adding: NI8

Adding: NI9

filling thread wants to exchange

filling thread receives exchange

emptying thread receives exchange

Adding: NI10

taking: NI0

Adding: NI11

taking: NI1

Adding: NI12

旗语

信号量维护一组许可证,用于限制可以访问有限资源的线程数量。当没有许可可用时,试图获取许可的线程将被阻塞,直到某个其他线程释放许可。

Note

当前值可以递增到 1 以上的信号量称为计数信号量,而当前值只能为 0 或 1 的信号量称为二进制信号量或互斥信号量。无论哪种情况,当前值都不能为负。

java.util.concurrent.Semaphore类实现了这个同步器,并将信号量概念化为维护一组许可的对象。通过调用Semaphore(int permits)构造函数来初始化信号量,其中permits指定了可用许可的数量。结果信号量的公平策略被设置为false(不公平)。或者,您可以调用Semaphore(int permits, boolean fair)构造函数来将信号量的公平性设置为true (fair)。

Semaphores and Fairness

当公平性设置为false时,Semaphore不保证线程获取许可的顺序。特别是,驳船是允许的;也就是说,调用acquire()的线程可以在已经等待的线程之前被分配一个许可——逻辑上,新线程将自己放在等待线程队列的最前面。当fair被设置为true时,信号量保证调用任何acquire()方法的线程被选择来按照它们对这些方法的调用被处理的顺序获得许可(先进先出;FIFO)。因为 FIFO 排序必须应用于这些方法中特定的内部执行点,所以一个线程可能在另一个线程之前调用acquire(),但在另一个线程之后到达排序点,从方法返回时也是如此。此外,不计时的tryAcquire()方法不尊重公平设置;他们会拿走所有可用的许可证。

一般来说,用于控制资源访问的信号量应该初始化为 fair,以确保没有线程会因饥饿而无法访问资源。当使用信号量进行其他类型的同步控制时,不公平排序的吞吐量优势通常超过公平性考虑。

Semaphore还提供了以下方法:

  • 从这个信号量中获取一个许可,阻塞直到一个许可可用或者调用线程被中断。InterruptedException中断时抛出。
  • void acquire(int permits):从这个信号量中获取permits许可,阻塞直到它们可用或者调用线程被中断。InterruptedException被打断时抛出;当permits小于零时IllegalArgumentException被抛出。
  • 获得许可证,直到有一个可用的为止。
  • void acquireUninterruptibly(int permits):获取permits许可,阻塞直至全部可用。当permits小于零时IllegalArgumentException被抛出。
  • int availablePermits():返回当前可用许可证的数量。此方法对于调试和测试非常有用。
  • 获取并返回所有立即可用的许可证的数量。
  • int getQueueLength():返回等待获取许可的线程数量的估计值。返回值只是一个估计值,因为在此方法遍历内部数据结构时,线程的数量可能会动态变化。该方法设计用于监控系统状态,而不是用于同步控制。
  • boolean hasQueuedThreads():查询是否有线程正在等待获取许可。因为取消可能随时发生,所以一个true返回值并不能保证另一个线程会获得许可。这种方法主要用于监控系统状态。当可能有其他等待线程时,它返回true
  • boolean isFair():返回公平性设置(true表示公平,false表示不公平)。
  • void release():释放一个许可,将其返回给信号量。可用许可证的数量增加一个。如果有任何线程试图获取一个许可,则选择一个线程并给予它刚刚释放的许可。出于线程调度的目的,该线程被重新启用。
  • void release(int permits):释放permits许可,将它们返回给信号量。可用许可证的数量因permits而增加。如果有任何线程试图获取许可,就会选择一个线程,并给它刚刚释放的许可。如果可用许可的数量满足该线程的请求,则为了线程调度的目的,该线程被重新启用;否则,线程将等待,直到有足够的许可可用。如果在这个线程的请求被满足后还有可用的许可,那么这些许可被分配给试图获取许可的其他线程。当permits小于零时IllegalArgumentException被抛出。
  • String toString():返回一个标识这个信号量及其状态的字符串。括号中的状态包括字符串文字"Permits =",后跟许可数量。
  • 从这个信号量获取一个许可,但是只有在调用时有一个许可可用的时候。获得许可时返回true。否则,立即返回值false
  • boolean tryAcquire(int permits):从这个信号量中获取permits许可,但是只有当它们在调用时可用时。获得许可后返回true。否则,立即返回值false。当permits小于零时IllegalArgumentException被抛出。
  • boolean tryAcquire(int permits, long timeout, TimeUnit unit):类似于前面的方法,但是当permits许可不可用时,调用线程等待。当许可变得可用、超时过期或调用线程被中断时,等待结束,在这种情况下会抛出InterruptedException
  • boolean tryAcquire(long timeOut, TimeUnit unit):类似于tryAcquire(int permits),但是调用线程等待,直到许可可用。当许可变得可用、超时过期或调用线程被中断时,等待结束,在这种情况下会抛出InterruptedException

清单 6-4 扩展了Semaphore的 Javadoc 中呈现的“控制对项目池的访问”Semaphore示例。

Listing 6-4. Using a Counting Semaphore to Control Access to a Pool of Items

import java.util.concurrent.Executors;

import java.util.concurrent.ExecutorService;

import java.util.concurrent.Semaphore;

public class SemaphoreDemo

{

public static void main(String[] args)

{

final Pool pool = new Pool();

Runnable r = new Runnable()

{

@Override

public void run()

{

String name = Thread.currentThread().getName();

try

{

while (true)

{

String item;

System.out.println(name + " acquiring " +

(item = pool.getItem()));

Thread.sleep(200 +

(int) (Math.random() * 100));

System.out.println(name + " putting back " +

item);

pool.putItem(item);

}

}

catch (InterruptedException ie)

{

System.out.println(name + "interrupted");

}

}

};

ExecutorService[] executors =

new ExecutorService[Pool.MAX_AVAILABLE + 1];

for (int i = 0; i < executors.length; i++)

{

executors[i] = Executors.newSingleThreadExecutor();

executors[i].execute(r);

}

}

}

final class Pool

{

public static final int MAX_AVAILABLE = 10;

private final Semaphore available = new Semaphore(MAX_AVAILABLE, true);

private final String[] items;

private final boolean[] used = new boolean[MAX_AVAILABLE];

Pool()

{

items = new String[MAX_AVAILABLE];

for (int i = 0; i < items.length; i++)

items[i] = "I" + i;

}

String getItem() throws InterruptedException

{

available.acquire();

return getNextAvailableItem();

}

void putItem(String item)

{

if (markAsUnused(item))

available.release();

}

private synchronized String getNextAvailableItem()

{

for (int i = 0; i < MAX_AVAILABLE; ++i)

{

if (!used[i])

{

used[i] = true;

return items[i];

}

}

return null; // not reached

}

private synchronized boolean markAsUnused(String item)

{

for (int i = 0; i < MAX_AVAILABLE; ++i)

{

if (item == items[i])

{

if (used[i])

{

used[i] = false;

return true;

}

else

return false;

}

}

return false;

}

}

清单 6-4 的默认主线程创建了一个资源池,一个用于重复获取和放回资源的 runnable,以及一个执行器数组。每个执行者被告知执行可运行的。

PoolString getItem()void putItem(String item)方法获取并返回基于字符串的资源。在获得getItem()中的一个项目之前,调用线程必须从信号量中获得一个许可,这保证了一个项目可供使用。当线程处理完该项时,它调用putItem(String),后者将该项返回到池中,然后向信号量释放一个许可,让另一个线程获取该项。

当调用acquire()时,不持有同步锁,因为这将阻止一个项目返回到池中。但是,String getNextAvailableItem()boolean markAsUnused(String item)是同步的,以保持池的一致性。(信号量封装了用于限制对池的访问的同步,这与维护池一致性所需的同步是分开的。)

编译清单 6-4 如下:

javac SemaphoreDemo.java

运行生成的应用,如下所示:

java SemaphoreDemo

您应该观察到类似如下的输出前缀(消息顺序可能有所不同):

pool-1-thread-1 acquiring I0

pool-2-thread-1 acquiring I1

pool-3-thread-1 acquiring I2

pool-5-thread-1 acquiring I3

pool-7-thread-1 acquiring I4

pool-4-thread-1 acquiring I5

pool-6-thread-1 acquiring I6

pool-9-thread-1 acquiring I7

pool-8-thread-1 acquiring I8

pool-10-thread-1 acquiring I9

pool-9-thread-1 putting back I7

pool-2-thread-1 putting back I1

pool-11-thread-1 acquiring I7

pool-9-thread-1 acquiring I1

pool-8-thread-1 putting back I8

pool-2-thread-1 acquiring I8

pool-5-thread-1 putting back I3

pool-8-thread-1 acquiring I3

pool-4-thread-1 putting back I5

pool-5-thread-1 acquiring I5

pool-6-thread-1 putting back I6

pool-4-thread-1 acquiring I6

pool-1-thread-1 putting back I0

pool-6-thread-1 acquiring I0

pool-7-thread-1 putting back I4

pool-1-thread-1 acquiring I4

pool-10-thread-1 putting back I9

pool-7-thread-1 acquiring I9

pool-3-thread-1 putting back I2

pool-10-thread-1 acquiring I2

短语

相位器是一种更灵活的循环屏障。像一个循环屏障一样,phaser 让一组线程等待一个屏障;这些线程在最后一个线程到达后继续。相位器也提供了相当于屏障的作用。与协调固定数量线程的循环屏障不同,phaser 可以协调可变数量的线程,这些线程可以在任何时候注册。为了实现这种能力,相位器使用相位和相位号。

相位是相位器的当前状态,该状态由基于整数的相位号标识。当最后一个注册的线程到达相位器屏障时,相位器前进到下一个相位,并将其相位号加 1。

java.util.concurrent.Phaser类实现了相位器。因为这个类在其 Javadoc 中得到了充分的描述,所以我将只指出几个构造函数和方法:

  • Phaser(int threads)构造函数创建一个 phaser,它最初协调nthreads线程(这些线程还没有到达 phaser barrier ),其相位号最初设置为 0。
  • int register()方法向 phaser 添加一个新的未驱动线程,并返回阶段号来对到达进行分类。这个数字被称为到达阶段数。
  • int arriveAndAwaitAdvance()方法记录到达并等待 phaser 前进(这发生在其他线程到达之后)。它返回到达应用的阶段号。
  • int arriveAndDeregister()方法到达这个 phaser 并取消注册,而不等待其他 phaser 到达,从而减少了在未来阶段前进所需的线程数量。

清单 6-5 展示了相位器同步器。它基于Phaser的 Javadoc 中的第一个例子。

Listing 6-5. Using a Phaser to Control a One-Shot Action Serving a Variable Number of Parties

import java.util.ArrayList;

import java.util.List;

import java.util.concurrent.Executors;

import java.util.concurrent.Phaser;

public class PhaserDemo

{

public static void main(String[] args)

{

List<Runnable> tasks = new ArrayList<>();

tasks.add(() -> System.out.printf("%s running at %d%n",

Thread.currentThread().getName(),

System.currentTimeMillis()));

tasks.add(() -> System.out.printf("%s running at %d%n",

Thread.currentThread().getName(),

System.currentTimeMillis()));

runTasks(tasks);

}

static void runTasks(List<Runnable> tasks)

{

final Phaser phaser = new Phaser(1); // "1" (register self)

// create and start threads

for (final Runnable task: tasks)

{

phaser.register();

Runnable r = () ->

{

try

{

Thread.sleep(50 + (int) (Math.random() * 300));

}

catch (InterruptedException ie)

{

System.out.println("interrupted thread");

}

phaser.arriveAndAwaitAdvance(); // await the ...

// creation of ...

// all tasks

task.run();

};

Executors.newSingleThreadExecutor().execute(r);

}

// allow threads to start and deregister self

phaser.arriveAndDeregister();

}

}

清单 6-5 的默认主线程创建了一对可运行的任务,每个任务报告它开始运行的时间(以毫秒为单位)。然后,在创建了一个Phaser实例并等待两个任务到达关卡之后,运行这些任务。

编译清单 6-5 如下:

javac PhaserDemo.java

运行生成的应用,如下所示:

java PhaserDemo

您应该观察到类似于以下内容的输出(应用不应该结束—按 Ctrl+C 或您的等效按键来结束应用):

pool-1-thread-1 running at 1445806012709

pool-2-thread-1 running at 1445806012712

正如您从倒计时锁行为中所预期的那样,两个线程同时开始运行(在本例中),尽管由于Thread.sleep()的存在,一个线程可能已经延迟了 349 毫秒。

注释掉phaser.arriveAndAwaitAdvance(); // await the ...,您现在应该观察到线程在完全不同的时间开始,如下所示:

pool-2-thread-1 running at 1445806212870

pool-1-thread-1 running at 1445806213013

Exercises

以下练习旨在测试您对第六章内容的理解:

Define synchronizer.   Describe the behavior of a countdown latch.   What happens when CountDownLatch’s void countDown() method is called and the count reaches zero?   Describe the behavior of a cyclic barrier.   True or false: CyclicBarrier’s int await() method returns -1 when the barrier is reset while any thread is waiting or when the barrier is broken when await() is invoked.   Describe the behavior of an exchanger.   What does Exchanger’s V exchange(V x) method accomplish?   Describe the behavior of a semaphore.   Identify the two kinds of semaphores.   Describe the behavior of a phaser.   What does Phaser’s int register() method return?   Listing 3-2 (in Chapter 3) presented an enhanced PC application. Recreate this application where the synchronization is handled by the Semaphore class.

摘要

Java 提供了synchronized关键字来同步线程对临界区的访问。因为很难正确编写基于synchronized的同步代码,所以在并发工具中包含了高级同步器。

倒计时锁存导致一个或多个线程在“门”处等待,直到另一个线程打开这个门,此时这些其他线程可以继续。它由一个计数和“使线程等待,直到计数达到零”以及“递减计数”的操作组成

循环障碍让一组线程相互等待到达一个公共障碍点。屏障是循环的,因为它可以在等待线程被释放后被重用。这种同步器在应用中非常有用,这些应用包含一组固定大小的线程,它们偶尔会相互等待。

交换器提供了一个同步点,线程可以在这里交换对象。每个线程在交换器的exchange()方法的入口提供一些对象,与一个伙伴线程匹配,并在返回时接收其伙伴的对象。

信号量维护一组许可证,用于限制可以访问有限资源的线程数量。当没有许可可用时,试图获取许可的线程将被阻塞,直到某个其他线程释放许可。

相位器是一种更灵活的循环屏障。像一个循环屏障一样,phaser 让一组线程等待一个屏障;这些线程在最后一个线程到达后继续。相位器也提供了相当于屏障的作用。与协调固定数量线程的循环屏障不同,phaser 可以协调可变数量的线程,这些线程可以在任何时候注册。为了实现这种能力,相位器使用相位和相位号。

第七章介绍了锁定框架。

七、锁定框架

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-1700-9_​7) contains supplementary material, which is available to authorized users.

java.util.concurrent.locks包提供了一个接口和类的框架,用于锁定和等待条件,其方式不同于对象固有的基于锁的同步和java.lang.Object的等待/通知机制。并发工具包括锁定框架,该框架通过提供锁轮询、定时等待等改进了内在同步和等待/通知。

Synchronized and Low-Level Locking

Java 支持同步,因此线程可以安全地更新共享变量,并确保一个线程的更新对其他线程可见。通过用synchronized关键字标记方法或代码块,可以在代码中利用同步。这些代码序列被称为临界区。Java 虚拟机(JVM)通过监视器和monitorentermonitorexit JVM 指令支持同步。

每个 Java 对象都与一个监视器相关联,这是一个互斥(一次只让一个线程在一个临界区中执行)结构,它可以防止多个线程同时在一个临界区中执行。在线程进入临界区之前,需要锁定监视器。如果监视器已经被锁定,线程将一直阻塞,直到监视器被解锁(通过另一个线程离开临界区)。

当线程在多核/多处理器环境中锁定监视器时,存储在主存储器中的共享变量的值被读入存储在线程的工作存储器(也称为本地存储器或高速缓冲存储器)中的这些变量的副本中。此操作确保线程将使用这些变量的最新值,而不是过时的值,这就是所谓的可见性。线程继续处理这些共享变量的副本。当线程在离开临界区时解锁监视器,其共享变量副本中的值被写回主存,这使得下一个进入临界区的线程可以访问这些变量的最新值。(volatile关键字只处理可见性。)

锁定框架包括常用的LockReentrantLockConditionReadWriteLockReentrantReadWriteLock类型,我将在本章中探讨这些类型。我也简单介绍一下StampedLock类,是 Java 8 引入的。

Lock接口提供了比监视器相关锁更广泛的锁定操作。例如,当锁不可用时,您可以立即退出获取锁的尝试。该接口声明了以下方法:

  • void lock():获取锁。当锁不可用时,调用线程被迫等待,直到锁可用。
  • void lockInterruptibly():除非调用线程中断,否则获取锁。当锁不可用时,调用线程被迫等待,直到锁可用或线程被中断,这导致该方法抛出java.lang.InterruptedException
  • Condition newCondition():返回一个绑定到这个Lock实例的新的Condition实例。当Lock实现类不支持条件时,该方法抛出java.lang.UnsupportedOperationException
  • boolean tryLock():在调用该方法时,获取可用的锁。当获得锁时,该方法返回true,当没有获得锁时,该方法返回false
  • boolean tryLock(long time, TimeUnit unit):当锁在指定的等待time内可用时,获取锁,以unit java.util.concurrent.TimeUnit为单位(秒、毫秒等),并且调用线程没有被中断。当锁不可用时,调用线程被迫等待,直到它在等待时间内变得可用,或者线程被中断,这导致该方法抛出InterruptedException。获得锁时,true返回;否则,false返回。
  • void unlock():解除锁定。

获得的锁必须被释放。在同步方法和块以及与每个对象关联的隐式监视器锁的上下文中,所有锁的获取和释放都以块结构的方式发生。当获得多个锁时,它们以相反的顺序被释放,并且所有的锁都在它们被获得的同一个词法范围内被释放。

锁的获取和释放在Lock接口实现的上下文中可以更加灵活。例如,一些用于遍历并发访问的数据结构的算法需要使用“移交”或“链式锁定”:您获取节点 A 的锁,然后获取节点 B 的锁,然后释放 A 并获取 C,然后释放 B 并获取 D,以此类推。通过允许在不同的范围内获取和释放锁,以及允许以任何顺序获取和释放多个锁,Lock接口的实现支持这种技术的使用。

随着灵活性的增加,责任也随之增加。块结构锁定的缺失消除了同步方法和块中发生的锁的自动释放。因此,对于锁的获取和释放,您通常应该采用以下习惯用法:

Lock l = ...; // ... is a placeholder for code that obtains the lock

l.lock();

try

{

// access the resource protected by this lock

}

catch (Exception ex)

{

// restore invariants

}

finally

{

l.unlock();

}

这个习惯用法确保获得的锁总是被释放。

Note

所有的Lock实现都需要执行与内置监控锁提供的相同的内存同步语义。

可重入锁

Lock是由ReentrantLock类实现的,它描述了一个可重入的互斥锁。此锁与保持计数相关联。当线程持有锁并通过调用lock()lockUninterruptibly()tryLock()方法之一重新获取锁时,持有计数增加 1。当线程调用unlock()时,保持计数减 1。当该计数达到 0 时,锁被释放。

ReentrantLock提供了与隐式监控锁相同的并发性和内存语义,通过同步的方法和块来访问。但是,它具有扩展的功能,并在高线程争用情况下(线程频繁请求获取另一个线程已经持有的锁)提供更好的性能。当许多线程试图访问一个共享资源时,JVM 花更少的时间调度这些线程,花更多的时间执行它们。

通过调用以下任一构造函数来初始化ReentrantLock实例:

  • ReentrantLock():创建一个ReentrantLock的实例。这个构造函数相当于ReentrantLock(false)
  • ReentrantLock(boolean fair):用指定的公平策略创建一个ReentrantLock的实例。当这个锁应该使用公平排序策略时,将true传递给fair:在争用的情况下,锁会优先授予等待时间最长的线程访问权。

ReentrantLock实现Lock的方法。然而,当调用线程没有持有锁时,unlock()的实现抛出java.lang.IllegalMonitorStateException。同样,ReentrantLock提供了自己的方法。例如,当锁被当前线程持有时,boolean isFair()返回公平策略,boolean isHeldByCurrentThread()返回true。清单 7-1 演示了Reentrant Lock

Listing 7-1. Achieving Synchronization in Terms of Reentrant Locks

import java.util.concurrent.Executors;

import java.util.concurrent.ExecutorService;

import java.util.concurrent.TimeUnit;

import java.util.concurrent.locks.ReentrantLock;

public class RLDemo

{

public static void main(String[] args)

{

ExecutorService executor = Executors.newFixedThreadPool(2);

final ReentrantLock lock = new ReentrantLock();

class Worker implements Runnable

{

private final String name;

Worker(String name)

{

this.name = name;

}

@Override

public void run()

{

lock.lock();

try

{

if (lock.isHeldByCurrentThread())

System.out.printf("Thread %s entered critical section.%n",name);

System.out.printf("Thread %s performing work.%n", name);

try

{

Thread.sleep(2000);

}

catch (InterruptedException ie)

{

ie.printStackTrace();

}

System.out.printf("Thread %s finished working.%n", name);

}

finally

{

lock.unlock();

}

}

}

executor.execute(new Worker("ThdA"));

executor.execute(new Worker("ThdB"));

try

{

executor.awaitTermination(5, TimeUnit.SECONDS);

}

catch (InterruptedException ie)

{

ie.printStackTrace();

}

executor.shutdownNow();

}

}

清单 7-1 描述了一个应用,它的默认主线程创建了一对工作线程,它们进入、模拟工作和离开临界区。他们使用ReentrantLocklock()unlock()方法来获得和释放可重入锁。当线程调用lock()并且锁不可用时,线程被禁用(并且不能被调度),直到锁变得可用。

编译清单 7-1 如下:

javac RLDemo.java

运行生成的应用,如下所示:

java RLDemo

您应该会发现类似于以下内容的输出(消息顺序可能会有所不同):

Thread ThdA entered critical section.

Thread ThdA performing work.

Thread ThdA finished working.

Thread ThdB entered critical section.

Thread ThdB performing work.

Thread ThdB finished working.

情况

Condition接口将Object的等待和通知方法(wait()notify()notifyAll())分解成不同的条件对象,通过将它们与任意的Lock实现结合起来,给出每个对象有多个等待集的效果。其中Lock替换synchronized方法和块,Condition替换Object的等待/通知方法。

Note

一个Condition实例本质上绑定到一个锁。为了获得某个Lock实例的Condition实例,使用LocknewCondition方法。

Condition声明以下方法:

  • void await():强制调用线程等待,直到它收到信号或被中断。
  • boolean await(long time, TimeUnit unit):强制调用线程等待,直到它被信号通知或中断,或者直到指定的等待时间过去。
  • long awaitNanos(long nanosTimeout):强制当前线程等待,直到它发出信号或被中断,或者直到指定的等待时间过去。
  • void awaitUninterruptibly():强制当前线程等待,直到它收到信号。
  • boolean awaitUntil(Date deadline):强制当前线程等待,直到它发出信号或被中断,或者直到指定的deadline过去。
  • void signal():唤醒一个等待线程。
  • void signalAll():唤醒所有等待的线程。

清单 7-2 重温了第三章的生产者-消费者应用(在清单 3-2 中)向您展示如何编写它来利用条件。

Listing 7-2. Achieving Synchronization in Terms of Locks and Conditions

import java.util.concurrent.locks.Condition;

import java.util.concurrent.locks.Lock;

import java.util.concurrent.locks.ReentrantLock;

public class PC

{

public static void main(String[] args)

{

Shared s = new Shared();

new Producer(s).start();

new Consumer(s).start();

}

}

class Shared

{

private char c;

private volatile boolean available;

private final Lock lock;

private final Condition condition;

Shared()

{

available = false;

lock = new ReentrantLock();

condition = lock.newCondition();

}

Lock getLock()

{

return lock;

}

char getSharedChar()

{

lock.lock();

try

{

while (!available)

try

{

condition.await();

}

catch (InterruptedException ie)

{

ie.printStackTrace();

}

available = false;

condition.signal();

}

finally

{

lock.unlock();

return c;

}

}

void setSharedChar(char c)

{

lock.lock();

try

{

while (available)

try

{

condition.await();

}

catch (InterruptedException ie)

{

ie.printStackTrace();

}

this.c = c;

available = true;

condition.signal();

}

finally

{

lock.unlock();

}

}

}

class Producer extends Thread

{

private final Lock l;

private final Shared s;

Producer(Shared s)

{

this.s = s;

l = s.getLock();

}

@Override

public void run()

{

for (char ch = 'A'; ch <= 'Z'; ch++)

{

l.lock();

s.setSharedChar(ch);

System.out.println(ch + " produced by producer.");

l.unlock();

}

}

}

class Consumer extends Thread

{

private final Lock l;

private final Shared s;

Consumer(Shared s)

{

this.s = s;

l = s.getLock();

}

@Override

public void run()

{

char ch;

do

{

l.lock();

ch = s.getSharedChar();

System.out.println(ch + " consumed by consumer.");

l.unlock();

}

while (ch != 'Z');

}

}

清单 7-2 类似于清单 3-2 的PC应用。但是,它用锁和条件代替了synchronized和等待/通知。

PCmain()方法实例化了SharedProducerConsumer类。将Shared实例传递给ProducerConsumer构造函数,然后启动这些线程。

在默认的主线程上调用ProducerConsumer构造函数。因为Shared实例也被生产者和消费者线程访问,所以这个实例必须对这些线程可见(特别是当这些线程运行在不同的内核上时)。在ProducerConsumer中,我通过声明sfinal来完成这个任务。我本可以将这个字段声明为volatile,但是volatile建议对该字段进行额外的写入,并且s在初始化后不应该被更改。

查看Shared的构造函数。注意,它通过lock = new ReentrantLock();创建了一个锁,并通过condition = lock.newCondition() ;创建了一个与这个锁相关联的条件。这个锁通过Lock getLock()方法对生产者线程和消费者线程可用。

生产者线程调用Sharedvoid setSharedChar(char c)方法来生成一个新的字符,然后输出一个标识所生成字符的消息。这个方法锁定之前创建的Lock对象,并进入一个while循环,重复测试变量available,当一个制作好的角色可供消费时就是true

availabletrue时,生产者调用条件的await()方法等待available变为false。当生产者消耗完角色时,消费者发出条件信号以唤醒生产者。(我使用循环而不是if语句,因为虚假唤醒是可能的,并且available可能仍然是true。)

离开循环后,生产者线程记录新字符,将true分配给available以指示新字符可供消费,并发出条件信号以唤醒等待的消费者。最后,它打开锁,退出setSharedChar()

Note

我在Producerrun()方法中锁定了setSharedChar() / System.out.println()块,在Consumerrun()方法中锁定了getSharedChar() / System.out.println()块,以防止应用在生成消息之前输出消费消息,即使字符是在消费之前生成的。

消费者线程和getSharedChar()方法的行为类似于我刚刚描述的生产者线程和setSharedChar()方法。

Note

我没有使用try / finally习惯用法来确保在ProducerConsumerrun()方法中释放锁,因为在这个上下文中不会抛出异常。

编译清单 7-2 如下:

javac PC.java

运行生成的应用,如下所示:

java PC

您应该观察到与输出的以下前缀相同的输出,这表示锁步同步(生产者线程直到项被消费后才产生项,消费者线程直到项被产生后才消费项):

A produced by producer.

A consumed by consumer.

B produced by producer.

B consumed by consumer.

C produced by producer.

C consumed by consumer.

D produced by producer.

D consumed by consumer.

读写锁

数据结构被读取的次数比被修改的次数多。例如,您可能已经创建了一个单词定义的在线词典,许多线程将同时读取该词典,而单个线程可能偶尔会添加新的定义或更新现有的定义。锁定框架为这些情况提供了读写锁定机制,在读取时产生更大的并发性,在写入时产生独占访问的安全性。这种机制基于ReadWriteLock接口。

维护一对锁:一个锁用于只读操作,另一个锁用于写操作。只要没有写线程,读锁就可以被多个读线程同时持有。写锁是排他的:只有一个线程可以修改共享数据。(与关键字synchronized相关联的锁也是排他的。)

ReadWriteLock声明以下方法:

  • Lock readLock():归还用于阅读的锁。
  • Lock writeLock():归还用于书写的锁。

可重入读写锁

ReadWriteLock是由ReentrantReadWriteLock类实现的,它描述了一个可重入的读写锁,其语义与ReentrantLock相似。

通过调用以下任一构造函数来初始化ReentrantReadWriteLock实例:

  • ReentrantReadWriteLock():创建一个ReentrantReadWriteLock的实例。这个构造函数相当于ReentrantReadWriteLock(false)
  • ReentrantReadWriteLock(boolean fair):用指定的公平策略创建一个ReentrantReadWriteLock的实例。当这个锁应该使用公平排序策略时,将true传递给fair

Note

对于公平排序策略,当当前持有的锁被释放时,等待时间最长的单个写线程将被分配写锁,或者当有一组读线程等待的时间比所有等待的写线程长时,该组将被分配读锁。

当写锁被持有或者有一个等待的写线程时,试图获得公平读锁(不可重入)的线程将会阻塞。在当前等待的最早的写线程获得并释放写锁之前,该线程不会获得读锁。如果一个等待的写线程放弃了它的等待,留下一个或多个读线程作为队列中等待时间最长的写线程,这些读线程将被分配读锁。

试图获得公平写锁(不可重入)的线程将会阻塞,除非读锁和写锁都是自由的(这意味着没有等待线程)。(非阻塞tryLock ()方法不遵守这个公平设置,如果可能的话会立即获取锁,而不管等待的线程。)

实例化该类后,调用以下方法来获取读写锁:

  • ReentrantReadWriteLock.ReadLock readLock():返回用于读取的锁。
  • ReentrantReadWriteLock.WriteLock writeLock():返回用于写入的锁。

每个嵌套的ReadLockWriteLock类都实现了Lock接口并声明了自己的方法。此外,ReentrantReadWriteLock声明了额外的方法,如下面的一对:

  • int getReadHoldCount():返回调用线程对这个锁的可重入读持有的次数,当读锁没有被调用线程持有时为0。对于每个与解锁操作不匹配的锁定操作,读取器线程都持有一个锁。
  • int getWriteHoldCount():返回调用线程对这个锁的可重入写持有的次数,当写锁没有被调用线程持有时为0。对于每个与解锁操作不匹配的锁定操作,写线程都持有一个锁。

为了演示ReadWriteLockReentrantReadWriteLock,清单 7-3 给出了一个应用,它的写线程填充一个单词/定义条目的字典,而读线程连续地随机访问条目并输出它们。

Listing 7-3. Using ReadWriteLock to Satisfy a Dictionary Application’s Reader and Writer Threads

import java.util.HashMap;

import java.util.Map;

import java.util.concurrent.Executors;

import java.util.concurrent.ExecutorService;

import java.util.concurrent.locks.Lock;

import java.util.concurrent.locks.ReadWriteLock;

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class Dictionary

{

public static void main(String[] args)

{

final String[] words =

{

"hypocalcemia",

"prolixity",

"assiduous",

"indefatigable",

"castellan"

};

final String[] definitions =

{

"a deficiency of calcium in the blood",

"unduly prolonged or drawn out",

"showing great care, attention, and effort",

"able to work or continue for a lengthy time without tiring",

"the govenor or warden of a castle or fort"

};

final Map<String, String> dictionary = new HashMap<String, String>();

ReadWriteLock rwl = new ReentrantReadWriteLock(true);

final Lock rlock = rwl.readLock();

final Lock wlock = rwl.writeLock();

Runnable writer = () ->

{

for (int i = 0; i < words.length; i++)

{

wlock.lock();

try

{

dictionary.put(words[i],

definitions[i]);

System.out.println("writer storing " +

words[i] + " entry");

}

finally

{

wlock.unlock();

}

try

{

Thread.sleep(1);

}

catch (InterruptedException ie)

{

System.err.println("writer " +

"interrupted");

}

}

};

ExecutorService es = Executors.newFixedThreadPool(1);

es.submit(writer);

Runnable reader = () ->

{

while (true)

{

rlock.lock();

try

{

int i = (int) (Math.random() *

words.length);

System.out.println("reader accessing " +

words[i] + ": " +

dictionary.get(words[i])

+ " entry");

}

finally

{

rlock.unlock();

}

}

};

es = Executors.newFixedThreadPool(1);

es.submit(reader);

}

}

清单 7-3 的默认主线程首先创建字符串的wordsdefinitions数组,它们被声明为final,因为它们将被匿名类访问。在创建了存储单词/定义条目的映射之后,它获得一个可重入的读/写锁,并访问读/写锁。

现在为编写器线程创建了一个 runnable。它的run()方法迭代words数组。每次迭代都会锁定写线程锁。当此方法返回时,编写器线程拥有独占的编写器锁,并且可以更新映射。它通过调用地图的put()方法来实现。在输出一条消息来标识添加的单词后,编写器线程释放锁并休眠一毫秒,以表现出正在执行其他工作。基于线程池的执行器被获取并用于调用写线程的 runnable。

随后为读取器线程创建一个 runnable。它的run()方法反复获取读锁,访问 map 中的一个随机条目,输出这个条目,解锁读锁。获得一个基于线程池的执行器,并用于调用读取器线程的 runnable。

虽然我本可以避免使用获取和释放锁的习惯用法,因为不会抛出异常,但我还是指定了try / finally作为良好的形式。

编译清单 7-3 如下:

javac Dictionary.java

运行生成的应用,如下所示:

java Dictionary

您应该观察到类似于我在一次执行中观察到的输出的以下前缀的输出(消息顺序可能有些不同):

writer storing hypocalcemia entry

writer storing prolixity entry

reader accessing hypocalcemia: a deficiency of calcium in the blood entry

writer storing assiduous entry

reader accessing assiduous: showing great care, attention, and effort entry

reader accessing castellan: null entry

reader accessing hypocalcemia: a deficiency of calcium in the blood entry

reader accessing assiduous: showing great care, attention, and effort entry

reader accessing indefatigable: null entry

reader accessing hypocalcemia: a deficiency of calcium in the blood entry

reader accessing hypocalcemia: a deficiency of calcium in the blood entry

reader accessing assiduous: showing great care, attention, and effort entry

reader accessing indefatigable: null entry

reader accessing prolixity: unduly prolonged or drawn out entry

reader accessing hypocalcemia: a deficiency of calcium in the blood entry

reader accessing castellan: null entry

reader accessing assiduous: showing great care, attention, and effort entry

reader accessing hypocalcemia: a deficiency of calcium in the blood entry

reader accessing prolixity: unduly prolonged or drawn out entry

reader accessing assiduous: showing great care, attention, and effort entry

reader accessing castellan: null entry

reader accessing hypocalcemia: a deficiency of calcium in the blood entry

reader accessing indefatigable: null entry

reader accessing castellan: null entry

reader accessing prolixity: unduly prolonged or drawn out entry

reader accessing hypocalcemia: a deficiency of calcium in the blood entry

writer storing indefatigable entry

reader accessing assiduous: showing great care, attention, and effort entry

reader accessing assiduous: showing great care, attention, and effort entry

Note

Java 8 在java.util.concurrent.locks包中增加了StampedLock。根据其 JDK 8 文档,StampedLock是一种基于能力的锁,具有三种模式来控制读/写访问。它以类似于ReentrantReadWriteLock的方式区分排他锁和非排他锁,但也允许乐观读取,这是ReentrantReadWriteLock不支持的。查看 Heinz Kabutz 博士的 Phaser 和 StampedLock 并发同步器视频演示( www.parleys.com/tutorial/5148922b0364bc17fc56ca4f/chapter0/about )以了解StampedLock。另外,请参见本演示文稿的 PDF 文件( www.jfokus.se/jfokus13/preso/jf13_PhaserAndStampedLock.pdf )。

Exercises

以下练习旨在测试您对第七章内容的理解:

Define lock.   What is the biggest advantage that Lock objects hold over the intrinsic locks that are obtained when threads enter critical sections (controlled via the synchronized reserved word)?   True or false: ReentrantLock’s unlock() method throws IllegalMonitorStateException when the calling thread doesn’t hold the lock.   How do you obtain a Condition instance for use with a particular Lock instance?   True or false: ReentrantReadWriteLock() creates an instance of ReentrantReadWriteLock with a fair ordering policy.   Define StampedLock.   The java.util.concurrent.locks package includes a LockSupport class. What is the purpose of LockSupport?   Replace the following ID class with an equivalent class that uses ReentrantLock in place of synchronized:

public class ID

{

private static int counter; // initialized to 0 by default

public static synchronized int getID()

{

int temp = counter + 1;

try

{

Thread.sleep(1);

}

catch (InterruptedException ie)

{

}

return counter = temp;

}

}

摘要

java.util.concurrent.locks包提供了一个接口和类的框架,用于锁定和等待条件,其方式不同于对象固有的基于锁的同步和Object的等待/通知机制。并发工具包括一个锁定框架,该框架通过提供锁轮询、定时等待等改进了内在同步和等待/通知。

锁定框架包括常用的LockReentrantLockConditionReadWriteLockReentrantReadWriteLock类型,我在本章中对此进行了探讨。我也简单介绍了一下StampedLock类,它是 Java 8 中引入的。

第八章介绍了额外的并发工具。

八、其他并发工具

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-1700-9_​8) contains supplementary material, which is available to authorized users.

第五章到第七章向您介绍了并发工具、执行器(以及可调用和期货)、同步器和锁定框架。在这一章中,我将通过向您介绍并发集合、原子变量、Fork/Join 框架和完成服务来完成我对并发工具的介绍。

Note

由于时间不够,我也无法报道完整的期货。如果你对这个话题感兴趣,我建议你看看 Tomasz Nurkiewicz 在 http://www.nurkiewicz.com/2013/05/java-8-definitive-guide-to.html 发表的题为“Java 8:CompletableFuture 权威指南”的精彩博文。

并发收款

Java 的集合框架提供了位于java.util包中的接口和类。接口包括ListSetMap;课程包括ArrayListTreeSetHashMap

ArrayListTreeSetHashMap以及其他实现这些接口的类都不是线程安全的。然而,您可以通过使用位于java.util.Collections类中的同步包装方法使它们成为线程安全的。例如,您可以将一个ArrayList实例传递给Collections.synchronizedList(),以获得一个ArrayList的线程安全变体。

尽管在多线程环境中经常需要它们来简化代码,但是线程安全集合存在一些问题:

  • 在迭代一个集合之前获取一个锁是必要的,这个集合可能在迭代过程中被另一个线程修改。如果没有获得锁并且集合被修改,那么很可能会抛出java.util.ConcurrentModificationException。发生这种情况是因为集合框架类返回失败快速迭代器,这些迭代器在迭代过程中修改集合时抛出ConcurrentModificationException。失败快速迭代器对于并发应用来说通常是不方便的。
  • 当从多个线程频繁访问同步集合时,性能会受到影响。这个性能问题最终会影响应用的可伸缩性。

并发工具通过包含并发集合来解决这些问题,并发集合是存储在java.util.concurrent包中的高并发性能和高度可伸缩的面向集合的类型。它的面向集合的类返回弱一致性迭代器,这些迭代器具有以下属性:

  • 迭代开始后移除但尚未通过迭代器的next()方法返回的元素不会被返回。
  • 迭代开始后添加的元素可能会返回,也可能不会返回。
  • 在集合的迭代过程中,任何元素都不会返回多次,无论在迭代过程中对集合做了什么更改。

下面的列表提供了一个面向并发的集合类型的简短示例,您可以在java.util.concurrent包中找到:

  • BlockingQueuejava.util.Queue的一个子接口,它也支持阻塞操作,即在检索元素之前等待队列变为非空,在存储元素之前等待队列中有可用空间。每个ArrayBlockingQueueDelayQueueLinkedBlockingQueuePriorityBlockingQueueSynchronousQueue类都直接实现了这个接口。LinkedBlockingDequeLinkedTransferQueue类通过BlockingQueue子接口实现这个接口。
  • ConcurrentMapjava.util.Map的子接口,它声明了额外的不可分割的putIfAbsent()remove()replace()方法。ConcurrentHashMap类(并发等价于java.util.HashMap)、ConcurrentNavigableMap类和ConcurrentSkipListMap类实现了这个接口。

Oracle 的 Javadoc for BlockingQueueArrayBlockingQueue和其他面向并发的集合类型将这些类型标识为集合框架的一部分。

使用 BlockingQueue 和 ArrayBlockingQueue

BlockingQueue的 Javadoc 揭示了生产者-消费者应用的核心,它比第三章(见清单 3-1 )中所示的等效应用要简单得多,因为它不需要处理同步。清单 8-1 在高级生产者-消费者对等体中使用了BlockingQueue及其ArrayBlockingQueue实现类。

Listing 8-1. The Blocking Queue Equivalent of Chapter 3’s PC Application

import java.util.concurrent.ArrayBlockingQueue;

import java.util.concurrent.BlockingQueue;

import java.util.concurrent.ExecutorService;

import java.util.concurrent.Executors;

public class PC

{

public static void main(String[] args)

{

final BlockingQueue<Character> bq;

bq = new ArrayBlockingQueue<Character>(26);

final ExecutorService executor = Executors.newFixedThreadPool(2);

Runnable producer = () ->

{

for (char ch = 'A'; ch <= 'Z'; ch++)

{

try

{

bq.put(ch);

System.out.printf("%c produced by " +

"producer.%n", ch);

}

catch (InterruptedException ie)

{

}

}

};

executor.execute(producer);

Runnable consumer = () ->

{

char ch = '\0';

do

{

try

{

ch = bq.take();

System.out.printf("%c consumed by " +

"consumer.%n", ch);

}

catch (InterruptedException ie)

{

}

}

while (ch != 'Z');

executor.shutdownNow();

};

executor.execute(consumer);

}

}

清单 8-1 分别使用BlockingQueueput()take()方法将一个对象放入阻塞队列和从阻塞队列中移除一个对象。put()没有地方放东西时会阻塞;take()队列为空时阻塞。

虽然BlockingQueue确保了一个角色在产生之前不会被消耗,但是这个应用的输出可能会有不同的指示。例如,下面是一次运行的部分输出:

Y consumed by consumer.

Y produced by producer.

Z consumed by consumer.

Z produced by producer.

清单 3-2 中第三章的PC应用通过在setSharedChar() / System.out.println()周围引入一个额外的同步层和在getSharedChar() / System.out.println()周围引入一个额外的同步层,克服了这种不正确的输出顺序。清单 7-2 中第七章的PC应用通过将这些方法调用放在lock() / unlock()方法调用之间,克服了这种不正确的输出顺序。

了解有关 ConcurrentHashMap 的更多信息

ConcurrentHashMap类的行为类似于HashMap,但是被设计成在多线程环境中工作,不需要显式同步。例如,您经常需要检查映射是否包含特定的值,如果没有该值,则将该值放入映射中:

if (!map.containsKey("some string-based key"))

map.put("some string-based key", "some string-based value");

尽管这段代码很简单,看起来也能完成工作,但它并不是线程安全的。在调用map.containsKey()map.put()之间,另一个线程可以插入这个条目,然后这个条目会被覆盖。要修复这种争用情况,您必须显式同步这段代码,我在这里演示了这一点:

synchronized(map)

{

if (!map.containsKey("some string-based key"))

map.put("some string-based key", "some string-based value");

}

这种方法的问题是,在检查键是否存在并在键不存在时将条目添加到映射中时,您已经锁定了整个映射的读写操作。当许多线程试图访问映射时,这种锁定会影响性能。

通用的ConcurrentHashMap<V>类通过提供V putIfAbsent(K key, V value)方法解决了这个问题,当key不存在时,该方法在地图中引入一个key / value条目。此方法等效于以下代码片段,但提供了更好的性能:

synchronized(map)

{

if (!map.containsKey(key))

return map.put(key, value);

else

return map.get(key);

}

使用putIfAbsent(),先前的代码片段被翻译成以下更简单的代码片段:

map.putIfAbsent("some string-based key", "some string-based value");

Note

Java 8 通过添加 30 多个新方法改进了ConcurrentHashMap,这些新方法主要通过聚合操作支持 lambda 表达式和 Streams API。执行聚合操作的方法包括forEach()方法(forEach()forEachKey()forEachValue()forEachEntry())、搜索方法(search()searchKeys()searchValues()searchEntries())以及归约方法(reduce()reduceToDouble()reduceToLong()等)。其他方法(如mappingCount()newKeySet())也已添加。由于 JDK 8 的改变,ConcurrentHashMap现在作为缓存更有用了。缓存改进的变化包括计算键值的方法,对扫描(也可能是驱逐)条目的改进支持,以及对包含大量元素的地图的更好支持。

原子变量

与对象监视器相关联的固有锁历来性能不佳。尽管性能有所提高,但在创建 web 服务器和其他需要高可伸缩性和高性能的应用时,在存在大量线程争用的情况下,它们仍然是一个瓶颈。

许多研究都致力于创建非阻塞算法,这些算法可以从根本上提高同步上下文中的性能。这些算法提高了可伸缩性,因为当多个线程争用相同的数据时,线程不会阻塞。此外,线程不会遇到死锁和其他活动问题。

Java 5 通过引入java.util.concurrent.atomic包提供了创建高效非阻塞算法的能力。根据这个包的 JDK 文档,java.util.concurrent.atomic提供了一个支持无锁、线程安全的单变量操作的小型工具包。

java.util.concurrent.atomic包中的类将volatile值、字段和数组元素的概念扩展到那些也提供原子条件更新的元素,因此不需要外部同步。换句话说,在没有外部同步的情况下,与volatile变量相关的内存语义是互斥的。

Note

术语原子和不可分割被广泛认为是等价的,即使我们可以分裂原子。

位于java.util.concurrent.atomic中的一些类描述如下:

  • AtomicBoolean:可以自动更新的boolean值。
  • AtomicInteger:可以自动更新的int值。
  • AtomicIntegerArray:一个int数组,它的元素可以自动更新。
  • AtomicLong:可以自动更新的long值。
  • AtomicLongArray:一个long数组,其元素可以被自动更新。
  • AtomicReference:可以自动更新的对象引用。
  • AtomicReferenceArray:一个对象引用数组,其元素可以自动更新。

原子变量用于实现计数器、序列生成器(如java.util.concurrent.ThreadLocalRandom)和其他需要互斥的构造,在高线程争用的情况下不会出现性能问题。例如,考虑列出 8-2 的ID类,其getNextID()类方法返回唯一的长整数标识符。

Listing 8-2. Returning Unique Identifiers in a Thread-Safe Manner via synchronized

class ID

{

private static volatile long nextID = 1;

static synchronized long getNextID()

{

return nextID++;

}

}

尽管代码得到了适当的同步(并且考虑到了可见性),但是在大量线程争用的情况下,与synchronized相关联的固有锁会损害性能。此外,还可能出现死锁等活性问题。清单 8-3 向您展示了如何通过用原子变量替换synchronized来避免这些问题。

Listing 8-3. Returning Unique IDs in a Thread-Safe Manner via AtomicLong

import java.util.concurrent.atomic.AtomicLong;

class ID

{

private static AtomicLong nextID = new AtomicLong(1);

static long getNextID()

{

return nextID.getAndIncrement();

}

}

在清单 8-3 中,我已经将nextIDlong转换为AtomicLong实例,并将该对象初始化为1。我还重构了getNextID()方法来调用AtomicLonggetAndIncrement()方法,该方法将AtomicLong实例的内部长整型变量递增 1,并在一个不可分割的步骤中返回前一个值。没有显式同步。

Note

java.util.concurrent.atomic包包括DoubleAccumulatorDoubleAdderLongAccumulatorLongAdder类,这些类解决了在维护单个计数、总和或其他值的环境中的可伸缩性问题,并且有可能从多个线程进行更新。这些新类“在内部采用了争用减少技术,与原子变量相比,这些技术提供了巨大的吞吐量改进。这是通过以一种在大多数应用中可接受的方式放松原子性保证而成为可能的。”

理解原子魔法

Java 的低级同步机制强制实施互斥(持有保护一组变量的锁的线程对它们具有独占访问权)和可见性(对被保护变量的更改对随后获得锁的其他线程变得可见),以下列方式影响硬件利用率和可伸缩性:

  • 争用同步(多个线程不断竞争一个锁)开销很大,结果会影响吞吐量。这种开销主要是由频繁的上下文切换(将中央处理单元从一个线程切换到另一个线程)引起的。每个上下文切换操作可能需要许多处理器周期才能完成。相比之下,现代 Java 虚拟机(JVM)使得无竞争同步变得廉价。
  • 当持有锁的线程被延迟时(例如,由于调度延迟),没有需要该锁的线程取得任何进展;硬件没有得到应有的利用。

尽管您可能认为可以使用volatile作为同步的替代方案,但这是行不通的。可变变量只能解决可见性问题。它们不能用于安全地实现原子的读-修改-写序列,而这些序列是实现线程安全计数器和其他需要互斥的实体所必需的。然而,有一个替代方案负责并发工具提供的性能增益(比如java.util.concurrent.Semaphore类)。这种替代方法被称为比较和交换。

比较和交换(CAS)是不间断微处理器专用指令的通用术语,该指令读取存储单元,将读取值与期望值进行比较,并在读取值与期望值匹配时在存储单元中存储新值。否则,什么都不做。现代微处理器提供各种 CAS。例如,英特尔微处理器提供了cmpxchg系列指令,而较老的 PowerPC 微处理器提供了等效的加载链接(如lwarx)和条件存储(如stwcx)指令。

CAS 支持原子读取-修改-写入序列。您通常按如下方式使用 CAS:

Read value x from address A.   Perform a multistep computation on x to derive a new value called y.   Use CAS to change the value of A from x to y. CAS succeeds when A’s value hasn’t changed while performing these steps.

为了理解 CAS 的好处,考虑一下清单 8-2 的ID类,它返回一个唯一的标识符。因为这个类声明了它的getNextID()方法synchronized,对监视器锁的高度争用会导致过多的上下文切换,这会延迟所有的线程,并导致应用不能很好地伸缩。

假设存在一个在value中存储基于int的值的CAS类。此外,它还提供了返回value的原子方法int getValue()和实现 CAS 的原子方法int compareAndSwap(int expectedValue, int newValue)。(在幕后,CAS 依靠 Java 本地接口[JNI]来访问特定于微处理器的 CAS 指令。)

compareAndSwap()方法自动执行以下指令序列:

int readValue = value;            // Obtain the stored value.

if (readValue == expectedValue)   // If stored value not modified ...

value = newValue;              // ... change to new value.

return readValue;                 // Return value before a potential change.

清单 8-4 展示了一个新版本的ID,它使用CAS类以高性能的方式获得一个惟一的标识符。(忘记使用 JNI 的性能影响,假设我们可以直接访问特定于微处理器的 CAS 指令。)

Listing 8-4. Returning Unique IDs in a Thread-Safe Manner via CAS

class ID

{

private static CAS value = new CAS(1);

static long getNextID()

{

int curValue = value.getValue();

while (value.compareAndSwap(curValue, curValue + 1) != curValue)

curValue = value.getValue();

return curValue - 1;

}

}

ID封装一个初始化为int-值1CAS实例,并声明一个getNextID()方法,用于检索当前标识符值,然后在该实例的帮助下递增该值。在检索实例的当前值后,getNextID()重复调用compareAndSwap(),直到curValue的值没有改变(被另一个线程改变)。然后,该方法可以随意更改该值,之后它会返回上一个值。当不涉及锁时,可以避免争用以及过多的上下文切换。性能提高了,代码更具可伸缩性。

作为 CAS 如何改进并发工具的一个例子,考虑java.util.concurrent.locks.ReentrantLock。在高线程争用的情况下,这个类比synchronized提供了更好的性能。为了提高性能,ReentrantLock的同步由抽象java.util.concurrent.locks.AbstractQueuedSynchronizer类的一个子类来管理。反过来,这个类利用了未记录的sun.misc.Unsafe类和它的compareAndSwapInt() CAS 方法。

原子变量类也利用了 CAS。此外,它们还提供了一种具有以下形式的方法:

boolean compareAndSet(expectedValue, updateValue)

这个方法(在不同的类中参数类型不同)在当前保存expectedValue时自动地为updateValue设置一个变量,成功时报告true

分叉/连接框架

代码总是需要更快地执行。历史上,这种需求是通过提高微处理器速度和/或支持多处理器来满足的。然而,在 2003 年左右,由于自然限制,微处理器速度停止增长。为了弥补这一点,处理器制造商开始在处理器中添加多个处理内核,通过大规模并行来提高速度。

Note

并行性是指通过多个处理器和内核的某种组合同时运行线程。相比之下,并发是一种更普遍的并行形式,其中线程通过上下文切换同时运行或似乎同时运行,也称为虚拟并行。有些人进一步将并发性描述为程序或操作系统的属性,将并行性描述为同时执行多个线程的运行时行为。

Java 通过其低级线程特性和高级并发工具(如线程池)来支持并发。并发的问题是它不能最大限度地利用可用的处理器/内核资源。例如,假设您创建了一个排序算法,该算法将数组分成两半,分配两个线程对每一半进行排序,并在两个线程完成后合并结果。

让我们假设每个线程运行在不同的处理器上。因为数组的每一半中可能出现不同数量的元素重新排序,所以一个线程可能会在另一个线程之前完成,并且必须在合并发生之前等待。在这种情况下,浪费了处理器资源。

这个问题(以及代码冗长和难以阅读的相关问题)可以通过递归地将任务分解成子任务并组合结果来解决。这些子任务并行运行,并且几乎同时完成(如果不是同时完成的话),它们的结果被合并,并通过栈传递到前一层子任务。等待几乎不会浪费任何处理器时间,递归代码也不那么冗长,而且(通常)更容易理解。Java 提供了 Fork/Join 框架来实现这个场景。

Fork/Join 由一个特殊的执行器服务和线程池组成。executor 服务使一个任务对框架可用,这个任务被分解成更小的任务,这些任务从池中派生出来(由不同的线程执行)。任务会一直等待,直到被加入(其子任务完成)。

Fork/Join 使用工作窃取来最小化线程争用和开销。工作线程池中的每个工作线程都有自己的双端工作队列,并将新任务推送到该队列。它从队列的头部读取任务。如果队列为空,工作线程会尝试从另一个队列的尾部获取任务。窃取并不常见,因为工作线程按照后进先出(LIFO)的顺序将任务放入队列中,并且随着问题被分成子问题,工作项的大小会变得更小。你开始把任务交给一个中心工作人员,它继续把它们分成更小的任务。最终,所有的工人都与最小同步有关。

Fork/Join 主要由java.util.concurrent包的ForkJoinPoolForkJoinTaskForkJoinWorkerThreadRecursiveActionRecursiveTaskCountedCompleter类组成:

  • ForkJoinPool是运行ForkJoinTaskjava.util.concurrent.ExecutorService实现。一个ForkJoinPool实例为来自非ForkJoinTask客户端的提交提供入口点,并提供管理和监控操作。
  • ForkJoinTask是在ForkJoinPool上下文中运行的任务的抽象基类。一个ForkJoinTask实例是一个类似线程的实体,它比普通线程要轻得多。在一个ForkJoinPool中,大量的任务和子任务可能由少量的实际线程托管,代价是一些使用限制。
  • ForkJoinWorkerThread描述一个由ForkJoinPool实例管理的线程,它执行ForkJoinTask s
  • RecursiveAction描述一个递归的无结果ForkJoinTask
  • RecursiveTask描述了一个递归的结果承载ForkJoinTask
  • CountedCompleter描述了一个ForkJoinTask,它具有一个在被触发时执行的完成动作(完成一个 fork/join 任务的代码),并且没有剩余的挂起动作。

Java 文档提供了基于RecursiveAction的任务(比如排序)和基于RecursiveTask的任务(比如计算斐波那契数)的例子。也可以用RecursiveAction来完成矩阵乘法(见en . Wikipedia . org/wiki/Matrix _ multiplication)。例如,假设您已经创建了清单 8-5 的Matrix类来表示由特定数量的行和列组成的矩阵。

Listing 8-5. A Class for Representing a Two-Dimensional Table

public class Matrix

{

private final int[][] matrix;

public Matrix(int nrows, int ncols)

{

matrix = new int[nrows][ncols];

}

public int getCols()

{

return matrix[0].length;

}

public int getRows()

{

return matrix.length;

}

public int getValue(int row, int col)

{

return matrix[row][col];

}

public void setValue(int row, int col, int value)

{

matrix[row][col] = value;

}

}

清单 8-6 展示了将两个Matrix实例相乘的单线程方法。

Listing 8-6. Multiplying Two Matrix Instances via the Standard Matrix-Multiplication Algorithm

public class MatMult

{

public static void main(String[] args)

{

Matrix a = new Matrix(1, 3);

a.setValue(0, 0, 1); // | 1 2 3 |

a.setValue(0, 1, 2);

a.setValue(0, 2, 3);

dump(a);

Matrix b = new Matrix(3, 2);

b.setValue(0, 0, 4); // | 4 7 |

b.setValue(1, 0, 5); // | 5 8 |

b.setValue(2, 0, 6); // | 6 9 |

b.setValue(0, 1, 7);

b.setValue(1, 1, 8);

b.setValue(2, 1, 9);

dump(b);

dump(multiply(a, b));

}

public static void dump(Matrix m)

{

for (int i = 0; i < m.getRows(); i++)

{

for (int j = 0; j < m.getCols(); j++)

System.out.printf("%d ", m.getValue(i, j));

System.out.println();

}

System.out.println();

}

public static Matrix multiply(Matrix a, Matrix b)

{

if (a.getCols() != b.getRows())

throw new IllegalArgumentException("rows/columns mismatch");

Matrix result = new Matrix(a.getRows(), b.getCols());

for (int i = 0; i < a.getRows(); i++)

for (int j = 0; j < b.getCols(); j++)

for (int k = 0; k < a.getCols(); k++)

result.setValue(i, j, result.getValue(i, j) +

a.getValue(i, k) * b.getValue(k, j));

return result;

}

}

清单 8-6 的MatMult类声明了一个演示矩阵乘法的multiply()方法。在验证了第一个Matrix ( a)中的列数等于第二个Matrix ( b)中的行数(这对于算法来说是必不可少的)之后,multiply()创建一个result Matrix,并进入一系列嵌套循环来执行乘法。

这些循环的本质如下:对于a中的每一行,将该行的每一列值乘以b中相应列的行值。将乘法的结果相加,并将总数存储在通过a中的行索引(i)和b中的列索引(j)指定的位置的result中。

编译清单 8-6 和清单 8-5 ,它们必须在同一个目录中,如下所示:

javac MultMat.java

运行生成的应用,如下所示:

java MatMult

您应该观察到以下输出,它表明 1 行 3 列的矩阵乘以 3 行 2 列的矩阵得到 1 行 2 列的矩阵:

1 2 3

4 7

5 8

6 9

32 50

计算机科学家将这种算法归类为 O(nnn),读作“n 次方的 big-oh”或“近似 n 次方”。这种符号是对算法性能进行分类的一种抽象方式(不会陷入具体细节,如微处理器速度)。O(nnn)分类指示非常差的性能,并且这种性能随着被相乘的矩阵的大小增加而恶化。

通过将每个逐行逐列的乘法任务分配给单独的类似线程的实体,可以提高性能(在多处理器和/或多核平台上)。清单 8-7 向您展示了如何在 Fork/Join 框架的上下文中完成这个场景。

Listing 8-7. Multiplying Two Matrix Instances with Help from the Fork/Join Framework

import java.util.ArrayList;

import java.util.List;

import java.util.concurrent.ForkJoinPool;

import java.util.concurrent.RecursiveAction;

public class MatMult extends RecursiveAction

{

private final Matrix a, b, c;

private final int row;

public MatMult(Matrix a, Matrix b, Matrix c)

{

this(a, b, c, -1);

}

public MatMult(Matrix a, Matrix b, Matrix c, int row)

{

if (a.getCols() != b.getRows())

throw new IllegalArgumentException("rows/columns mismatch");

this.a = a;

this.b = b;

this.c = c;

this.row = row;

}

@Override

public void compute()

{

if (row == -1)

{

List<MatMult> tasks = new ArrayList<>();

for (int row = 0; row < a.getRows(); row++)

tasks.add(new MatMult(a, b, c, row));

invokeAll(tasks);

}

else

multiplyRowByColumn(a, b, c, row);

}

public static void multiplyRowByColumn(Matrix a, Matrix b, Matrix c,

int row)

{

for (int j = 0; j < b.getCols(); j++)

for (int k = 0; k < a.getCols(); k++)

c.setValue(row, j, c.getValue(row, j) +

a.getValue(row, k) * b.getValue(k, j));

}

public static void dump(Matrix m)

{

for (int i = 0; i < m.getRows(); i++)

{

for (int j = 0; j < m.getCols(); j++)

System.out.print(m.getValue(i, j) + " ");

System.out.println();

}

System.out.println();

}

public static void main(String[] args)

{

Matrix a = new Matrix(2, 3);

a.setValue(0, 0, 1); // | 1 2 3 |

a.setValue(0, 1, 2); // | 4 5 6 |

a.setValue(0, 2, 3);

a.setValue(1, 0, 4);

a.setValue(1, 1, 5);

a.setValue(1, 2, 6);

dump(a);

Matrix b = new Matrix(3, 2);

b.setValue(0, 0, 7); // | 7 1 |

b.setValue(1, 0, 8); // | 8 2 |

b.setValue(2, 0, 9); // | 9 3 |

b.setValue(0, 1, 1);

b.setValue(1, 1, 2);

b.setValue(2, 1, 3);

dump(b);

Matrix c = new Matrix(2, 2);

ForkJoinPool pool = new ForkJoinPool();

pool.invoke(new MatMult(a, b, c));

dump(c);

}

}

清单 8-7 展示了一个扩展RecursiveActionMatMult类。为了完成有意义的工作,RecursiveActionvoid compute()方法被覆盖。

Note

虽然compute()通常用于递归地将任务细分为子任务,但我选择以不同的方式处理乘法任务(为了简洁)。

创建Matrix es ab后,清单 8-7 的main()方法创建Matrix并实例化ForkJoinPool。然后实例化MatMult,将这三个Matrix实例作为参数传递给MatMult(Matrix a, Matrix b, Matrix c)构造函数,并调用ForkJoinPoolT invoke(ForkJoinTask<T> task)方法开始运行这个初始任务。该方法直到初始任务及其所有子任务完成后才返回。

MatMult(Matrix a, Matrix b, Matrix c)构造函数调用MatMult(Matrix a, Matrix b, Matrix c, int row)构造函数,将-1指定为row的值。作为前面提到的invoke()方法调用的结果,compute()使用这个值来区分初始任务和子任务。

当最初调用compute()(row等于-1)时,它创建一个MatMult任务的List,并将这个List传递给RecursiveActionCollection<T> invokeAll(Collection<T> tasks)方法(继承自ForkJoinTask)。这个方法派生出所有List集合的任务,这些任务将开始执行。然后等待,直到invokeAll()方法返回(也加入到所有这些任务中),当boolean isDone()方法(也继承自ForkJoinTask)为每个任务返回true时,就会发生这种情况。

注意tasks.add(new MatMult(a, b, c, row));方法调用。这个调用为一个MatMult实例分配一个特定的row值。当invokeAll()被调用时,每个任务的compute()方法被调用并检测分配给row的不同值(除了-1)。然后,它为其特定的row执行multiplyRowByColumn(a, b, c, row);

编译清单 8-7 ( javac MatMult.java)并运行结果应用(java MatMult)。您应该观察到以下输出:

1 2 3

4 5 6

7 1

8 2

9 3

50 14

122 32

完井服务

完成服务是java.util.concurrent.CompletionService<V>接口的一个实现,它将新的异步任务的产生(生产者)与已完成任务的结果的消费(消费者)分离开来。V是任务结果的类型。

生产者通过调用其中一个submit()方法提交任务以供执行:一个方法接受可调用的参数,另一个方法接受可运行的参数以及任务完成时返回的结果。每个方法返回一个代表任务挂起完成的Future<V>实例。然后您可以调用一个poll()方法来轮询任务的完成情况,或者调用阻塞的take()方法。

消费者通过调用take()方法获得一个完成的任务。此方法会一直阻止,直到任务完成。然后它返回一个代表已完成任务的Future<V>对象。您将调用Future<V>get()方法来获得这个结果。

CompletionService<V>一起,Java 7 引入了java.util.concurrent.ExecutorCompletionService<V>类,通过提供的执行器支持任务执行。这个类确保当提交的任务完成时,它们被放在一个take()可以访问的队列中。

为了演示CompletionServiceExecutorCompletionService,我在重温我在第五章中首次提出的计算欧拉数的应用。清单 8-8 给出了一个新应用的源代码,该应用提交了两个可调用的任务来计算不同精度的数字。

Listing 8-8. Calculating Euler’s Number via a Completion Service

import java.math.BigDecimal;

import java.math.MathContext;

import java.math.RoundingMode;

import java.util.concurrent.Callable;

import java.util.concurrent.CompletionService;

import java.util.concurrent.ExecutorCompletionService;

import java.util.concurrent.Executors;

import java.util.concurrent.ExecutorService;

import java.util.concurrent.Future;

public class CSDemo

{

public static void main(String[] args) throws Exception

{

ExecutorService es = Executors.newFixedThreadPool(10);

CompletionService<BigDecimal> cs =

new ExecutorCompletionService<BigDecimal>(es);

cs.submit(new CalculateE(17));

cs.submit(new CalculateE(170));

Future<BigDecimal> result = cs.take();

System.out.println(result.get());

System.out.println();

result = cs.take();

System.out.println(result.get());

es.shutdown();

}

}

class CalculateE implements Callable<BigDecimal>

{

final int lastIter;

public CalculateE(int lastIter)

{

this.lastIter = lastIter;

}

@Override

public BigDecimal call()

{

MathContext mc = new MathContext(100, RoundingMode.HALF_UP);

BigDecimal result = BigDecimal.ZERO;

for (int i = 0; i <= lastIter; i++)

{

BigDecimal factorial = factorial(new BigDecimal(i));

BigDecimal res = BigDecimal.ONE.divide(factorial, mc);

result = result.add(res);

}

return result;

}

private BigDecimal factorial(BigDecimal n)

{

if (n.equals(BigDecimal.ZERO))

return BigDecimal.ONE;

else

return n.multiply(factorial(n.subtract(BigDecimal.ONE)));

}

}

清单 8-8 呈现了两个类:CSDemoCalculateECSDemo驱动应用,CalculateE描述欧拉数计算任务。

CSDemomain()方法首先创建一个将执行任务的执行器服务。然后,它创建一个完成服务来完成任务。两个计算任务随后被提交给完成服务,完成服务异步运行每个任务。对于每个任务,完成服务的take()方法被调用以返回任务的未来,其get()方法被调用以获得任务结果,然后输出。

CalculateE包含的代码与第五章中的几乎相同(见清单 5-1 )。唯一的区别是从一个LASTITER常量变成了一个lastIter变量,它记录了最后一次执行的迭代(并决定了精度的位数)。

编译清单 8-8 如下:

javac CSDemo.java

运行生成的应用,如下所示:

java CSDemo

您应该观察到以下输出:

2.718281828459045070516047795848605061178979635251032698900735004065225042504843314055887974344245741730039454062711

2.7182818284590452353602874713526624977572470936999595749669676277240766303535475945713821785251664274638961162816541248130487298653803083054255628382459134600326751445819115604942105262868564884769196304284703491677706848122126664838550045128841929851772268853216753574895628940347880297133296754744949375835005542283846314528419863840501124972044069282255484327668062074149805932978161481951711991448146506

Note

如果您想知道 executor 服务和完成服务之间的区别,请考虑一下,对于 executor 服务,在编写了提交任务的代码之后,您需要编写代码来有效地检索任务结果。有了完成服务,这项工作几乎是自动化的。看待这些结构的另一种方式是,执行器服务为任务提供一个传入队列并提供工作线程,而完成服务为任务、工作线程提供一个传入队列,并为存储任务结果提供一个输出队列。

Exercises

以下练习旨在测试您对第八章内容的理解:

Identify the two problems with thread-safe collections.   Define concurrent collection.   What is a weakly-consistent iterator?   Describe the BlockingQueue interface.   Describe the ConcurrentMap interface.   Describe the ArrayBlockingQueue and LinkedBlockingQueue BlockingQueue-implementation classes.   True or false: The concurrency-oriented collection types are part of the Collections Framework.   Describe the ConcurrentHashMap class.   Using ConcurrentHashMap, how would you check if a map contains a specific value and, when this value is absent, put this value into the map without relying on external synchronization?   Define atomic variable.   What does the AtomicIntegerArray class describe?   True or false: volatile supports atomic read-modify-write sequences.   What’s responsible for the performance gains offered by the concurrency utilities?   Describe the Fork/Join Framework.   Identify the main types that comprise the Fork/Join Framework.   To accomplish meaningful work via RecursiveAction, which one of its methods would you override?   Define completion service.   How do you use a completion service?   How do you execute tasks via a completion service?   Convert the following expressions to their atomic variable equivalents: int total = ++counter; int total = counter--;

摘要

本章通过介绍并发集合、原子变量、Fork/Join 框架和完成服务,完成了我的并发工具之旅。

并发集合是一种存储在java.util.concurrent包中的并发高性能和高度可伸缩的面向集合的类型。它克服了线程安全集合的ConcurrentModificationException和性能问题。

原子变量是一个类的实例,它封装了单个变量,并支持对该变量进行无锁、线程安全的操作,例如AtomicInteger

Fork/Join 框架由一个特殊的执行器服务和线程池组成。executor 服务使一个任务对框架可用,并且这个任务被分解成从池中派生(由不同的线程执行)的更小的任务。任务会一直等待,直到它被加入(其子任务完成)。

完成服务是CompletionService<V>接口的一个实现,它将新的异步任务的产生(生产者)与已完成任务的结果的消费(消费者)分离开来。V是任务结果的类型。

附录 A 给出了每章练习的答案。

九、练习答案

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-1700-9_​9) contains supplementary material, which is available to authorized users.

第一章到第八章的每一章都有一个“练习”部分,测试你对本章内容的理解。这些练习的答案见本附录。

第一章:线程和可运行线程

A thread is an independent path of execution through an application’s code.   A runnable is a code sequence encapsulated into an object whose class implements the Runnable interface.   The Thread class provides a consistent interface to the underlying operating system’s threading architecture. The Runnable interface supplies the code to be executed by the thread that’s associated with a Thread object.   The two ways to create a Runnable object are to instantiate an anonymous class that implements the Runnable interface and to use a lambda expression.   The two ways to connect a runnable to a Thread object are to pass the runnable to a Thread constructor that accepts a runnable argument and to subclass Thread and override its void run() method when the constructor doesn’t accept a runnable argument. Thread implements the Runnable interface, which makes Thread objects runnables as well.   The five kinds of Thread state are a name, an indication of whether the thread is alive or dead, the execution state of the thread (is it runnable?), the thread’s priority, and an indication of whether the thread is daemon or nondaemon.   The answer is false: a default thread name starts with the Thread- prefix.   You give a thread a nondefault name by calling a Thread constructor that accepts a thread name or by calling Thread’s void setName( String name) method.   You determine if a thread is alive or dead by calling Thread’s boolean isAlive() method.   The Thread.State enum’s constants are NEW (a thread that has not yet started is in this state), RUNNABLE (a thread executing in the Java virtual machine [JVM] is in this state), BLOCKED (a thread that is blocked waiting for a monitor lock is in this state), WAITING (a thread that is waiting indefinitely for another thread to perform a particular action is in this state), TIMED_WAITING (a thread that is waiting for another thread to perform an action for up to a specified waiting time is in this state), and TERMINATED (a thread that has exited is in this state).   You obtain the current thread execution state by calling Thread’s Thread.State getState() method.   Priority is thread-relative importance.   Using setPriority() can impact an application’s portability across operating systems because different schedulers can handle a priority change in different ways.   The range of values that you can pass to Thread’s void setPriority(int priority) method are Thread.MIN_PRIORITY to Thread.MAX_PRIORITY.   The answer is true: a daemon thread dies automatically when the application’s last nondaemon thread dies so that the application can terminate.   Thread’s void start() method throws IllegalThreadStateException when called on a Thread object whose thread is running or has died.   You would stop an unending application on Windows by pressing the Ctrl and C keys simultaneously.   The methods that form Thread’s interruption mechanism are void interrupt(), static boolean interrupted(), and boolean isInterrupted().   The answer is false: the boolean isInterrupted() method doesn’t clear the interrupted status of this thread. The interrupted status is unaffected.   A thread throws InterruptedException when it’s interrupted.   A busy loop is a loop of statements designed to waste some time.   Thread’s methods that let a thread wait for another thread to die are void join(), void join(long millis), and void join(long millis, int nanos).   Thread’s methods that let a thread sleep are void sleep(long millis) and void sleep(long millis, int nanos).   Listing A-1 presents the IntSleep application that was called for in Chapter 1.   Listing A-1. Interrupting a Sleeping Background Thread

public class IntSleep

{

public static void main(String[] args)

{

Runnable r = new Runnable()

{

@Override

public void run()

{

while (true)

{

System.out.println("hello");

try

{

Thread.sleep(100);

}

catch (InterruptedException ie)

{

System.out.println("interrupted");

break;

}

}

}

};

Thread t = new Thread(r);

t.start();

try

{

Thread.sleep(2000);

}

catch (InterruptedException ie)

{

}

t.interrupt();

}

}

第二章:同步

The three problems with threads are race conditions, data races, and cached variables.   The answer is false: when the correctness of a computation depends on the relative timing or interleaving of multiple threads by the scheduler, you have a race condition.   Synchronization is a JVM feature that ensures that two or more concurrent threads don’t simultaneously execute a critical section.   The two properties of synchronization are mutual exclusion and visibility.   Synchronization is implemented in terms of monitors, which are concurrency constructs for controlling access to critical sections, which must execute indivisibly. Each Java object is associated with a monitor, which a thread can lock or unlock acquiring and releasing the monitor’s lock token.   The answer is true: a thread that has acquired a lock doesn’t release this lock when it calls one of Thread’s sleep() methods.   You specify a synchronized method by including the keyword synchronized in the method header.   You specify a synchronized block by specifying the syntax synchronized(object) {}.   Liveness refers to something beneficial happening eventually.   The three liveness challenges are deadlock, livelock, and starvation (also known as indefinite postponement).   The volatile keyword differs from synchronized in that volatile deals with visibility only, whereas synchronized deals with mutual exclusion and visibility.   The answer is true: Java also lets you safely access a final field without the need for synchronization.   The thread problems with the CheckingAccount class are the check-then-act race condition in the withdraw() method between if (amount <= balance) and balance -= amount; (which results in more money being withdrawn than is available for withdrawal) and the potentially cached balance field. The balance field can be cached on multiprocessor/multicore systems and the cached copy used by the withdrawal thread might not contain the initial balance set in the constructor by the default main thread.   Listing A-2 presents the CheckingAccount application that was called for in Chapter 2.   Listing A-2. Fixing a Problematic Checking Account

public class CheckingAccount

{

private volatile int balance;

public CheckingAccount(int initialBalance)

{

balance = initialBalance;

}

public synchronized boolean withdraw(int amount)

{

if (amount <= balance)

{

try

{

Thread.sleep((int) (Math.random() * 200));

}

catch (InterruptedException ie)

{

}

balance -= amount;

return true;

}

return false;

}

public static void main(String[] args)

{

final CheckingAccount ca = new CheckingAccount(100);

Runnable r = new Runnable()

{

@Override

public void run()

{

String name = Thread.currentThread().getName();

for (int i = 0; i < 10; i++)

System.out.println (name + " withdraws $10: " +

ca.withdraw(10));

}

};

Thread thdHusband = new Thread(r);

thdHusband.setName("Husband");

Thread thdWife = new Thread(r);

thdWife.setName("Wife");

thdHusband.start();

thdWife.start();

}

}

这个应用使用volatile来处理潜在的缓存问题,使用synchronized来处理互斥需求。

第三章:等待和通知

A condition is a prerequisite for continued execution.   The API that supports conditions consists of Object’s three wait() methods, one notify() method, and one notifyAll() method. The wait() methods wait for a condition to exist; the notify() and notifyAll() methods notify the waiting thread when the condition exists.   The answer is true: the wait() methods are interruptible.   You would call the notifyAll() method to wake up all threads that are waiting on an object’s monitor.   The answer is false: a thread that has acquired a lock releases this lock when it calls one of Object’s wait() methods.   A condition queue is a data structure that stores threads waiting for a condition to exist. The waiting threads are known as the wait set.   When you call any of the API’s methods outside of a synchronized context, IllegalMonitorStateException is thrown.   A spurious wakeup is a thread waking up without being notified, interrupted, or timing out.   You should call a wait() method in a loop context to ensure liveness and safety.   Listing A-3 presents the Await application that was called for in Chapter 3.   Listing A-3. Using wait() and notifyAll() to Create a Higher-Level Concurrency Construct

public class Await

{

static volatile int count;

public static void main(String[] args)

{

Runnable r = () ->

{

Thread curThread = Thread.currentThread();

System.out.printf("%s has entered runnable and is " +

"waiting%n", curThread.getName());

synchronized(Await.class)

{

count++;

try

{

Thread.sleep(2000);

while (count < 3)

Await.class.wait();

}

catch (InterruptedException ie)

{

}

}

System.out.printf("%s has woken up and is " +

"terminating%n",

curThread.getName());

};

Thread thdA = new Thread(r, "thdA");

Thread thdB = new Thread(r, "thdB");

Thread thdC = new Thread(r, "thdC");

thdA.start();

thdB.start();

thdC.start();

r = new Runnable()

{

@Override

public void run()

{

try

{

while (count < 3)

Thread.sleep(100);

synchronized(Await.class)

{

Await.class.notifyAll();

}

}

catch (InterruptedException ie)

{

}

}

};

Thread thd = new Thread(r);

thd.start();

}

}

第四章:附加线程能力

A thread group is a set of threads. It’s represented by the ThreadGroup class.   You might use a thread group to perform a common operation on its threads, to simplify thread management.   You should avoid using thread groups because the most useful ThreadGroup methods have been deprecated and because of the “time of check to time of use” race condition between obtaining a count of active threads and enumerating those threads.   You should be aware of thread groups because of ThreadGroup’s contribution in handling exceptions that are thrown while a thread is executing.   A thread-local variable is a variable that provides a separate storage slot to each thread that accesses the variable. It’s represented by the ThreadLocal class.   The answer is true: if an entry doesn’t exist in the calling thread’s storage slot when the thread calls get(), this method calls initialValue().   You would pass a value from a parent thread to a child thread by working with the InheritableThreadLocal class.   The classes that form the Timer Framework are Timer and TimerTask.   The answer is false: Timer() creates a new timer whose task-execution thread runs as a nondaemon thread.   In fixed-delay execution, each execution is scheduled relative to the actual execution time of the previous execution. When an execution is delayed for any reason (such as garbage collection), subsequent executions are also delayed.   You call the schedule() methods to schedule a task for fixed-delay execution.   In fixed-rate execution, each execution is scheduled relative to the scheduled execution time of the initial execution. When an execution is delayed for any reason (such as garbage collection), two or more executions will occur in rapid succession to “catch up.”   The difference between Timer’s cancel() method and TimerTask’s cancel() method is as follows: Timer’s cancel() method terminates the timer, discarding any currently scheduled timer tasks. In contrast, TimerTask’s cancel() method cancels the invoking timer task only.   Listing A-4 presents the BackAndForth application that was called for in Chapter 4.   Listing A-4. Repeatedly Moving an Asterisk Back and Forth via a Timer

import java.util.Timer;

import java.util.TimerTask;

public class BackAndForth

{

static enum Direction { FORWARDS, BACKWARDS }

public static void main(String[] args)

{

TimerTask task = new TimerTask()

{

final static int MAXSTEPS = 20;

volatile Direction direction = Direction.FORWARDS;

volatile int steps = 0;

@Override

public void run()

{

switch (direction)

{

case FORWARDS : System.out.print("\b ");

System.out.print("*");

break;

case BACKWARDS: System.out.print("\b ");

System.out.print("\b\b*");

}

if (++steps == MAXSTEPS)

{

direction =

(direction == Direction.FORWARDS)

? Direction.BACKWARDS

: Direction.FORWARDS;

steps = 0;

}

}

};

Timer timer = new Timer();

timer.schedule(task, 0, 100);

}

}

第五章:并发工具和执行器

The concurrency utilities are a framework of classes and interfaces that overcome problems with Java’s low-level thread capabilities. Specifically, low-level concurrency primitives such as synchronized and wait()/notify() are often hard to use correctly, too much reliance on the synchronized primitive can lead to performance issues, which affect an application’s scalability, and higher-level constructs such as thread pools and semaphores aren’t included with Java’s low-level thread capabilities.   The packages in which the concurrency utilities types are stored are java.util.concurrent, java.util.concurrent.atomic, and java.util.concurrent.locks.   A task is an object whose class implements the Runnable interface (a runnable task) or the Callable interface (a callable task).   An executor is an object whose class directly or indirectly implements the Executor interface, which decouples task submission from task-execution mechanics.   The Executor interface focuses exclusively on Runnable, which means that there’s no convenient way for a runnable task to return a value to its caller (because Runnable’s run() method doesn’t return a value); Executor doesn’t provide a way to track the progress of executing runnable tasks, cancel an executing runnable task, or determine when the runnable task finishes execution; Executor cannot execute a collection of runnable tasks; and Executor doesn’t provide a way for an application to shut down an executor (much less to properly shut down an executor).   Executor’s limitations are overcome by providing the ExecutorService interface.   The differences existing between Runnable’s run() method and Callable’s call() method are as follows: run() cannot return a value, whereas call() can return a value; and run() cannot throw checked exceptions, whereas call() can throw checked exceptions.   The answer is false: you can throw checked and unchecked exceptions from Callable’s call() method but can only throw unchecked exceptions from Runnable’s run() method.   A future is an object whose class implements the Future interface. It represents an asynchronous computation and provides methods for canceling a task, for returning a task’s value, and for determining whether or not the task has finished.   The Executors class’s newFixedThreadPool() method creates a thread pool that reuses a fixed number of threads operating off of a shared unbounded queue. At most, nThreads threads are actively processing tasks. If additional tasks are submitted when all threads are active, they wait in the queue for an available thread. If any thread terminates because of a failure during execution before the executor shuts down, a new thread will take its place when needed to execute subsequent tasks. The threads in the pool will exist until the executor is explicitly shut down.   Listing A-5 presents the CountingThreads application that was called for in Chapter 5.   Listing A-5. Executor-Based Counting Threads

import java.util.concurrent.Executors;

import java.util.concurrent.ExecutorService;

public class CountingThreads

{

public static void main(String[] args)

{

Runnable r = new Runnable()

{

@Override

public void run()

{

String name = Thread.currentThread().getName();

int count = 0;

while (true)

System.out.println(name + ": " + count++);

}

};

ExecutorService es = Executors.newFixedThreadPool(2);

es.submit(r);

es.submit(r);

}

}

Listing A-6 presents the CountingThreads application with custom-named threads that was called for in Chapter 5.   Listing A-6. Executor-Based Counting Threads A and B

import java.util.concurrent.Executors;

import java.util.concurrent.ExecutorService;

import java.util.concurrent.ThreadFactory;

public class CountingThreads

{

public static void main(String[] args)

{

Runnable r = new Runnable()

{

@Override

public void run()

{

String name = Thread.currentThread().getName();

int count = 0;

while (true)

System.out.println(name + ": " + count++);

}

};

ExecutorService es =

Executors.newSingleThreadExecutor(new NamedThread("A"));

es.submit(r);

es = Executors.newSingleThreadExecutor(new NamedThread("B"));

es.submit(r);

}

}

class NamedThread implements ThreadFactory

{

private volatile String name; // newThread() could be called by a

// different thread

NamedThread(String name)

{

this.name = name;

}

@Override

public Thread newThread(Runnable r)

{

return new Thread(r, name);

}

}

第六章:同步器

A synchronizer is a class that facilitates a common form of synchronization.   A countdown latch causes one or more threads to wait at a “gate” until another thread opens this gate, at which point these other threads can continue. It consists of a count and operations for “causing a thread to wait until the count reaches zero” and “decrementing the count”.   When CountDownLatch’s void countDown() method is called and the count reaches zero, all waiting threads are released.   A cyclic barrier lets a set of threads wait for each other to reach a common barrier point. The barrier is cyclic because it can be reused after the waiting threads are released. This synchronizer is useful in applications involving a fixed-size party of threads that must occasionally wait for each other.   The answer is false: CyclicBarrier’s int await() method throws BrokenBarrierException when the barrier is reset while any thread is waiting or when the barrier is broken when await() is invoked.   An exchanger provides a synchronization point where threads can swap objects. Each thread presents some object on entry to the exchanger’s exchange() method, matches with a partner thread, and receives its partner’s object on return.   Exchanger’s V exchange(V x) method waits for another thread to arrive at this exchange point (unless the calling thread is interrupted), and then transfers the given object to it, receiving the other thread’s object in return.   A semaphore maintains a set of permits for restricting the number of threads that can access a limited resource. A thread attempting to acquire a permit when no permits are available blocks until some other thread releases a permit.   The two kinds of semaphores are counting semaphores (the current values can be incremented past 1) and binary semaphores or mutexs (the current values can be only 0 or 1).   A phaser is a more flexible cyclic barrier. Like a cyclic barrier, a phaser lets a group of threads wait on a barrier; these threads continue after the last thread arrives. A phaser also offers the equivalent of a barrier action. Unlike a cyclic barrier, which coordinates a fixed number of threads, a phaser can coordinate a variable number of threads, which can register at any time. To implement this capability, a phaser uses phases (current states) and phase numbers (current state identifiers).   Phaser’s int register() method returns the phase number to classify the arrival. If this value is negative, this phaser has terminated, in which case registration has no effect. This number is known as the arrival phase number.   Listing A-7 presents the PC application that was called for in Chapter 6.   Listing A-7. Semaphore-Based Producer and Consumer

import java.util.concurrent.Semaphore;

public class PC

{

public static void main(String[] args)

{

Shared s = new Shared();

Semaphore semCon = new Semaphore(0);

Semaphore semPro = new Semaphore(1);

new Producer(s, semPro, semCon).start();

new Consumer(s, semPro, semCon).start();

}

}

class Shared

{

private char c;

void setSharedChar(char c)

{

this.c = c;

}

char getSharedChar()

{

return c;

}

}

class Producer extends Thread

{

private final Shared s;

private final Semaphore semPro, semCon;

Producer(Shared s, Semaphore semPro, Semaphore semCon)

{

this.s = s;

this.semPro = semPro;

this.semCon = semCon;

}

@Override

public void run()

{

for (char ch = 'A'; ch <= 'Z'; ch++)

{

try

{

semPro.acquire();

}

catch (InterruptedException ie)

{

}

s.setSharedChar(ch);

System.out.println(ch + " produced by producer.");

semCon.release();

}

}

}

class Consumer extends Thread

{

private final Shared s;

private final Semaphore semPro, semCon;

Consumer(Shared s, Semaphore semPro, Semaphore semCon)

{

this.s = s;

this.semPro = semPro;

this.semCon = semCon;

}

@Override

public void run()

{

char ch;

do

{

try

{

semCon.acquire();

}

catch (InterruptedException ie)

{

}

ch = s.getSharedChar();

System.out.println(ch + " consumed by consumer.");

semPro.release();

}

while (ch != 'Z');

}

}

第七章:锁紧框架

A lock is an instance of a class that implements the Lock interface, which provides more extensive locking operations than can be achieved via the synchronized reserved word. Lock also supports a wait/notification mechanism through associated Condition objects.   The biggest advantage that Lock objects hold over the intrinsic locks that are obtained when threads enter critical sections (controlled via the synchronized reserved word) is their ability to back out of an attempt to acquire a lock.   The answer is true: ReentrantLock’s unlock() method throws IllegalMonitorStateException when the calling thread doesn’t hold the lock.   You obtain a Condition instance for use with a particular Lock instance by invoking Lock’s Condition newCondition() method.   The answer is false: ReentrantReadWriteLock() creates an instance of ReentrantReadWriteLock without a fair ordering policy.   Introduced by JDK 8, StampedLock is a capability-based lock with three modes for controlling read/write access. It differentiates between exclusive and nonexclusive locks in a manner that’s similar to ReentrantReadWriteLock, but also allows for optimistic reads, which ReentrantReadWriteLock doesn’t support.   The purpose of LockSupport is to provide basic thread-blocking primitives for creating locks and other synchronization classes.   Listing A-8 presents the ID class that was called for in Chapter 7.   Listing A-8. ReentrantLock-Based ID Generator

import java.util.concurrent.locks.ReentrantLock;

public class ID

{

private static int counter; // initialized to 0 by default

private final static ReentrantLock lock = new ReentrantLock();

public static int getID()

{

lock.lock();

try

{

int temp = counter + 1;

try

{

Thread.sleep(1);

}

catch (InterruptedException ie)

{

}

return counter = temp;

}

finally

{

lock.unlock();

}

}

}

第八章:额外的并发工具

The two problems with thread-safe collections are the possibility of thrown ConcurrentModificationException objects and poor performance. It’s necessary to acquire a lock before iterating over a collection that might be modified by another thread during the iteration. If a lock isn’t acquired and the collection is modified, it’s highly likely that ConcurrentModificationException will be thrown. Also, performance suffers when synchronized collections are accessed frequently from multiple threads.   A concurrent collection is a concurrency performant and highly-scalable collection-oriented type that is stored in the java.util.concurrent package.   A weakly-consistent iterator is an iterator with the following properties:

  • 迭代开始后移除但尚未通过迭代器的next()方法返回的元素不会被返回。
  • 迭代开始后添加的元素可能会返回,也可能不会返回。
  • 在集合的迭代过程中,任何元素都不会返回多次,无论在迭代过程中对集合做了什么更改。

BlockingQueue is a subinterface of java.util.Queue that also supports blocking operations that wait for the queue to become nonempty before retrieving an element and wait for space to become available in the queue before storing an element.   ConcurrentMap is a subinterface of java.util.Map that declares additional indivisible putIfAbsent(), remove(), and replace() methods.   ArrayBlockingQueue is a bounded blocking queue backed by an array. LinkedBlockingQueue is an optionally-bounded blocking queue based on linked nodes.   The answer is true: the concurrency-oriented collection types are part of the Collections Framework.   ConcurrentHashMap behaves like HashMap but has been designed to work in multithreaded contexts without the need for explicit synchronization.   Using ConcurrentHashMap, you would call its putIfAbsent() method to check if a map contains a specific value and, when this value is absent, put this value into the map without relying on external synchronization.   An atomic variable is an instance of a class that encapsulates a single variable and supports lock-free, thread-safe operations on that variable, for example, AtomicInteger.   The AtomicIntegerArray class describes an int array whose elements may be updated atomically.   The answer is false: volatile doesn’t support atomic read-modify-write sequences.   The compare-and-swap instruction is responsible for the performance gains offered by the concurrency utilities.   The Fork/Join Framework consists of a special executor service and thread pool. The executor service makes a task available to the framework, and this task is broken down into smaller tasks that are forked (executed by different threads) from the pool. A task waits until it’s joined (its subtasks finish).   The main types that comprise the Fork/Join Framework are the java.util.concurrent package’s ForkJoinPool, ForkJoinTask, ForkJoinWorkerThread, RecursiveAction, RecursiveTask, and CountedCompleter classes.   To accomplish meaningful work via RecursiveAction, you would override its void compute() method.   A completion service is an implementation of the CompletionService<V> interface that decouples the production of new asynchronous tasks (a producer) from the consumption of the results of completed tasks (a consumer). V is the type of a task result.   You use a completion service as follows: Submit a task for execution (via a worker thread) by calling one of CompletionService<V>’s submit() methods. Each method returns a Future<V> instance that represents the pending completion of the task. You can then call a poll() method to poll for the task’s completion or call the blocking take() method. A consumer takes a completed task by calling the take() method. This method blocks until a task has completed. It then returns a Future<V> object that represents the completed task. You would call Future<V>’s get() method to obtain this result.   You execute tasks via a completion service by working with the ExecutorCompletionService<V> class, which implements CompletionService<V>, and which supports task execution via a provided executor.   The atomic variable equivalent of int total = ++counter; is as follows: AtomicInteger counter = new AtomicInteger(0); int total = counter.incrementAndGet(); The atomic variable equivalent of int total = counter--; is as follows: AtomicInteger counter = new AtomicInteger(0); int total = counter.getAndDecrement();

十、Swing 线程

Electronic supplementary material The online version of this chapter (doi:10.​1007/​978-1-4842-1700-9_​10) contains supplementary material, which is available to authorized users.

Swing 是一个独立于平台的、基于模型-视图-控制器GUI 工具包,用于创建 Java 应用的图形前端。在本附录中,我首先探索 Swing 的线程架构,然后探索 Swing APIs,以避免在图形环境中使用额外线程时出现问题。最后,我将展示一个基于 Swing 的幻灯片放映应用,作为本附录内容的一个重要示例,并作为本书的一个有趣结尾。

Note

我假设您对 Swing APIs 以及 Swing 应用的架构有一些经验。

单线程编程模型

Swing 遵循一个单线程的 ?? 编程模型。它被设计成单线程而不是多线程的,因为多线程图形工具包的设计经验表明,它们不可避免地会导致死锁和竞争情况。要了解关于这些问题的更多信息,请查看“为什么 GUI 是单线程的?”博文( http://codeidol.com/java/java-concurrency/GUI-Applications/Why-are-GUIs-Single-threaded/ )。

用于渲染图形和处理事件的线程被称为事件调度线程(EDT)。EDT 处理来自底层抽象窗口工具包的事件队列的事件,并调用 GUI 组件(如按钮)事件侦听器,后者处理该线程上的事件。组件甚至在 EDT 上重绘自己(响应导致paintComponent()paintBorder()paintChildren()方法调用的paint()方法调用)。

注意代码如何与 EDT 交互,以确保 Swing 应用正常工作。有两条规则需要记住:

  • 总是在东部时间创建 Swing GUIs。
  • 不要延迟美国东部时间。

Swing 是单线程的一个结果是,您必须只在 EDT 上创建 Swing 应用的 GUI。在任何其他线程上创建这个 GUI 都是不正确的,包括运行 Java 应用的main()方法的默认主线程。

大多数 Swing 对象(比如javax.swing.JFrame对象,它用菜单栏和边框描述 GUI 顶层“框架”窗口)都不是线程安全的。从多个线程访问这些对象存在线程干扰和/或内存不一致错误的风险:

  • 线程干扰:两个线程在处理相同数据时执行两种不同的操作。例如,一个线程读取一个长整数计数器变量,而另一个线程更新这个变量。因为在 32 位机器上读取或写入一个长整数需要两次读/写访问,所以有可能读取线程读取该变量当前值的一部分,然后写入线程更新该变量,然后读取线程读取该变量的其余部分。结果是读取线程具有不正确的值。
  • 内存不一致错误:在不同处理器或处理器内核上运行的两个或多个线程对相同数据的视图不一致。例如,一个处理器或内核上的写线程更新一个counter变量,然后另一个处理器或内核上的读线程读取这个变量。但是,因为使用了缓存机制来提高性能,所以两个线程都不会访问主内存中变量的单个副本。相反,每个线程从本地内存(缓存)中访问自己的变量副本。

当 GUI 不是在 EDT 上创建时,这些问题是如何发生的?约翰·祖科夫斯基在他题为“ Swing 线程和事件调度线程”(www.javaworld.com/article/2077754/core-java/swing-threading-and-the-event-dispatch-thread.html)的 JavaWorld 文章中演示了一个场景。

祖科夫斯基展示了一个向框架窗口容器组件添加容器监听器的例子。在框架中添加或移除组件时,将调用侦听器方法。他演示了在默认主线程上实现框架窗口之前,在监听器方法中运行 EDT 代码。

Note

实现意味着组件的paint()方法已经被调用或者可能被调用。通过在这个容器组件上调用setVisible(true)show()pack()中的一个来实现框架窗口。框架窗口实现后,它包含的所有组件也实现了。实现组件的另一种方式是将它添加到已经实现的容器中。

在 EDT 开始在侦听器方法中运行之后,并且在默认主线程继续初始化 GUI 的同时,组件可以由默认主线程创建并由 EDT 访问。EDT 可能试图在这些组件存在之前访问它们;这样做可能会导致应用崩溃。

即使默认主线程在 EDT 从 listener 方法访问组件之前创建了这些组件,EDT 也可能会有不一致的视图(因为缓存),并且无法访问对新组件的引用。应用崩溃(可能是抛出的java.lang.NullPointerException对象)很可能会发生。

清单 B-1 向ViewPage展示源代码,这是一个用于查看网页 HTML 的 Swing 应用。这种应用存在两个问题。

Listing B-1. A Problematic Web Page HTMLViewer Swing Application

import java.awt.BorderLayout;

import java.awt.Dimension;

import java.awt.EventQueue;

import java.awt.event.ActionEvent;

import java.awt.event.ActionListener;

import java.io.InputStream;

import java.io.IOException;

import java.net.URL;

import javax.swing.JFrame;

import javax.swing.JLabel;

import javax.swing.JPanel;

import javax.swing.JScrollPane;

import javax.swing.JTextArea;

import javax.swing.JTextField;

public class ViewPage

{

public static void main(String[] args)

{

final JFrame frame = new JFrame("View Page");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

JPanel panel = new JPanel();

panel.add(new JLabel("Enter URL"));

final JTextField txtURL = new JTextField(40);

panel.add(txtURL);

frame.getContentPane().add(panel, BorderLayout.NORTH);

final JTextArea txtHTML = new JTextArea(10, 40);

frame.getContentPane().add(new JScrollPane (txtHTML),

BorderLayout.CENTER);

ActionListener al = (ae) ->

{

InputStream is = null;

try

{

URL url = new URL(txtURL.getText());

is = url.openStream();

StringBuilder sb = new StringBuilder();

int b;

while ((b = is.read()) != -1)

sb.append((char) b);

txtHTML.setText(sb.toString());

}

catch (IOException ioe)

{

txtHTML.setText(ioe.getMessage());

}

finally

{

txtHTML.setCaretPosition(0);

if (is != null)

try

{

is.close();

}

catch (IOException ioe)

{

}

}

};

txtURL.addActionListener(al);

frame.pack();

frame.setVisible(true);

}

}

清单 B-1 的main()方法创建了一个 GUI,由一个用于输入网页 URL 的文本字段和一个用于显示页面 HTML 的可滚动文本区域组成。输入 URL 后按 Enter 键会导致ViewPage获取并显示 HTML。

编译清单 B-1 如下:

javac ViewPage.java

运行生成的应用,如下所示:

java ViewPage

您应该看到如图 B-1 所示的 GUI(填充了一个示例 URL 和部分结果网页的 HTML)。

A978-1-4842-1700-9_10_Fig1_HTML.jpg

图 B-1。

Entering a URL in a text field and viewing web page output in a scrollable text area

这个应用的第一个问题是 GUI 是在默认的主线程上创建的,而不是在 EDT 上。虽然在运行ViewPage时可能不会遇到问题,但是存在潜在的线程干扰和内存不一致问题。

这个应用的第二个问题是,EDT 运行动作监听器来响应文本字段上的 Enter 键,它被打开 URL 的输入流并将其内容读入字符串生成器的代码所延迟。在此期间,GUI 没有响应。

线程 API

Swing 提供的 API 克服了 EDT 的上述问题。在本节中,我将向您介绍这些 API。我还将向您介绍 Swing 版本的计时器,它与我在第四章中介绍的计时器框架有很大不同。

SwingUtilities 和 EventQueue

javax.swing. SwingUtilities类提供了一组在 Swing 上下文中有用的static方法。其中三种方法对于使用 EDT 和避免前面的问题特别有用:

  • void invokeAndWait(Runnable doRun):使doRun.run()在 EDT 上同步执行。这个调用会一直阻塞,直到所有未决事件都被处理完,然后doRun.run()返回。在等待 EDT 完成执行doRun.run()的过程中,该方法中断时invokeAndWait()抛出java.lang.InterruptedException。当应用线程需要从除 EDT 之外的任何线程更新 GUI 时,应该使用从doRun.run(). invokeAndWait()抛出异常时抛出java.lang.reflect.InvocationTargetException。它不应该从美国东部时间调用。
  • void invokeLater( Runnable doRun):导致doRun.run()在 EDT 上异步执行。这发生在所有未决事件处理完毕之后。当应用线程需要更新 GUI 时,应该使用invokeLater()。可以从任何线程调用它。
  • boolean isEventDispatchThread():当调用线程为 EDT 时,返回true;否则,返回false

invokeAndWait()invokeLater()isEventDispatchThread()方法是调用java.awt.EventQueue类中等效方法的包装器。虽然您可以用SwingUtilities作为这些方法的前缀,但我使用EventQueue作为前缀(出于习惯)。

通常使用invokeLater()根据以下模式构建 Swing GUI:

Runnable r = ... // ... refers to the runnable’s anonymous class or lambda

EventQueue.invokeLater(r);

清单 B-2 给出了第二个版本ViewPage的源代码,该版本使用invokeLater()在 EDT 上构建 Swing GUI。

Listing B-2. Constructing the HTML Viewer Swing Application GUI on the EDT

import java.awt.BorderLayout;

import java.awt.Dimension;

import java.awt.EventQueue;

import java.awt.event.ActionEvent;

import java.awt.event.ActionListener;

import java.io.InputStream;

import java.io.IOException;

import java.net.URL;

import javax.swing.JFrame;

import javax.swing.JLabel;

import javax.swing.JPanel;

import javax.swing.JScrollPane;

import javax.swing.JTextArea;

import javax.swing.JTextField;

public class ViewPage

{

public static void main(String[] args)

{

Runnable r = () ->

{

final JFrame frame = new JFrame("View Page");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

JPanel panel = new JPanel();

panel.add(new JLabel("Enter URL"));

final JTextField txtURL = new JTextField(40);

panel.add(txtURL);

frame.getContentPane().add(panel, BorderLayout.NORTH);

final JTextArea txtHTML = new JTextArea(10, 40);

frame.getContentPane().add(new JScrollPane (txtHTML),

BorderLayout.CENTER);

ActionListener al = (ae) ->

{

InputStream is = null;

try

{

URL url = new URL(txtURL.getText());

is = url.openStream();

StringBuilder sb = new StringBuilder();

int b;

while ((b = is.read()) != -1)

sb.append((char) b);

txtHTML.setText(sb.toString());

}

catch (IOException ioe)

{

txtHTML.setText(ioe.getMessage());

}

finally

{

txtHTML.setCaretPosition(0);

if (is != null)

try

{

is.close();

}

catch (IOException ioe)

{

}

}

};

txtURL.addActionListener(al);

frame.pack();

frame.setVisible(true);

};

EventQueue.invokeLater(r) ;

}

}

列表 B-2 解决了一个问题,但是我们仍然必须防止东部夏令时被延迟。我们可以通过创建一个工作线程来读取页面,并使用invokeAndWait()在 EDT 上用页面内容更新可滚动文本区域来解决这个问题。查看清单 B-3 。

Listing B-3. Constructing the HTML Viewer Swing Application GUI on a Non-Delayed EDT

import java.awt.BorderLayout;

import java.awt.Dimension;

import java.awt.EventQueue;

import java.awt.event.ActionEvent;

import java.awt.event.ActionListener;

import java.io.InputStream;

import java.io.IOException;

import java.lang.reflect.InvocationTargetException;

import java.net.URL;

import javax.swing.JFrame;

import javax.swing.JLabel;

import javax.swing.JPanel;

import javax.swing.JScrollPane;

import javax.swing.JTextArea;

import javax.swing.JTextField;

public class ViewPage

{

public static void main(String[] args)

{

Runnable r = () ->

{

final JFrame frame = new JFrame("View Page");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

JPanel panel = new JPanel();

panel.add(new JLabel("Enter URL"));

final JTextField txtURL = new JTextField(40);

panel.add(txtURL);

frame.getContentPane().add(panel, BorderLayout.NORTH);

final JTextArea txtHTML = new JTextArea(10, 40);

frame.getContentPane().add(new JScrollPane (txtHTML),

BorderLayout.CENTER);

ActionListener al = (ae) ->

{

txtURL.setEnabled(false);

Runnable worker = () ->

{

InputStream is = null;

try

{

URL url = new URL(txtURL.getText());

is = url.openStream();

final StringBuilder sb = new StringBuilder();

int b;

while ((b = is.read()) != -1)

sb.append((char) b);

Runnable r1 = () ->

{

txtHTML.setText(sb.toString());

txtURL.setEnabled(true);

};

try

{

EventQueue.invokeAndWait(r1) ;

}

catch (InterruptedException ie)

{

}

catch (InvocationTargetException ite)

{

}

}

catch (final IOException ioe)

{

Runnable r1 = () ->

{

txtHTML.setText(ioe.getMessage());

txtURL.setEnabled(true);

};

try

{

EventQueue.invokeAndWait(r1) ;

}

catch (InterruptedException ie)

{

}

catch (InvocationTargetException ite)

{

}

}

finally

{

Runnable r1 = () ->

{

txtHTML.setCaretPosition(0);

txtURL.setEnabled(true);

};

try

{

EventQueue.invokeAndWait(r1) ;

}

catch (InterruptedException ie)

{

}

catch (InvocationTargetException ite)

{

}

if (is != null)

try

{

is.close();

}

catch (IOException ioe)

{

}

}

};

new Thread(worker).start();

};

txtURL.addActionListener(al);

frame.pack();

frame.setVisible(true);

};

EventQueue.invokeLater(r);

}

}

我选择了在获取页面时禁用文本字段以进一步输入,并在之后启用它。您仍然可以随时关闭 GUI。

虽然清单 B-3 解决了 GUI 无响应的问题,但是这个解决方案有些冗长。幸运的是,有一个替代的解决方案。

摇摆工人

Swing 提供了javax.swing. SwingWorker类来适应长时间运行的任务(比如读取 URL 内容),减少了冗长性。您必须继承这个abstract类,并覆盖一个或多个方法来完成有用的工作。

SwingWorker的通用类型是SwingWorker<T, V>。参数TV分别标识最终和中间任务结果类型。

您重写了protected abstract T doInBackground()方法,在一个工作线程上执行一个长时间运行的任务,并返回一个类型为T的结果(没有结果时,Void是返回类型)。当这个方法完成时,在 EDT 上调用protected void done()方法。默认情况下,此方法不执行任何操作。但是,您可以覆盖done()来安全地更新 GUI。

当任务运行时,您可以通过调用protected void publish(V... chunks)方法定期向 EDT 发布结果。这些结果由覆盖的protected void process(List<V> chunks)方法检索,该方法的代码在 EDT 上运行。如果没有要处理的中间结果,可以为V指定Void(避免使用publish()process()方法)。

SwingWorker提供了另外两种你需要了解的方法。首先,void execute()调度调用的SwingWorker对象在一个工作线程上执行。第二, T get()等待doInBackground()完成,然后返回最终结果。

Note

当试图检索从doInBackground()返回的对象时发生异常,则SwingWorkerget()方法抛出一个java.util.concurrent.ExecutionException类的实例。这也能扔了InterruptedException

清单 B-4 将源代码呈现给最终的ViewPage应用,该应用使用SwingWorker而不是invokeAndWait()

Listing B-4. Constructing the HTML Viewer Swing Application GUI on a Non-Delayed EDT, Revisited

import java.awt.BorderLayout;

import java.awt.Dimension;

import java.awt.EventQueue;

import java.awt.event.ActionEvent;

import java.awt.event.ActionListener;

import java.io.InputStream;

import java.io.IOException;

import java.net.URL;

import java.util.concurrent.ExecutionException;

import javax.swing.JFrame;

import javax.swing.JLabel;

import javax.swing.JPanel;

import javax.swing.JScrollPane;

import javax.swing.JTextArea;

import javax.swing.JTextField;

import javax.swing.SwingWorker;

public class ViewPage

{

public static void main(String[] args)

{

Runnable r = () ->

{

final JFrame frame = new JFrame("View Page");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

JPanel panel = new JPanel();

panel.add(new JLabel("Enter URL"));

final JTextField txtURL = new JTextField(40);

panel.add(txtURL);

frame.getContentPane().add(panel, BorderLayout.NORTH);

final JTextArea txtHTML = new JTextArea(10, 40);

frame.getContentPane().add(new JScrollPane (txtHTML),

BorderLayout.CENTER);

ActionListener al = (ae) ->

{

txtURL.setEnabled(false);

class GetHTML extends SwingWorker<StringBuilder, Void>

{

private final String url;

GetHTML(String url)

{

this.url = url;

}

@Override

public StringBuilder doInBackground()

{

StringBuilder sb = new StringBuilder();

InputStream is = null;

try

{

URL url = new URL(this.url);

is = url.openStream();

int b;

while ((b = is.read()) != -1)

sb.append((char) b);

return sb;

}

catch (IOException ioe)

{

sb.setLength(0);

sb.append(ioe.getMessage());

return sb;

}

finally

{

if (is != null)

try

{

is.close();

}

catch (IOException ioe)

{

}

}

}

@Override

public void done()

{

try

{

StringBuilder sb = get();

txtHTML.setText(sb.toString());

txtHTML.setCaretPosition(0);

}

catch (ExecutionException ee)

{

txtHTML.setText(ee.getMessage());

}

catch (InterruptedException ie)

{

txtHTML.setText("Interrupted");

}

txtURL.setEnabled(true);

}

}

new GetHTML(txtURL.getText()).execute();

};

txtURL.addActionListener(al);

frame.pack();

frame.setVisible(true);

};

EventQueue.invokeLater(r);

}

}

这个ViewPage的最终版本依赖于GetHTML,一个在动作监听器 lambda 主体中声明的本地SwingWorker子类,在一个工作线程上读取网页(保持用户界面响应),并在 EDT 上用 HTML 更新用户界面(Swing 代码必须在 EDT 上执行)。

当 lambda 运行时(用户在文本字段中输入 URL 后按 Enter),它用文本字段的文本实例化GetHTML(文本字段不能从工作线程访问,因为 Swing 是单线程的)并调用SwingWorkerexecute()方法。

execute()导致GetHTML的覆盖doInBackground()方法在一个工作线程上被调用,该线程用 HTML/错误文本填充一个java.lang.StringBuilder对象并返回该对象。然后 EDT 调用覆盖的done()方法,该方法通过调用SwingWorkerget()方法来访问StringBuilder对象,并用这些内容填充文本区域。

计时器

Swing 提供了javax.swing.Timer类(作为定时器框架的简化版本——参见第四章)来定期在 EDT 上执行 Swing 代码。在最初的延迟之后,它向注册的侦听器触发一个操作事件,此后,通过事件之间的延迟重复触发事件。

调用Timer(int delay,ActionListenerlistener)构造函数创建一个计时器,以初始和事件间delay为单位(以毫秒为单位),以初始动作listener(可能是null)作为每delay毫秒发送的事件的目标。

delay参数值用作初始延迟和事件间延迟。您也可以通过调用void setInitialDelay(int initialDelay)void setDelay(int delay)方法来分别设置这些值。

Note

用一个false参数调用Timervoid setRepeats(boolean flag)方法,指示计时器只发送一个动作事件。

调用void addActionListener( ActionListener listener)添加另一个动作listener,调用void removeActionListener(ActionListener listener)删除之前注册的动作listener。调用 ActionListener [] getActionListeners()获取所有注册的监听器。

新创建的计时器处于停止状态。要启动计时器,调用它的void start()方法。相反,您可以调用void stop()来终止计时器。您可能还想调用boolean isRunning()来确定计时器是否正在运行。

清单 B-5 将源代码呈现给一个Counter应用,该应用创建一个计时器,通过标签持续显示运行计数。

Listing B-5. Starting and Stopping a Count

import java.awt.EventQueue;

import java.awt.FlowLayout;

import java.awt.event.ActionListener;

import javax.swing.JButton;

import javax.swing.JFrame;

import javax.swing.JLabel;

import javax.swing.JPanel;

import javax.swing.Timer;

public class Counter extends JFrame

{

int count;

public Counter(String title)

{

super(title);

setDefaultCloseOperation(EXIT_ON_CLOSE);

JPanel pnl = new JPanel();

((FlowLayout) pnl.getLayout()).setHgap(20);

final JLabel lblCount = new JLabel("");

pnl.add(lblCount);

final JButton btnStartStop = new JButton("Start");

ActionListener al = (ae) ->

{

++count;

lblCount.setText(count + " ");

};

final Timer timer = new Timer(30, al);

al = (ae) ->

{

if (btnStartStop.getText().equals("Start"))

{

btnStartStop.setText("Stop");

timer.start();

}

else

{

btnStartStop.setText("Start");

timer.stop();

}

};

btnStartStop.addActionListener(al);

pnl.add(btnStartStop);

setContentPane(pnl);

setSize(300, 80);

setVisible(true);

}

public static void main(String[] args)

{

EventQueue.invokeLater(() -> new Counter("Counter"));

}

}

清单 B-5 的main()方法创建了一个由标签和开始/停止按钮组成的 GUI。标签显示count变量的当前值,按钮文本在开始和停止之间交替。当按钮显示“开始”时,单击该按钮会启动计时器;当按钮显示停止时,单击该按钮会使计时器停止。计时器动作监听器递增count变量,并通过标签显示其值。追加到count的空格字符将表达式转换成一个字符串,并确保其最右边的像素不会被截断。

编译清单 B-5 如下:

javac Counter.java

运行生成的应用,如下所示:

java Counter

图 B-2 显示了最终的 GUI。

A978-1-4842-1700-9_10_Fig2_HTML.jpg

图 B-2。

The panel’s components are horizontally centered

基于计时器的幻灯片放映

幻灯片放映是在投影屏幕上呈现静止图像,通常是按照预先安排的顺序。在被下一个图像替换之前,每个图像通常显示至少几秒钟。

幻灯片放映包括投影仪、屏幕和幻灯片。投影仪包含要投影的幻灯片,屏幕显示投影的幻灯片图像,幻灯片包含图像和其他属性(如文本标题)。

我创建了一个名为SlideShow的 Java 应用,可以让你放映任意的幻灯片。清单 B-6 展示了它的源代码。

Listing B-6. Describing a Timer-Based Slide Show

import java.awt.AlphaComposite;

import java.awt.Color;

import java.awt.Dimension;

import java.awt.EventQueue;

import java.awt.Font;

import java.awt.FontMetrics;

import java.awt.Graphics;

import java.awt.Graphics2D;

import java.awt.RenderingHints;

import java.awt.event.ActionListener;

import java.awt.event.WindowAdapter;

import java.awt.event.WindowEvent;

import java.awt.image.BufferedImage;

import java.io.BufferedReader;

import java.io.File;

import java.io.FileReader;

import java.io.IOException;

import java.util.ArrayList;

import java.util.List;

import javax.imageio.ImageIO;

import javax.swing.JComponent;

import javax.swing.JFrame;

import javax.swing.Timer;

class Projector

{

private volatile List<Slide> slides;

private Screen s;

private Timer t;

private volatile int slideIndexC, slideIndexN;

private volatile float weight;

Projector(List<Slide> slides, Screen s)

{

this.slides = slides;

this.s = s;

t = new Timer(1500, null);

t.setDelay(3000);

slideIndexC = 0;

slideIndexN = 1;

}

void start()

{

s.drawImage(Slide.blend(slides.get(0), null, 1.0f));

ActionListener al = (ae) ->

{

weight = 1.0f;

Timer t2 = new Timer(0, null);

t2.setDelay(10);

ActionListener al2 = (ae2) ->

{

Slide slideC = slides.get(slideIndexC);

Slide slideN = slides.get(slideIndexN);

BufferedImage bi = Slide.blend(slideC, slideN, weight);

s.drawImage(bi);

weight -= 0.01f;

if (weight <= 0.0f)

{

t2.stop();

slideIndexC = slideIndexN;

slideIndexN = (slideIndexN + 1) % slides.size();

}

};

t2.addActionListener(al2);

t2.start();

};

t.addActionListener(al);

t.start();

}

void stop()

{

t.stop();

}

}

class Screen extends JComponent

{

private Dimension d;

private BufferedImage bi;

private String text;

Screen(int width, int height)

{

d = new Dimension(width, height);

}

void drawImage(BufferedImage bi)

{

this.bi = bi;

repaint();

}

@Override

public Dimension getPreferredSize()

{

return d;

}

@Override

public void paint(Graphics g)

{

int w = getWidth();

int h = getHeight();

g.drawImage(bi, Slide.WIDTH <= w ? (w - Slide.WIDTH) / 2 : 0,

Slide.HEIGHT <= h ? (h - Slide.HEIGHT) / 2 : 0, null);

}

}

class Slide

{

static int WIDTH, HEIGHT;

private static int TEXTBOX_WIDTH, TEXTBOX_HEIGHT, TEXTBOX_X, TEXTBOX_Y;

private BufferedImage bi;

private String text;

private static Font font;

private Slide(BufferedImage bi, String text)

{

this.bi = bi;

this.text = text;

font = new Font("Arial", Font.BOLD, 20);

}

static BufferedImage blend(Slide slide1, Slide slide2, float weight)

{

BufferedImage bi1 = slide1.getBufferedImage();

BufferedImage bi2 = (slide2 != null)

? slide2.getBufferedImage()

: new BufferedImage(Slide.WIDTH, Slide.HEIGHT,

BufferedImage.TYPE_INT_RGB);

BufferedImage bi3 = new BufferedImage(Slide.WIDTH, Slide.HEIGHT,

BufferedImage.TYPE_INT_RGB);

Graphics2D g2d = bi3.createGraphics();

g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER,

weight));

g2d.drawImage(bi1, 0, 0, null);

g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER,

1.0f - weight));

g2d.drawImage(bi2, 0, 0, null);

g2d.setColor(Color.BLACK);

g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING,

RenderingHints.VALUE_ANTIALIAS_ON);

g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER,

0.5f));

g2d.fillRect(TEXTBOX_X, TEXTBOX_Y, TEXTBOX_WIDTH, TEXTBOX_HEIGHT);

g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER,

weight));

g2d.setColor(Color.WHITE);

g2d.setFont(font);

FontMetrics fm = g2d.getFontMetrics();

g2d.drawString(slide1.getText(), TEXTBOX_X + (TEXTBOX_WIDTH -

fm.stringWidth(slide1.getText())) / 2,

TEXTBOX_Y + TEXTBOX_HEIGHT / 2 + fm.getHeight() / 4);

g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER,

1.0f - weight));

if (slide2 != null)

g2d.drawString(slide2.getText(), TEXTBOX_X + (TEXTBOX_WIDTH -

fm.stringWidth(slide2.getText())) / 2, TEXTBOX_Y +

TEXTBOX_HEIGHT / 2 + fm.getHeight() / 4);

g2d.dispose();

return bi3;

}

BufferedImage getBufferedImage()

{

return bi;

}

String getText()

{

return text;

}

static List<Slide> loadSlides(String imagesPath) throws IOException

{

File imageFilesPath = new File(imagesPath);

if (!imageFilesPath.isDirectory())

throw new IOException(imagesPath + " identifies a file");

List<Slide> slides = new ArrayList<>();

try (FileReader fr = new FileReader(imagesPath + "/index");

BufferedReader br = new BufferedReader(fr))

{

String line;

while ((line = br.readLine()) != null)

{

String[] parts = line.split(",");

File file = new File(imageFilesPath + "/" + parts[0] + ".jpg");

System.out.println(file);

BufferedImage bi = ImageIO.read(file);

if (WIDTH == 0)

{

WIDTH = bi.getWidth();

HEIGHT = bi.getHeight();

TEXTBOX_WIDTH = WIDTH / 2 + 10;

TEXTBOX_HEIGHT = HEIGHT / 10;

TEXTBOX_Y = HEIGHT - TEXTBOX_HEIGHT - 5;

TEXTBOX_X = (WIDTH - TEXTBOX_WIDTH) / 2;

}

slides.add(new Slide(bi, parts[1]));

}

}

if (slides.size() < 2)

throw new IOException("at least one image must be loaded");

return slides;

}

}

public class SlideShow

{

public static void main(String[] args) throws IOException

{

if (args.length != 1)

{

System.err.println("usage: java SlideShow ssdir");

return;

}

List<Slide> slides = Slide.loadSlides(args[0]);

final Screen screen = new Screen(Slide.WIDTH, Slide.HEIGHT);

final Projector p = new Projector(slides, screen);

Runnable r = () ->

{

final JFrame f = new JFrame("Slide Show");

WindowAdapter wa = new WindowAdapter()

{

@Override

public void windowClosing(WindowEvent we)

{

p.stop();

f.dispose();

}

};

f.addWindowListener(wa);

f.setContentPane(screen);

f.pack();

f.setVisible(true);

p.start();

};

EventQueue.invokeLater(r);

}

}

清单 B-6 根据ProjectorScreenSlideSlideShow类对幻灯片进行建模。Projector声明了几个private字段,一个将投影仪初始化为Slide对象的java.util.List和一个Screen对象的Projector(List<Slide> slides, Screen s)构造器,一个启动投影仪的void start()方法,一个停止投影仪的void stop()方法。

Screen,它子类化javax.swing.JComponent以使一个Screen实例成为一种特殊的 Swing 组件,声明了几个private字段,一个Screen(int width, int height)构造器,用于实例化这个组件到传递给widthheight的屏幕范围,以及一个void drawImage(BufferedImage bi)方法,用于在屏幕表面绘制传递给这个方法的缓冲图像。这个类还覆盖了Dimension getPreferredSize()void paint(Graphics g)来返回组件的首选大小并绘制其表面。

Slide声明了各种常量、几个private字段、一个用于初始化Slide对象的private Slide(BufferedImage bi, String text)构造函数、用于返回幻灯片缓冲图像和文本的BufferedImage getBufferedImage()String getText() getter 方法、一个用于混合一对缓冲图像以显示幻灯片之间过渡的BufferedImage blend(Slide slide1, Slide slide2, float weight)类方法,以及一个用于加载所有幻灯片图像的List<Slide> loadSlides(String imagesPath)类方法。

blend()方法提取与其幻灯片参数相关的缓冲图像,并将这些图像混合在一起,混合量由weight的值决定(必须在0.01.0的范围内)。传递给weight的值越高,slide1的图像对返回的缓冲图像的贡献就越大。混合图像后,blend()在混合图像上混合一对文本字符串。java.awt.AlphaComposite类用于处理每种情况下的混合。

我设计了blend()来处理一种特殊的情况,即null被传递给slide2。这发生在Projectorstart()方法开始时,它执行s.drawImage(Slide.blend(slides.get(0), null, 1.0f));来显示第一张幻灯片——此时没有转换。

loadSlides()方法在由该方法的字符串参数标识的目录中查找名为index的文本文件,并按照该文本文件内容标识的顺序创建SlideList——您可以选择不同于目录中存储的图像文件顺序的幻灯片显示顺序。每一行都被组织成一个文件名,后跟一个逗号,再跟一个文本描述(比如earth, Terran System)。指定文件名时,不要指定文件扩展名;loadSlides()只能识别 JPEG 文件。

SlideShow声明了一个驱动这个应用的main()方法。该方法首先验证已经指定了一个标识幻灯片目录(包含index和 JPEG 文件的目录)的命令行参数。然后它调用loadSlides()从这个目录加载index和所有幻灯片图像。loadSlides()无法加载图像或图像数量少于 2 时抛出java.io.IOException。毕竟,你怎么能有一个少于两张图片的幻灯片呢?

main() next 创建一个用于显示幻灯片图像的Screen组件对象。它将每张幻灯片的宽度和高度(实际上是每张幻灯片图像的宽度和高度)传递给Screen的构造函数,确保屏幕刚好足够显示这些幻灯片。(所有幻灯片图像必须具有相同的宽度和高度,尽管我在loadSlides()中没有强制要求。)

唯一剩下的要创建的重要模型对象是投影仪,main()通过将从loadSlides()返回的Slide对象的List和先前创建的Screen对象传递给Projector的构造函数来完成这项任务。

最后一个任务是在 EDT 上构建 GUI。这个线程将内容窗格设置为Screen对象,并调用Projectorvoid start()方法来开始幻灯片放映。它还创建了一个窗口监听器,当用户试图关闭窗口时,这个监听器调用Projectorvoid stop()方法。然后,该窗口被丢弃。

Projector使用一对Timer对象来管理幻灯片。主定时器对象负责将投影仪推进到下一张幻灯片,从属定时器对象(主定时器每次触发动作事件时创建)负责从当前显示的幻灯片图像过渡到下一张幻灯片的图像(在blend()的帮助下)。

每个定时器实例都在 EDT 上运行。重要的是,当从属定时器运行时,主定时器不执行定时器任务。如果不遵守这条规则,幻灯片放映将会失灵。我选择主定时器任务的连续执行间隔为3000毫秒,从属定时器任务的连续执行间隔为10毫秒,从属定时器任务运行 100 次,总共大约 1000 毫秒。当从属计时器任务完成时,它会自行停止。

编译清单 B-6 如下:

javac SlideShow.java

假设是 Windows 操作系统,运行生成的应用如下:

java SlideShow ..\ss

ss标识示例太阳系幻灯片(包含在本书的代码中),位于当前目录的父目录中。

图 B-3 显示了生成的 GUI。

A978-1-4842-1700-9_10_Fig3_HTML.jpg

图 B-3。

SlideShow horizontally centers a slide’s text near the bottom of the slide

第一部分:线程 API

第二部分:并发工具

posted @ 2024-08-06 16:40  绝不原创的飞龙  阅读(9)  评论(0编辑  收藏  举报