Java併發編程系列-(1) 併發編程基礎

1.併發編程基礎

1.1 基本概念

CPU核心與線程數關係

Java中通過多線程的手段來實現併發,對於單處理器機器上來講,宏觀上的多線程並行執行是通過CPU的調度來實現的,微觀上CPU在某個時刻只會運行一個線程。事實上,如果這些任務不存在阻塞,也就是程序中的某個任務因爲該程序控制範圍之外的某些條件(通常是I/O)而導致不能繼續執行,由於在任務之間切換會產生開銷,因此並行的效率可能沒有順序執行的效率高,並行也就沒有意義。

一般來講,CPU核心數和線程數的關係爲核心數:線程數=1:1;但是如果使用了超線程技術,可以達到1:2甚至更多。

CPU調度方式

CPU採用時間片輪轉機制,來調度不同的線程運行,又稱RR調度,注意這樣會導致上下文切換。如果線程數目過大,可能產生較大的線程切換開銷。

線程和進程

進程:進程是具有一定獨立功能的程序關於某個數據集合上的一次運行活動,是系統進行資源分配和調度的一個獨立單位。(包括程序段,相關數據段,和進程控制塊PCB)

線程:線程是進程的一個實體,是CPU調度和分派的基本單位,它是比進程更小的能獨立運行的基本單位。線程自己基本上不擁有系統資源,只擁有一點在運行中必不可少的資源(如程序計數器,一組寄存器和棧),但是它可與同屬一個進程的其他的線程共享進程所擁有的全部資源.

關係:一個線程可以創建和撤銷另一個線程;同一個進程中的多個線程之間可以併發執行.相對進程而言,線程是一個更加接近於執行體的概念,它可以與同進程中的其他線程共享數據,但擁有自己的棧空間,擁有獨立的執行序列。
  
區別:主要差別在於它們是不同的操作系統資源管理方式。進程有獨立的地址空間,一個進程崩潰後,在保護模式下不會對其它進程產生影響,而線程只是一個進程中的不同執行路徑。線程有自己的堆棧和局部變量,但線程之間沒有單獨的地址空間,一個線程死掉就等於整個進程死掉,所以多進程的程序要比多線程的程序健壯,但在進程切換時,耗費資源較大,效率要差一些。但對於一些要求同時進行並且又要共享某些變量的併發操作,只能用線程,不能用進程。

優缺點:線程和進程在使用上各有優缺點:線程執行開銷小,但不利於資源的管理和保護;而進程正相反。同時,線程適合於在SMP機器上運行,而進程則可以跨機器遷移。

並行和併發

並行:同一時刻,可以同時處理事情的能力。

併發:與單位時間相關,在單位時間內可以處理事情的能力。

高併發編程的意義和注意事項

意義和好處:充分利用cpu的資源、加快用戶響應的時間,程序模塊化,異步化
問題。

缺陷和注意事項:

  • 線程共享資源,存在衝突;
  • 容易導致死鎖;
  • 啓用太多的線程,會產生巨大的CPU和內存開銷,就有搞垮機器的可能。

1.2 線程的啓動與停止

線程的3種啓動方式

Java裏線程有3種啓動方式,或者換句話說有3種方式可以實現多線程,分別是:

  • 繼承Thread類
    /*繼承自Thread類*/
    private static class UseExtendsThread extends Thread {
        @Override
        public void run() {
            System.out.println("I am from the extends thread.");
        }
    }
  • 實現Runnable接口
    /*實現Runnable接口*/
    private static class UseRun implements Runnable{

        @Override
        public void run() {
            System.out.println("I am implements Runnable");
        }
        
    }
  • 實現Callable接口
    /*實現Callable接口,允許有返回值*/
    private static class UseCall implements Callable<String>{

        @Override
        public String call() throws Exception {
            System.out.println("I am implements Callable");
            return "I am the CallResult";
        }
        
    }   

Runnable和Callable的區別主要在於後者能夠返回值。

