线程同步、死锁与生产者消费者

网友投稿 762 2022-09-10

线程同步、死锁与生产者消费者

线程同步、死锁与生产者消费者

一、问题引出

多个线程访问同一个资源时,如果操作不当就很容易产生意想不到的错误,比如常见的抢票程序:

public class Demo1 { public static void main(String[] args) { Ticket tt = new Ticket(); new Thread(tt, "甲").start(); new Thread(tt, "乙").start(); }}class Ticket implements Runnable { private int ticketCount = 10; @Override public void run() { while (ticketCount > 0) { try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "抢到了第【" + (10 - ticketCount + 1) + "】张火车票"); ticketCount--; } }}

结果:

乙抢到了第【1】张火车票甲抢到了第【2】张火车票乙抢到了第【3】张火车票甲抢到了第【4】张火车票乙抢到了第【5】张火车票甲抢到了第【6】张火车票乙抢到了第【7】张火车票甲抢到了第【8】张火车票乙抢到了第【9】张火车票甲抢到了第【9】张火车票乙抢到了第【11】张火车票

以上代码可以看出,第9张票被两个人抢,且出现了第11张票,这不符合实际。为什么会出现这种现象?原因是当乙抢到第9张票但还未执行ticketCount–;语句时,甲也进入了run()方法,此时票数仍然是第9张票,也打印出了第9票,因此第9张票被抢了两遍。之后甲和乙都会执行ticketCount–;语句,当ticketCount =0时,无论哪个线程抢到资源都会打印抢到了第【11】张火车票,当ticketCount = -1时,停止。

综上分析,问题出现的主要原因就是甲、乙两个线程同时访问同一资源,造成了资源污染。

二、线程同步

上面可知当多个线程同时访问同一资源,就可能会造成了资源污染,解决这个问题的方法就是在某个线程访问资源时,其他线程在资源或者方法外面等待,也就是线程同步,或者叫加锁。 线程同步就是指多个操作在同一时间段内只能有一个线程进行,而其他线程要等待此线程完成之后才可以继续进行。 要实现线程同步,需要通过关键字synchronized关键字,利用这个关键字可以定义同步方法或者代码块。格式如下:

synchronized(同步对象){ 操作;}

一般要进行同步对象处理时,采用当前对象this进行同步。上面的抢票代码加上同步后如下:

public class Demo1 { public static void main(String[] args) { Ticket tt = new Ticket(); new Thread(tt, "甲").start(); new Thread(tt, "乙").start(); }}class Ticket implements Runnable { private int ticketCount = 10; @Override public void run() { synchronized(this){ while (ticketCount > 0) { try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "抢到了第【" + (10 - ticketCount + 1) + "】张火车票"); ticketCount--; } } }}

结果:

甲抢到了第【1】张火车票甲抢到了第【2】张火车票甲抢到了第【3】张火车票甲抢到了第【4】张火车票甲抢到了第【5】张火车票甲抢到了第【6】张火车票甲抢到了第【7】张火车票甲抢到了第【8】张火车票甲抢到了第【9】张火车票甲抢到了第【10】张火车票

加锁或者同步处理以后,虽然多线程同时访问同一资源的问题虽然解决了,但是线程同步会降低整体性能。

三、死锁

死锁就是多个线程间相互等待的状态。

