天天看點

高性能程式設計-02 多線程基礎一、線程的狀态二、線程的中止三、CPU緩存和記憶體屏障四、線程通信五、線程封閉六、小結

目錄

一、線程的狀态

二、線程的中止

三、CPU緩存和記憶體屏障

多級緩存:

緩存同步協定:

運作時指令重排:

記憶體屏障:

四、線程通信

五、線程封閉

六、小結

       多線程是處理複雜問題的基本手段,合理運用,能夠顯著提升解決問題的性能,提高使用者體驗。是以,多線程的正确使用是java程式員的基本功,今天主要講解多線程相關的基礎知識,為後期工作、學習做準備。

一、線程的狀态

       在java.lang.Thread.State中定義了線程的6中狀态,分别是:New、Runnable、Blocked、Waiting、TimedWaiting和Terminated。

       New:線程剛建立出來,尚未啟動時的狀态;

       Runnable:可運作狀态,執行start方法後的狀态(跟CPU時間片沒有關系);

       Blocked:阻塞狀态,如等待鎖資源;

       Waiting:等待狀态,等待其他線程通知,如執行了wait()/Thread.join()/LockSupport.park();

       TimedWaiting:定時等待,有逾時時間的等待其他線程通知狀态,如Thread.sleep()、Object.wait()、Thread.join()、LockSupport.parkNanos()、LockSupport.parkUntil();

       Terminated:終止狀态;

       各狀态之間的轉化關系如下圖:

高性能程式設計-02 多線程基礎一、線程的狀态二、線程的中止三、CPU緩存和記憶體屏障四、線程通信五、線程封閉六、小結

圖2-1 線程狀态

二、線程的中止

       開車有刹車,潑出去的水也有想收回來的時候,那麼一個正在執行的線程可不可以中止呢?java為我們提供了哪些線程中止的方式?答案是肯定的。常用的線程中止方法有以下幾種:

       1、Stop:中止線程,并且清除監控器鎖的資訊,但是可能導緻線程安全問題(如:同步代碼塊執行到一半的時候,線程被中止了,會破壞該同步代碼塊的原子性、一緻性),不建議使用。

       2、Destroy:JDK未實作該方法。

       3、interrupt:如果目标線程在調用Object class的wait()/wait(long)/wait(long,int)方法、join()/join(long,int)/join(long,int)方法、sleep(long,int)方法時被阻塞,那麼interrupt會生效,該線程的中斷狀态将被清除,并且抛出InterruptedException異常,由開發人員通過捕獲異常進而決定是繼續執行完還是復原,避免同步代碼塊的原子性被破壞。如果目标線程是被IO或者NIO的Channel鎖阻塞,則IO操作會被中斷或者傳回特殊異常值。達到終止線程的目的。如果以上條件都不滿足,則會設定此線程的中斷狀态。推薦使用!

       4、标志位:通過判斷共享flag标志變量的值,控制線程是否繼續執行。推薦使用!

三、CPU緩存和記憶體屏障

       在我們組裝電腦的時候,記憶體大小是一個重要的性能參數。因為CPU的運作速度極快,如果資料直接從硬碟讀取,那CPU再強大也是英雄無用武之地,高射炮打蚊子,大材小用。那你以為有了記憶體做緩存就可以了嗎?不是的,CPU還是嫌記憶體慢!于是各廠商就在CPU内部又增加了緩存結構,而且還不隻是一個,而是增加了三級緩存,如下圖:

高性能程式設計-02 多線程基礎一、線程的狀态二、線程的中止三、CPU緩存和記憶體屏障四、線程通信五、線程封閉六、小結

圖2-2 多級緩存

多級緩存:

       主要分三級緩存,從内到外分别是L1、L2、L3。

       L1 Cache(一級緩存):是CPU第一層高速緩存,分為資料緩存和指令緩存,一般大小為32-4096KB;

       L2 Cache(二級緩存):由于L1級高速緩存容量的限制,為了再次提高CPU的運算速度,在CPU外部放置一高速存儲器,即二級緩存;

       L3 Cache(三級緩存):可以進一步降低記憶體延遲,同時提升大資料量計算時處理器的性能。具有較大L3緩存的處理器可以提供更有效的檔案系統緩存行為及較短消息和處理器隊列長度,一般多核共享一個L3緩存。

       CPU緩存現在都是内置的。

       CPU在讀取資料時,先在L1中尋找,再從L2尋找,再從L3尋找,然後是記憶體,最後是外部存儲器。

       同一資料可能同時儲存在主記憶體、各個CPU核心的各級緩存中,就像是你們家的大門鑰匙有好幾份,各個家庭成員人手一份,那你怎麼知道現在誰在家?在家做了什麼?或者說你把鎖換了後,怎麼通知其他人?這就存在一個緩存之間同步的問題。

緩存同步協定:

       多CPU讀取同樣的資料進行緩存,進行不同運算之後,最終寫入主記憶體以哪個CPU為準?為了保證多核CPU高速緩存回寫資料一緻性,CPU廠商提出并實作了MESI協定,保證了緩存的一緻性。MESI協定,它規定每條緩存有個狀态位,定義了四個狀态:

       Modified(修改态):此cache行已被修改(髒行),内容不同于主記憶體,為此cache專有;

       Exclusive(專有态):此cache行内容同于主記憶體,但不出現于其他cache中;

       Shared(共享态):此cache行内容同于主記憶體,但也出現于其他cache中;

       Invalid(無效态):此cache行内容無效(空行)。

       多處理器時,單個CPU對緩存中資料進行了改動,需要通知給其他CPU,也就是說,CPU要控制自己的讀寫操作,還要監聽其他CPU發出的通知,進而保證最終一緻。

