详解Java多线程

来源:互联网 发布:mac系统编辑文档的软件 编辑:程序博客网 时间:2024/05/23 01:15

线程与多线程的概念

关于线程与多线程的较详细的理解可以参考:线程的解释 和多线程的解释。

而我们要做的是,对其进行“精炼"。我们每天都在和电脑、手机打交道,每天都在使用各种各样的应用软件。

打开上电脑的任务管理器,就可以看到有一项名为"进程"的栏目,点击到里面可能就会发现一系列熟悉的名称:QQ,360等等。

所以首先知道了,QQ、360之类的应用软件在计算机上被称为一个进程。


而一个应用程序都会有自己的功能,用以执行这些进程当中的个别功能的程序执行流就是所谓的线程。

所以,线程有时候也被称为轻量级进程,是程序执行流当中的最小单元。

线程的划分尺度小于进程,其不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。

进程在执行过程中拥有独立的内存单元,而多个线程共享内存,所以能极大地提高了程序的运行效率。

所以简而言之的概括的话,就是:一个程序至少有一个进程,一个进程至少有一个线程。


以360杀毒来说,里面的一项功能任务“电脑体检”就是该应用程序进程中的一个线程任务。

而除此任务之外,我们还可以同时进行多项操作。例如:“木马查杀”、“电脑清理”等。

那么,以上同时进行的多项任务就是所谓的存活在360应用程序进程中的多线程并发。


多线程的利与弊

多线程的有利之处,显而易见。在传统的程序设计语言中,同一时刻只能执行单任务操作,效率非常低。

假设在某个任务执行的过程中发生堵塞,那么下一个任务就只能一直等待,直至该任务执行完成后,才能接着执行。

而得益于多线程能够实现的并发操作,即使执行过程中某个线程因某种原因发生阻塞,也不会影响到其它线程的执行。

也就是说,多线程并发技术带来的最大好处就是:很大程度上提高了程序的运行效率。


似乎百里而无一害的多线程并发技术,还有弊端吗?从某种程度上来说,也是存在的:会导致任务执行效率的降低。

之所以这样讲,是因为所谓的“并发”并不是真正意义上的并发,而是CPU在多个线程之间做着快速切换的操作。

但CPU的运算速度肯定是远远高于人类的思维速度,所以就带来了一种“并发”的错觉。

那就不难想象了:假设某一进程中,线程A与线程B并发执行,CPU要做的工作就是:

不断快速且随机的在两个线程之间做着切换,分别处理对应线程上的线程任务,直到两个线程上的任务都被处理完成。


那么,也就可以考虑这样的情况:CPU执行完原本线程A的线程任务只需要5秒;但如今因为另一个线程B的并发加入。

CPU则不得不分出一部分时间切换到线程B上进行运算处理。于是可能CPU完成该线程任务A的时间反而延长到了7秒。

所以所谓的效率降低,就是指针对于某单个任务的执行效率而言的。

也就是说,如果在多线程并发操作时,如果有某个线程的任务你认为优先级很高。那么则可以:

通过设置线程优先级或者通过代码控制等手段,来保证该线程享有足够的“特权”。

注:Java中设置线程优先级,实际上也只是设置的优先级越大,该线程被CPU随机访问到的概率会相对高一些。


这个过程可以替换成一些实际生活中的情形来进行思考。快过年了,以家庭团聚为例。

假设你除了准备炒一桌子美味的菜肴之外,过年自然还要有一顿热腾腾的饺子。那么:

传统单任务的操作过程可以被理解为:先把准备的菜肴都做好;菜都端上桌后便开始煮饺子。

这样做的坏处就是:如果在炒菜的中途发生一些意外情况,那么随着炒菜动作的暂停。煮饺子的动作也将被无限期延后。

而对应于多线程并发的操作就是:一边炒菜,一边煮饺子。这时你就是CPU,你要做的动作可能是这样的:

炒菜的中途你能会抽空去看看锅里的饺子煮好没有;发现没有煮好,又回来继续炒菜。炒好一道菜后,再去看看饺子能出锅了没。

由此你发现,你做的工作与CPU处理多线程并发的工作是一样的:不断的在“煮饺子”与“炒菜”两个任务之间做着切换。


线程的周期及状态

