龙空技术网

零基础学Java第7章 多线程

JAVA架构前端技术 168

前言:

当前我们对“java234”都比较讲究,咱们都需要剖析一些“java234”的相关文章。那么小编同时在网摘上收集了一些对于“java234””的相关知识,希望你们能喜欢,姐妹们快快来了解一下吧!

本章学习目标

· 理解进程和线程

· 熟练掌握线程的创建

· 了解线程的生命周期及状态转换

· 熟练掌握线程的调度

· 熟练掌握多线程同步

· 掌握多线程通信

· 了解线程组和未处理的异常

· 了解线程池

在前面章节讲到的都是单线程编程,单线程的程序如同只雇佣一名员工的工厂,这名员工必须做完一件事情后才可以做下一件事,多线程的程序则如同雇佣多名员工的工厂,他们可以同时做多件事情,Java语言提供了非常优秀的多线程支持,程序可以通过非常简单的方式来启动多线程。

7.1 线程概述

多线程是实现并发机制的一种有效手段。进程和线程一样,都是实现并发的一个基本单位。线程是比进程更小的执行单位,线程是在进程的基础之上进行的进一步划分。所谓多线程是指一个进程在执行过程中可以产生多个更小的程序单元,这些更小的单元称为线程,这些线程可以同时存在、同时运行,一个进程可能包含了多个同时执行的线程,进程与线程的区别如图7.1所示。

图7.1 进程与线程区别

7.1.1 进程

进程是程序的一次动态执行过程,它需要经历从代码加载、代码执行到执行完毕的一个完整过程,这个过程也是进程本身从产生、发展到最终消亡的过程。每个运行中的程序就是一个进程,一般而言,进程在系统中独立存在,拥有自己独立的资源,多个进程可以在单个处理器上并发执行且互不影响。例如打开计算机中的杀毒软件,可以在Windows任务管理器中查看该进程,如图7.2所示。

图7.2 杀毒软件进程

图7.2中,Windows任务管理器的进程选项卡中,可以查看到刚打开的杀毒软件,将软件正常关闭或者右键结束进程,都可以使这个进程消亡。

7.1.2 线程

操作系统可以同时执行多个任务,每个任务就是进程,进程可以同时执行多个任务,每个任务就是线程。例如前面讲解的杀毒软件程序是一个进程,那么它在为电脑体检的同时可以清理垃圾文件,这就是两个线程同时运行。在Windows任务管理器中也可以查看当前系统的线程数,如图7.3所示。

图7.3 当前系统线程

图7.3中,Windows任务管理器的性能选项卡中,可以查看到当前系统的总进程数和总线程数,可以看出线程数远远多于进程数。另外,多个线程并发执行时相互独立、互不影响。

7.2 线程的创建

在Java中,类仅支持单继承,也就是说,当定义一个新的类时,它只能扩展一个外部类。如果创建自定义线程类的时候是通过扩展 Thread类的方法来实现的,那么这个自定义类就不能再去扩展其他的类,也就无法实现更加复杂的功能。因此,如果自定义类必须扩展其他的类,那么就可以使用实现Runnable接口的方法来定义该类为线程类,这样就可以避免Java单继承所带来的局限性。Java提供了三种创建线程的方式,下面分别进行详细的讲解。

7.2.1 继承Thread类创建线程

Java提供了Thread类代表线程,它位于java.lang包中,下面介绍Thread类创建并启动多线程的步骤,具体步骤如下:

· 定义Thread类的子类,并重写run()方法,run()方法称为线程执行体。

· 创建Thread子类的实例,即创建了线程对象。

· 调用线程对象的start()方法启动线程。

启动一个新线程时,需要创建一个Thread类实例,接下来了解一下Thread类的常用构造方法,如表7.1所示。

表7.1 Thread类常用构造方法

表7.1中列出了Thread类的常用构造方法,这些构造方法可以创建线程实例,线程真正的功能代码在类的run()方法中。当一个类继承Thread类后,可以在类中覆盖父类的run()方法,在方法内写入功能代码。另外,Thread类还有一些常用方法,如表7.2所示。

表7.2 Thread类常用方法

表7.2中列出了Thread类的常用方法,接下来用一个案例来演示如何用继承Thread类的方式创建多线程,如例7-1所示。

例7-1 TestThread.java

1 public class TestThread {

2 public static void main(String[] args) {

3 SubThread1 st1 = new SubThread1();// 创建SubThread1实例

4 SubThread1 st2 = new SubThread1();

5 st1.start();// 开启线程

6 st2.start();

7 }

8 }

9 class SubThread1 extends Thread {

10 public void run() {// 重写run()方法

11 for (int i = 0; i < 4; i++) {

12 if (i % 2 != 0) {

13 System.out.println(Thread.

14 currentThread().getName() + ":" + i);

15 }

16 }

17 }

18 }

程序的运行结果如图7.4所示。

图7.4 例7-1运行结果

例7-1中,声明SubThread1类继承Thread类,在类中重写了run()方法,方法内循环打印小于4的奇数,其中currentThread()方法是Thread类的静态方法,可以返回当前正在执行的线程对象的引用,最后在main()方法中创建两个SubThread1类实例,分别调用start()方法启动两个线程,两个线程都运行成功。这是继承Thread类创建多线程的方式。

