安琪拉教百里守約學併發編程之多線程基礎

《安琪拉與面試官二三事》系列文章
一個HashMap能跟面試官扯上半個小時
一個synchronized跟面試官扯了半個小時

《安琪拉教魯班學算法》系列文章

安琪拉教魯班學算法之動態規劃

安琪拉教魯班學算法之BFS和DFS

安琪拉教魯班學算法之堆排序
《安琪拉教妲己學分佈式》系列文章
安琪拉教妲己分佈式限流
《安琪拉教百里守約學併發編程》系列文章
安琪拉教百里守約學併發編程之多線程基礎

本文是來自讀者羣裏@凱的建議,決定開一個多線程的專欄,尊重讀者安琪拉是認真的,爲什麼是教百里守約,也是因爲讀者羣裏@百里守約是安琪拉的忠實讀者,每期必讀,經常草叢裏定點蹲安琪拉,然後搶板凳(放大招)。這期是《安琪拉教百里守約學併發線程》系列文章第一集多線程基礎。

前言

併發編程應該是Java 後端工程必備的技能,在日常開發中用的好能提升系統吞吐量,提升業務邏輯執行效率,提高系統的響應性,簡化程序結構,當然這把青龍偃月刀也不是隨隨便便就能耍的好,需要些內力。先放一張Java 併發工具包JUC的知識腦圖,後面 Wx公衆號【安琪拉的博客】《安琪拉教百里守約學併發線程》會按以下思維腦圖詳細介紹 JUC 的各部分組件實際使用場景以及組件特性:

開場

百里守約:安琪拉,你熟悉線程(Thread)嗎?和進程(Process)有什麼區別?

安琪拉:熟悉啊!一個應用就是一個進程,一個進程可以包含多個線程,從操作系統層面看,同一個進程中的線程共享該進程的資源,例如內存空間和文件句柄。Linux 操作系統中線程是輕量級進程。

百里守約:在Java 中怎麼創建一個線程呢?

安琪拉:線程的創建有2 種方式,如下,很多網上的文章還寫了通過線程池的方式創建,其本質也是這二種中的一種:

  1. 繼承 Thread 類;
  2. 實現 Runnable 接口;

百里守約:能不能用實際的代碼舉例說一下?

安琪拉:可以,如下所示:

public static void main(String[] args) {
  new Seller("筆").start();
  new Thread(new Seller02("書")).start();
}

//第一種方式 繼承 Thread
public static class Seller extends Thread{

  String product;

  public Seller(String product){
    this.product = product;
  }

  @Override
  public void run() {
    System.out.println("繼承 Thread類 賣 " + product);
  }
}

//第二種方式 實現 Runnable
public static class Seller02 implements Runnable{

  String product;

  public Seller02(String product){
    this.product = product;
  }

  @Override
  public void run() {
    System.out.println("實現 Runnable接口 賣 " + product);
  }
}

百里守約:如果我直接使用 new Seller("筆").run() 執行和start() 有什麼區別?

安琪拉start() 方法是native 方法,JVM 會另起一個線程執行,而直接執行run() 方法是本地線程執行,我們可以使用示例程序對比一下,如下:

public static void main(String[] args) {
  new Seller("筆").run(); //沒有另起一個線程
  new Seller("筆").start(); //在新線程中執行 run 函數
}

//第一種方式 繼承 Thread
public static class Seller extends Thread{

  String product;

  public Seller(String product){
    this.product = product;
  }

  @Override
  public void run() {
    System.out.println(String.format("當前線程: %s 賣%s", Thread.currentThread().getName(), product));
  }
}

看下控制檯輸出如下:

當前線程: main 賣筆
當前線程: Thread-1 賣筆

因爲調用start() 方法後,JVM 會新建一個線程來執行run() 方法內容。

百里守約:我理解了,Thread 對象是Java 中普通的對象,和其他對象一樣,只是在調用 start() 這個native 方法時變得不一樣了,JVM 會根據Thread 對象來創建線程。

安琪拉:你說的非常對,new Thread() 創建Thread 對象時,JVM 還沒有實際創造線程,調用start() 方法後JVM 纔會通過 pthread_create 方法(Linux系統)創建線程。因此一定要將Thread 對象和真實的線程區分開。

