第 9章 多线程编程
多线程是 Java程序设计语言的一个亮点,它使用户可以很方便地编写多线程程序,虽然编写多线程代码需要考虑诸如安全、死锁、
资源共享的问题,但是总体上讲 Java在编写多线程程序上比其他语言都要简洁。
使用多线程最直接的例子是具有用户界面的程序。如果用户界面上设计了一个按钮,一旦单击该按钮程序会自动在网络上搜索指定数据,当然这个过程会持续一段时间。如果没有多线程实现技术,就会出现用户界面无法控制的局面,即在网络数据搜索完之前,用户界面根本不响应其他界面输入。整个界面象是静止在那里而无法操作。而我们希望不管系统当前在完成什么任务,都允许用户操作界面元素,如查询数据,完成其他信息的处理等。这样就要求程序可以同时执行多个任务,响应用户的不同操作请求。
对于用户而言就仿佛有多个处理器在为其工作。而在单处理器的计算机上完成程序的多任务功能就需要多线程技术。
多线程技术可以模拟多处理器的效果,对用户而言计算机同时完成一个程序的多个任务,而实际上该机制使得计算机把 CPU周期按照一定策略分配给每一个线程,而高速的 CPU使得用户觉得计算机在同时完成多个任务。
9.1 线程概述
线程是操作系统的概念,线程也称之为轻量级进程
( lightweight process LWP),是 CPU的基本使用单元,
它的轻量级名称是和进程相关的。线程由线程 ID、程序记数器、寄存器和堆栈组成,多个线程可以共享代码段、数据段和诸如打开的文件等的系统资源。而传统的进程其实就是单线程控制程序,每个进程都有自己的代码段、数据段和其他系统资源。这无疑使得每个进程管理更多的内容,
从而称为重量级进程。“轻量”是指线程没有独自的存储空间,和同一个进程的多个线程共享存储空间。
多线程和传统的单线程在程序设计上的最大区别是每个线程独自运行,是彼此独立的指令流,造成线程之间的执行是乱序的,所以线程的控制需要谨慎对待。
下面分别详细介绍进程和线程的概念,如何创建线程、设置线程的优先级、线程控制和线程同步等关键问题。
9.2 创建线程
在学习线程前,一定要先了解 Java的线程机制,
然后学习如何利用 Thread类实现多线程。 Java的多线程机制提供了两种方式实现多线程编程,一种是通过继承 java.long.Thread类来实现,一种是通过实现 Runnable接口实现。
9.2.1 继承 Thread类创建线程
Thread类是 Java实现多线程的提供了简单的方法,Thread类已经具备了运行多线程所需要的资源,用户只需要重载该类的 run()方法,把需要使用多线程运行的代码放入该方法。这样这些代码就可以和其他线程“同时”
存在。创建线程对象并用该对象调用 start()方法则线程开始运行,
start()方法提供了启动线程和线程运行所需要的框架。
代码是一个例子,说明使用继承 Thread类实现多线程。每次 new一个线程都设置一个线程计数器,表明建立的线程数。整个程序启动 3个线程,每个线程会有 9次输出,但是三个线程的建立并非顺序执行,而每个线程的 9
次输出也不一定会顺序输出。如代码继承 Thread类实现多线程示例所示。
9.2.2 实现 Runnable接口创建线程
Java提供了另一个有用的接口实现多线程编程。因为 Java
不支持多继承,所以如果用户的类已经继承了一个类,而又需要多线程机制的支持,此时继承 thread类就不现实了。
所以 Runnable接口在这种情况下就很实用。
Runnable接口有唯一一个方法 run(),所以实现该接口时必须自己定义该方法,提供多线程需要执行的代码。如果运行通过实现 Runnable接口的多线程程序,则需要借助
Thread类,因为 Runnable接口没有提供任何东西支持多线程,必须借助 Thead类的框架实现多线程,即通过类 Thread
的构造函数 public Thread(Runnable target)来实现。代码是通过继承 Runnable接口而实现多线程的例子。我们分析和运行该程序,观察输出结果就可以很好的理解其运用。
9.3 线程的状态
在 Java中线程的执行过程稍微有些复杂,但线程对象创建后并不不是立即执行,需要做些准备工作才有执行的权利,
而一旦抢占到 CPU周期,则线程可以运行,但 CPU周期结束则线程必须暂时停止,或线程执行过程中的某个条件无法满足时也会暂时停止,只有等待条件满足时才会继续执行,
最后从 run()方法返回后,线程退出。可以看出线程的执行过程中涉及一些状态,线程就在这些状态之间迁移。
做一点说明,Java规范中只定义了线程的四种状态,即新建状态、可运行状态、阻塞状态和死亡状态。为了更清晰的说明线程的状态变化过程,我们认为划分为五个状态更好理解,这里把可运行状态( Runnable)分解为就绪状态和运行状态,可以更好的理解可运行状态的含义。
线程包括五个状态:新建状态、就绪状态、运行状态、阻塞状态和死亡状态。下面分别详细介绍这五种状态。
9.4 线程的优先级
线程的有限级表示一个线程被 CPU执行的机会多少。注意,
这里用“机会多少”来表达而不是用“先后顺序”来表达。
在 Java中虽然定义了设置线程优先级高低的方法,但是优先级低并不意味着在不同优先级的线程中就不会被执行,
优先级低只说明该线程被执行的概率小,同理优先级高的线程获得 CPU的概率就大。
通过 Tread类的 setPriority()方法设置线程的优先级,该方法的参数为 int型,其实 Java提供了三个优先级别,都为
Thread类的常量,从高到低依次为 Thread,MAX-PRIORITY、
Thread.NORM_PRIORITY,Thread.MIN_PRIORITY。这里再次重申,优先级低并不意味着线程得不到执行,而是线程被优先执行的概率小。这也说明设置线程的优先级不会造成死锁的发生。
9.5 线程的同步
在多线程中经常遇到的一个问题就是资源共享问题,假设两个线程同时访问一个数据区,一个读数据、一个写数据,在一个线程读数据前另一个线程修改了数据,则读数据线程读到的不是原始数据而是被修改过的数据,显然这样使不允许的。
而在多线程编程中经常会遇到访问共享资源的问题,这些资源可以是数据、文件、一块内存区或是外围设备的访问等。所以必须解决多线程编程中如何实现资源的共享问题,在 Java中称为线程的同步问题,在多数的编程语言中解决共享资源冲突的方法是采用顺序机制( Serialize),通过为共享资源加锁的方法实现资源的顺序访问。
9.5.1 Java程序的资源共享
通过下面的例子,说明如果没有实现线程同步的访问共享资源会遇到的问题。我们设计一个线程类 FooOne,该类的多线程代码无限循环的输出一个值,每次循环该值递增,
但递增到 100时,停止循环线程退出,这由方法 run()中调用方法 printVal()实现。另一个线程类 FooTwo调用类
FooOne的对象,该类的 run()方法调用类 FooOne对象的
printVal()方法,也实现对变量的递增输出。我们希望是两次调用各自完成变量的递增,相互之间不要有干扰,即两次调用要求顺序执行。但事实上目前我们无法控制这种顺序执行(随后会介绍 synchronized关键字解决这个问题)。所以结果是两个线程交替执行,确实实现了并发,
或者更抽象的说两个线程同时访问了某个资源,造成数据的不确定性。
9.5.2 synchronized关键字
在设计多线程模式中,解决线程冲突问题都是采用
synchronize关键字实现的。这意味着在给定时刻只允许一个线程访问共享资源。通常是在代码前加上一条锁语句实现的,这就保证了在一段时间内只有一个线程运行这段代码,如果另一个线程需要访问这段共享资源,必须等待当前的线程释放锁。可见锁语句产生了一种互斥的效果,所以常常称锁为“互斥量”( mutex)。
要控制对共享资源的访问,首先要把它封装进一个类,即编写一个方法来访问共享资源,为了保证对象在调用该方法访问资源时实现互斥访问,必须提供保证机制,保证顺序的访问共享资源。一般来说类中的数据成员都被声明为私有的,只有通过方法来访问这些数据。所以可以把方法标记为 synchronized来防止资源冲突。
9.5.3 同步控制方法
修改代码 9-4的部分代码,使用 synchronized关键字修饰方法,实现对方法的顺序访问,代码修改部分如下:
11 public synchronized void printVal(int v,String y){
12 while(v<10)
13 System.out.println(y+":"+v++);
14 }
这里只是在方法前增加了一个关键字,这就表示如果一个线程调用该方方法,则必须首先获得方法所在类的对象的锁,执行完后释放锁。下一个线程在访问该方法前,先获得锁,然后再执行代码,这样就实现了对共享资源(或关键代码)的顺序访问。保证了多线程的安全性。其执行结果为:
修改代码的部分代码,使用 synchronized关键字修饰方法,实现对方法的顺序访问,代码修改部分如下:
11 publicsynchronized void printVal(int v,String y){
12 while(v<10)
13 System.out.println(y+":"+v++);
14 }
这里只是在方法前增加了一个关键字,这就表示如果一个线程调用该方方法,则必须首先获得方法所在类的对象的锁,执行完后释放锁。下一个线程在访问该方法前,先获得锁,然后再执行代码,这样就实现了对共享资源(或关键代码)的顺序访问。保证了多线程的安全性。其执行结果为:
9.5.4 同步控制块
实际中会遇到这样一种情况,两个函数共享公共资源,为了使资源得到保护,
必须实现资源访问地同步控制,尤其是 static方法和非 static方法共享资源的情况更是如此 。 此时可以使用同步控制块来解决这个问题 。 代码 6同步控制块示例显示了具体用法 。
代码同步控制块示例
1 class SynControlBlock implements Runnable{
2 private SomeObj obj
3 public static void method1(){
4 synchronized(obj){
5 //共享资源代码
6 }
7 }
8 public void method2(){
9 synchronized(obj){
10 //共享资源代码
11 }
12 }
13} 。
9.6 线程的控制
线程是一个相对独立的执行单元,完成一个具体的任务。线程的可以被创建、执行、阻塞、恢复执行、结束等行为,这些行为组成了线程的控制机制。本节介绍线程控制的内容、具体方法和实现方式。
9.6.1 启动线程
无论是通过继承 Thread类实现多线程还是通过实现
Runnable接口实现多线程,如果要启动线程都需要 Thread
类的 start()方法。该方法完成线程执行的一些初始化工作。
假设一个多线程类继承 Thread实现,类名为 MyThread,而另一个类实现 Runnable接口设计多线程,类名为
MyRunThread,则二者启动线程方式如下:
1 //创建类 MyThread的对象 thread,并启动该线程。
2 MyThread thread = new MyThread();
3 thread.start()
4 //创建类 MyRunThread()的对象 myRunThread,并启动该线程。
5 Thread myRunThread = new Thread(new
MyRunThread());
6 myRunThread.start();
9.6.2 挂起和恢复线程
在 Java2之前,用户会看到 suspend()和 resume()
用来阻塞和唤醒线程,但是在 Java2中这两个方法不再使用了。首先分析一下使用 suspend()方法会发生什么问题。 suspend()方法的作用是挂起拥有锁的线程,但是与 wait()方法不同,它不会释放锁。如果一线程调用 suspend()方法,把另一个线程挂起,此时被挂起的线程在等待恢复,而挂起它的线程在等待获得锁(该锁就是被挂起的线程对象),此时就会发生死锁。
9.6.3 线程的休眠
Java提供了一种控制线程的方法 Sleep(int miliseconds),这里称为线程的休眠,它将线程停止一段时间,该时间由方法的参数决定,当时间结束时线程进入就绪状态,可以抢占 CPU的时间周期。把例子代码修改后观察
sleep()方法的作用,将得到代码。
9.6.4 等待和通知
等待和通知实现了线程之间的协调机制,使得线程之间可以建立“和谐”地协作关系。 Java提供了线程对象的
wait(),notifty()或 notifyAll()方法来实现这种协作,
wait()方法使线程挂起一段时间,而 notifty()或
notifyAll()方法使线程从 wait()方法调用的状态中恢复到就绪状态。
Wait() 和 sleep()方法相似,都是让线程暂时挂起,都可以接受一个时间参数,确定线程挂起时间。但是 wait()方法有其特殊之处。
( 1) 线程一旦调用 wait()方法,线程中同步方法的锁被释放,
别的线程可以调用该线程中相应的同步方法 。
( 2)使用 wait()方法的线程可以使用 notifty()和 notifyAll()方法获得执行的权利,即获得抢占 CPU周期的权利。
9.6.5 结束线程
在 Java2中 stop()方法不再被支持,在将来的版本中该方法可能被替换但是由于其天生的不安全性,替换的方法也不会取得好的效果。尽管在 Java2中不再支持该方法,但 Java
仍然包含了该 API,也就是说程序员仍然可以调用该方法来结束线程。
但调用 stop()方法终止一个线程时,会释放该线程持有的所有锁,而问题是用户无法知道代码目前的工作内容,这是导致 stop()方法不安全的因素。如果通过谨慎的设计,
或许可以实现安全的使用该方法。如果用户知道在调用该方法时,线程没有处在处理或更新其他对象或数据的状态,
则可以安全的使用该方法,但是有多少程序员会遇到这种情况呢,非常少。所以,多数情况下,用户认为自己安全的使用了 stop()方法来结束线程,往往造成不可预知的后果。
9.7 线程间通信
线程间进行输入输出通信最常用的方式是“管道”
方式。 Java线程支持这种形式的通信。即一个线程从管道一端写入数据,另一个线程从管道对端读出数据,用户不必关心管道是如何传输数据和实现管道两端的线程通信的。在 Java的输入输出类库中两个类 PipedWriter和 PipedReader都支持管道通信方式。前者允许向管道写数据,后者允许向不同的线程从同一个管道读数据。下面分别介绍这连个类和相应的方法。
9.7.1 PipeWriter类详解
该类的作用是创建一个 PipedWriter类对象 writer,
链接到一个 PipedReader类对象 reader,从 writer
写入的数据从 reader可以轻松读出。该类的声明方式有两种。
public PipedWriter(PipedReader reader)
throws IOException{/*类主体 */}
类的构造函数参数为 PipedReader类对象,明确了建立管道链接的两个对象。
public PipedWriter() throws IOException{/*
类主体 */}
9.7.2 PipedReader类详解
该类的作用是创建一个 PipedReader类对象 reader,
链接到一个 PipedWriter类对象 writer,从 writer
写入的数据从 reader可以轻松读出。该类的声明方式有两种。
public PipedReader(PipedWriter writer)
throws IOException{/*类主体 */}
类的构造函数参数为 PipedWriter类对象,明确了建立管道链接的两个对象。
public PipedReader() throws IOException{/*
类主体 */}
9.7.3 管道通信实例
代码 9-11说明了线程间通过管道通信的实现方式。
该程序建立两个类,一个类 PipeSender负责向管道写数据,一个类 PipedReceiver负责从管道读数据,但两个线程都启动后,负责读数据的线程不断的从管道读数据,并打印到输出屏幕上。
9.8 多线程的死锁问题
上节讲了线程的各种控制,以及如何避免资源的共享访问问题。这些方法使读者可以很方便的控制线程,但是正如一枚硬币的两面,它同时也带来了不利的一面,即死锁问题。由于线程会进入阻塞状态,并且对象同步锁的存储在,
使得只有获得对象的锁才能访问该对象。因此很容易发上循环死锁。如线程 A等待线程 B释放锁,而线程 B等待线程 C
释放锁,线程 C有等待线程 A释放锁,这样就造成一个轮回等待。三个线程都无法继续运行。
对于 Java语言来讲没有很好地预防死锁的方法,只有依靠读者谨慎的设计来避免死锁的发生。这里提供一个避免死锁的基本原则。
( 1)避免使用 suspend()和 resume()方法,这些方法具有与生俱来产生死锁的缺点。
( 2)不要对长时间 I/O操作的方法施加锁。
( 3)使用多个锁时,确保所有线程都按相同的顺序获得锁。
9.9 多线程的缺点
多线程的主要目的是对大量并行的任务进行有序地管理 。 通过同时执行多个任务,可以有效的利用计算机的资源 ( 主要是提高 CPU的利用率 ),或者实现对用户来讲响应及时的程序界面 。 但是不可避免的任何,好东西,都有代价,所以使用多线程也有其缺点主要包括:
( 1) 等待访问共享资源时使得程序运行变慢 。 如果用户访问网络数据库,而改善数据库的访问是互斥的,所以一个线程在访问大量数据或修改大量数据时,其他线程就只用等待而不能执行,同时如果把网络链接和数据传输的时间计算在内,则等待的时间或许是,不可忍受的,。
( 2) 当线程数量增多时,对线程的管理要求额外的 CPU开销 。 虽然线程是轻量级进程和其他线程共享一些数据,但是毕竟每个线程需要自己的管理资源,而这些资源的管理会耗费 CPU时间片,如果线程数量增多到一定程度如 ( 100个以上 ),则线程的管理开销代价会增大 。
( 3) 死锁是难以避免的,只有依靠程序员谨慎地设计多线程程序 。
任何语言都不可能提供预防死锁的方法,Java也不例外除了尽量不使用控制线程的一些方法如 suspend(),resume()外,需要认真的分析线程的执行过程,以避免线程间的死锁 。
( 4)随意使用线程技术有时会耗费系统资源,所以要求程序员知道何时使用多线程以及何时避免使用该技术。
9.10 习题
( 1)简答题
1.解释线程的概念,线程和进程的区别是什么?
2,Java线程有几中状态,各状态之间转换的条件?
3.创建线程有几种途径,这些途径如何具体实现线程。它们的使用场合有什么不同,二者的关系?
4,Java如何定义线程的优先级,优先级高的线程是否一定先于优先级低的线程执行,为什么?
5.使用 Synchronized关键字可以实现锁机制,实现资源的同步访问,
这里同步指什么?如何使用 synchronized关键字?
6.线程等待和线程休眠都会使当前线程停止执行,而等待某个条件从而获得继续执行的机会,那么线程等待和线程休眠的区别在哪里?
7.线程间通信是通过管道流实现的,发送数据的线程把数据写入管道,接收数据的线程把数据从管道读出,Java是如何通过管道流机制实现的线程间通信的?
8.解释线程控制中方法 resume()和 suspend()方法的功能,为什么这两种控制线程的方法容易引起线程的死锁?
9.如何避免线程的死锁?
9.10 习题
( 2)编程题
1.继承 Thread类编写一个线程类,覆写 run()方法,每次启动线程时打印一行输入说明启动的是第几个线程。
2.编写两个线程类(继承自 Thread),一个资源类提供资源访问的方法,包括一个读数据方法一个写数据方法,要求实现两个线程对资源的互斥访问。