從執行緒到併發程式設計

weixin_33807284發表於2019-01-10

什麼是執行緒

說起執行緒,還是得從程式說起。那麼程式是什麼呢?現代作業系統在執行一個程式時,會為其建立一個程式。比如你電腦上開啟個QQ或者是啟動一個Java程式,作業系統都會為其建立一個程式。而執行緒是作業系統的最小排程單元,一個程式中可以有多個執行緒。OS排程會讓多個執行緒之間高速切換,讓我們以為是多個執行緒在同時執行。

執行緒的建立與銷燬

執行緒的建立

那麼怎麼去建立一個執行緒呢。在Java中我們可以有以下三種方式來建立執行緒:

  1. 繼承Thread類,重寫run方法。

    public class ThreadDemo1 extends Thread {
    
        @Override
        public void run() {
            System.out.println("extends thread run");
        }
    
        public static void main(String[] args) {
            ThreadDemo1 thread1 = new ThreadDemo1();
            ThreadDemo1 thread2 = new ThreadDemo1();
            thread1.start();
            thread2.start();
        }
    }
    
  2. 實現Runnable介面,重寫run方法。

    public class ThreadDemo2 implements Runnable{
    
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + " implements runnable run");
        }
    
        public static void main(String[] args) {
            new Thread(new ThreadDemo2(), "thread1").start();
            new Thread(new ThreadDemo2(), "thread2").start();
        }
    }
    
  3. 實現Callable介面,重寫call方法,實現帶返回值的執行緒。

    public class ThreadDemo3 implements Callable<String> {
    
        public static void main(String[] args) throws ExecutionException, InterruptedException {
            ExecutorService executorService = newFixedThreadPool(1);
            ThreadDemo3 thread = new ThreadDemo3();
            Future<String> future = executorService.submit(thread);
            System.out.println(future.get());
            executorService.shutdown();
        }
    
        @Override
        public String call() throws Exception {
            System.out.println(Thread.currentThread().getName() + " implements callable");
            return Thread.currentThread().getName();
        }
    }
    

終止執行緒

  1. interrupt中斷標誌

    前面看完了如何建立一個執行緒,那麼又怎麼去終止一個執行緒呢。以前的Thread類中有個stop方法可以用來終止執行緒,而現在已經被標記過期了,其實也不建議使用stop方法來終止執行緒,為什麼呢!因為我想用過Linux系統的都知道kill -9吧,stop方法與其類似,stop方法會強制殺死執行緒,而不管執行緒中的任務是否執行完畢。那麼我們如何更加優雅的去終止一個執行緒呢。

    這裡Thread類為我們提供了一個interrupt方法。

    當我們需要終止一個執行緒,可以呼叫它的interrupt方法,相當於告訴這個執行緒你可以終止了,而不是暴力的殺死該執行緒,執行緒會自行中斷,我們可以使用isInterrupted方法來判斷執行緒是否已經終止了:這段程式碼可以測試到,如果interrupt方法無法終止執行緒,那麼這個執行緒將會是死迴圈,而無法結束。這裡使用interrupt以一種更加安全中斷執行緒。

    public class InterruptDemo {
        private static int i;
        public static void main(String[] args) throws InterruptedException {
            Thread thread = new Thread(() -> {
                while(!Thread.currentThread().isInterrupted()){
                    i++;
                }
                System.out.println("result: " + i);
            }, "interrupt-test");
            thread.start();
            TimeUnit.SECONDS.sleep(2);
            thread.interrupt();
        }
    }
    
  2. volatile共享變數作為中斷標誌

    這裡先不介紹volatile的記憶體語義以及原理,它可以解決共享變數的記憶體可見性問題。使其他執行緒可以及時看到被volatile變數修飾的共享變數的變更。所以我們也可以使用volatile來達到中斷執行緒的目的。

    public class VolatileDemo {
    
        private volatile static boolean flag = false;
        public static void main(String[] args) throws InterruptedException {
    
            Thread thread = new Thread(() -> {
                long i = 0L;
                while (!flag) {
                    i++;
                }
                System.out.println(i);
            }, "volatile-demo");
            thread.start();
            System.out.println("volatile-demo is start");
            Thread.sleep(1000);
            flag = true;
        }
    }
    

    比如上面示例中的程式碼,我們可以控制在特定的地方,改變共享變數,來達到讓執行緒退出。

