線程、併發、內存模型

1.爲何會用到併發java

充分利用多核CPU的計算能力程序員

方便進行業務拆分,提高應用性能面試

面對複雜業務模型,並行程序會比串行程序更適應業務需求,而併發編程更能吻合這種業務拆分算法

 

2.併發編程缺點數據庫

頻繁上下文切換編程

時間片是CPU分配給各個線程的時間,由於時間很是短,因此CPU不斷經過切換線程,讓咱們以爲多個線程是同時執行的,時間片通常是幾十毫秒。而每次切換時,須要保存當前的狀態起來,以便可以進行恢復先前狀態,而這個切換時很是損耗性能,過於頻繁反而沒法發揮出多線程編程的優點。一般減小上下文切換能夠採用無鎖併發編程,CAS算法,使用最少的線程和使用協程。
  • 無鎖併發編程:能夠參照concurrentHashMap鎖分段的思想,不一樣的線程處理不一樣段的數據,這樣在多線程競爭的條件下,能夠減小上下文切換的時間。數組

  • CAS算法,利用Atomic下使用CAS算法來更新數據,使用了樂觀鎖,能夠有效的減小一部分沒必要要的鎖競爭帶來的上下文切換緩存

  • 使用最少線程:避免建立不須要的線程,好比任務不多,可是建立了不少的線程,這樣會形成大量的線程都處於等待狀態安全

  • 協程:在單線程裏實現多任務的調度,並在單線程裏維持多個任務間的切換性能優化

線程安全
多線程編程中最難以把握的就是臨界區線程安全問題,稍微不注意就會出現死鎖的狀況,一旦產生死鎖就會形成系統功能不可用。

那麼,一般能夠用以下方式避免死鎖的狀況:

  1. 避免一個線程同時得到多個鎖;
  2. 避免一個線程在鎖內部佔有多個資源,儘可能保證每一個鎖只佔用一個資源;
  3. 嘗試使用定時鎖,使用lock.tryLock(timeOut),當超時等待時當前線程不會阻塞;
  4. 對於數據庫鎖,加鎖和解鎖必須在一個數據庫鏈接裏,不然會出現解鎖失敗的狀況。

 

3.須要瞭解的概念

3.1 同步與異步

同步和異步一般用來形容一次方法調用。同步方法調用一開始,調用者必須等待被調用的方法結束後,調用者後面的代碼才能執行。而異步調用,指的是,調用者不用管被調用方法是否完成,都會繼續執行後面的代碼,當被調用的方法完成後會通知調用者。

3.2 併發與並行

併發和並行是十分容易混淆的概念。併發指的是多個任務交替進行,而並行則是指真正意義上的「同時進行」。實際上,若是系統內只有一個CPU,而使用多線程時,那麼真實系統環境下不能並行,只能經過切換時間片的方式交替進行,而成爲併發執行任務。真正的並行也只能出如今擁有多個CPU的系統中。

3.3 阻塞和非阻塞

阻塞和非阻塞一般用來形容多線程間的相互影響,好比一個線程佔有了臨界區資源,那麼其餘線程須要這個資源就必須進行等待該資源的釋放,會致使等待的線程掛起,這種狀況就是阻塞,而非阻塞就剛好相反,它強調沒有一個線程能夠阻塞其餘線程,全部的線程都會嘗試地往前運行。

3.4 臨界區

臨界區用來表示一種公共資源或者說是共享數據,能夠被多個線程使用。可是每一個線程使用時,一旦臨界區資源被一個線程佔有,那麼其餘線程必須等待。

 

4.新建線程

一個java程序從main()方法開始執行,而後按照既定的代碼邏輯執行,看似沒有其餘線程參與,但實際上java程序天生就是一個多線程程序,包含了:(1)分發處理髮送給給JVM信號的線程;(2)調用對象的finalize方法的線程;(3)清除Reference的線程;(4)main線程,用戶程序的入口。那麼,如何在用戶程序中新建一個線程了,只要有三種方式:

  1. 經過繼承Thread類,重寫run方法;

  2. 經過實現runable接口;

  3. 經過實現callable接口這三種方式,下面看具體demo。

