多執行緒核心技術(1)-執行緒的基本方法

ALivn發表於2019-04-13

多執行緒核心技術(1)-執行緒的基本方法

程式和執行緒

  瞭解多執行緒首先要了解程式和執行緒的概念,在作業系統裡,程式是資源分配最小單位,一般情況下一個應用就會在計算機系統內開啟一個程式,執行緒可以理解為程式中多個獨立執行的子任務,是作業系統能夠進行排程運算的最小單位,但是執行緒不擁有資源,只能共享程式中的資料,所以多個執行緒對程式中某個資料同時進行修改時,就會產生執行緒安全問題。由於一個程式中允許存在多個執行緒,所以在多執行緒中,如何處理執行緒併發和執行緒之間通訊的問題,是學習多執行緒程式設計的重點。
複製程式碼

多執行緒的使用

​ 在java中,建立一個執行緒一般有兩種方式,繼承Thread類或者實現Runable介面,重寫run方法即可,然後呼叫start()方法即可以開啟一個執行緒並執行。如果想要獲取當前執行緒執行返回值,在jdk1.5以後,可以通過實現Callable介面,然後藉助FutureTask或者執行緒池得到返回值。由於執行緒的執行具有隨機性,所以執行緒的開啟順序並不意味執行緒的執行順序。

1、繼承Thread建立一個執行緒
/**
 * 繼承Thread 重寫Run方法
 * 類是單繼承的,生產環境中如果此類無需實現其他介面 可使用這種方法建立執行緒
 * User: lijinpeng
 * Created by Shanghai on 2019/4/13.
 */
@Slf4j
public class MyThread extends Thread {
    public MyThread(String name) {
        super(name);
    }
    @Override
    public void run() {
        log.info("Hi,I am a thread extends Thread,My name is:{}", this.getName());
    }
}

複製程式碼
2、實現Runable介面建立一個執行緒

/**
 * 實現Runnable介面
 * 類允許有多個介面實現 生產中一般使用這種方式建立執行緒
 * 執行緒的開啟還需要藉助於Thread實現
 * User: lijinpeng
 * Created by Shanghai on 2019/4/13.
 */
@Slf4j
@Getter
public class ThreadRunable implements Runnable {

    private String name;

    public ThreadRunable(String name) {
        this.name = name;
    }

    public void run() {
        log.info("Hi,I am a thread implements Runnable,My name is:{}", this.getName());
    }
}
複製程式碼
3、實現Callable 介面 獲取執行緒執行結果
/**
 * 實現Callable介面建立獲取具有返回值的執行緒
 * 執行緒使用需要藉助FutureTask和Thread,或者使用執行緒池
 * User: lijinpeng
 * Created by Shanghai on 2019/4/13.
 */
@Slf4j
public class CallableThread implements Callable<Integer> {

    private AtomicInteger seed;
    @Getter
    private String name;

    public CallableThread(String name, AtomicInteger seed) {
        this.name = name;
        this.seed = seed;
    }

    public Integer call() throws Exception {
        //使用併發安全的原子類生成一個整數
        Integer value = seed.getAndIncrement();
        log.info("I am thread implements Callable,my name is:{} my value is:{}", this.name, value);
        return value;
    }
}
複製程式碼
4、驗證三種執行緒的啟動

/**
 * User: lijinpeng
 * Created by Shanghai on 2019/4/13.
 */
@Slf4j
public class ThreadTest {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
     threadTest();
     runableTest();
     callableTest();
    }

    public static void threadTest() {
        MyThread threadA = new MyThread("threadA");
        threadA.start();
    }

    public static void runableTest() {
        ThreadRunable runable = new ThreadRunable("threadB");
        //需要藉助Thread來開啟一個新的執行緒
        Thread threadB = new Thread(runable);
        threadB.start();
    }

    public static void callableTest() throws ExecutionException, InterruptedException {
        AtomicInteger atomic = new AtomicInteger();
        CallableThread threadC1 = new CallableThread("threadC1", atomic);
        CallableThread threadC2 = new CallableThread("threadC2", atomic);
        CallableThread threadC3 = new CallableThread("threadC3", atomic);
        FutureTask<Integer> task1 = new FutureTask<Integer>(threadC1);
        FutureTask<Integer> task2 = new FutureTask<Integer>(threadC2);
        FutureTask<Integer> task3 = new FutureTask<Integer>(threadC3);
        Thread thread1 = new Thread(task1);
        Thread thread2 = new Thread(task2);
        Thread thread3 = new Thread(task3);
        thread1.start();
        thread2.start();
        thread3.start();
        while (task1.isDone()&&task2.isDone()&&task3.isDone())
        {
        }
        log.info(threadC1.getName()+"執行結果:"+String.valueOf(task1.get()));
        log.info(threadC2.getName()+"執行結果:"+String.valueOf(task2.get()));
        log.info(threadC2.getName()+"執行結果:"+String.valueOf(task3.get()));
    }
}
複製程式碼

