Java多執行緒超級詳解(只看這篇就夠了)

2022-08-11 15:05:20

多執行緒能夠提升程式效能,也屬於高薪必能核心技術棧,本篇會全面詳解Java多執行緒。@mikechen

主要包含如下幾點:

基本概念

很多人都對其中的一些概念不夠明確,如同步、並行等等,讓我們先建立一個資料字典,以免產生誤會。

程序

在作業系統中執行的程式就是程序,比如你的QQ、播放器、遊戲、IDE等等

執行緒

一個程序可以有多個執行緒,如視訊中同時聽聲音,看影象,看彈幕,等等。

多執行緒

多執行緒:多個執行緒並行執行。

同步

Java中的同步指的是通過人為的控制和排程,保證共用資源的多執行緒存取成為執行緒安全,來保證結果的準確。

比如:synchronized關鍵字,在保證結果準確的同時,提高效能,執行緒安全的優先順序高於效能。

並行


多個cpu範例或者多臺機器同時執行一段處理邏輯,是真正的同時。

並行

通過cpu排程演演算法,讓使用者看上去同時執行,實際上從cpu操作層面不是真正的同時。

並行往往在場景中有公用的資源,那麼針對這個公用的資源往往產生瓶頸,我們會用TPS或者QPS來反應這個系統的處理能力。

 

執行緒的生命週期

線上程的生命週期中,它要經過新建(New)、就緒(Runnable)、執行(Running)、阻塞(Blocked)和死亡(Dead)5種狀態

  • 新建狀態:當程式使用new關鍵字建立了一個執行緒之後,該執行緒就處於新建狀態,此時僅由JVM為其分配記憶體,並初始化其成員變數的值
  • 就緒狀態:當執行緒物件呼叫了start()方法之後,該執行緒處於就緒狀態。Java虛擬機器器會為其建立方法呼叫棧和程式計數器,等待排程執行
  • 執行狀態:如果處於就緒狀態的執行緒獲得了CPU,開始執行run()方法的執行緒執行體,則該執行緒處於執行狀態
  • 阻塞狀態:當處於執行狀態的執行緒失去所佔用資源之後,便進入阻塞狀態
  • 死亡狀態:執行緒在run()方法執行結束後進入死亡狀態。此外,如果執行緒執行了interrupt()或stop()方法,那麼它也會以異常退出的方式進入死亡狀態。

 

執行緒狀態的控制


可以對照上面的執行緒狀態流轉圖來看具體的方法,這樣更清楚具體作用:

1.start()

啟動當前執行緒, 呼叫當前執行緒的run()方法

2.run()

通常需要重寫Thread類中的此方法, 將建立的執行緒要執行的操作宣告在此方法中

3.yield()

釋放當前CPU的執行權

4.join()

線上程a中呼叫執行緒b的join(), 此時執行緒a進入阻塞狀態, 知道執行緒b完全執行完以後, 執行緒a才結束阻塞狀態

5.sleep(long militime)

讓執行緒睡眠指定的毫秒數,在指定時間內,執行緒是阻塞狀態

6.wait()

一旦執行此方法,當前執行緒就會進入阻塞,一旦執行wait()會釋放同步監視器。

7.sleep()和wait()的異同

相同點:兩個方法一旦執行,都可以讓執行緒進入阻塞狀態。

不同點:

1) 兩個方法宣告的位置不同:Thread類中宣告sleep(),Object類中宣告wait()

2) 呼叫要求不同:sleep()可以在任何需要的場景下呼叫。wait()必須在同步程式碼塊中呼叫。

2) 關於是否釋放同步監視器:如果兩個方法都使用在同步程式碼塊呵呵同步方法中,sleep不會釋放鎖,wait會釋放鎖。

8.notify()

一旦執行此方法,將會喚醒被wait的一個執行緒。如果有多個執行緒被wait,就喚醒優先度最高的。

9.notifyAll()

一旦執行此方法,就會喚醒所有被wait的執行緒 。

10.LockSupport

LockSupport.park()和LockSupport.unpark()實現執行緒的阻塞和喚醒的。

 

多執行緒的5種建立方式

1.繼承Thread類

package com.mikechen.java.multithread;



/**
* 多執行緒建立:繼承Thread
*
* @author mikechen
*/
class MyThread extends Thread {

    private int i = 0;


    @Override
    public void run() {
        for (i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + " " + i);
        }
    }


    public static void main(String[] args) {
        MyThread myThread=new MyThread();
        myThread.start();
    }


}

 

2.實現Runnable介面

package com.mikechen.java.multithread;


/**
* 多執行緒建立:實現Runnable介面
*
* @author mikechen
*/
public class MyRunnable implements Runnable {
    private int i = 0;