public class CreateThread {
    public static void main(String[] args) {
        //繼承Thread
        Thread thread = new Thread(){
            @Override
            public void run() {
                System.out.println("繼承Thread");
                super.run();
            }
        };
        thread.start();
        //實現Runnable接口
        Thread thread1 =  new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("實現Runnable接口");
            }
        });
        thread1.start();
        //經過callable接口實現
        ExecutorService  service = Executors.newSingleThreadExecutor();
        Future<String> future = service.submit(new Callable<String>() {
            @Override
            public String call() throws Exception {
                return "經過callable接口實現";
            }
        });
        try {
            String result = future.get();
            System.out.println(result);
        }catch (InterruptedException e){
            e.printStackTrace();
        }catch (ExecutionException e){
            e.printStackTrace();
        }
    }
}

5. 線程的狀態

 

 

 

此圖來源於《JAVA併發編程的藝術》一書中,線程是會在不一樣的狀態間進行轉換的,java線程線程轉換圖如上圖所示。線程建立以後調用start()方法開始運行,當調用wait(),join(),LockSupport.lock()方法線程會進入到WAITING狀態,而一樣的wait(long timeout),sleep(long),join(long),LockSupport.parkNanos(),LockSupport.parkUtil()增長了超時等待的功能,也就是調用這些方法後線程會進入TIMED_WAITING狀態,當超時等待時間到達後,線程會切換到Runable的狀態,另外當WAITING和TIMED _WAITING狀態時能夠經過Object.notify(),Object.notifyAll()方法使線程轉換到Runable狀態。當線程出現資源競爭時,即等待獲取鎖的時候,線程會進入到BLOCKED阻塞狀態,當線程獲取鎖時,線程進入到Runable狀態。線程運行結束後,線程進入到TERMINATED狀態,狀態轉換能夠說是線程的生命週期。另外須要注意的是:

  • 當線程進入到synchronized方法或者synchronized代碼塊時,線程切換到的是BLOCKED狀態,而使用java.util.concurrent.locks下lock進行加鎖的時候線程切換的是WAITING或者TIMED_WAITING狀態,由於lock會調用LockSupport的方法。

用一個表格將上面六種狀態進行一個總結概括。

6.線程狀態的基本操做

6.1 interrupted

中斷能夠理解爲線程的一個標誌位,它表示了一個運行中的線程是否被其餘線程進行了中斷操做。中斷比如其餘線程對該線程打了一個招呼。其餘線程能夠調用該線程的interrupt()方法對其進行中斷操做,同時該線程能夠調用
isInterrupted()來感知其餘線程對其自身的中斷操做,從而作出響應。另外,一樣能夠調用Thread的靜態方法
interrupted()對當前線程進行中斷操做,該方法會清除中斷標誌位。 須要注意的是,當拋出InterruptedException時候,會清除中斷標誌位,也就是說在調用isInterrupted會返回false。

6.2 join

join方法能夠看作是線程間協做的一種方式,不少時候,一個線程的輸入可能很是依賴於另外一個線程的輸出.若是一個線程實例A執行了threadB.join(),其含義是:當前線程A會等待threadB線程終止後threadA纔會繼續執行。關於join方法一共提供以下這些方法:

public final synchronized void join(long millis)
public final synchronized void join(long millis, int nanos)
public final void join() throws InterruptedException
public class TestJoin {
    public static void main(String[] args) {
        Thread previousThread = Thread.currentThread();
        for (int i = 0; i <= 10; i++) {
            Thread curThread = new JoinThread(previousThread, i);
            curThread.start();
            previousThread = curThread;
        }
    }
    static class JoinThread extends Thread {

