从不同步的代码块中调用了对象同步方法。_Java高级编程基础:详解高并发编程中的等待-通知机制...

15646309909fec839dd120294a80652c.png

前言

我们在进行多线程计算编程过程中,经常会遇到使用生产者/消费者模式的经典方案,一个常见的任务是有多个工作线程等待一个生产者线程给它们分配一些工作任务的情形。

实现的方案有很多,很多人会想可以在每个消费者实现中都放一个无限循环去检测某个值是否就位,一旦就位就启动执行处理,这样的方式来处理绝对不是一个好的方案,因为会大量的浪费CPU的处理时间。

此时,又有人会想,我们可以通过添加Thread.sleep()来减少循环空转时间,提高CPU时间利用效率。其实这也不是一个最佳的方案,因为有可能我们想让工作线程在一出现条件就位的情况下就立刻开始工作,而如果让线程休眠一段时间,就无法准确的达到一旦就绪就立刻被处理的要求。

Wait和Notify机制

Java编程语言为上面这种情形提供了另外一个结构式解决方案,它就是wait()和notify()机制。

我们知道这两个方法都是类从java.lang.Object基类继承而来的,其中wait()可以用来暂停当前线程的执行,并等待直到另外一个线程用notify()唤醒它。

为了能够正确的理解和使用它们,我们必须首先要记住一个前提:就是只有某个线程获取到一个同步锁的情况下才能使用它们,而且这个锁必须是在调用它们之前通过使用synchronized关键字获取的,否则使用它们无意义。

在我们调用了wait()方法后,该线程持有的锁就会被释放,然后该线程就会进入等待状态,直到另外一个获取到该锁的线程在同一个锁定对象上,调用notify()方法来唤醒它。

当然通常我们的多线程应用程序中不会只有两个线程,一般都会有多个线程在等待某些被锁定对象的唤醒。所以,就有了我们看到的两种唤醒等待线程的方式:notify()notifyAll()。其中notify()只能够唤醒一个等待该锁的线程,而notifyAll()方法会唤醒所有等待的线程。

这里一定要注意,跟synchronized关键字一样,没有规则指定当调用notify()方法后,具体是哪个线程被唤醒,还是由操作系统决定。

我们还拿上面的问题情形来看,在一个简单的生产者和消费者示例中,至于那个线程被唤醒,这并不重要,因为该模式中对确切唤醒哪个线程也不需要明确。

下面的代码演示了如何使用wait()和notify()机制让消费者线程等待从某个生产者线程将新的工作推入队列:

85917d88b43475655e477652108daef0.png

图 3-1

28d435a1fa67e1a3ad1c3ebe94990e1a.png

图 3-2

81c588a767cbdba30c1194f2bc327860.png

图 3-3

简单来看,main()方法启动三个消费者线程和一个生产者线程,然后等待它们完成。消费者线程获取队列锁,然后进入休眠状态,以便稍后当队列再次被填满时被唤醒。生产者线程将一个新值插入队列,并通知所有等待的线程发生的事情。当生产者线程完成其工作后,它通知所有消费者线程唤醒。

如果我们不执行最后一步,消费者线程将永远等待下一个通知,因为我们这里没有为等待指定任何超时。

一般在开发中,我们可以使用wait(long timeout)方法,至少在经过一段时间之后才能唤醒。

cb1f6e351640372d426bfca5889e2d22.png

wait()和notify()嵌套同步块

前面我们说过,在某个对象监视器上调用wait()只释放该对象监视器上的锁。而在同一个线程中持有的其它对象锁是不能被释放的。

这很容易理解,在日常工作中,调用wait()的线程可能持有不止一个锁。如果其它的线程都在等待这些未被释放的锁,那么死锁的情况就会发生。

2e91af4f54a66ff1ea44487873fc60e1.png

图 3-1

b649c38ac57844b7bb63c0bb44c2dd90.png

图 3-2

37410b1b5e412f78a10ed3e61cef6132.png

图 3-3

我们这里的代码中,定义了四个锁,两个应用在实例方法上,两个应用在静态队列对象上。

也就是说,执行该方法的线程中会有两个锁存在,一个是实例方法锁,另一个是静态队列上的锁。

