java笔记:熟练掌握线程技术---基础篇之线程的协作和死锁的问题(下)

  本文的主题是线程的协作和死锁。

  线程的协作我个人觉得就是线程的通信,比如有A和B两个线程,A和B都可以独立运行,A和B有时也会做信息的交换,这就是线程的协作了。在java里线程的协作是通过线程之间的“握手机制”进行的,这种握手机制是通过Object类里的wait()和notify()来实现的

  在我的记忆里,sleep(),wait()和notify()(notifyAll())方法是最爱被面试官问道的问题。下面我就从这几个方法的关系开始说起最终引入到线程协作的问题。

  sleep()方法属于Thread类,wait()和notify()(notifyAll())方法属于Object类

  上面就是我要说的第一的原理,这里我再强调下:sleep()方法属于Thread类,wait()和notify()(notifyAll())方法属于Object类

  我在前面文章讲过,java里的对象天生都包含一个锁,锁是属于对象Object类而非Thread类,那么这里就又有一个原理了:调用sleep()的时候锁并没有被释放。sleep()和wait()方法都可以让线程停止,但是两种方法停止的本质是不同的,wait()方法可以释放锁,而sleep()不能释放锁,这个特性很重要,锁机制是保证线程安全的,实现线程同步,sleep()方法不能释放锁也就说明sleep()方法控制不了线程同步,而wait方法使用时候可以让被锁同步的其他的方法被调用,所以wait()方法能控制线程同步大家看到了,wait()方法的使用影响到了其他线程的使用,这就是所谓的线程的协作了,同样的对于notify()和notifyAll()方法他们会唤醒等待的线程,就是让线程获得当前的对象锁,而通知其他线程暂停下,这也是一种线程协作了

  总之等待(wait())和通知(notify())就是线程协作的一种方式了。

  Object类的wait()方法有两种形式,第一种是接受毫秒数作为参数,这种用法的意义和sleep()方法里的参数的意思相同,都是表达在“某个时间之内暂停”。但也有不同之处:一个就是上面释放锁的问题,第二个被wait()等待的线程可以通过notify()、notifyAll(),或者令时间到期从wait状态中恢复过来。

  第二种用法wait()方法不带任何参数,这种用法更常见。wait()使得线程无限的等待下去,直到这个线程收到notify()或者notifyAll()消息。

  wait()、notify()和notifyAll()方法属于Object类的一部分而不是Thread类的一部分,这个咋看一下真的很奇怪,不过细细思考下,这种做法是有道理的,我们把锁机制授予对象成为对象密不可分的属性会帮我们扩展线程应用的思路,至少把这几个方法放到对象中,我们就可以把wait方法放到任何的具有同步控制功能的方法,而不用去考虑方法所在的类是继承了Thread还是实现了Runnable接口。但是事实上使用sleep()、wait()、notify()和notifyAll()方法还是要注意:只能在同步控制方法或同步块里调用wait()、notify()和notifyAll()方法,因为这些操作都会使用到锁;而对于不操作锁的操作也就是非同步控制方法里我们才能调用sleep()方法。下面就是我们常常在无意中会犯的问题:如果是在非同步的方法里调用wait()、notify()和notifyAll()方法,程序会编译通过,但是在运行时候程序会报出IllegalMonitorStateException异常,同时会包含一些让人摸不着头脑的提示例如:当前线程不是拥有者,这个提示的含义是:调用wait()、notify()和notifyAll()方法的线程在调用这些方法前必须拥有这个对象的锁

  要理解wait()、notify()和notifyAll()这三个方法,关键就在wait()方法,一般在什么样情况下我们使用wait()方法了,下面这段文字就是我自己对他的总结:

  当你编写的带有同步性质的线程们其中有个A线程,我们先让A线程暂停了,当程序运行到某个时刻,我们又需要A线程启动起来干活,而此时的A线程在干嘛呢?它在等待一个条件去激活它,而激活它的条件又必须是另外一个线程比如是B线程发出,那就应该使用wait方法让A线程停止了,而想唤醒被wait方法停止的线程就得使用notify或者是notifyAll方法了

  下面我写了一段代码来演示wait和notify的用法:

package cn.com.sxia;

class Order{
private static int i = 0;
private int count = i++;

public Order(){
if (count == 10){
System.out.println("食物没有了,打烊");
System.exit(0);
}
}

@Override
public String toString() {
return "Order [count=" + count + "]";
}
}

