Java基础教程之多线程 · 下
- 🔹本节学习目标
- 1️⃣ 线程的同步与死锁
- 1.1 同步问题的引出
- 2.2 synchronized 同步操作
- 2.3 死锁
- 2️⃣ 多线程经典案例——生产者与消费者
- 🔍分析sleep()和wait()的区别?
- 🌾 总结
🔹本节学习目标
- 理解多线程中的同步与死锁的概念;
- 掌握Object 类中对于多线程的支持;
1️⃣ 线程的同步与死锁
程序利用线程可以进行更为高效的程序处理,如果在没有多线程的程序中, 一个程序在处理某些资源时会有主方法(主线程全部进行处理),但是这样的处理速度一定会比较慢,如下图 (a) 所示。而如果采用了多线程的处理机制,利用主线程创建出许多子线程(相当于多了许多帮手), 一起进行资源的操作,如下图 (b) 所示,那么执行效率一定会比只使用一个主线程更高。
在程序开发中,所有程序都是通过主方法执行的,而主方法本身就属于一个主线程, 所以通过主方法创建的新的线程对象都是子线程。在Android开发中,默认运行的 Activity 就可以理解为主线程,当移动设备需要读取网络信息时往往会启动新的子线程读取,而不会在主线程中操作。
利用子线程可以进行异步的操作处理,这样可以在不影响主线程运行的前提下进行其他操作,程序的执行速度不仅变快了,并且操作起来也不会产生太多的延迟。对于这部分知识,有些刚接触Java的朋友理解起来可能会有些困难,但随着开发经验提升,自己慢慢可以领会的更多。
虽然使用多线程同时处理资源效率要比单线程高许多,但是多个线程操作同一个资源时也一定会带来一些问题,如资源操作的完整性问题等等。
1.1 同步问题的引出
同步是多线程开发中的一个重要概念,既然有同步,就一定会存在不同步的操作。多个线程操作同一资源时就有可能出现不同步的问题,例如:现在产生 N 个线程对象进行卖票操作,为了更加明显地观察不同步所带来的问题,所以下面案例程序将使用线程的休眠操作。
// 范例 1: 观察非同步情况下的操作
package com.xiaoshan.demo;class MyThread implements Runnable{private int ticket = 5; //一共有5张票@Overridepublic void run(){for(int x=0; x<20; x++){if(this.ticket > 0){ //判断当前是否还有剩余票try{Thread.sleep(100); //休眠1s, 模拟延迟}catch (InterruptedException e){e.printStackTrace();}System.out.println(Thread.currentThread().getName() + "卖票,ticket="+ this.ticket--);}}}
}public class TestDemo {public static void main(String[] args) throws Exception {MyThread mt = new MyThread();new Thread(mt,"窗口A").start(); //启动多线程new Thread(mt,"窗口B").start(); new Thread(mt,"窗口C").start(); new Thread(mt,"窗口D").start();}
}
执行结果:
窗口A卖票,ticket=5
窗口B卖票,ticket=5 (错误的数据,因为不同步所引起)
窗口D卖票,ticket=4
窗口C卖票,ticket=3
窗口D卖票,ticket=2
窗口C卖票,ticket=0
窗口B卖票,ticket=-1 (错误的数据,因为不同步所引起)
窗口A卖票,ticket=1
此程序模拟了一个卖票程序的实现,其中将有4 个线程对象共同完成卖票的任务,为了保证每次在有剩余票数时实现卖票操作,在卖票前增加了一个判断条件 (if (this.ticket>0)
), 满足此条件的线程对象才可以卖票,不过根据最终的结果却发现,这个判断条件的作用并不明显。
从上边范例的操作代码可以发现,对于票数的操作有如下步骤。
(1)判断票数是否大于0, 大于0 表示还有票可以卖;
(2)如果票数大于0, 则卖票出去。
但是,在上边范例的操作代码中,在第1步和第2步之间加入了延迟操作,那么一个线程就有可能在还没有对票数进行减操作之前,其他线程就已经将票数减少了,这样一来就会出现票数为负的情况,如下图所示。
2.2 synchronized 同步操作
如果想解决上边范例程序的问题,就必须使用同步操作。所谓同步操作就是一个代码块中的多个操作在同一个时间段内只能有一个线程进行,其他线程要等待此线程完成后才可以继续执行,如下图所示。
在 Java 里面如果要想实现线程的同步,操作可以使用 synchronized
关键字。 synchronized
关键字可以通过以下两种方式进行使用。
- 同步代码块:利用
synchronized
包装的代码块,但是需要指定同步对象,一般设置为this
; - 同步方法:利用
synchronized
定义的方法。
// 范例 2: 观察同步块
package com.xiaoshan.demo;class MyThread implements Runnable{private int ticket = 60;@Overridepublic void run(){for (int x=0; x<20; x++){synchronized(this){ //定义同步代码块if(this.ticket>0){ //判断当前是否还有剩余票try{Thread.sleep(100); //休眠1s, 模拟延迟}catch (InterruptedException e){e.printStackTrace();}System.out.println(Thread.currentThread().getName() + "卖票,ticket = " + this.ticket--);}}}}
}public class TestDemo {public static void main(String[] args) throws Exception {MyThread mt = new MyThread();new Thread(mt,"窗口A").start(); //启动多线程new Thread(mt,"窗口B").start(); new Thread(mt,"窗口C").start(); new Thread(mt,"窗口D").start();}
}
程序执行结果:
窗口A卖票,ticket = 60
窗口A卖票,ticket = 59
窗口A卖票,ticket = 58
窗口A卖票,ticket = 57
窗口A卖票,ticket = 56
窗口A卖票,ticket = 55
窗口A卖票,ticket = 54
窗口A卖票,ticket = 53
窗口A卖票,ticket = 52
窗口A卖票,ticket = 51
窗口A卖票,ticket = 50
窗口A卖票,ticket = 49
窗口A卖票,ticket = 48
窗口A卖票,ticket = 47
窗口A卖票,ticket = 46
窗口A卖票,ticket = 45
窗口C卖票,ticket = 44
窗口C卖票,ticket = 43
窗口C卖票,ticket = 42
窗口C卖票,ticket = 41
窗口C卖票,ticket = 40
窗口C卖票,ticket = 39
窗口C卖票,ticket = 38
窗口C卖票,ticket = 37
窗口C卖票,ticket = 36
窗口C卖票,ticket = 35
窗口C卖票,ticket = 34
窗口C卖票,ticket = 33
窗口D卖票,ticket = 32
窗口D卖票,ticket = 31
窗口D卖票,ticket = 30
窗口D卖票,ticket = 29
窗口D卖票,ticket = 28
窗口D卖票,ticket = 27
窗口D卖票,ticket = 26
窗口D卖票,ticket = 25
窗口D卖票,ticket = 24
窗口D卖票,ticket = 23
窗口D卖票,ticket = 22
窗口D卖票,ticket = 21
窗口D卖票,ticket = 20
窗口B卖票,ticket = 19
窗口B卖票,ticket = 18
窗口B卖票,ticket = 17
窗口B卖票,ticket = 16
窗口B卖票,ticket = 15
窗口B卖票,ticket = 14
窗口B卖票,ticket = 13
窗口B卖票,ticket = 12
窗口B卖票,ticket = 11
窗口B卖票,ticket = 10
窗口B卖票,ticket = 9
窗口B卖票,ticket = 8
窗口B卖票,ticket = 7
窗口B卖票,ticket = 6
窗口B卖票,ticket = 5
窗口B卖票,ticket = 4
窗口B卖票,ticket = 3
窗口B卖票,ticket = 2
窗口B卖票,ticket = 1
此程序将判断是否有票以及卖票的两个操作都统一放到了同步代码块中,这样当某一个线程操作时,其他线程无法进入到方法中进行操作,从而实现了线程的同步操作。
可以从程序运行结果发现,卖票数量被大致平均到了各个线程,而且未出现错误数据的情况。
// 范例 3: 使用同步方法解决问题
package com.xiaoshan.demo;class MyThread implements Runnable {private int ticket = 60; //一共有60张票@Overridepublic void run(){for(int x=0; x<20; x++){ this.sale();}}//卖票操作public synchronized void sale(){ //同步方法if(this.ticket>0){ //判断当前是否还有剩余票try{Thread.sleep(100); //休眠1s, 模拟延迟} catch (InterruptedException e){e.printStackTrace();}System.out.println(Thread.currentThread().getName()+ "卖票,ticket=" + this.ticket--);}}
}public class TestDemo {public static void main(String[] args) throws Exception {MyThread mt = new MyThread();new Thread(mt,"窗口A").start(); //启动多线程new Thread(mt,"窗口B").start(); new Thread(mt,"窗口C").start(); new Thread(mt,"窗口D").start();}
}
程序执行结果:
窗口A卖票,ticket=60
窗口A卖票,ticket=59
窗口A卖票,ticket=58
窗口A卖票,ticket=57
窗口A卖票,ticket=56
窗口A卖票,ticket=55
窗口A卖票,ticket=54
窗口A卖票,ticket=53
窗口A卖票,ticket=52
窗口A卖票,ticket=51
窗口A卖票,ticket=50
窗口A卖票,ticket=49
窗口A卖票,ticket=48
窗口A卖票,ticket=47
窗口A卖票,ticket=46
窗口A卖票,ticket=45
窗口A卖票,ticket=44
窗口A卖票,ticket=43
窗口A卖票,ticket=42
窗口A卖票,ticket=41
窗口D卖票,ticket=40
窗口D卖票,ticket=39
窗口D卖票,ticket=38
窗口D卖票,ticket=37
窗口D卖票,ticket=36
窗口D卖票,ticket=35
窗口D卖票,ticket=34
窗口D卖票,ticket=33
窗口D卖票,ticket=32
窗口D卖票,ticket=31
窗口D卖票,ticket=30
窗口D卖票,ticket=29
窗口D卖票,ticket=28
窗口D卖票,ticket=27
窗口D卖票,ticket=26
窗口D卖票,ticket=25
窗口D卖票,ticket=24
窗口C卖票,ticket=23
窗口C卖票,ticket=22
窗口C卖票,ticket=21
窗口C卖票,ticket=20
窗口C卖票,ticket=19
窗口C卖票,ticket=18
窗口C卖票,ticket=17
窗口C卖票,ticket=16
窗口C卖票,ticket=15
窗口B卖票,ticket=14
窗口B卖票,ticket=13
窗口B卖票,ticket=12
窗口B卖票,ticket=11
窗口B卖票,ticket=10
窗口B卖票,ticket=9
窗口B卖票,ticket=8
窗口B卖票,ticket=7
窗口B卖票,ticket=6
窗口B卖票,ticket=5
窗口B卖票,ticket=4
窗口B卖票,ticket=3
窗口B卖票,ticket=2
窗口B卖票,ticket=1
此时利用同步方法同样解决了同步操作的问题。但是在此处需要说明一个问题:加入同步后明显比不加入同步慢许多,所以同步的代码性能会很低,但是数据的安全性会高,或者可以称为线程安全性高。
那么在了解了以上知识后,同步和异步有什么区别呢,在什么情况下分别使用它们呢?
如果一块数据要在多个线程间进行共享。例如,正在写的数据以后可能被另一个线程读到,或者正在读的数据可能已经被另一个线程写过了,那么这些数据就是共享数据,必须进行同步存取。
当应用程序在对象上调用了一个需要花费很长时间来执行的方法,并且不希望让程序等待方法的返回时,就应该使用异步编程,在很多情况下采用异步途径往往更有效率。
2.3 死锁
同步就是指一个线程要等待另外一个线程执行完毕才会继续执行的一种操作形 式,虽然在一个程序中,使用同步可以保证资源共享操作的正确性,但是过多同步也会产生问题。
例如:张三想要李四的画,李死想要张三的书,那么张三对李四说 了:“把你的画给我,我就给你书", 李四也对张三说了:“把你的书给我,我就给你画", 这时,张三在等着李四的答复,而李四也在等着张三的答复,这样下去最终结果可想而知,张三得不到李四的画,李四也得不到张三的书,这实际上就是死锁的概念,如下图所示。
所谓死锁就是指两个线程都在等待彼此先完成,造成了程序的停滞状态, 一般程序的死锁都是在程序运行时出现的,下面通过一个简单的范例来观察一下出现死锁的情况。
// 范例 4: 程序死锁操作
package com.xiaoshan.demo;class A{public synchronized void say(B b){System.out.println("A先生:把你的本给我,我给你笔,否则不给!");b.get();}public synchronized void get(){System.out.println("A先生:得到了本,付出了笔,还是什么都干不了!");}
}class B{public synchronized void say(A a){System.out.println("B先生:把你的笔给我,我给你本,否则不给!");a.get();}public synchronized void get(){System.out.println("B先生:得到了笔,付出了本,还是什么都干不了!");}
}public class TestDemo implements Runnable{private static A a= new A(); //定义类对象private static B b= new B(); //定义类对象public static void main(String[] args) throws Exception {new TestDemo(); //实例化本类对象}public TestDemo(){ //构造方法new Thread(this).start(); //启动线程b.say(a); //互相引用}@Overridepublic void run(){a.say(b); //互相引用}
}
程序执行结果:
B先生:把你的笔给我,我给你本,否则不给!
A先生:把你的本给我,我给你笔,否则不给!
(程序将不再向下执行,并且不会退出,此为死锁情况出现)
此程序由于两个类的都使用了同步方法定义,就会造成 a
对象等待 b
对象执行完毕,而 b
对象等待 a
对象执行完毕,这样就会出现死锁现象。
综上,多个线程访问同一资源时,考虑到数据操作的安全性问题, 一定要使用同步操作。同步有以下两种操作模式:
- 同步代码块:
synchronized(锁定对象){代码}
; - 同步方法:
public synchronized 返回值 方法名称() {代码}
。
需要注意的是,过多的同步操作有可能会带来死锁问题,导致程序进入停滞状态。
2️⃣ 多线程经典案例——生产者与消费者
在开发中线程的运行状态并不固定,所以只能利用线程的名字以及当前执行的线程对象来进行区分。但是多个线程间也有可能会出现数据交互的情况。本节将利用一个线程的经典操作案例来分析线程的交互中存在问题以及问题的解决方案。
在生产者和消费者模型中,生产者不断生产,消费者不断取走生产者生产的产品,如下图所示。
在图中非常清楚地表示出,生产者生产出信息后将其放到一个区域中,然后消费者从此区域里取出数据,但是在程序中因为牵涉线程运行的不确定性,所以会存在以下两点问题。
(1)假设生产者线程向数据存储空间添加信息的名称,还没有加入该信息的内容,程序就切换到了消费者线程,消费者线程将把该信息的名称和上一个信息的内容联系到一起。
(2)生产者放了若干次的数据,消费者才开始取数据,或者是消费者取完一个数据后,还没等到生产者放入新的数据,又重复取出已取过的数据。
// 范例 5: 程序基本模型
package com.xiaoshan.demo;class Message{private String title; //保存信息的标题 private String content; //保存信息的内容public void setTitle(String title){this.title = title;}public void setContent(String content){this.content = content;}public String getTitle(){return title;}public String getContent(){return content;}
}class Producer implements Runnable { //定义生产者private Message msg = null;public Producer(Message msg){this.msg = msg;}@Overridepublic void run(){for(int x=0; x<8; x++){ //生产8次数据if(x%2 == 0){this.msg.setTitle("小山"); //设置 title属性try{Thread.sleep(100); //延迟操作} catch(InterruptedException e){e.printStackTrace();}this.msg.setContent("Java专栏作者"); //设置content属性}else{this.msg.setTitle("xiaoshan"); //设置 title 属性try {Thread.sleep(100);}catch (InterruptedException e){e.printStackTrace();}this.msg.setContent("www.xiaoshan.cn");// 设置content属性}}}
}class Consumer implements Runnable { //定义消费者private Message msg = null;public Consumer (Message msg){this.msg = msg;}@Overridepublic void run(){for(int x=0; x<8; x++){ //取走8次数据try{Thread.sleep(100); //延迟} catch(InterruptedException e){e.printStackTrace();}System.out.println(this.msg.getTitle() + "-->" + this.msg.getContent());}}
}public class TestDemo {public static void main(String[] args) throws Exception {Message msg = new Message(); //定义Message 对象,用于保存和取出数据new Thread(new Producer(msg)).start(); // 启动生产者线程new Thread(new Consumer(msg)).start(); // 取得消费者线程}
}
程序执行结果:
xiaoshan-->Java专栏作者
xiaoshan-->Java专栏作者
xiaoshan-->Java专栏作者
xiaoshan-->www.xiaoshan.cn
小山-->Java专栏作者
xiaoshan-->Java专栏作者
xiaoshan-->Java专栏作者
xiaoshan-->www.xiaoshan.cn
通过本程序的运行结果可以发现两个严重的问题:设置的数据错位;数据会重复设置及重复取出。
首先我们来解决数据错乱问题,数据错位完全是因为未同步的操作,所以应该使用同步处理。因为取出和设置是两个不同的操作,所以要想进行同步控制,就需要将其定义在一个类里面完成。
// 范例 6: 加入同步,解决数据错乱问题
package com.xiaoshan.demo;class Message {private String title; //保存信息的标题private String content; //保存信息的内容public synchronized void set(String title, String content){this.title = title;try {Thread.sleep(200);}catch(InterruptedException e){e.printStackTrace();}this.content = content;}public synchronized void get(){try {Thread.sleep(100);} catch(InterruptedException e){e.printStackTrace();}System.out.println(this.title + "-->" + this.content);}// setter、getter略
}class Producer implements Runnable { //定义生产者private Message msg = null;public Producer(Message msg){this.msg = msg;}@Overridepublic void run(){for(int x=0; x<8; x++){ //生产8次数据if(x%2 == 0){this.msg.set("小山", "Java专栏作者"); //设置属性}else{this.msg.set("xiaoshan", "www.xiaoshan.cn"); //设置属性}}}
}class Consumer implements Runnable { //定义消费者private Message msg = null;public Consumer (Message msg){this.msg = msg;}@Overridepublic void run(){for (int x=0; x<8; x++){ //取走8数据this.msg.get(); //取得属性}}
}public class TestDemo {public static void main(String[] args) throws Exception {Message msg = new Message(); //定义Message 对象,用于保存和取出数据new Thread(new Producer(msg)).start(); //启动生产者线程 new Thread(new Consumer(msg)).start(); //取得消费者线程}
}
程序执行结果:
小山-->Java专栏作者
小山-->Java专栏作者
小山-->Java专栏作者
小山-->Java专栏作者
小山-->Java专栏作者
xiaoshan-->www.xiaoshan.cn
xiaoshan-->www.xiaoshan.cn
xiaoshan-->www.xiaoshan.cn
从运行结果可以发现,数据错位问题此时已经因为使用了同步处理而得到了解决。下面我们来解决数据重复问题,要想解决数据重复的问题,需要等待及唤醒机制,而这一机制的实现只能依靠 Object
类完成,前面在《【Java基础教程】(十六)面向对象篇 · 第十讲:解读Object类——定义、操作方法、深拷贝和浅拷贝的差异、多线程编程支持及使用场景~》一文中介绍到了在 Object
类中定义了3个方法完成线程的操作,如下所示。
public final void wait(throws InterruptedException)
:线程的等待;public final void notify()
:唤醒第一个等待线程;public final void notifyAll()
:唤醒全部等待线程。
可以发现,一个线程可以为其设置等待状态,但是对于唤醒的操作却有两个: notify()
、notifyAll()
。一般来说,所有等待的线程会按照顺序进行排列。如果使用了 notify()
方法,则会唤醒第一个等待的线程执行;如果使用了notifyAll()
方法,则会唤醒所有的等待线程。哪个线程的优先级高,哪个线程就有可能先执行,如下图所示。
清楚了Object
类中的3个方法作用后,下面就可以利用这些方法来解决程序中的问题。如果想让生产者不重复生产,消费者不重复取走,则可以增加一个标志位,假设标志位为 boolean
型变量。如果标志位的内容为 true
, 则表示可以生产,但是不能取走,如果此时线程执行到了,消费者线程则应该等待;如果标志位的内容为 false
, 则表示可以取走,但是不能生产,如果生产者线程运行,则应该等待。
操作流程如下图所示。
所以要想解决数据重复的问题,只需要直接修改 Message
类即可。在 Message
类中加入标志位,并通过判断标志位完成等待与唤醒的操作。
// 范例 7: 解决程序问题class Message{private String title;private String content;private boolean flag = true; // flag == true: 表示可以生产,但是不能取走; flag == false:表示可以取走,但是不能生产public synchronized void set(String title, String content){if (this.flag == false) { //已经生产过了,不能生产try {super.wait(); //等待} catch (InterruptedException e) {e.printStackTrace();}}this.title = title;try {Thread.sleep(200);} catch (InterruptedException e){e.printStackTrace();}this.content = content;this.flag = false; //已经生产完成,修改标志位super.notify(); //唤醒等待线程}public synchronized void get(){if (this.flag == true){ //未生产,不能取走try{super.wait(); //等待}catch(InterruptedException e){e.printStackTrace();}}try{Thread.sleep(100);}catch(InterruptedException e){e.printStackTrace();}System.out.println(this.title + "-->" + this.content);this.flag = true; //已经取走了,可以继续生产super.notify(); //唤醒等待线程}// setter、getter略
}class Producer implements Runnable { //定义生产者private Message msg = null;public Producer(Message msg){this.msg = msg;}@Overridepublic void run(){for(int x=0; x<8; x++){ //生产8次数据if(x%2 == 0){this.msg.set("小山", "Java专栏作者"); //设置属性}else{this.msg.set("xiaoshan", "www.xiaoshan.cn"); //设置属性}}}
}class Consumer implements Runnable { //定义消费者private Message msg = null;public Consumer (Message msg){this.msg = msg;}@Overridepublic void run(){for (int x=0; x<8; x++){ //取走8数据this.msg.get(); //取得属性}}
}public class TestDemo {public static void main(String[] args) throws Exception {Message msg = new Message(); //定义Message 对象,用于保存和取出数据new Thread(new Producer(msg)).start(); //启动生产者线程new Thread(new Consumer(msg)).start(); //取得消费者线程}
}
程序的运行结果:
小山-->Java专栏作者
xiaoshan-->www.xiaoshan.cn
小山-->Java专栏作者
xiaoshan-->www.xiaoshan.cn
小山-->Java专栏作者
xiaoshan-->www.xiaoshan.cn
小山-->Java专栏作者
xiaoshan-->www.xiaoshan.cn
从程序的运行结果中可以清楚地发现,生产者每生产一个信息就要等待消费者取走,消费者每取走一个信息就要等待生产者生产,这样就避免了重复生产和重复取走的问题。
🔍分析sleep()和wait()的区别?
sleep()
是Thread
类定义的static
方法,表示线程休眠,将执行机会给其他线程,但是监控状态依然保持,休眠时间到了会自动恢复;wait()
是Obiect
类定义的方法,表示线程等待,一直到执行了notify()
或notifyAll()
后才会被唤醒,结束等待。
🌾 总结
本文主要介绍了多线程编程中的同步与死锁问题,以及经典的生产者与消费者案例,并分析了sleep()
和wait()
方法的区别。
我们首先引出了线程同步问题,解释了多个线程同时访问共享资源时可能导致的数据不一致性和并发安全性问题。为了解决这些问题,我们介绍了synchronized
关键字,说明了如何使用它来实现线程的同步操作,以确保只有一个线程可以访问共享资源,从而避免数据的争用和冲突。
接下来,我们讨论了死锁问题,详细说明了死锁是由于多个线程相互等待对方释放资源而无法继续执行的情况。死锁的出现是由于资源竞争和线程之间的依赖所导致的,后面的文章中将会为大家介绍一些避免死锁的常见方法,如避免嵌套锁、按顺序获取资源等,敬请期待。
随后,我们介绍了经典的生产者与消费者案例,展示了多线程协作的实践应用。通过使用wait()
、notify()
和notifyAll()
方法,我们演示了如何实现生产者与消费者之间的有效通信和资源共享。
最后,我们对比了sleep()
和wait()
方法的区别。sleep()
方法是让线程暂停一段指定的时间,不释放锁资源;而wait()
方法是让线程进入等待状态,同时释放锁资源,直到被其他线程唤醒并重新获得锁资源。我们强调了在使用wait()
方法时需要注意与notify()
或notifyAll()
方法配合使用,以免出现线程无法被唤醒或永久等待的情况。
《【Java基础教程】(四十二)多线程篇 · 上:多进程与多线程、并发与并行的关系,多线程的实现方式、线程流转状态、常用操作方法解析~》
《【Java基础教程】(四十四)IO篇 · 上:解析Java文件操作——File类、字节流与字符流,分析字节输出流、字节输入流、字符输出流和字符输入流的区别》