運作時指令重排:

       為了保證資料的一緻性、完整性,存儲資源一般都支援“一寫多讀”,即在同一時刻對同一資源的通路隻允許一個程式做寫操作或者多個線程做讀操作。這就存在一個問題,如果所有程式都按順序執行,那麼将存在很多阻塞等待時間,CPU沒有充分的利用起來。

       比如你要做兩件事情,一個是去銀行存錢,一個是吃早餐,如果必須先取錢再吃早餐,那你可能需要在銀行排很長的隊,但是如果你發現銀行現在很多人在排隊,你是不是可以先吃了早餐再說,即可以将沒有依賴影響的其他事情先做了,稱為指令重排。同樣的,當CPU寫緩存時發現緩存區塊正被其他CPU占用,為了提高CPU處理性能,可能将後面的讀緩存指令優先執行,隻要保證最終結果是一緻的就可以,即指令重排遵守as-if-serial語義,不管怎麼重排序,程式執行(單線程)的結果不能被改變。編譯器和處理器不會對存在資料依賴關系的操作做重排序。

       指令重排對于單線程程式來說是優化,但是對于多線程程式來說就不一定了,也有可能是災難,可能導緻程式執行的亂序問題。那該如何解決?

記憶體屏障:

       解決了上述問題,處理器提供了兩個記憶體屏障指令(Memory Barrier)。

       寫記憶體屏障(Store Memory Barrier):在指令後插入Store Barrier,能讓寫入緩存中的最新資料立即寫入主記憶體,讓其他線程可見。強制寫入主記憶體,這種顯示調用,CPU就不會因為性能考慮而去對指令重排序。

       讀記憶體屏障(Load Memory Barrier):在指令前插入Load Barrier,可以讓高速緩存中的資料失效,強制重新從主記憶體加載資料。

       類似java中的volatile關鍵字,後期會有詳細講解。

四、線程通信

       多人團隊協作開發的時候,成員之間會有一個溝通的問題,互相之間要了解工作進度及遇到的問題,成員溝通效率會影響項目的開發進度及品質。同樣的,當多個線程同時工作時,也存線上程通信、協調的問題。多線程之間通信方式有多種,檔案共享、網絡共享、共享變量及JDK提供的線程協調API等等。這裡主要講解JDK提供的線程協調API。

       suspend/resume(不推薦使用,已棄):調用suspend挂起目标線程,通過resume可以恢複線程的執行。棄用的原因主要是容易寫出線程挂起後無法喚醒的程式,比如:1、在同步代碼塊中調用suspend方法挂起之後,由于不會釋放鎖資源,如果喚醒代碼塊需要擷取同一鎖資源,則會出現死鎖的現象;2、如果先調用resume,後調用suspend,也會出現線程無法正常喚醒的情況。

       wait/notify/notifyAll(推薦):這些方法隻能由同一對象鎖的持有者線程調用,也就是說必須寫在同步代碼塊中,否則會抛出IllegalMonitorStateException異常。

       wait方法導緻目前線程等待,加入該對象鎖的等待集合中,并且放棄目前持有的對象鎖。

       notify/notifyAll方法喚醒一個或所有正在等待這個對象鎖的線程。

       對象鎖.wait();會釋放鎖資源,同步代碼塊不會死鎖。但是如果先調用notify,再調用wait,也會導緻wait的線程無法恢複,一直處于waiting狀态。

       park/unpark(推薦):線程通過工具類LockSupport調用part方法則等待“許可”(LockSupport.park();),通過工具類LockSupport調用unpark方法則為指定線程提供“許可(permit)”(LockSupport.unpark(threadObject);)。不要求park/unpark的調用順序。多次調用unpark之後,再調用park,程式會直接運作。多次調用unpark也不會累加許可,即多次unpark之後,隻對一個park有效,再次調用park依然會等待。

       在同步代碼塊中,park不會釋放鎖資源,是以在同步代碼塊中使用容易出現死鎖。

       編碼建議:判斷等待條件是否成立的代碼,應該在循環中檢查等待條件,原因是處于等待狀态的線程可能會收到錯誤警報或僞喚醒,如果不在循環中檢查等待條件,程式可能會在沒有滿足結束條件的情況下退出等待狀态。

五、線程封閉

       多線程之間需要共享部分資料,當然也會有自己獨有的資料,比如線程内的局部變量就是該線程獨有的,其他線程無法通路(棧封閉)。但是線上程内部,通過局部變量來儲存線程特有的資料有一定的局限性,比如多個方法之間互相調用時,如果想把資料傳過去就必須使用參數,參數清單過多會影響代碼的可讀性。那有沒有更優美的方式儲存線程特有的資料呢?答案是肯定的,那就是ThreadLocal變量(線程封閉)。

       ThreadLocal變量的使用分三步:1、定義公共變量;2、線程内設定資料;3、線程内擷取資料;

定義公共變量:

       public static ThreadLocal<String> value = new ThreadLocal<>();

線程内設定資料:

       value.set("這是目前線程設定的資料123");

線程内擷取資料:

       String v = value.get();

       目前線程隻能擷取到目前線程設定的資料,線程之間不會有影響。可以了解為ThreadLocal變量底層維護了一個Map,key是線程對象,value是線程設定的資料。舉個例子:我們去超市購物,會将自己的東西放到儲物櫃裡,這個儲物櫃就是你定義的公共的ThreadLocal變量,每個人會将自己的東西放到不同的格子内,取東西的時候也是從自己那個格子裡取。

六、小結

       這一節,我們學習了java中的線程狀态、線程中止、線程通信、線程封閉等内容,以及CPU緩存、指令重排、記憶體屏障等概念。這些都是基礎知識,是為後面的學習服務的。接下來我們學習線程池的應用及實作原理。