初级
学习时间30分钟
适合人群零基础
开发语言java
开发环境- JDK v11
- IntelliJIDEA v2018.3
- 本教学属于系列教学,内容具有连贯性,本章使用到的内容之前教学中都有详细讲解。
- 本章内容针对零基础或基础较差的同学比较友好,可能对于有基础的同学来说很简单,希望大家可以根据自己的实际情况选择继续看完或等待看下一篇文章。谢谢大家的谅解!
前面在《“全栈2019”Java多线程第三十三章:await与signal/signalAll》一章中介绍了Condition对象的await()方法与signal()/signalAll()方法。
在《“全栈2019”Java多线程第三十四章:超时自动唤醒被等待的线程》一章中介绍了Condition对象的await(long time, TimeUnit unit)方法。
在《“全栈2019”Java多线程第三十五章:如何获取线程被等待的时间?》一章中介绍了Condition对象的awaitNanos(long nanosTimeout)方法。
在《“全栈2019”Java多线程第三十六章:如何设置线程的等待截止时间》一章中介绍了Condition对象的awaitUntil(Date deadline)方法。
在《“全栈2019”Java多线程第三十七章:如何让等待的线程无法被中断》一章中介绍了Condition对象的awaitUninterruptibly()方法。
现在我们来讲解Lock与Condition实战项目:从零手写一个线程安全缓冲区。
2.Lock与Condition实战项目:从零手写一个线程安全缓冲区我们先为缓冲区取一个名字,就叫“SafeBuffer”好了,单词Safe是安全的意思,Buffer是缓冲的意思。
下面就开始将SafeBuffer类创建出来:
缓冲区只有两个功能:存数据和取数据。
以上所述,我们创建出两个方法,一个用于存数据的put()方法:
一个用于获取数据的take()方法:
这里可能大家有疑问:为什么put()方法和take()方法都是无参无返回值的?
我们先不着急完善这两个方法,一步一步地来,先这样写着,待会再来完善。
现在有一个问题是:我们存数据,数据放哪?我们取数据,从哪取数据?
这个问题好解决,弄一个容器,比如数组。然后把数据放存数组里面,取的时候,直接从数据里面取就好了。
容器有了,是数组,那数组的类型是什么呢?
如果只是存int类型的数据,那就定义int类型数组;
如果只是存String字符串类型的数据,那就定义String类型数组;
如果你不确定存什么类型的数据或者你什么都想存,那就定义Object类型的数组。
于是,我们可以在SafeBuffer类中定义一个Object类型的数组:
数组需要指定长度,这里我们设置数组长度为100:
数据容器创建成功。
接下来,存数据put()方法和获取数据take()方法可以完善一部分了。
存入数据的put()方法需要一个参数,用于接收要存入数据容器的数据:
获取数据的take()方法需要一个返回值,用于返回用户获取的数据:
现在又出现一个问题:数据怎么存?怎么取?是先存先取,还是先存后取?
先存后取,就好比栈数据结构:
先存先取,就好比队列数据结构:
对比两个数据结构,栈是从一端存入/获取数据;队列是从一端存入数据,从另一端获取数据。
这里选择哪个好?
我们还是选择队列比较好,因为我们缓冲区的数据一般是一端疯狂加入数据,另一端疯狂获取数据。而且是优先加入的数据优先获取到。
如上所述,我们把put()方法完善好:
可以看到的是,往数组里面存入数据是需要指定下标的,上图中我们把下标写死了,下标一直为0,显然是错误的,存入数据的下标应该是动态的。
由此可见,我们需要定义一个动态的数组下标,来表示当前存入数据的位置:
记录当前存入数据的位置下标的变量叫putptr,putptr是put和ptr组合而成,put代表存入,ptr代表下标。
下面,我们用putptr变量替换0下标:
数据存入之后,当前存入数据的位置需要往后移一位,即putptr 1,否则存入的位置一直不变:
当然了,你也可以将putptr 写在它上一句代码里面:
put()方法暂时写到这。
接着,完善take()方法。
因为我们选择的是队列数据结构,所以take()方法需要按照先存先取的方式返回数据。
如上所述,我们先把数据返回写了:
总返回下标为0的数据肯定不对,获取数据的下标肯定也是动态的,于是定义一个用来记录当前获取数据的下标变量takeptr:
然后用takeptr替换下标0:
takeptr下标在每次获取完数据之后需要累加,即takeptr ,这里我们将takeptr 与获取数据的数组写在一起:
好了,缓冲类初步完成。
下面我们来试试。
在Main类中的main()方法里创建缓冲类SafeBuffer的实例:
然后,存入数据:
接着,获取数据并输出:
例子书写完毕。
运行程序,执行结果:
从运行结果来看,好像程序没什么问题,感觉还可以。
问题一:ArrayIndexOutOfBoundsException数组下标越界异常我们来循环存入/获取数据看一下:
运行程序,执行结果:
从运行结果来看,存100个数据和取100个数据没什么问题。
接下来,我们来试试存/取大于100个数据会怎样?
我们来循环101次:
运行程序,执行结果:
从运行结果来看,程序发生了异常:
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 100 out of bounds for length 100 at lab.SafeBuffer.put(SafeBuffer.java:30) at main.Main.main(Main.java:19)
ArrayIndexOutOfBoundsException是数组下标越界异常,说明我们数据容器满了之后还有人往里面存数据,导致出现此异常。
解决办法是:当存入数据下标大于数组最后一个下标时,不再接受新的数据存入。
如上所述,更改put()方法:
我们再来运行程序,看看执行结果:
从运行结果来看,程序又出错了:
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 100 out of bounds for length 100 at lab.SafeBuffer.take(SafeBuffer.java:44) at main.Main.main(Main.java:22)
还是ArrayIndexOutOfBoundsException异常,只不过这次获取的时候数组下标越界了。
同样的,我们来take()方法内部也需要判断一下,当获取数据下标大于最大下标时:
此处我们的处理方式是返回null,显然这不是完美处理方式,后面会给出完美处理方式。
总之,我们先来运行一下程序,看看执行结果:
越界的问题解决完了,虽然暂时不完美,但是后面会逐步完善的。
问题二:重复获取数据现在还有一个问题:存1个数据进去,取100次会怎样?
我们来看看,改写Main类,其他类不变:
运行程序,执行结果:
从运行结果来看,除了第一个数据是存入的数据,其他的都是null。这个null值是Object类型数组子项的默认值。
由此可见,当我们数据容器里面只有一个数据时,不停地获取数据时,takeptr还不断的在累加,这是个BUG。应当是当容器里数据只有一个时,获取完这个数据后,再有获取数据的时候判断数据容器里面还有没有数据,如果有,则takeptr累加并返回数组下标为takeptr的数据;如果没有则返回null(暂时这样处理,后面还有更完美的处理方式)。
如上所述,我们应该定义一个记录当前数据容器已有数据的变量:
当当前数据容器已有数据的个数为0时,返回null:
此时也把put()方法改写一下,当当前数据容器已有数据的个数等于数组长度时,直接返回:
别忘了,在存入数据的时候count需要累加,在获取数据的时候count需要递减:
put()方法里面的两个if语句和take()方法里面的两个if语句除了判断条件不同以外,返回都一样,它们的作用是否重复?
这里并不重复。可以结合下面这个问题来解释。
这个问题是:何时重置存入数据下标putptr和获取数据下标takeptr这两个下标?
当存入数据下标putptr等于数组最大下标时,重置putptr;
当获取数据下标takeptr等于数组最大下标时,重置takeptr;
如上所述,更改SafeBuffer类:
因为在数组中 会造成代码的阅读性很差,所以这里我们将 运算符提到了if语句里面,而且将count 改为了 count,这么做都是为了提高代码的阅读性:
问题三:生产者-消费者模型
接下来,我们要使用两个线程,一个线程存数据,另一个线程取数据,看程序会不会出问题。
移除在Main类的main()方法中的以下代码:
然后,创建两个线程:
接着,重写run()方法:
然后,我们让线程thread1去生产数据并存入缓冲区:
接着,我们让线程thread2去获取数据并输出:
紧接着,启动线程:
例子书写完毕。
运行程序,执行结果:
从运行结果来看,程序是故意多运行几次的,为的就是暴露出问题,在最后一次运行程序时,我们可以看到data返回null的结果:
什么原因导致的呢?
这是一个生产者-消费者模型问题,在此例中,线程thread1是生产者,线程thread2是消费者。然后请大家结合下面这个动画来看:
简单来说,就是线程thread2先启动,但缓冲区里面还没有放入数据呢,线程thread2执行获取缓冲区里面的数据肯定为null。
如果想了解生产者-消费者模型的小伙伴可以点击《“全栈2019”Java多线程第二十五章:生产者与消费者线程详解》一章进行阅读。
怎么解决这个问题呢?
如果缓冲区里面没有数据,让获取数据的线程进行等待。
如上所述,更改SafeBuffer类的take()方法。这里采用显式锁Lock和Condition对象:
然后,在put()方法内部写上同步:
接着,在take()方法内部也写上同步:
我们需要在take()方法中判断(当缓冲区里面没有数据时,让获取数据的线程等待):
await()方法会产生异常,此处我们将其抛出为好,不宜在内部处理:
同理,我们的put()方法也需要进行判断(当缓冲区里面数据已满时,让存入数据的线程等待):
同样的,我们将await()方法产生的异常抛出:
线程被等待何时被唤醒呢?
在方法返回前唤醒。
如上所述,更改put()方法,调用Condition对象的signal()方法唤醒被等待的线程:
更改take()方法,调用Condition对象的signal()方法唤醒被等待的线程:
SafeBuffer类暂时改写完毕。
接下来,我们去修改完Main类,只需处理put()方法和take()方法抛出的异常即可:
运行程序,执行结果:
从运行结果来看,程序不会再出现取到null的情况了。
问题四:被唤醒的线程需再次判断条件是否成立此程序还有一个问题:当count为0时,获取数据的线程醒来之后仍然往下执行。
下面我们重现这个问题。
首先,我们将这两处代码分别移至两个实现了Runnable接口的匿名内部类中。即以下两处代码:
移至实现了Runnable接口的匿名内部类中:
接下来,创建1个存入数据的线程和2个获取数据的线程:
然后,先启动2个获取数据的线程:
接着,使主线程睡1秒钟,目的是让获取数据的线程因count为0而进行等待:
最后,启动存入线程:
运行程序,执行结果:
静图:
从运行结果来看,我们只存入一个数据,但被取了两次,第二次还是一个null(数组默认值)。
是什么原因导致的?
如上图所示,错误是当我们获取数据的线程醒来之后,没有再次判断count是否等于0而直接往下执行所致。
解决办法就是将if语句改为while语句,if语句只判断一次,while语句判断多次。
下面,我们来修改SafeBuffer里的take()方法:
同理,put()方法也是如此,也要进行修改:
我们再来运行程序,看看执行结果:
从运行结果来看,符合预期。被唤醒的线程需再次判断条件是否成立。
问题五:所有线程都被阻塞为了重现这个问题,我们将数据容器的长度改为1:
接着,我们去更改Main类,创建出3个存入数据的线程和2个获取数据的线程:
然后,先启动3个存入数据的线程:
接着,使主线程睡1秒钟,目的是为了让其他2个存入数据的线程等待:
最后,启动2个获取数据的线程:
运行程序,执行结果:
从运行结果来看,符合问题预期。果然所有线程被等待。
造成所有线程等待的原因是什么?
结合以下动画来说明原因。
主要原因是存入数据的线程执行存入数据之后唤醒了获取数据的线程,此时数据容器里只有1个数据,获取数据的线程在获取完数据之后唤醒了另一个获取数据的线程,但此时数据容器里面没有数据了,故被唤醒的获取数据的线程被等待,至此,所有线程被等待。
解决办法一:将唤醒线程的signal()方法改为signalAll()方法。
解决办法二:创建两个Condition对象,一个Condition对象管存入数据线程,另一个Condition对象管获取数据线程。
这里推荐解决办法二。
如上所述,在SafeBuffer类中创建两个Condition对象:
当数据容器已满时,使存入线程等待;当存完数据之后唤醒获取数据线程:
当数据容器为空时,使获取线程等待;当获取完数据之后唤醒存入数据线程:
SafeBuffer类改写完毕。
运行程序,执行结果:
从运行结果来看,符合预期。所有线程被阻塞的问题也解决了。
至此,我们的安全缓冲区就写完了。
泛型我们可以将SafeBuffer类改为更灵活的泛型形式:
put()方法也需要更改:
take()方法也需要更改:
SafeBuffer类更改完毕,接下来更改Main类:
这里可以创建类型更加明确的缓冲区。
运行程序,执行结果:
从运行结果来看,符合预期。SafeBuffer类加上泛型之后更加灵活。
最后,希望大家可以把这个例子照着写一遍,然后再自己默写一遍,方便以后碰到类似的面试题可以轻松应对。
祝大家编码愉快!
GitHub本章程序GitHub地址:https://github.com/gorhaf/Java2019/tree/master/Thread/SafeBuffer
总结- await()方法使当前线程在等待,直到它被唤醒,通常由被唤醒或中断。
- signal()方法唤醒正在此对象监视器上等待的单个线程,选择是随机的。
- signalAll()方法作用是唤醒正在此对象监视器上等待的所有线程。
至此,Java中显式锁Lock与Condition实战安全缓冲区相关内容讲解先告一段落,更多内容请持续关注。
答疑如果大家有问题或想了解更多前沿技术,请在下方留言或评论,我会为大家解答。
上一章“全栈2019”Java多线程第三十七章:如何让等待的线程无法被中断
下一章“全栈2019”Java多线程第三十九章:显式锁实现生产者消费者模型
学习小组加入同步学习小组,共同交流与进步。
- 方式一:关注头条号Gorhaf,私信“Java学习小组”。
- 方式二:关注公众号Gorhaf,回复“Java学习小组”。
关注我们,加入“全栈工程师学习计划”。
版权声明
原创不易,未经允许不得转载!
,