        private Thread thread;
        private int i;

        public JoinThread(Thread thread, int i) {
            this.thread = thread;
            this.i = i;
        }
        @Override
        public void run() {
            try {
                thread.join();
                System.out.println(thread.getName() + " terminated" + ">>>" + "i=" + i);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}


輸出結果:
main terminated>>>i=0
Thread-0 terminated>>>i=1
Thread-1 terminated>>>i=2
Thread-2 terminated>>>i=3
Thread-3 terminated>>>i=4
Thread-4 terminated>>>i=5
Thread-5 terminated>>>i=6
Thread-6 terminated>>>i=7
Thread-7 terminated>>>i=8
Thread-8 terminated>>>i=9
Thread-9 terminated>>>i=10

在上面的例子中一個建立了10個線程,每一個線程都會等待前一個線程結束纔會繼續運行。能夠通俗的理解成接力,前一個線程將接力棒傳給下一個線程,而後又傳給下一個線程......

6.3 sleep

public static native void sleep(long millis)方法顯然是Thread的靜態方法,很顯然它是讓當前線程按照指定的時間休眠,其休眠時間的精度取決於處理器的計時器和調度器。須要注意的是若是當前線程得到了鎖,sleep方法並不會失去鎖。sleep方法常常拿來與Object.wait()方法進行比價,這也是面試常常被問的地方。

二者主要的區別:

  1. sleep()方法是Thread的靜態方法,而wait是Object實例方法
  2. wait()方法必需要在同步方法或者同步塊中調用,也就是必須已經得到對象鎖。而sleep()方法沒有這個限制能夠在任何地方種使用。另外,wait()方法會釋放佔有的對象鎖,使得該線程進入等待池中,等待下一次獲取資源。而sleep()方法只是會讓出CPU並不會釋放掉對象鎖;
  3. sleep()方法在休眠時間達到後若是再次得到CPU時間片就會繼續執行,而wait()方法必須等待Object.notift/Object.notifyAll通知後,纔會離開等待池,而且再次得到CPU時間片纔會繼續執行。
6.4 yeild
public static native void yield();這是一個靜態方法,一旦執行,它會是當前線程讓出CPU,可是,須要注意的是,讓出的CPU並非表明當前線程再也不運行了,若是在下一次競爭中,又得到了CPU時間片當前線程依然會繼續運行。另外,讓出的時間片只會分配 給當前線程相同優先級的線程。

6.5 守護線程Daemon

守護線程是一種特殊的線程,就和它的名字同樣,它是系統的守護者,在後臺默默地守護一些系統服務,好比垃圾回收線程,JIT線程就能夠理解守護線程。與之對應的就是用戶線程,用戶線程就能夠認爲是系統的工做線程,它會完成整個系統的業務操做。用戶線程徹底結束後就意味着整個系統的業務任務所有結束了,所以系統就沒有對象須要守護的了,守護線程天然而然就會退。當一個Java應用,只有守護線程的時候,虛擬機就會天然退出。
須要注意的是 守護線程在退出的時候並不會執行finnaly塊中的代碼,因此將釋放資源等操做不要放在finnaly塊中執行,這種操做是不安全的.
線程能夠經過setDaemon(true)的方法將線程設置爲守護線程。而且須要注意的是設置守護線程要先於start()方法,不然會報
Exception in thread "main" java.lang.IllegalThreadStateException
at java.lang.Thread.setDaemon(Thread.java:1365)
at learn.DaemonDemo.main(DaemonDemo.java:19)
可是該線程仍是會執行,只不過會當作正常的用戶線程執行。
 
7.JMM介紹
當多個線程訪問同一個對象時,若是不用考慮這些線程在運行時環境下的調度和交替運行,也不須要進行額外的同步,或者在調用方進行任何其餘的協調操做,調用這個對象的行爲均可以獲取正確的結果,那這個對象是線程安全的。關於定義的理解這是一個仁者見仁智者見智的事情。出現線程安全的問題通常是由於 主內存和工做內存數據不一致性重排序致使的,而解決線程安全的問題最重要的就是理解這兩種問題是怎麼來的,那麼,理解它們的核心在於理解java內存模型(JMM)。
 
在多線程條件下,多個線程確定會相互協做完成一件事情,通常來講就會涉及到 多個線程間相互通訊告知彼此的狀態以及當前的執行結果等,另外,爲了性能優化,還會 涉及到編譯器指令重排序和處理器指令重排序
 
8.內存模型抽象結構
在併發編程中主要須要解決兩個問題: 1. 線程之間如何通訊;2.線程之間如何完成同步(這裏的線程指的是併發執行的活動實體)。通訊是指線程之間以何種機制來交換信息,主要有兩種:共享內存和消息傳遞。java內存模型是 共享內存的併發模型,線程之間主要經過讀-寫共享變量來完成隱式通訊。若是程序員不能理解Java的共享內存模型在編寫併發程序時必定會遇到各類各樣關於內存可見性的問題。
 
8.1哪些是共享變量
在java程序中全部 實例域,靜態域和數組元素都是放在堆內存中(全部線程都可訪問到,是能夠共享的),而局部變量,方法定義參數和異常處理器參數不會在線程間共享。共享數據會出現線程安全的問題,而非共享數據不會出現線程安全的問題。
 
8.2 JMM抽象結構模型
咱們知道CPU的處理速度和主存的讀寫速度不是一個量級的,爲了平衡這種巨大的差距,每一個CPU都會有緩存。所以,共享變量會先放在主存中,每一個線程都有屬於本身的工做內存,而且會把位於主存中的共享變量拷貝到本身的工做內存,以後的讀寫操做均使用位於工做內存的變量副本,並在某個時刻將工做內存的變量副本寫回到主存中去。JMM就從抽象層次定義了這種方式,而且JMM決定了一個線程對共享變量的寫入什麼時候對其餘線程是可見的。

如圖爲JMM抽象示意圖,線程A和線程B之間要完成通訊的話,要經歷以下兩步:

  1. 線程A從主內存中將共享變量讀入線程A的工做內存後並進行操做,以後將數據從新寫回到主內存中;
  2. 線程B從主存中讀取最新的共享變量

從橫向去看看,線程A和線程B就好像經過共享變量在進行隱式通訊。這其中有頗有意思的問題,若是線程A更新後數據並無及時寫回到主存,而此時線程B讀到的是過時的數據,這就出現了「髒讀」現象。能夠經過同步機制(控制不一樣線程間操做發生的相對順序)來解決或者經過volatile關鍵字使得每次volatile變量都可以強制刷新到主存,從而對每一個線程都是可見的。

9. 重排序
一個好的內存模型實際上會放鬆對處理器和編譯器規則的束縛,也就是說軟件技術和硬件技術都爲同一個目標而進行奮鬥:在不改變程序執行結果的前提下,儘量提升並行度。JMM對底層儘可能減小約束,使其可以發揮自身優點。所以,在執行程序時, 爲了提升性能,編譯器和處理器經常會對指令進行重排序。通常重排序能夠分爲以下三種:
  • 編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,能夠從新安排語句的執行順序;
  • 指令級並行的重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。若是不存在數據依賴性,處理器能夠改變語句對應機器指令的執行順序;
  • 內存系統的重排序。因爲處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操做看上去多是在亂序執行的。
10.happens-before規則
上面的內容講述了重排序原則,一會是編譯器重排序一會是處理器重排序,若是讓程序員再去了解這些底層的實現以及具體規則,那麼程序員的負擔就過重了,嚴重影響了併發編程的效率。所以,JMM爲程序員在上層提供了六條規則,這樣咱們就能夠根據規則去推論跨線程的內存可見性問題,而不用再去理解底層重排序的規則。下面以兩個方面來講。

 10.1 .happens-before定義

happens-before的概念最初由Leslie Lamport在其一篇影響深遠的論文(《Time,Clocks and the Ordering of Events in a Distributed System》)中提出,有興趣的能夠google一下。JSR-133使用happens-before的概念來指定兩個操做之間的執行順序。因爲這兩個操做能夠在一個線程以內,也能夠是在不一樣線程之間。所以,JMM能夠經過happens-before關係向程序員提供跨線程的內存可見性保證(若是A線程的寫操做a與B線程的讀操做b之間存在happens-before關係,儘管a操做和b操做在不一樣的線程中執行,但JMM向程序員保證a操做將對b操做可見)。具體的定義爲:

1)若是一個操做happens-before另外一個操做,那麼第一個操做的執行結果將對第二個操做可見,並且第一個操做的執行順序排在第二個操做以前。

2)兩個操做之間存在happens-before關係,並不意味着Java平臺的具體實現必需要按照happens-before關係指定的順序來執行。若是重排序以後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序並不非法(也就是說,JMM容許這種重排序)。

上面的1)是JMM對程序員的承諾。從程序員的角度來講,能夠這樣理解happens-before關係:若是A happens-before B,那麼Java內存模型將向程序員保證——A操做的結果將對B可見,且A的執行順序排在B以前。注意,這只是Java內存模型向程序員作出的保證!