Java中线程的整个生命周期基本可以划分为如下4种状态:

  • new - 创建状态:顾明思议,Java通过new创建了一个线程对象过后,该线程就处于该状态。
  • runnable- 可执行状态:也就是指在线程对象调用start()方法后进入的状态。但需要注意的是该状态是“可执行状态”而不是“执行状态”。也就是说,当一个线程对象调用start方法后,只是意味着它获取到了CPU的执行资格,并不代表马上就会被运行(CPU此时当然可能恰好切换在其它线程上做处理),只有具备了CPU当前执行权的线程才会被执行。
  • non Runnable- 不可执行/阻塞状态:也就是通过一些方法的控制,使该线程暂时释放掉了CPU的执行资格的状态。但此时该线程仍然是存在于内存中的。
  • done -退出状态:简单的说也就是当线程进入到退出状态,就意味着它消亡了,不存在了。Java里通过stop方法可以强制线程退出,但该方法因为可能引起死锁,所以是不建议使用的。另外一种进入该状态的方式,是线程的自然消亡,也就当一个线程的任务被执行完毕之后,就会自然的进入到退出状态。

以下是Java中一些用于改变线程状态的方法列表:



Java中创建线程的方式

Java里面创建的线程的方式主要分为:
  • 继承Thread类,并覆写run方法。
[java] view plain copy
  1. public class Demo extends Thread{  
  2.   
  3.     @Override  
  4.     public void run() {  
  5.         //...  
  6.     }  
  7. }  
  • 实现Runnable接口,并定义run方法:
[java] view plain copy
  1. public class Demo implements Runnable{  
  2.   
  3.     @Override  
  4.     public void run() {  
  5.         //...  
  6.     }  
  7. }  
  • 还有一种情况,如果你认为没有将线程单独封装出来的时候,可以通过匿名内部类来实现。

开发中通常选择通过实现Runnbale接口的方式创建线程,好处在于:

1.Java中不支持多继承,所以使用Runnable接口可以避免此问题。

2.实现Runnable接口的创建方式,等于是将线程要执行的任务单独分离了出来,更符合OO要求的封装性。


多线程的安全隐患

春运将至了,还是先通过一个老话题来看一个多线程并发的例子,来看看多线程可能存在的安全隐患。

[java] view plain copy
  1. package com.tsr.j2seoverstudy.thread;  
  2.   
  3. public class TicketDemo {  
  4.   
  5.     public static void main(String[] args) {  
  6.         Runnable sale = new TicketOffice();  
  7.         Thread t1 = new Thread(sale, "1号售票窗口");  
  8.         Thread t2 = new Thread(sale, "2号售票窗口");  
  9.         t1.start();  
  10.         t2.start();  
  11.     }  
  12. }  
  13.   
  14. class TicketOffice implements Runnable {  
  15.     // 某车次的车票存量  
  16.     private int ticket_num = 10;  
  17.   
  18.     @Override  
  19.     public void run() {  
  20.         while (true) {  
  21.             if (ticket_num > 0) {  
  22.                 try {  
  23.                     Thread.sleep(10);  
  24.                 } catch (InterruptedException e) {  
  25.                 }  
  26.                 String output = Thread.currentThread().getName() + "售出了"  
  27.                 + ticket_num-- + "号票";  
  28.                 System.out.println(output);  
  29.             } else {  
  30.                 break;  
  31.             }  
  32.         }  
  33.     }  
  34. }  
  35.   
  36. /* 
  37. 可能出现如下的输出结果: 
  38. 2号售票窗口售出了10号票 
  39. 1号售票窗口售出了9号票 
  40. 1号售票窗口售出了8号票 
  41. 2号售票窗口售出了7号票 
  42. 1号售票窗口售出了6号票 
  43. 2号售票窗口售出了5号票 
  44. 1号售票窗口售出了4号票 
  45. 2号售票窗口售出了3号票 
  46. 1号售票窗口售出了2号票 
  47. 2号售票窗口售出了1号票 
  48. 1号售票窗口售出了0号票 
  49. */  
按我们的理想的想法是:两个售票处共同完成某车次列车的10张车票:座位号为1号到10号的车票的售票工作。

而根据程序的输出结果,我们发现的安全隐患是:有座位号为0号的车票被售出了,买到这张车票的顾客该找谁说理去呢?


我们来分析一下为什么会出现这样的错误情况,其形成的原因可能是这样的:

当线程1执行完“1号售票窗口售出了2号票”之后,根据while循环的规则,再一次开始售票工作。

首先判断while为true,进入到while循环体;接着判断if语句,此时余票数为1张(也就是只剩下座位号为1的车票了)。