執行緒復位

  • interrupted

    前面說了使用interrupt可以告訴執行緒可以中斷了,執行緒同時也提供了另外一個方法即Thread.interrupted()可以已經設定過中斷標誌的執行緒進行復位。

    public class InterruptDemo {
        public static void main(String[] args) throws InterruptedException {
            Thread thread = new Thread(() -> {
                while (true) {
                    boolean isInterrupted = Thread.currentThread().isInterrupted();
                    if(isInterrupted){
                        System.out.println("before: " + isInterrupted);
                        Thread.interrupted(); // 對執行緒進行復位,中斷標識為false
                        System.out.println("after: " + Thread.currentThread().isInterrupted());
                    }
                }
            }, "InterruptDemo");
            thread.start();
            TimeUnit.SECONDS.sleep(1);
            thread.interrupt(); // 設定中斷標識為true
        }
    }
    

    輸出結果:

    before: true
    after: false
    

    通過demo可以看到執行緒確實是先被設定了中斷標識,後又被複位。

  • 異常復位

    除了使用interupted來設定中斷復位,還有一種情況,就是對丟擲InterruptedException異常的方法,在 InterruptedExceptio丟擲之前,JVM會先把執行緒的中斷標識位清除,然後才會丟擲 InterruptedException,這個時候如果呼叫isInterrupted方法,將會返回false,例如:

    public class InterruptDemo {
        public static void main(String[] args) throws InterruptedException {
            Thread thread = new Thread(() -> {
                while (true) {
                    try {
                        Thread.sleep(10000);
                    } catch (InterruptedException e) {
                        // 丟擲InterruptedException會將復位標識設定為false
                        e.printStackTrace();
    
                    }
                }
            }, "InterruptDemo");
            thread.start();
            TimeUnit.SECONDS.sleep(1);
            thread.interrupt(); // 設定中斷標誌為true
            TimeUnit.SECONDS.sleep(1);
            System.out.println(thread.isInterrupted()); 
        }
    }
    

    輸出結果:

    java.lang.InterruptedException: sleep interrupted
      at java.lang.Thread.sleep(Native Method)
      at top.felixu.chapter1.lifecycle.InterruptDemo.lambda$main$0(InterruptDemo.java:48)
      at java.lang.Thread.run(Thread.java:748)
    false
    

    通過例子可以看到,在丟擲異常之後,isInterrupted確實是又變成了false

為什麼要併發程式設計

單執行緒有時候也可以解決問題啊,那麼我們為什麼還要併發程式設計呢,很大程度上是因為更好的利用CPU資源,提升我們系統的效能。根據摩爾定律(當價格不變時,積體電路上可容納的元器件的數目,約每隔18-24個月便會增加一倍,效能也將提升一倍。換言之,每一美元所能買到的電腦效能,將每隔18-24個月翻一倍以上。這一定律揭示了資訊科技進步的速度。)推算,不久就會有超強的計算能力,然而,事情並未像預料的那樣發展。2004年,Intel宣佈4GHz晶片的計劃推遲到2005年,然後在2004年秋季,Intel宣佈徹底取消4GHz的計劃。現在雖然有4GHz的晶片但頻率極限已逼近,而且近10年停留在4GHz,也就是摩爾定律應該是失效了。既然單核CPU的計算能力短期無法提升了,多核CPU在此時應運而生。單執行緒畢竟只可能跑在一個核心上,浪費了CPU的資源,從而催生了併發程式設計,併發程式設計是為了發揮出多核CPU的計算能力,提升效能。

頂級電腦科學家Donald Ervin Knuth如此評價這種情況:在我看來,這種現象(併發)或多或少是由於硬體設計者無計可施了導致的,他們將摩爾定律的責任推給了軟體開發者。

併發程式設計總結起來說大致有以下優點:

  • 充分利用CPU,提高計算能力。

  • 方便對業務的拆分。比如一個購物流程,我們可以拆分成下單,減庫存等,利用多執行緒來加快響應。

  • 對於需要阻塞的場景,可以非同步處理,來減少阻塞。

  • 對於執行效能,可以通過多執行緒平行計算。

併發程式設計有哪些問題