上面的2)是JMM對編譯器和處理器重排序的約束原則。正如前面所言,JMM實際上是在遵循一個基本原則:只要不改變程序的執行結果(指的是單線程程序和正確同步的多線程程序),編譯器和處理器怎麼優化都行。JMM這麼作的緣由是:程序員對於這兩個操做是否真的被重排序並不關心,程序員關心的是程序執行時的語義不能被改變(即執行結果不能被改變)。所以,happens-before關係本質上和as-if-serial語義是一回事。as-if-serial語義的意思是:無論怎麼重排序(編譯器和處理器爲了提供並行度),(單線程)程序的執行結果不能被改變。編譯器,runtime和處理器都必須遵照as-if-serial語義。as-if-serial語義把單線程程序保護了起來,遵照as-if-serial語義的編譯器,runtime和處理器共同爲編寫單線程程序的程序員建立了一個幻覺:單線程程序是按程序的順序來執行的。

as-if-serial VS happens-before

  • as-if-serial語義保證單線程內程序的執行結果不被改變,happens-before關係保證正確同步的多線程程序的執行結果不被改變。
  • as-if-serial語義給編寫單線程程序的程序員創造了一個幻境:單線程程序是按程序的順序來執行的。happens-before關係給編寫正確同步的多線程程序的程序員創造了一個幻境:正確同步的多線程程序是按happens-before指定的順序來執行的。
  • as-if-serial語義和happens-before這麼作的目的,都是爲了在不改變程序執行結果的前提下,儘量地提升程序執行的並行度。