1大于0,满足判断条件,进入到if语句块当中。此时执行到"Thread.sleep(10)"语句。

OK,当前线程进入到堵塞状态,暂时失去了Cpu的执行资格。于是Cpu重新切换,开始执行线程2。


于是线程2开始执行线程任务,又是老样子:while判断 - if判断,由于上次线程1判断后还没执行售票工作,就被阻塞了。

所以这次if判断仍然为"1>0",满足判断条件,继续执行,又执行到线程休眠语句,于是线程2也进入阻塞状态。

此时两个线程暂时都不具备执行资格,但我们指定线程休眠的时间为10毫秒,于是10毫秒后,可能两个线程都苏醒了,恢复了Cpu的执行资格。

面对两个都处于可执行状态的线程,Cpu又只好随机选择一个先执行了。于是Cpu选择了线程2,线程2恢复执行。

线程2开始做自己上次没做完的事,于是执行表达式和输出语句,于是得到输出信息"2号售票窗口售出了1号票"。


线程2继续执行while判断,没问题。再执行if判断"0>0",不满足判断条件,于是执行到了break语句。

线程2到此退出循环,完成了所有线程任务,于是自然消亡进入done状态。

于是现在Cpu的执行权自然就属于线程1了,线程1也如同线程2一样,从美梦中醒来,开始上次没做完的事。

问题就在这里出现了,虽然这个时候,堆内存中存放的对象成员变量“ticket_num”的值实际上已经是0了。

但是!因为上一次线程1已经经过了if判断进入到了if语句块之内。所以它将直接开始执行表达式,并输出。

就形成了我们看到的错误信息:“1号售票窗口售出了0号票”。并且这个时候实际上余票数的值已经是“-1”了。


所以,实际上之所以我们在处理卖票的代码之前加上让线程休眠10毫秒的代码,目的也就是为了模拟线程安全隐患的问题。

而根据这个例子我们能够得到的信息就是:之所以多线程并发存在着安全隐患,正是CPU的实际处理方式是在不同线程之间做着随机的快速切换。

这意味着它并不会保证当处理一个线程的任务时,一定会执行完该次线程的所有代码才做切换。而是可能做到一半就切换了。


所以,我们可以归纳线程安全隐患之所以会出现的原因就是因为:

  • 多个并发线程操作同一个共享数据
  • 操作该共享数据的代码不止一行,存在多行


解决线程安全隐患的方法 - 同步锁

既然已经了解了线程安全隐患之所以产生,就是因为线程在操作共享数据的途中,其它线程被参与了进来。

那么我们想要解决这一类的安全隐患,自然就是保证在某个线程在执行线程任务的时候,不能让其余线程来捣乱。

在样的做法,在Java当中被称为同步锁,也就是说给封装在同步当中的代码加上一把锁。

每次只能由一个线程能够获取到这把锁,只有当前持有锁的线程才能执行同步当中的代码,其它线程将被拒之门外。


Java中对于同步的使用方式通常分为两种,即:同步代码块和同步函数。关键字synchronized用以声明同步。其格式分别为:

[java] view plain copy
  1. //同步代码块  
  2. synchronized (对象锁) {  
  3. //同步代码  
  4.     }  
  5.   
  6. //同步函数  
  7. synchronized void method(){  
  8. //同步代码  
  9. }  
通过同步我们就可以解决上面所说的“春节卖票”问题的安全隐患:
[java] view plain copy
  1. package com.tsr.j2seoverstudy.thread;  
  2.   
  3. public class TicketDemo {  
  4.   
  5.     public static void main(String[] args) {  
  6.         Runnable sale = new TicketOffice();  
  7.         Thread t1 = new Thread(sale, "1号售票窗口");  
  8.         Thread t2 = new Thread(sale, "2号售票窗口");  
  9.         t1.start();  
  10.         t2.start();  
  11.     }  
  12. }  
  13.   
  14. class TicketOffice implements Runnable {  
  15.     // 某车次的车票存量  
  16.     private int ticket_num = 10;  
  17.     Object objLock = new Object();  
  18.   
  19.     @Override  
  20.     public void run() {  
  21.         while (true) {  
  22.             synchronized (objLock) {  
  23.                 if (ticket_num > 0) {  
  24.                     try {  
  25.                         Thread.sleep(10);  
  26.                     } catch (InterruptedException e) {  
  27.                     }  
  28.                     String output = Thread.currentThread().getName() + "售出了"  
  29.                             + ticket_num-- + "号票";  
  30.                     System.out.println(output);  
  31.                 } else {  
  32.                     break;  
  33.                 }  
  34.             }  
  35.         }  
  36.     }  
  37. }  