    @Override
    public void run() {
        for (i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + " " + i);
        }
    }


    public static void main(String[] args) {
        Runnable myRunnable = new MyRunnable(); // 建立一個Runnable實現類的物件
        Thread thread = new Thread(myRunnable); // 將myRunnable作為Thread target建立新的執行緒
        thread.start();
    }
}

 

3.執行緒池建立

執行緒池:其實就是一個可以容納多個執行緒的容器,其中的執行緒可以反覆的使用,省去了頻繁的建立執行緒物件的操作,無需反覆建立執行緒而消耗過多的系統資源。

package com.mikechen.java.multithread;


import java.util.concurrent.Executor;
import java.util.concurrent.Executors;



/**
* 多執行緒建立:執行緒池
*
* @author mikechen
*/
public class MyThreadPool {


        public static void main(String[] args) {
            //建立帶有5個執行緒的執行緒池
            //返回的實際上是ExecutorService,而ExecutorService是Executor的子介面
            Executor threadPool = Executors.newFixedThreadPool(5);
            for(int i = 0 ;i < 10 ; i++) {
                threadPool.execute(new Runnable() {
                    public void run() {
                        System.out.println(Thread.currentThread().getName()+" is running");
                    }
                });
            }



        }




}

 

核心引數

public ThreadPoolExecutor(
    int corePoolSize,
    int maximumPoolSize,
    long keepAliveTime,
    TimeUnit unit,
    BlockingQueue<Runnable> workQueue,
    ThreadFactory threadFactory,
    RejectedExecutionHandler handler
)
{
    ....
}

 

執行緒池任務執行流程

從上圖可以看出,提交任務之後,首先會嘗試著交給核心執行緒池中的執行緒來執行,但是必定核心執行緒池中的執行緒數有限,所以必須要由任務佇列來做一個快取,先將任務放佇列中快取,然後等待執行緒去執行。

最後,由於任務太多,佇列也滿了,這個時候執行緒池中剩下的執行緒就會啟動來幫助核心執行緒池執行任務。

如果還是沒有辦法正常處理新到的任務,則執行緒池只能將新提交的任務交給飽和策略來處理了。

4.匿名內部類

適用於建立啟動執行緒次數較少的環境,書寫更加簡便

package com.mikechen.java.multithread;



/**
* 多執行緒建立:匿名內部類
*
* @author mikechen
*/
public class MyThreadAnonymous {




        public static void main(String[] args) {
            //方式1:相當於繼承了Thread類,作為子類重寫run()實現
            new Thread() {
                public void run() {
                    System.out.println("匿名內部類建立執行緒方式1...");
                };
            }.start();



            //方式2:實現Runnable,Runnable作為匿名內部類
            new Thread(new Runnable() {
                public void run() {
                    System.out.println("匿名內部類建立執行緒方式2...");
                }
            } ).start();
        }



}

 

 

5.Lambda表示式建立

package com.mikechen.java.multithread;



/**
* 多執行緒建立:lambda表示式
*
* @author mikechen
*/
public class MyThreadLambda {
    public static void main(String[] args) {
        //匿名內部類建立多執行緒
        new Thread(){
            @Override
            public void run() {
                System.out.println(Thread.currentThread().getName()+"mikchen的網際網路架構建立新執行緒1");
            }
        }.start();




        //使用Lambda表示式,實現多執行緒
        new Thread(()->{
            System.out.println(Thread.currentThread().getName()+"mikchen的網際網路架構建立新執行緒2");
        }).start();



        //優化Lambda
        new Thread(()-> System.out.println(Thread.currentThread().getName()+"mikchen的網際網路架構建立新執行緒3")).start();



    }
}
 

 

執行緒的同步

執行緒的同步是為了防止多個執行緒存取一個資料物件時,對資料造成的破壞,執行緒的同步是保證多執行緒安全存取競爭資源的一種手段。

1.普通同步方法

鎖是當前範例物件 ,進入同步程式碼前要獲得當前範例的鎖。

/**
* 用在普通方法
*/
private synchronized void synchronizedMethod() {
System.out.println("--synchronizedMethod start--");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("--synchronizedMethod end--");
}

 

2.靜態同步方法

鎖是當前類的class物件 ,進入同步程式碼前要獲得當前類物件的鎖。

/**
* 用在靜態方法
*/
private synchronized static void synchronizedStaticMethod() {
System.out.println("synchronizedStaticMethod start");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("synchronizedStaticMethod end");
}

 

3.同步方法塊

鎖是括號裡面的物件,對給定物件加鎖,進入同步程式碼庫前要獲得給定物件的鎖。

/**
* 用在類
*/
private void synchronizedClass() {
synchronized (SynchronizedTest.class) {
System.out.println("synchronizedClass start");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("synchronizedClass end");
}
}

 

4.synchronized底層實現