class MyThread implements Runnable{ int flag = 1; // 必须是静态资源 static Object o1 = new Object(); static Object o2 = new Object(); @Override public void run() { System.out.println("flag= " + flag); if(flag == 1){ synchronized (o1){ System.out.println(Thread.currentThread().getName() + "我抢到了o1,还需要o2"); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (o2){ System.out.println("111"); } } } if(flag == 0){ synchronized (o2){ System.out.println(Thread.currentThread().getName() + "我抢到了o2,还需要o1"); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (o1){ System.out.println("222"); } } } }}public class DeadLock { public static void main(String[] args) { MyThread myThread1 = new MyThread(); MyThread myThread2 = new MyThread(); // 设置线程1先抢占o1 myThread1.flag = 1; // 设置线程1先抢占o2 myThread2.flag = 0; Thread t1 = new Thread(myThread1); Thread t2 = new Thread(myThread2); t1.start(); t2.start(); }}

3.1 死锁检测

一旦出现死锁,业务是可感知的,因为不能继续提供服务了,那么只能通过dump线程查看到底是哪个线程出现了问题。

jstack命令

jstack是java虚拟机自带的一种堆栈跟踪工具。 jstack用于打印出Java堆栈信息,生成java虚拟机当前时刻的线程快照。 线程快照是当前java虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如​​​线程间死锁​​​、​​死循环​​​、​​请求外部资源导致的长时间等待​​​ 等。 线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做什么事情,或者等待什么资源。

首先,我们通过jps确定当前执行任务的进程号:

jonny@~$ jps5971370 JConsole1362 AppMain1421 Jps1361 Launcher

jonny@~$ jstack -F 1362Attaching to process ID 1362, please wait...Debugger attached successfully.Server compiler detected.JVM version is 23.21-b01Deadlock Detection:Found one Java-level deadlock:============================="Thread-1": waiting to lock Monitor@0x00007fea1900f6b8 (Object@0x00000007efa684c8, a java/lang/Object), which is held by "Thread-0""Thread-0": waiting to lock Monitor@0x00007fea1900ceb0 (Object@0x00000007efa684d8, a java/lang/Object), which is held by "Thread-1"Found a total of 1 deadlock.

可以看到,进程的确存在死锁,两个线程分别在等待对方持有的Object对象。

② JConsole工具

3.2 死锁预防

如果一个线程每次只能获得一个锁,那么就不会产生锁顺序的死锁。

先介绍避免死锁的几个常见方法: (1)避免一个线程同时获取多个锁。上面的例子就是一个线程占用两个锁。 (2)避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。 (3)尝试使用定时锁,使用 lock.tryLock(timeout)来替代使用内部锁机制。 (4)对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。 (5)线程池死锁 :扩大线程池线程数 or 任务结果之间不再互相依赖。

但如果此时有多个线程,都在竞争不同的锁,简单按照锁对象的hashCode进行排序(单纯按照hashCode顺序排序会出现“环路等待”),可能就无法满足要求了,这个时候开发者可以使用银行家算法,所有的锁都按照特定的顺序获取,同样可以防止死锁的发生。

小结

死锁就是“两个任务以不合理的顺序互相争夺资源”造成,因此为了规避死锁,应用程序需要妥善处理资源获取的顺序。 另外有些时候,死锁并不会马上在应用程序中体现出来,在通常情况下,都是应用在生产环境运行了一段时间后,才开始慢慢显现出来,在实际测试过程中,由于死锁的隐蔽性,很难在测试过程中及时发现死锁的存在,而且在生产环境中,应用出现了死锁,往往都是在应用状况最糟糕的时候——在高负载情况下。因此,开发者在开发过程中要谨慎分析每个系统资源的使用情况,合理规避死锁。

四、生产者和消费者模式

生产者和消费者模式是用来解决死锁问题的。 为什么可以解决呢? 生产者和消费者模式中,一种是生产者线程用于生产数据,另一种是消费者线程用于消费数据,为了解耦生产者和消费者的关系,通常会采用共享的数据区域,就像是一个仓库,生产者生产数据之后直接放置在共享数据区中,并不需要关心消费者的行为;而消费者只需要从共享数据区中去获取数据,就不再需要关心生产者的行为。但是,这个共享数据区域中应该具备这样的线程间并发协作的功能,需要根据信号来生产或获取:当生产者线程生产物品时,如果没有空缓冲区可用,那么生产者线程必须等待消费者线程释放出一个空缓冲区。当消费者线程消费物品时,如果没有满的缓冲区,那么消费者线程将被阻塞,直到新的物品被生产出来。这里实际体现了信号灯法:flag = true,生产者生产,消费者等待;反之,生产者等待,消费者生产。

下面以蒸包子和吃包子为例,厨师作为生产者输出包子,吃包子的作为消费者,如果没有采用生产者消费者模式,那就可能出现吃包子的没包子吃或者蒸包子的产能过剩的情况;而采用生产者消费者模式后,厨师蒸好包子后不是接着蒸,而是把蒸好的包子放在篮子里然后停下来告诉消费者可以吃了,然后消费者接到信号开始从篮子里拿包子吃,直到吃完后会给厨师一个信号,告诉他可以接着蒸包子了,这时消费者停下,厨师开始蒸包子,接着循环下去。

/** * 装包子的篮子类 */class Container{ private String baozi; // 篮子状态信号标志:flag = true,篮子空了,生产者生产;flag = false,篮子满了,消费者消费 private boolean flag = true; // 模拟生产过程,生产过程要加锁,防止生产过程中消费者过来消费 public synchronized void play(String baozi) throws InterruptedException { // 生产者等待 if(!flag){ this.wait(); } // 生产者生产 Thread.sleep(500); // 模拟生产耗时 // 包子蒸好了 this.baozi = baozi; System.out.println("生产" + baozi); // 通知消费者来吃 this.notify(); // 停止生产 this.flag = false; } // 模拟消费过程,消费过程要加锁,防止消费过程中生产者过来生产 public synchronized void eat() throws InterruptedException { // 消费者等待 if(flag){ this.wait(); } // 消费者消费 Thread.sleep(100); // 模拟消费耗时 System.out.println("篮子里的包子已经吃完了"); // 通知生产者需要蒸包子了 this.notify(); // 停止吃包子 this.flag = true; }}/** * 定义生产者 */class Player implements Runnable{ private Container container; public Player(Container container) { this.container = container; } // 单日生产素馅的包子,双日生产肉馅的包子 @Override public void run() { for (int i = 0; i < 6; i++) { if(i%2 == 0){ try { container.play("肉馅的包子"); } catch (InterruptedException e) { e.printStackTrace(); } }else { try { container.play("素馅的包子"); } catch (InterruptedException e) { e.printStackTrace(); } } } }}/** * 定义消费者 */class Consumer implements Runnable{ private Container container; public Consumer(Container container) { this.container = container; } // 不管啥馅的包子我都吃 @Override public void run() { for (int i = 0; i < 6; i++) { try { container.eat(); } catch (InterruptedException e) { e.printStackTrace(); } } }}public class CpDemo { public static void main(String[] args) { // 同一个篮子 Container container = new Container(); Player player = new Player(container); Consumer consumer = new Consumer(container); new Thread(player, "生产者").start(); new Thread(consumer, "消费者").start(); }}

生产肉馅的包子篮子里的包子已经吃完了生产素馅的包子篮子里的包子已经吃完了生产肉馅的包子篮子里的包子已经吃完了生产素馅的包子篮子里的包子已经吃完了生产肉馅的包子篮子里的包子已经吃完了生产素馅的包子篮子里的包子已经吃完了

以上,生产者线程和消费者线程交替进行,就能很好的避免死锁问题。

一般为了解耦生产者和消费者的关系,通常会采用共享的数据区域,就像是一个仓库,生产者生产数据之后直接放置在共享数据区中,并不需要关心消费者的行为;而消费者只需要从共享数据区中去获取数据,就不再需要关心生产者的行为。但是,这个共享数据区域中应该具备这样的线程间并发协作的功能:

如果共享数据区已满的话,阻塞生产者继续生产数据放置入内;如果共享数据区为空的话,阻塞消费者继续消费数据;

因此,生产者消费者模型的作用主要是:

【运行效率】:通过平衡生产者的生产能力和消费者的消费能力来提升整个系统的运行效率,不会互相影响抢夺共享资源,这是生产者消费者模型最重要的作用【解耦】:解耦意味着生产者和消费者之间的联系少,联系越少越可以独自发展而不需要收到相互的制约,这是生产者消费者模型附带的作用。

在实现生产者消费者可以采用三种方式: 1.使用 Object 的 wait/notify 的消息通知机制; 2.使用 Lock 的 Condition 的 await/signal 的消息通知机制; 3.使用 BlockingQueue 实现。本文主要将这三种实现方式进行总结归纳。 上面的例子用的是第一种方式。

五、volatile和synchronized的区别

volatile关键字主要是在属性定义上使用,表示此属性为直接数据操作,而不进行副本的拷贝处理。

volatile和synchronized的区别:

volatile主要是在属性定义上使用,而synchronized是在代码块或方法上使用volatile无法描述同步处理,是一种直接内存处理,避免了副本操作;synchronized是同步操作。

六、sleep和wait的区别

① 这两个方法来自不同的类分别是,sleep来自Thread类,和wait来自Object类。

sleep是Thread的静态类方法,谁调用的谁去睡觉,即使在a线程里调用b的sleep方法,实际上还是a去睡觉,要让b线程睡觉要在b的代码中调用sleep。

② 锁: sleep方法不会释放lock,但是wait会释放,而且会加入到等待队列中,使得其他线程可以使用同步控制块或者方法。

sleep不需要被唤醒(休眠之后推出阻塞),但是wait需要(不指定时间需要被别人中断)。sleep不出让系统资源;wait是进入线程等待池等待,出让系统资源,其他线程可以占用CPU。一般wait不会加时间限制,因为如果wait线程的运行资源不够,再出来也没用,要等待其他线程调用notify/notifyAll唤醒等待池中的所有线程,才会进入就绪队列等待OS分配系统资源。sleep(milliseconds)可以用时间指定使它自动唤醒过来,如果时间不到只能调用interrupt()强行打断。

③ 使用范围:wait,notify和notifyAll只能在同步控制方法或者同步控制块里面使用,而sleep可以在任何地方使用。sleep方法不依赖于同步器synchronized,但是wait需要依赖synchronized关键字。

七、notify和notifyAll的区别?

先要理解锁池和等待池:

锁池:假设线程A已经拥有了某个对象(注意:不是类)的锁,而其它的线程想要调用这个对象的某个synchronized方法(或者synchronized块),由于这些线程在进入对象的synchronized方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程A拥有,所以这些线程就进入了该对象的锁池中。等待池:假设一个线程A调用了某个对象的wait()方法,线程A就会释放该对象的锁后,进入到了该对象的等待池中

notify和notifyAll的区别

如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。当有线程调用了对象的 notifyAll()方法(唤醒所有 wait 线程)或notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。也就是说,调用了notify后只有一个线程会由等待池进入锁池,而notifyAll会将该对象等待池内的所有线程移动到锁池中,等待锁竞争优先级高的线程竞争到对象锁的概率大,假若某线程没有竞争到该对象锁,它还会留在锁池中,唯有线程再次调用 wait()方法,它才会重新回到等待池中。而竞争到对象锁的线程则继续往下执行,直到执行完了 synchronized 代码块,它会释放掉该对象锁,这时锁池中的线程会继续竞争该对象锁。

八、并发编程的挑战

8.1 上下文切换

即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。 CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。 这就像我们同时读两本书,当我们在读一本英文的技术书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书读到了多少页的第多少行,等查完单词之后,能够继续读这本书。这样的切换是会影响读书效率的,同样上下文切换也会影响多线程的执行速度。

8.2 资源限制

(1)什么是资源限制 资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。例如,服务器的带宽只有2Mb/s,某个资源的-速度是1Mb/s每秒,系统启动10个线程-资源,-速度不会变成10Mb/s,所以在进行并发编程时,要考虑这些资源的限制。硬件资源限制有带宽的上传/-速度、硬盘读写速度和CPU的处理速度。软件资源限制有数据库的连接数和socket连接数等。 (2)资源限制引发的问题 在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间。例如,之前看到一段程序使用多线程在办公网并发地-和处理数据时,导致CPU利用率达到100%,几个小时都不能运行完成任务,后来修改成单线程,一个小时就执行完成了。 (3)如何解决资源限制的问题 对于硬件资源限制,可以考虑使用集群并行执行程序。既然单机的资源有限制,那么就让程序在多机上运行。比如使用ODPS、Hadoop或者自己搭建服务器集群,不同的机器处理不同的数据。可以通过“数据ID%机器数”,计算得到一个机器编号,然后由对应编号的机器处理这笔数据。对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和Socket连接复用,或者在调用对方webservice接口获取数据时,只建立一个连接。 (4)在资源限制情况下进行并发编程 如何在资源限制的情况下,让程序执行得更快呢?方法就是,根据不同的资源限制调整程序的并发度,比如-文件程序依赖于两个资源——带宽和硬盘读写速度。有数据库操作时,涉及数据库连接数,如果SQL语句执行非

九、并发机制的底层实现原理

synchronized的实现原理与应用: 先来看下利用synchronized实现同步的基础:Java中的每一个对象都可以作为锁。具体表现为以下3种形式。

对于普通同步方法,锁是当前实例对象。 ·对于静态同步方法,锁是当前类的Class对象。对于同步方法块,锁是Synchonized括号里配置的对象。当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。那么锁到底存在哪里呢?锁里面会存储什么信息呢?JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,使用monitorenter和monitorexit指令实现。monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。

上一篇:爬取豆瓣电影排名的代码以及思路(爬取豆瓣电影并数据分析)
下一篇:「运维有小邓」监控文件及文件夹变更
相关文章

 发表评论

暂时没有评论,来抢沙发吧~