再次运行该代码,就不会再出现之前的安全隐患。

这正是因为我们通过同步代码块,将希望每次只有有一个线程执行的代码封装了起来,为它们加上了一把同步锁(对象)。


同步最需要注意的地方,就是要保证锁的一致性。这是因为我们说过了:

同步的原理就是锁,每次当有线程想要访问同步当中的代码的时候,只有获取到该锁才能执行。

所以如果锁不能保证是同一把的话,自然也就实现不了所谓的同步了。

可以试着将定义在TicketOffice的成员变量objLock移动定义到run方法当中,就会发现线程安全问题又出现了。

这正是因为,将对象类型变量objLock定义为成员变量,它会随着该类的对象存储在堆内存当中,该变量在内存中独此一份。

而移动到run方法内,则会存储在栈内存当中,而每一个线程都会在栈内存中,单独开辟一条方法栈。

这样就等于每个线程都有一把独自的锁,自然也就不是所谓的同步了。


而同步函数的原理实际上与同步代码块是相同的,不同的只是将原本包含在同步代码块当中的代码单独封装到一个函数中:

[java] view plain copy
  1. private synchronized void saleTicket() {  
  2.         while (true) {  
  3.             if (ticket_num > 0) {  
  4.                 try {  
  5.                     Thread.sleep(10);  
  6.                 } catch (InterruptedException e) {  
  7.                 }  
  8.                 String output = Thread.currentThread().getName() + "售出了"  
  9.                         + ticket_num-- + "号票";  
  10.                 System.out.println(output);  
  11.             } else {  
  12.                 break;  
  13.             }  
  14.         }  
  15.     }  

而另外一点值得说明的是,就是关于不同方式使用的锁的差别:

同步代码块:可以使用任一对象锁。

同步函数:使用this作为锁。

静态同步函数:使用该函数所在类的字节码文件对象作为锁


死锁现象

提到同步,就不得不提到与之相关的一个概念:死锁。

死锁是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。

此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。同理,线程也会出现死锁现象。

[java] view plain copy
  1. <span style="font-family:SimSun;font-size:12px;">package com.tsr.j2seoverstudy.thread;  
  2.   
  3. public class DeadLockDemo {  
  4.     public static void main(String[] args) {  
  5.         Queue q1 = new Queue(true);  
  6.         Queue q2 = new Queue(false);  
  7.         Thread t1 = new Thread(q1, "线程1");  
  8.         Thread t2 = new Thread(q2, "线程2");  
  9.         t1.start();  
  10.         t2.start();  
  11.     }  
  12. }  
  13.   
  14. class MyLocks {  
  15.     public static final Object LOCK_A = new Object();  
  16.     public static final Object LOCK_B = new Object();  
  17. }  
  18.   
  19. class Queue implements Runnable {  
  20.   
  21.     boolean flag;  
  22.   
  23.     Queue(boolean flag) {  
  24.         this.flag = flag;  
  25.     }  
  26.   
  27.     @Override  
  28.     public void run() {  
  29.         String threadName = Thread.currentThread().getName();  
  30.         while (true) {  
  31.             if (flag) {  
  32.                 synchronized (MyLocks.LOCK_A) {  
  33.                     System.out.println(threadName + "获取了锁A");  
  34.                     synchronized (MyLocks.LOCK_B) {  
  35.                         System.out.println(threadName + "获取了锁B");  
  36.                     }  
  37.                 }  
  38.             } else {  
  39.                 synchronized (MyLocks.LOCK_B) {  
  40.                     System.out.println(threadName + "获取了锁B");  
  41.                     synchronized (MyLocks.LOCK_A) {  
  42.                         System.out.println(threadName + "获取了锁A");  
  43.                     }  
  44.                 }  
  45.             }  
  46.   
  47.         }  
  48.   
  49.     }  
  50.   
  51. }</span>  
上面的程序就演示了一个死锁的现象:

线程1开启执行后,判断标记为true,于是先获取了锁A,并输出信息。

此时CPU做切换,线程2开启执行,判断标记为false,首先获取锁B,并输出相关信息。

但这时候无论CPU再怎么样切换,程序都已经无法继续推进了。