百里守約:那JVM 又是如何創建線程的呢?

安琪拉:你這個問的有點深了,我可以大致講講,因爲今天是基礎篇,因此不展開聊,想深入瞭解的可以關注【安琪拉的博客】公衆號,有源代碼層的詳細的講解,先丟個源代碼地址:Hotspot1.8 jvm.cpp,推薦先將本篇文章整體看完,然後回過頭來再看實現原理。擔心很多同學沒學過c++,或者源碼太多無從下嘴,後面會出一期JVM 創建線程源代碼解析。

今天先丟個大致原理:Java 種新建的Thread 對象只是操作系統線程運行的載體,Thread類的作用主要有二點:

  • Thread 對象內的屬性提供了創建新線程時所需要的線程描述信息,例如線程名、線程id、線程組、是否爲守護線程;
  • Thread 對象內的方法提供了Java 程序可以跟操作系統線程打交道的手段,例如wait、sleep、join、interrupt等。

前面說到JVM new Thread對象時其實還沒有真實創建線程,調用start() 方法時纔開始正式創建。

百里守約:那線程是怎麼從創建到執行,最後銷燬的啊?

安琪拉:那你就要看Java 中線程的生命週期了,如下圖所示:

線程狀態轉換圖

在 Thread 類中有個State 枚舉類型標識線程狀態,如下。

public static enum State {
  NEW,
  RUNNABLE,
  BLOCKED,
  WAITING,
  TIMED_WAITING,
  TERMINATED;
}

同時可以使用Thread.currentThread().getState()獲取當前線程的狀態。

解釋一下每種狀態:

  • New: 剛創建而未啓動的線程就是這個狀態。由於一個線程只能被啓動一次,因此一個線程只可能有一次在這個狀態。
  • Runnable:如上圖,這個狀態實際是個複合狀態,包含二個子狀態:Ready 和 Running。Ready是就緒狀態,可以被JVM 線程調度器(Scheduler) 進行調度,如果是單核CPU,同一時刻只有一個線程處於Running 狀態,可能有多個線程處於 Ready 狀態,Running 表示當前線程正在被CPU 執行,在Java 中就是Thread 對象只 run() 方法正在被執行。當 yield() 方法被調用,或者線程時間片被用完,線程就會從 Running 狀態轉爲 Ready 狀態。另外有個小姿勢點,CPU 的一個時間片時間是多久呢? 這個展開來講又可以單獨寫篇文章,這裏只說一個結論:CPU時間片和主機時鐘頻率有關係,一般是10 ~ 20 ms。
  • Blocked:一個線程發生一個阻塞式I/0 (文件讀寫I/O, 網絡讀寫I/O)時,或者試圖獲取其他線程持有的鎖時,線程會進入此狀態,例如:獲取別的線程已經持有的 synchronized 修飾的對象鎖。如果大家對synchronized 關鍵字感興趣,可以看我這篇文章 一個synchronized跟面試官扯了半個小時,建議看完這篇再回過頭看,順便還可以點個贊。在Blocked 狀態的線程不會佔用CPU 資源,但是程序如果出現大量處於這個狀態的線程,需要警惕了,可以考慮優化一下程序性能。
  • Waiting: 一個線程執行了Object.wait( )、 Thread.join( ) 、LockSupport.park( ) 後會進入這個狀態,這個狀態是處於無限等待狀態,沒有指定等待時間,可以和Timed_Waiting 對比,Timed_Waiting是有等待時間的。這個狀態的線程如果要恢復到Runnable 狀態需要通過別的線程調用Object.notify( )、Object.notifyAll( )、LockSupport.unpark( thread )。
  • Timed_Waiting: 帶時間限制的Waiting。
  • Terminated: 已經執行結束的線程處於此狀態。Thread 的 run( ) 方法執行結束,或者由於異常而提前終止都會讓線程處於這個狀態。

百里守約:你剛纔上面講了wait( )、sleep( )、join( )、yield( ) 、notify()、notifyAll( ) 都是做什麼的?什麼區別?