10.2 具體規則

具體的一共有六項規則:

  1. 程序順序規則:一個線程中的每一個操做,happens-before於該線程中的任意後續操做。
  2. 監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
  3. volatile變量規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
  4. 傳遞性:若是A happens-before B,且B happens-before C,那麼A happens-before C。
  5. start()規則:若是線程A執行操做ThreadB.start()(啓動線程B),那麼A線程的ThreadB.start()操做happens-before於線程B中的任意操做。
  6. join()規則:若是線程A執行操做ThreadB.join()併成功返回,那麼線程B中的任意操做happens-before於線程A從ThreadB.join()操做成功返回。
  7. 程序中斷規則:對線程interrupted()方法的調用先行於被中斷線程的代碼檢測到中斷時間的發生。
  8. 對象finalize規則:一個對象的初始化完成(構造函數執行結束)先行於發生它的finalize()方法的開始。

11. 總結

上面已經聊了關於JMM的兩個方面:1. JMM的抽象結構(主內存和線程工做內存);2. 重排序以及happens-before規則。接下來,咱們來作一個總結。從兩個方面進行考慮。1. 若是讓咱們設計JMM應該從哪些方面考慮,也就是說JMM承擔哪些功能;2. happens-before與JMM的關係;3. 因爲JMM,多線程狀況下可能會出現哪些問題?
11.1 JMM的設計