因为线程1想要继续推进必须获取的资源锁B现在被线程2持有,反之线程2需要的锁A被线程1持有。

这正是因为两个线程因为互相争夺资源而造成的死锁现象。

死锁还是很蛋疼的,一旦出现,程序的调试和查错修改工作都会变得很麻烦


线程通信 - 生产者与消费者的例子

关于多线程编程,类似于车站卖票的例子是一种常见的使用途径。

这种应用途径通常为:多个线程操作共享数据,并且执行的是同一个动作(线程任务)。

车站售票:多个线程都是操作同一组车票,并且都是执行同一个动作:出售车票。


那么在多线程当中的另一个经典例子:生产者与消费者,就描述的是另一种常见的应用途径。

多个线程操作共享数据,但是不同的线程之间执行的是不同的动作(线程任务),这就是线程通信的使用。


不同线程间的通信应当怎么样来完成,其手段是通过Object类当中提供的几个相关方法:

  • wait():在其他线程调用此对象的notify()方法或notifyAll()方法前,导致当前线程等待。
  • notify():唤醒在此对象监视器上等待的单个(任一一个)线程。
  • notifyAll():唤醒在此对象监视器上等待的所有线程。

首先,我们可能会思考的一点就是:既然是针对于线程之间相互通信的方法,为什么没有被定义在线程类,反而被定义在了Object类当中。

因为这些方法事实上我们可以视作是线程监视器的方法,监视器其实就是锁。

我们知道同步中的锁,可以是任意的对象,那么既然是任一对象调用的方法,自然一定被定义在Object类中。

可以将所有使用同一个同步的线程视作被存储在同一个线程池当中,而该同步的锁就是该线程池的监视器。

由该监视器来调度对应线程池内的各个线程,从而达到线程通信的目的。


接下来就来看生产者与消费者的例子:

1.生产者生产商品;

2.消费者购买商品。

3.可能会同时存在多个生产者与多个消费者。

4.多个生产者中某个生产者生产一件商品,就暂停生产,并在多个消费者中通知一个消费者进行消费;

  消费者消费掉商品后,停止消费,再通知任一一个生产者进行新的生产工作。

[java] view plain copy
  1. package com.tsr.j2seoverstudy.thread;  
  2.   
  3. public class ThreadCommunication {  
  4.     public static void main(String[] args) {  
  5.         Queue q = new Queue();  
  6.         Customer c = new Customer(q);  
  7.         Producer p = new Producer(q);  
  8.         Thread t1 = new Thread(c, "消费者1-");  
  9.         Thread t2 = new Thread(c, "消费者2-");  
  10.         Thread t3 = new Thread(p, "生产者1-");  
  11.         Thread t4 = new Thread(p, "生产者2-");  
  12.   
  13.         t1.start();  
  14.         t2.start();  
  15.         t3.start();  
  16.         t4.start();  
  17.     }  
  18. }  
  19.   
  20. class Queue {  
  21.     //当前商品数量是否为0  
  22.     private boolean isEmpty = true;  
  23.   
  24.     //生产  
  25.     public synchronized void put() {  
  26.         String threadName = Thread.currentThread().getName();  
  27.         //如果生产者线程进入,而现在还有剩余商品  
  28.         while (!isEmpty) {  
  29.             try {  
  30.                 wait();//则该生产者暂时等待,不进行生产  
  31.             } catch (InterruptedException e) {  
  32.             }  
  33.         }  
  34.         //否则则生产一件商品  
  35.         isEmpty = false;  
  36.         System.out.println(threadName + "生产了一件商品");  
  37.         //唤醒阻塞的线程,通知消费者消费  
  38.         this.notifyAll();  
  39.   
  40.     }  
  41.   
  42.     //消费  
  43.     public synchronized void take() {  
  44.         String threadName = Thread.currentThread().getName();  
  45.         //消费者前来消费,如果此时没有剩余商品  
  46.         while (isEmpty) {  
  47.             try {  
  48.                 wait();//则让消费者先行等待  
  49.             } catch (InterruptedException e) {  
  50.             }  
  51.         }  
  52.         //否则则消费掉商品  
  53.         isEmpty = true;  
  54.         System.out.println(threadName + "消费了一件商品");  
  55.         //通知生产者没有商品了,起来继续生产  
  56.         this.notifyAll();  
  57.     }  
  58. }  
  59.   
  60. class Customer implements Runnable {  
  61.     Queue q;  
  62.   
  63.     Customer(Queue q) {  
  64.         this.q = q;  
  65.     }  
  66.   
  67.     @Override  
  68.     public void run() {  
  69.         for (int i = 0; i < 5; i++) {  
  70.             q.take();  
  71.         }  
  72.   
  73.     }  
  74. }  
  75.   
  76. class Producer implements Runnable {  
  77.     Queue q;  
  78.   
  79.     Producer(Queue q) {  
  80.         this.q = q;  
  81.     }  
  82.   
  83.     @Override  
  84.     public void run() {  
  85.         for (int i = 0; i < 5; i++) {  
  86.             q.put();  
  87.         }  
  88.   
  89.     }  
  90. }  

