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

87 阅读50分钟

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类组成,以便于在定时器上下文中使用线程。

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