以下是程式執行結果:

多執行緒核心技術(1)-執行緒的基本方法

結論:

  1. 這三種方式都可以開啟一個執行緒,實現具體開啟執行緒的任務還是交給Thread類實現,因此對於Runable和Callable來說,最後都是要藉助於Thread開啟執行緒,類是單繼承的,介面是多實現的,由於生產環境業務複雜性,一個類可能會有其他功能,因此一般使用介面實現的方式。
  2. 從上面的執行緒宣告順序和執行順序結果來看,執行緒的執行是無序的,CPU執行任務是採用輪詢機制來提高CPU使用率,線上程獲取執行資源進行就緒佇列後 才會再次被CPU呼叫,而這個過程跟程式無關。

執行緒的生命週期

​ 一個執行緒的執行通常伴隨著執行緒的啟動、阻塞、停止等過程,執行緒啟動可以通過Thread類的start()方法執行,由於多執行緒可能會共享程式資料,阻塞一般發生在等待其他執行緒釋放程式某塊資源的過程,當執行緒執行完畢,可以自動停止,也可以通過呼叫stop()強制終止執行緒,或者線上程執行過程中由於異常導致執行緒終止,瞭解執行緒的生命週期是學習多執行緒最重要的理論基礎。

​ 下圖為執行緒的生命週期以及狀態轉換過程

多執行緒核心技術(1)-執行緒的基本方法

新建狀態

當通過Thread thead=new Thread()建立一個執行緒的時候,該執行緒就處於 new 狀態,也叫新建狀態。

就緒狀態

當呼叫thread.start()時,執行緒就進入了就緒狀態,在該狀態下執行緒並不會執行,只是表示執行緒進入可供CPU呼叫的就緒佇列,具備執行條件。

執行狀態

當執行緒獲得了JVM中執行緒排程器的排程時候,執行緒就進入執行狀態,會執行重寫的 run方法。

阻塞狀態

此時的執行緒仍處於活動狀態,但是由於某種原因失去了CPU對其排程權利,具體原因可分為以下幾種

  1. 同步阻塞

    此時由於執行緒A需要獲取程式的資源1,但是資源1被執行緒B所持有,必須等待執行緒B釋放資源1之後,該執行緒才會進入資源1的就緒執行緒池裡,獲取到資源1後,等待被CPU排程器排程再次執行。同步阻塞一般出現線上程等待某項資源的使用權利,在程式中使用鎖機制會產生同步阻塞。
    複製程式碼
  2. 等待阻塞

    當執行Thread類的wait() 和join()方法時,會造成當前執行緒的同步阻塞,wait()會使當前執行緒暫停執行,並且釋放所擁有的鎖,可以通該執行緒要等待的某個類(Object)的notify()或者notifyall()方法喚醒當前執行緒。join()方法會阻塞當前執行緒,直到執行緒執行完畢,可以通過join(time)指定等待的時間,然後喚醒執行緒。
    複製程式碼
  3. 其他阻塞

    呼叫sleep()方法主動放棄所佔用的CPU資源,這種方式不會釋放該執行緒所擁有的鎖,或者呼叫一個阻塞式IO方法、發出了I/O請求,進入這種阻塞狀態。被阻塞的執行緒會在合適的時候(阻塞解除後)重新進入就緒狀態,重新等待執行緒排程器再次排程它。
    複製程式碼
死亡狀態

​ 當執行緒執行完run方法時,就會自動終止或者處於死亡狀態,這是執行緒的正常死亡過程。或者通過顯示呼叫stop()終止執行緒,但不安全。還可以通過拋異常法終止執行緒。

例項變數與執行緒安全

多執行緒訪問程式資源

​ 在多執行緒任務應用中如果多個執行緒執行之間使用了程式的不同資源,即執行中不共享任何程式資源,各執行緒執行不受影響,且不會產生資料安全問題。如果多個執行緒共享了程式的某塊資源,會同時修改該塊資源資料,產生最終結果與預期結果不一致的情況,導致執行緒安全問題。如圖:

多執行緒核心技術(1)-執行緒的基本方法

主記憶體與工作記憶體

​ Java記憶體模型分為主記憶體,和工作記憶體。主記憶體是所有的執行緒所共享的,工作記憶體是每個執行緒自己有一個,不是共享的。每條執行緒還有自己的工作記憶體,執行緒的工作記憶體中儲存了被該執行緒使用到的變數的主記憶體副本拷貝。執行緒對變數的所有操作(讀取、賦值),都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。不同執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞均需要通過主記憶體來完成,執行緒、主記憶體、工作記憶體三者之間的互動關係如下圖:

多執行緒核心技術(1)-執行緒的基本方法