(脚下留心

如果start()方法调用一个已经启动的线程,程序会报IllegalThreadStateException异常。

7.2.2 实现Runnable接口创建线程

上一节讲解了继承Thread类的方式创建多线程,但Java只支持单继承,一个类只能有一个父类,继承Thread类后,就不能再继承其他类,为了解决这个问题,可以用实现Runnable接口的方式创建多线程,下面介绍实现Runnable接口创建并启动多线程,具体步骤如下:

· 定义Runnable接口实现类,并重写run()方法。

· 创建Runnable实现类的示例,并将实例对象传给Thread类的target来创建线程对象。

· 调用线程对象的start()方法启动线程。

接下来通过一个案例来演示如何用实现Runnable接口的方式创建多线程,如例7-2所示。

例7-2 TestRunnable.java

19 public class TestRunnable {

20 public static void main(String[] args) {

21 SubThread2 st = new SubThread2();// 创建SubThread2实例

22 new Thread(st, "线程1").start();// 创建并开启线程对象

23 new Thread(st, "线程2").start();

24 }

25 }

26 class SubThread2 implements Runnable {

27 public void run() {// 重写run()方法

28 for (int i = 0; i < 4; i++) {

29 if (i % 2 != 0) {

30 System.out.println(Thread.

31 currentThread().getName() + ":" + i);

32 }

33 }

34 }

35 }

程序的运行结果如图7.5所示。

图7.5 例7-2运行结果

例7-2中,声明SubThread2类实现Runnable接口,在类中实现了run()方法,方法内循环打印小于4的奇数,最后在main()方法中创建SubThread2类实例,分别创建并开启两个线程对象,这里调用public Thread(Runnable target, String name)构造方法,指定了线程名称,两个线程都运行成功。这是实现Runnable接口的方式创建多线程。

7.2.3 使用Callable接口和Future接口创建线程

上一节讲解了实现Runnable接口的方式创建多线程,但重写run()方法实现功能代码有一定局限性,这样做方法没有返回值且不能抛出异常,JDK5.0后,Java提供了Callable接口来解决此问题,接口内有一个call()方法可以作为线程执行体,call()方法有返回值且可以抛出异常。下面介绍实现Callable接口创建并启动多线程,具体步骤如下:

· 定义Callable接口实现类,指定返回值类型,并重写call()方法。

· 创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。

· 使用FutureTask对象作为Thread对象的target创建并启动新线程。

· 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。

Callable接口不是Runnable接口的子接口,所以不能直接作为Thread的target,而且call()方法有返回值,是被调用者,JDK5.0还提供了一个Future接口代表call()方法的返回值,Future接口有一个FutureTask实现类,它实现了Runnable接口,可以作为Thread类的target,接下来先了解一下Future接口的方法,如表7.3所示。

表7.3 Future接口的方法

表7.3中列出了Future接口的方法,接下来通过一个案例来演示如何用Callable接口和Future接口创建多线程,如例7-3所示。

例7-3 TestCallable.java

36 import java.util.concurrent.*;

37 public class TestCallable {

38 public static void main(String[] args) {

39 // 创建MyCallable对象

40 Callable<Integer> myCallable = new SubThread3();

41 // 使用FutureTask来包装MyCallable对象

42 FutureTask<Integer> ft = new FutureTask<Integer>(myCallable);

43 for (int i = 0; i < 4; i++) {

44 System.out.println(Thread.currentThread().getName() + ":" + i);

45 if (i == 1) {

46 // FutureTask对象作为Thread对象的target创建新的线程

47 Thread thread = new Thread(ft);

48 thread.start(); // 线程进入到就绪状态

49 }

50 }

51 System.out.println("主线程for循环执行完毕..");

52 try {

53 int sum = ft.get(); // 取得新创建线程中的call()方法返回值

54 System.out.println("sum = " + sum);

55 } catch (InterruptedException e) {

56 e.printStackTrace();

57 } catch (ExecutionException e) {

58 e.printStackTrace();

59 }

60 }

61 }

62 class SubThread3 implements Callable<Integer> {

63 private int i = 0;

64 public Integer call() throws Exception {

65 int sum = 0;

66 for (i = 0; i < 3; i++) {

67 System.out.println(Thread.currentThread().getName() + ":" + i);

68 sum += i;

69 }

70 return sum;

71 }

72 }

程序的运行结果如图7.6和图7.7所示。

图7.6 例7-3第一次运行结果

图7.7 例7-3第二次运行结果

例7-3中,声明SubThread3类实现Callable接口,重写call()方法,在方法内实现功能代码,main()方法中执行for循环,循环中启动子线程,最后调用get()方法获得子线程call()方法的返回值。

另外,多次执行例7-3的程序,sum=3永远都是最后打印,而"主线程for循环执行完毕.."可能在子线程循环前、后或中间输出,sum=3永远都是最后输出,是因为通过get()方法获取子线程call()方法的返回值时,当子线程此方法还未执行完毕,get()方法会一直阻塞,直到call()方法执行完毕才能取到返回值。这是用Callable接口和Future接口创建多线程的方式。

7.2.4 三种实现多线程方式的对比分析

前面讲解了可以通过三种方式创建多线程,包括继承Thread类、实现Runnable接口和实现Callable接口的方式,接下来介绍一下这三种创建多线程方式的优点和弊端。

1.继承Thread类创建多线程

优点:编写简单,如果需要访问当前线程,则无需使用Thread.currentThread()方法,直接使用this即可获得当前线程。

弊端:线程类已经继承了Thread类,所以不能再继承其他父类。

2.实现Runnable接口创建多线程

优点:避免由于Java单继承带来的局限性。

弊端:编程稍微复杂,如果要访问当前线程,则必须使用Thread.currentThread()方法。

3.使用Callable接口和Future接口创建多线程

优点:避免由于Java单继承带来的局限性,有返回值,可以抛出异常。

弊端:编程稍微复杂,如果要访问当前线程,则必须使用Thread.currentThread()方法。

如上列出了三种创建多线程方式的优点和弊端,一般情况下推荐使用后两种实现接口的方式创建多线程,实际开发中要根据实际需求确定使用哪种方式。

7.3 线程的生命周期及状态转换

前面讲解了线程的创建,接下来了解一下线程的生命周期。线程有新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和死亡(Terminated)五种状态,线程从新建到死亡称为线程的生命周期,接下来了解一下线程的生命周期及状态转换,如图7.8所示。

图7.8 线程的生命周期及状态转换

图7.8描述了线程的生命周期及状态转换,下面详细讲解线程这五种状态。

1.新建状态

当程序使用new关键字创建一个线程后,该线程处于新建状态,此时它和其他Java对象一样,在堆空间内分配了一块内存,但还不能运行。

2.就绪状态

当一个线程对象创建后,其他线程调用它的start()方法,该线程就进入就绪状态,Java虚拟机会为它创建方法调用栈和程序计数器。处于这个状态的线程位于可运行池中,等待获得CPU的使用权。

3.运行状态

处于这个状态的线程占用CPU,执行程序代码。在并发执行时,如果计算机只有一个CPU,那么只会有一个线程处于运行状态。如果计算机有多个CPU,那么同一时刻可以有多个线程占用不同CPU处于运行状态,只有处于就绪状态的线程才可以转换到运行状态。

4.阻塞状态

阻塞状态是指线程因为某些原因放弃CPU,暂时停止运行。当线程处于阻塞状态时,Java虚拟机不会给线程分配CPU,直到线程重新进入就绪状态,它才有机会转换到运行状态。

下面列举一下线程由运行状态转换成阻塞状态的原因,以及如何从阻塞状态转换成就绪状态。

· 当线程调用了某个对象的suspend()方法时,也会使线程进入阻塞状态,如果想进入就绪状态需要使用resume()方法唤醒该线程。

· 当线程试图获取某个对象的同步锁时,如果该锁被其他线程持有,则当前线程就会进入阻塞状态,如果想从阻塞状态进入就绪状态必须要获取到其他线程持有的锁,关于锁的概念会在后面详细讲解。

· 当线程调用了Thread类的sleep()方法时,也会使线程进入阻塞状态,在这种情况下,需要等到线程睡眠的时间结束,线程会自动进入就绪状态。

· 当线程调用了某个对象的wait()方法时,也会使线程进入阻塞状态,如果想进入就绪状态需要使用notify()方法或notifyAll()方法唤醒该线程。

· 当在一个线程中调用了另一个线程的join()方法时,会使当前线程进入阻塞状态,在这种情况下,要等到新加入的线程运行结束后才会结束阻塞状态,进入就绪状态。

(脚下留心

线程从阻塞状态只能进入就绪状态,不能直接进入运行状态。另外,suspend()方法和resume()方法已被标记为过时,因为suspend()方法具有死锁倾向,resume()方法只与suspend()方法一起使用,建议不使用这两个方法。

5.死亡状态

· 线程的run()方法正常执行完成,线程正常结束。

· 线程抛出异常(Exception)或错误(Error)。

· 调用线程对象的stop()方法结束该线程。

线程一旦转换为死亡状态,就不能运行且不能转换为其他状态。

7.4 线程的调度

如果计算机只有一个CPU,那么在任意时刻只能执行一条指令,每个线程只有获得CPU使用权才能执行指令。多线程的并发运行,从宏观上看,是各个线程轮流获得CPU的使用权,分别执行各自的任务。但在运行池中,会有多个处于就绪状态的线程在等待CPU,Java虚拟机的一项任务就是负责线程的调度,即按照特定的机制为多个线程分配CPU使用权。调度模型分为分时调度模型和抢占式调度模型两种。

分时调度模型是让所有线程轮流获得CPU使用权,平均分配每个线程占用CPU的时间片。抢占式调度模型是优先让可运行池中优先级高的线程占用CPU,若运行池中线程优先级相同,则随机选择一个线程使用CPU,当它失去CPU使用权,再随机选取一个线程获取CPU使用权。Java默认使用抢占式调度模型,接下来详细讲解线程调度的相关知识。

7.4.1 线程的优先级

所有处于就绪状态的线程根据优先级存放在可运行池中,优先级低的线程运行机会较少,优先级高的线程运行机会更多。Thread类的setPriority(int newPriority)方法和getPriority()方法分别用于设置优先级和读取优先级。优先级用整数表示,取值范围1~10,除了直接用数字表示线程的优先级,还可以用Thread类中提供的三个静态常量来表示线程的优先级,如表7.4所示。

表7.4 Thread类的静态常量

表7.4中列出了Thread类的三个静态常量,可以用这些常量设置线程的优先级,接下来用一个案例演示线程优先级的使用,如例7-4所示。

例7-4 TestPriority.java

73 public class TestPriority {

74 public static void main(String[] args) {

75 // 创建SubThread1实例

76 SubThread1 st1 = new SubThread1("优先级低的线程");

77 SubThread1 st2 = new SubThread1("优先级高的线程");

78 st1.setPriority(Thread.MIN_PRIORITY);// 设置优先级

79 st2.setPriority(Thread.MAX_PRIORITY);

80 st1.start(); // 开启线程

81 st2.start();

82 }

83 }

84 class SubThread1 extends Thread {

85 public SubThread1(String name) {

86 super(name);

87 }

88 public void run() { // 重写run()方法

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

90 if (i % 2 != 0) {

91 System.out.println(Thread.

92 currentThread().getName() + ":" + i);

93 }

94 }

95 }

96 }

程序的运行结果如图7.9所示。

图7.9 例7-4运行结果

例7-4中,声明SubThread1类继承Thread类,在类中重写了run()方法,方法内循环打印小于6的奇数,在main()方法中创建三个SubThread1类实例并指定线程名称,调用setPriority(int newPriority)方法分别设置三个线程的优先级,最后调用start()方法启动三个线程,从执行结果看,优先级高的线程优先执行。这里要注意,优先级低的不一定永远后执行,有可能优先级低的线程先执行,只不过几率较小。

(脚下留心

Thread类的setPriority(int newPriority)方法可以设置10种优先级,但这些优先级级别需要操作系统的支持,但是不同的操作系统上支持的优先级不同,不能很好地支持Java的10个优先级别,例如Windows2000只支持7个优先级别,所以尽量避免直接用数字指定线程优先级,应该使用Thread类的三个常量指定线程优先级别,这样可以保证程序有很好的可移植性。

7.4.2 线程休眠

前面讲解了线程的优先级,可以发现将需要后执行的线程设置为低优先级,也有一定几率先执行该线程,可以用Thread类的静态方法sleep()来解决这一问题,sleep()方法有两种重载形式,具体示例如下:

static void sleep(long millis)

static void sleep(long millis, int nanos)

如上所示是sleep()方法的两种重载形式,前者参数是指定线程休眠的毫秒数,后者是指定线程休眠的毫秒数和毫微秒数。正在执行的线程调用sleep()方法可以进入阻塞状态,也叫线程休眠,在休眠时间内,即使系统中没有其他可执行的线程,该线程也不会获得执行的机会,当休眠时间结束才可以执行该线程。接下来用一个案例来演示线程休眠,如例7-5所示。

例7-5 TestSleep.java

97 import java.text.SimpleDateFormat;

98 import java.util.Date;

99 public class TestSleep {

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

101 for (int i = 0; i < 5; i++) {

102 System.out.println("当前时间:"

103 + new SimpleDateFormat("hh:mm:ss").format(new Date()));

104 Thread.sleep(2000);

105 }

106 }

107 }

程序的运行结果如图7.10所示。

图7.10 例7-5运行结果

例7-5中,在循环中打印五次格式化后的当前时间,每次打印后都调用Thread类的sleep()方法,让程序休眠2秒,打印的五次运行结果,每次的间隔都是2秒。这是线程休眠的基本使用。

7.4.3 线程让步

前面讲解了使用sleep()方法使线程阻塞,Thread类还提供一个yield()方法,它与sleep()方法类似,它也可以让当前正在执行的线程暂停,但yield()方法不会使线程阻塞,只是将线程转换为就绪状态,也就是让当前线程暂停一下,线程调度器重新调度一次,有可能还会将暂停的程序调度出来继续执行,这也称为线程让步。接下来用一个案例来演示线程让步,如例7-6所示。

例7-6 TestYield.java

108 public class TestYield {

109 public static void main(String[] args) {

110 SubThread3 st = new SubThread3(); // 创建SubThread3实例

111 new Thread(st, "线程1").start(); // 创建并开启线程

112 new Thread(st, "线程2").start();

113 }

114 }

115 class SubThread3 implements Runnable {

116 public void run() { // 重写run()方法

117 for (int i = 1; i <= 6; i++) {

118 System.out.println(Thread.

119 currentThread().getName() + ":" + i);

120 if (i % 3 == 0) {

121 Thread.yield();

122 }

123 }

124 }

125 }

程序的运行结果如图7.11所示。

图7.11 例7-6运行结果

例7-6中,声明SubThread3类实现Runnable接口,在类中实现了run()方法,方法内循环打印数字1~6,当变量i能被3整除时,调用yield()方法线程让步,在main()方法中创建SubThread3类实例,分别创建并开启两个线程,运行结果中,线程执行到3或6次时,变量i能被3整除,调用Thread类的yield()方法线程让步,切换到其他线程,这里注意,并不是线程执行到3或6次一定切换到其他线程,也有可能线程继续执行。这是线程让步的基本使用。

7.4.4 线程插队

Thread类提供了一个join()方法,当某个线程执行中调用其他线程的join()方法时,此线程将被阻塞,直到被join()方法加入的线程执行完为止,也称为线程插队。接下来用一个案例来演示线程插队,如例7-7所示。

例7-7 TestJoin.java

126 public class TestJoin {

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

128 SubThread4 st = new SubThread4(); // 创建SubThread4实例

129 Thread t = new Thread(st, "线程1"); // 创建并开启线程

130 t.start();

131 for (int i = 1; i < 6; i++) {

132 System.out.println(Thread.

133 currentThread().getName() + ":" + i);

134 if (i == 2) {

135 t.join(); // 线程插队

136 }

137 }

138 }

139 }