这就是对线程通信一个简单的应用。而需要记住的是:关于线程的停止与唤醒都必须定义在同步中。

因为我们说过了,关于所谓的线程通信工作。实际上是通过监视器对象(也就是锁),来完成对线程的停止或唤醒的操作的。

既然使用的是锁,那么自然必须被定义在同步中。并且,必须确保互相通信的线程使用的是同一个锁。

这是十分重要的,试想一下,如果试图用线程池A的监视器锁A去唤醒另一个线程池B内的某一个线程,这自然是办不到的。

简单解释下,你可能已经注意到在上面的例子中,我是直接采用"wait()"和"notifyAll()"的方式来唤醒和阻塞线程的。

那么你应该明白这其实对应于隐式的"this.wait()"与"this.notifyAll()",而同时我们已经说过了:

在同步方法中,使用的锁正是this。也就是说,在线程通信中,你可以将同步锁this看做是一个线程池的对象监视器。

当某个线程执行到this.wait(),就代表它在该线程池内阻塞了。而通过this.notify()则可以唤醒阻塞在这个线程池上的线程。



而到了这里,另一值得一提的一点就是:

Thread类的sleep()方法和Object类的wait()方法都可以使当前线程挂起,而它们的不同之处在于:

1:sleep方法必须线程挂起的时间,超过指定时间,线程将自动从挂起中恢复。而wait方法可以指定时间,也可以不指定。

2:线程调用sleep方法会释放Cpu的执行资格(也就是进入到non Runnable状态),但不会释放锁;

   而通过调用wait方法,线程即会释放cpu的执行资格,同时也会释放掉锁。


线程通信的安全隐患

与之前说过的卖票用例一样,对于线程通信的通信也应当小心谨慎,否则也可能会引发相关的错误。常见的问题例如:

一、使用notify而不是notifyAll唤醒线程可能会出现的问题

我在最初接触多线程的时候,容易这样考虑,既然想要达到的目的是:

生产者线程生产一件商品,则唤醒一个消费者线程。消费者进行消费,则唤醒一个生产者线程。

既然notify()方法用于唤醒单个线程,而notifyAll()用于唤醒所有线程,那使用notifyAll不是浪费效率吗?

后来明白,很可惜的是,我们要做的是唤醒单个对方线程。而notify没有这么强大。

它只是随机的唤醒一个处于阻塞状态下的线程,所以如果使用notify(),可能会看到如下的错误情况:


没错,操蛋,又出现了坑爹的死锁。为什么出现这样的情况呢?我们来分析一下:

  • 我们创建的4个线程经调用start方法之后,都进入了可执行状态,具备CPU执行资格。
  • CPU随机切换,首先赋予“生产者1”执行权,生产者1开始执行。
  • 生产者1判断isEmpty为true,执行一次生产任务。当执行notify方法时,当前还没有任何可以唤醒的阻塞线程。
  • 生产者1继续while循环,判断isEmpty为flase。执行wait,于是生产者1进入阻塞状态。

执行到此,当前处于可执行状态的线程为:生产者2、消费者1、消费者2

  • CPU在剩下的3个可执行状态中随机切换到了生产者2,于是生产者2开始执行。
  • 生产者2判断isEmpty为false,执行wait方法,于是生产者2也进入到临时阻塞状态。

于是,当前处于可执行状态的线程变为了:消费者1、消费者2

  • CPU继续随机切换,此次切换到消费者1开始执行。
  • 消费者1判断isEmpty为false,于是执行一次消费,修改isEmpty为true。
  • 执行到notify()方法,唤醒任一阻塞状态的线程,于是唤醒了生产者2。
  • 消费者1继续while循环,判断isEmpty为true,于是执行wait,进入阻塞。