JMM是語言級的內存模型,在個人理解中JMM處於中間層,包含了兩個方面:(1)內存模型;(2)重排序以及happens-before規則。同時,爲了禁止特定類型的重排序會對編譯器和處理器指令序列加以控制。而上層會有基於JMM的關鍵字和J.U.C包下的一些具體類用來方便程序員可以迅速高效率的進行併發編程。站在JMM設計者的角度,在設計JMM時須要考慮兩個關鍵因素:

  1. 程序員對內存模型的使用
    程序員但願內存模型易於理解、易於編程。程序員但願基於一個強內存模型來編寫代碼。
  2. 編譯器和處理器對內存模型的實現
    編譯器和處理器但願內存模型對它們的束縛越少越好,這樣它們就能夠作儘量多的優化來提升性能。編譯器和處理器但願實現一個弱內存模型。

另外還要一個特別有意思的事情就是關於重排序問題,更簡單的說,重排序能夠分爲兩類:

  1. 會改變程序執行結果的重排序。
  2. 不會改變程序執行結果的重排序。

JMM對這兩種不一樣性質的重排序,採起了不一樣的策略,以下。

  1. 對於會改變程序執行結果的重排序,JMM要求編譯器和處理器必須禁止這種重排序。
  2. 對於不會改變程序執行結果的重排序,JMM對編譯器和處理器不作要求(JMM容許這種
    重排序)

JMM的設計圖爲:

 

從圖能夠看出:

  1. JMM向程序員提供的happens-before規則能知足程序員的需求。JMM的happens-before規則不但簡單易懂,並且也向程序員提供了足夠強的內存可見性保證(有些內存可見性保證其實並不必定真實存在,好比上面的A happens-before B)。
  2. JMM對編譯器和處理器的束縛已經儘量少。從上面的分析能夠看出,JMM實際上是在遵循一個基本原則:只要不改變程序的執行結果(指的是單線程程序和正確同步的多線程程序),編譯器和處理器怎麼優化都行。例如,若是編譯器通過細緻的分析後,認定一個鎖只會被單個線程訪問,那麼這個鎖能夠被消除。再如,若是編譯器通過細緻的分析後,認定一個volatile變量只會被單個線程訪問,那麼編譯器能夠把這個volatile變量看成一個普通變量來對待。這些優化既不會改變程序的執行結果,又能提升程序的執行效率。
11.2 happends-before與JMM的關係

 

一個happens-before規則對應於一個或多個編譯器和處理器重排序規則。對於Java程序員來講,happens-before規則簡單易懂,它避免Java程序員爲了理解JMM提供的內存可見性保證而去學習複雜的重排序規則以及這些規則的具體實現方法

從上面內存抽象結構來講,可能出在數據「髒讀」的現象,這就是 數據可見性的問題,另外,重排序在多線程中不注意的話也容易存在一些問題,好比一個很經典的問題就是DCL(雙重檢驗鎖),這就是須要 禁止重排序,另外,在多線程下原子操做例如i++不加以注意的也容易出現線程安全的問題。但總的來講,在多線程開發時須要從 原子性,有序性,可見性三個方面進行考慮。J.U.C包下的併發工具類和併發容器也是須要花時間去掌握的
 

 原文地址

https://www.jianshu.com/p/959cf355b574https://www.jianshu.com/p/f65ea68a4a7fhttps://www.jianshu.com/p/d52fea0d6ba5

相關文章
相關標籤/搜索