140 class SubThread4 implements Runnable {

141 public void run() { // 重写run()方法

142 for (int i = 1; i < 6; i++) {

143 System.out.println(Thread.

144 currentThread().getName() + ":" + i);

145 }

146 }

147 }

程序的运行结果如图7.12所示。

图7.12 例7-7运行结果

例7-7中,声明SubThread4类实现Runnable接口,在类中实现了run()方法,方法内循环打印数字1~5,在main()方法中创建SubThread4类实例并启动线程,main()方法中同样循环打印数字1~5,当变量i为2时,调用join()方法将子线程插入,子线程开始执行,直到子线程执行完,main()方法的主线程才能继续执行。这是线程插队的基本使用。

7.4.5 后台线程

线程中还有一种后台线程,它是为其他线程提供服务的,又称为"守护线程"或"精灵线程",JVM的垃圾回收线程就是典型的后台线程。

如果所有的前台线程都死亡,后台线程会自动死亡。当整个虚拟机中只剩下后台线程,程序就没有继续运行的必要了,所以虚拟机也就退出了。

若将一个线程设置为后台线程,可以调用Thread类的setDaemon(boolean on)方法,将参数指定为true即可,Thread类还提供了一个isDaemon()方法,用于判断一个线程是否是后台线程,接下来用一个案例来演示后台线程,如例7-8所示。