到此,当前处于可执行状态的线程变为了:生产者2、消费者2

  • 同样的,CPU这次切换到消费者2执行。
  • 消费者2判断isEmpty为true,于是执行wait,进入阻塞。

好了,处于可执行状态的线程只剩下:生产者2。

  • 那么,自然现在只能是轮到生产者2执行了。
  • 判断isEmpty为true,执行一次生产。修改isEmpty为false。
  • 通过notify()方法随机唤醒了生产者1线程。
  • 再次执行while循环,判断isEmpty为false后,进入阻塞。

至此,唯一处于可执行状态的线程变为了:生产者1

  • 生产者1线程开始执行。
  • 判断isEmpty为false,执行wait进入阻塞。

这下好了,4个线程都进入了阻塞状态,而不是消亡状态。自然的,死锁了。


二、使用if而不是使用while判断isEmpty可能出现的问题

如果使用if而不是while对isEmpty进行判断,可能会出现的错误为:

1、不同的生产者连续生产了多件商品,但消费者只消费掉其中一件。

2、一个生产者生产了一件商品之后,有多个消费者进行连续消费。

出现这样的安全问题是因为if的判断机制造成的:通过if来判断标记,只会执行一次判断。

所以可能会导致不该运行的线程运行了,从而出现数据错误的情况。

这种问题的出现也就是与我们上面说的“售票处售出0号票”的错误类似。


JDK1.5之后的新特性

我们前面已经说到了,关于生产者与消费者的问题中。

我们的目的是,每当一个线程执行完毕一次任务后,只唤醒单一的对方线程。

而在JDK1.5之前,为了避免死锁的发生,我们不得不使用notifyAll()来唤醒线程。

而这样做有一个缺点就在于:每次都要唤醒所有处于阻塞的线程,自然就会导致效率降低。


在JDK1.5之后,,Java提供了新的工具用于解决此类问题,就是:Lock和Condition接口。

简答的说,就是对将原本的同步锁synchronized与对象监视器进行了封装,分别对应于于Lock及Condition。

并且,重要的是相对于1.5之前,新的工具拥有更灵活及更广泛的操作。


一、Lock的使用及注意事项

1、通过Lock lock =  new ReentrantLock();获取一个Lock对象。

2、通过成员方法lock(),用于对代码进行同步管理。

3、通过成员方法unlock(),用于同步代码执行完毕后,释放锁对象。

4、由于不管在同步代码的执行过程中是否出现异常,最后都必须释放该锁,否则可能会导致死锁现象的产生。所以通常在使用lock时,都会遵循如下格式:

     lock.lock();

     try{

     {

      // 同步代码....

     }finally{

    lock.unlock();

       }

     }

二、对象监视器Condition的使用及注意事项

1、可以通过Lock对象使用成员方法newCondition()来获取一个新的监视器对象。

2、Condition分别使用await();signal();signalAll()来替代原本Object类当中的wait();notify();及notifyAll()方法。

3、同一个Lock对象可以拥有多个不同的Condition对象。


请注意一个很关键的特性:同一个Lock对象可以拥有多个不同的Condition对象

也就是说:通过此特性,我们可以获取多个Condition对象,将操作不同线程任务的线程分别存放在不同的Condition对象当中。

例如在前面所说的生产者消费者例子当中,我们就可以生成两组监视器,一组监视生产者线程,一组监视消费者线程。

从而达到我们想要的每次只唤醒对方线程而不唤醒本方线程的目的,修改后的例子代码如下:

