多線程:安全&通信、Callable接口與線程池
尚硅谷JavaSE筆記-19

線程安全

線程的生命週期

Thread.State類中定義了:

  • 新建:Thread類的物件被創建
  • 就緒:start()後等待分配CPU資源的階段,可能是獲取了同步鎖、被notify()
  • 運行:拿到實際資源、開始執行run()方法
  • 阻塞:被暫時掛起,可能是sleep()或是被join()、或等待同步鎖、wait()
  • 死亡:跑完或提前stop()、出錯了

同步代碼塊

  • 解決多線程安全問題

  • 格式:

    synchronized (同步器) {
    // 需要同步的代碼
    }
    
  • 同步器可以是任何物件,只需要滿足"它是多個線程共用的",比如同類中的一個變量

    • 若是靠實現Runnable接口方法的多線程,可以用this,因為只有一個該類,當前對象是同一個
    • 如果是繼承類實現的多線程,則可以用static變量,或是考慮"類名.class"(這玩意也是唯一的)
  • 但這樣做實質等於單線程了,效率不高

同步方法

  • 解決多線程安全問題

  • 舉例:

    public class SynTest implements Runnable {
        int ticket = 100;
    
        @Override
        public void run() {
            show();
        }
    
        private synchronized void show() {
            for (; ticket > 0; ) {
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + "賣票" + ticket);
                ticket--;
            }
        }
    }
    
    class Test {
        public static void main(String[] args) {
            SynTest s1 = new SynTest();
            Thread thread0 = new Thread(s1);
            Thread thread1 = new Thread(s1);
            Thread thread2 = new Thread(s1);
            thread0.start();
            thread1.start();
            thread2.start();
        }
    }
    
  • 把要鎖的地方提取出來,寫成一個synchronized修飾的方法,切記不是寫在run()

  • 非靜態的同步方法,此時的同步器=this

    • 同理,若是由繼承類實現的多線程,它必然是一個靜態方法(加上static的),此時的同步器是類.class(類本身)

懶漢單例模式改進

雙重判斷是為了增加效率,同時也線程安全了

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

死鎖deadlock

  • 不同的線程分別占用對方需要的同步資源不放棄,都在等待對方釋出資源,就形成死鎖
  • 形成死鎖後不會報錯,只會互相阻塞無法繼續
  • 解法:專門的算法、原則,盡量減少定義同步資源,盡量避免嵌套同步

可重入鎖ReentrantLock

解決線程安全問題

  1. 造一個ReentrantLock類的物件 (Reentrant=可重入)

  2. 上鎖,然後用try把要保護的地方包起來

  3. finally區塊寫上解鎖

  4. 範例:

    import java.util.concurrent.locks.ReentrantLock;
    
    public class SynTest implements Runnable {
        int ticket = 100;
    
        private ReentrantLock lock = new ReentrantLock(true);
    
        @Override
        public void run() {
            while (true) {
                lock.lock(); // 這裡加鎖
                try {
                    if (ticket > 0) {
                        System.out.println(Thread.currentThread().getName() + "賣票" + ticket);
                        ticket--;
                    } else {
                        break;
                    }
                } finally {
                    lock.unlock(); // 這裡解鎖,切記
                }
            }
        }
    }
    
    class Test {
        public static void main(String[] args) {
            SynTest s1 = new SynTest();
            Thread thread0 = new Thread(s1);
            Thread thread1 = new Thread(s1);
            Thread thread2 = new Thread(s1);
            thread0.start();
            thread1.start();
            thread2.start();
        }
    }
    
  5. new ReentrantLock(true);後面的true表示是一個公平鎖,先入先出;預設沒寫則=false

  6. 同樣的,若是由繼承類實現的多線程,new lock時還要加上static

  7. 優勢在於Lock可以靈活手動控制,而synchronized只能自動

練習Lock

同時存錢

import java.util.concurrent.locks.ReentrantLock;

class Account {
    private int balance = 0;
    ReentrantLock lock = new ReentrantLock(true);

    public void deposit(int amt) {
        lock.lock();
        try {
            if (amt > 0) {
                balance = balance + amt;
                System.out.println(Thread.currentThread().getName() + "存錢成功");
                System.out.println("餘額" + balance);
            }
        } finally {
            lock.unlock();
        }
    }
}

class Customer extends Thread {

    private Account acct;

    public Customer(Account acct) {
        this.acct = acct;
    }