例7-8 TestBackThread.java

148 public class TestBackThread {

149 public static void main(String[] args) {

150 // 创建SubThread5实例

151 SubThread5 st1 = new SubThread5("新线程");

152 st1.setDaemon(true);

153 st1.start();

154 for (int i = 0; i < 2; i++) {

155 System.out.println(Thread.

156 currentThread().getName() + ":" + i);

157 }

158 }

159 }

160 class SubThread5 extends Thread {

161 public SubThread5(String name) {

162 super(name);

163 }

164 public void run() {// 重写run()方法

165 for (int i = 0; i < 1000; i++) {

166 if (i % 2 != 0) {

167 System.out.println(Thread.

168 currentThread().getName() + ":" + i);

169 }

170 }

171 }

172 }

程序的运行结果如图7.13所示。

图7.13 例7-8运行结果

例7-8中,声明SubThread5类继承Thread类,在类中实现了run()方法,方法内循环打印数字0~1000的奇数,在main()方法中创建SubThread5类实例,调用setDaemon(boolean on)方法,将参数指定为true,此线程被设置为后台线程,随后开启线程,最后循环打印0~2的数字,这里可以看到,新线程本应该执行到打印999,但是这里执行到3就结束了,因为前台线程执行完毕,线程死亡,后台线程随之死亡。这是后台线程的基本使用。