class WaitPerson extends Thread{
private Restaurant restaurant;

public WaitPerson(Restaurant r){
restaurant = r;
start();
}

public void run(){
while(true){
while(restaurant.order == null){
synchronized (this) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("服务员得到订单:" + restaurant.order);
restaurant.order = null;
}
}
}
}

class Chef extends Thread{
private Restaurant restaurant;
private WaitPerson waitPerson;

public Chef(Restaurant r,WaitPerson w){
restaurant = r;
waitPerson = w;
start();
}

public void run(){
while(true){
if (restaurant.order == null){
restaurant.order = new Order();
System.out.println("下订单");
synchronized (waitPerson) {
waitPerson.notify();
}
}
try {
sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

public class Restaurant {

Order order;

public static void main(String[] args) {
Restaurant restaurant = new Restaurant();
WaitPerson waitPerson = new WaitPerson(restaurant);
Chef chef = new Chef(restaurant, waitPerson);

}

}

  结果如下:

下订单
服务员得到订单:Order [count=0]
下订单
服务员得到订单:Order [count=1]
下订单
服务员得到订单:Order [count=2]
下订单
服务员得到订单:Order [count=3]
下订单
服务员得到订单:Order [count=4]
下订单
服务员得到订单:Order [count=5]
下订单
服务员得到订单:Order [count=6]
下订单
服务员得到订单:Order [count=7]
下订单
服务员得到订单:Order [count=8]
下订单
服务员得到订单:Order [count=9]
食物没有了,打烊

  程序的逻辑意思大致是这样的:有一个餐馆Restaurant,里面只有一个厨师Chef和一个服务员WaitPerson,服务员必须等待厨师做好食物,而厨师做好了食物的时候会通知服务员食物做好了,服务员得到食物分给客人,接着服务员获得订单告诉厨师,服务员继续等待,直到厨师的10个食物全部做完。

  这就是典型的线程协作的例子:生产者(厨师)—消费者()服务员。

  程序中,WaitPerson必须首先从Restaurant哪里获得订单restaurant.order,接着在waitperson的run方法里调用wait方法让waitperson对象的线程处于等待状态,直到chef对象使用notify方法唤醒他。因为我们写的是简单程序,所以我们知道一个WaitPerson等待被唤醒,如果有多个waitperson服务员再等待同一个锁,那么就得使用notifyAll方法了,notifyAll将唤醒所有等待该锁的线程,而到底哪个线程对厨师的结果做出相应的回应就是要这些线程自己做协调了。至于Chef对象他必须知道餐馆的订单和要取走它食物的waitperson对象,这样就保证了订单的食物做好后会通知相关的服务员,大家看到了在Chef的run方法里调用notify方法,这里有个细节我要讲讲了:当Chef里的run方法调用waitperson的notify方法时候,chef对象会获得waitperson对象的锁,而原来执行wait()的waitperson对象会释放自己的锁,而notify方法调用时候对锁的控制有唯一性,这就保证了多个线程都有同一个对象的notify方法时候不会引起线程冲突。

  从这个例子表达的内容我们可以看出:一个线程操作了某个对象,另一个线程通过一定的方式可以使用前一个线程操作的对象,而这种典型的线程里的“生产者-消费者”模式中,使用的是“先进先出”的队列方式实现生产者和消费者模型

  Java里的线程协作还有更多的方式,不过上面的方式是最常用的,至于其他的实现我现在的资料不全,等资料收集全面我会在线程的进阶篇里写道。

  下面我要讲死锁了。讲到死锁之前首先要了解线程的状态。

  线程的状态一共分为5类:

  1. 新建(New):线程对象已经被创建,但是它还没有被启动,因此这个新建的好的线程还不能运行;
  2. 待运行(Runnable):在这种状态下,只要线程的调度程序把CPU计算的时间片分配给该线程,该线程就能运行了。这种状态就和汽车空转一样,我们知道汽车已经启动了,但它就是没跑,只要我们稍微踩下油门,汽车马上就可以飞奔起来。
  3. 运行(Running):线程已经启动了,线程调度机制赋予了线程CPU运算时间,正在跑的状态,线程正处在run方法运行之中;
  4. 死亡(Dead):线程的死亡通常都是因为run方法被跳出。
  5. 阻塞(Blocked):线程能够运行,但是某个条件阻止了它的运行。当线程处在阻塞状态下,线程的调度机制将会忽略该线程,不会再分配给该线程任何CPU计算时间,只有当该线程重新进入到待运行状态时候,该线程才能执行。

  我们要关心的是阻塞状态。那些原因能引起线程的阻塞呢?下面是我的总结:

  1. 当线程调用了sleep方法使得线程被挂起等待时候;
  2. 同样线程使用wait方法时候也会阻塞线程;
  3. 线程打算在某个对象上调用其同步控制方法,但是该线程的锁却无法被使用。

  死锁的问题就是线程阻塞和同步结合时候所产生的毛病。因为线程可以被阻塞,并且对象具有同步控制方法来防止别的线程在锁没有被释放时候就访问该对象,那么当线程运行时候就会产生下面的问题:A在等待B执行完毕或者是等待B程通知自己被释放的时候,而B线程又等待别的线程,这样一直延伸下去,直到这个等待的线程链条上的某个线程又会等待第一个线程也就是A线程释放掉自己的锁,这就很像死循环了,线程之间相互等待互不相让,最终所有线程都无法执行了,这就是“死锁”

  谈到线程的死锁,就不得不提经典的死锁现象:哲学家就餐问题。哲学家就餐问题是计算机界著名的科学家艾兹格·迪科斯彻提出的,原问题是:有5名哲学家,这些哲学家花部分时间思考问题,部分时间就餐,当他们思考时候,不需要任何共享资源,但是当他们就餐时候,但是他们的餐桌旁只有有限的餐具,如果餐桌的食物是面条,那么一个哲学家需要两根筷子。但是哲学家们都很穷,因此他们只购买了5跟筷子,5跟筷子平分给5个哲学家,当一个哲学家就餐时候,该哲学家必须从他左边或者右边的哲学家里借到一根筷子,当借到筷子的哲学家就餐时候,被借筷子的哲学家就得等待了

  下面就是哲学家问题的代码:

package cn.com.sxia;

import java.util.Random;

class Chopstick{
private static int counter = 0;
private int number = counter++;

@Override
public String toString() {
return "Chopstick [number=" + number + "]";
}
}

class Philosopher extends Thread{
private static Random rand = new Random();
private static int counter = 0;
private int number = counter++;
private Chopstick leftChopstick;
private Chopstick rightChopstick;
static int ponder = 0;

public Philosopher(Chopstick left,Chopstick right){
leftChopstick = left;
rightChopstick = right;
start();
}

public void think(){
System.out.println(this + "正在思考");
if (ponder > 0){
try {
sleep(rand.nextInt(ponder));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

public void eat(){
synchronized (leftChopstick) {
System.out.println(this + "拥有" + this.leftChopstick + "正在等待" + rightChopstick);
synchronized (rightChopstick) {
System.out.println(this + "正在吃");
}
}
}

@Override
public String toString() {
return "Philosopher [number=" + number + "]";
}

public void run(){
while(true){
think();
eat();
}
}
}

public class DiningPhilosophers {

public static void main(String[] args) {
if (args.length > 3){
System.out.println("参数输入的个数不正确");
System.exit(1);
}

Philosopher[] philosophers = new Philosopher[Integer.parseInt(args[0])];
Philosopher.ponder = Integer.parseInt(args[1]);
Chopstick left = new Chopstick(),right = new Chopstick(),first = left;
int i = 0;
while(i < philosophers.length - 1){
philosophers[i++] = new Philosopher(left, right);
left = right;
right = new Chopstick();
}
if (args[2].equals("deadlock")){
philosophers[i] = new Philosopher(left, first);
}else{
philosophers[i] = new Philosopher(first, left);
}

if (args.length >= 4){
int delay = Integer.parseInt(args[3]);
if (delay != 0){
//Timeout就是我在前面写的超时框架
new Timeout(delay * 1000, "超时了");
}
}
}

}

  程序中Chopstick就是筷子和哲学家Philosopher都使用一个自动增加的static counter来给每个生成的对象做标示,每一个Philosopher哲学家对象都有一个对左边和右边的Chopstick对象的引用,筷子就是哲学家在就餐前的餐具。静态变量ponder英文单词的意思是思考标示哲学家花多少时间进行思考,如果我们传入的值是0,那么在think方法里线程休眠的时间就由随机产生的。而在eat方法里哲学家通过同步控制获得左边筷子,如果筷子不可用,哲学家就会等待,下面就是我对哲学家问题的改变,让哲学家要获得两根筷子才能吃东西,当哲学家获得左边筷子后再用同样的方法获取右边的筷子,就餐完毕后先释放右边的筷子在释放左边的筷子。

  在main方法里,我们要传入参数,如果参数小于3个,程序会提示参数个数不正确,让程序退出,如果参数多余3个,假如第三个参数输入的是数字为N,那么在N秒后程序就会提示超时,正确参数个数是3个,第一个参数用来指明哲学家的个数,第二个参数用来指定哲学家思考的时间,第三个参数有两种用法一个就是上面说的超时时间,一个就是填入deadlock,这个参数就会让程序死锁,比如我输入3,20,deadlock参数,结果如下:

……………………….
Philosopher [number=0]正在吃
Philosopher [number=0]正在思考
Philosopher [number=0]拥有Chopstick [number=0]正在等待Chopstick [number=1]
Philosopher [number=0]正在吃
Philosopher [number=0]正在思考
Philosopher [number=2]拥有Chopstick [number=2]正在等待Chopstick [number=0]
Philosopher [number=2]正在吃
Philosopher [number=2]正在思考
Philosopher [number=0]拥有Chopstick [number=0]正在等待Chopstick [number=1]
Philosopher [number=1]拥有Chopstick [number=1]正在等待Chopstick [number=2]
Philosopher [number=2]拥有Chopstick [number=2]正在等待Chopstick [number=0]

  程序死锁的原因是:最后一个Philosopher被给予了左边筷子和前面存在在第一个first筷子,由于最后一个哲学家坐在第一个哲学家旁边,他们共享了第一根筷子,在这种情况就会出现在某个时间点上所有哲学家都会准备就餐的情况,最终就会产生死锁了

  死锁是我们很不愿意看到的现象,而且死锁的问题很隐蔽,有时在程序交付客户使用前都很难发现,让客户发现死锁问题可能就是程序员最丢脸的时候了。要避免死锁就得知道哪些情况会产生死锁,产生死锁一共需要4个条件被同时满足:

  1. 线程使用的资源至少有一个不能被共享,比如上面的哲学家问题里的筷子一次只能被一个哲学家使用,这种共享是排他的;
  2. 一个线程持有资源后还是处在等待状态中,这个线程正等待另一个线程释放相关的资源。
  3. 共享的资源不能被线程们抢占,其实就是线程们一定要被同步;
  4. 线程之间必须有相互等待资源的情况,这个很像死循环了。

  要解决死锁问题就是要破坏这四个条件中的一个即可,死锁问题的产生很难从语言级别进行控制,它只能依靠程序员仔细设计自己的程序来避免,这是一个很麻烦的过程,但是也没别的好办法了。

  线程阻塞是产生很多问题的源头,阻塞并不是错误,但有时因为线程阻塞碰到了错误我们很难纠正时候,那么我们可以用很暴力的方法中断线程,中断了阻塞的线程也许我们程序里的问题可能就被简单解决了,但是这种方案的给人的体验可能非常不好,想要中断阻塞的线程我们可以使用Thread.interrupt()方法实现,大家看下面的代码:

package cn.com.sxia;

import java.util.Timer;
import java.util.TimerTask;

class Blocked extends Thread{
public Blocked(){
System.out.println("线程阻塞开始了");
start();
}

public void run(){

try {
synchronized (this) {
wait();
}
} catch (InterruptedException e) {
System.out.println("中断操作");
}
System.out.println("退出run方法");
}
}

public class Interrupt {

static Blocked blocked = new Blocked();
public static void main(String[] args) {
new Timer(true).schedule(new TimerTask() {

@Override
public void run() {
System.out.println("准备中断线程");
blocked.interrupt();
blocked = null;
}
},2000);
}

}

  结果如下:

线程阻塞开始了
准备中断线程
中断操作
退出run方法

  线程的基础篇讲解完了,但是线程的主题还没有结束,不过我所知道线程的基础知识应该都讲到了,对于线程我还会开启一个线程进阶篇,进阶篇不是讲解更难的东西,而是讲一些有用或者有意思的线程技术。







posted @ 2011-12-21 12:36  夏天的森林  阅读(2197)  评论(9编辑  收藏  举报