
JAVA编程讲义之J多线程
前面我们进行的都是单线程编程,即一个程序只有一个从头到尾的执行路径。这样做的优点是易于编程,无需考虑过多的情况。但是,由于单线程需要在上一个任务完成之后才开始下一个任务,所以其效率比较低。在真实的项目运行过程中都具有多任务同时执行的特点,比如项目中用到的数据库连接池,这些任务在执行的时候互不干扰,这就需要多线程技术。
14.1 线程概述
随着计算机技术的飞速发展,计算机的操作系统一般都是支持多任务的,即在同一个时间内执行多个程序,一般的操作系统都需要引入多进程与多线程技术。
14.1.1 进程
在学习线程之前,需要先简单了解一下什么是进程。进程是程序的一次执行过程,是系统运行程序的基本单位。在操作系统中,每个独立执行的程序都可以称之为是一个进程,包括创建、运行、消亡3个阶段。在操作系统中,进程是独立存在的,它拥有自己独立的资源,多个进程可以在同一个处理器上并发执行且互不影响。
例如,每一台计算机都可以同时运行腾讯QQ以及QQ音乐两个程序,在听音乐时聊天。此时,同时按下“Ctrl+Alt+Del”打开Windows任务管理器,在进程选项卡中就可以查看进程,如图14.2所示。图14.2中,可以看到腾讯QQ、QQ音乐以及此时电脑正在运行的其他程序,将软件正常关闭或者右键结束进程,都可以使这个进程消亡。
需要明确指出的的是,表面上看操作系统中是多个进程同时执行的,如图14.2中所示的腾讯QQ、QQ音乐以及其他程序都在同时执行,但实际上这些进程并不是同时运行。因为,计算机中所有的程序都是由CPU执行的,且一般的计算机都只有一个CPU,而一个CPU只能同时执行一个进程,但是操作系统会给各个同时打开的程序分配占用时间,在这段时间里可以执行QQ聊天,当这段时间段过了则切换到QQ音乐,之后在切换到其他程序。由于CPU的执行速度很快,人们根本发觉不到它是在切换执行,所以会有一种计算机同时执行多个程序的感觉。
知识点拨:中央处理器(central processing unit,简称CPU)作为计算机系统的运算和控制核心,是信息处理、程序运行的最终执行单元。它是一块超大规模的集成电路,它的功能主要是解释计算机指令以及处理计算机软件中的数据。CPU的能力高低直接影响了整个电脑的运行速度。
14.1.2 线程
通过前面关于进程的讲解我们可以知道,每个程序都是一个进程。但是,现在流行的操作系统不但支持多进程,还支持多线程,在一个进程中还可以有多个执行单元同时执行,这些执行单元就称为线程。换句话说,操作系统可以同时执行多个任务,每个任务就是一个进程,每个进程又可以同时执行多个子任务,每个子任务就是一个线程。例如,图14.1所示的计算机运行状态中,腾讯QQ就是一个进程,然而我们在聊天的时候可以同时打开多个聊天窗口,并且互不影响,这就是多个线程同时运行。打开Windows任务管理器,点击性能选项卡,可以查看当前系统的线程数,如图14.2所示。图14.2显示,当前系统的总进程数为213、总线程数为2773,总线程数要比总进程数多很多,原因就是一个进程里面可以有多个线程在同时执行。
所谓多线程,指的就是在一个进程中多个线程可以同时存在、同时运行、互不影响。
当有多个线程在操作时,如果系统只有一个CPU,则它根本不可能真正同时进行一个以上的线程,它只能把CPU运行时间划分成若干个时间段,再将时间段分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状,这种方式称为并发(Concurrent)。并发环境是以“挂起→执行→挂起”的方式将很小的时间片分给各线程,给用户一种线程在同时运行的错觉。在并发环境中,多线程缩短了系统的响应时间,给用户更好的体验。
图14.1 软件进程 图14.2 当前系统线程
进程和线程一样都是实现并发机制的一种手段,进程是可以独立运行的一段程序,线程是比进程更小的执行单位。一个线程只能属于一个进程,一个进程可以拥有多个线程。线程一般不拥有自己的系统资源,但是可以访问其隶属的进程的资源。如图14.3所示,给出了进程与线程的关系结构。
图14.3 进程与线程的关系结构
Java语言对多线程提供直接支持,通过其设定的机制组织代码,可以将按照逻辑顺序执行的代码片段转成并发执行,而每一个代码片段还是一个逻辑上比较完整的程序代码段。
14.2 多线程的实现
Java语言提供了3种实现多线程的方式:继承Thread类实现多线程、实现Runnable接口实现多线程、使用Callable接口和Future接口实现多线程。
14.2.1 继承Thread类实现多线程
Java提供了Thread类,代表线程,它位于java.lang包中,开发人员可以通过继承Thread类来创建并启动多线程,具体步骤如下:
• 从Thread类派生出一个子类,并且在子类中重写run()方法。
• 用这个子类创建一个实例对象。
• 调用对象的start()方法启动线程。
启动一个新线程时,需要创建一个Thread类的实例, Thread类的常用构造方法如表14.1所示。
表14.1 Thread类常用构造法
构造方法声明
方法描述
public Thread()
创建新的Thread对象,自动生成的线程名称为 "Thread-"+n,其中n为整数
public Thread(String name)
创建新的Thread对象,name是新线程的名称
public Thread(Runnable target)
创建新的Thread对象,其中target是run()方法被调用时的对象
public Thread(Runnable target, String name)
创建新的Thread对象,其中target是run()方法被调用时的对象,name是新线程的名字
表14.1中列出了Thread类中的常用构造方法,创建线程实例的时候需要使用这些构造方法,线程中真正的功能代码写在这个类的run()方法中。当一个类继承Thread类之后,要重写父类的run()方法。另外,Thread类还有一些常用方法,如表14.2所示。
表14.2 Thread类常用方法
常用方法声明
方法描述
String getName()
返回该线程的名称
Thread.State getState()
返回该线程的状态
boolean isAlive()
判断该线程是不是处于活跃状态
void setName(String name)
更改线程的名字,使其与参数的name保持一致
void start()
开始执行线程,Java 虚拟机调用该线程里面的 run() 方法
static void sleep(long millis)
在指定的毫秒数内让当前正在执行的线程休眠(暂停执行),此操作受到系统计时器和调度程序精度和准确性的影响
static Thread currentThread()
返回当前正在运行的线程的对象的引用
接下来,通过案例来演示使用继承Thread类的方式创建多线程,如例14-1所示。
例14-1 Demo1401.java
1 package com.aaa.p140201;
2
3 public class Demo1401 {
4 public static void main(String[] args) {
5 MyThread myThread1 = new MyThread(); // 创建MyThread实例对象
6 MyThread myThread2 = new MyThread();
7 myThread1.start(); // 开启线程
8 myThread2.start();
9 }
10 }
11 class MyThread extends Thread {
12 public void run() { // 重写run()方法
13 for (int i = 0; i < 10; i++) {
14 if (i % 2 != 0) {
15 System.out.println(Thread.
16 currentThread().getName() + ":" + i);
17 }
18 }
19 }
20 }
程序的运行结果如下:
Thread-0:1
Thread-1:1
Thread-0:3
Thread-1:3
Thread-0:5
Thread-0:7
Thread-0:9
Thread-1:5
Thread-1:7
Thread-1:9
例14-1中,声明了一个类MyThread类,继承Thread类,并且在类中重写了run()方法,方法的功能是循环打印小于10的奇数,其中currentThread()方法是Thread类的静态方法,调用该方法返回的是当前正在执行的线程对象的引用。Demo1401类在main()方法中创建了两个MyThread类的实例对象,分别调用实例对象的start()方法启动两个线程,两个线程都运行成功。以上就是继承Thread类创建多线程的方式。
注意:如果start()方法调用一个已经启动的线程,程序会报IllegalThreadStateException异常。
14.2.2 实现Runnable接口实现多线程
Runnable是Java中用于实现线程的接口,从理论上来讲,任何实现线程功能的类都必须实现该接口。第14.2.1节讲到的继承Thread类的方式创建多线程,实际上就是因为Thread类实现了Runnable接口,所以它的子类才具有了线程的功能。但是,Java只支持单继承,一个类只能有一个父类,当一个类继承Thread类之后就不能再继承其他类,因此可以用实现Runnable接口的方式创建多线程,这种创建线程的方式更具有灵活性,同时可令用户线程能够具有其他类的一些特性,所以这种方法是经常使用的。通过实现Runnable接口创建并启动多线程的步骤如下:
• 定义Runnable接口实现类,并重写run()方法。
• 创建Runnable接口实现类的实例对象,并将该实例对象传递给Thread类的一个构造方法,该实例对象提供线程体run()方法。
• 调用实例对象的start()方法启动线程。
接下来,通过案例来演示如何通过实现Runnable接口的方式创建多线程,如例14-2所示。
例14-2 Demo1402.java
1 package com.aaa.p140202;
2
3 public class Demo1402 {
4 public static void main(String[] args) {
5 MyThread myThread = new MyThread(); // 创建myThread实例
6 //第1个参数是myThread对象,第2个参数是线程名称
7 new Thread(myThread, "线程1").start();// 启动线程
8 new Thread(myThread, "线程2").start();
9 }
10 }
11 class MyThread implements Runnable {
12 public void run() { // 重写run()方法
13 for (int i = 0; i < 10; i++) {
14 if (i % 2 != 0) {
15 System.out.println(Thread.
16 currentThread().getName() + ":" + i);
17 }
18 }
19 }
20 }
程序的运行结果如下:
线程1:1
线程1:3
线程1:5
线程1:7
线程1:9
线程2:1
线程2:3
线程2:5
线程2:7
线程2:9
例14-2中,MyThread类实现了Runnable接口并且重写了run()方法,方法的功能是循环打印小于10的奇数。Demo1402类在main()方法中以MyThread类的实例分别创建并开启两个线程对象,调用public Thread(Runnable target, String name)构造方法的目的是指定线程的名称“线程1”和“线程2”。以上就是通过实现Runnable接口的方式创建多线程。
14.2.3 通过Callable接口和Future接口实现多线程
前文讲解了创建多线程的两种方式,但是这两种方式都有一个缺陷,在执行完任务之后无法获取线程的执行结果,如果想要获取执行结果,就必须通过共享变量或者使用线程通信的方式来达到效果,这样使用起来就比较麻烦。于是,JDK5.0后Java便提供了Callable接口来解决这个问题,接口内有一个call()方法,这个方法是线程执行体,有返回值且可以抛出异常。通过实现Callable接口创建并启动多线程的步骤如下:
• 定义Callable接口实现类,指定返回值的类型,并重写call()方法。
• 创建Callable实现类的实例。
• 使用FutureTask类来包装Callable对象,该FutureTask对象封装了Callable对象的call()方法的返回值。
• 将FutureTask类的实例注册进入Thread中并启动线程。
• 采用FutureTask
Callable接口不是Runnable接口的子接口,所以不能直接作为Thread类构造方法的参数,而且call()方法有返回值,是被调用者。JDK5.0中提供了Future接口,该接口有一个FutureTask实现类,该类实现了Runnable接口,封装了Callable对象的call()方法的返回值,所以该类可以作为参数传入Thread类中。接下来先了解一下Future接口的方法,如表14.3所示。
表14.3 Future接口的方法
接口方法声明
方法描述
boolean cancel(boolean b)
试图取消对该任务的执行
V get()
如有必要,等待计算完成,然后获取其结果
V get(long timeout, TimeUnit unit)
如有必要,最多等待使计算完成所用时间之后,获取其结果(若结果可用)
boolean isCancelled()
如果在任务正常完成前将其取消,则返回 true
boolean isDone()
如果任务已完成,则返回 true
接下来,通过案例来演示如何通过Callable接口和Future接口创建多线程,如例14-3所示。
例14-3 Demo1403.java
1 package com.aaa.p140203;
2 import java.util.concurrent.Callable;
3 import java.util.concurrent.FutureTask;
4
5 public class Demo1403 {
6 public static void main(String[] args) {
7 Callable
8 // 使用FutureTask来包装Callable对象
9 FutureTask
10 for (int i = 0; i < 15; i++) {
11 System.out.println(Thread.currentThread().getName() + ":" + i);
12 if (i == 1) {
13 // FutureTask对象作为Thread对象的参数创建新的线程
14 Thread thread = new Thread(futureTask);
15 thread.start(); // 启动线程
16 }
17 }
18 System.out.println("主线程循环执行完毕..");
19 try {
20 // 取得新创建线程中的call()方法返回值
21 String result = futureTask.get();
22 System.out.println("result = " + result);
23 } catch (Exception e) {
24 e.printStackTrace();
25 }
26 }
27 }
28 class MyThread implements Callable
29 public String call() {
30 for (int i = 10; i > 0; i--) {
31 System.out.println(Thread.currentThread().getName() + "倒计时:" + i);
32 }
33 return "线程执行完毕!!!";
34 }
35 }
程序的第1次运行结果如下:
main:0
main:1
main:2
main:3
main:4
main:5
main:6
main:7
main:8
main:9
main:10
main:11
main:12
main:13
main:14
主线程循环执行完毕..
Thread-0倒计时:10
Thread-0倒计时:9
Thread-0倒计时:8
Thread-0倒计时:7
Thread-0倒计时:6
Thread-0倒计时:5
Thread-0倒计时:4
Thread-0倒计时:3
Thread-0倒计时:2
Thread-0倒计时:1
result = 线程执行完毕!!!
程序的第2次运行结果如下:
main:0
main:1
main:2
main:3
main:4
main:5
main:6
main:7
main:8
main:9
Thread-0倒计时:10
main:10
Thread-0倒计时:9
main:11
Thread-0倒计时:8
main:12
Thread-0倒计时:7
main:13
main:14
主线程循环执行完毕..
Thread-0倒计时:6
Thread-0倒计时:5
Thread-0倒计时:4
Thread-0倒计时:3
Thread-0倒计时:2
Thread-0倒计时:1
result = 线程执行完毕!!!
例14-3中,MyThread类实现了Callable接口,指定了返回值的类型并且重写了call()方法。该方法主要是用于打印倒计时的时间。main()方法中执行15次循环,并且在循环的过程中启动子线程并获取子线程的返回值。
反复执行例14-3的程序,会发现有一个规律:“result = 线程执行完毕!!!”一直都是在最后输出,而“主线程循环执行完毕..”输出的位置则不固定,有时候会在子线程循环前,有时候会在子线程循环后,有时候也会在子线程循环中。之所以会出现这种现象,是因为通过get()方法获取子线程的返回值时,子线程的方法没有执行完毕,所以get()方法就会阻塞,当子线程中的call()方法执行完毕,get()方法才能取到返回值。以上就是使用Callable接口和Future接口的方式创建多线程。
14.2.4 三种实现多线程方式的对比分析
前面讲解了创建多线程的3种方式,这3种方式各有优缺点,具体如表14.4所示。
表14.4 三种实现多线程方式的对比
实现方式
优劣
具体内容
继承Thread类创建多线程
优点
程序代码简单
使用run()方法可以直接调用线程的其他方法
缺点
只能继承Thread类
不能实现资源共享
实现Runnable接口创建多线程
优点
符合面向对象的设计思想
便于继承其他的类
能实现资源共享
缺点
编程比较复杂
使用Callable接口和Future接口创建多线程
优点
便于继承其他的类
有返回值,可以抛异常
缺点
编程比较复杂
表14.4列出了3种创建多线程方式的优点和缺点,想要代码简洁就采用第1种方式,想要实现资源共享就采用第2种方式,想要有返回值并且能抛异常就采用第3种方式。
14.2.5 后台线程
Java中有一种线程,它是在后台运行的,它的主要任务就是为其他线程提供服务,这种线程被称为后台线程或守护线程。JVM的垃圾回收机制使用的就是后台线程。
后台线程有一个重要的特征:如果所有的前台线程都死亡,后台线程会自动死亡。
调用Thread类的setDaemon(true)方法可以将指定的线程设置为后台线程,所有的前台线程都死亡的时候,后台线程就会自动死亡。Thread类还提供了一个isDaemon()方法,该方法主要是用于判断一个线程是否是一个后台线程,
接下来,通过案例来演示后台线程的使用,如例14-4所示。
例14-4 Demo1404.java
1 package com.aaa.p140205;
2
3 public class Demo1404 {
4 public static void main(String[] args) {
5 // 创建MyThread类实例
6 System.out.println("青年学子梁山伯辞家求学,路上偶遇女扮男装的学子祝英台,");
7 System.out.println("两人一见如故,志趣相投,遂于草桥结拜为兄弟。");
8 MyThread1 myThread1 = new MyThread1("梁山伯:");
9 myThread1.start();// 开启线程
10 MyThread2 myThread2 = new MyThread2("祝英台:");
11 myThread2.setDaemon(true);
12 myThread2.start();// 开启线程
13 }
14 }
15 class MyThread1 extends Thread {
16 private String socialStatus;
17 public MyThread1(String socialStatus) {
18 this.socialStatus = socialStatus;
19 }
20 @Override
21 public void run() {
22 for (int i = 1; i <= 20; i++) {
23 System.out.println(socialStatus + i);
24 }
25 }
26 }
27 class MyThread2 extends Thread {
28 private String socialStatus;
29 public MyThread2(String socialStatus) {
30 this.socialStatus = socialStatus;
31 }
32 @Override
33 public void run() {
34 for (int i = 1; i <= 100; i++) {
35 System.out.println(socialStatus + i);
36 }
37 }
38 }
程序的运行结果如下:
青年学子梁山伯辞家求学,路上偶遇女扮男装的学子祝英台,
两人一见如故,志趣相投,遂于草桥结拜为兄弟。
梁山伯:1
梁山伯:2
祝英台:1
梁山伯:3
梁山伯:4
梁山伯:5
梁山伯:6
梁山伯:7
梁山伯:8
梁山伯:9
梁山伯:10
祝英台:2
梁山伯:11
祝英台:3
梁山伯:12
梁山伯:13
梁山伯:14
梁山伯:15
祝英台:4
梁山伯:16
祝英台:5
梁山伯:17
祝英台:6
祝英台:7
祝英台:8
祝英台:9
祝英台:10
梁山伯:18
梁山伯:19
祝英台:11
梁山伯:20
祝英台:12
祝英台:13
祝英台:14
祝英台:15
祝英台:16
祝英台:17
祝英台:18
祝英台:19
例14-4中,MyThread1与MyThread2类继承了Thread类并且实现了run()方法,MyThread1中的run()方法调用20次循环,MyThread2中的run()方法调用100次循环,Demo1404类在main()方法中分别创建MyThread1与MyThread2的实例,MyThread2类中调用setDaemon(true),此时该线程被设置为后台线程。开启前台线程和后台线程就发现MyThread2线程本应该执行循环100次,但是结果发现执行19次就结束了,这是因为前台线程执行完毕后,线程死亡,只剩下后台线程,当线程只剩下后台线程的时候程序就没有执行的必要了,所以后台线程也会随之退出。这就是后台线程的基本使用。
注意:setDaemon(true)必须在start()方法之前调用,否则会引发异常。
14.3 线程的生命周期
在讲解了线程的创建及使用之后,下面再来讲解一下线程的生命周期。在Java中,任何对象都有生命周期,线程也不例外。线程有新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)和死亡(Terminated)5种状态,从新建到死亡称之为线程的生命周期,如图14.4所示。
14.3.1 新建状态和就绪状态
当程序使用new关键字创建一个线程后,该线程处于新建状态,此时JVM给它分配一块内存,但不可运行。
当线程对象调用了start()方法之后,该线程处于就绪状态,JVM会为它创建方法调用栈和程序计数器。处于就绪状态的线程并没有开始运行,只是表示该线程可以运行了。获得CPU的使用权之后线程即可开始运行。
注意:启动线程使用的是start()方法,而不是run()方法!如果直接调用run()方法,系统会把当前的线程识别为一个普通的对象,而run()方法也就是一个普通的方法,并不是线程的执行体。
接下来,通过案例来演示线程的启动,如例14-5所示。
例14-5 Demo1405.java
1 package p140301;
2
3 public class Demo1405 {
4 public static void main(String[] args) throws InterruptedException {
5 new MyThread().run();
6 new MyThread().run();
7 }
8 }
9 class MyThread extends Thread {
10 @Override
11 public void run() {
12 for (int i = 0; i < 5; i++) {
13 System.out.println(Thread.currentThread().getName()+" "+i);
14 }
15 }
16 }
程序的运行结果如下:
main 0
main 1
main 2
main 3
main 4
main 0
main 1
main 2
main 3
main 4
线程创建之后如果直接调用run()方法,程序的运行结果是整个程序只有一个线程——主线程。通过上面的程序运行结果不难看出,启动线程的正确方法是调用线程的start()方法,而不是直接调用run()方法,否则就会变成单线程。
14.3.2 运行状态和阻塞状态
运行状态是指处于就绪状态的线程占用了CPU,执行程序代码。并发执行时,如果CPU的占用时间超时,则会执行其他线程。只有处于就绪状态的线程才可以转换到运行状态。
阻塞状态是指线程因为一些原因放弃CPU使用权,暂时停止运行。当线程处于阻塞状态时,Java虚拟机不会给线程分配CPU,直到线程重新进入就绪状态,它才有机会转换到运行状态。
下面列举一下线程由运行状态转换成阻塞状态的原因,以及如何从阻塞状态转换成就绪状态:
• 当线程调用了某个对象的suspend()方法时,也会使线程进入阻塞状态,如果想进入就绪状态需要使用resume()方法唤醒该线程。
• 当线程试图获取某个对象的同步锁时,如果该锁被其他线程持有,则当前线程就会进入阻塞状态,如果想从阻塞状态进入就绪状态必须获取到其他线程持有的锁。关于锁的概念,会在14.5.2节详细讲解。
• 当线程调用了Thread类的sleep()方法时,也会使线程进入阻塞状态,在这种情况下,需要等到线程睡眠的时间结束,线程会自动进入就绪状态。关于线程休眠的概念,会在第14.4.2节详细讲解。
• 当线程调用了某个对象的wait()方法时,也会使线程进入阻塞状态,如果想进入就绪状态,需要使用notify()方法或notifyAll()方法唤醒该线程。关于wait()会在14.5.4节详细讲解。
• 当在一个线程中调用了另一个线程的join()方法时,会使当前线程进入阻塞状态,在这种情况下,要等到新加入的线程运行结束才会结束阻塞状态,进入就绪状态。调用join()方法,意味着线程插队,关于线程插队的概念,会在第14.4.4节详细讲解。
注意:线程从阻塞状态只能进入就绪状态,不能直接进入运行状态。
14.3.3 死亡状态
线程会以如下方式结束,结束之后线程就处于死亡状态:
• 线程的run()方法正常执行完毕,线程正常结束。
• 线程抛出异常(Exception)或错误(Error)导致线程死亡。
• 调用线程对象的stop()方法结束线程。
线程一旦转换为死亡状态,就不能运行且不能转换为其他状态。
注意:不要对处于死亡状态的线程调用start()方法,程序只能对新建状态的线程调用start()方法。判断线程是否死亡可以使用线程的isAlive()方法,当线程处于就绪、运行、阻塞这3种状态时,放方法返回true。当线程处于新建和死亡状态时,该方法返回false。
14.4 线程的调度
通过前面的学习我们知道,线程就绪之后就可以运行,但这并不意味着这个线程能够立刻运行,如果想让线程运行就必须获得CPU的使用权。因为多线程是并发运行的,所以在一台只有一个CPU的计算机上就必须考虑CPU是如何分配的。线程的调度就是为线程分配CPU使用权的,常用如下两种模型:
• 分时调度模型:让所有的线程轮流获得CPU的使用权,平均分配每个线程占用CPU的时间。
• 抢占式调度模型:优先让可运行池中优先级高的线程占用CPU,若运行池中线程优先级相同,则遵循“先进先出”的原则。
本节就来详细讲解线程调度的相关知识。
14.4.1 线程的优先级
所有处于就绪状态的线程会根据他们的优先级存放在可运行池中,优先级高的线程运行的机会比较多,优先级低的线程运行机会比较少。Thread类的setPriority(int newPriority)方法用于设置线程的优先级,getPriority()方法用于获取线程的优先级。优先级可以用Thread类中的静态常量来表示,如表14.5所示。
表14.5 Thread类的静态常量
常量声明
方法描述
static int MAX_PRIORITY
取值为10,表示最高优先级。
static int NORM_PRIORITY
取值为5,表示默认优先级。
static int MIN_PRIORITY
取值为1,表示最低优先级。
表14.4中列出了Thread类中与优先级有关的3个静态常量,在设置线程的优先级的时候可以使用这些静态常量。
接下来,通过案例来演示线程优先级的使用,如例14-6所示。
例14-6 Demo1406.java
1 package com.aaa.p140401;
2
3 public class Demo1406 {
4 public static void main(String[] args) throws InterruptedException {
5 // 创建MyThread实例
6 System.out.println("吃饭时吃菜的顺序:");
7 MyThread myThread1 = new MyThread("水煮肉片");
8 MyThread myThread2 = new MyThread("酱焖茼蒿");
9 MyThread myThread3 = new MyThread("树根炒树皮");
10 myThread1.setPriority(Thread.MIN_PRIORITY); // 设置优先级
11 myThread2.setPriority(Thread.MAX_PRIORITY);
12 myThread3.setPriority(Thread.NORM_PRIORITY);
13 myThread1.start(); // 开启线程
14 myThread2.start();
15 myThread3.start();
16 }
17 }
18
19 class MyThread extends Thread {
20 private final String Cuisine;
21
22 public MyThread(String Cuisine) {
23 this.Cuisine = Cuisine;
24 }
25
26 @Override
27 public void run() {
28 for (int i = 0; i < 5; i++) {
29 System.out.println(Cuisine + i);
30 }
31 }
32 }
程序的运行结果如下:
吃饭时吃菜的顺序:
酱焖茼蒿0
树根炒树皮0
酱焖茼蒿1
酱焖茼蒿2
酱焖茼蒿3
酱焖茼蒿4
树根炒树皮1
树根炒树皮2
树根炒树皮3
树根炒树皮4
水煮肉片0
水煮肉片1
水煮肉片2
水煮肉片3
水煮肉片4
例14-6中,声明了MyThread类,继承Thread类并在类中重写了run()方法,run()方法内循环打印结果。Demo1406类在main()方法中先创建了3个MyThread类的实例并指定线程的名称,再使用setPriority(int newPriority)方法设置线程的优先级,最后调用start()方法启动线程,从执行结果来看,优先级高的会优先执行。但是需要注意的是,优先级比较低的不一定永远最后执行,也有可能先执行,只不过机率稍微小一点。
注意:Thread类的setPriority(int newPriority)方法可以设置10种优先级,但是优先级的级别需要操作系统的支持,不同的操作系统上支持的优先级也各不同,所以要尽量避免直接用数字指定线程优先级,应该使用Thread类的3个静态常量指定线程优先级别,这样可以保证程序有很好的可移植性。
14.4.2 线程休眠sleep()
线程的调度是按照线程的优先级的高低顺序抢占CPU资源的,优先级高的线程会优先抢占CPU资源,线程不执行完,优先级低的线程就无法抢占CPU资源。Thread类提供了sleep()方法,该方法可使正在执行的线程进入阻塞状态,也叫线程休眠,休眠时间内该线程是不运行的,休眠时间结束后线程才继续运行。如果想让优先级低的线程抢占CPU资源,就需要调用sleep()方法,该方法是人为地控制线程,让正在执行的线程暂停一段固定的时间,在暂停的时间内,线程让出CPU资源,让优先级低的线程有机会运行。休眠方法结束之后,线程将进入可运行状态。
sleep()方法有两种形式,具体如下:
static void sleep(long millis)
static void sleep(long millis, int nanos)
上述两种形式,第1种中的参数指的是线程休眠的毫秒数,第2种的参数指的是线程休眠的毫秒数和纳秒数。使用sleep(long millis)方法时会报InterruptedException异常,此时必须要捕获异常或抛出异常。
接下来,通过案例来演示线程休眠,如例14-7所示。
例7-%2 Demo1407.java
1 package com.aaa.p140402;
2 import java.text.SimpleDateFormat;
3 import java.util.Date;
4
5 public class Demo1407 {
6 public static void main(String[] args) throws InterruptedException {
7 // 创建MyThread实例
8 System.out.println("吃饭时吃菜的顺序");
9 MyThread myThread1 = new MyThread("水煮肉片");
10 MyThread myThread2 = new MyThread("酱焖茼蒿");
11 MyThread myThread3 = new MyThread("树根炒树皮");
12 myThread1.setPriority(Thread.MIN_PRIORITY); // 设置优先级
13 myThread2.setPriority(Thread.MAX_PRIORITY);
14 myThread3.setPriority(Thread.NORM_PRIORITY);
15 myThread3.start(); // 开启线程
16 Thread.sleep(2000);
17 myThread2.start();
18 Thread.sleep(2000);
19 myThread1.start();
20 }
21 }
22
23 class MyThread extends Thread {
24 private final String Cuisine;
25
26 public MyThread(String Cuisine) {
27 this.Cuisine = Cuisine;
28 }
29
30 @Override
31 public void run() {
32 for (int i = 0; i < 5; i++) {
33 System.out.println(Cuisine + i);
34 }
35 }
36 }
程序的运行结果如下:
吃饭时吃菜的顺序
树根炒树皮0
树根炒树皮1
树根炒树皮2
树根炒树皮3
树根炒树皮4
酱焖茼蒿0
酱焖茼蒿1
酱焖茼蒿2
酱焖茼蒿3
酱焖茼蒿4
水煮肉片0
水煮肉片1
水煮肉片2
水煮肉片3
水煮肉片4
例14-7中,线程启动后调用Thread类的sleep()方法,让程序休眠2秒,打印的结果跟例14-6对比可以看到有很明显的差别。
14.4.3 线程让步yield()
Thread类还提供一个yield()方法,该方法和sleep()方法类似,它也可以让当前正在执行的线程暂停,sleep()方法在暂停期间对象锁不释放从而导致线程阻塞,而yield()方法只是将线程的状态转化为就绪状态,等待线程调度器的再次调用,线程调度器有可能会将刚才处于就绪状态的线程重新调度出来,这就是线程让步。
接下来,通过案例来演示线程让步。如例14-8所示。
例14-8 Demo1408.java
1 package com.aaa.p140403;
2
3 public class Demo1408 {
4 public static void main(String[] args) throws InterruptedException {
5 // 创建MyThread实例
6 System.out.println("吃饭时吃菜的顺序:");
7 MyThread myThread1 = new MyThread("水煮肉片");
8 MyThread myThread2 = new MyThread("酱焖茼蒿");
9 myThread1.setPriority(Thread.MAX_PRIORITY); // 设置优先级
10 myThread2.setPriority(Thread.MIN_PRIORITY);
11 myThread1.start();
12 myThread2.start();
13 }
14 }
15
16 class MyThread extends Thread {
17 private final String Cuisine;
18
19 public MyThread(String Cuisine) {
20 this.Cuisine = Cuisine;
21 }
22
23 @Override
24 public void run() {
25 for (int i = 0; i < 5; i++) {
26 Thread.yield(); // 设置线程让步
27 System.out.println(Cuisine + i);
28 }
29 }
30 }
程序的运行结果如下:
吃饭时吃菜的顺序:
水煮肉片0
酱焖茼蒿0
水煮肉片1
酱焖茼蒿1
水煮肉片2
酱焖茼蒿2
酱焖茼蒿3
酱焖茼蒿4
水煮肉片3
水煮肉片4
例14-8中,声明MyThread类,继承Thread类,并实现了run()方法,方法内循环打印数字0~4,每次打印前调用yield()方法线程让步。Demo1408类在main()方法中创建MyThread类实例,分别创建并开启两个线程。这里注意,并不是线程执行到yield()方法就一定切换到其他线程,也有可能线程继续执行。
注意:调用yield()方法之后,可以使具有与当前线程相同优先级的线程有运行的机会。如果有其他的线程与当前的线程具有相同的优先级并且处于可运行状态,该方法会把调用yield()方法的线程放入可运行池中,并允许其他线程运行。如果没有同等的优先级的线程使可运行状态,yield()方法什么也不做,即该线程讲继续运行。
14.4.4 线程插队join()
Thread类提供了join()方法,当某个线程执行中调用其他线程的join()方法时,线程被阻塞,直到join()方法所调用的线程结束,这种情况称为线程插队。
接下来,通过案例来演示线程插队,如例14-9所示。
例14-9 Demo1409.java
1 package com.aaa.p140404;
2
3 public class Demo1409 {
4 public static void main(String[] args) throws Exception {
5 // 创建MyThread实例
6 System.out.println("吃饭时吃菜的顺序:");
7 MyThread myThread1 = new MyThread("酱焖茼蒿");
8 myThread1.start();// 开启线程
9 for (int i = 1; i < 6; i++) {
10 if (i == 3) {
11 try {
12 System.out.println("酱焖茼蒿要开始插队了...");
13 myThread1.join();// 线程插入
14 } catch (Exception e) {
15 e.printStackTrace();
16 }
17 }
18 System.out.println("水煮肉片" + i);
19 }
20 }
21 }
22 class MyThread extends Thread {
23 private String socialStatus;
24 private int tickets = 10;
25 public MyThread(String socialStatus) {
26 this.socialStatus = socialStatus;
27 }
28 @Override
29 public void run() {
30 for (int i = 1; i < 6; i++) {
31 System.out.println(socialStatus + i);
32 }
33 }
34 }
程序的运行结果如下:
吃饭时吃菜的顺序:
水煮肉片1
水煮肉片2
酱焖茼蒿要开始插队了...
酱焖茼蒿1
酱焖茼蒿2
酱焖茼蒿3
酱焖茼蒿4
酱焖茼蒿5
水煮肉片3
水煮肉片4
水煮肉片5
例14-9中,声明了MyThread类,继承Thread类并在类中实现了run()方法,方法内循环打印“酱焖茼蒿”。Demo1409类在main()方法中创建MyThread类实例并启动线程,main()方法中也循环打印吃菜的顺序,当变量i的值为3时,调用join()方法插入子线程后子线程开始执行,直到子线程执行完,main()方法的主线程才能继续执行。
14.5 多线程同步
前面讲解了线程的基本使用,多线程可以提高程序的运行效率,但是多线程也会导致很多不合理的现象的出现,比如在卖外卖的时候出现超卖的现象。之所以出现这些现象,是因为系统的调度具有随机性,多线程在操作同一数据时,很容易出现这种错误。接下来我们来讲解一下如何解决这种错误。
14.5.1 线程安全
关于线程安全,我们通过卖外卖来展示。卖外卖的基本流程大致为:首先,要知道一共有多少外卖,每卖掉1份外卖,对应的数量就会减1;其次,可以有多个窗口卖外卖,当外卖的数量为0时就停止售卖。如果是单线程,这个流程不会出现什么问题,但是如果这个流程放在多线程并发的情况下,就会出现超卖的情况。
接下来,我们通过案例来演示这个问题。如例14-10所示。
例14-10 Demo1410.java
1 package com.aaa.p140501;
2
3 public class Demo1410 {
4 public static void main(String[] args) {
5 Takeout takeout = new Takeout();
6 Thread t1 = new Thread(takeout);
7 Thread t2 = new Thread(takeout);
8 Thread t3 = new Thread(takeout);
9 t1.start();
10 t2.start();
11 t3.start();
12 }
13 }
14 class Takeout implements Runnable {
15 private int takeout = 5;
16
17 public void run() {
18 for (int i = 0; i < 100; i++) {
19 if (takeout> 0) {
20 try {
21 Thread.sleep(100);
22 } catch (InterruptedException e) {
23 e.printStackTrace();
24 }
25 System.out.println(
26 "卖出第" + takeout+ "份外卖,还剩" + --takeout + "份外卖");
27 }
28 }
29 }
30 }
程序的运行结果如下:
卖出第5份外卖,还剩4份外卖
卖出第5份外卖,还剩3份外卖
卖出第3份外卖,还剩2份外卖
卖出第2份外卖,还剩1份外卖
卖出第1份外卖,还剩0份外卖
卖出第0份外卖,还剩-1份外卖
例14-10中,声明了Takeout类,实现了Runnable接口。首先,在类中定义一个int类型的变量takeout,这个变量代表的是外卖的总数量;然后,重写run()方法,run()方法中循环卖外卖每卖1份外卖,外卖总数减1,为了演示可能出现的问题,通过调用sleep()的方法让程序在每次循环的时候休眠100毫秒;最后,Demo1410类在main()方法中创建并启动3个线程,模拟3个窗口同时卖外卖。运行结果可以看出,第5五份外卖重复卖了2次,剩余的外卖还出现了-1份。
例14-10中之所以会出现超卖的情况,是因为run()方法的循环中判断外卖总数量是否大于0,如果大于0就会继续售卖,但售卖的时候线程调用了sleep()方法导致程序每次循环都会休眠100毫秒,这就会出现,1个线程执行到此处休眠的同时,第2和第3个线程也进入执行,所以卖出的数量就会变多,这就是线程安全的问题。
14.5.2 多线程中的同步代码块
我们使用多个线程访问同一资源的时候,若多个线程只有读操作,那么不会发生线程安全问题,但是如果多个线程中对资源有读和写的操作,就容易出现线程安全问题。前面卖外卖的案例中就出现了线程安全的问题。为了解决这种问题,我们可以使用线程锁。
线程锁主要是给方法或代码块加锁。当某个方法或者代码块使用锁时,那么在同一时刻至多仅有一个线程在执行该段代码。当有多个线程访问同一对象的加锁方法或代码块时,同一时间只有一个线程在执行,其余线程必须要等待当前线程执行完之后才能执行该代码段。但是,其余线程可以访问该对象中的非加锁代码块。
Java的多线程引入了同步代码块,当多个线程使用同一个共享资源时,可以将处理共享资源的代码放置在一个使用synchronized关键字来修饰的代码块中。具体示例如下:
synchronized (obj) {
… // 要同步的代码块
}
Java中每个对象都有一个内置锁。当程序运行到synchronized同步代码块时,就会获得当前执行的代码块里面的对象锁。一个对象只有一个锁称为锁对象。如果一个线程获得该锁,其他线程就无法再次获得这个对象的锁,直到第一个线程释放锁。释放锁是指持线程退出了synchronized同步方法或代码块。
如上所示,synchronized(obj)中的obj就是同步锁,它是同步代码块的关键,当线程执行同步代码块时,会先检查同步监视器的标志位,默认情况下标志位为1。标志位为1的时候线程会执行同步代码块,同时将标志位改为0;当第2个线程执行同步代码块前,先检查标志位,如果检查到标志位为0,第2个线程就会进入阻塞状态;当第1个线程执行完同步代码块内的代码时,标志位重新改为1,第2个线程进入同步代码块。
接下来,通过修改例14-10的代码来演示如何使用同步代码块解决线程安全问题,如例14-11所示。
例14-11 Demo1411.java
1 package com.aaa.p140502;
2
3 public class Demo1411 {
4 public static void main(String[] args) {
5 Takeout takeout = new Takeout();
6 Thread t1 = new Thread(takeout);
7 Thread t2 = new Thread(takeout);
8 Thread t3 = new Thread(takeout);
9 t1.start();
10 t2.start();
11 t3.start();
12 }
13 }
14 class Takeout implements Runnable {
15 private int takeout = 5;
16
17 public void run() {
18 for (int i = 0; i < 100; i++) {
19 synchronized (this) { // this代表当前对象
20 if (takeout > 0) {
21 try {
22 Thread.sleep(100);
23 } catch (InterruptedException e) {
24 e.printStackTrace();
25 }
26 System.out.println("卖出第" + takeout + "份外卖,还剩" +
27 --takeout + "份外卖");
28 }
29 }
30 }
31 }
32 }
程序的运行结果如下:
卖出第5份外卖,还剩4份外卖
卖出第4份外卖,还剩3份外卖
卖出第3份外卖,还剩2份外卖
卖出第2份外卖,还剩1份外卖
卖出第1份外卖,还剩0份外卖
例14-11与前边的例14-10几乎是完全一样,区别就是例14-11在run()方法的循环中执行售卖操作时,将操作变量takeout的操作都放到同步代码块中,在使用同步代码块时必须指定一个需要同步的对象,一般使用当前对象(this)即可。将例14-10修改为例14-11后,多次运行该程序同样不会出现重复的售卖或超卖的情况。
注意:同步代码块中的锁对象可以是任意类型的对象,但多个线程共享的锁对象必须是相同的。“任意”说的是共享锁对象的类型。所以,锁对象的创建代码不能放到run()方法中,否则每个线程运行到run()方法都会创建一个新对象,这样每个线程都会有一个不同的锁,每个锁都有自己的标志位,线程之间便无法产生同步的效果。
14.5.3 synchronized修饰的同步方法
第14.5.3节讲解了使用同步代码块解决线程安全问题,另外Java还提供了同步方法,即用synchronized关键字修饰的方法,它的监视器是调用该方法的对象,使用同步方法同样可以解决线程安全的问题。
接下来,通过修改例14-10的代码来演示如何使用同步方法解决线程安全问题,如例14-12所示。
例14-12 Demo1412.java
1 package com.aaa.p140503;
2
3 public class Demo1412 {
4 public static void main(String[] args) throws Exception {
5 Takeout takeout = new Takeout();
6 Thread t1 = new Thread(takeout);
7 Thread t2 = new Thread(takeout);
8 Thread t3 = new Thread(takeout);
9 t1.start();
10 t2.start();
11 t3.start();
12 }
13 }
14
15 class Takeout implements Runnable {
16 private int takeout = 5;
17
18 public synchronized void run() {
19 for (int i = 0; i < 100; i++) {
20 if (takeout > 0) {
21 try {
22 Thread.sleep(100);
23 } catch (InterruptedException e) {
24 e.printStackTrace();
25 }
26 System.out.println(
27 "卖出第" + takeout + "份外卖,还剩" + --takeout + "份外卖");
28 }
29 }
30 }
31 }
程序的运行结果如下:
卖出第5份外卖,还剩4份外卖
卖出第4份外卖,还剩3份外卖
卖出第3份外卖,还剩2份外卖
卖出第2份外卖,还剩1份外卖
卖出第1份外卖,还剩0份外卖
例14-12与前边的例14-10几乎一样,区别就是例14-10的run()方法没有使用synchronized关键字修饰,将例14-10修改为例14-12后,多次运行程序不会出现超卖或者重复售卖的情况。
注意:同步方法的锁就是调用该方法的对象,也就是this所指向的对象,但是静态方法不需要创建对象就可以用“类名.方法名()”的方式进行调用,这时的锁则不再是this,静态同步方法的锁是该方法所在类的class对象,该对象可以直接用“类名.class”的方式获取。
14.5.4 生产者和消费者
不同的线程执行不同的任务,有些复杂的程序需要多个线程共同完成一个任务,这个时候就需要线程之间能够相互通信。线程通信中的一个经典问题就是生产者和消费者问题。java.lang包中Object类中提供了三种方法用于线程的通信。如表14.6所示
表14.6 Thread类的静态常量
方法
方法描述
void wait()
导致当前线程等待,直到另一个线程调用该对象的 notify() 方法或 notifyAll() 方法。
void notify ()
唤醒正在等待对象监视器的单个线程。
void notifyAll ()
唤醒正在等待对象监视器的所有线程。
表14.6列举了线程通信需要使用的三个方法,这三个方法只有在synchronized方法或synchronized代码块中才能使用,否则会报IllegalMonitorStateException异常。
生产者和消费者问题(Producer-consumer problem),也称有限缓冲问题(Bounded-buffer problem),是一个多线程同步问题的经典案例。该问题描述了两个共享固定大小缓冲区的线程(即所谓的“生产者”和“消费者”)在实际运行时会发生的问题。生产者的主要作用是生成一定量的数据放到缓冲区中,然后重复此过程。与此同时,消费者也在缓冲区消耗这些数据。如图14.5所示。
生产者和消费者问题会导致死锁的出现,下面简单介绍一下死锁。
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。
生产者和消费者问题如果不加以协调可能会出现以下情况:缓冲区中数据已满,而生产者依然占用着它,消费者等着生产者让出空间从而去消费产品,生产者等着消费者消费产品,从而向空间中添加产品。互相等待,从而发生死锁。
接下来,通过一个案例来演示如何解决生产者和消费者问题,如例14-13所示。
例14-13 Demo1413.java
1 package com.aaa.p140504;
2
3 import java.util.LinkedList;
4 public class Demo1413 {
5 private static final int MAX_NUM = 5; // 设置仓库的最大值
6 private LinkedList