7.5 多线程同步

7.5.1 前面讲解了线程的基本使用,在并发执行的情况下,多线程可能会突然出现"错误",这是因为系统的线程调度有一定随机性,多线程操作同一数据时,很容易出现这种"错误",接下来会详细讲解如何解决这种"错误"。

7.5.2 线程安全

关于线程安全,有一个经典的问题——窗口卖票的问题。窗口卖票的基本流程大致为首先知道共有多少张票,每卖掉一张票,票的总数要减1,多个窗口同时卖票,当票数剩余0时说明没有余票,停止售票。流程很简单,但如果这个流程放在多线程并发的场景下,就存在问题,可能问题不会及时暴露出来,运行很多次才出一次问题。接下来用一个案例来演示这个卖票窗口的经典问题,如例7-9所示。

例7-9 TestTicket1.java

173 public class TestTicket1 {

174 public static void main(String[] args) {

175 Ticket1 ticket = new Ticket1();

176 Thread t1 = new Thread(ticket);

177 Thread t2 = new Thread(ticket);

178 Thread t3 = new Thread(ticket);

179 t1.start();

180 t2.start();

181 t3.start();

182 }

183 }

184 class Ticket1 implements Runnable {

185 private int ticket = 5;

186 public void run() {

187 for (int i = 0; i < 100; i++) {

188 if (ticket > 0) {

189 try {

190 Thread.sleep(100);

191 } catch (InterruptedException e) {

192 e.printStackTrace();

193 }

194 System.out.println(

195 "卖出第" + ticket + "张票,还剩" + --ticket + "张票");

196 }

197 }

198 }

199 }

程序的运行结果如图7.14所示。

图7.14 例7-9运行结果

例7-9中,声明Ticket1类实现Runnable接口,首先在类中定义一个int型变量,用于记录总票数,然后在for循环中卖票,每卖一张,票总数减1,为了让程序的问题暴露出来,这里调用sleep()方法让程序每次循环都休眠100毫秒,最后在main()方法中创建并启动三个线程,模拟三个窗口同时售票。运行结果可以看出,第5张票重复卖了2次,剩余的票数还出现了-1张。

例7-9中出现这种情况是因为run()方法的循环中判断票数是否大于0,大于0则继续出售,但这里调用sleep()方法让程序每次循环都休眠100毫秒,这就会出现第一个线程执行到此处休眠的同时,第二和第三个线程也进入执行,所以总票数减的次数增多,这就是线程安全的问题。

7.5.3 同步代码块

前面提出了线程安全的问题,为了解决这个问题,Java的多线程引入了同步监视器来解决这个问题,使用同步监视器的通用方法就是同步代码块,具体示例如下:

synchronized (obj) {

// 要同步的代码块

}

如上所示,synchronized关键字后括号里的obj就是同步监视器,当线程执行同步代码块时,首先会检查同步监视器的标志位,默认情况下标志位为1,线程会执行同步代码块,同时将标志位改为0,当第二个线程执行同步代码块前,检查到标志位为0,第二个线程会进入阻塞状态,直到前一个线程执行完同步代码块内的操作,标志位重新改为1,第二个线程才有可能进入同步代码块。接下来通过修改例7-9的代码来演示用同步代码块解决线程安全问题,如例7-10所示。

例7-10 TestSynBlock.java

200 public class TestSynBlock {

201 public static void main(String[] args) {

202 Ticket2 ticket = new Ticket2();

203 Thread t1 = new Thread(ticket);

204 Thread t2 = new Thread(ticket);

205 Thread t3 = new Thread(ticket);

206 t1.start();

207 t2.start();

208 t3.start();

209 }

210 }

211 class Ticket2 implements Runnable {

212 private int ticket = 5;

213 public void run() {

214 for (int i = 0; i < 100; i++) {

215 synchronized (this) {

216 if (ticket > 0) {

217 try {

218 Thread.sleep(100);

219 } catch (InterruptedException e) {

220 e.printStackTrace();

221 }

222 System.out.println(

223 "卖出第" + ticket + "张票,还剩" + --ticket + "张票");

224 }

225 }

226 }

227 }

228 }

程序的运行结果如图7.15所示。