    @Override
    public void run() {
        for (int i = 0; i < 3; i++) {
            acct.deposit(1000);
        }
    }
}

public class BankTest {
    public static void main(String[] args) {
        Account acct = new Account();
        Customer c1 = new Customer(acct);
        Customer c2 = new Customer(acct);
        c1.start();
        c2.start();
    }
}

線程的通信

方法

  • wait():使當前線程進入阻塞狀態,並釋放同步器
  • notifly():喚醒被wait的一個線程,若有多個則喚醒優先極高的那個
  • notifyAll():全叫醒

限制

  • 三個方法必須位於同步代碼塊或同步方法中
  • 三個方法的調用者必須是同一個的計數器,例如都是this;否則會IllegalMonitorStateException
  • 從任何物件都能成為計數器可以得知,這些方法都是位於Object類中

範例

class Number implements Runnable {
    private int num = 1;
    Object obj = new Object();

    @Override
    public void run() {
        while (true) {
            synchronized (this) {
                this.notify(); // this可省略
                if (num <= 100) {
                    try {
                        Thread.sleep(1);
                        System.out.println(Thread.currentThread().getName() + "-" + num);
                        num++;
                        this.wait(); // this可省略
                        // 注意要先執行完才wait,否則會多一次
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                } else {
                    break;
                }
            }
        }
    }
}

public class ComTest {
    public static void main(String[] args) {
        Number number = new Number();
        Thread thread1 = new Thread(number);
        Thread thread2 = new Thread(number);
        thread1.setName("t1");
        thread2.setName("t2");
        thread1.start();
        thread2.start();
    }

}

面試題-sleep與wait

相同:都能使當前線程進入阻塞

相異:

sleep() wait()
調用的主體 Thread類 Object類
使用的位置 任意場景 同步代碼塊或方法中
重新就緒條件 設定的時間 等到被notify為止
是否釋放同步器

經典例題-生產者與消費者

可能同時有複數的生產者跟消費者都在使用同一個資源(產品),它們共同的屬性:經過店員當作中轉

所以造一個店員類,店員擁有這個資源(產品)作為屬性

而生產者跟消費者都繼承Thread類,並且以同一個店員作為構造器生成物件(生出來的實例都有同一個"店員"屬性,所以可以拿這個店員當共同的計數器,實現線程間的溝通

class Clerk { // 店員
    private int Product = 0;

    public synchronized void launchProduct() {
        if (Product < 20) {
            Product++;
            System.out.println(Thread.currentThread().getName() +
                    ":上架第" + Product + "個產品");
            notify(); // 上架了就能喚醒消費者
        } else {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

    }

    public synchronized void sellProduct() {
        if (Product > 0) {

            System.out.println(Thread.currentThread().getName() +
                    ":消費了第" + Product + "個產品");
            Product--;
            notify(); // 消費了就能喚醒生產者
        } else {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

class Producer extends Thread {
    private Clerk clerk;

    @Override
    public void run() {
        System.out.println(getName() + "開始生產商品");
        while (true) {
            try {
                sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            clerk.launchProduct();
        }
    }

    public Producer(Clerk clerk) {
        this.clerk = clerk;
    }
}

class Customer extends Thread {
    private Clerk clerk;

    public Customer(Clerk clerk) {
        this.clerk = clerk;
    }

    @Override
    public void run() {
        System.out.println(getName() + "開始消費商品");
        while (true) {
            try {
                sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            clerk.sellProduct();
        }
    }
}

public class ProductTest {
    public static void main(String[] args) {
        Clerk c1 = new Clerk();
        Customer cus = new Customer(c1);
        Customer cus2 = new Customer(c1);
        Producer pro = new Producer(c1);
        cus.setName("消費者");
        cus2.setName("消費者2");
        pro.setName("生產者");
        cus.start();
        cus2.start();
        pro.start();
    }

}

JDK5.0新增的線程特性

Callable接口

Runnable相比,Callable有以下優勢:

  • 可以有返回值,並支持泛型的返回值
  • 方法可以拋出異常
  • 需要藉助FutureTask類,例如獲取返回結果

Future接口

  • 可以對具體RunnableCallable任務的執行結果進行取消、查詢等等
  • FutureTask類是Future接口的唯一實現類
  • FutureTask同時實現了RunnableFuture接口,他既可以做為Runnable被線程執行,也可以作為Future獲取Callable的返回值

方法三-實現Callable接口

其實跟Runnable沒差多少,就是多套了一層FutureTask當中轉來解決返回值跟拋異常的需求

  1. 創建一個自訂類實現Callable接口

  2. 在自訂類實現call方法,裡面放進想多線程的代碼(類似於run)

  3. 創建此自訂類的實例物件

  4. 將剛剛創立的物件作為形參傳遞到FutureTask構造器中,創立FutureTask類的物件

  5. 將剛剛創立的FutureTask物件作為形參傳遞到Thread構造器,創立Thread類的物件

  6. 以此Thread類物件調用start()方法

  7. 若想獲取返回值,使用FutureTask類的物件.get(),並且此方法可以拋出異常

  8. 舉例:

    import java.util.concurrent.Callable;
    import java.util.concurrent.ExecutionException;
    import java.util.concurrent.FutureTask;
    
    class NumThread implements Callable {
        @Override
        public Object call() throws Exception {
            int sum = 0;
            for (int i = 1; i <= 100; i++) {
                System.out.println(i);
                sum += i;
            }
            return sum;
        }
    }
    
    public class CallTest {
        public static void main(String[] args) {
            NumThread numThread = new NumThread();
            FutureTask futureTask = new FutureTask(numThread);
            new Thread(futureTask).start();
    
            try {
                Object sum = futureTask.get();
                System.out.println("總合為" + sum);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (ExecutionException e) {
                e.printStackTrace();
            }
    
        }
    }
    

方法四-使用線程池

實際開發中使用的

  • 使用ExecutorService 接口名 = Executors.newFixedThreadPool();創造指定線程數量的線程池接口

  • 設置線程池的各種屬性,他們的關係其實是這樣:

    • ThreadPoolExecutor extends AbstractExecutorService
    • AbstractExecutorService implements ExecutorService
    • 所以可以向下強轉接口得到方便設置屬性的物件ThreadPoolExecutor 物件名= (ThreadPoolExecutor) 接口名;
  • 線程池接口.execute調用Runnable方法

  • 線程池接口.submit調用Callable方法,或再加.get()獲取返回值

  • 線程池接口.shutdown()關閉線程池

  • 舉例:

    import java.util.concurrent.*;
    
    class NumThread1 implements Runnable {
        @Override
        public void run() {
            for (int i = 1; i <= 10; i++) {
                System.out.println(Thread.currentThread().getName() + "-i=" + i);
            }
        }
    }
    
    class NumThread2 implements Callable {
        @Override
        public Object call() throws Exception {
            int sum = 0;
            for (int i = 1; i <= 10; i++) {
                System.out.println(Thread.currentThread().getName() + "-i=" + i);
                sum += i;
            }
            return sum;
        }
    }
    
    public class ThreadPool {
        public static void main(String[] args) {
    //        1. 使用ExecutorService 接口名 = Executors.newFixedThreadPool();創造指定線程數量的線程池接口
            ExecutorService service = Executors.newFixedThreadPool(5);
    //        2. 設置線程池的各種屬性
            ThreadPoolExecutor serviceObj = (ThreadPoolExecutor) service;
            System.out.println(service.getClass());
            serviceObj.setCorePoolSize(15);
            serviceObj.setKeepAliveTime(100, TimeUnit.MILLISECONDS);
    //        3. 線程池接口.execute調用Runnable方法
            service.execute(new NumThread1());
    
    //        4. 線程池接口.submit調用Callable方法,或再加.get()獲取返回值
            try {
                Object obj = service.submit(new NumThread2()).get();
                System.out.println("返回值=" + obj);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (ExecutionException e) {
                e.printStackTrace();
            }
    
    //        5. 線程池接口.shutdown()關閉線程池
            service.shutdown();
        }
    }
    

小結

  • 線程的就緒:被notify、獲取了同步器
  • 線程的阻塞:sleepwait、被join、等待同步器
  • 同步器:必須是多個線程間共用的物件,要嘛是this、要嘛static的則是類.class本身
  • 確保線程安全時,鎖的性能考慮:ReentranLock > 同步代碼塊 > 同步方法
  • 創建多線程的4種方式與關鍵點
    • 繼承Thread類:用static
    • 實現Runnable接口:用this
    • 實現Callable接口:可返回,用FutureTask中轉
    • 線程池:便於開發使用,記得關

上次修改於 2021-12-04