当我们在锁定队列的锁代码块中调用queue.wait()方法后,该线程只能释放队列上的锁,而getNextInt()方法上的锁依然被占用。

此时我们在应用程序中开启两个线程对队列进行插入操作和读取工作,在调用putInt()方法时,该方法锁定队列,通知等待队列的线程执行。

我们知道,这里我们应用程序总共有三个线程,分别是主线程和producer线程和consumer线程,它们在由synchronizated定义的同步块区域被同步,需要去获取锁来执行。由于滥用了synchronized块造成了嵌套的同步,在释放锁时,又无法全部释放,所以死锁发生。

3c4785bbdf4318454a31011daafa5049.png

发生死锁

正如我们之前所了解的,向方法签名添加synchronized等同于创建一个synchronized(this){}块。

在上面的示例中,我们意外地将synchronized关键字添加到方法中,然后在对象监视器队列queue上进行同步,并让当前线程处于休眠状态以便在等待队列中的下一个值。

然后,当前线程释放队列上的锁持有,而不是这个队列queue上的锁持有。putInt()方法通知正在休眠的线程添加了一个新值。

但意外的是,我们还向这个方法添加了synchronized关键字。现在,当第二个线程休眠时,它仍然持有锁。

然后,第一个线程不能进入方法putInt(),因为这个锁由第一个线程持有。因此出现死锁情况,程序挂起。如果您执行上面的代码,这将在程序开始后立即发生。

当然我们在平时的应用开发过程中,面对的情况可能不像上面那样简单清楚。

线程持有的锁可能取决于运行时的参数和条件,这会导致出现问题的同步块可能不太接近代码中放置wait()调用的位置,让我们很难这么直观的找到这样的问题,大部分情况下都是在一段时间后或在重负载下才会出现问题。

同步块中的条件使用

通常我们在对同步对象执行某些操作之前,必须检查它是否满足某些条件。

比如,我们有一个队列时,希望等待该队列被填满后再对其进行处理。

因此,我们可以编写一个方法来检查队列是否已被填满。如果没有,则将当前线程休眠,直到它被填满时唤醒该处理线程,简单代码如下:

48c03aa5bd583f27099f35b7993237e8.png

双同步块

上面的代码在调用wait()之前对队列进行同步,然后在while循环中等待,直到队列至少有一个所需内容。

第二个同步块再次使用队列queue作为对象监视器,它轮询polls()队列中的值。为了简单说明问题,我们不做处理,而是当poll()返回null时直接抛出IllegalStateException异常。也就是当队列中没有要轮询的值时出现这种情况。

如果运行这个示例,我们将看到很快就抛出IllegalStateException异常。

虽然我们在队列监视器上进行的同步处理没有问题,但是为何还是抛出了异常呢?原因在于我们这里使用了两个单独的同步块。

假设有两个线程到达了第一个同步块,第一个线程进入块并因为发现队列是空的,所以进入休眠状态。对于第二个线程进入后,情况也是如此。

现在,当两个线程都被唤醒(这里假设由另一个线程调用监视器上的notifyAll())时,它们都在队列中看到一个由生产者添加的值。

两个线程都到达第二个同步块,我们假设第一个线程进入并处理队列中的值。而当第二个线程进入时,队列已经是空的。结果就是它从poll()调用中获取null作为返回值,并抛出异常。

为了避免上述情况,我们必须在同一个同步块中执行所有依赖于监视器状态的操作:

68ec895adead5f6671871c8660954aa9.png

同一个块中

这里我们将poll()方法和isEmpty()方法放到同一个同步块中执行。通过synchronized块我们确保了只有一个线程在特定的时间里在该监视器上执行方法。不会出现在isEmpty()和poll()方法调用中间出现了元素被移除的问题。

总结

在我们多线程并发编程过程中,大量的同步块不可避免的被使用,同时要调度多个线程对该同步块的访问,这就需要避免死锁,以及注意效率问题。

这里我们介绍了wait/notify这种由Java基类的监视器提供的简单结构方式处理,可以很好提高代码效率,但是在使用过程中还是必须首先深刻理解它们的前提,那就是synchronized的同步锁的存在与否。也就是说在调用锁定对象的wait()或者notify()方法之前必须保证线程已经进入过同步块,离开同步块再调用这些方法将没有任何意义。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值