多線程的一些案例

單例模式

這是一種常見的“設計模式”。

“設計模式”類似於“棋譜”。

場景:代碼中的有些概念不應該存在多個實例,此時應該使用單例模式來解決

兩種典型的方式實現單例模式:

1、餓漢模式:“餓”代表只要類被加載,就會立刻實例化 Singleton 實例,後續無論怎麼操作,只要永遠不使用 getlnstance,就不會出現其他的實例。

2、懶漢模式

類加載的時候,沒有立刻實例化,第一次調用 getInstance 的時候纔會真正實例化,如果要是代碼一整場都沒有調用getInstance 此時實例化的過程也就被省略了

 

那麼單例模式和線程有什麼關係呢?

剛纔兩種單例模式的實現方式中,餓漢是線程安全的,懶漢是線程不安全的。

原因:

首先回顧一下導致線程不安全的原因:1.線程的調度搶佔式執行;2.修改操作不是原子的;3.多線程同時修改同一個變量;4.內存可見性;5.指令重排序。對於餓漢來說,多線程同時調用 getInstance,由於 getInstance 裏只做了一件事:讀取 instance 實例的地址,這就代表着多個線程在同時讀取同一個變量,並不是修改,所以餓漢是線程安全的。

對於懶漢模式來說,多線程同時調用 getInstance ,getInstance中做了四件事:1.讀取 instance 的內容;2.判斷 instance 是否爲 null;3.如果 instance 爲 null,就 new 實例;4.返回實例的地址。在第二步操作中 new 實例會修改 instance 的值。所以是線程不安全的。

 

用一個時間軸來展示懶漢模式:

如何改進懶漢模式,讓代碼變成線程安全的?

第一種優化方式:加鎖

下面展示一個錯誤的修改方式:

這樣寫,此時讀取判斷,操作和 new 修改操作讓不是原子的,下面的操作爲正確的解決辦法

這兩種寫法都是正確的,認爲上面的寫法鎖的粒度更小,下面的鎖的粒度更大,(鎖中包含的代碼越多就認爲“粒度”越大),一般代碼的粒度越小越好。

另一種優化方式:在鎖上方再加一個 if 後這樣可以提高效率:

public static Singleton getInstance(){
            if (instance == null) {
                synchronized (Singleton.class){
                    if (instance == null){
                        instance = new Singleton();
                    }
                }
            }
            return instance;
        }

 

單例模式爲了保證線程安全,涉及到三個要點:

  1. 加鎖保證線程安全
  2. 雙重 if 保證效率
  3. volatile 避免內存可見性引來的問題

阻塞隊列

是併發編程中的一個重要基礎組件,幫助我們實現“生產者-消費者模型”(這是一種典型的處理併發編程的模式)

“生產者-消費者模型”:

舉一個生活中的例子,就是如果甲、乙、丙三個人需要一個桌子

他們需要的操作步驟就是:

1.用斧子砍樹 2.把木頭通過小車車運輸 3.拼接成桌子

那麼就會有兩種情況:第一種情況是三個人每個人都操作一遍兩個步驟;第二種情況就是甲砍樹,乙運輸,丙個人拼接桌子

第一種情況對於斧子(鎖)的需求太高,而第二種情況就比較常見,其中第一個人就是生產者,另外兩個人就是消費者

還有一個問題就是甲砍樹太快,小車車放不下;或者乙運輸的太快,丙不能很快拼接完成。

阻塞隊列的特點也是如此,他是一個先進先出的隊列:入隊列的時候如果發現隊列滿了就會阻塞,直到有其他線程調用出隊列操作讓隊列中有空位之後,才能繼續入隊列;如果出隊列操作太快,隊列空了額,繼續出隊列,也會阻塞,一直阻塞到有其他線程生產了元素,才能繼續出隊列


隊列的基本操作:

  1. 入隊列
  2. 出隊列
  3. 取隊首元素

阻塞隊裏只提供前兩個操作,不支持取隊首元素

        //阻塞版本的入隊列
        public void put(int value) throws InterruptedException {

            synchronized (this) {
                if (size == array.length){
                    wait();
                }
                array[tail] = value;
                tail++;
                if (tail == array.length){
                    tail = 0;
                }
                size++;
                notify();
            }
        }

        //阻塞版本的出隊列
        public int take() throws InterruptedException {
            int ret = -1;
            synchronized (this){
                if (size == 0){
                    wait();
                }
                ret = array[head];
                head = 0;
                if (head == array.length) {
                    head = 0;
                }
                size--;
                notify();
            }
            return ret;
        }
    }
}

體會上面的兩個wait 操作,一個在隊列滿的時候阻塞,一個在隊列空的時候阻塞,兩個操作永遠不會衝突

 

假設兩個線程入隊列,一個線程入隊列,一個線程出隊列,此時如果隊列已經滿了,兩個入隊列線程就會線程就阻塞了,此時如果出隊列操作