synchronized的底層實現是完全依賴JVM虛擬機器器的,所以談synchronized的底層實現,就不得不談資料在JVM記憶體的儲存:Java物件頭,以及Monitor物件監視器。

1.Java物件頭

在JVM虛擬機器器中,物件在記憶體中的儲存佈局,可以分為三個區域:

  • 物件頭(Header)
  • 範例資料(Instance Data)
  • 對齊填充(Padding)

Java物件頭主要包括兩部分資料:

1)型別指標(Klass Pointer)

是物件指向它的類後設資料的指標,虛擬機器器通過這個指標來確定這個物件是哪個類的範例;

2)標記欄位(Mark Word)

用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒 ID、偏向時間戳等等,它是實現輕量級鎖和偏向鎖的關鍵.

所以,很明顯synchronized使用的鎖物件是儲存在Java物件頭裡的標記欄位裡。

2.Monitor

monitor描述為物件監視器,可以類比為一個特殊的房間,這個房間中有一些被保護的資料,monitor保證每次只能有一個執行緒能進入這個房間進行存取被保護的資料,進入房間即為持有monitor,退出房間即為釋放monitor。

下圖是synchronized同步程式碼塊反編譯後的截圖,可以很清楚的看見monitor的呼叫。

使用syncrhoized加鎖的同步程式碼塊在位元組碼引擎中執行時,主要就是通過鎖物件的monitor的取用(monitorenter)與釋放(monitorexit)來實現的。

 

多執行緒引入問題

多執行緒的優點很明顯,但是多執行緒的缺點也同樣明顯,執行緒的使用(濫用)會給系統帶來上下文切換的額外負擔,並且執行緒間的共用變數可能造成死鎖的出現。

1.執行緒安全問題

1)原子性

並行程式設計中很多的操作都不是原子操作,比如:

i++;   // 操作2
i = j; // 操作3
i = i + 1; // 操作4

在單執行緒環境中這3個操作都不會出現問題,但是在多執行緒環境中,如果不通過加鎖操作,往往很可能會出現意料之外的值。

在java中可以通過synchronized或者ReentrantLock來保證原子性。

 

2)可見性

可見性:指當多個執行緒存取同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即得到這個修改的值。

如上圖所示,每個執行緒都有自己的工作記憶體,工作記憶體和主記憶體間要通過store和load進行互動。

為了解決多執行緒的可見性問題,java提供了volatile關鍵字,當一個共用變數被volatile修飾時,他會保證修改的值會立即更新到主記憶體,當有其他執行緒需要讀取時,他會去主記憶體中讀取新值,而普通共用變數不能保證其可見性,因為變數被修改後刷回到主記憶體的時間是不確定的。

2.執行緒死鎖

執行緒死鎖是指由於兩個或者多個執行緒互相持有對方所需要的資源,導致這些執行緒處於等待狀態,無法前往執行。

當執行緒互相持有對方所需要的資源時,會互相等待對方釋放資源,如果執行緒都不主動釋放所佔有的資源,將產生死鎖,如圖所示:

舉一個例子:

public void add(int m) {
    synchronized(lockA) { // 獲得lockA的鎖
        this.value += m;
        synchronized(lockB) { // 獲得lockB的鎖
            this.another += m;
        } // 釋放lockB的鎖
    } // 釋放lockA的鎖
}

public void dec(int m) {
    synchronized(lockB) { // 獲得lockB的鎖
        this.another -= m;
        synchronized(lockA) { // 獲得lockA的鎖
            this.value -= m;
        } // 釋放lockA的鎖
    } // 釋放lockB的鎖
}

 

兩個執行緒各自持有不同的鎖,然後各自試圖獲取對方手裡的鎖,造成了雙方無限等待下去,這就是死鎖。

3.上下文切換

多執行緒並行一定會快嗎?其實不一定,因為多執行緒有執行緒建立和執行緒上下文切換的開銷。


CPU是很寶貴的資源,速度也非常快,為了保證均衡,通常會給不同的執行緒分配時間片,當CPU從一個執行緒切換到另外一個執行緒的時候,CPU需要儲存當前執行緒的本地資料,程式指標等狀態,並載入下一個要執行的執行緒的本地資料,程式指標等,這個切換稱之為上下文切換。

一般減少上下文切換的方法有:無鎖並行程式設計CAS演演算法,使用協程等方式。

多執行緒用好了可以成倍的增加效率,用不好可能比單執行緒還慢。

以上

作者簡介

陳睿|mikechen,10年+大廠架構經驗,《BAT架構技術500期》系列文章作者,分享十餘年BAT架構經驗以及面試心得!

閱讀mikechen的網際網路架構更多技術文章合集

Java並行|JVM|MySQL|Spring|Redis|分散式|高並行|架構師