图7.15 例7-10运行结果

例7-10与前边的例7-9几乎完全一样,区别就是例7-10在run()方法的循环中执行售票操作时,将操作变量ticket的操作都放到同步代码块中,在使用同步代码块时必须指定一个需要同步的对象,一般用当前对象(this)即可。将例7-9修改为例7-10后,多次运行该程序不会出现重票或负票的情况。

(脚下留心

同步代码块中的锁对象可以是任意类型的对象,但多个线程共享的锁对象必须是唯一的。"任意"说的是共享锁对象的类型。所以,锁对象的创建代码不能放到run()方法中,否则每个线程运行到run()方法都会创建一个新对象,这样每个线程都会有一个不同的锁,每个锁都有自己的标志位,线程之间便不能产生同步的效果。

7.5.4 同步方法

前面讲解了用同步代码块解决线程安全问题,Java还提供了同步方法,即使用synchronized关键字修饰方法,该方法就是同步方法,同步方法的监视器是this,也就是调用该方法的对象,同步方法也可以解决线程安全的问题,接下来通过修改例7-9的代码来演示用同步方法解决线程安全问题,如例7-11所示。

例7-11 TestSynMethod.java

229 public class TestSynMethod {

230 public static void main(String[] args) {

231 Ticket3 ticket = new Ticket3();

232 Thread t1 = new Thread(ticket);

233 Thread t2 = new Thread(ticket);

234 Thread t3 = new Thread(ticket);

235 t1.start();

236 t2.start();

237 t3.start();

238 }

239 }

240 class Ticket3 implements Runnable {

241 private int ticket = 5;

242 public synchronized void run() {

243 for (int i = 0; i < 100; i++) {

244 if (ticket > 0) {

245 try {

246 Thread.sleep(100);

247 } catch (InterruptedException e) {

248 e.printStackTrace();

249 }

250 System.out.println(

251 "卖出第" + ticket + "张票,还剩" + --ticket + "张票");

252 }

253 }

254 }

255 }

程序的运行结果如图7.16所示。

图7.16 例7-11运行结果

例7-11与前边的例7-9几乎完全一样,区别就是例7-11的run()方法是用synchronized关键字修饰的,将例7-9修改为例7-11后,多次运行该程序不会出现重票或负票的情况。

(脚下留心

同步方法的锁就是当前调用该方法的对象,也就是this指向的对象,但是静态方法不需要创建对象就可以用"类名.方法名()"的方式调用,这时的锁不再是this,静态同步方法的锁是该方法所在类的class对象,该对象可以直接用"类名.class"的方式获取。

7.5.5 死锁问题

在多线程应用中还存在死锁的问题,不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了线程的死锁。接下来通过一个案例来演示死锁的情况,如例7-12所示。

例7-12 TestDeadLock.java

256 public class TestDeadLock implements Runnable {

257 public int flag = 1;

258 // 静态对象是类的所有对象共享的

259 private static Object o1 = new Object();

260 private static Object o2 = new Object();

261 public static void main(String[] args) {

262 TestDeadLock td1 = new TestDeadLock();

263 TestDeadLock td2 = new TestDeadLock();

264 td1.flag = 1;

265 td2.flag = 0;

266 new Thread(td1).start();

267 new Thread(td2).start();

268 }

269 public void run() {

270 System.out.println("flag=" + flag);

271 if (flag == 1) {

272 synchronized (o1) {

273 try {

274 Thread.sleep(500);

275 } catch (Exception e) {

276 e.printStackTrace();

277 }

278 synchronized (o2) {

279 System.out.println("1");

280 }

281 }

282 }

283 if (flag == 0) {

284 synchronized (o2) {

285 try {

286 Thread.sleep(500);

287 } catch (Exception e) {

288 e.printStackTrace();

289 }

290 synchronized (o1) {

291 System.out.println("0");

292 }

293 }

294 }

295 }

296 }

程序的运行结果如图7.17所示。

图7.17 例7-12运行结果

例7-12中,当TestDeadLock类的对象flag==1时(td1),先锁定o1,睡眠500毫秒,而td1在睡眠的时候另一个flag==0的对象(td2)线程启动,再锁定o2,睡眠500毫秒,td1睡眠结束后需要锁定o2才能继续执行,而此时o2已被td2锁定;td2睡眠结束后需要锁定o1才能继续执行,而此时o1已被td1锁定;td1、td2相互等待,都需要得到对方锁定的资源才能继续执行,从而死锁,程序出现阻塞状态。

在编写代码时要尽量避免死锁,采用专门的算法、原则,尽量减少同步资源的定义。此外,Thread类的suspend()方法也容易导致死锁,已被标记为过时的方法。

7.6 多线程通信

不同的线程执行不同的任务,如果这些任务有某种联系,线程之间必须能够通信,协调完成工作,例如生产者和消费者互相操作仓库,当仓库为空时,消费者无法从仓库取出产品,应该先通知生产者向仓库中加入产品。当仓库已满时,生产者无法继续加入产品,应该先通知消费者从仓库取出产品。java.lang包中Object类提供了三个用于线程通信的方法,如表7.5所示:

表7.5 Object类线程通信方法

表7.5中列出了Object类提供的三个用于线程通信的方法,这里要注意的是,这三个方法只有在synchronized方法或synchronized代码块中才能使用,否则会报IllegalMonitorStateException异常。

线程通信中有一个经典例子就是生产者和消费者问题,生产者(Productor)将产品交给售货员(Clerk),而消费者(Customer)从售货员处取走产品,售货员一次最多只能持有固定数量的产品(比如10),如果生产者试图生产更多的产品,售货员会让生产者停一下,如果店中有空位放产品了再通知生产者继续生产;如果店中没有产品了,售货员会告诉消费者等一下,如果店中有产品了再通知消费者来取走产品。接下来通过一个案例来演示生产者和消费者的问题,首先需要创建一个代表售货员的类,如例7-13所示。

例7-13 Clerk.java

297 public class Clerk {// 售货员

298 private int product = 0;

299 public synchronized void addProduct() {

300 if (product >= 10) {

301 try {

302 wait();

303 } catch (InterruptedException e) {

304 e.printStackTrace();

305 }

306 } else {

307 product++;

308 System.out.println("生产者生产了第" + product + "个产品");

309 notifyAll();

310 }

311 }

312 public synchronized void getProduct() {

313 if (this.product <= 0) {

314 try {

315 wait();

316 } catch (InterruptedException e) {

317 e.printStackTrace();

318 }

319 } else {

320 System.out.println("消费者取走了第" + product + "个产品");

321 product--;

322 notifyAll();

323 }

324 }

325 }

例7-13的Clerk类代表售货员,它有两个方法和一个变量,两个方法都是同步方法,其中addProduct()方法用来添加商品,getProduct()方法用来取走商品,接下来继续编写代表生产者和消费者的类,如例7-14和例7-15所示。

例7-14 Productor.java

326 class Productor implements Runnable { // 生产者

327 Clerk clerk;

328 public Productor(Clerk clerk) {

329 this.clerk = clerk;

330 }

331 public void run() {

332 while (true) {

333 try {

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

335 } catch (InterruptedException e) {

336 }

337 clerk.addProduct();

338 }

339 }

340 }

例7-15 Consumer.java

341 class Consumer implements Runnable { // 消费者

342 Clerk clerk;

343 public Consumer(Clerk clerk) {

344 this.clerk = clerk;

345 }

346 public void run() {

347 while (true) {

348 try {

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

350 } catch (InterruptedException e) {

351 }

352 clerk.getProduct();

353 }

354 }

355 }

例7-14的Productor类代表生产者,调用Clerk类的addProduct()方法不停地生产产品,例7-15的Consumer类代表消费者,调用Clerk类的getProduct()方法不停地消费产品,最后来编写程序的入口main()方法,如例7-16所示。

例7-16 TestProduct.java

356 public class TestProduct {

357 public static void main(String[] args) {

358 Clerk clerk = new Clerk();

359 Thread productorThread = new Thread(new Productor(clerk));

360 Thread consumerThread = new Thread(new Consumer(clerk));

361 productorThread.start();

362 consumerThread.start();

363 }

364 }

程序的运行结果如图7.18所示。

图7.18 例7-16运行结果

例7-16中,先创建售货员实例,然后创建并开启生产者和消费者两个线程,生产者和消费者不停地生产和消费产品,且售货员一次持有的产品数量不超过10个,这就是线程通信中生产者和消费者的经典问题。

7.7 线程组和未处理的异常

Java中使用ThreadGroup来表示线程组,它可以对一批线程进行分类管理,Java允许程序直接对线程组进行控制。用户创建的所有线程都属于指定的线程组,若未指定线程属于哪个线程组,则该线程属于默认线程组。在默认情况下,子线程和创建它的父线程处于同一个线程组内,另外,线程运行中不能改变它所属的线程组。Thread类提供了一些构造方法来设置新创建的线程属于哪个线程组,如表7.6所示。

表7.6 Thread类构造方法

表7.6列出了Thread类的构造方法,这些构造方法可以为线程指定所属的线程组,指定线程组的参数为ThreadGroup类型,接下来了解一下ThreadGroup类的构造方法,如表7.7所示。

表7.7 ThreadGroup类构造方法

表7.7列出了ThreadGroup类的构造方法,构造方法中都有一个String类型的名称,这就是线程组的名字,可以通过ThreadGroup类的getName()方法来获取,但不允许修改线程组的名字。另外,还需要了解一下ThreadGroup类的常用方法,如表7.8所示。

表7.8 ThreadGroup类常用方法

表7.8列出了ThreadGroup类的常用方法,接下来通过一个案例来演示线程组的使用,如例7-17所示。

例7-17 TestThreadGroup.java

365 public class TestThreadGroup {

366 public static void main(String[] args) {

367 ThreadGroup tg1 = Thread.currentThread().getThreadGroup();

368 System.out.println("主线程的名字:" + tg1.getName());

369 System.out.println("主线程组是否是后台线程组:" + tg1.isDaemon());

370 SubThread st = new SubThread("主线程组的线程");

371 st.start();

372 ThreadGroup tg2 = new ThreadGroup("新线程组");

373 tg2.setDaemon(true);

374 System.out.println("新线程组是否是后台线程组:" + tg2.isDaemon());

375 SubThread st2 = new SubThread(tg2, "tg2组的线程1");

376 st2.start();

377 new SubThread(tg2, "tg2组的线程2").start();

378 }

379 }

380 class SubThread extends Thread {

381 public SubThread(String name) {

382 super(name);

383 }

384 public SubThread(ThreadGroup group, String name) {

385 super(group, name);

386 }

387 public void run() {

388 for (int i = 0; i < 3; i++) {

389 System.out.println(getName() + "线程执行第" + i + "次");

390 }

391 }

392 }

程序的运行结果如图7.19所示。

图7.19 例7-17运行结果

例7-17中,声明SubThread类继承Thread类,该类有两个构造方法,一个是指定名称,一个是指定线程组和名称,在run()方法的循环中打印执行了第几次,在main()方法中先得到主线程名称并判断是否是后台线程,然后创建一个新线程组并设为后台线程,判断新线程是否是后台线程,最后同时运行这些线程,从运行结果可看出主线程、tg2组的线程1、tg2组的线程2分别执行了3次。这就是线程组的基本使用。

ThreadGroup类还定义了一个可以处理线程组内任意线程抛出的未处理异常,具体示例如下:

void uncaughtException(Thread t, Throwable e)

当此线程组中的线程因为一个未捕获的异常而停止,并且线程在JVM结束该线程之前没有查找到对应的Thread.UncaughtExceptionHandler时,由JVM调用如上方法。Thread.UncaughtExceptionHandler是Thread类的一个静态内部接口,接口内只有一个方法void uncaughtException(Thread t,Throwable e),方法中的t代表出现异常的线程,e代表该线程抛出的异常。接下来通过一个案例来演示主线程运行抛出未处理异常如何处理,如例7-18所示。

例7-18 TestExceptionHandling.java

393 import java.lang.Thread.UncaughtExceptionHandler;

394 public class TestExceptionHandling {

395 public static void main(String[] args) {

396 Thread.currentThread().

397 setUncaughtExceptionHandler(new MyHandler());

398 int i = 10 / 0;

399 System.out.println("程序正常结束");

400 }

401 }

402 class MyHandler implements UncaughtExceptionHandler {

403 public void uncaughtException(Thread t, Throwable e) {

404 System.out.println(t + "线程出现了异常:" + e);

405 }

406 }

程序的运行结果如图7.20所示。

图7.20 例7-18运行结果

例7-18中,声明MyHandler类实现UncaughtExceptionHandler类,在uncaughtException(Thread t,Throwable e)方法中打印某个线程出现某个异常,在main()方法中用0做除数,运行程序报错,可以看到异常处理器对未捕获的异常进行处理了,但程序仍然不能正常结束,说明异常处理器与通过catch捕获异常是不同的,异常处理器对异常进行处理后,异常依然会传播给上一级调用者。

7.8 线程池

程序启动一个新线程成本是比较高的,因为它涉及到要与操作系统进行交互。而使用线程池可以很好地提高性能,尤其是当程序中要创建大量生存期很短的线程时,更应该考虑使用线程池。线程池里的每一个线程代码结束后,并不会死亡,而是再次回到线程池中成为空闲状态,等待下一个对象来使用。

在JDK5.0之前,我们必须手动实现自己的线程池,从JDK5.0开始,Java内置支持线程池。提供一个Executors工厂类来产生线程池,该类中都是静态工厂方法,先来了解一下

Executors类的常用方法,如表7.9所示。

表7.9 Executors类常用方法

表7.9列出了Executors类的常用方法,接下来通过一个案例演示线程池的使用,如例7-19所示。

例7-19 TestThreadPool.java

407 import java.util.concurrent.*;

408 public class TestThreadPool {

409 public static void main(String[] args) {

410 ExecutorService es = Executors.newFixedThreadPool(10);

411 Runnable run = new Runnable() {

412 public void run() {

413 for (int i = 0; i < 3; i++) {

414 System.out.println(Thread.currentThread().getName()

415 + "执行了第" + i + "次");

416 }

417 }

418 };

419 es.submit(run);

420 es.submit(run);

421 es.shutdown();

422 }

423 }

程序的运行结果如图7.21所示。

图7.21 例7-19运行结果

例7-19中,调用Executors类的newFixedThreadPool(int nThreads)方法,创建了一个大小为10的线程池,向线程池中添加了2个线程,两个线程分别循环打印执行了第几次,最后关闭线程池。

7.9 本章小结

通过本章的学习,能够掌握Java多线程的相关知识。重点要了解的是Java的多线程机制可以同时运行多个程序块,从而使程序有更好的用户体验,也解决了传统程序设计语言所无法解决的问题。

7.10 习题

1.填空题

(1) ________是Java程序的并发机制,它能同步共享数据、处理不同的事件。

(2) 线程有新建、就绪、运行、 和死亡五种状态。

(3) JDK5.0以前,线程的创建有两种方法:实现________接口和继承Thread类。

(4) 多线程程序设计的含义是可以将程序任务分成几个________的子任务。

(5) 在多线程系统中,多个线程之间有________和互斥两种关系。

2.选择题

(1) 线程调用了sleep()方法后,该线程将进入( )状态。

A.可运行状态 B.运行状态

C.阻塞状态D.终止状态

(2) 关于Java线程,下面说法错误的是( )。

A.线程是以CPU为主体的行为B.Java利用线程使整个系统成为异步

C.继承Thread类可以创建线程D.新线程被创建后,它将自动开始运行

(3) 线程控制方法中,yield()的作用是( )。

A.返回当前线程的引用B.使比其低的优先级线程执行C.强行终止线程D.只让给同优先级线程运行

(4) 当( )方法终止时,能使线程进入死亡状态。

A.run()B.setPrority()

C.yield()D.sleep()

(5) 线程通过( )方法可以改变优先级。

A.run()B.setPrority()

C.yield()D.sleep()

3.思考题

(1) 请简述什么是线程?什么是进程?

(2) 请简述Java有哪几种创建线程的方式?

(3) 请简述什么是线程的生命周期?

(4) 请简述启动一个线程是用什么方法?

4.编程题

(1) 利用多线程设计一个程序,同时输出10以内的奇数和偶数,以及当前运行的线程名称,输出数字完毕后输出end。

(2) 编写一个继承Thread类的方式实现多线程的程序。该类MyThread有两个属性,一个字符串WhoAmI代表线程名,一个整数delay代表该线程随机要休眠的时间。利用有参的构造函数指定线程名称和休眠时间,休眠时间为随机数,线程执行时,显示线程名和要休眠时间。最后,在main()方法中创建三个线程对象以展示执行情况。

标签: #java234