进程与线程
[TOC]
1.进程
1.1 进程的概念
进程就是正在运行的程序,它会占用对应的内存区域,由CPU进行执行与计算。
1.2 进程的特点
独立性
进程是系统中独立存在的实体,它可以拥有自己独立的资源,每个进程都拥有自己私有的地址空间,在没有经过进程本身允许的情况下,一个用户进程不可以直接访问其他进程的地址空间动态性
进程与程序的区别在于,程序只是一个静态的指令集合,而进程是一个正在系统中活动的指令集合,程序加入了时间的概念以后,称为进程,具有自己的生命周期和各种不同的状态,这些概念都是程序所不具备的.并发性
多个进程可以在单个处理器CPU上并发执行,多个进程之间不会互相影响.并发执行的进程数目并不受限于CPU数目。操作系统会为每个进程分配CPU时间片,给人并行处理的感觉。
2.线程
2.1 线程的概念
线程是控制线程的简称。线程是操作系统OS能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位.如果一个进程可以同时运行多个线程,则称这个进程是多线程的(multithreaded)。一个进程可以开启多个线程,其中有一个主线程来调用本进程中的其他线程。
我们看到的进程的切换,切换的也是不同进程的主线程
多线程可以让同一个进程同时并发处理多个任务,相当于扩展了进程的功能。
2.2 线程与进程的关系
一个操作系统中可以有多个进程,一个进程中可以包含一个线程(单线程程序),也可以包含多个线程(多线程程序)
每个线程在共享同一个进程中的内存的同时,又有自己独立的内存空间.
所以想使用线程技术,得先有进程,进程的创建是OS操作系统来创建的,一般都是C或者C++完成
多线程与多进程的区别:每个线程都拥有自己的一整套变量,而线程则共享数据。共享变量使线程之间的通信比进程更有效、更容易。此外,在有些操作系统中,与进程相比较,线程更”轻量级”,创建、撤销一个线程比启动新进程的开销要小得多。
3.多线程的特性
3.1 随机性
我们宏观上觉得多个进程是同时运行的,但实际的微观层面上,一个CPU【单核】只能执行一个进程中的一个线程。
那为什么看起来像是多个进程同时执行呢?
是因为CPU以纳秒级别甚至是更快的速度高效切换着,超过了人的反应速度,这使得各个进程从看起来是同时进行的,也就是说,宏观层面上,所有的进程看似并行【同时运行】,但是微观层面上是串行的【同一时刻,一个CPU只能处理一件事】。
串行与并行
串行是指同一时刻一个CPU只能处理一件事,类似于单车道
并行是指同一时刻多个CPU可以处理多件事,类似于多车道
3.2 CPU分时调度
时间片,即CPU分配给各个线程的一个时间段,称作它的时间片,即该线程被允许运行的时间,如果在时间片用完时线程还在执行,那CPU将被剥夺并分配给另一个线程,将当前线程挂起,如果线程在时间片用完之前阻塞或结束,则CPU当即进行切换,从而避免CPU资源浪费,当再次切换到之前挂起的线程,恢复现场,继续执行。
注意:我们无法控制OS选择执行哪些线程,OS底层有自己规则,如:
- FCFS(First Come First Service 先来先服务算法)
- SJS(Short Job Service 短服务算法)
在有多个处理器的机器上,每一个处理器运行一个线程,可以有多个线程并行运行。当然,如果线程的数目多于处理器的数目,调度器还是需要分配时间片。
3.3 线程的状态
Java中线程的状态分为6种。
- **初始(NEW)**:新创建了一个线程对象,但还没有调用start()方法。
- 运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。- **阻塞(BLOCKED)**:表示线程阻塞于锁。
- **等待(WAITING)**:进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
- **超时等待(TIMED_WAITING)**:该状态不同于WAITING,它可以在指定的时间后自行返回。
- **终止(TERMINATED)**:表示该线程已经执行完毕。
PCB(Process Control Block):为了保证参与并发执行的每个线程都能独立运行,OS配置了特有的数据结构PCB来描述线程的基本情况和活动过程,进而控制和管理线程
线程生命周期,主要有七种状态:
初始状态(NEW)
实现Runnable接口和继承Thread可以得到一个线程类,new一个实例出来,线程就进入了初始状态。
就绪状态(Runnable-Ready)
当调用线程对象的start()方法,线程即为进入可运行状态.
处于该状态的线程,只是说明线程已经做好准备,随时等待CPU调度执行,并不是执行了t.start()此线程立即就会执行,要由操作系统为线程提供具体的执行时间。
- 就绪状态只是说你资格运行,调度程序没有挑选到你,你就永远是就绪状态。
- 调用线程的start()方法,此线程进入就绪状态。
- 当前线程sleep()方法结束,其他线程join()结束,等待用户输入完毕,某个线程拿到对象锁,这些线程也将进入就绪状态。
- 当前线程时间片用完了,调用当前线程的yield()方法,当前线程进入就绪状态。
- 锁池里的线程拿到对象锁后,进入就绪状态
运行中状态(Runnable-Running)
当CPU调度了处于就绪状态的线程时,此线程才是真正的执行,即进入到运行中状态
- 就绪状态是进入运行中状态的唯一入口,也就是线程想要进入运行状态状态执行,先得处于就绪状态(不过Java规范并没有把正在运行状态作为一个单独的状态,一个正在运行的线程仍然处于运行(Runnable)状态)
注:一旦一个线程开始运行,它不一定始终保持运行。事实上,运行中的线程有时需要暂停,让其他线程有机会运行。线程调度的细节依赖于操作系统提供的服务。抢占式调度系统给每一个可运行线程一个时间片来执行任务。当时间片用完后,操作系统剥夺该线程的运行权,并给另一个线程一个机会来运行。当选择下一个线程时,操作系统会考虑线程的优先级。
阻塞状态(Blocked)
处于运行中状态的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入就绪状态才有机会被CPU选中再次执行.
根据阻塞状态产生的原因不同,阻塞状态又可以细分成三种:
- 等待阻塞:当线程等待另一个线程通知调度器出现一个条件,或当该线程调用wait()方法时,这个线程会进入等待状态。有几个方法有超时参数,调用这些方法会让线程进入计时等待(time waiting)状态。这一状态将一直保持到超时期满或者接收到适当的通知。
- 同步阻塞:线程在获取synchronized同步锁失败(因为锁被其他线程占用),它会进入同步阻塞状态。当所有其他线程都释放了这个锁,并且线程调度器运行该线程持有这个锁时,它将变成非阻塞状态。
- 其他阻塞:调用线程的sleep()或者join()或发出了I/O请求时,线程会进入到阻塞状态.当sleep()状态超时.join()等待线程终止或者超时或者I/O处理完毕时线程重新转入就绪状态
等待(WAITING)
处于这种状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。
超时等待(TIMED_WAITING)
处于这种状态的线程不会被分配CPU执行时间,不过无须无限期等待被其他线程显示地唤醒,在达到一定时间后它们会自动唤醒。
终止状态(TERMINATED)
run方法正常退出或者因为一个没有捕获的异常终止了run方法使线程意外终止,该线程结束生命周期。具体来说,可以调用线程的stop方法杀死一个线程。该方法抛出一个ThreadDeath错误对象,这会杀死线程。不过,stop方法已经废弃,不要在你的代码中调用该方法。
注:当一个线程阻塞或等待时(或终止时),可以调度另一个线程运行。当一个线程被重新激活(例如,因为超时期满或成功地获得了一个锁)调度器检查它是否具有比当前运行线程更高的优先级。如若这样,调度器会剥夺某个当前运行线程的运行权,选择一个新线程运行。
3.4 线程的属性
3.4.1 中断线程
当该线程的run方法执行方法体中最后一条语句后再执行return语句返回时,或者出现了一个没有捕获的异常时,线程将终止。
除了已经废弃的stop方法,没有办法可以强制线程终止。不过,interrupt方法可以用来请求终止一个线程。当对一个线程调用interrupt方法时,就会设置线程的中断状态。这是每个线程都有的boolean标志。每个线程都应不时地检查这个标志,以判断线程是否被中断。可以通过调用
1 | Thread.currentThread().isInterrupted() |
但如果线程被阻塞,就无法检查中断状态。当在一个被sleep或wait调用阻塞的线程上调用interrupt方法时,将会抛出InterruptedException异常。
注: 没有任何语言要求被中断的线程应当终止。中断一个线程只是要引起它的注意。被中断的线程可以决定如何响应中断。某些线程非常重要,所以应该处理这个异常,然后再继续执行。但是,更普遍的情况是,线程只希望将中断解释为一个终止请求。
如果设置了中断状态,此时倘若调用sleep方法,它不会休眠,而是会清除中断状态并抛出InterruptedException异常。因此,如果你的循环调用了sleep,不要检测中断状态,而应该捕获InterruptedException异常。
1 | API java.lang.Thread |
1 | void interrupt() |
3.4.2 守护线程
可以通过调用
1 | t.setDaemon(ture) |
将一个线程转换为守护线程(daemon thread)。守护线程的唯一用途是为其他线程提供服务。例如计时器线程,它定时发送”计时器嘀嗒”信号给其他线程,另外清空过时缓存项的线程也是守护线程。当只剩下守护线程时,虚拟机就会退出。因为如果只剩下守护线程,就没必要继续运行程序了。
1 | API java.lang.Thread |
1 | void setDaemon(boolean isDaemon) |
4.多线程代码对象创建
4.1 继承Thread类
4.1.1 概述
Thread类本质上是实现了Runnable接口的一个实例,代表一个线程的实例
启动线程的唯一方法就是通过Thread类的start()实例方法
start()方法是一native方法,它将通知底层操作系统,.最终由操作系统启动一个新线程,操作系统将执行run()
这种方式实现的多线程很简单,通过自己的类直接extends Thread,并重写run()方法,就可以自动启动新线程并执行自己定义的run()方法
模拟开启多个线程,每个线程调用run()方法.
4.2 实现Runnable接口
4.2.1 概述
如果自己的类已经extends另一个类,就无法多继承,此时,可以实现一个Runnable接口
4.2.2 常用方法
1 | API java.lang.Thread |
构造方法
1 | Thread() 构造一个新线程 |
普通方法
1 | static Thread currentThread( ) |
1 | API java.lang.Runnable |
1 | void run() |
4.2.3 测试代码
1 |
|
1 |
|
警告:不要调用Thread类或Runnable对象的run方法。直接调用run方法只会在同一个线程中执行这个任务——而没有启动新的线程。实际上,应当调用
1 Thread.start()这会创建一个执行run方法的新线程
注:我可以通过建立Thread类的一个子类开定义线程,构造这个子类的一个对象并调用它的start方法。不过,现在不再推荐这种方法。应当把要并行运行的任务与运行机制解耦合。如果有多个任务,为每个任务分别创造一个单独的线程开销会太大。实际上,可以使用一个线程池。
4.2.4 小结
在大多数实际的多线程应用中,两个或两个以上的线程需要共享对同一数据的存取。如果两个线程存取同一个对象,并且每个线程分别调用了一个修改该对象状态的方法,会发生什么呢?可以想见,这两个线程会相互覆盖。这有可能破坏共享数据。在这里的测试程序运行时,可以清楚地看见余额有轻微变化,有时可能需要很长时间才能发现这个错误。在现实的银行存取中,你肯定不希望看到自己的余额莫名其妙便少了,当然也有可能变多(可以试试)。为了防止这种不稳定情况出现我们需要防止并发访问这块代码,一种是使用synchronnized关键字,另外也可以使用锁。
5. 同步
上节说到,在大多数实际的多线程应用中,两个或两个以上的线程需要共享对同一数据的存取。如果两个线程存取同一个对象,并且每个线程分别调用了一个修改该对象状态的方法,会发生什么呢?可以想见,这两个线程会相互覆盖。这取决于线程访问数据的次序,可能会导致对象被破坏。这种情况通常称为竟态条件(race condition)。
5.1 非同步程序测试
1 |
|
可以清晰的看到,原总金额为10000,经过几次转账后余额发生了变化
5.2 竟态条件详解
上一节中运行了一个程序,其中有几个线程会更新银行账户余额。一段时间之后,不知不觉地出现了错误,可能有些钱会丢失,也可能几个账户同时有钱进账。当两个线程试图同时更新同一个账户时,就会出现这个问题。假设两个线程同时执行指令
accounts[to]+= amount;
问题在于这不是原子操作。这个指令可能如下处理:
1.将accounts[to]加载到寄存器。
2.增加 amount。
3.将结果写回accounts[to]。
现在,假定第1个线程执行步骤1和2,然后,它的运行权被抢占。再假设第2个线程被唤醒,更新acount数组中的同一个元素。然后,第1个线程被唤醒并完成其第3步。
这个动作会抹去第2个线程所做的更新。这样一来,总金额就不再正确了
出现这种破坏的可能性有多大呢?在一个有多个内核的现代处理器上,出问题的风险相当高。我们将打印语句和更新余额的语句交错执行,以提高观察到这种问题的概率。
如果删除打印语句,出问题的风险会降低,因为每个线程在再次休眠之前所做的工作很少,调度器不太可能在线程的计算过程中抢占它的运行权。但是,产生破坏的风险并没有完全消失。如果在负载很重的机器上运行大量线程,那么,即使删除了打印语句,程序依然会出错。这种错误可能几分钟、几小时或几天后才出现。坦白地说,对程序员而言,最糟糕的事情莫过于这种不定期地出现错误。
真正的问题是transfer方法可能会在执行到中间时被中断。如果能够确保线程失去控制之前方法已经运行完成,那么银行账户对象的状态就不会被破坏。
5.3 锁对象
5.3.1 概述
悲观锁:像它的名字一样,对于并发间操作产生的线程安全问题持悲观状态.
悲观锁认为竞争总是会发生,因此每次对某资源进行操作时,都会持有一个独占的锁,就像synchronized,不管三七二十一,直接上了锁就操作资源了。
乐观锁:还是像它的名字一样,对于并发间操作产生的线程安全问题持乐观状态.
乐观锁认为竞争不总是会发生,因此它不需要持有锁,将”比较-替换”这两个动作作为一个原子操作尝试去修改内存中的变量,如果失败则表示发生冲突,那么就应该有相应的重试逻辑。
5.3.2 几种常见的锁
synchronized 互斥锁(悲观锁,有罪假设)
采用synchronized修饰符实现的同步机制叫做互斥锁机制,它所获得的锁叫做互斥锁。
每个对象都有一个monitor(锁标记),当线程拥有这个锁标记时才能访问这个资源,没有锁标记便进入锁池。任何一个对象系统都会为其创建一个互斥锁,这个锁是为了分配给线程的,防止打断原子操作。每个对象的锁只能分配给一个线程,因此叫做互斥锁。
ReentrantLock 排他锁(重入锁)(悲观锁,有罪假设)
ReentrantLock是排他锁,排他锁在同一时刻仅有一个线程可以进行访问,实际上独占锁是一种相对比较保守的锁策略,在这种情况下任何“读/读”、“读/写”、“写/写”操作都不能同时发生,这在一定程度上降低了吞吐量。然而读操作之间不存在数据竞争问题,如果”读/读”操作能够以共享锁的方式进行,那会进一步提升性能。
ReentrantReadWriteLock 读写锁(乐观锁,无罪假设)
因此引入了ReentrantReadWriteLock,顾名思义,ReentrantReadWriteLock是Reentrant(可重入)Read(读)Write(写)Lock(锁),我们下面称它为读写锁。
读写锁内部又分为读锁和写锁,读锁可以在没有写锁的时候被多个线程同时持有,写锁是独占的。
读锁和写锁分离从而提升程序性能,读写锁主要应用于读多写少的场景。
用锁保护代码块的基本结构如下
1 | mylock.lock(); //一个锁对象 |
这个结构确保任何时刻只有一个线程进入临界区。一旦一个线程锁定了锁对象,其他任何线程都无法通过lock语句。当其他线程调用lock时,它们会阻塞,直到原本占有该锁的线程释放这个锁对象后,其他线程中的一个线程将会再次获得同一个锁对象,除这一个线程之外的所有想要访问该代码块的线程将会再次阻塞。
下面使用一个锁来保护Bank类的transfer方法
1 | private Lock bankLock; |
假设一个线程调用了transfer,但是在执行结束前被抢占。再假设第二个线程也调用transfer,由于第二个线程不能获得锁,将在调用lock方法时被阻塞。它会暂停,必须等待第一个线程执行完transfer方法。当第一个线程释放锁时,第二个线程才能开始运行。
尝试一下。把加锁代码增加到transfer方法并再次运行程序。这个程序可以一直运行下去,银行余额绝对不会有错误。
注意每个Bank对象都有自己的ReentrantLock对象。(如果两个线程试图访问同一个Bank对象,那么锁可以用来保证串行化访问 不过,如果两个线程访问不同的Bank对象,每个线程会得到不同的锁对象,两个线程都不会阻塞 本该如此,因为线程在操纵不同的Bank实例时,线程之间不会相互影响。
这个锁称为重入(reentrant)锁 因为线程可以反复获得已拥有的锁。锁有一个持有计数(hold count)来跟踪对lock方法的嵌套调用。线程每一次调用lock后都要调用unlock来释放锁。由于这个特性,被一个锁保护的代码可以调用另一个使用相同锁的方法。
例如,transfer方法调用getTotalBalance方法,这也会封锁bankLock对象,此时bankLock对象的持有计数为2。当getTotalBalance方法退出时,持有计数变回1。当transfer方法退出的时候,持有计数变为0,线程释放锁。
1 | API java.util.concurrent.locks.Lock |
1 | void lock() |
1 | API java.util.concurrent.locks.ReentrantLock |
1 | ReentrantLock() |
5.4 条件对象
通常,线程进入临界区后却发现只有满足的某个条件之后它才能执行。可以使用一个条件对象来管理那些已经获得了一个锁却不能做有用工作的线程。
在上一节的代码中的transfer代码,假如账户中没有足够的资金,我们需要等待,直到另一个线程向账户中增加了资金。但是,这个线程刚刚获得了对bankLock的排他性访问,因此别的线程没有存款的机会。这里就要引入条件对象。
一个锁对象可以有一个或多个相关联的条件对象。你可以用newCondition方法获得一个条件对象。例如在这里我们建立了一个条件对象来表示“资金充足”条件
1 | class Bank |
如果transfer方法发现资金不足,它会调用 sufficientFunds.await();
当前线程现在暂停,并放弃锁。这就允许另一个线程执行,我们希望它能增加账户余额。等待获得锁的线程和已经调用了await方法的线程存在本质上的不同。一旦一个线程调用了await方法,它就进入这个条件的等待集(wait set)。当锁可用时,该线程并不会变为可运行状态。实际上,它仍保持非活动状态,直到另一个线程在同一条件上调用signalAlt方法。
当另一个线程完成转账时,它应该调用
sufficientFunds.signalAll();
这个调用会重新激活等待这个条件的所有线程。当这些线程从等待集中移出时,它们再次成为可运行的线程,调度器最终将再次将它们激活。同时,它们会尝试重新进入该对象。一旦锁可用,它们中的某个线程将从await调用返回,得到这个锁,并从之前暂停的地方继续执行。
此时,线程应当再次测试条件。不能保证现在一定满足条件——signalAll方法仅仅是通知等待的线程:现在有可能满足条件,值得再次检查条件。
注: 通常,await调用应该放在如下形式的循环中
while(!(OK to proceed))
conditon.await();
最终需要有某个其他线程调用signalAll方法,这一点至关重要。当一个线程调用await时,它没有办法重新自行激活。它寄希望于其他线程。如果没有其他线程来重新激活等待的线程,它就永远不再运行了。这将导致令人不快的死锁 (deadlock)现象。如果所有其他线程都被阻塞,最后一个活动线程调用了await方法但没有先解除另外某个线程的阻塞,现在这个线程也会阻塞。此时没有线程可以解除其他线程的阻塞状态,程序会永远挂起。
应该什么时候调用signalAll呢?从经验上讲,只要一个对象的状态有变化,而且可能有利于等待的线程,就可以调用signalAll。例如,当一个账户余额发生改变时,就应该再给等待的线程一个机会来检查余额。在这个例子中,完成转账时,我们就会调用signalAll方法。
1 | public void transfer(int from,int to,double amount) throws InterruptedException { |
注: signalAll方法调用不会立即激活一个等待的线程。它只是解除等待线程的阻塞,使这些线程可以在当前线程释放锁后竞争访问对象。
只有当线程拥有一个条件的锁时,它才能在这个条件上调用await,signalAll,signal方法
1 | API java.util.concurrent.locks.Lock |
1 | Condition newCondition() |
1 | API java.util.concurrent.locks.Condition |
1 | void await() |
5.5 synchronized关键字
5.5.1 概述
在前面的小节中,我们了解了如何使用Lock和Condition对象。在进一步深入之前,先对锁和条件的要点做一个总结。
- 锁可以用来保护代码片段,一次只能有一个线程执行被保护的代码
- 锁可以管理试图进入被保护代码段的线程
- 一个锁可以有一个或多个相关联的条件对象
- 每个条件对象管理那些已经进入被保护代码段但还不能运行的线程
Lock和Condition接口允许程序员充分控制锁定。不过大多数情况下,你并不需要那样控制,完全可以使用java语言内部的一种机制。从1.0版本开始,Java中的每个对象都有一个内部锁。如果一个方法声明时有synchronized关键字,那么对象的锁将保护整个方法。也就是说要调用这个方法,线程必须获得内部对象锁。
public synchronized void method()
{
method body
}
等价于
public void method()
{
this.intrinsicLock.lock();
try{
method body
}
finally{
this.intrinsicLock.unlock();
}
}
内部对象锁只有一个关联条件。其中,wait()等价于intrinsicCondition.await(),notifyAll()等价于intrinsicCondition.signalAll().
5.5.2 特点
- synchronized同步关键字可以用来修饰代码块,称为同步代码块,使用的锁对象类型任意,但注意:必须唯一!
- synchronized同步关键字可以用来修饰方法,称为同步方法
- 同步的缺点是会降低程序的执行效率,但我们为了保证线程的安全,有些性能是必须要牺牲的
例如,可以用Java如下实现Bank类
1 | public synchronized void transfer(int from,int to,double amount) throws InterruptedException { |
可以看到,使用synchronized关键字能够得到更为简洁的代码。
5.5.4 限制
内部锁和条件存在一些限制,包括:
- 不能中断一个正在尝试获得锁的线程
- 不能指定尝试获得锁时的超时时间
- 每个锁仅有一个条件可能是不够的
在代码中我们应该使用Lock和Condition对象还是synchronized同步方法?
- 最好既不使用Lock/Condition也不使用synchronized关键字。在许多情况下,我们可以使用阻塞队列来同步完成一个共同任务的线程
- 如果synchronized关键字适合你的程序,那么尽量使用这种方法,既减少了代码的编写量,也不容易出错
- 如果确实特别需要Lock/Condition的额外能力,则使用它
1 |
|
1 | API java.lang.Object |
1 | void notifyAll() |
5.5.5 同步块
每一个Java对象都有一个内部锁。线程可以通过调用同步方法获得锁,也可以进入一个同步块来获得锁。
synchronized(obj)//获得对象obj的锁
{
critical section //临界区
}
5.5.6 死锁
锁和条件不能解决多线程中可能出现的所有问题。考虑下面的情况:
1.账户1:$200
2.账户2:$300
3.线程1:从账户1转$300到账户2
4.线程2:从账户 2 转 $400 到账户 1
线程1和线程2显然都被阻塞。因为账户1以及账户2中的余额都不足以进行转账,两个线程都无法执行下去。
有可能会因为每一个线程要等待更多的钱款存入而导致所有线程都被阻塞。这样的状态称为死锁 (deadlock)。
在这个程序里,死锁不会发生,原因很简单。每一次转账至多$1000。因为总共有100个账户,而且所有账户的总金额是$100000,在任意时刻,至少有一个账户的余额高于$1000。从该账户转账的线程可以继续运行。
但是,如果修改run方法,把每次转账至多$1000的限制去掉,很快就会发生死锁。将NACCOUNTS设为10。每次交易的金额上限max值设置为2*INITIAL_BALANCE,然后运行该程序。程序运行一段时间后就会挂起。
还有一种做法会导致死锁,让第1个线程负责向第1个账户存钱,而不是从第1个账户取 也钱。这样一来,有可能所有线程都集中到一个账户上,每一个线程都试图从这个账户中取出大于该账户余额的钱。在程序中,转用TransferRunnable类的run方法。在transfer调用中,交换fromAccount和toAccount。运行该程序,会看到它几乎会立即死锁。
还有一种很容易导致死锁的情况:在程序中,将signalAll方法修改为signal方法,会发现该程序最终会挂起。(同样,将NACCOUNTS设为10可以更快地看到结果)。signaAll方法会通知所有等待增加资金的线程,与此不同,signal方法只解除一个线程的阻塞。如果该线程不能继续运行,所有的线程都会阻塞。
考虑下面这种可能发生死锁的情况。
1.账户1:$1990
2.所有其他账户:分别有$990
3.线程1:从账户1转$995到账户2
4.所有其他线程:从它们的账户转$995到另一个账户
显然,除了线程1,所有的线程都被阻塞,因为它们的账户中没有足够的金额。线程1继续执行,现在情况如下:
1.账户 1:$995
2.账户 2:$985
3.所有其他账户:分别有$990
然后,线程1调用signal方法。signal方法随机选择一个线程将它解除阻塞。假定它选择了线程3。该线程被唤醒,发现在它的账户里没有足够的金额,它再次调用await。但是,线程1仍在运行,将随机地产生一个新的交易,例如,
1.线程1:从账户1转$997到账户2
现在,线程1也调用await,所有的线程都被阻塞。系统死锁。
问题的起因在于signal调用。它只为一个线程解除阻塞,而且,它很可能选择一个根本不能继续运行的线程(在我们的例子中,线程2必须从账户2中取钱)。
遗憾的是,Java编程语言中没有任何东西可以避免或打破这种死锁。必须仔细设计程序,确保不会出现死锁。
5.4 小结
如果多个线程要并发地修改一个数据结构,例如散列表,那么很容易破坏这个数据结构。例如,一个线程可能开始向表中插入一个新元素。假定在调整散列表各个桶之间的链接关系的过程中,这个线程的控制权被抢占。如果另一个线程开始遍历同一个链表,可能使用无效的链接并造成混乱,可能会抛出异常或者陷入无限循环。
可以通过提供锁来保护共享的数据结构,但是选择线程安全的实现可能更为容易。在下一篇文章中,将讨论Java类库提供的另外一些线程安全的集合。