下面是main函數中的啓動方式,

    public static void main(String[] args) 
            throws InterruptedException, ExecutionException {
        UseExtendsThread useExtendsThread = new UseExtendsThread();
        useExtendsThread.start();
        
        UseRun useRun = new UseRun();
        new Thread(useRun).start();
        Thread t = new Thread(useRun);
        t.interrupt();
        
        UseCall useCall = new UseCall();
        FutureTask<String> futureTask = new FutureTask<>(useCall);
        new Thread(futureTask).start();
        System.out.println(futureTask.get());
    }

注意Callable接口一般與FutureTask結合使用。

線程的停止

在Java裏提供了stop(),suspend(),resume()方法,用來停止線程、掛起線程和恢復掛起的線程,但是這三個方法已不建議使用

  • 對於suspend()方法,在導致線程暫停的同時,並不釋放任何資源,若其他線程也想訪問它佔用的鎖時,也會受到影響導致無法運行。

  • 對於resume()方法,用於恢復被suspend掛起的程序,但是如果resume在suspend之前運行了,那就會導致掛起的線程繼續掛起,它佔用的鎖也不會被釋放,可能導致整個系統無法正常工作。

  • 對於stop()方法,會簡單粗暴的停止線程,可能導致線程無法正確釋放資源。

安全的停止線程-interrupt()、isInterrupted()、interrupted()

java線程是協作式,而非搶佔式。

  • 當調用一個線程的interrupt() 方法會中斷一個線程,但並不是強行關閉這個線程,只是跟這個線程打個招呼,將線程的中斷標誌位置爲true,線程是否中斷,由線程本身決定。
  • isInterrupted() 用於判定當前線程是否處於中斷狀態。
  • static方法interrupted() 判定當前線程是否處於中斷狀態,同時中斷標誌位改爲false。

方法裏如果拋出InterruptedException,線程的中斷標誌位會被複位成false,如果確實是需要中斷線程,要求我們自己在catch語句塊裏再次調用interrupt()。

在下面的例子裏,當主線程試圖中斷子線程時,sleep函數會拋出異常,清除掉中斷標誌位,爲了使線程中斷,我們需要重新調用interrupt()中斷線程。

public class HasInterrputException {
    
    private static class UseThread extends Thread{
        
        public UseThread(String name) {
            super(name);
        }
        
        @Override
        public void run() {
            String threadName = Thread.currentThread().getName();
            while(!isInterrupted()) {
                try {
                    System.out.println(threadName + " is running");
                    Thread.sleep(300);
                } catch (InterruptedException e) {
                    System.out.println(threadName+" catch interrput flag is "
                            +isInterrupted());
                    interrupt();
                    e.printStackTrace();
                }   
            }
            System.out.println(threadName+" interrput flag is "
                    +isInterrupted());
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread endThread = new UseThread("exampleThread");
        endThread.start();
        System.out.println("Main Thread sleep 800 ms");
        Thread.sleep(800);
        System.out.println("Main begin interrupt thread !");
        endThread.interrupt();
    }
}

1.3 線程狀態

線程可以有如下6種狀態:

  • New ( 新創建 )
  • Runnable ( 可運行 )
  • Blocked ( 被阻塞 )
  • Waiting ( 等待)
  • Timed waiting ( 計時等待 )
  • Terminated ( 被終止 )

要確定當前線程的狀態,可調用getState()方法。

新創建線程

當用new操作符創建一個新線程時,如newThread (r),該線程還沒有開始運行。這意味 着它的狀態是New,當一個線程處於新創建狀態時 程序還沒有開始運行線程中的代碼 在 線程運行之前還有一些基礎工作要做

可運行狀態

一旦調用start方法,線程處於runnable狀態。一個可運行的線桿可能正在運行也可能沒 有運行,這取決於操作系統給線程提供運行的時間( Java 的規範說明沒有將它作爲一個單獨狀態。)

阻塞、等待、計時等待狀態