如果多個線程 wait notify 的時候喚醒哪個線程由操作系統調度器說了算(程序員的角度理解就是隨機的)

如果沒有 wait 執行了 notify 沒有影響,有線程在 wait ,notify 就就喚醒一個線程,沒有線程 wait 不會有任何負面影響

 

public static void main(String[] args) {
        BlockingQueue blockingQueue = new BlockingQueue();
        //第一次讓消費者消費的快一些,生產者慢一些
        //此時就會消費者等待
        //第二次讓消費者生產的快一些,消費者慢一些
        //此時就會預期看到,生產者線程剛開始的時候會快速插入元素,直到隊列滿的時候就會阻塞
        //此時就要消費了以後才能生產
        Thread producer = new Thread(){
            @Override
            public void run(){
                for (int i = 0; i < 10000; i++) {
                    try {
                        blockingQueue.put(i);
                        System.out.println("生產元素:" + i);
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        producer.start();
        Thread consumer = new Thread(){
            @Override
            public void run(){
               while (true){
                   try {
                       int ret = blockingQueue.take();
                       System.out.println("消費元素:" + ret);
                       //
                   } catch (InterruptedException e) {
                       e.printStackTrace();
                   }
               }
            }
        };
        consumer.start();
    }
}

運行效果:即使消費的比生產的快,但是還是要等生產完成後才能消費

 

 public static void main(String[] args) {
        BlockingQueue blockingQueue = new BlockingQueue();
        //第一次讓消費者消費的快一些,生產者慢一些
        //此時就會消費者等待
        //第二次讓消費者生產的快一些,消費者慢一些
        //此時就會預期看到,生產者線程剛開始的時候會快速插入元素,直到隊列滿的時候就會阻塞
        //此時就要消費了以後才能生產
        Thread producer = new Thread(){
            @Override
            public void run(){
                for (int i = 0; i < 10000; i++) {
                    try {
                        blockingQueue.put(i);
                        System.out.println("生產元素:" + i);
                        
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        };
        producer.start();
        Thread consumer = new Thread(){
            @Override
            public void run(){
               while (true){
                   try {
                       int ret = blockingQueue.take();
                       System.out.println("消費元素:" + ret);
                       Thread.sleep(500);
                   } catch (InterruptedException e) {
                       e.printStackTrace();
                   }
               }
            }
        };
        consumer.start();
    }
}

 

第二次運行效果:剛開始生產者比較快就快速生產,直到隊列滿了,就阻塞等待消費者消耗了以後才繼續生產

                                           

 

當有兩個消費者線程的時候

當兩個消費者都觸發 wait 操作後,接下來當我們調用 notifyAll 的時候,就把上面兩個線程都喚醒了,於是兩個線程就都去重新獲取鎖:

消費者1 ,先獲取到鎖,於是就執行下面出隊列操作(執行完畢釋放鎖)

消費者2,後獲取到鎖,於是也會執行下面的出隊列操作,但是注意:剛纔生產者生產的一個元素,已經被消費者1 線程給取走了,當前實際是一個空隊列,如果強行往下執行取隊裏取素操作,就會出現邏輯錯誤。

定時器

相當於一個鬧鐘,進行任務的管理。

定時器是多線程編程中的一個重要/常用組件,應用場景非常廣泛,網絡編程中特別常見。

定時器的構成:

  1. 使用一個類來描述“一段邏輯”(一個要執行的任務),同時也要記錄這個任務在什麼時間點執行
  2. 使用一個阻塞優先隊列來組織若干個 Task。(使用優先隊列是爲了保證隊首元素就是要被最早執行的任務)【阻塞隊列既支持阻塞的特性,又支持優先級的“先進先出”,本質上是一個“堆”】
  3. 需要一個掃描線程,不停的掃描,判定隊首是否時間到。(掃描線程要循環的檢測,隊首元素是否需要執行,如果需要執行的話,就執行這個任務。)
  4. 實現一個方法 schedule,給定時器內部安排一個任務。
  5. 爲了避免忙等,還需要引入一個額外的對象,讓掃描線程藉助這個對象進行 wait 。(使用帶超時時間版本的 wait)

隨意一個對象都可以放入優先隊列中麼?

答:優先隊裏而需要知道對象之間的大小關係,才能把優先級排出來(才能保證隊首元素是優先級最高的)

優先隊列中的元素必須是可比較的
比較規則的指定主要是兩種方式:1、讓 Task 實現 Comparable 接口 2、讓優先隊列構造的時候,傳入一個比較器對象(Comparator)

標準庫中其實已經提供了阻塞隊列,定時器等基本組件,實際工作中,可以直接運用,下面的代碼是爲了理解原理,也是爲了加深對多線程的掌握。

import java.sql.Time;
import java.util.concurrent.PriorityBlockingQueue;

public class ThreadDemo1 {
    //優先隊列中的元素必須是可比較的
    //比較規則的指定主要是兩種方式
    static class Task implements Comparable<Task> {
        //Runnable 中有一個 run 方法,就可以藉助這個 run 方法來描述要執行的具體任務是什麼
        private Runnable command;
        //time 表示什麼時候來執行 command,是一個絕對時間(ms級別的時間戳)
        private long time;

        //構造方法的 after 參數表示:after 秒後執行(是一個相對時間)
        //這個相對時間的參數是爲了而用起來方便
        public Task(Runnable command, long after) {
            this.command = command;
            this.time = System.currentTimeMillis() + after;
        }

        //執行具體的邏輯
        public void run(){
            command.run();
        }

        @Override
        public int compareTo(Task o) {
            return (int) (this.time - o.time);
        }
    }

    static class Worker extends Thread{
        private PriorityBlockingQueue<Task> queue = null;

        public Worker(PriorityBlockingQueue<Task> queue) {
            this.queue = queue;
        }

        @Override
        public void run() {
            //實現具體的線程執行內容
            while (true){

                try {
                    //1.取出隊首元素,檢查時間是否到了
                    Task task = queue.take();
                    //2.檢查當前任務時間是否到了
                    long curTime = System.currentTimeMillis();
                    if (task.time > curTime){
                        //時間還沒到,就把任務再放回隊列中
                        queue.put(task);

                    }else {
                        task.run();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    break;
                }
            }
        }
    }

    static class Timer{
        //1.用一個 Task 類來描述任務
        //2.用一個阻塞隊隊列來組織若昂的任務,隊首元素就是時間最早的任務
        private PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>();
        //3.用一個線程循環掃描擋牆的阻塞隊列隊首元素,如果時間到,就執行任務
        public Timer(){
            //創建線程
            Worker worker = new Worker(queue);
            worker.start();
        }
        //4.還需要提供一個方法,讓調用者能把任務安排進來
        public void schedule(Runnable command,long after){  //安排任務
            Task task = new Task(command,after);
            queue.put(task);
        }
    }

    public static void main(String[] args) {
        Timer timer = new Timer();
        timer.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("hehe");
            }
        },5000);
    }
}

我設置的任務爲運行5s 後輸出一個“hehe”

 

爲了更直觀的看到效果,可以把主函數的內容改成每隔 2s 執行一次輸出

運行10s 後的結果

忙等

舉個例子:我們定一個上課的鬧鐘時間爲 9:00,小明現在看了一下時間爲 8:00,又過了一會兒又看了一下時間8:01,又過了一會兒又看了一下時間8:02,剩下的時間還有將近一個小時,還可以做的事情有很多,但是小明一直在看時間,等待上課,這種頻繁的盯着表的行爲就叫作忙等。

我們的線程就可能會出現這種問題,掃描線程極快的運行 while  循環,有可能會大量的資源浪費 CPU 資源進行比較時間和入隊列出隊列操作。爲了解決這個問題,我們就要藉助 wait / notify 來解決。有下面幾種情況

  • wait() 死等,一直等到 notify 的通知過來
  • wait(time),等待是有上限的,如果有 notify 就被提前喚醒,如果沒有 notify,時間到了也一樣可以被喚醒。

代碼阻塞在 wait 處,避免了頻繁佔用 CPU

解決忙等問題部分的代碼:

    static class Worker extends Thread{
        private PriorityBlockingQueue<Task> queue = null;
        private Object mailBox = null;
        
        public Worker(PriorityBlockingQueue<Task> queue,Object mailBox) {
            this.queue = queue;
            this.mailBox = mailBox;
        }

        @Override
        public void run() {
            //實現具體的線程執行內容
            while (true){

                try {
                    //1.取出隊首元素,檢查時間是否到了
                    Task task = queue.take();
                    //2.檢查當前任務時間是否到了
                    long curTime = System.currentTimeMillis();
                    if (task.time > curTime){
                        //時間還沒到,就把任務再放回隊列中
                        queue.put(task);
                        synchronized (mailBox){
                            mailBox.wait(task.time - curTime);
                        }
                    }else {
                        task.run();
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    break;
                }
            }
        }
    }

    static class Timer{
        //爲了避免忙等,需要使用 wait 方法
        //使用一個單獨的對象來輔助進行 wait
        private Object mailBox = new Object();
        
        
        //1.用一個 Task 類來描述任務
        //2.用一個阻塞隊隊列來組織任務,隊首元素就是時間最早的任務
        private PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue<>();
        //3.用一個線程循環掃描擋牆的阻塞隊列隊首元素,如果時間到,就執行任務
        public Timer(){
            //創建線程
            Worker worker = new Worker(queue,mailBox);
            worker.start();
        }
        //4.還需要提供一個方法,讓調用者能把任務安排進來
        public void schedule(Runnable command,long after){  //安排任務
            Task task = new Task(command,after);
            queue.put(task);
            synchronized (mailBox){
                mailBox.notify();
            }
        }
    }

在掃描線程內部加上 wait

在安排任務方法內部加上 notify 

 

線程池

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章