看起來好像多執行緒確實很好,那麼我們就可以儘量多的去開執行緒了嘛。也並不是這樣的,多執行緒的效能也受多方面因素所影響:

  • 時間片的切換

    時間片是CPU分配給執行緒執行的時間,即便是單核CPU也是可以通過時間片的切換使多個執行緒切換執行,讓我們覺得是多個執行緒在同時執行,因為時間片的切換是非常快的,我們感覺不到的。每次切換執行緒是需要時間的,而且切換的時候需要儲存當前執行緒的狀態,以便切換回來的時候可以繼續執行。所以當執行緒較多的時候,切換時間片所帶來的消耗也同樣可觀。那麼有沒有什麼姿勢可以解決這個問題呢,是有的:

    • 無鎖併發程式設計:多執行緒在競爭鎖時會引起上下文的切換,可以使用對資料Hash取模分段的思想來避免使用鎖。
    • CAS演算法:可以使用Atomic包中相關原子操作,來避免使用鎖。
    • 使用最少執行緒:根據業務需求建立執行緒數,過多的建立執行緒會造成執行緒閒置和資源浪費。
    • 協程:在單執行緒裡實現多工的排程,並在單執行緒裡維持多個任務間的切換。
  • 死鎖

    為了保證多執行緒的正確性,很多時候,我們都會使用鎖,它是一個很好用的工具,然而在一些時候,不正確的姿勢會造成死鎖問題,進而引發系統不可用。下面我們就來看一個死鎖案例:

    public class DeadLockDemo {
    
        public static void main(String[] args) {
            new DeadLockDemo().deadLock();
        }
    
        private void deadLock() {
            Object o1 = new Object();
            Object o2 = new Object();
            Thread one = new Thread(() -> {
                synchronized (o1) {
                    try {
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (o2) {
                        System.out.println(Thread.currentThread().getName());
                    }
                }
            }, "thread-one");
    
            Thread two = new Thread(() -> {
                synchronized (o2) {
                    synchronized (o1) {
                        System.out.println(Thread.currentThread().getName());
                    }
                }
            }, "thread-two");
    
            one.start();
            two.start();
        }
    }
    

    執行之後便會發現程式無法終止了,那麼究竟發生了什麼呢?我們通過jps命令來檢視一下當前JavaPID

    $ jps
    1483 DeadLockDemo
    

    可以看到當前的程式PID1483(每個人的都不一樣,得自己執行哦),接下來我們使用jstack命令dump出當前程式的執行緒資訊,看一下究竟發生了什麼。

     jstack 1483
    . . . . . .省略部分資訊
    "thread-two" #12 prio=5 os_prio=31 tid=0x00007fbba9956800 nid=0x5603 waiting for monitor entry [0x0000700011058000]
       java.lang.Thread.State: BLOCKED (on object monitor)
            at top.felixu.section1.deadlock.DeadLockDemo.lambda$deadLock$1(DeadLockDemo.java:32)
            - waiting to lock <0x000000076ada81b8> (a java.lang.Object)
            - locked <0x000000076ada81c8> (a java.lang.Object)
            at top.felixu.section1.deadlock.DeadLockDemo$$Lambda$2/381259350.run(Unknown Source)
            at java.lang.Thread.run(Thread.java:748)
    
    "thread-one" #11 prio=5 os_prio=31 tid=0x00007fbba8033800 nid=0xa803 waiting for monitor entry [0x0000700010f55000]
       java.lang.Thread.State: BLOCKED (on object monitor)
            at top.felixu.section1.deadlock.DeadLockDemo.lambda$deadLock$0(DeadLockDemo.java:24)
            - waiting to lock <0x000000076ada81c8> (a java.lang.Object)
            - locked <0x000000076ada81b8> (a java.lang.Object)
            at top.felixu.section1.deadlock.DeadLockDemo$$Lambda$1/1607521710.run(Unknown Source)
            at java.lang.Thread.run(Thread.java:748)
    . . . . . .省略部分資訊
    Found one Java-level deadlock:
    =============================
    "thread-two":
      waiting to lock monitor 0x00007fbba9006eb8 (object 0x000000076ada81b8, a java.lang.Object),
      which is held by "thread-one"
    "thread-one":
      waiting to lock monitor 0x00007fbba90082a8 (object 0x000000076ada81c8, a java.lang.Object),
      which is held by "thread-two"
    
    Java stack information for the threads listed above:
    ===================================================
    "thread-two":
            at top.felixu.section1.deadlock.DeadLockDemo.lambda$deadLock$1(DeadLockDemo.java:32)
            - waiting to lock <0x000000076ada81b8> (a java.lang.Object)
            - locked <0x000000076ada81c8> (a java.lang.Object)
            at top.felixu.section1.deadlock.DeadLockDemo$$Lambda$2/381259350.run(Unknown Source)
            at java.lang.Thread.run(Thread.java:748)
    "thread-one":
            at top.felixu.section1.deadlock.DeadLockDemo.lambda$deadLock$0(DeadLockDemo.java:24)
            - waiting to lock <0x000000076ada81c8> (a java.lang.Object)
            - locked <0x000000076ada81b8> (a java.lang.Object)
            at top.felixu.section1.deadlock.DeadLockDemo$$Lambda$1/1607521710.run(Unknown Source)
            at java.lang.Thread.run(Thread.java:748)
    
    Found 1 deadlock.
    
    

    從上面來看,兩個執行緒都是阻塞狀態,都在等待別的執行緒釋放鎖,但是永遠都等不到,從而形成了死鎖。那麼平常開發過程中儘量按以下操作來避免不必要的死鎖(當然有時候不注意還是會莫名死鎖,得dump資訊加以分析才能找出問題的):

    • 避免一個執行緒同時獲取多個鎖。
    • 儘量避免一個執行緒在鎖內同時獲取多個資源,儘量保證每個鎖內只佔有一個資源。
    • 嘗試使用定時鎖,使用lock.tryLock(timeout)來替代使用內部鎖機制。
    • 對於資料庫鎖,加鎖和解鎖必須在一個資料庫連線裡,否則會出現解鎖失敗的情況。
  • 軟體和硬體資源的限制

    程式跑在伺服器上,必然受到伺服器等方面的限制。

    • 硬體資源限制:一般指磁碟讀寫速度、頻寬、CPU效能等方面
    • 軟體資源限制:一般指資料庫連線數、Socket連線數等方面

所以,如何合理的使用執行緒需要我們在實踐中具體去分析。

參考自《Java併發程式設計的藝術》

相關文章