JavaEE——多线程代码案例2:阻塞队列
阻塞队列的概念:
基于普通队列,做出的扩展,也是先进先出。
阻塞队列的特点:
- 线程安全的
标准库中的容器——队列Queue是线程不安全的。
集合里的大多是线程不安全的。集合里的所谓线程安全的几个,是在关键方法加了锁,相对安全。(并不是加锁就一定安全) - 具有阻塞特性
(a)如果针对一个已经满了的队列进行入队列,此时入队列的操作就会阻塞,一直阻塞到队列不满(其他线程出队列元素)之后。
(b)如果针对一个已经空了的队里进行出队列,此时出队列的操作就会阻塞,一直阻塞到队列不空(其他线程入队列元素)之后。
阻塞队里的用处
基于阻塞队列,可以实现“生产者消费者模型”——用处很大
【生产者消费者模型】描述的是一种多线程编程的方法 ~ ~(分工协作 来 解决问题,比单线程效率高)(流水线工作快)
在这个模型中,阻塞队列充当传递数据的作用。
生产者消费者模型的意义
生产者消费者模型,在实际开发中有非常重要的意义。
- 意义一:解耦合
引入生产者消费者模型,就可以更好的做到“解耦合” (降低代码的耦合度)
【分布式系统】
实际开发中,服务器整个功能 不是由 一个服务器 完成的。而是 每个服务器负责一部分功能,通过服务器之间的网络通信,最终完成整个功能。(如下图:图一)
如图所示,这种耦合性比较高。引入“生产者消费者模型”,降低上述的耦合 ~ ~(如下图:)
【代价】
这样解决分布式系统耦合性的方法,需要加入机器,引入更多的硬件资源!!!
(1)上述描述的“阻塞队列”,并非简单的数据结构,而是基于这个数据结构实现的服务器程序,并被部署到单独的主机上了。——消息队列(message queue,mq)
(2)整个系统的结构更复杂了,需要维护的服务器更多了。
(3)效率降低。引入中间商(阻塞队列),还是有代价的:请求从A发出来到B收到,这个过程中就经历队列的转发。这个过程有是一定的开销的。 - 意义二:削峰填谷
(此处所谓的“峰”、“谷”,都不是长时间持续的,而是短时间出现的——太长时间仍然会挂)
图一中:
【外界请求】属于用户行为,不可预知,数量不固定。
一旦用户请求出突发峰值,突发峰值就会使B和C直接寄了。。。(一些突发事件导致请求骤增的经典场景:双十一)
(1) “请求多了,服务器会挂”,原因:
服务器处理每个请求都需要消耗硬件资源:
CPU——处理代码实现
内存、硬盘——存储数据
网络带宽——传输数据
……
(上述任何一种硬件资源达到瓶颈,服务器都会挂!!!直观的现象就是:你给他发请求,他不会返回响应了)
(2)“A比B、C的抗压能力强”,原因:
A:入口服务器,A的工作计较简单,每个请求消耗的资源少(并不是不消耗,请求进一步增多,A也会挂,消息队列也是)
B、C完成的工作更复杂,每个请求消耗的资源多 ~ ~
(B:用户服务器,要从数据库中找到对应的用户信息 C:商品服务器,也许需要从数据库中找到对应的商品,还一些规则需要匹配,过滤……)
【削峰填谷——消息队列,处理请求骤增的情况,防止服务器挂了】
【注意】
阻塞队列——数据结构
消息队列——基于阻塞队列实现的服务器程序
(概念要清楚,这样区分更有针对性)
如图,在入口服务器A和其他两个需要取请求并相应请求的服务器中间加上一个“消息队列”,起到一个缓冲作用。
即使请求骤然发生峰值,也是由队列来承担峰值请求,也不会轻易使B、C挂掉,B、仍然是按照之前的速度来取请求 ~ ~
当然,如果请求一直往上增加,A和消息队列肯定也会有顶不住的时候,会挂掉。
对此,大厂的系统,往往会有非常充裕的冗余量 ~ ~(引入更多的硬件资源,避免上述情况)
阻塞队列的用法(比较简单)
Java标准库里提供了现成的阻塞队列数据结构——BlockingQueue(接口,有很多类实现了这个接口,也就有很多种阻塞队列)
注意:阻塞队列的类,既有带阻塞的方法,也有普通的队列不带阻塞的方法。(阻塞队列没有带阻塞的获取队首元素的方法,不过它可以使用不带阻塞的)
【put】
入队列,offer也是入队列。
put带有阻塞功能,offer没带阻塞(队列满了会返回boolean类型)
BlockingQueue queue = new ArrayBlockingQueue(100);
queue.put("aaa");
【take】
出队列,……
take带有阻塞功能,……
//
【注意】
阻塞队列没有提供带有阻塞功能的获取队首元素的方法 ~ ~
Java标准库提供的阻塞队列(重点关注用法,很简单)
Java标准库提供了现成的数据结构。
自己实现一个阻塞队列
-
先实现普通队列
——基于数组来实现(环形队列) -
再加上线程安全
-
再加上阻塞功能
public void put(String elem){
if(size >= elems.length){
//队列满了
//后续需要让这个代码能够阻塞
return;
}
//新的元素要放到tail指向的位置上
elems[tail] = elem;
tail++;
if(tail >= elems.length){
tail = 0;
}
size++;
}
【实现普通队列】
队列满——
队列空——
这两个情况的判定,有两种方法:
(1)浪费一个格子,tail最多走到head的前一个位置
(2)引入size变量
评价某个代码好不好的标准,参考的指标:
1)开发效率(代码是否容易理解)
2)运行效率 (代码执行的速度快不快)
写法(1):
if(tail >= elems.length){
tail = 0;
}
开发效率:程序员都能理解if语句
运行效率:if是条件跳转语句,执行速度非常快。并且大多数情况下,不会满足if条件从而不会触发赋值
写法(2):
tail = tail % elems.length;
//如果tail<length,此时求余的值,就是tail原来的值
//如果tail==length,求余的值就是0
开发效率:不是所有程序员都熟悉%操作的特性——这个代码并不好理解
运行效率:%本质上是除法运算指令,除法运算,属于比较低效的指令(CPU更擅长的是加减运算,计算*/的速度要比+-更逊色一筹 ~ ~)
===>综上,推荐写法一(小胜一筹)。不过具体怎么写还是要看个人习惯。虽然有差别,但是差别不大,如果你的程序太卡,大概率不是因为这里的缘故导致的 ~ ~(C++追求性能到极致,会非常扣这些细节,Java更推荐说服老板多加机器 ~ ~)
——要知道,很多所谓的“优化”,最后归根结底,是增加机器,增加硬件资源,更为立竿见影。 仅仅是软件层面的优化,当然也会有用,但是投入产出比较低 ~ ~费很大劲,只能优化一点点……
【加上线程安全】——引入锁,解决线程安全问题 ~ ~(出、入队列需要使用同一把锁,否则一个线程出队列,一个线程入队列,不考虑阻塞的情况就有线程安全问题了——没有锁竞争)
引入锁需要考虑的几个问题:
1)如何加锁/锁放到哪里合适?
2)加了锁之后,是否还有问题?
——这两项,都是多线程的难点。需要仔细考虑。确保所有执行顺序下,程序的结果都是对的!!
如上图所示的加锁方式 和 执行顺序下,如果put的是最后一个元素,会多加一个元素(上图只考虑了“都是“写操作”的部分进行加锁了”)
因此,需要将这里的操作打包。也就是如下代码所示:
public void put(String elem){
synchronized(locker){
if(size >= elems.length){
//队列满了
//后续需要让这个代码能够阻塞。
return;
}
//新的元素要放到 tail 指向的位置上
elems[tail] = elem;
tail++;
if(tail >= elems.length){
tail = 0;
}
size++;
}
}
——这里可以思考一下,如过“上边”“if 判断条件”的代码,会因结果不同,影响到“下边”代码的执行,并且,下边代码的执行结果,会改变上边条件判断的结果,那么,就要考虑 加锁解决可能存在的线程安全问题
【阻塞】
大致原理就是:
出队列操作,如果队列空,则阻塞,等待有入队列成功的操作之后notify唤醒;
入队列操作,如果队里满,则阻塞,等待有出队列成功的操作之后notify唤醒;
——可行原因 及 要求1)队列不可能 既空又满,所以一个一定可以在另一个成功之后被唤醒。
2)根据以上notify唤醒wait的原理分析,入队列和出队列需用同一把锁(对同一个锁对象加锁)
不过,还可能出现其他问题:
这是分析中的理想情况——可以正常执行,且没有问题:
但是,还可能出现下面这种情况:一个notify“连锁唤醒”(我想的描述词 ~ ~)
两个线程都入队列阻塞了:
线程1入队列阻塞后,线程2出队列操作成功一次,并执行notify,唤醒了线程1,线程1能够成功put了,
但是在线程1被唤醒后成功put的这次操作后的notify之前,线程3也执行put操作,但是被阻塞了。(这里的顺序描述不必如此严谨,因为notify一次只能唤醒一个使用同一个锁对象的阻塞线程,这里的主要问题是接连触发多个notify唤醒多个wait)
本来线程2只take一次,只空出了一个位置供put使用,但是,线程1的notify却唤醒了线程3的wait.
也就是说,入队列的唤醒操作会唤醒其他等待入队列的操作。(一开始有两个wait)
如何解决——if判断改while判断,唤醒之后再判定一次,相当于多做了一步确认操作。
——问题所在:提前被唤醒
(明明条件还没满足,就唤醒了)
——这里可以思考一下:一个线程 被阻塞 到 被唤醒,需要尤其注意这中间的变化,尤其是是否影响到阻塞前的条件判断的条件(如果有条件判断的话——一般会有,满足什么条件,才会进入阻塞),是否在这个过程中发生了变化——由此也可见,唤醒的线程,是从阻塞的位置继续执行的,而不是从该线程从头开始重新执行。
理论上,应该每次唤醒都是满足继续执行的条件的。
但是实际并非如此,既然唤醒信号 代表的含义并不可靠,那么就不能再依赖这个信号进行决定是否继续执行,于是,采用其他方法进行判断。
每次被唤醒,都应该确认一下,看看当前是否就应应该要继续执行,还是再等待一会儿。也就是:利用while进行多一次判断
如下图所示,Java本身也是推荐我们用while这种操作来解决的:
自己实现一个阻塞队列的完整代码如下:
基于这个阻塞队列,可以实现:生产者消费者模型
实际开发中,生产者消费者模型(最核心的部分仍然是阻塞队列。使用synchronized和wait/notify达到 线程安全/阻塞),往往是多个生产者,多个消费者。
这里的生产者和消费者不仅仅是一个线程,也可能是一个独立的服务器程序,甚至是一组服务器程序。