  • 當一個線程試圖獲取一個內部的對象鎖(而不是 java.util.concurrent 庫中的鎖 而該鎖被其他線程持有,則該線程進人阻塞狀態。當所有其他線程釋放該鎖 並且線程調度器允許,本線程持有它的時候 該線程將變成非阻塞狀態。

  • 當線程等待另一個線程通知調度器一個條件時 它自己進入等待狀態。在調用 Object.wait方法或Thread.join方法或者是等待java.util.concurrent庫中的 Lock或Condition時,就會出現這種情況。注意,被阻塞狀態與等待狀態是有很大不同的。

  • 有幾個方法有一個超時參數。調用它們導致線程進人計時等待( timed waiting ) 狀態這一狀態將一直保持到超時期滿或者接收到適當的通知,帶有超時參數的方法有Thread.sleep和Object.wait、Thread.join、Lock, try Lock 以及 Condition.await的計時版。

終止狀態

線程因如下兩個原因之一而被終止:

  • 因爲run方法正常退出而自然死亡。
  • 因爲一個沒有捕獲的異常終止了run方法而意外死亡。

可以調用線程的stop方法殺死一個線程,該方法拋出ThreadDeath錯誤對象,由此殺死線程。

線程狀態之間的切換如下圖:

Screen Shot 2019-11-28 at 9.41.23 PM.png

1.4 線程屬性

線程優先級

Java中每個線程有一個優先級,默認情況下會繼承父線程的優先級。可以用setPriority方法來設定線程的優先級,優先級在MIN_PRIORITY(1)和MAX_PRIORITY(10)之間。

注意:優先級的實現高度依賴系統,Java的優先級會被映射到宿主機平臺的優先級上,因此有可能優先級變多或者變少,極端情況下,可能所有優先級映射到了宿主機的同一個優先級,因此不要過度依賴優先級。

優先級的設置不合理,可能導致低優先級的線程永遠無法運行。

守護線程

可以通過調用t.setDaemon(true)來將線程轉換爲守護線程。守護線程的唯一功能就是爲其他線程提供服務。當只剩下守護線程時,虛擬機會退出。

守護線程應該永遠不去訪問固有資源,如文件、數據庫等,因爲它可能在一個操作的中間發生中斷。

1.5 線程同步與共享

在大多數實際的多線程應用中,兩個或兩個以上的線程需要共享對同一數據的存取。如果多個線程之間不進行協調與同步,無法保證在訪問共享資源時的正確性。Java提供了一些用於線程共享的工具。

Synchronized內置鎖

synchronized主要有兩種用法,分別是

  • 對象鎖,鎖的是類的對象實例。
  • 類鎖,鎖的是每個類的的Class對象,每個類的的Class對象在一個虛擬機中只有一個,所以類鎖也只有一個。

具體來講,有如下幾種用法,

1. 修飾代碼塊

被修飾的代碼塊稱爲同步語句塊,其作用的範圍是大括號{}括起來的代碼,作用的對象是調用這個代碼塊的對象.

一個線程訪問一個對象中的synchronized(this)同步代碼塊時,其他試圖訪問該對象的線程將被阻塞。類似如下操作,

/**
 * 同步線程
 */
class SyncThread implements Runnable {
   private static int count;
   public SyncThread() {
      count = 0;
   }
   public  void run() {
      synchronized(this) {
         for (int i = 0; i < 5; i++) {
            try {
               System.out.println(Thread.currentThread().getName() + ":" + (count++));
               Thread.sleep(100);
            } catch (InterruptedException e) {
               e.printStackTrace();
            }
         }
      }
   }
   public int getCount() {
      return count;
   }
}

注意:當一個線程訪問對象的一個synchronized(this)同步代碼塊時,另一個線程仍然可以訪問該對象中的非synchronized(this)同步代碼塊。

2. 修飾一個方法

Synchronized修飾一個方法很簡單,就是在方法的前面加synchronized.修飾方法和修飾一個代碼塊類似,只是作用範圍不一樣,修飾代碼塊是大括號括起來的範圍,而修飾方法範圍是整個函數。

public synchronized void run() {
   for (int i = 0; i < 5; i ++) {
      try {
         System.out.println(Thread.currentThread().getName() + ":" + (count++));
         Thread.sleep(100);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
   }
}

雖然可以使用synchronized來定義方法,但synchronized並不屬於方法定義的一部分,因此,synchronized關鍵字不能被繼承。如果在父類中的某個方法使用了synchronized關鍵字,而在子類中覆蓋了這個方法,在子類中的這個方法默認情況下並不是同步的,而必須顯式地在子類的這個方法中加上synchronized關鍵字纔可以。

3. 修飾一個靜態的方法

Synchronized也可修飾一個靜態方法,用法如下:

public synchronized static void method() {
   // todo
}

4. 修飾一個類

Synchronized還可作用於一個類,用法如下:

class ClassName {
   public void method() {
      synchronized(ClassName.class) {
         // todo
      }
   }
}

synchronized作用於一個類T時,是給這個類T加鎖,T的所有對象用的是同一把鎖。

總結

A. 無論synchronized關鍵字加在方法上還是對象上,如果它作用的對象是非靜態的,則它取得的鎖是對象;如果synchronized作用的對象是一個靜態方法或一個類,則它取得的鎖是對類,該類所有的對象同一把鎖。
B. 每個對象只有一個鎖(lock)與之相關聯,誰拿到這個鎖誰就可以運行它所控制的那段代碼。
C. 實現同步是要很大的系統開銷作爲代價的,甚至可能造成死鎖,所以儘量避免無謂的同步控制。

Volatile變量

Java語言提供了一種稍弱的同步機制,即volatile變量,用來確保將變量的更新操作通知到其他線程。

當把變量聲明成volatile類型後,編譯器和運行時都會注意到這個變量是共享的,因此不會將改變量與其他內存操作一起重排序。volatile變量不會被緩存在寄存器或者其他處理器不可見的地方,因此在讀取volatile類型的變量時,總會返回最新寫入的值。

ThreadLocal

維護線程封閉性的一種更規範性的方法是ThreadLocal,這個類能使線程中的某個值與保存值的對象關聯起來。ThreadLocal提供了get與set等訪問接口與方法,這些方法爲使用該變量的每個線程都存有一份獨立的副本,因此get總是返回由當前執行線程在調用set時設置的最新值。

當某個線程初次調用ThreadLocal.get方法時,就會調用initialValue來獲取初始值。從概念上講,你可以將ThreadLocal視爲包含了Map<Thread, T>對象,其中保存了特定於該線程的值,但ThreadLocal的實現並非如此,這些特定的值保存在Thread對象中,當線程終止後,這些值會作爲垃圾回收。

具體使用可以參考下面的例子。

public class UseThreadLocal {
    
    //可以理解爲 一個map,類型 Map<Thread,Integer>
    static ThreadLocal<Integer> threadLaocl = new ThreadLocal<Integer>(){
        @Override
        protected Integer initialValue() {
            return 1;
        }
    };

    /**
     * 運行3個線程
     */
    public void StartThreadArray(){
        Thread[] runs = new Thread[3];
        for(int i=0;i<runs.length;i++){
            runs[i]=new Thread(new TestThread(i));
        }
        for(int i=0;i<runs.length;i++){
            runs[i].start();
        }
    }
    
    /**
     *類說明:測試線程,線程的工作是將ThreadLocal變量的值變化,並寫回,看看線程之間是否會互相影響
     */
    public static class TestThread implements Runnable{
        int id;
        public TestThread(int id){
            this.id = id;
        }
        public void run() {
            System.out.println(Thread.currentThread().getName()+":start");
            Integer s = threadLaocl.get();//獲得變量的值
            s = s+id;
            threadLaocl.set(s);
            System.out.println(Thread.currentThread().getName()+":"
            +threadLaocl.get());
            //threadLaocl.remove();
        }
    }

    public static void main(String[] args){
        UseThreadLocal test = new UseThreadLocal();
        test.StartThreadArray();
    }
}

1.6 線程間的協作

等待與通知wait、notify、notifyAll

JDK提供了wait、notify、notifyAll方法來進行多個線程之間的協作,注意這些方法是在Object類中的。

  • 調用Object.wait()方法後,當前線程會在這個對象上等待;一直等待到其他線程調用了該對象對應的notifyAll對象爲止。
  • 如果該對象上有多個線程調用了wait()方法,那樣爲了喚醒所有的線程,需要調用notifyAll()方法。

注意:一般wait和notifyAll配合使用,因爲當有多個線程調用wait後,會進入到該對象的等待隊列,如果調用notify,則只會從等待列表中隨機喚醒一個線程。可能並不是我們想要的結果。

以下是wait和notify、notifyAll方法的工作流程:

Screen Shot 2019-11-29 at 12.45.25 PM.png

以下是notify和wait的使用範例,

public class Express {
    public final static String CITY = "ShangHai";
    private int km;/*快遞運輸里程數*/
    private String site;/*快遞到達地點*/

    public Express() {
    }

    public Express(int km, String site) {
        this.km = km;
        this.site = site;
    }

    /* 變化公里數,然後通知處於wait狀態並需要處理公里數的線程進行業務處理*/
    public synchronized void changeKm(){
        this.km = 101;
        notifyAll();
//      notify();
    }

    /* 變化地點,然後通知處於wait狀態並需要處理地點的線程進行業務處理*/
    public synchronized void changeSite(){
        this.site = "BeiJing";
        notify();
    }

    public synchronized void waitKm(){
        while(this.km<=100) {
            try {
                System.out.println("check km thread["+Thread.currentThread().getId()
                        +"] is still waiting.");
                wait();
                System.out.println("check km thread["+Thread.currentThread().getId()
                        +"] is notified.");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public synchronized void waitSite(){
        while(CITY.equals(this.site)) {
            try {
                System.out.println("check site thread["+Thread.currentThread().getId()
                        +"] is still waiting.");
                wait();
                System.out.println("check site thread["+Thread.currentThread().getId()
                        +"] is notified.");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

在測試程序中,啓動了6個線程,當changeKm調用後,所有wait的線程被喚醒。但是由於城市未發生變化,因此檢查城市的線程在被喚醒後繼續等待。

public class TestWN {
    private static Express express = new Express(0,Express.CITY);

    /*檢查里程數變化的線程,不滿足條件,線程一直等待*/
    private static class CheckKm extends Thread{
        @Override
        public void run() {
            express.waitKm();
        }
    }

    /*檢查地點變化的線程,不滿足條件,線程一直等待*/
    private static class CheckSite extends Thread{
        @Override
        public void run() {
            express.waitSite();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        for(int i=0;i<3;i++){//三個線程
            new CheckSite().start();
        }
        for(int i=0;i<3;i++){//里程數的變化
            new CheckKm().start();
        }

        Thread.sleep(1000);
        express.changeKm();//快遞地點變化
    }
}

注意到調用wait和notify的方法都有synchronized關鍵字,因爲在調用這些方法之前,都需要獲得目標對象的監視器,執行完後會釋放這個監視器。當某個線程被喚醒時,第一件事是試圖獲取目標對象的監視器,如果獲取到了,則執行後續代碼,否則一直等待獲取監視器。

等待線程結束(join)和謙讓(yeild)

join方法

當一個線程的輸入可能非常依賴另一個線程或者多個線程的輸出,此時,這個線程需要等待依賴線程執行完畢才能繼續。JDK提供了join操作來實現這個功能,

比如在線程A裏,執行了線程B.join()方法,線程A必須要等待B執行完成了以後,線程A才能繼續自己的工作。

yeild方法

Thread.yield()是一個靜態方法,一旦執行,他會使當前線程讓出CPU,但是讓出CPU並不表示當前線程不執行。當前線程在讓出CPU之後,還會進行CPU資源的爭奪,但是是否能被分配就不一定。

yield() 、sleep()、wait()、notify()等方法對鎖有何影響

  • yield:讓出時間片,不會釋放鎖

  • sleep:線程進入睡眠狀態,不會釋放鎖

  • wait:必須拿到鎖才能執行,執行後釋放鎖,進入鎖的等待隊列,方法被notify返回後重新拿到鎖。

  • notify:必須拿到鎖才能執行,執行後不會立馬釋放鎖,而是通知等待隊列中的某一個線程,同步代碼塊執行完畢後纔會釋放鎖。本身是不會釋放鎖的。


本文由『後端精進之路』原創,首發於博客 http://teckee.github.io/ , 轉載請註明出處

搜索『後端精進之路』關注公衆號,立刻獲取最新文章和價值2000元的BATJ精品面試課程

後端精進之路.png

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