安琪拉:這些方法都是線程控制方法,JAVA 通過這些方法跟它創建的操作系統線程進行交互,具體如下:

  • wait:線程等待,調用該方法會讓線程進入 Waiting 狀態,同時很重要的一點,線程會釋放對象鎖,所以wait 方法一般用在同步方法或同步代碼塊中;

  • sleep: 線程休眠,調用該方法會讓線程進入Time_Waiting 狀態,調sleep 方法需要傳入一個參數標識線程需要休眠的時間;

  • yield:線程讓步,yield 會使當前線程讓出 CPU 執行時間片,與其他線程一起重新競爭CPU 時間片,一般來說,優先級高的線程有更大的可能性成功競爭到CPU 時間片,但不是絕對的,有的系統對優先級不敏感。

  • join:在當前線程中調用另一個線程的join 方法,則當前線程轉爲阻塞狀態,等到另一線程執行結束,當前線程纔會從阻塞狀態變爲就緒狀態,等待CPU 的調度。寫個代碼一看就明白:

    public static void main(String[] args) {
      System.out.println(String.format("主線程%s 開始運行...", Thread.currentThread().getName()));
      Thread threadA = new Thread(new ThreadA());
      threadA.start();
      try {
        // 主線程 wait(0) 釋放 thread 對象鎖,主線程進入 waiting 狀態
        threadA.join();
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    
      System.out.println(String.format("主線程%s 運行結束...", Thread.currentThread().getName()));
    }
    
    private static class ThreadA implements Runnable{
    
      @Override
      public void run() {
        System.out.println(String.format("子線程%s 開始運行...", Thread.currentThread().getName()));
    
        try {
          Thread.sleep(3000);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
        System.out.println(String.format("子線程%s 準備結束運行...", Thread.currentThread().getName()));
      }
    }
    

    控制檯輸出如下:

    主線程main 開始運行...
    子線程Thread-0 開始運行...
    子線程Thread-0 準備結束運行...
    主線程main 運行結束...
    

    主線程調用threadA.join() 導致主線程等Thread-0 線程執行結束纔開始繼續執行。

    join() 函數的內部實現如下:

    public final void join() throws InterruptedException {
      join(0);
    }
    /**
         * Waits at most {@code millis} milliseconds for this thread to
         * die. A timeout of {@code 0} means to wait forever.
         *
         * <p> This implementation uses a loop of {@code this.wait} calls
         * conditioned on {@code this.isAlive}. As a thread terminates the
         * {@code this.notifyAll} method is invoked. It is recommended that
         * applications not use {@code wait}, {@code notify}, or
         * {@code notifyAll} on {@code Thread} instances.
         */
    public final synchronized void join(long millis)
      throws InterruptedException {
      long base = System.currentTimeMillis();
      long now = 0;
    
      if (millis < 0) {
        throw new IllegalArgumentException("timeout value is negative");
      }
    
      if (millis == 0) {
        //如果當前Thread對象關聯的線程還是存活的,當前正在執行的線程進入 Waitting狀態,如果當前Thread對象關聯的線程執行結束,會調用notifyAll() 喚醒進入 Waitting狀態的線程。
        while (isAlive()) {
          wait(0);
        }
      } else {
        while (isAlive()) {
          long delay = millis - now;
          if (delay <= 0) {
            break;
          }
          wait(delay);
          now = System.currentTimeMillis() - base;
        }
      }
    }
    
    //wait 屬於 Object 對象方法
    public class Object{
      //線程進入 Time_Watting 或 Waiting 狀態
      public final native void wait(long timeout) throws InterruptedException;
    }
    

    爲了便於大家理解,我畫了圖(一言不合就上圖),大家對照着代碼和圖看,上面代碼主要有二個線程,主線程和 ThreadA 線程,主線程創建ThreadA並啓動ThreadA線程,然後調用threadA.join() 會導致主線程阻塞,直到ThreadA 線程執行結束 isActive 變爲 false,主線程恢復繼續執行。

    join()

  • interrupt:線程中斷,調用interrupt 方法中斷一個線程,是希望給這個線程一個通知信號,會改變線程內部的一箇中斷標識位,線程本身並不會因爲中斷而改變狀態(如阻塞、終止等)。調用interrupt 方法有二種情況:

    1. 如果當前線程正處於 Running 狀態,interrupt( ) 只會改變中斷標識位,不會真的中斷正在運行的線程;
    2. 如果線程當前處於 Timed_Waiting 狀態,interrupt( ) 會讓線程拋出 InterruptedException。

    所以我們在編寫多線程程序時,優雅關閉線程需要同時處理這二種情況,常規寫法是:

    public static class ThreadInterrupt implements Runnable{
    
      @Override
      public void run() {
        //1. 非阻塞狀態,通過檢查中斷標識位退出
        while(!Thread.currentThread().isInterrupted()){
          try{
            //doSomething()
            Thread.sleep(1000);
          } catch (InterruptedException e) {
            //2. 阻塞狀態,捕獲中斷異常,break 退出
            e.printStackTrace();
            break;
          }
        }
      }
    }
    
  • notify:notify方法和wait方法一樣,也是Object 類中的方法,notify方法用於喚醒在此對象監視器上等待的單個線程,如果有多個線程在此對象監視器上等待,選擇其中一個進行喚醒。另外要注意一點的是,當前線程喚醒等待線程後不會立即釋放鎖,而是當前線程執行結束纔會釋放鎖,因此被喚醒的線程不是說喚醒之後立即就可以開始執行,而是要等到喚醒的線程執行結束,獲得對象鎖之後開始執行。上代碼吧。

    public static void main(String[] args) {
      new Thread(new ThreadA()).start();
      try {
        Thread.sleep(1000);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      new Thread(new ThreadB()).start();
    }
    
    private static final Object lock = new Object();
    
    private static class ThreadA implements Runnable{
      @Override
      public void run() {
        synchronized (lock){
          System.out.println("Thread-A 進入狀態 running...");
    
          try {
            System.out.println("Thread-A 進入狀態 waiting...");
            lock.wait();
    
            System.out.println("Thread-A 進入狀態 running...");
          } catch (InterruptedException e) {
            e.printStackTrace();
          }
        }
        System.out.println("Thread-A 執行完畢, 進入狀態 terminated...");
      }
    }
    
    private static class ThreadB implements Runnable{
    
      @Override
      public void run() {
        synchronized (lock){
          System.out.println("Thread-B 進入狀態 running...");
          try {
            System.out.println("Thread-B 進入狀態 time_waiting...");
            Thread.sleep(3000);
    
            System.out.println("Thread-B 進入狀態 running...");
    
            lock.notify();
            System.out.println("Thread-B 進入狀態 time_waiting...");
            Thread.sleep(5000);
            System.out.println("Thread-B 進入狀態 running...");
          } catch (InterruptedException e) {
            e.printStackTrace();
          }
        }
        System.out.println("Thread-B 執行完畢, 進入狀態 terminated...");
      }
    }
    

    控制檯輸出:

    Thread-A 進入狀態 running...
    Thread-A 進入狀態 waiting...
    Thread-B 進入狀態 running...
    Thread-B 進入狀態 time_waiting...
    Thread-B 進入狀態 running...
    Thread-B 進入狀態 time_waiting...
    Thread-B 進入狀態 running...
    Thread-B 執行完畢, 進入狀態 terminated...
    Thread-A 進入狀態 running...
    Thread-A 執行完畢, 進入狀態 terminated...
    

    可以看到B 線程調用 lock.notify() 之後A 線程沒有立即開始執行,而是等到B 線程執行結束後纔開始執行,所以lock.notify() 喚醒 A 線程只是讓 A 線程進入預備執行的狀態,而不是直接進 Running 狀態,B 線程調 notify 沒有立即釋放對象鎖。

    鑑於篇幅原因,此篇也是基礎篇,知識部分就到此爲止,接下來是一些常規的線程面試題。

第一題:關閉線程的方式有哪幾種?哪種方式最可取?(美團一面面試題)
  1. 使用退出標識位;

    public class ThreadSafe extends Thread { 
      public volatile boolean exit = false;
      public void run() { 
        while (!exit){
      		//do something 
        }
      }
    }
    
  2. 調用 interrupt 方法,這種是最可取的,但是要考慮到處理二種情況;

  3. stop 方法,這種屬於強行終止,非常危險。就像直接給線程斷電,調用thread.stop() 方法時,會釋放子線程持有的所有鎖,這種突然的釋放可能會導致數據不一致,因此不推薦使用這種方式終止線程。

第二題:很多面試會問wait 和sleep 的區別?(比心一面面試題)

主要有以下3點:

  1. sleep 方法讓線程進入 Timed_Waiting 狀態,sleep 方法必須傳入時間參數,會讓當前線程掛起一段時間,過了這個時間會恢復到runnable 狀態(取決於系統計時器和調度程序的精度和準確性)。而wait 方法會讓當前線程進入Waiting 狀態,會一直阻塞,直到別的線程調用 notify 或者 notifyAll 方法喚醒。
  2. wait 是Object 類中的方法,sleep 是Thread 類中的方法,理解這點很重要,wait方法跟對象綁定的,調用wait方法會釋放wait 關聯的對象鎖;
  3. 如果在同步代碼塊,當前線程持有鎖,執行到wait 方法會釋放對象鎖,sleep 只是單純休眠,不會釋放鎖;

我們看個代碼鞏固一下:

public static void main(String[] args) {
  new Thread(new ThreadA()).start();
  try {
    Thread.sleep(1000);
  } catch (InterruptedException e) {
    e.printStackTrace();
  }
  new Thread(new ThreadB()).start();
}

private static final Object lock = new Object();

private static class ThreadA implements Runnable{
  @Override
  public void run() {
    synchronized (lock){
      System.out.println("Thread-A 進入狀態 running...");

      try {
        System.out.println("Thread-A 進入狀態 waiting...");
        lock.wait();

        System.out.println("Thread-A 進入狀態 running...");
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }
    System.out.println("Thread-A 執行完畢, 進入狀態 terminated...");
  }
}

private static class ThreadB implements Runnable{

  @Override
  public void run() {
    synchronized (lock){
      System.out.println("Thread-B 進入狀態 running...");
      try {
        System.out.println("Thread-B 進入狀態 time_waiting...");
        Thread.sleep(3000);

        System.out.println("Thread-B 進入狀態 running...");

        lock.notify();
        System.out.println("Thread-B 進入狀態 time_waiting...");
        Thread.sleep(5000);
        System.out.println("Thread-B 進入狀態 running...");
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }
    System.out.println("Thread-B 執行完畢, 進入狀態 terminated...");
  }
}

這裏我建議大家先不急着看控制檯輸出,根據自己經驗猜測一下輸出應該是怎樣的,然後對比輸出,這樣對比能看是否有偏差。另外我建議大家有條件,把本篇文章的示例程序拷貝到本地,實際看下運行。

控制檯輸出如下:

Thread-A 進入狀態 running...
Thread-A 進入狀態 waiting...
Thread-B 進入狀態 running...
Thread-B 進入狀態 time_waiting...
Thread-B 進入狀態 running...
Thread-B 進入狀態 time_waiting...
Thread-B 進入狀態 running...
Thread-B 執行完畢, 進入狀態 terminated...
Thread-A 進入狀態 running...
Thread-A 執行完畢, 進入狀態 terminated...
第三題:手寫一個死鎖的例子?(美團二面面試題)
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();

public static class DeadLockSample implements Runnable{
  Object[] locks;

  public DeadLockSample(Object lock1, Object lock2){
    locks = new Object[2];
    locks[0] = lock1;
    locks[1] = lock2;
  }

  @Override
  public void run() {
    synchronized (lock1) {
      try {
        Thread.sleep(3000);
        synchronized (lock2) {
          System.out.println(String.format("%s come in...", Thread.currentThread().getName()));
        }
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }
  }
}

public static void main(String[] args) {
  Thread a = new Thread(new DeadLockSample(lock1, lock2));
  Thread b = new Thread(new DeadLockSample(lock2, lock1));

  a.start();
  b.start();
}
第四題:寫一個通過線程wait / notify通信的生產者消費者代碼?(聲網四面面試題)
static class MangoIce{
        int counter;

        public MangoIce(int counter) {
            this.counter = counter;
        }
    }

    static class Producer implements Runnable
    {
        private final List<MangoIce> barCounter;
        private final int           MAX_CAPACITY;

        public Producer(List<MangoIce> sharedQueue, int size)
        {
            this.barCounter = sharedQueue;
            this.MAX_CAPACITY = size;
        }

        @Override
        public void run()
        {
            int counter = 1;
            while (!Thread.currentThread().isInterrupted())
            {
                try
                {
                    produce(counter++);
                }
                catch (InterruptedException ex)
                {
                    ex.printStackTrace();
                    break;
                }
            }
        }

        private void produce(int i) throws InterruptedException
        {
            synchronized (barCounter)
            {
                while (barCounter.size() == MAX_CAPACITY)
                {
                    System.out.println("吧檯滿了,冰沙放不下 " + Thread.currentThread().getName() + " 線程等待,當前吧檯冰沙數: " + barCounter.size());
                    barCounter.wait();
                }

                Thread.sleep(1000);
                barCounter.add(new MangoIce(i));
                System.out.println("生產第: " + i + "杯冰沙...");
                barCounter.notifyAll();
            }
        }
    }

    static class Consumer implements Runnable
    {
        private final List<MangoIce> barCounter;

        public Consumer(List<MangoIce> sharedQueue)
        {
            this.barCounter = sharedQueue;
        }

        @Override
        public void run()
        {
            while (!Thread.currentThread().isInterrupted())
            {
                try
                {
                    consume();
                } catch (InterruptedException ex)
                {
                    ex.printStackTrace();
                    break;
                }
            }
        }

        private void consume() throws InterruptedException
        {
            synchronized (barCounter)
            {
                while (barCounter.isEmpty())
                {
                    System.out.println("吧檯空的,沒有冰沙 " + Thread.currentThread().getName() + " 消費者線程等待,當前吧檯冰沙數: " + barCounter.size());
                    barCounter.wait();
                }
                Thread.sleep(1000);
                MangoIce i = barCounter.remove(0);
                System.out.println("消費第: " + i.counter + "杯冰沙...");
                barCounter.notifyAll();
            }
        }
    }

    public static void main(String[] args)
    {
        List<MangoIce> taskQueue = new ArrayList<>();
        int MAX_CAPACITY = 5;
        Thread tProducer = new Thread(new Producer(taskQueue, MAX_CAPACITY), "生產者");
        Thread tConsumer = new Thread(new Consumer(taskQueue), "消費者");
        tProducer.start();
        tConsumer.start();
    }

控制檯輸出

生產第: 1杯冰沙...
生產第: 2杯冰沙...
生產第: 3杯冰沙...
生產第: 4杯冰沙...
生產第: 5杯冰沙...
吧檯滿了,冰沙放不下 生產者 線程等待,當前吧檯冰沙數: 5
消費第: 1杯冰沙...
消費第: 2杯冰沙...
消費第: 3杯冰沙...
消費第: 4杯冰沙...
消費第: 5杯冰沙...
吧檯空的,沒有冰沙 消費者 消費者線程等待,當前吧檯冰沙數: 0
生產第: 6杯冰沙...
生產第: 7杯冰沙...
生產第: 8杯冰沙...
生產第: 9杯冰沙...
生產第: 10杯冰沙...
吧檯滿了,冰沙放不下 生產者 線程等待,當前吧檯冰沙數: 5
消費第: 6杯冰沙...
消費第: 7杯冰沙...

後面幾期會分別講以下內容,順序還沒定,大概會按照讀者羣的反饋來。

  • 線程上下文切換、JAVA鎖
  • 線程池實戰、Fork / Join
  • 併發工具 CyclicBarrier、CountDownLatch、Semaphore的實際使用場景
  • synchronized、volatile、Atomic*** 涉及的原子性、內存可見性和指令重排序原理
  • AQS、ReentrantLock原理、以及和synchronized區別、CAS原理
  • 阻塞隊列、線程調度
    歡迎關注Wx 公衆號【安琪拉的博客】查看後續內容更新

參考:How to work with wait(), notify() and notifyAll() in Java?

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