在學校的時候,我不愛去食堂成功,一是由於暗黑料理,更重要的一點是人太多了,隊伍往往從視窗排到了門口,點菜、計算價格、付款三種業務由打飯阿姨一人完成,思維切換忙碌,操作變更頻繁,導致效率低下,降低了食堂的吞吐量,造成了不好的使用者體驗。
而最近在公司食堂吃飯,發現是另外一種設計:工作人員向餐檯上新增食物,兩條通道對顧客進行分流,顧客依次進入佇列然後對餐檯上的食物進行自取,最後在佇列出口進行結賬。
變成了這樣:
在學校食堂提供的打飯服務中,是由打飯阿姨單獨完成一系列的操作。而在公司食堂,則將打飯解耦成了排盤與收銀兩個服務,降低了每個工作人員的工作複雜度,使其能更加專注於自己負責的工作。同時,在學校食堂中,一名學生在打飯的同時,排在隊伍後面的同學是出於等待狀態,等待獲取打飯阿姨的服務,這對於打飯阿姨的效能是極大的浪費。
而在公司食堂,這是一種生產-消費的模型。我們來看看都有些什麼?
首先,消費者是誰?肯定是來用餐的顧客,消費的東西是什麼?是餐檯上的食物。而生產者則是將食物擺上餐檯的工作人員。
那麼將這個場景抽象化,可以用這張圖來表示:
這張圖主要包含了這些資訊:
-
一個資料倉儲
用於資料快取,生產者生產完資料放入,消費者從中取出。它有兩個注意點:
- 同步,即生產者和消費者不能同時訪問資料倉儲。對應到現實世界,你可以理解為:顧客和工作人員不會在一個瞬間去訪問餐檯,兩個顧客也不能在同一瞬間去爭搶同一盤食物。
- 當倉庫滿了,生產者無法再新增資料進去,將處於阻塞狀態,並提醒消費者進行消費;當倉庫為空,消費者無法從中獲取資料。處於阻塞狀態,並提醒生產者進行生產。
- 兩種角色:生產者、消費者。
- 兩種關係:
- 依賴關係:生產者與消費者
- 競爭關係:生產者與生產者、消費者與消費者
那如何使用這種模型,將食堂這個場景來實現還原呢?我們可以這麼分析:
- 資料倉儲可以使用一個佇列或陣列來實現。但是要注意同步。我們這裡選擇java.util.concurrent包下的BlockingQueue,它有著已經實現的阻塞新增和阻塞刪除的特性。當然也可以用一個List去自己實現。
- 生產者是一些執行緒,生產一些資料,將其入隊。
- 消費者是一些執行緒,從隊首獲取資料,將其消費。
核心概念就是以上三點,那麼我們接下來用程式碼簡單實現一下。預期目標是讓整個流程自動執行起來。
首先我們來定義一個食物類Food
,這裡從簡,只給它一個id
屬性。
public class Food {
private int id;
//含參建構函式,這裡用不到無參構造器,所以可以不寫。否則新建物件將會出錯。
public Food(int id){
this.id=id;
}
//get/set
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
}
複製程式碼
接著我們來編寫生產者類Producer
和消費者Consumer
,讓我們來想想這兩個類該怎麼去編寫?
首先我們需要使用組合的方式來加入一條BlockingQueue
(阻塞佇列),它是整個問題的核心,又由於這個容器是隻用來存放Food
的,我們可以加上泛型。
之前也提到了,Food有一個屬性是id,那麼這個id從何而來呢?應該是生產者生產Food時賦予的,而由於多位生產者在同時生產,為了保證id的唯一性,我們需要對其進行原子化的自增操作。這裡我們可以使用java.util.concurrent.atomic包中的AtomicInteger,當然如果使用int,然後自己做一些同步操作也是可以的。(若這裡對Java記憶體模型JMM不瞭解的,可以去閱讀我寫過的這篇文章:)
那麼,Producer
類可以這麼寫,將每個工作執行緒當作一名生產者,並作一些必要的文字輸出,方便控制檯觀察:
public class Producer implements Runnable {
private boolean working=true;
private BlockingQueue<Food> queue;
private static AtomicInteger count = new AtomicInteger();
//建構函式
public Producer(BlockingQueue<Food> queue){
this.queue=queue;
}
@Override
public void run() {
while(working){
int id=count.incrementAndGet();
Food food=new Food(id);
// System.out.println(Thread.currentThread().getId()+"號員工開始工作");
if(queue.offer(food)){
System.out.println(Thread.currentThread().getId()+"號員工將"+food.getId()+"號食物加入餐檯");
}else {
System.out.println("餐檯已滿,"+food.getId()+"號食物無法加入");
}
try {
Thread.sleep(1000*3);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public void stop(){
working=false;
}
}
複製程式碼
同理,Consumer
可以這麼寫:
public class Consumer implements Runnable {
private boolean working=true;
private BlockingQueue<Food> queue;
//含參建構函式
public Consumer(BlockingQueue<Food> queue){
this.queue=queue;
}
@Override
public void run() {
while (true){
try {
Food food=queue.take();//take()方式,若佇列中沒有元素則執行緒被阻塞
System.out.println(food.getId()+"號食物已被"+Thread.currentThread().getId()+"號顧客端走");
Thread.sleep(1000*2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
複製程式碼
最後,就是主函式呼叫了,我們使用執行緒池來對執行緒進行分配,這裡我將資料佇列定義為10個元素空間,執行緒池使用了newFixedThreadPool方式來規定5條執行緒,初始化3名生產者和15名消費者。
Main.java
public class Main {
public static void main(String[] args) {
BlockingQueue<Food> queue = new LinkedBlockingDeque<>(10);
Producer[] p=new Producer[3];
Consumer[] c=new Consumer[15];
for (int i=0;i<3;i++){
p[i]=new Producer(queue);
}
for (int j=0;j<15;j++){
c[j]=new Consumer(queue);
}
ExecutorService executorService= Executors.newFixedThreadPool(5);
for (int i=0;i<3;i++){
executorService.execute(p[i]);
}
for (int j=0;j<15;j++){
executorService.execute(c[j]);
}
try {
Thread.sleep(1000*20);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
複製程式碼
下面就是食堂開張的時刻了? 讓我們趕緊執行試試。
可以看到,生意還不錯,並且沒有出什麼問題。作為老闆(程式設計師),我們需要高負荷壓榨員工(程式效能),在現實生活中,也是老闆(真的老闆)高負荷壓榨員工(程式設計師),這條壓榨鏈條就是:老闆-->程式設計師-->計算機
有點扯遠了哈哈。繼續回到生產-消費模型,其實現在很多的店都採用了這種思想。比如你去買奶茶,都是先付款獲取一個唯一id,然後進入等待狀態,點單員執行緒繼續處理後面的消費者。接著商品製作完成,通過唯一id匹配,你獲取商品,然後再把它消費掉。
今天飯點在朋友圈看到一張圖片:
可以看得出來,一大堆執行緒都被阻塞了。
我的公眾號:位元組流。