執行緒對主存的操作指令:lock,unlock,read,load,use,assign,store,write操作

  • read-load階段從主記憶體複製變數到當前工作記憶體

  • use和assign階段執行程式碼改變共享變數值

  • store和write階段用工作記憶體資料重新整理主存對應變數的值。

  • store and write執行時機

    1、java記憶體模型規定不允許read和load、store和write操作之一單獨出現,以上兩個操作必須按順序執行,沒必要    連續執行,也就是說read與load之間、store與write之間是可插入其他指令的。
    2、不允許一個執行緒丟棄它的最近的assign操作,即變數在工作記憶體中改變了之後必須把該變化同步回主記憶體。變數在當前執行緒中改變一次其實就是一次assign,而且不允許丟棄最近的assign,所以必定有一次store and write,又根據第一條read and load 和store and write 不能單一出現,所以有一次store and write 必定有一次 read and load,因此推斷出,變數在當前執行緒中每一次變化都會執行 read 、 load 、use 、assign、store、write
    3、volatile修飾變數,是在use和assign階段保證獲取到的變數永遠是跟主記憶體變數保持同步
    複製程式碼
    非執行緒安全問題

    ​ 在多執行緒環境下use和assign是多次出現的,但此操作並不是原子性的,也就是說線上程A執行了read和load從主記憶體 載入過變數C後,此時如果B執行緒修改了主記憶體中變數C的值,由於執行緒A已經載入過變數C,無法感知資料已經發生變化,即從執行緒A的角度來看,工作記憶體和主記憶體的變數A已經不再同步,當執行緒A使用use和assign時,就會出現非執行緒安全的問題。解決此問題可以通過使用volatile關鍵字修飾,volatile可以保證執行緒每次使用use和assign時,都從主記憶體中拿到最新的資料,而且可以防止指令重排,但volatile僅僅是保證變數的可見性,無法使資料載入的幾個步驟是原子操作,所以volatile並不能保證執行緒安全。

    如下程式碼所示:

    多個業務執行緒訪問使用者餘額balance,最終導致扣款總金額超過了使用者餘額,由執行緒不安全導致的資損情景.而且每個業務執行緒都扣款了兩次,也說明了執行緒啟動時需要將balance載入到工作記憶體中,之後該執行緒基於載入到的balance操作,其他執行緒如何改變balance值,對當前業務執行緒來說都是不可見的。

    /**
     * 業務訂單代扣執行緒 持續扣費
     * User: lijinpeng
     * Created by Shanghai on 2019/4/13.
     */
    @Slf4j
    public class WithHoldPayThread extends Thread {
        //繳費金額
        private Integer amt;
        //業務型別
        private String busiType;
    
        public WithHoldPayThread(Integer amt, String busiType) {
            this.amt = amt;
            this.busiType = busiType;
        }
    
        @Override
        public void run() {
            int payTime = 0;
            while (WithHodeTest.balance > 0) {
                synchronized (WithHodeTest.balance) {
                    boolean result = false;
                    if (WithHodeTest.balance >= amt) {
                        WithHodeTest.balance -= amt;
                        result = true;
                        payTime++;
                    }
                    log.info("業務:{} 扣款金額:{} 扣款狀態:{}", busiType, amt,result);
                }
            }
            log.info("業務:{} 共繳費:{} 次", busiType, payTime);
        }
    }
    複製程式碼

    測試函式

    /**
     * User: lijinpeng
     * Created by Shanghai on 2019/4/13.
     */
    public class WithHodeTest {
        //使用者餘額 單位 分
        public static volatile Integer balance=100;
    
        public static void main(String[] args) {
            WithHoldPayThread phoneFare = new WithHoldPayThread(50, "繳存話費");
            WithHoldPayThread waterFare = new WithHoldPayThread(50, "繳存水費");
            WithHoldPayThread electricFare = new WithHoldPayThread(50, "繳存電費");
            phoneFare.start();
            waterFare.start();
            electricFare.start();
        }
    }
    複製程式碼

    執行結果:

多執行緒核心技術(1)-執行緒的基本方法

實驗結果證明,每個執行緒的扣款都成功了,這就導致了執行緒安全問題,解決這個問題最簡單的做法是在run方法裡面加synchronized修飾,並且對balance使用volatile修飾就可以了。

   //使用者餘額 單位 分
    public static  volatile Integer balance=100;
複製程式碼
 @Override
    public void run() {
        int payTime = 0;
        while (WithHodeTest.balance > 0) {
            synchronized (WithHodeTest.balance) {
                boolean result = false;
                if (WithHodeTest.balance >= amt) {
                    WithHodeTest.balance -= amt;
                    result = true;
                    payTime++;
                }
                log.info("業務:{} 扣款金額:{} 扣款狀態:{}", busiType, amt,result);
            }
        }
        log.info("業務:{} 共繳費:{} 次", busiType, payTime);
    }
複製程式碼

執行結果:

多執行緒核心技術(1)-執行緒的基本方法

執行緒的基本API

執行緒的停止

相關文章