[java] view plain copy
  1. import java.util.concurrent.locks.Condition;    
  2. import java.util.concurrent.locks.Lock;    
  3. import java.util.concurrent.locks.ReentrantLock;    
  4.     
  5.     
  6. public class ThreadCommunication {    
  7.     public static void main(String[] args) {    
  8.        Queue q = new Queue();    
  9.        Customer c = new Customer(q);    
  10.        Producer p = new Producer(q);    
  11.        Thread t1 = new Thread(c,"消费者1-");    
  12.        Thread t2 = new Thread(c,"消费者2-");    
  13.        Thread t3 = new Thread(p,"生产者1-");    
  14.        Thread t4 = new Thread(p,"生产者2-");    
  15.            
  16.        t1.start();    
  17.        t2.start();    
  18.        t3.start();    
  19.        t4.start();    
  20.     }    
  21. }    
  22.     
  23. class Queue {    
  24.     private int goodsTotal;    
  25.     private boolean isEmpty = true;    
  26.         
  27.     final Lock lock = new ReentrantLock();    
  28.     final Condition notFull  = lock.newCondition();    
  29.     final Condition notEmpty = lock.newCondition();    
  30.         
  31.     public void put() {    
  32.         String threadName = Thread.currentThread().getName();    
  33.         lock.lock();    
  34.         try{    
  35.         while (!isEmpty) {    
  36.             try {    
  37.                 notFull.await();    
  38.             } catch (InterruptedException e) {    
  39.             }    
  40.         }    
  41.         goodsTotal ++;    
  42.         System.out.println(threadName + "生产了一件商品");    
  43.         isEmpty = false;    
  44.         notEmpty.signal();    
  45.         }finally{    
  46.             lock.unlock();    
  47.         }    
  48.     
  49.     }    
  50.     
  51.     public synchronized void take() {    
  52.         String threadName = Thread.currentThread().getName();    
  53.         lock.lock();    
  54.         try{    
  55.         while (isEmpty) {    
  56.             try {    
  57.                 notEmpty.await();    
  58.             } catch (InterruptedException e) {    
  59.             }    
  60.         }    
  61.         goodsTotal --;    
  62.         System.out.println(threadName + "消费了一件商品");    
  63.         isEmpty = true;    
  64.         notFull.signal();    
  65.         }finally{    
  66.             lock.unlock();    
  67.         }    
  68.     }    
  69. }    
  70.     
  71. class Customer implements Runnable {    
  72.     Queue q;    
  73.     
  74.     Customer(Queue q) {    
  75.         this.q = q;    
  76.     }    
  77.     
  78.     @Override    
  79.     public void run() {    
  80.         while (true) {    
  81.             q.take();    
  82.         }    
  83.     
  84.     }    
  85. }    
  86.     
  87. class Producer implements Runnable {    
  88.     Queue q;    
  89.     
  90.     Producer(Queue q) {    
  91.         this.q = q;    
  92.     }    
  93.     
  94.     @Override    
  95.     public void run() {    
  96.         while (true) {    
  97.             q.put();    
  98.         }    
  99.     
  100.     }    
  101. }    


线程的常用方法

最后,看一下一些关于线程的常用方法。

一、线程的中断工作

1、通常使用自然中断的做法,也就是当某个线程的线程任务执行结束之后,该线程就会自然终结。

2、通过标记控制。如果线程任务中存在循环(通常都有),那么,可以在循环中使用标记,通过标记来控制线程的中断。


、interrupt()方法:中断线程

我们知道sleep及wait等方法都可以使线程进入阻塞状态。所以可能你在程序通过使用标记的方式来控制线程的中断,但由于过程中线程陷入了冻结(挂起/阻塞)状态,这时通过标记将无法正常的控制线程中断。这时,就可以通过interrupt方法来中断线程的冻结状态,强制恢复到运行状态中来,让线程具备cpu的执行资格。但是因为此方法具有强制性,所以会引发InterruptedException,所以要记得处理异常。

三、setDaemon()方法:将该线程标记为守护线程或用户线程。

所谓守护线程,可以理解为后台线程。对应的,我们在程序中开辟的线程都可以视为前台线程,在Java中,当所有的前台线程都执行结束之后,后台线程也将随之结束。

例如:你在某个程序中开辟两个线程,一个用于接收输入,一个用于控制输出。因为只有当有输入存在时,才会存在输出。这时就可以通过setDaemon将输出线程设置为守护线程。这样当输入线程中断结束时,输出线程就会随之自动中断,而不必再人为控制中断。


四、控制线程优先级

所谓控制线程优先级,是指我们可以通过设置线程的优先级来控制线程被CPU运行到的几率,线程的优先级越高,被CPU运行的概率越大。

通过setPriority()与getPriority()方法可以分别设置和获取某个线程的优先级。Java中线程的优先级取值范围为:1-10

Thread类中使用MAX_PRIORITY(10),NORM_PRIORITY(5),MIN_PRIORITY(1)三个常量代表最常用的线程优先级值。


五、join()方法

线程使用join方法,意味着该线程申请加入执行,所以通常如果要临时加入一个线程,可以使用join()方法。并且,当执行到join方法之后,其余线程将等待使用该方法的线程执行完线程任务之后,再继续执行。


六、yiled()方法

暂停正在执行的线程对象,并执行其他线程。
0 0
原创粉丝点击